xberg-cli 1.0.0rc14__py3-none-win_amd64.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.
xberg_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """CLI proxy package."""
2
+
3
+ __version__ = "0.1.0"
xberg_cli/cli.py ADDED
@@ -0,0 +1,46 @@
1
+ """CLI entry point for the xberg proxy.
2
+
3
+ A platform-specific wheel bundles the native binary under
4
+ ``xberg_cli/bin/<target>/``; the sdist (and unknown platforms) ship no binary
5
+ and fall back to the runtime downloader. Each wheel contains exactly one ``bin/<target>``
6
+ directory, so we locate the binary by globbing rather than recomputing the target
7
+ triple (which cannot distinguish glibc from musl at runtime).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import platform
14
+ import subprocess
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from .downloader import run
19
+
20
+
21
+ def _find_bundled_binary() -> str | None:
22
+ """Return the path to the bundled native binary if this wheel shipped one."""
23
+ bin_root = Path(__file__).parent / "bin"
24
+ if not bin_root.is_dir():
25
+ return None
26
+
27
+ binary_name = "xberg.exe" if platform.system().lower() == "windows" else "xberg"
28
+ for candidate in bin_root.glob(f"*/{binary_name}"):
29
+ if candidate.is_file() and os.access(candidate, os.X_OK):
30
+ return str(candidate)
31
+ return None
32
+
33
+
34
+ def main() -> None:
35
+ """Resolve the native binary (bundled or downloaded) and exec it with forwarded argv."""
36
+ bundled = _find_bundled_binary()
37
+ if bundled:
38
+ completed = subprocess.run([bundled, *sys.argv[1:]], check=False)
39
+ sys.exit(completed.returncode)
40
+
41
+ # Fall back to the runtime download path (sdist / unknown platform).
42
+ sys.exit(run(sys.argv[1:]))
43
+
44
+
45
+ if __name__ == "__main__":
46
+ main()
@@ -0,0 +1,264 @@
1
+ """Resolve, download, verify, and run the native xberg binary.
2
+
3
+ Self-healing asset discovery: query the GitHub releases API and pick the asset
4
+ whose name contains the platform target triple, instead of hardcoding one exact
5
+ asset name. This keeps working as release asset naming standardizes over time.
6
+ All diagnostics go to stderr.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import platform
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ import tarfile
19
+ import tempfile
20
+ import zipfile
21
+ from pathlib import Path, PurePosixPath
22
+ from urllib.error import URLError
23
+ from urllib.parse import quote, urlsplit
24
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
25
+
26
+ REPO = "xberg-io/xberg"
27
+ BIN_NAME = "xberg"
28
+ PKG_NAME = "xberg-cli"
29
+ VERSION_ENV = "XBERG_CLI_VERSION"
30
+ _USER_AGENT = "xberg-cli-python-proxy"
31
+
32
+
33
+ def _target_triple() -> str:
34
+ system = platform.system().lower()
35
+ machine = platform.machine().lower()
36
+
37
+ if system == "windows":
38
+ if machine in {"amd64", "x86_64"}:
39
+ return "x86_64-pc-windows-msvc"
40
+ raise RuntimeError(f"unsupported Windows arch: {machine}")
41
+ if system == "linux":
42
+ if machine in {"amd64", "x86_64"}:
43
+ return "x86_64-unknown-linux-gnu"
44
+ if machine in {"aarch64", "arm64"}:
45
+ return "aarch64-unknown-linux-gnu"
46
+ raise RuntimeError(f"unsupported Linux arch: {machine}")
47
+ if system == "darwin":
48
+ if machine in {"aarch64", "arm64"}:
49
+ return "aarch64-apple-darwin"
50
+ if machine in {"amd64", "x86_64"}:
51
+ return "x86_64-apple-darwin"
52
+ raise RuntimeError(f"unsupported macOS arch: {machine}")
53
+ raise RuntimeError(f"unsupported platform: {system} {machine}")
54
+
55
+
56
+ def _binary_name() -> str:
57
+ return f"{BIN_NAME}.exe" if platform.system().lower() == "windows" else BIN_NAME
58
+
59
+
60
+ class _HttpsOnlyRedirectHandler(HTTPRedirectHandler):
61
+ """Reject any redirect whose target is not https (downgrade/SSRF guard)."""
62
+
63
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
64
+ if urlsplit(newurl).scheme.lower() != "https":
65
+ raise URLError(f"refusing non-https redirect to: {newurl}")
66
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
67
+
68
+
69
+ _opener = build_opener(_HttpsOnlyRedirectHandler())
70
+
71
+
72
+ def _http_get(url: str, accept: str | None = None) -> bytes:
73
+ if urlsplit(url).scheme.lower() != "https":
74
+ raise RuntimeError(f"refusing non-https URL: {url}")
75
+ headers = {"User-Agent": _USER_AGENT}
76
+ if accept:
77
+ headers["Accept"] = accept
78
+ request = Request(url, headers=headers)
79
+ try:
80
+ with _opener.open(request, timeout=60) as response:
81
+ if response.status != 200:
82
+ raise RuntimeError(f"HTTP {response.status} for {url}")
83
+ return response.read()
84
+ except URLError as exc:
85
+ raise RuntimeError(f"failed to fetch {url}: {exc}") from exc
86
+
87
+
88
+ def _asset_score(name: str) -> int:
89
+ lowered = name.lower()
90
+ score = 0
91
+ if BIN_NAME.lower() in lowered:
92
+ score += 2
93
+ if "cli" in lowered:
94
+ score += 1
95
+ return score
96
+
97
+
98
+ def _resolve_release() -> tuple[str, dict, dict | None]:
99
+ """Return (tag, archive_asset, checksums_asset_or_none) for this platform."""
100
+ triple = _target_triple()
101
+ pinned = os.getenv(VERSION_ENV)
102
+ if pinned:
103
+ api_url = f"https://api.github.com/repos/{REPO}/releases/tags/{quote(pinned, safe='')}"
104
+ else:
105
+ api_url = f"https://api.github.com/repos/{REPO}/releases/latest"
106
+
107
+ release = json.loads(_http_get(api_url, accept="application/vnd.github+json"))
108
+ assets = release.get("assets") or []
109
+ tag = release.get("tag_name") or pinned or "latest"
110
+
111
+ archives = [
112
+ a
113
+ for a in assets
114
+ if triple in (a.get("name") or "").lower()
115
+ and ((a.get("name") or "").lower().endswith(".tar.gz") or (a.get("name") or "").lower().endswith(".zip"))
116
+ ]
117
+ if not archives:
118
+ raise RuntimeError(f'no release asset matching target triple "{triple}" in {REPO} release {tag}')
119
+ archives.sort(key=lambda a: _asset_score(a.get("name") or ""), reverse=True)
120
+ archive = archives[0]
121
+
122
+ checksums = next((a for a in assets if "SHA256SUMS" in (a.get("name") or "").upper()), None)
123
+ return tag, archive, checksums
124
+
125
+
126
+ def _expected_digest(text: str, asset_name: str) -> str | None:
127
+ for line in text.splitlines():
128
+ stripped = line.strip()
129
+ if not stripped:
130
+ continue
131
+ parts = stripped.split()
132
+ if len(parts) < 2:
133
+ continue
134
+ name = parts[-1].lstrip("*")
135
+ if name == asset_name:
136
+ return parts[0].lower()
137
+ return None
138
+
139
+
140
+ def _verify_or_warn(archive_path: Path, asset_name: str, checksums: dict | None) -> None:
141
+ if not checksums:
142
+ print(
143
+ f"WARNING: no SHA256SUMS asset found for {asset_name}; "
144
+ "installing over HTTPS without checksum verification.",
145
+ file=sys.stderr,
146
+ )
147
+ return
148
+ sums_text = _http_get(checksums["browser_download_url"]).decode("utf-8")
149
+ expected = _expected_digest(sums_text, asset_name)
150
+ if not expected:
151
+ raise RuntimeError(
152
+ f"no checksum entry for {asset_name} in {checksums['name']} — refusing to install unverified binary"
153
+ )
154
+ digest = hashlib.sha256(archive_path.read_bytes()).hexdigest().lower()
155
+ if digest != expected:
156
+ raise RuntimeError(f"checksum mismatch for {asset_name} (expected {expected}, got {digest})")
157
+ print(f"Checksum verified for {asset_name}.", file=sys.stderr)
158
+
159
+
160
+ def _is_within(base: Path, target: Path) -> bool:
161
+ """True if `target`'s resolved path stays inside `base`."""
162
+ base_resolved = base.resolve()
163
+ try:
164
+ target.resolve().relative_to(base_resolved)
165
+ except ValueError:
166
+ return False
167
+ return True
168
+
169
+
170
+ def _reject_unsafe_member(name: str) -> None:
171
+ """Reject absolute paths and any `..` component (zip-slip / tar-slip)."""
172
+ normalized = name.replace("\\", "/")
173
+ pure = PurePosixPath(normalized)
174
+ if pure.is_absolute() or normalized.startswith("/"):
175
+ raise RuntimeError(f"refusing absolute path in archive: {name}")
176
+ # Windows drive letters / UNC prefixes are also absolute escapes.
177
+ if len(normalized) >= 2 and normalized[1] == ":":
178
+ raise RuntimeError(f"refusing absolute path in archive: {name}")
179
+ if any(part == ".." for part in pure.parts):
180
+ raise RuntimeError(f"refusing parent-directory escape in archive: {name}")
181
+
182
+
183
+ def _safe_extract(archive_path: Path, asset_name: str, dest: Path) -> None:
184
+ """Extract a tar/zip, validating every member stays within `dest`.
185
+
186
+ Does not rely on extractall() defaults or the 3.12-only ``filter='data'``:
187
+ each member name is rejected up front if it is absolute or contains ``..``,
188
+ and the resolved destination path is re-checked against ``dest``.
189
+ """
190
+ dest = dest.resolve()
191
+ dest.mkdir(parents=True, exist_ok=True)
192
+
193
+ if asset_name.lower().endswith(".zip"):
194
+ with zipfile.ZipFile(archive_path) as zf:
195
+ for member in zf.namelist():
196
+ _reject_unsafe_member(member)
197
+ if not _is_within(dest, dest / member):
198
+ raise RuntimeError(f"refusing archive entry escaping dest: {member}")
199
+ for member in zf.namelist():
200
+ zf.extract(member, dest)
201
+ else:
202
+ with tarfile.open(archive_path, "r:gz") as tf:
203
+ for member in tf.getmembers():
204
+ _reject_unsafe_member(member.name)
205
+ if not _is_within(dest, dest / member.name):
206
+ raise RuntimeError(f"refusing archive entry escaping dest: {member.name}")
207
+ # Reject link members that point outside dest as well.
208
+ if member.islnk() or member.issym():
209
+ link_target = dest / member.name
210
+ if not _is_within(dest, link_target.parent / member.linkname):
211
+ raise RuntimeError(f"refusing link escaping dest: {member.name}")
212
+ tf.extractall(dest) # noqa: S202 (members validated above)
213
+
214
+
215
+ def _find_binary(root: Path, name: str) -> Path | None:
216
+ for candidate in root.rglob(name):
217
+ if candidate.is_file():
218
+ return candidate
219
+ return None
220
+
221
+
222
+ def _cache_dir(tag: str) -> Path:
223
+ cache = Path.home() / ".cache" / PKG_NAME / tag
224
+ cache.mkdir(parents=True, exist_ok=True, mode=0o700)
225
+ return cache
226
+
227
+
228
+ def ensure_binary() -> str:
229
+ """Ensure the native binary exists locally, downloading if necessary."""
230
+ override = os.getenv("XBERG_BINARY")
231
+ if override:
232
+ return override
233
+
234
+ tag, archive, checksums = _resolve_release()
235
+ cache = _cache_dir(tag)
236
+ binary_path = cache / _binary_name()
237
+ if binary_path.exists() and os.access(binary_path, os.X_OK):
238
+ return str(binary_path)
239
+
240
+ print(f"Downloading {BIN_NAME} {tag} asset {archive['name']}...", file=sys.stderr)
241
+ with tempfile.TemporaryDirectory() as tmpdir:
242
+ tmp = Path(tmpdir)
243
+ archive_path = tmp / Path(archive["name"]).name
244
+ archive_path.write_bytes(_http_get(archive["browser_download_url"]))
245
+ _verify_or_warn(archive_path, archive["name"], checksums)
246
+ extract_dir = tmp / "extract"
247
+ extract_dir.mkdir()
248
+ _safe_extract(archive_path, archive["name"], extract_dir)
249
+ found = _find_binary(extract_dir, _binary_name())
250
+ if not found:
251
+ raise RuntimeError(f"binary {_binary_name()} not found after extracting {archive['name']}")
252
+ shutil.move(str(found), str(binary_path))
253
+
254
+ if platform.system().lower() != "windows":
255
+ binary_path.chmod(0o755)
256
+ print(f"{BIN_NAME} installed.", file=sys.stderr)
257
+ return str(binary_path)
258
+
259
+
260
+ def run(args: list[str]) -> int:
261
+ """Run the native binary with the given args, returning its exit code."""
262
+ binary_path = ensure_binary()
263
+ completed = subprocess.run([binary_path, *args], check=False)
264
+ return completed.returncode
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: xberg-cli
3
+ Version: 1.0.0rc14
4
+ Summary: CLI proxy for xberg — downloads and runs the native xberg binary from GitHub releases.
5
+ Project-URL: Homepage, https://github.com/xberg-io/xberg
6
+ Project-URL: Issues, https://github.com/xberg-io/xberg/issues
7
+ Project-URL: Repository, https://github.com/xberg-io/xberg.git
8
+ Author-email: Na'aman Hirschfeld <naaman@xberg.io>
9
+ License: MIT
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+
23
+ # xberg-cli
24
+
25
+ CLI proxy for [`xberg`](https://github.com/xberg-io/xberg). Installing
26
+ this package provides a `xberg` command that downloads the matching native
27
+ binary from GitHub releases and runs it.
28
+
29
+ Pin a specific release tag with the `XBERG_CLI_VERSION` environment variable.
@@ -0,0 +1,8 @@
1
+ xberg_cli/__init__.py,sha256=rwJ37WekKXRa7EDStzrjLzDjv8d9alyKp9ssKuqycIo,48
2
+ xberg_cli/cli.py,sha256=SErDoGWRnF8hdXtGNcsvFtmOpm1bc8wruQQIPYtPyNs,1467
3
+ xberg_cli/downloader.py,sha256=gU_Lk4hYo--xtSPbXp3xsCJ0Ej5Thi_KwQVXF2zpvuY,10026
4
+ xberg_cli/bin/x86_64-pc-windows-msvc/xberg.exe,sha256=u2VEBNf7SAd53t7XjiMgn8isXjKoYRE9qJXCfkvBaok,85030912
5
+ xberg_cli-1.0.0rc14.dist-info/METADATA,sha256=EUoqbTQDZCfGkgEJnud8q41iQ_u8t3w_Aj1IbyJ9xZU,1229
6
+ xberg_cli-1.0.0rc14.dist-info/WHEEL,sha256=xx6qpbXm_1wrhsLtr7J2DHdByvFFuLXz-tIuSfVCWyQ,94
7
+ xberg_cli-1.0.0rc14.dist-info/entry_points.txt,sha256=DJY95DjhCS3CtY6dF1IL3_ELC-Jcw1Ec0K2sH5R1XNw,45
8
+ xberg_cli-1.0.0rc14.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: false
4
+ Tag: py3-none-win_amd64
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ xberg = xberg_cli.cli:main