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/_engine.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Download + SHA256-verify + cache a release binary on first use.
|
|
2
|
+
|
|
3
|
+
Cache layout (`$XDG_CACHE_HOME` or `~/.cache`):
|
|
4
|
+
|
|
5
|
+
ohbin/<tool>/<version>/<binary> ← extracted, chmod +x binary
|
|
6
|
+
ohbin/<tool>/<version>/.lock ← flock during install
|
|
7
|
+
|
|
8
|
+
Concurrent invocations on the same host coordinate via flock — first one
|
|
9
|
+
downloads, the rest wait and reuse the same binary. POSIX-only (fcntl); a
|
|
10
|
+
Windows path isn't shipped until someone needs it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import fcntl
|
|
16
|
+
import hashlib
|
|
17
|
+
import http.client
|
|
18
|
+
import os
|
|
19
|
+
import shutil
|
|
20
|
+
import tarfile
|
|
21
|
+
import tempfile
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.request
|
|
24
|
+
import zipfile
|
|
25
|
+
from contextlib import contextmanager
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import IO, TYPE_CHECKING
|
|
28
|
+
from urllib.parse import unquote, urlparse
|
|
29
|
+
|
|
30
|
+
from ohbin._crypto import decrypt_binary
|
|
31
|
+
from ohbin._errors import OhbinError
|
|
32
|
+
from ohbin._retry import retry, retryable_http_status
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from collections.abc import Iterator
|
|
36
|
+
|
|
37
|
+
_DOWNLOAD_TIMEOUT = 60 # seconds per read/connect attempt
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ChecksumMismatchError(OhbinError):
|
|
41
|
+
"""Raised when a downloaded asset's SHA256 doesn't match the pinned value."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BinaryNotFoundError(OhbinError):
|
|
45
|
+
"""Raised when the expected binary isn't present inside a downloaded archive."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MissingPasswordError(OhbinError):
|
|
49
|
+
"""Raised when an encrypted tool is run without a password from --password or pyproject."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _TransientDownloadError(RuntimeError):
|
|
53
|
+
"""Internal: a retryable download failure (network / timeout / truncated stream)."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cache_root(tool: str, version: str) -> Path:
|
|
57
|
+
xdg = os.environ.get("XDG_CACHE_HOME")
|
|
58
|
+
base = Path(xdg) if xdg else Path.home() / ".cache"
|
|
59
|
+
return base / "ohbin" / tool / version
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@contextmanager
|
|
63
|
+
def _exclusive_lock(lock_path: Path) -> Iterator[None]:
|
|
64
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR, 0o644)
|
|
66
|
+
try:
|
|
67
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
68
|
+
yield
|
|
69
|
+
finally:
|
|
70
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
71
|
+
os.close(fd)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _sha256_of(stream: IO[bytes]) -> str:
|
|
75
|
+
h = hashlib.sha256()
|
|
76
|
+
for chunk in iter(lambda: stream.read(1 << 20), b""):
|
|
77
|
+
h.update(chunk)
|
|
78
|
+
return h.hexdigest()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _download_once(url: str, dest: Path) -> None:
|
|
82
|
+
# Release URLs redirect to a signed CDN host; urllib follows redirects by
|
|
83
|
+
# default. Stream to disk so we don't buffer the archive in RAM.
|
|
84
|
+
request = urllib.request.Request(url, headers={"User-Agent": "ohbin"})
|
|
85
|
+
try:
|
|
86
|
+
with (
|
|
87
|
+
urllib.request.urlopen(request, timeout=_DOWNLOAD_TIMEOUT) as response,
|
|
88
|
+
dest.open("wb") as out,
|
|
89
|
+
):
|
|
90
|
+
shutil.copyfileobj(response, out)
|
|
91
|
+
except urllib.error.HTTPError as exc: # check first — subclass of URLError/OSError
|
|
92
|
+
if retryable_http_status(exc.code):
|
|
93
|
+
raise _TransientDownloadError(f"download {url} → HTTP {exc.code}") from exc
|
|
94
|
+
raise
|
|
95
|
+
except (OSError, http.client.IncompleteRead) as exc:
|
|
96
|
+
# URLError, ConnectionError, timeout, or a truncated/reset stream.
|
|
97
|
+
raise _TransientDownloadError(f"download {url} failed: {exc}") from exc
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _download(url: str, dest: Path) -> None:
|
|
101
|
+
retry(lambda: _download_once(url, dest), op="download", retry_on=(_TransientDownloadError,))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def sha256_of_url(url: str) -> str:
|
|
105
|
+
"""Download a URL to a temp file and return its SHA256 (used by `add`)."""
|
|
106
|
+
fd, name = tempfile.mkstemp(prefix="ohbin-")
|
|
107
|
+
os.close(fd)
|
|
108
|
+
tmp = Path(name)
|
|
109
|
+
try:
|
|
110
|
+
_download(url, tmp)
|
|
111
|
+
with tmp.open("rb") as fh:
|
|
112
|
+
return _sha256_of(fh)
|
|
113
|
+
finally:
|
|
114
|
+
tmp.unlink(missing_ok=True)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def text_of_url(url: str) -> str:
|
|
118
|
+
"""Download a URL to a temp file and return its decoded text.
|
|
119
|
+
|
|
120
|
+
Used to read a gist file (e.g. the `ohbin.json` index) from its `raw_url` when
|
|
121
|
+
GitHub truncates the inline `content` returned by the gists API.
|
|
122
|
+
"""
|
|
123
|
+
fd, name = tempfile.mkstemp(prefix="ohbin-")
|
|
124
|
+
os.close(fd)
|
|
125
|
+
tmp = Path(name)
|
|
126
|
+
try:
|
|
127
|
+
_download(url, tmp)
|
|
128
|
+
return tmp.read_text()
|
|
129
|
+
finally:
|
|
130
|
+
tmp.unlink(missing_ok=True)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _verify(path: Path, expected_sha256: str) -> None:
|
|
134
|
+
with path.open("rb") as fh:
|
|
135
|
+
actual = _sha256_of(fh)
|
|
136
|
+
if actual != expected_sha256:
|
|
137
|
+
msg = f"checksum mismatch for {path.name}: expected {expected_sha256}, got {actual}"
|
|
138
|
+
raise ChecksumMismatchError(msg)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _url_filename(url: str) -> str:
|
|
142
|
+
name = Path(unquote(urlparse(url).path)).name
|
|
143
|
+
return name or "download"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _finalize(staged: Path, dest: Path) -> None:
|
|
147
|
+
staged.chmod(0o755)
|
|
148
|
+
staged.replace(dest) # atomic within the same filesystem
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _extract_tar(archive: Path, binary: str, dest: Path) -> None:
|
|
152
|
+
with tarfile.open(archive, "r:*") as tf: # r:* auto-detects gz/xz/bz2/plain
|
|
153
|
+
member = next(
|
|
154
|
+
(m for m in tf.getmembers() if m.isfile() and Path(m.name).name == binary),
|
|
155
|
+
None,
|
|
156
|
+
)
|
|
157
|
+
if member is None:
|
|
158
|
+
msg = f"binary {binary!r} not found in archive {archive.name}"
|
|
159
|
+
raise BinaryNotFoundError(msg)
|
|
160
|
+
with tempfile.TemporaryDirectory(dir=str(dest.parent)) as staging:
|
|
161
|
+
tf.extract(member, path=staging, filter="data")
|
|
162
|
+
_finalize(Path(staging) / member.name, dest)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _extract_zip(archive: Path, binary: str, dest: Path) -> None:
|
|
166
|
+
with zipfile.ZipFile(archive) as zf:
|
|
167
|
+
member = next(
|
|
168
|
+
(n for n in zf.namelist() if not n.endswith("/") and Path(n).name == binary),
|
|
169
|
+
None,
|
|
170
|
+
)
|
|
171
|
+
if member is None:
|
|
172
|
+
msg = f"binary {binary!r} not found in archive {archive.name}"
|
|
173
|
+
raise BinaryNotFoundError(msg)
|
|
174
|
+
with tempfile.TemporaryDirectory(dir=str(dest.parent)) as staging:
|
|
175
|
+
zf.extract(member, path=staging)
|
|
176
|
+
_finalize(Path(staging) / member, dest)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _place_raw(archive: Path, dest: Path) -> None:
|
|
180
|
+
# Asset is the bare executable (no archive wrapper).
|
|
181
|
+
with tempfile.TemporaryDirectory(dir=str(dest.parent)) as staging:
|
|
182
|
+
staged = Path(staging) / dest.name
|
|
183
|
+
shutil.copyfile(archive, staged)
|
|
184
|
+
_finalize(staged, dest)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _decrypt_into(archive: Path, dest: Path, *, password: str | None, binary_sha256: str | None) -> None:
|
|
188
|
+
# The downloaded file is base64 text (gzip+AES blob); decrypt to the raw binary.
|
|
189
|
+
if not password:
|
|
190
|
+
msg = "encrypted tool requires a password (pass --password or set 'password' in pyproject)"
|
|
191
|
+
raise MissingPasswordError(msg)
|
|
192
|
+
binary = decrypt_binary(archive.read_text(), password)
|
|
193
|
+
if binary_sha256 is not None:
|
|
194
|
+
actual = hashlib.sha256(binary).hexdigest()
|
|
195
|
+
if actual != binary_sha256:
|
|
196
|
+
msg = (
|
|
197
|
+
f"decrypted binary checksum mismatch: expected {binary_sha256}, got {actual} "
|
|
198
|
+
"(wrong password or corrupt blob)"
|
|
199
|
+
)
|
|
200
|
+
raise ChecksumMismatchError(msg)
|
|
201
|
+
with tempfile.TemporaryDirectory(dir=str(dest.parent)) as staging:
|
|
202
|
+
staged = Path(staging) / dest.name
|
|
203
|
+
staged.write_bytes(binary)
|
|
204
|
+
_finalize(staged, dest)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _extract(archive: Path, binary: str, dest: Path) -> None:
|
|
208
|
+
name = archive.name.lower()
|
|
209
|
+
if name.endswith((".tar.gz", ".tgz", ".tar.xz", ".tar.bz2", ".tar")):
|
|
210
|
+
_extract_tar(archive, binary, dest)
|
|
211
|
+
elif name.endswith(".zip"):
|
|
212
|
+
_extract_zip(archive, binary, dest)
|
|
213
|
+
else:
|
|
214
|
+
_place_raw(archive, dest)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def ensure_from(
|
|
218
|
+
*,
|
|
219
|
+
tool: str,
|
|
220
|
+
version: str,
|
|
221
|
+
binary: str,
|
|
222
|
+
url: str,
|
|
223
|
+
sha256: str,
|
|
224
|
+
encrypted: bool = False,
|
|
225
|
+
password: str | None = None,
|
|
226
|
+
binary_sha256: str | None = None,
|
|
227
|
+
) -> Path:
|
|
228
|
+
"""Return the cached binary path, downloading + verifying it on first use.
|
|
229
|
+
|
|
230
|
+
For encrypted tools the downloaded file is a base64 gzip+AES blob: `sha256`
|
|
231
|
+
verifies the ciphertext (tamper-evidence on the gist), then it's decrypted with
|
|
232
|
+
`password` and the plaintext checked against `binary_sha256`. The decrypted
|
|
233
|
+
binary is cached like any other — first run decrypts, the rest reuse it.
|
|
234
|
+
"""
|
|
235
|
+
cache = cache_root(tool, version)
|
|
236
|
+
target = cache / binary
|
|
237
|
+
if target.is_file() and os.access(target, os.X_OK):
|
|
238
|
+
return target
|
|
239
|
+
|
|
240
|
+
with _exclusive_lock(cache / ".lock"):
|
|
241
|
+
# Another process may have finished while we waited on the lock.
|
|
242
|
+
if target.is_file() and os.access(target, os.X_OK):
|
|
243
|
+
return target
|
|
244
|
+
|
|
245
|
+
archive = cache / _url_filename(url)
|
|
246
|
+
try:
|
|
247
|
+
_download(url, archive)
|
|
248
|
+
_verify(archive, sha256)
|
|
249
|
+
if encrypted:
|
|
250
|
+
_decrypt_into(archive, target, password=password, binary_sha256=binary_sha256)
|
|
251
|
+
else:
|
|
252
|
+
_extract(archive, binary, target)
|
|
253
|
+
finally:
|
|
254
|
+
archive.unlink(missing_ok=True)
|
|
255
|
+
|
|
256
|
+
return target
|
ohbin/_errors.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Common base for all expected, user-facing ohbin failures.
|
|
2
|
+
|
|
3
|
+
The CLI catches `OhbinError` and prints `error: <message>` without a stack trace;
|
|
4
|
+
anything else propagates as a real (unexpected) crash. Library callers can likewise
|
|
5
|
+
`except ohbin.OhbinError` to handle every anticipated failure in one place.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OhbinError(RuntimeError):
|
|
12
|
+
"""Base class for anticipated ohbin failures (bad manifest, checksum, decrypt, …)."""
|
ohbin/_gist.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""`ohbin publish-gist` / `add-gist`: ship encrypted private binaries through gists.
|
|
2
|
+
|
|
3
|
+
A gist holds one base64'd `gzip+AES` blob per platform (`<name>-<platform>.enc.b64`)
|
|
4
|
+
plus an `ohbin.json` index that maps each platform to its file and the *decrypted*
|
|
5
|
+
binary's SHA256. `publish-gist` produces and uploads them; `add-gist` reads the index,
|
|
6
|
+
resolves each file's immutable raw URL, pins the ciphertext SHA, and writes the tool
|
|
7
|
+
into pyproject. Gists are secret (link-gated), the binary is encrypted, and the
|
|
8
|
+
password lives elsewhere — a leaked link alone is useless.
|
|
9
|
+
|
|
10
|
+
All GitHub access goes through the `gh` CLI (`gh api`), reusing the user's auth.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import TypedDict
|
|
21
|
+
from urllib.parse import urlparse
|
|
22
|
+
|
|
23
|
+
from ohbin._add import local_pyproject, write_tool
|
|
24
|
+
from ohbin._crypto import encrypt_binary
|
|
25
|
+
from ohbin._engine import sha256_of_url, text_of_url
|
|
26
|
+
from ohbin._errors import OhbinError
|
|
27
|
+
from ohbin._platform import current_platform
|
|
28
|
+
from ohbin._types import AssetEntry, ToolConfig
|
|
29
|
+
|
|
30
|
+
_OHBIN_INDEX = "ohbin.json"
|
|
31
|
+
_INDEX_VERSION = 1
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class GistAssetInfo(TypedDict):
|
|
35
|
+
"""Per-platform entry in a gist's `ohbin.json` index."""
|
|
36
|
+
|
|
37
|
+
file: str
|
|
38
|
+
binary_sha256: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class GistMeta(TypedDict):
|
|
42
|
+
"""The `ohbin.json` index stored alongside the encrypted blobs in a gist."""
|
|
43
|
+
|
|
44
|
+
ohbin_gist: int
|
|
45
|
+
name: str
|
|
46
|
+
binary: str
|
|
47
|
+
encrypted: bool
|
|
48
|
+
assets: dict[str, GistAssetInfo]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class GistError(OhbinError):
|
|
52
|
+
"""Raised when a gist can't be published or resolved (gh failure, malformed index)."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _require_gh() -> str:
|
|
56
|
+
path = shutil.which("gh")
|
|
57
|
+
if path is None:
|
|
58
|
+
msg = "gh CLI not found on PATH — required to publish/resolve gists"
|
|
59
|
+
raise GistError(msg)
|
|
60
|
+
return path
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _gh_api(method: str, path: str, body: dict[str, object] | None = None) -> dict[str, object]:
|
|
64
|
+
cmd = [_require_gh(), "api", "-X", method, path]
|
|
65
|
+
payload: bytes | None = None
|
|
66
|
+
if body is not None:
|
|
67
|
+
cmd += ["--input", "-"]
|
|
68
|
+
payload = json.dumps(body).encode()
|
|
69
|
+
proc = subprocess.run(cmd, input=payload, capture_output=True, check=False)
|
|
70
|
+
if proc.returncode != 0:
|
|
71
|
+
detail = proc.stderr.decode(errors="replace").strip()
|
|
72
|
+
msg = f"gh api {method} {path} failed: {detail or 'unknown error'}"
|
|
73
|
+
raise GistError(msg)
|
|
74
|
+
parsed: dict[str, object] = json.loads(proc.stdout)
|
|
75
|
+
return parsed
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _parse_gist_id(ref: str) -> str:
|
|
79
|
+
ref = ref.strip()
|
|
80
|
+
if "/" not in ref and ":" not in ref:
|
|
81
|
+
return ref # already a bare id
|
|
82
|
+
parts = [p for p in urlparse(ref).path.split("/") if p]
|
|
83
|
+
if "raw" in parts: # gist raw URL: .../<user>/<id>/raw/<sha>/<file>
|
|
84
|
+
parts = parts[: parts.index("raw")]
|
|
85
|
+
if not parts:
|
|
86
|
+
msg = f"cannot parse a gist id from {ref!r}"
|
|
87
|
+
raise GistError(msg)
|
|
88
|
+
return parts[-1]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _file_entry(files: dict[str, object], name: str) -> dict[str, object]:
|
|
92
|
+
entry = files.get(name)
|
|
93
|
+
if not isinstance(entry, dict):
|
|
94
|
+
msg = f"gist is missing file {name!r}"
|
|
95
|
+
raise GistError(msg)
|
|
96
|
+
return entry
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _load_index(files: dict[str, object]) -> GistMeta:
|
|
100
|
+
entry = _file_entry(files, _OHBIN_INDEX)
|
|
101
|
+
raw = entry.get("content")
|
|
102
|
+
# GitHub truncates the inline `content` of *every* file in the gists API once
|
|
103
|
+
# any single file exceeds ~1MB — so a tiny index sitting next to a multi-MB
|
|
104
|
+
# encrypted blob comes back empty (`truncated: true`, `content: ""`). Fall back
|
|
105
|
+
# to the file's raw_url, which always serves the complete bytes.
|
|
106
|
+
if entry.get("truncated") or not isinstance(raw, str) or not raw:
|
|
107
|
+
raw_url = entry.get("raw_url")
|
|
108
|
+
if not isinstance(raw_url, str):
|
|
109
|
+
msg = f"{_OHBIN_INDEX} content is truncated and has no raw_url to fall back to"
|
|
110
|
+
raise GistError(msg)
|
|
111
|
+
raw = text_of_url(raw_url)
|
|
112
|
+
meta: GistMeta = json.loads(raw)
|
|
113
|
+
return meta
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def publish_gist(
|
|
117
|
+
*,
|
|
118
|
+
name: str,
|
|
119
|
+
binary: str,
|
|
120
|
+
binary_path: Path,
|
|
121
|
+
password: str,
|
|
122
|
+
platform_key: str | None,
|
|
123
|
+
gist_ref: str | None,
|
|
124
|
+
description: str,
|
|
125
|
+
) -> str:
|
|
126
|
+
"""Encrypt `binary_path` for one platform and POST/PATCH it into a secret gist.
|
|
127
|
+
|
|
128
|
+
With `gist_ref` the blob is added to an existing gist (another platform for the
|
|
129
|
+
same tool); otherwise a fresh secret gist is created. Returns the gist's html_url.
|
|
130
|
+
"""
|
|
131
|
+
plat = platform_key or current_platform().key
|
|
132
|
+
data = binary_path.read_bytes()
|
|
133
|
+
blob = encrypt_binary(data, password)
|
|
134
|
+
fname = f"{name}-{plat}.enc.b64"
|
|
135
|
+
|
|
136
|
+
if gist_ref is not None:
|
|
137
|
+
gid = _parse_gist_id(gist_ref)
|
|
138
|
+
existing = _gh_api("GET", f"gists/{gid}")
|
|
139
|
+
files = existing.get("files")
|
|
140
|
+
meta = _load_index(files) if isinstance(files, dict) else _new_index(name, binary)
|
|
141
|
+
else:
|
|
142
|
+
meta = _new_index(name, binary)
|
|
143
|
+
|
|
144
|
+
meta["assets"][plat] = {"file": fname, "binary_sha256": hashlib.sha256(data).hexdigest()}
|
|
145
|
+
body: dict[str, object] = {
|
|
146
|
+
"public": False,
|
|
147
|
+
"files": {
|
|
148
|
+
fname: {"content": blob},
|
|
149
|
+
_OHBIN_INDEX: {"content": json.dumps(meta, indent=2, sort_keys=True)},
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
if gist_ref is not None:
|
|
153
|
+
result = _gh_api("PATCH", f"gists/{_parse_gist_id(gist_ref)}", body)
|
|
154
|
+
else:
|
|
155
|
+
body["description"] = description
|
|
156
|
+
result = _gh_api("POST", "gists", body)
|
|
157
|
+
|
|
158
|
+
html_url = result.get("html_url")
|
|
159
|
+
if not isinstance(html_url, str):
|
|
160
|
+
msg = "gist created but GitHub returned no html_url"
|
|
161
|
+
raise GistError(msg)
|
|
162
|
+
return html_url
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _new_index(name: str, binary: str) -> GistMeta:
|
|
166
|
+
return {
|
|
167
|
+
"ohbin_gist": _INDEX_VERSION,
|
|
168
|
+
"name": name,
|
|
169
|
+
"binary": binary,
|
|
170
|
+
"encrypted": True,
|
|
171
|
+
"assets": {},
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _snapshot(gist: dict[str, object], gid: str) -> str:
|
|
176
|
+
"""Short id for the gist's current revision — used as the cache-busting version."""
|
|
177
|
+
history = gist.get("history")
|
|
178
|
+
if isinstance(history, list) and history:
|
|
179
|
+
first = history[0]
|
|
180
|
+
if isinstance(first, dict):
|
|
181
|
+
version = first.get("version")
|
|
182
|
+
if isinstance(version, str):
|
|
183
|
+
return version[:12]
|
|
184
|
+
return gid[:12]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def resolve_gist(gist_ref: str, *, password: str | None = None) -> tuple[str, ToolConfig]:
|
|
188
|
+
"""Read a gist's `ohbin.json` index and build a ToolConfig (pins ciphertext SHAs)."""
|
|
189
|
+
gid = _parse_gist_id(gist_ref)
|
|
190
|
+
gist = _gh_api("GET", f"gists/{gid}")
|
|
191
|
+
files = gist.get("files")
|
|
192
|
+
if not isinstance(files, dict):
|
|
193
|
+
msg = f"gist {gid} has no files"
|
|
194
|
+
raise GistError(msg)
|
|
195
|
+
meta = _load_index(files)
|
|
196
|
+
|
|
197
|
+
assets: dict[str, AssetEntry] = {}
|
|
198
|
+
for plat, info in meta["assets"].items():
|
|
199
|
+
raw_url = _file_entry(files, info["file"]).get("raw_url")
|
|
200
|
+
if not isinstance(raw_url, str):
|
|
201
|
+
msg = f"gist file {info['file']!r} has no raw_url"
|
|
202
|
+
raise GistError(msg)
|
|
203
|
+
# Hash the bytes GitHub actually serves (immune to any newline normalisation).
|
|
204
|
+
assets[plat] = AssetEntry(
|
|
205
|
+
url=raw_url,
|
|
206
|
+
sha256=sha256_of_url(raw_url),
|
|
207
|
+
binary_sha256=info["binary_sha256"],
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
cfg = ToolConfig(
|
|
211
|
+
repo=f"gist:{gid}",
|
|
212
|
+
version=f"gist-{_snapshot(gist, gid)}",
|
|
213
|
+
binary=meta["binary"],
|
|
214
|
+
assets=assets,
|
|
215
|
+
encrypted=True,
|
|
216
|
+
)
|
|
217
|
+
if password is not None:
|
|
218
|
+
cfg["password"] = password
|
|
219
|
+
return meta["name"], cfg
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def add_gist_tool(
|
|
223
|
+
*,
|
|
224
|
+
gist_ref: str,
|
|
225
|
+
name: str | None,
|
|
226
|
+
password: str | None,
|
|
227
|
+
pyproject: Path | None,
|
|
228
|
+
) -> tuple[str, Path]:
|
|
229
|
+
"""Resolve a gist and write its (encrypted) tool entry into pyproject."""
|
|
230
|
+
resolved_name, cfg = resolve_gist(gist_ref, password=password)
|
|
231
|
+
cmd_name = name or resolved_name
|
|
232
|
+
target = local_pyproject(pyproject)
|
|
233
|
+
write_tool(target, cmd_name, cfg)
|
|
234
|
+
return cmd_name, target
|
ohbin/_github.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Resolve a GitHub release + its assets — via the `gh` CLI when present, else the REST API.
|
|
2
|
+
|
|
3
|
+
Failures are split two ways: a clean **404** means "this tag doesn't exist"
|
|
4
|
+
(we try `v`-prefixed then bare, then give up — never retried), while anything
|
|
5
|
+
else — network error, timeout, rate-limit, 5xx, a non-404 `gh` failure — is
|
|
6
|
+
**transient** and retried with backoff. Conflating the two is exactly what made
|
|
7
|
+
a flaky network look like "release not found".
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.request
|
|
18
|
+
from typing import Any, NamedTuple
|
|
19
|
+
|
|
20
|
+
from ohbin._errors import OhbinError
|
|
21
|
+
from ohbin._retry import retry, retryable_http_status
|
|
22
|
+
|
|
23
|
+
_API = "https://api.github.com"
|
|
24
|
+
_TIMEOUT = 30 # seconds per attempt
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ReleaseNotFoundError(OhbinError):
|
|
28
|
+
"""Raised when no release matches the given repo/version (a real 404)."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _TransientError(RuntimeError):
|
|
32
|
+
"""Internal: a retryable GitHub fetch failure (network / timeout / 5xx / rate-limit / gh error)."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Asset(NamedTuple):
|
|
36
|
+
name: str
|
|
37
|
+
url: str
|
|
38
|
+
digest: str | None # GitHub asset digest like "sha256:..." (may be absent)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Release(NamedTuple):
|
|
42
|
+
tag: str
|
|
43
|
+
assets: list[Asset]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _candidate_paths(repo: str, version: str | None) -> list[str]:
|
|
47
|
+
if version is None:
|
|
48
|
+
return [f"repos/{repo}/releases/latest"]
|
|
49
|
+
# Accept both `v1.2.3` and `1.2.3` tag conventions.
|
|
50
|
+
return [
|
|
51
|
+
f"repos/{repo}/releases/tags/v{version}",
|
|
52
|
+
f"repos/{repo}/releases/tags/{version}",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _fetch_gh_one(path: str) -> dict[str, Any] | None:
|
|
57
|
+
"""Return parsed JSON, None on a clean 404, or raise `_TransientError` otherwise."""
|
|
58
|
+
try:
|
|
59
|
+
proc = subprocess.run( # fixed argv, `gh` resolved from PATH
|
|
60
|
+
["gh", "api", path],
|
|
61
|
+
capture_output=True,
|
|
62
|
+
text=True,
|
|
63
|
+
check=False,
|
|
64
|
+
timeout=_TIMEOUT,
|
|
65
|
+
)
|
|
66
|
+
except subprocess.TimeoutExpired as exc:
|
|
67
|
+
raise _TransientError(f"gh api {path} timed out after {_TIMEOUT}s") from exc
|
|
68
|
+
if proc.returncode == 0:
|
|
69
|
+
parsed: dict[str, Any] = json.loads(proc.stdout)
|
|
70
|
+
return parsed
|
|
71
|
+
stderr = proc.stderr.strip()
|
|
72
|
+
if "http 404" in stderr.lower() or "not found" in stderr.lower():
|
|
73
|
+
return None
|
|
74
|
+
raise _TransientError(f"gh api {path} failed: {stderr or 'unknown error'}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _fetch_http_one(path: str) -> dict[str, Any] | None:
|
|
78
|
+
headers = {
|
|
79
|
+
"Accept": "application/vnd.github+json",
|
|
80
|
+
"User-Agent": "ohbin",
|
|
81
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
82
|
+
}
|
|
83
|
+
token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
|
|
84
|
+
if token:
|
|
85
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
86
|
+
request = urllib.request.Request(f"{_API}/{path}", headers=headers) # fixed https host
|
|
87
|
+
try:
|
|
88
|
+
with urllib.request.urlopen(request, timeout=_TIMEOUT) as response:
|
|
89
|
+
parsed: dict[str, Any] = json.loads(response.read())
|
|
90
|
+
return parsed
|
|
91
|
+
except urllib.error.HTTPError as exc:
|
|
92
|
+
if exc.code == 404:
|
|
93
|
+
return None
|
|
94
|
+
if retryable_http_status(exc.code):
|
|
95
|
+
raise _TransientError(f"GET {path} → HTTP {exc.code}") from exc
|
|
96
|
+
raise
|
|
97
|
+
except urllib.error.URLError as exc: # network / DNS / timeout
|
|
98
|
+
raise _TransientError(f"GET {path} failed: {exc.reason}") from exc
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _fetch_raw(repo: str, version: str | None) -> dict[str, Any]:
|
|
102
|
+
fetch_one = _fetch_gh_one if shutil.which("gh") else _fetch_http_one
|
|
103
|
+
candidates = _candidate_paths(repo, version)
|
|
104
|
+
|
|
105
|
+
def attempt() -> dict[str, Any] | None:
|
|
106
|
+
for path in candidates:
|
|
107
|
+
data = fetch_one(path)
|
|
108
|
+
if data is not None:
|
|
109
|
+
return data
|
|
110
|
+
return None # every candidate was a clean 404 → genuinely not found
|
|
111
|
+
|
|
112
|
+
raw = retry(attempt, op=f"fetch release for {repo}", retry_on=(_TransientError,))
|
|
113
|
+
if raw is None:
|
|
114
|
+
msg = f"could not resolve a release for {repo} (tried: {', '.join(candidates)})"
|
|
115
|
+
raise ReleaseNotFoundError(msg)
|
|
116
|
+
return raw
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def fetch_release(repo: str, version: str | None) -> Release:
|
|
120
|
+
raw = _fetch_raw(repo, version)
|
|
121
|
+
assets = [
|
|
122
|
+
Asset(name=str(a["name"]), url=str(a["browser_download_url"]), digest=a.get("digest"))
|
|
123
|
+
for a in raw.get("assets", [])
|
|
124
|
+
]
|
|
125
|
+
return Release(tag=str(raw["tag_name"]), assets=assets)
|