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