ohbin 0.2.2__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.
ohbin/__init__.py ADDED
@@ -0,0 +1,95 @@
1
+ """ohbin — declarative GitHub-release binaries for uv projects.
2
+
3
+ Declare tools in your project's pyproject:
4
+
5
+ [tool.ohbin.tools.rg]
6
+ repo = "BurntSushi/ripgrep"
7
+ version = "14.1.1"
8
+ binary = "rg"
9
+
10
+ [tool.ohbin.tools.rg.assets.darwin-arm64]
11
+ url = "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-aarch64-apple-darwin.tar.gz"
12
+ sha256 = "..."
13
+
14
+ then `uv run ohbin run rg -- <args>`, or in-process `ohbin.ensure("rg")`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from ohbin._engine import (
23
+ BinaryNotFoundError,
24
+ ChecksumMismatchError,
25
+ MissingPasswordError,
26
+ cache_root,
27
+ ensure_from,
28
+ )
29
+ from ohbin._errors import OhbinError
30
+ from ohbin._manifest import ManifestError, find_pyproject, load_tool, load_tools
31
+ from ohbin._platform import Platform, UnsupportedPlatformError, current_platform
32
+ from ohbin._types import AssetEntry, ToolConfig
33
+
34
+ __all__ = [
35
+ "AssetEntry",
36
+ "BinaryNotFoundError",
37
+ "ChecksumMismatchError",
38
+ "ManifestError",
39
+ "MissingPasswordError",
40
+ "OhbinError",
41
+ "Platform",
42
+ "ToolConfig",
43
+ "UnsupportedPlatformError",
44
+ "cache_root",
45
+ "current_platform",
46
+ "ensure",
47
+ "ensure_from",
48
+ "find_pyproject",
49
+ "load_tool",
50
+ "load_tools",
51
+ ]
52
+
53
+
54
+ def _resolve_password(tool: str, cfg: ToolConfig, explicit: str | None) -> str | None:
55
+ """`--password` wins; otherwise fall back to the manifest, warning unless ack'd."""
56
+ if explicit is not None:
57
+ return explicit
58
+ pw = cfg.get("password")
59
+ if pw is not None and not cfg.get("password_committed_ok"):
60
+ print(
61
+ f"ohbin: warning: reading the password for {tool!r} from pyproject — keep it out "
62
+ "of public repos; set 'password_committed_ok = true' to silence this.",
63
+ file=sys.stderr,
64
+ )
65
+ return pw
66
+
67
+
68
+ def ensure(tool: str, *, pyproject: Path | None = None, password: str | None = None) -> Path:
69
+ """Return the cached binary path for a declared tool, downloading on first use.
70
+
71
+ Reads `[tool.ohbin.tools.<tool>]` from the project's pyproject (discovered
72
+ from CWD, or `OHBIN_PYPROJECT`). This is the in-process entry point — call it
73
+ from build tooling that needs the binary's path rather than exec-ing it.
74
+
75
+ For encrypted tools, `password` (from `--password`) takes precedence over the
76
+ manifest's `password` field.
77
+ """
78
+ cfg = load_tool(tool, pyproject=pyproject)
79
+ plat = current_platform()
80
+ entry = cfg["assets"].get(plat.key)
81
+ if entry is None:
82
+ declared = ", ".join(sorted(cfg["assets"])) or "none"
83
+ msg = f"tool {tool!r} has no asset for {plat.key} (declared: {declared})"
84
+ raise UnsupportedPlatformError(msg)
85
+ encrypted = bool(cfg.get("encrypted"))
86
+ return ensure_from(
87
+ tool=tool,
88
+ version=cfg["version"],
89
+ binary=cfg["binary"],
90
+ url=entry["url"],
91
+ sha256=entry["sha256"],
92
+ encrypted=encrypted,
93
+ password=_resolve_password(tool, cfg, password) if encrypted else None,
94
+ binary_sha256=entry.get("binary_sha256"),
95
+ )
ohbin/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Allow `python -m ohbin`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from ohbin.cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
ohbin/_add.py ADDED
@@ -0,0 +1,254 @@
1
+ """`ohbin add`: resolve a GitHub release's per-platform assets and write them into pyproject."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ from ohbin._engine import sha256_of_url
10
+ from ohbin._errors import OhbinError
11
+ from ohbin._github import Asset, fetch_release
12
+ from ohbin._platform import (
13
+ ALL_ARCH_TOKENS,
14
+ TARGET_PLATFORMS,
15
+ Platform,
16
+ arch_tokens,
17
+ os_tokens,
18
+ )
19
+ from ohbin._types import AssetEntry, ToolConfig
20
+
21
+ if TYPE_CHECKING:
22
+ from tomlkit.items import Item, Key, Table
23
+
24
+ # One element of a tomlkit container body: a real key + item, or (None, item)
25
+ # for standalone whitespace/comment lines.
26
+ BodyEntry = tuple[Key | None, Item]
27
+
28
+ # Asset filenames that are never the binary we want.
29
+ _DENY_SUFFIXES = (
30
+ ".txt",
31
+ ".sha256",
32
+ ".sha256sum",
33
+ ".sums",
34
+ ".asc",
35
+ ".sig",
36
+ ".minisig",
37
+ ".pem",
38
+ ".pubkey",
39
+ ".json",
40
+ ".deb",
41
+ ".rpm",
42
+ ".msi",
43
+ ".pdb",
44
+ ".md",
45
+ )
46
+
47
+
48
+ class AddError(OhbinError):
49
+ """Raised when a release yields no usable assets, or pyproject can't be located."""
50
+
51
+
52
+ def _is_candidate(name: str) -> bool:
53
+ low = name.lower()
54
+ if low.endswith(_DENY_SUFFIXES):
55
+ return False
56
+ return not ("checksum" in low or "sbom" in low)
57
+
58
+
59
+ def _archive_rank(name: str) -> int:
60
+ low = name.lower()
61
+ if low.endswith((".tar.gz", ".tgz")):
62
+ return 0
63
+ if low.endswith((".tar.xz", ".tar.bz2", ".tar")):
64
+ return 1
65
+ if low.endswith(".zip"):
66
+ return 2
67
+ return 3 # bare binary
68
+
69
+
70
+ def _has_token(name: str, tokens: tuple[str, ...]) -> bool:
71
+ low = name.lower()
72
+ return any(token in low for token in tokens)
73
+
74
+
75
+ def match_asset(assets: list[Asset], plat: Platform) -> Asset | None:
76
+ candidates = [a for a in assets if _is_candidate(a.name)]
77
+ primary = [a for a in candidates if _has_token(a.name, os_tokens(plat)) and _has_token(a.name, arch_tokens(plat))]
78
+ if not primary:
79
+ # Universal asset: matches the OS but carries no arch token at all
80
+ # (e.g. a `*_darwin_universal.tar.gz` serves both darwin arches).
81
+ primary = [
82
+ a for a in candidates if _has_token(a.name, os_tokens(plat)) and not _has_token(a.name, ALL_ARCH_TOKENS)
83
+ ]
84
+ if not primary:
85
+ return None
86
+ primary.sort(key=lambda a: (_archive_rank(a.name), len(a.name)))
87
+ return primary[0]
88
+
89
+
90
+ def _digest_sha256(asset: Asset) -> str | None:
91
+ if asset.digest and asset.digest.startswith("sha256:"):
92
+ return asset.digest.removeprefix("sha256:")
93
+ return None
94
+
95
+
96
+ def local_pyproject(explicit: Path | None) -> Path:
97
+ """Resolve the pyproject to *write* to: an explicit `--pyproject-file`, else ./pyproject.toml.
98
+
99
+ Mutating commands never walk up the tree — that would let `ohbin add` from a
100
+ subdirectory silently edit a parent project's pyproject. Reading still discovers
101
+ upward (see `find_pyproject`); writing stays local and explicit.
102
+ """
103
+ if explicit is not None:
104
+ return explicit
105
+ local = Path("pyproject.toml")
106
+ if not local.is_file():
107
+ msg = "no pyproject.toml in the current directory (pass --pyproject-file to target one)"
108
+ raise AddError(msg)
109
+ return local
110
+
111
+
112
+ def resolve_tool(*, repo: str, version: str | None, binary: str | None) -> ToolConfig:
113
+ """Fetch the release and build a ToolConfig with per-platform assets + checksums."""
114
+ release = fetch_release(repo, version)
115
+ cmd_name = repo.split("/")[-1]
116
+ bin_name = binary or cmd_name
117
+
118
+ assets: dict[str, AssetEntry] = {}
119
+ for plat in TARGET_PLATFORMS:
120
+ asset = match_asset(release.assets, plat)
121
+ if asset is None:
122
+ print(f" ! {plat.key:15} no matching asset — skipped", file=sys.stderr)
123
+ continue
124
+ sha256 = _digest_sha256(asset)
125
+ source = "digest" if sha256 else "downloaded+hashed"
126
+ if sha256 is None:
127
+ sha256 = sha256_of_url(asset.url)
128
+ assets[plat.key] = AssetEntry(url=asset.url, sha256=sha256)
129
+ print(f" + {plat.key:15} {asset.name} ({source})")
130
+
131
+ if not assets:
132
+ msg = f"no matching assets found in {repo}@{release.tag}"
133
+ raise AddError(msg)
134
+
135
+ return ToolConfig(
136
+ repo=repo,
137
+ version=release.tag.removeprefix("v"),
138
+ binary=bin_name,
139
+ assets=assets,
140
+ )
141
+
142
+
143
+ def _deepest_last_table(table: Table) -> Table:
144
+ """Descend through the last sub-table at each level to the innermost one.
145
+
146
+ Standalone comments/blank lines between the end of a tool's last sub-table and
147
+ the next section header are parsed by tomlkit into *that* innermost sub-table's
148
+ body — so this is where trailing trivia lives and must be re-attached.
149
+ """
150
+ from tomlkit.items import Table
151
+
152
+ last_sub: Table | None = None
153
+ for _key, item in table.value.body:
154
+ if isinstance(item, Table):
155
+ last_sub = item
156
+ if last_sub is None:
157
+ return table
158
+ return _deepest_last_table(last_sub)
159
+
160
+
161
+ def _detach_trailing_trivia(table: Table) -> list[BodyEntry]:
162
+ """Pop the trailing run of standalone whitespace/comments from a tool's last table.
163
+
164
+ Returns them in document order; an empty list when the table ends on a real key.
165
+ These belong to the *following* section (a comment block before the next header),
166
+ not the tool — overwriting the tool entry must not eat them.
167
+ """
168
+ from tomlkit.items import Comment, Whitespace
169
+
170
+ body = _deepest_last_table(table).value.body
171
+ trailing: list[BodyEntry] = []
172
+ while body and body[-1][0] is None and isinstance(body[-1][1], (Whitespace, Comment)):
173
+ trailing.insert(0, body.pop())
174
+ return trailing
175
+
176
+
177
+ def write_tool(pyproject: Path, name: str, cfg: ToolConfig) -> None:
178
+ """Write/overwrite `[tool.ohbin.tools.<name>]`, preserving the rest of the file."""
179
+ import tomlkit
180
+ from tomlkit import TOMLDocument
181
+ from tomlkit.items import Table
182
+
183
+ doc = tomlkit.parse(pyproject.read_text())
184
+
185
+ def ensure(parent: TOMLDocument | Table, key: str, *, super_table: bool) -> Table:
186
+ existing = parent.get(key)
187
+ if isinstance(existing, Table):
188
+ return existing
189
+ created = tomlkit.table(is_super_table=super_table)
190
+ parent[key] = created
191
+ return created
192
+
193
+ tools = ensure(
194
+ ensure(ensure(doc, "tool", super_table=True), "ohbin", super_table=True),
195
+ "tools",
196
+ super_table=True,
197
+ )
198
+
199
+ # A comment block before the next top-level section is parsed into the
200
+ # innermost body of whichever tool is physically last in `[tool.ohbin.tools]`.
201
+ # Two cases must both preserve it:
202
+ # - overwriting a tool: its own trailing trivia goes with the rebuilt entry;
203
+ # - appending a new tool: tomlkit puts the new table *after* that trivia,
204
+ # stranding the comment above the new entry and dropping the separator —
205
+ # so detach it from the current last tool and move it past the new one.
206
+ old_entry = tools.get(name)
207
+ own_trailing = _detach_trailing_trivia(old_entry) if isinstance(old_entry, Table) else []
208
+ appending = not isinstance(old_entry, Table)
209
+ next_section_trivia: list[BodyEntry] = []
210
+ if appending and _deepest_last_table(tools) is not tools:
211
+ next_section_trivia = _detach_trailing_trivia(tools)
212
+
213
+ entry = tomlkit.table()
214
+ entry["repo"] = cfg["repo"]
215
+ entry["version"] = cfg["version"]
216
+ entry["binary"] = cfg["binary"]
217
+ if cfg.get("encrypted"):
218
+ entry["encrypted"] = True
219
+ if "password" in cfg:
220
+ entry["password"] = cfg["password"]
221
+ if cfg.get("password_committed_ok"):
222
+ entry["password_committed_ok"] = True
223
+
224
+ assets = tomlkit.table(is_super_table=True)
225
+ for plat_key, asset in cfg["assets"].items():
226
+ asset_table = tomlkit.table()
227
+ asset_table["url"] = asset["url"]
228
+ asset_table["sha256"] = asset["sha256"]
229
+ if "binary_sha256" in asset:
230
+ asset_table["binary_sha256"] = asset["binary_sha256"]
231
+ assets[plat_key] = asset_table
232
+ entry["assets"] = assets
233
+
234
+ tools[name] = entry
235
+ if own_trailing:
236
+ _deepest_last_table(entry).value.body.extend(own_trailing)
237
+ if next_section_trivia:
238
+ _deepest_last_table(entry).value.body.extend(next_section_trivia)
239
+ pyproject.write_text(tomlkit.dumps(doc))
240
+
241
+
242
+ def add_tool(
243
+ *,
244
+ repo: str,
245
+ version: str | None,
246
+ name: str | None,
247
+ binary: str | None,
248
+ pyproject: Path | None,
249
+ ) -> tuple[str, Path]:
250
+ cfg = resolve_tool(repo=repo, version=version, binary=binary)
251
+ cmd_name = name or repo.split("/")[-1]
252
+ target = local_pyproject(pyproject)
253
+ write_tool(target, cmd_name, cfg)
254
+ return cmd_name, target
ohbin/_crypto.py ADDED
@@ -0,0 +1,95 @@
1
+ """Password-based encryption of release binaries via the system `openssl` CLI.
2
+
3
+ A published blob is `gzip(raw binary)` → AES-256-CBC (PBKDF2 key derivation) →
4
+ base64 text, so it fits inside a (text-only) GitHub gist. ohbin is the only thing
5
+ that decrypts it, so a leaked gist link is useless without the password — which
6
+ stays out of the gist (a private repo, or `--password`).
7
+
8
+ We shell out to `openssl` instead of taking a Python crypto dependency, keeping the
9
+ `run` hot path free of pip deps. The password is handed to openssl over an inherited
10
+ pipe FD (`-pass fd:N`), never on argv, so it can't leak via the process table.
11
+
12
+ CBC is unauthenticated; integrity is enforced one layer up by pinning the decrypted
13
+ binary's SHA256 in the manifest (`binary_sha256`), which also catches a wrong
14
+ password — a bad key almost always yields invalid PKCS#7 padding (openssl errors) or
15
+ garbage that fails the gzip/SHA check.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import base64
21
+ import gzip
22
+ import os
23
+ import shutil
24
+ import subprocess
25
+
26
+ from ohbin._errors import OhbinError
27
+
28
+ # LibreSSL (macOS) and OpenSSL agree on this fully-pinned recipe; -pbkdf2 + an
29
+ # explicit digest/iter count keeps producer and consumer interoperable.
30
+ _ITER = "200000"
31
+ _ENC_ARGS = ("enc", "-aes-256-cbc", "-pbkdf2", "-md", "sha256", "-iter", _ITER, "-salt")
32
+
33
+
34
+ class OpensslMissingError(OhbinError):
35
+ """Raised when the `openssl` CLI isn't on PATH (required for encrypted tools)."""
36
+
37
+
38
+ class DecryptError(OhbinError):
39
+ """Raised when decryption fails — almost always a wrong password or a corrupt blob."""
40
+
41
+
42
+ def _openssl() -> str:
43
+ path = shutil.which("openssl")
44
+ if path is None:
45
+ msg = "openssl not found on PATH — required to (de)crypt an encrypted ohbin tool"
46
+ raise OpensslMissingError(msg)
47
+ return path
48
+
49
+
50
+ def _run(extra: tuple[str, ...], stdin: bytes, password: str) -> bytes:
51
+ """Run `openssl enc [...] -pass fd:N` feeding the password over an inherited pipe."""
52
+ read_fd, write_fd = os.pipe()
53
+ try:
54
+ os.write(write_fd, password.encode()) # passwords are tiny — never blocks the buffer
55
+ os.close(write_fd)
56
+ # pass_fds keeps read_fd open + inheritable in the child; argv carries no secret.
57
+ proc = subprocess.run(
58
+ [_openssl(), *_ENC_ARGS, *extra, "-pass", f"fd:{read_fd}"],
59
+ input=stdin,
60
+ capture_output=True,
61
+ pass_fds=(read_fd,),
62
+ check=False,
63
+ )
64
+ finally:
65
+ os.close(read_fd)
66
+ if proc.returncode != 0:
67
+ detail = proc.stderr.decode(errors="replace").strip()
68
+ raise DecryptError(detail or "openssl failed")
69
+ return proc.stdout
70
+
71
+
72
+ def encrypt_binary(binary: bytes, password: str) -> str:
73
+ """`gzip(binary)` → AES-256-CBC → base64. Returns gist-ready text."""
74
+ ciphertext = _run((), gzip.compress(binary), password)
75
+ return base64.b64encode(ciphertext).decode()
76
+
77
+
78
+ def decrypt_binary(blob_b64: str, password: str) -> bytes:
79
+ """Reverse `encrypt_binary`: base64-decode → openssl -d → gunzip → raw binary."""
80
+ try:
81
+ ciphertext = base64.b64decode(blob_b64, validate=True) # binascii.Error <: ValueError
82
+ except ValueError as exc:
83
+ msg = "encrypted blob is not valid base64"
84
+ raise DecryptError(msg) from exc
85
+ try:
86
+ plaintext = _run(("-d",), ciphertext, password)
87
+ except DecryptError as exc:
88
+ # openssl's raw "bad decrypt" + libressl file path is noise — say what it means.
89
+ msg = "decryption failed — wrong password or corrupt blob"
90
+ raise DecryptError(msg) from exc
91
+ try:
92
+ return gzip.decompress(plaintext) # BadGzipFile <: OSError
93
+ except (OSError, EOFError) as exc:
94
+ msg = "decryption produced invalid data — wrong password or corrupt blob"
95
+ raise DecryptError(msg) from exc