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 +95 -0
- ohbin/__main__.py +10 -0
- ohbin/_add.py +254 -0
- ohbin/_crypto.py +95 -0
- ohbin/_engine.py +256 -0
- ohbin/_errors.py +12 -0
- ohbin/_gist.py +234 -0
- ohbin/_github.py +125 -0
- ohbin/_manifest.py +107 -0
- ohbin/_platform.py +83 -0
- ohbin/_retry.py +56 -0
- ohbin/_types.py +40 -0
- ohbin/cli.py +175 -0
- ohbin-0.2.2.dist-info/METADATA +161 -0
- ohbin-0.2.2.dist-info/RECORD +18 -0
- ohbin-0.2.2.dist-info/WHEEL +4 -0
- ohbin-0.2.2.dist-info/entry_points.txt +3 -0
- ohbin-0.2.2.dist-info/licenses/LICENSE +21 -0
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
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
|