xberg-cli 1.0.0rc14__tar.gz

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.
@@ -0,0 +1,323 @@
1
+ # Build artifacts
2
+ target/
3
+ build/
4
+ dist/
5
+ .polylint/
6
+ # Ad-hoc PDF/image debug binaries
7
+ /check_pdf_oxide
8
+ /check_*
9
+ # Hand-authored WASM JS distribution files (intentionally committed)
10
+ !crates/xberg-wasm/dist/index.js
11
+ !crates/xberg-wasm/dist/extraction/files.js
12
+ !crates/xberg-wasm/dist/ocr/enabler.js
13
+ .lycheecache
14
+ dist-musl/
15
+ *.so
16
+ *.dylib
17
+ *.dll
18
+ *.pdb
19
+ *.node
20
+ *.whl
21
+ *.tar.gz
22
+ *.gem
23
+ *.jar
24
+ wheels/
25
+ **/bin/
26
+ !**/src/bin/
27
+ **/obj/
28
+
29
+ # Rust
30
+ *.rs.bk
31
+ *.profraw
32
+ rust-coverage.lcov
33
+ rust_out
34
+
35
+ # Python
36
+ *.py[cod]
37
+ *$py.class
38
+ *.egg-info/
39
+ __pycache__/
40
+ .venv/
41
+ .mypy_cache/
42
+ .ruff_cache/
43
+ .pytest_cache/
44
+ .hypothesis/
45
+ .coverage
46
+ .coverage*
47
+ .e2e-logs/
48
+ htmlcov/
49
+ share/python-wheels/
50
+ prompt_template.egg-info/
51
+ packages/python/xberg/xberg-cli
52
+
53
+ # Node.js / WASM
54
+ node_modules/
55
+ *.tsbuildinfo
56
+ npm-debug.log*
57
+ pnpm-debug.log*
58
+ yarn-debug.log*
59
+ yarn-error.log*
60
+ # NAPI-RS generated files (index.js and index.d.ts are committed for TypeScript type resolution)
61
+ crates/xberg-node/*.tgz
62
+ # ORT dylibs are copied to crates/xberg-node/ at build time and included in npm publish
63
+ # despite the global *.dylib/*.so/*.dll rules below
64
+ !crates/xberg-node/*.dylib
65
+ !crates/xberg-node/*.so
66
+ !crates/xberg-node/*.dll
67
+ .wrangler/
68
+ .pnpm-store/
69
+
70
+ # Ruby
71
+ packages/ruby/.bundle/
72
+ packages/ruby/vendor/
73
+ !packages/ruby/vendor/rb-sys/
74
+ !vendor/rb-sys/build/
75
+ !vendor/rb-sys/build/**
76
+ *.Cache
77
+ .yardoc/
78
+
79
+ # PHP
80
+ vendor/
81
+ !crates/xberg-candle-ocr/src/vendor/
82
+ php-xberg.ini
83
+ packages/php/php-xberg.ini
84
+ packages/php/tests.xml
85
+ packages/php/var/
86
+ .php-cs-fixer.cache
87
+
88
+ # Swift / SwiftPM / Xcode
89
+ .build/
90
+ .swiftpm/
91
+ # Legacy SwiftPM resolved-packages dir — anchored to packages/swift/ so it
92
+ # does NOT match our top-level packages/ directory on macOS case-insensitive
93
+ # filesystems (where the bare `Packages/` pattern previously matched and
94
+ # silently dropped packages/dart/pubspec.yaml + 9 other scaffold files from
95
+ # the index during regen).
96
+ packages/swift/**/Packages/
97
+ Package.resolved
98
+ *.xcodeproj/
99
+ *.xcworkspace/
100
+ DerivedData/
101
+
102
+ # Java / C# / .NET
103
+ *.suo
104
+ *.user
105
+ *.cscfg
106
+ *.cobertura.xml
107
+ TestResults/
108
+
109
+ # Generated test apps (e2e-generator --mode published)
110
+ test_apps/
111
+
112
+ # Elixir
113
+ .nox/
114
+
115
+ # Go
116
+ packages/go/v*/install
117
+
118
+ # C FFI
119
+ crates/xberg-ffi/tests/c/test_*
120
+ !crates/xberg-ffi/tests/c/test_*.c
121
+ crates/xberg-ffi/tests/c/*.o
122
+ crates/xberg-ffi/tests/c/*.dSYM/
123
+ e2e/c/test_*
124
+ !e2e/c/test_*.c
125
+ e2e/c/*.o
126
+ e2e/c/*.dSYM/
127
+
128
+ # Generated pkg-config
129
+ *.pc
130
+
131
+ # Coverage
132
+ coverage/
133
+ coverage.info
134
+ coverage.json
135
+ coverage.lcov
136
+ coverage.out
137
+ coverage.xml
138
+ packages/*/.coverage
139
+ packages/*/coverage.json
140
+ packages/*/coverage.out
141
+ packages/*/htmlcov/
142
+ packages/*/test-results/
143
+ packages/*/TestResults/
144
+
145
+ # IDE / Editor
146
+ .idea/
147
+ *.iml
148
+ .vscode/
149
+ .run/
150
+ .DS_store
151
+
152
+ # CI / Tools
153
+ .cache/
154
+ .tmp/
155
+ *temp/
156
+ *.temp
157
+ *.tmp
158
+ *.log
159
+ **/logs/
160
+ **/test_output.log
161
+ .mvn/
162
+ .tox/
163
+ .artifacts/
164
+ .remote-cache/
165
+ .rumdl_cache/
166
+ docker-logs/
167
+
168
+ # Benchmarks
169
+ benchmark-results/
170
+ benchmark_results.json
171
+ benchmarks/results/
172
+ !benchmarks/baselines/
173
+ aggregated-results/
174
+ results/
175
+ reports/
176
+ tests/benchmarks/results/
177
+ profiling-results/
178
+ profiling-results-*/
179
+
180
+ # Local perf-iteration artifacts (flamegraphs/ is committed; intermediate outputs are not)
181
+ profile.json.gz
182
+ profile.pb.gz
183
+ *.heap
184
+ bench/
185
+
186
+ # Docs
187
+ docs/_build/
188
+ docs/build/
189
+ docs/benchmarks/charts/
190
+ site/
191
+
192
+ # Elixir build artifacts (lock files MUST stay tracked — see CLAUDE.md
193
+ # task-runner / dependency-awareness rules and the brotli/alloc-no-stdlib
194
+ # regression that bit CI run 27492590406 because the NIF lock wasn't
195
+ # committed and the resolver picked a fresh broken combo).
196
+ packages/elixir/_build/
197
+ packages/elixir/deps/
198
+ e2e/elixir/_build/
199
+ e2e/elixir/deps/
200
+ e2e/elixir/config/
201
+ test_documents/_build/
202
+ test_documents/deps/
203
+
204
+ # Maven plugin artifacts
205
+ *.versionsBackup
206
+
207
+ # Gradle (Kotlin e2e)
208
+ e2e/kotlin/.gradle/
209
+ e2e/kotlin/build/
210
+ e2e/kotlin_android/.gradle/
211
+ e2e/kotlin_android/build/
212
+
213
+ # Ruby native build artifacts
214
+ packages/ruby/lib/*.bundle
215
+ packages/ruby/tmp/
216
+
217
+ # Tests
218
+ test_report.json
219
+ tests/e2e/logs/
220
+ tests/e2e/test_report.json
221
+ temp-test-*
222
+ packages/csharp/test_html_debug.cs
223
+
224
+ # AI / MCP / Agent config
225
+ .claude/
226
+ .codex/
227
+ .gemini/
228
+ .xberg/
229
+ .mcp.json
230
+ .playwright-mcp/
231
+ .fastembed_cache/
232
+ */.fastembed_cache/
233
+ AGENTS.md
234
+ CLAUDE.md
235
+ GEMINI.md
236
+ .cursorrules
237
+ .windsurfrules
238
+ [Tt][Oo][Dd][Oo]*
239
+
240
+ # Git worktrees
241
+ .worktrees/
242
+
243
+ # Misc
244
+ .env
245
+ output.txt
246
+ examples.txt
247
+ requirements.txt
248
+ docker-compose.yaml
249
+ /tmp/xberg-docker-test-results.json
250
+ tools/benchmark-harness/datasets/
251
+ tools/benchmark-harness/.fixtures-pdf-subset/
252
+ tools/benchmark-harness/vendored/docling/md/iso_21111_10.md
253
+ visualizations/
254
+
255
+
256
+ # Additional generated artifacts
257
+ .alef/
258
+ *.pyd
259
+ .gems/
260
+ vendor/bundle/
261
+ *.h.bak
262
+ *.test
263
+ *.class
264
+ bin/
265
+ obj/
266
+ *.nupkg
267
+ pkg/
268
+
269
+
270
+ # Local dev artifacts
271
+ docs-site/public/demo-dev.html
272
+ docs-site/dist/
273
+ docs-site/.astro/
274
+ demo-loaded.png
275
+ scratch/
276
+
277
+
278
+ .php-cs-fixer.*
279
+
280
+ e2e/zig/.zig-cache/
281
+
282
+ # Swift e2e build artifacts (Swift Package Manager)
283
+ e2e/swift/.build/
284
+ e2e/swift/.swiftpm/
285
+ e2e/swift/Package.resolved
286
+ e2e/swift_e2e/.build/
287
+ e2e/swift_e2e/.swiftpm/
288
+ e2e/swift_e2e/Package.resolved
289
+
290
+ # Dart e2e build artifacts (lock file tracked for reproducibility)
291
+ e2e/dart/.dart_tool/
292
+ e2e/dart/build/
293
+
294
+ # Dart FRB codegen cache (incremental build optimization)
295
+ packages/dart/rust/.frb_codegen_hash
296
+
297
+ # Go e2e build artifacts
298
+ e2e/go/vendor/
299
+
300
+ erl_crash.dump
301
+
302
+
303
+ .gradle/
304
+
305
+ packages/go/.lib/
306
+
307
+ .build/
308
+
309
+ # basemind code-map index (local tooling)
310
+ .basemind/
311
+
312
+ .bench-rc32/
313
+ build-output-musl-node/
314
+
315
+ # BEGIN ai-rulez (DO NOT EDIT - managed by ai-rulez)
316
+ .agents/
317
+ .ai-rulez/.generated-manifest.json
318
+ .cursor/
319
+ .github/agents/
320
+ .github/commands/
321
+ .github/copilot-instructions.md
322
+ .github/skills/
323
+ # END ai-rulez
@@ -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,7 @@
1
+ # xberg-cli
2
+
3
+ CLI proxy for [`xberg`](https://github.com/xberg-io/xberg). Installing
4
+ this package provides a `xberg` command that downloads the matching native
5
+ binary from GitHub releases and runs it.
6
+
7
+ Pin a specific release tag with the `XBERG_CLI_VERSION` environment variable.
@@ -0,0 +1 @@
1
+ """Build package marker for the CLI proxy project."""
@@ -0,0 +1,151 @@
1
+ """Custom hatchling build hook that bundles the native xberg binary into a wheel.
2
+
3
+ When building a platform-specific wheel in CI, the target triple is supplied via
4
+ the ``XBERG_CLI_TARGET`` env var (the build host is always linux/amd64, so the
5
+ triple cannot be inferred from ``platform.*``). The matching
6
+ ``xberg-cli-<target>.tar.gz`` / ``.zip`` is located (repo root or ``dist/``),
7
+ the binary is extracted into ``xberg_cli/bin/<target>/``, force-included in the
8
+ wheel, and the wheel is tagged for that platform so PyPI serves the right artifact.
9
+
10
+ If no target/binary is found (e.g. the sdist build, or an unknown platform), the
11
+ hook is a no-op and the package falls back to the runtime downloader in
12
+ ``xberg_cli/downloader.py`` (see ``cli.py``).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import shutil
19
+ import tarfile
20
+ import zipfile
21
+ from pathlib import Path
22
+
23
+ from hatchling.builders.hooks.plugin.interface import BuildHookInterface
24
+
25
+ # Rust target triple -> wheel platform tag. PyPI uses the platform tag to serve the
26
+ # correct wheel per OS/arch/libc.
27
+ _TAG_MAP = {
28
+ "x86_64-pc-windows-msvc": "win_amd64",
29
+ "x86_64-unknown-linux-gnu": "manylinux_2_28_x86_64",
30
+ "aarch64-unknown-linux-gnu": "manylinux_2_28_aarch64",
31
+ "x86_64-unknown-linux-musl": "musllinux_1_2_x86_64",
32
+ "aarch64-unknown-linux-musl": "musllinux_1_2_aarch64",
33
+ "aarch64-apple-darwin": "macosx_11_0_arm64",
34
+ "x86_64-apple-darwin": "macosx_11_0_x86_64",
35
+ }
36
+
37
+
38
+ def _safe_destination(root: Path, member_name: str) -> Path:
39
+ """Resolve an archive member path and require it to stay under root."""
40
+ root = root.resolve()
41
+ target = (root / member_name.replace("\\", "/")).resolve(strict=False)
42
+ if target != root and not target.is_relative_to(root):
43
+ raise RuntimeError(f"archive member escapes extraction directory: {member_name}")
44
+ return target
45
+
46
+
47
+ def _extract_zip_bounded(archive: Path, extract_dir: Path) -> None:
48
+ """Extract zip entries after bounding each destination path."""
49
+ with zipfile.ZipFile(archive) as zf:
50
+ for member in zf.infolist():
51
+ target = _safe_destination(extract_dir, member.filename)
52
+ if member.is_dir():
53
+ target.mkdir(parents=True, exist_ok=True)
54
+ continue
55
+
56
+ target.parent.mkdir(parents=True, exist_ok=True)
57
+ with zf.open(member) as source, target.open("wb") as destination:
58
+ shutil.copyfileobj(source, destination)
59
+
60
+
61
+ def _extract_tar_bounded(archive: Path, extract_dir: Path) -> None:
62
+ """Extract regular tar entries after bounding each destination path."""
63
+ with tarfile.open(archive, "r:gz") as tf:
64
+ for member in tf.getmembers():
65
+ target = _safe_destination(extract_dir, member.name)
66
+ if member.isdir():
67
+ target.mkdir(parents=True, exist_ok=True)
68
+ continue
69
+ if not member.isfile():
70
+ raise RuntimeError(f"unsupported archive member type: {member.name}")
71
+
72
+ target.parent.mkdir(parents=True, exist_ok=True)
73
+ source = tf.extractfile(member)
74
+ if source is None:
75
+ raise RuntimeError(f"could not read archive member: {member.name}")
76
+ with source, target.open("wb") as destination:
77
+ shutil.copyfileobj(source, destination)
78
+
79
+
80
+ class CustomBuildHook(BuildHookInterface):
81
+ """Inject the matching native binary into a platform-tagged wheel."""
82
+
83
+ PLUGIN_NAME = "custom"
84
+
85
+ def initialize(self, version: str, build_data: dict) -> None: # noqa: ARG002
86
+ """Bundle a staged native binary when building a targeted wheel."""
87
+ target = os.environ.get("XBERG_CLI_TARGET", "").strip()
88
+ if not target:
89
+ # sdist build or unbundled wheel: leave a pure, download-at-runtime package.
90
+ return
91
+
92
+ archive = self._find_archive(target)
93
+ if archive is None:
94
+ # Target requested but no binary staged — fail loudly rather than ship an
95
+ # empty platform wheel that shadows the working sdist on PyPI.
96
+ raise RuntimeError(
97
+ f"XBERG_CLI_TARGET={target} but no xberg-cli-{target}.(tar.gz|zip) "
98
+ f"found in repo root or dist/; refusing to build an empty platform wheel."
99
+ )
100
+
101
+ wheel_tag = _TAG_MAP.get(target)
102
+ if wheel_tag is None:
103
+ raise RuntimeError(f"no wheel platform tag mapped for target {target}")
104
+
105
+ binary = self._extract_binary(archive, target)
106
+
107
+ # force_include maps absolute source paths -> in-wheel relative paths.
108
+ relative = f"xberg_cli/bin/{target}/{binary.name}"
109
+ force_include = build_data.setdefault("force_include", {})
110
+ force_include[str(binary)] = relative
111
+
112
+ # Bundle runtime dylibs staged next to the binary. The native Intel-macOS
113
+ # build loads ONNX Runtime dynamically and ships libonnxruntime.dylib (plus
114
+ # its vendored deps) alongside the executable; ort resolves them relative to
115
+ # the binary, so they must sit in the same in-wheel directory.
116
+ for lib in sorted(binary.parent.glob("*.dylib")):
117
+ force_include[str(lib)] = f"xberg_cli/bin/{target}/{lib.name}"
118
+
119
+ # Make it a platform wheel (not pure-python, not py3-none-any).
120
+ build_data["pure_python"] = False
121
+ build_data["infer_tag"] = False
122
+ build_data["tag"] = f"py3-none-{wheel_tag}"
123
+
124
+ def _find_archive(self, target: str) -> Path | None:
125
+ root = Path(self.root) # the project dir hatchling is building (cli-proxy/pypi)
126
+ repo_root = root.parent.parent
127
+ for base in (repo_root, repo_root / "dist", root, root / "dist"):
128
+ for ext in ("tar.gz", "zip"):
129
+ candidate = base / f"xberg-cli-{target}.{ext}"
130
+ if candidate.is_file():
131
+ return candidate
132
+ return None
133
+
134
+ def _extract_binary(self, archive: Path, target: str) -> Path:
135
+ is_windows = target.endswith("windows-msvc")
136
+ binary_name = "xberg.exe" if is_windows else "xberg"
137
+ extract_dir = Path(self.root) / ".build-extract" / target
138
+ if extract_dir.exists():
139
+ shutil.rmtree(extract_dir, ignore_errors=True)
140
+ extract_dir.mkdir(parents=True, exist_ok=True)
141
+
142
+ if str(archive).lower().endswith(".zip"):
143
+ _extract_zip_bounded(archive, extract_dir)
144
+ else:
145
+ _extract_tar_bounded(archive, extract_dir)
146
+
147
+ for candidate in extract_dir.rglob(binary_name):
148
+ if candidate.is_file():
149
+ candidate.chmod(0o755)
150
+ return candidate
151
+ raise RuntimeError(f"binary {binary_name} not found inside {archive.name}")
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ build-backend = "hatchling.build"
3
+ requires = ["hatchling"]
4
+
5
+ [project]
6
+ name = "xberg-cli"
7
+ version = "1.0.0rc14"
8
+ description = "CLI proxy for xberg — downloads and runs the native xberg binary from GitHub releases."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Na'aman Hirschfeld", email = "naaman@xberg.io" }]
12
+ requires-python = ">=3.10"
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Environment :: Console",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: 3.14",
24
+ ]
25
+ urls.Homepage = "https://github.com/xberg-io/xberg"
26
+ urls.Issues = "https://github.com/xberg-io/xberg/issues"
27
+ urls.Repository = "https://github.com/xberg-io/xberg.git"
28
+ scripts.xberg = "xberg_cli.cli:main"
29
+
30
+ [tool.hatch]
31
+ build.hooks.custom.path = "hatch_build.py"
32
+ build.targets.wheel.packages = ["xberg_cli"]
@@ -0,0 +1,3 @@
1
+ """CLI proxy package."""
2
+
3
+ __version__ = "0.1.0"
@@ -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