visync 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of visync might be problematic. Click here for more details.
- visync/__init__.py +3 -0
- visync/config.toml +259 -0
- visync/download.py +1505 -0
- visync/finder.py +699 -0
- visync/main.py +1172 -0
- visync/net.py +84 -0
- visync/output.py +126 -0
- visync/pm.py +133 -0
- visync/verify.py +703 -0
- visync-0.1.0.dist-info/METADATA +204 -0
- visync-0.1.0.dist-info/RECORD +14 -0
- visync-0.1.0.dist-info/WHEEL +4 -0
- visync-0.1.0.dist-info/entry_points.txt +2 -0
- visync-0.1.0.dist-info/licenses/LICENSE +21 -0
visync/net.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Network safety helpers shared by scraping, downloading, and verification."""
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import urllib.error
|
|
5
|
+
import urllib.request
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
_opener_installed = False
|
|
9
|
+
|
|
10
|
+
# Names that unambiguously denote the local machine. Anything outside this set
|
|
11
|
+
# must parse as an IP literal to earn the loopback exemption, which keeps
|
|
12
|
+
# ambiguous spellings such as "127.1", the decimal form "2130706433", or a
|
|
13
|
+
# lookalike host from slipping past as if they were loopback.
|
|
14
|
+
_LOOPBACK_NAMES = frozenset({"localhost"})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _is_loopback(host: str | None) -> bool:
|
|
18
|
+
"""True only for unambiguous loopback hosts.
|
|
19
|
+
|
|
20
|
+
Accepts the exact name "localhost", any "*.localhost" subdomain (RFC 6761
|
|
21
|
+
reserves the whole domain for loopback), and IPv4/IPv6 literals that
|
|
22
|
+
ipaddress classifies as loopback. Non-canonical and integer spellings of
|
|
23
|
+
127.0.0.1 are deliberately *not* treated as loopback: urllib would connect
|
|
24
|
+
to the local machine for them, so allowing them would hand out a cleartext
|
|
25
|
+
exemption for an address that merely looks local.
|
|
26
|
+
|
|
27
|
+
IPv4-mapped IPv6 (``::ffff:127.0.0.1``) is also excluded. It resolves to
|
|
28
|
+
loopback, but it is a distinct spelling that no legitimate local mirror
|
|
29
|
+
uses, so rejecting it costs nothing and removes an obfuscation avenue.
|
|
30
|
+
"""
|
|
31
|
+
if not host:
|
|
32
|
+
return False
|
|
33
|
+
host = host.strip().strip("[]").lower()
|
|
34
|
+
if not host or host.endswith("."):
|
|
35
|
+
# A trailing dot is the fully-qualified form of the same name, but only
|
|
36
|
+
# for names; it is not valid on an IP literal and is not worth a
|
|
37
|
+
# second code path here.
|
|
38
|
+
host = host.rstrip(".")
|
|
39
|
+
if not host:
|
|
40
|
+
return False
|
|
41
|
+
if host in _LOOPBACK_NAMES or host.endswith(".localhost"):
|
|
42
|
+
return True
|
|
43
|
+
try:
|
|
44
|
+
address = ipaddress.ip_address(host)
|
|
45
|
+
except ValueError:
|
|
46
|
+
return False
|
|
47
|
+
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped:
|
|
48
|
+
return False
|
|
49
|
+
return address.is_loopback
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SafeRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
53
|
+
"""HTTPRedirectHandler that refuses to follow https -> cleartext redirects."""
|
|
54
|
+
|
|
55
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
56
|
+
if (
|
|
57
|
+
urlparse(req.full_url).scheme == "https"
|
|
58
|
+
and urlparse(newurl).scheme != "https"
|
|
59
|
+
):
|
|
60
|
+
raise urllib.error.URLError(
|
|
61
|
+
f"blocked insecure redirect downgrade: {req.full_url} -> {newurl}"
|
|
62
|
+
)
|
|
63
|
+
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def install_safe_opener() -> None:
|
|
67
|
+
"""Install the downgrade-safe opener once for the whole process."""
|
|
68
|
+
global _opener_installed
|
|
69
|
+
if _opener_installed:
|
|
70
|
+
return
|
|
71
|
+
urllib.request.install_opener(urllib.request.build_opener(SafeRedirectHandler()))
|
|
72
|
+
_opener_installed = True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def require_https(url: str, what: str = "URL") -> None:
|
|
76
|
+
"""Reject non-HTTPS fetch targets (unambiguous loopback exempt for testing).
|
|
77
|
+
|
|
78
|
+
The exemption exists so a local mirror or test server can be used during
|
|
79
|
+
development. It is deliberately narrow: only "localhost", "*.localhost" and
|
|
80
|
+
canonical loopback IP literals qualify.
|
|
81
|
+
"""
|
|
82
|
+
parsed = urlparse(url)
|
|
83
|
+
if parsed.scheme != "https" and not _is_loopback(parsed.hostname):
|
|
84
|
+
raise ValueError(f"refusing {what} over non-HTTPS: {url}")
|
visync/output.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Rich-based output formatting for visync."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.markup import escape as _esc
|
|
7
|
+
from rich.progress import (
|
|
8
|
+
BarColumn,
|
|
9
|
+
DownloadColumn,
|
|
10
|
+
Progress,
|
|
11
|
+
TextColumn,
|
|
12
|
+
TransferSpeedColumn,
|
|
13
|
+
)
|
|
14
|
+
from rich.status import Status
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
_status: Status | None = None
|
|
20
|
+
_spin_lock = threading.Lock()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def spin_start(msg: str) -> None:
|
|
24
|
+
"""Start a global spinner at the bottom of the console."""
|
|
25
|
+
global _status
|
|
26
|
+
with _spin_lock:
|
|
27
|
+
if _status:
|
|
28
|
+
_status.stop()
|
|
29
|
+
_status = Status(msg, spinner="dots", console=console)
|
|
30
|
+
_status.start()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def spin_update(msg: str) -> None:
|
|
34
|
+
"""Update the global spinner message."""
|
|
35
|
+
with _spin_lock:
|
|
36
|
+
if _status:
|
|
37
|
+
_status.update(_esc(str(msg)))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def spin_stop() -> None:
|
|
41
|
+
"""Stop the global spinner."""
|
|
42
|
+
global _status
|
|
43
|
+
with _spin_lock:
|
|
44
|
+
if _status:
|
|
45
|
+
_status.stop()
|
|
46
|
+
_status = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def header(text: str) -> None:
|
|
50
|
+
console.print(f"\n[bold]{_esc(str(text))}[/bold]\n")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def success(msg: str) -> None:
|
|
54
|
+
console.print(f" [green]✓[/green] {_esc(str(msg))}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def info(msg: str) -> None:
|
|
58
|
+
console.print(f" [dim]{_esc(str(msg))}[/dim]")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def warn(msg: str) -> None:
|
|
62
|
+
console.print(f" [yellow]⚠[/yellow] {_esc(str(msg))}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def error(msg: str) -> None:
|
|
66
|
+
console.print(f" [red]✗[/red] {_esc(str(msg))}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def removed(msg: str) -> None:
|
|
70
|
+
# A plain hyphen, not an en dash: the CLI's glyphs are ✓ ⚠ ✗ - , and the
|
|
71
|
+
# en dash (U+2013) is a homoglyph for a hyphen that appears nowhere else in
|
|
72
|
+
# the output.
|
|
73
|
+
console.print(f" [yellow]-[/yellow] {_esc(str(msg))}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def iso_table(rows: list[tuple[str, str, str, str]], total_gb: float) -> None:
|
|
77
|
+
"""Render a rich table of ISO files. Cell values are markup-escaped."""
|
|
78
|
+
table = Table(
|
|
79
|
+
show_header=True,
|
|
80
|
+
header_style="bold",
|
|
81
|
+
pad_edge=False,
|
|
82
|
+
show_lines=False,
|
|
83
|
+
)
|
|
84
|
+
table.add_column("Distro", style="cyan", no_wrap=True)
|
|
85
|
+
table.add_column("Version", style="green")
|
|
86
|
+
table.add_column("Size", justify="right", style="magenta")
|
|
87
|
+
table.add_column("Filename", style="dim")
|
|
88
|
+
|
|
89
|
+
for distro, version, size, filename in rows:
|
|
90
|
+
table.add_row(_esc(distro), _esc(version), _esc(size), _esc(filename))
|
|
91
|
+
|
|
92
|
+
console.print(table)
|
|
93
|
+
console.print(f"\n [dim]{len(rows)} ISO(s) — {total_gb:.1f} GiB total[/dim]")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def failure_table(rows: list[tuple[str, str]]) -> None:
|
|
97
|
+
"""Render (name, reason) pairs as an aligned, markup-escaped table.
|
|
98
|
+
|
|
99
|
+
A table (rather than hand-formatted lines) keeps long URLs and reasons from
|
|
100
|
+
wrapping back to column zero and destroying the indentation.
|
|
101
|
+
"""
|
|
102
|
+
table = Table(
|
|
103
|
+
show_header=True,
|
|
104
|
+
header_style="bold",
|
|
105
|
+
pad_edge=False,
|
|
106
|
+
show_lines=False,
|
|
107
|
+
box=None,
|
|
108
|
+
)
|
|
109
|
+
table.add_column("Problem", style="red", no_wrap=True)
|
|
110
|
+
table.add_column("Reason", style="yellow")
|
|
111
|
+
|
|
112
|
+
for name, reason in rows:
|
|
113
|
+
table.add_row(_esc(str(name)), _esc(str(reason)))
|
|
114
|
+
|
|
115
|
+
console.print(table)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def make_download_progress() -> Progress:
|
|
119
|
+
"""Create a rich Progress bar for ISO downloads."""
|
|
120
|
+
return Progress(
|
|
121
|
+
TextColumn("[bold blue]{task.fields[filename]}[/bold blue]"),
|
|
122
|
+
BarColumn(),
|
|
123
|
+
DownloadColumn(),
|
|
124
|
+
TransferSpeedColumn(),
|
|
125
|
+
console=console,
|
|
126
|
+
)
|
visync/pm.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Package manager state and operations for visync.
|
|
2
|
+
|
|
3
|
+
Tracks which distros are "installed" (wanted) on the Ventoy drive.
|
|
4
|
+
State file: .visync/installed.json
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from visync.output import warn
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _state_path(drive_root: Path) -> Path:
|
|
15
|
+
"""Path to the installed.json state file."""
|
|
16
|
+
return drive_root / ".visync" / "installed.json"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_installed(drive_root: Path) -> dict:
|
|
20
|
+
"""Load the installed distros state. Returns {entry_id: {installed_at, version}}.
|
|
21
|
+
|
|
22
|
+
Corrupt or non-object JSON is treated as empty state (with a warning)
|
|
23
|
+
rather than crashing every subsequent command.
|
|
24
|
+
"""
|
|
25
|
+
path = _state_path(drive_root)
|
|
26
|
+
if not path.exists():
|
|
27
|
+
return {}
|
|
28
|
+
try:
|
|
29
|
+
with path.open() as f:
|
|
30
|
+
data = json.load(f)
|
|
31
|
+
except (json.JSONDecodeError, OSError):
|
|
32
|
+
return {}
|
|
33
|
+
if not isinstance(data, dict):
|
|
34
|
+
warn(f"installed.json has unexpected shape ({type(data).__name__}) — ignoring")
|
|
35
|
+
return {}
|
|
36
|
+
return data
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def save_installed(drive_root: Path, installed: dict) -> None:
|
|
40
|
+
"""Save the installed distros state atomically (tmp file + rename)."""
|
|
41
|
+
path = _state_path(drive_root)
|
|
42
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
tmp_path = path.with_suffix(".json.tmp")
|
|
44
|
+
with tmp_path.open("w") as f:
|
|
45
|
+
json.dump(installed, f, indent=2)
|
|
46
|
+
tmp_path.replace(path)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def mark_installed(drive_root: Path, entry_id: str, version: str = "") -> None:
|
|
50
|
+
"""Mark a distro as installed."""
|
|
51
|
+
installed = load_installed(drive_root)
|
|
52
|
+
installed[entry_id] = {
|
|
53
|
+
"installed_at": datetime.now(UTC).isoformat(),
|
|
54
|
+
"version": version,
|
|
55
|
+
}
|
|
56
|
+
save_installed(drive_root, installed)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def mark_removed(drive_root: Path, entry_id: str) -> None:
|
|
60
|
+
"""Mark a distro as removed."""
|
|
61
|
+
installed = load_installed(drive_root)
|
|
62
|
+
installed.pop(entry_id, None)
|
|
63
|
+
save_installed(drive_root, installed)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_installed_ids(drive_root: Path) -> list[str]:
|
|
67
|
+
"""Return list of installed distro entry IDs."""
|
|
68
|
+
return list(load_installed(drive_root).keys())
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _norm_query(text: str) -> str:
|
|
72
|
+
"""Lowercase and collapse every run of non-alphanumerics to one space.
|
|
73
|
+
|
|
74
|
+
Lets ``ubuntu-desktop``, ``ubuntu_desktop``, ``Ubuntu Desktop`` and
|
|
75
|
+
``UbuntuDesktop`` all reach the same entry. Reuses finder's normaliser so
|
|
76
|
+
query matching and filename matching cannot drift apart.
|
|
77
|
+
"""
|
|
78
|
+
from visync.finder import _norm_tokens
|
|
79
|
+
|
|
80
|
+
return _norm_tokens(text)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def matching_distros(query: str, config: dict) -> tuple[str | None, list[str]]:
|
|
84
|
+
"""Match a user query to distro entry_ids.
|
|
85
|
+
|
|
86
|
+
Returns (exact_entry_id, partial_candidates). *exact_entry_id* is set for
|
|
87
|
+
an unambiguous exact match on entry_id, clean_name, or keyword, ignoring
|
|
88
|
+
case and separator style. *partial_candidates* lists every entry_id whose
|
|
89
|
+
entry_id or clean_name contains the query as a normalised substring.
|
|
90
|
+
"""
|
|
91
|
+
query_norm = _norm_query(query)
|
|
92
|
+
if not query_norm:
|
|
93
|
+
return None, []
|
|
94
|
+
distros = config.get("distros", {})
|
|
95
|
+
|
|
96
|
+
for key in distros:
|
|
97
|
+
if _norm_query(key) == query_norm:
|
|
98
|
+
return key, []
|
|
99
|
+
|
|
100
|
+
for entry_id, settings in distros.items():
|
|
101
|
+
if _norm_query(settings.get("clean_name", "")) == query_norm:
|
|
102
|
+
return entry_id, []
|
|
103
|
+
|
|
104
|
+
keyword_hits = [
|
|
105
|
+
entry_id
|
|
106
|
+
for entry_id, settings in distros.items()
|
|
107
|
+
if _norm_query(settings.get("keyword", "")) == query_norm
|
|
108
|
+
]
|
|
109
|
+
if len(keyword_hits) == 1:
|
|
110
|
+
return keyword_hits[0], []
|
|
111
|
+
|
|
112
|
+
partials = [
|
|
113
|
+
entry_id
|
|
114
|
+
for entry_id, settings in distros.items()
|
|
115
|
+
if query_norm in _norm_query(settings.get("clean_name", ""))
|
|
116
|
+
or query_norm in _norm_query(entry_id)
|
|
117
|
+
]
|
|
118
|
+
return None, partials
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def resolve_distro(query: str, config: dict) -> str | None:
|
|
122
|
+
"""Resolve a user query (name, keyword, partial match) to a distro entry_id.
|
|
123
|
+
|
|
124
|
+
Exact matches always win. Partial substring matches only resolve when
|
|
125
|
+
they are unique; ambiguous queries return None so callers can refuse
|
|
126
|
+
rather than act on an arbitrary pick.
|
|
127
|
+
"""
|
|
128
|
+
exact, partials = matching_distros(query, config)
|
|
129
|
+
if exact:
|
|
130
|
+
return exact
|
|
131
|
+
if len(partials) == 1:
|
|
132
|
+
return partials[0]
|
|
133
|
+
return None
|