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 +3 -0
- xberg_cli/bin/x86_64-pc-windows-msvc/xberg.exe +0 -0
- xberg_cli/cli.py +46 -0
- xberg_cli/downloader.py +264 -0
- xberg_cli-1.0.0rc14.dist-info/METADATA +29 -0
- xberg_cli-1.0.0rc14.dist-info/RECORD +8 -0
- xberg_cli-1.0.0rc14.dist-info/WHEEL +4 -0
- xberg_cli-1.0.0rc14.dist-info/entry_points.txt +2 -0
xberg_cli/__init__.py
ADDED
|
Binary file
|
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()
|
xberg_cli/downloader.py
ADDED
|
@@ -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,,
|