aisc-cli 0.1.0__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.
- aisc/VERSION +1 -0
- aisc/__init__.py +49 -0
- aisc/__main__.py +6 -0
- aisc/adapters/__init__.py +1 -0
- aisc/adapters/config_reader.py +215 -0
- aisc/adapters/container_registry.py +718 -0
- aisc/adapters/data_root_store.py +296 -0
- aisc/adapters/docker_.py +1183 -0
- aisc/adapters/docker_gateway.py +1244 -0
- aisc/adapters/docker_sdk_backed.py +274 -0
- aisc/adapters/maintenance_lock.py +97 -0
- aisc/adapters/state_file.py +184 -0
- aisc/adapters/system.py +101 -0
- aisc/adapters/windows_config_reader.py +249 -0
- aisc/adapters/workspace_lease_store.py +189 -0
- aisc/application/__init__.py +1 -0
- aisc/application/artifact.py +265 -0
- aisc/application/bundle_fetch.py +836 -0
- aisc/application/cc_switch_provider.py +245 -0
- aisc/application/cc_switch_resolver.py +363 -0
- aisc/application/config_service.py +452 -0
- aisc/application/conversation.py +516 -0
- aisc/application/data_migration.py +440 -0
- aisc/application/data_root.py +275 -0
- aisc/application/docker_lifecycle.py +659 -0
- aisc/application/doctor.py +821 -0
- aisc/application/legacy_scan.py +252 -0
- aisc/application/network_subscription.py +642 -0
- aisc/application/profile_service.py +105 -0
- aisc/application/provider.py +92 -0
- aisc/application/repair.py +387 -0
- aisc/application/resources.py +270 -0
- aisc/application/runtime.py +1826 -0
- aisc/application/session.py +320 -0
- aisc/application/tool_versions.py +210 -0
- aisc/application/toolchain.py +91 -0
- aisc/application/update.py +286 -0
- aisc/application/usage.py +305 -0
- aisc/application/version.py +133 -0
- aisc/application/web_gateway.py +459 -0
- aisc/application/workspace_reconcile.py +318 -0
- aisc/applog.py +103 -0
- aisc/cli/__init__.py +1 -0
- aisc/cli/build_progress_parser.py +217 -0
- aisc/cli/commands/__init__.py +1 -0
- aisc/cli/commands/agents.py +117 -0
- aisc/cli/commands/artifact.py +210 -0
- aisc/cli/commands/build.py +566 -0
- aisc/cli/commands/bundle.py +107 -0
- aisc/cli/commands/cc_switch.py +158 -0
- aisc/cli/commands/config.py +104 -0
- aisc/cli/commands/container.py +787 -0
- aisc/cli/commands/conversation.py +72 -0
- aisc/cli/commands/data_root.py +84 -0
- aisc/cli/commands/logs.py +89 -0
- aisc/cli/commands/network.py +132 -0
- aisc/cli/commands/profile.py +52 -0
- aisc/cli/commands/provider.py +49 -0
- aisc/cli/commands/run.py +494 -0
- aisc/cli/commands/runs.py +125 -0
- aisc/cli/commands/runtime.py +450 -0
- aisc/cli/commands/serve.py +550 -0
- aisc/cli/commands/serve_fs.py +369 -0
- aisc/cli/commands/session.py +167 -0
- aisc/cli/commands/update.py +145 -0
- aisc/cli/commands/usage.py +74 -0
- aisc/cli/commands/wizard.py +130 -0
- aisc/cli/commands/workspaces.py +162 -0
- aisc/cli/help_i18n.py +385 -0
- aisc/cli/main.py +2926 -0
- aisc/cli/output.py +239 -0
- aisc/cli/tutorial.py +109 -0
- aisc/domain/__init__.py +1 -0
- aisc/domain/artifacts.py +279 -0
- aisc/domain/cc_switch_release.py +303 -0
- aisc/domain/config.py +64 -0
- aisc/domain/data_migration.py +259 -0
- aisc/domain/data_root.py +124 -0
- aisc/domain/docker_ownership.py +148 -0
- aisc/domain/gateway.py +168 -0
- aisc/domain/models.py +674 -0
- aisc/domain/toolchain.py +72 -0
- aisc/domain/web_services.py +355 -0
- aisc/domain/workspace_lease.py +139 -0
- aisc/schemas/__init__.py +1 -0
- aisc/schemas/config_schema.py +145 -0
- aisc_cli-0.1.0.dist-info/METADATA +420 -0
- aisc_cli-0.1.0.dist-info/RECORD +92 -0
- aisc_cli-0.1.0.dist-info/WHEEL +5 -0
- aisc_cli-0.1.0.dist-info/entry_points.txt +2 -0
- aisc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- aisc_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
"""Bundle fetch/install machinery (0.1.0 A3, guide 3.3).
|
|
2
|
+
|
|
3
|
+
Two halves:
|
|
4
|
+
|
|
5
|
+
1. MIGRATED from ``packaging/artifact.py`` (guide 3.3.3: sdist/wheel do not
|
|
6
|
+
contain ``packaging/`` — pip environments must import these): archive
|
|
7
|
+
member validation + safe extraction, manifest write, and staged-bundle
|
|
8
|
+
verification. ``packaging/artifact.py`` re-exports the original symbols
|
|
9
|
+
so ``tests/packaging/test_artifact.py`` and the packaging CLI are
|
|
10
|
+
untouched consumers of the SAME implementation.
|
|
11
|
+
|
|
12
|
+
2. NEW: the runtime fetch path — resolve a same-version ``AISC-<ver>-<plat>-
|
|
13
|
+
<arch>`` asset from GitHub Releases, verify the API digest, safe-extract,
|
|
14
|
+
verify the ``aisc-bundle/`` subtree, and atomically install it under
|
|
15
|
+
``<data-root>/bundles/<ver>/``. Trust model (guide 3.3.3, ADR-002 bound):
|
|
16
|
+
transport integrity + GitHub account trust; the same-source ``.sha256``
|
|
17
|
+
sidecar alone proves nothing about tampering, so the API-reported digest
|
|
18
|
+
is the required root and offline ``--from-file`` must pin its own hash.
|
|
19
|
+
Fail-closed everywhere; three manual escape hatches in every network
|
|
20
|
+
error copy (manual URL / --from-file / --aisc-root).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import shutil
|
|
30
|
+
import stat
|
|
31
|
+
import sys
|
|
32
|
+
import tarfile
|
|
33
|
+
import tempfile
|
|
34
|
+
import time
|
|
35
|
+
import urllib.error
|
|
36
|
+
import urllib.request
|
|
37
|
+
import zipfile
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Platform tags (migrated; the runtime needs the same names as the packager)
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
_PLAT_TAG = {"linux": "linux", "darwin": "macos", "win32": "windows"}.get(sys.platform, sys.platform)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _detect_arch() -> str:
|
|
49
|
+
machine = os.uname().machine if hasattr(os, "uname") else os.environ.get("PROCESSOR_ARCHITECTURE", "x86_64")
|
|
50
|
+
if machine in ("x86_64", "AMD64", "amd64"):
|
|
51
|
+
return "x86_64"
|
|
52
|
+
if machine in ("aarch64", "arm64", "ARM64"):
|
|
53
|
+
return "arm64"
|
|
54
|
+
return machine
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
ARCH_TAG = _detect_arch()
|
|
58
|
+
|
|
59
|
+
#: AISC's own release repo (CLI updates + bundle archives live here).
|
|
60
|
+
AISC_RELEASE_REPO = "wangyuncepu/AISC"
|
|
61
|
+
|
|
62
|
+
DEFAULT_API_BASE = "https://api.github.com"
|
|
63
|
+
DEFAULT_TIMEOUT_S = 30.0
|
|
64
|
+
DEFAULT_MAX_PAGES = 10
|
|
65
|
+
|
|
66
|
+
BUNDLE_FETCH_ERROR_NETWORK = "aisc.bundle-fetch/network"
|
|
67
|
+
BUNDLE_FETCH_ERROR_NOT_FOUND = "aisc.bundle-fetch/not-found"
|
|
68
|
+
BUNDLE_FETCH_ERROR_INTEGRITY = "aisc.bundle-fetch/integrity"
|
|
69
|
+
BUNDLE_FETCH_ERROR_INVALID = "aisc.bundle-fetch/invalid"
|
|
70
|
+
BUNDLE_FETCH_ERROR_CONFLICT = "aisc.bundle-fetch/conflict"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class BundleFetchError(Exception):
|
|
74
|
+
"""Fail-closed fetch failure. ``code`` maps to a stable CLI error code."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, code: str, message: str) -> None:
|
|
77
|
+
super().__init__(message)
|
|
78
|
+
self.code = code
|
|
79
|
+
self.message = message
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Archive member validation + safe extraction (migrated verbatim in behavior)
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def _norm_path(p: str) -> str:
|
|
87
|
+
return re.sub(r'/+', '/', p.replace("\\", "/")).strip("/")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _validate_archive_path(name: str) -> Optional[str]:
|
|
91
|
+
"""Validate archive member path. Returns error or None."""
|
|
92
|
+
raw = name
|
|
93
|
+
if "\x00" in raw:
|
|
94
|
+
return "NUL byte in path"
|
|
95
|
+
r = raw.lstrip()
|
|
96
|
+
if r.startswith("//") or r.startswith("\\\\"):
|
|
97
|
+
return f"UNC path: {raw!r}"
|
|
98
|
+
if r.startswith("/"):
|
|
99
|
+
return f"POSIX absolute path: {raw!r}"
|
|
100
|
+
if re.match(r'^[a-zA-Z]:[/\\\\]', r):
|
|
101
|
+
return f"Windows drive path: {raw!r}"
|
|
102
|
+
n = _norm_path(raw)
|
|
103
|
+
if not n or n == ".":
|
|
104
|
+
return f"empty or '.' path: {raw!r}"
|
|
105
|
+
parts = n.split("/")
|
|
106
|
+
if ".." in parts:
|
|
107
|
+
return f"path escape (..): {raw!r}"
|
|
108
|
+
if "." in parts:
|
|
109
|
+
return f"path with '.' segment: {raw!r}"
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _normalised_key(name: str) -> str:
|
|
114
|
+
"""Casefolded normalised path for duplicate detection."""
|
|
115
|
+
return _norm_path(name).casefold()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def validate_tar_members(tar: tarfile.TarFile) -> List[str]:
|
|
119
|
+
"""Validate all tar members. Only REGTYPE/DIRTYPE allowed.
|
|
120
|
+
|
|
121
|
+
Rejects symlink, hardlink, FIFO, char/block device; checks for
|
|
122
|
+
normalised+casefold duplicate targets.
|
|
123
|
+
"""
|
|
124
|
+
errors: List[str] = []
|
|
125
|
+
seen: Set[str] = set()
|
|
126
|
+
for m in tar.getmembers():
|
|
127
|
+
e = _validate_archive_path(m.name)
|
|
128
|
+
if e:
|
|
129
|
+
errors.append(e)
|
|
130
|
+
continue
|
|
131
|
+
if m.type not in (tarfile.REGTYPE, tarfile.DIRTYPE):
|
|
132
|
+
tname = {tarfile.SYMTYPE: "symlink", tarfile.LNKTYPE: "hardlink",
|
|
133
|
+
tarfile.FIFOTYPE: "fifo", tarfile.CHRTYPE: "chardev",
|
|
134
|
+
tarfile.BLKTYPE: "blockdev"}.get(m.type, f"type({m.type})")
|
|
135
|
+
errors.append(f"forbidden tar member type {tname}: {m.name}")
|
|
136
|
+
if m.linkname:
|
|
137
|
+
le = _validate_archive_path(m.linkname)
|
|
138
|
+
if le:
|
|
139
|
+
errors.append(f"unsafe tar link target: {m.linkname} ({le})")
|
|
140
|
+
continue
|
|
141
|
+
nk = _normalised_key(m.name)
|
|
142
|
+
if nk in seen:
|
|
143
|
+
errors.append(f"duplicate path in tar: {m.name}")
|
|
144
|
+
seen.add(nk)
|
|
145
|
+
return errors
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def validate_zip_members(zf: zipfile.ZipFile) -> List[str]:
|
|
149
|
+
"""Validate zip members. Rejects unsafe paths, special file types,
|
|
150
|
+
Unix symlinks, casefold duplicates."""
|
|
151
|
+
errors: List[str] = []
|
|
152
|
+
seen: Set[str] = set()
|
|
153
|
+
for zi in zf.infolist():
|
|
154
|
+
e = _validate_archive_path(zi.filename)
|
|
155
|
+
if e:
|
|
156
|
+
errors.append(e)
|
|
157
|
+
continue
|
|
158
|
+
full_mode = (zi.external_attr >> 16) & 0xFFFF
|
|
159
|
+
if zi.create_system == 3 and full_mode != 0:
|
|
160
|
+
if stat.S_ISLNK(full_mode):
|
|
161
|
+
errors.append(f"zip contains Unix symlink: {zi.filename}")
|
|
162
|
+
elif not (stat.S_ISREG(full_mode) or stat.S_ISDIR(full_mode)):
|
|
163
|
+
ftype = "unknown"
|
|
164
|
+
if stat.S_ISFIFO(full_mode):
|
|
165
|
+
ftype = "fifo"
|
|
166
|
+
elif stat.S_ISCHR(full_mode):
|
|
167
|
+
ftype = "chardev"
|
|
168
|
+
elif stat.S_ISBLK(full_mode):
|
|
169
|
+
ftype = "blockdev"
|
|
170
|
+
elif stat.S_ISSOCK(full_mode):
|
|
171
|
+
ftype = "socket"
|
|
172
|
+
errors.append(f"zip contains forbidden file type {ftype}: {zi.filename}")
|
|
173
|
+
if zi.is_dir() and not stat.S_ISDIR(full_mode):
|
|
174
|
+
errors.append(f"zip directory flag vs mode mismatch: {zi.filename}")
|
|
175
|
+
if stat.S_ISDIR(full_mode) and not zi.is_dir():
|
|
176
|
+
errors.append(f"zip mode says dir but not flagged as dir: {zi.filename}")
|
|
177
|
+
elif zi.create_system != 3 and full_mode != 0:
|
|
178
|
+
if stat.S_ISLNK(full_mode):
|
|
179
|
+
errors.append(f"zip contains symlink (non-Unix): {zi.filename}")
|
|
180
|
+
nk = _normalised_key(zi.filename)
|
|
181
|
+
if nk in seen:
|
|
182
|
+
errors.append(f"duplicate path in zip: {zi.filename}")
|
|
183
|
+
seen.add(nk)
|
|
184
|
+
return errors
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _check_containment(dest_dir: Path, target: Path) -> Optional[str]:
|
|
188
|
+
"""Check target resolves inside dest_dir. Returns error or None."""
|
|
189
|
+
try:
|
|
190
|
+
target.resolve().relative_to(dest_dir.resolve())
|
|
191
|
+
except ValueError:
|
|
192
|
+
return f"extraction escapes dest: {target}"
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def safe_extract_tar(tar: tarfile.TarFile, dest_dir: Path) -> List[str]:
|
|
197
|
+
errors = validate_tar_members(tar)
|
|
198
|
+
if errors:
|
|
199
|
+
return errors
|
|
200
|
+
for m in tar.getmembers():
|
|
201
|
+
if m.type == tarfile.DIRTYPE:
|
|
202
|
+
continue # dirs created implicitly
|
|
203
|
+
target = dest_dir / m.name
|
|
204
|
+
ce = _check_containment(dest_dir, target)
|
|
205
|
+
if ce:
|
|
206
|
+
errors.append(ce)
|
|
207
|
+
continue
|
|
208
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
with tar.extractfile(m) as src:
|
|
210
|
+
if src is None:
|
|
211
|
+
errors.append(f"cannot read tar member: {m.name}")
|
|
212
|
+
continue
|
|
213
|
+
target.write_bytes(src.read())
|
|
214
|
+
target.chmod(m.mode & 0o777)
|
|
215
|
+
return errors
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def safe_extract_zip(zf: zipfile.ZipFile, dest_dir: Path) -> List[str]:
|
|
219
|
+
errors = validate_zip_members(zf)
|
|
220
|
+
if errors:
|
|
221
|
+
return errors
|
|
222
|
+
for zi in zf.infolist():
|
|
223
|
+
if zi.is_dir():
|
|
224
|
+
continue # dirs created implicitly
|
|
225
|
+
target = dest_dir / zi.filename
|
|
226
|
+
ce = _check_containment(dest_dir, target)
|
|
227
|
+
if ce:
|
|
228
|
+
errors.append(ce)
|
|
229
|
+
continue
|
|
230
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
target.write_bytes(zf.read(zi.filename))
|
|
232
|
+
mode = (zi.external_attr >> 16) & 0o777
|
|
233
|
+
target.chmod(mode if mode else 0o644)
|
|
234
|
+
return errors
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def safe_extract_archive(archive_path: Path, dest_dir: Path) -> List[str]:
|
|
238
|
+
"""Safely extract archive to dest_dir (created; must be empty if exists).
|
|
239
|
+
|
|
240
|
+
Validates all members before writing anything. Returns error list.
|
|
241
|
+
"""
|
|
242
|
+
if dest_dir.exists():
|
|
243
|
+
if not dest_dir.is_dir():
|
|
244
|
+
return [f"extraction dest is not a directory: {dest_dir}"]
|
|
245
|
+
if any(dest_dir.iterdir()):
|
|
246
|
+
return [f"extraction dest is not empty: {dest_dir}"]
|
|
247
|
+
else:
|
|
248
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
249
|
+
suffix = archive_path.name.lower().rsplit(".", 1)[-1] if "." in archive_path.name else ""
|
|
250
|
+
try:
|
|
251
|
+
if archive_path.name.lower().endswith(".tar.gz") or archive_path.name.lower().endswith(".tgz"):
|
|
252
|
+
with tarfile.open(archive_path, "r:gz") as tar:
|
|
253
|
+
return safe_extract_tar(tar, dest_dir)
|
|
254
|
+
if suffix == "zip":
|
|
255
|
+
with zipfile.ZipFile(archive_path) as zf:
|
|
256
|
+
return safe_extract_zip(zf, dest_dir)
|
|
257
|
+
if suffix == "tar":
|
|
258
|
+
with tarfile.open(archive_path, "r:") as tar:
|
|
259
|
+
return safe_extract_tar(tar, dest_dir)
|
|
260
|
+
except (tarfile.TarError, zipfile.BadZipFile, OSError) as exc:
|
|
261
|
+
return [f"archive open/read failed: {exc}"]
|
|
262
|
+
return [f"unsupported archive type: {archive_path.name}"]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _parse_archive_name(name: str) -> Optional[Tuple[str, str, str]]:
|
|
266
|
+
"""``AISC-<version>-<platform>-<arch>`` -> (version, platform, arch)."""
|
|
267
|
+
m = re.match(r"^AISC-(.+?)-([a-z0-9]+)-([a-z0-9_]+)\.(?:tar\.gz|zip|tgz)$", name)
|
|
268
|
+
if not m:
|
|
269
|
+
return None
|
|
270
|
+
return m.group(1), m.group(2), m.group(3)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ---------------------------------------------------------------------------
|
|
274
|
+
# Manifest + staged-bundle verification (migrated; verify side stays whole)
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
BUNDLE_REQUIRED = ["VERSION", "README.md", "LICENSE", ".dockerignore", "config/versions.env"]
|
|
278
|
+
BUNDLE_FORBIDDEN_TOP = ["tools", "scripts", "packaging", "tests", "docs", ".git", "aisc-bundle"]
|
|
279
|
+
|
|
280
|
+
# _glob_module indirection kept so tests can shim glob behaviour (migrated
|
|
281
|
+
# alongside _verify_dockerfile_sources which is its only consumer).
|
|
282
|
+
import glob as _glob_module # noqa: E402 (deliberate: mirrors packaging layout)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def bundle_version(bundle_root: Path) -> Optional[str]:
|
|
286
|
+
"""First non-empty line of the bundle's VERSION (dual-shape root read:
|
|
287
|
+
staged bundles/frozen roots carry it at the root; repo checkouts at
|
|
288
|
+
src/aisc/VERSION)."""
|
|
289
|
+
for rel in ("VERSION", "src/aisc/VERSION"):
|
|
290
|
+
vf = bundle_root / rel
|
|
291
|
+
if vf.is_file():
|
|
292
|
+
text = vf.read_text(encoding="utf-8").strip()
|
|
293
|
+
if text:
|
|
294
|
+
return text
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _write_manifest(path: Path, data: dict) -> None:
|
|
299
|
+
sv, vs = data.get("schema_version", 1), sorted(data.get("compatible_cli_versions", []))
|
|
300
|
+
path.write_text('{\n "schema_version": ' + json.dumps(sv) + ',\n "compatible_cli_versions": ' + json.dumps(vs) + '\n}\n',
|
|
301
|
+
encoding="utf-8", newline="")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def verify_staged_bundle(bundle_root: Path) -> List[str]:
|
|
305
|
+
errors: List[str] = []
|
|
306
|
+
mp = bundle_root / "manifest.json"
|
|
307
|
+
if not mp.is_file():
|
|
308
|
+
return ["manifest.json missing from staged bundle"]
|
|
309
|
+
try:
|
|
310
|
+
mt = mp.read_text(encoding="utf-8")
|
|
311
|
+
if "\r" in mt:
|
|
312
|
+
errors.append("manifest.json contains CR (must be LF)")
|
|
313
|
+
if not mt.endswith("\n"):
|
|
314
|
+
errors.append("manifest.json does not end with newline")
|
|
315
|
+
m = json.loads(mt)
|
|
316
|
+
except json.JSONDecodeError as e:
|
|
317
|
+
return [f"manifest.json is not valid JSON: {e}"]
|
|
318
|
+
if m.get("schema_version") != 1:
|
|
319
|
+
errors.append(f"manifest.json schema_version={m.get('schema_version')}, expected 1")
|
|
320
|
+
compat = m.get("compatible_cli_versions")
|
|
321
|
+
if not isinstance(compat, list):
|
|
322
|
+
errors.append("manifest.json compatible_cli_versions is not a list")
|
|
323
|
+
elif len(compat) == 0:
|
|
324
|
+
errors.append("manifest.json compatible_cli_versions is empty")
|
|
325
|
+
else:
|
|
326
|
+
pv = bundle_version(bundle_root)
|
|
327
|
+
seen: Set[str] = set()
|
|
328
|
+
for v in compat:
|
|
329
|
+
if not isinstance(v, str):
|
|
330
|
+
errors.append(f"manifest.json version not string: {v}")
|
|
331
|
+
elif v in seen:
|
|
332
|
+
errors.append(f"manifest.json duplicate version: {v}")
|
|
333
|
+
seen.add(v)
|
|
334
|
+
if pv not in compat:
|
|
335
|
+
errors.append(f"manifest.json allowlist missing current version {pv}")
|
|
336
|
+
for k in ("timestamp", "platform", "arch", "checksums"):
|
|
337
|
+
if k in m:
|
|
338
|
+
errors.append(f"manifest.json contains forbidden field: {k}")
|
|
339
|
+
for k in m:
|
|
340
|
+
if k not in ("schema_version", "compatible_cli_versions"):
|
|
341
|
+
errors.append(f"manifest.json contains unknown field: {k}")
|
|
342
|
+
for fn in BUNDLE_REQUIRED:
|
|
343
|
+
if not (bundle_root / fn).is_file():
|
|
344
|
+
errors.append(f"Required file missing: {fn}")
|
|
345
|
+
if not (bundle_root / "config" / "versions.env").is_file():
|
|
346
|
+
errors.append("config/versions.env missing")
|
|
347
|
+
df = bundle_root / "container" / "Dockerfile"
|
|
348
|
+
if df.is_file():
|
|
349
|
+
errors.extend(_verify_dockerfile_sources(df, bundle_root))
|
|
350
|
+
else:
|
|
351
|
+
errors.append("container/Dockerfile missing")
|
|
352
|
+
errors.extend(_verify_vendor_checksums(bundle_root))
|
|
353
|
+
errors.extend(_audit_forbidden(bundle_root))
|
|
354
|
+
if not (bundle_root / "container" / "_bundle" / "plugins").exists():
|
|
355
|
+
errors.append("container/_bundle/plugins missing")
|
|
356
|
+
if not (bundle_root / "container" / "downloads").exists():
|
|
357
|
+
errors.append("container/downloads missing")
|
|
358
|
+
return errors
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _audit_forbidden(bundle_root: Path) -> List[str]:
|
|
362
|
+
es = []
|
|
363
|
+
for dp, dns, fns in os.walk(str(bundle_root)):
|
|
364
|
+
rp = Path(dp).relative_to(bundle_root)
|
|
365
|
+
rs = str(rp).replace("\\", "/")
|
|
366
|
+
for dn in dns:
|
|
367
|
+
if dn == "__pycache__" or dn.startswith(".pytest_cache"):
|
|
368
|
+
es.append(f"Forbidden dir: {rs}/{dn}")
|
|
369
|
+
for fn in fns:
|
|
370
|
+
fr = (rs + "/" + fn) if rs != "." else fn
|
|
371
|
+
if fn.endswith(".pyc"):
|
|
372
|
+
es.append(f"Forbidden .pyc: {fr}")
|
|
373
|
+
if fn in (".env", "api-keys", ".git-credentials") and len(fr.split("/")) > 1:
|
|
374
|
+
es.append(f"Forbidden file: {fr}")
|
|
375
|
+
for fb in BUNDLE_FORBIDDEN_TOP:
|
|
376
|
+
if (bundle_root / fb).exists():
|
|
377
|
+
es.append(f"Forbidden top-level: {fb}")
|
|
378
|
+
return es
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _verify_dockerfile_sources(df: Path, br: Path) -> List[str]:
|
|
382
|
+
es = []
|
|
383
|
+
for no, line in enumerate(df.read_text(encoding="utf-8").splitlines(), 1):
|
|
384
|
+
s = line.strip()
|
|
385
|
+
if not s or s.startswith("#"):
|
|
386
|
+
continue
|
|
387
|
+
if not s.upper().startswith("COPY"):
|
|
388
|
+
continue
|
|
389
|
+
if s.startswith("COPY [") or s.startswith("COPY["):
|
|
390
|
+
es.append(f"Dockerfile line {no}: JSON-form COPY not supported")
|
|
391
|
+
continue
|
|
392
|
+
if "--FROM" in s.upper():
|
|
393
|
+
es.append(f"Dockerfile line {no}: COPY --from not supported")
|
|
394
|
+
continue
|
|
395
|
+
parts = s.split()
|
|
396
|
+
if len(parts) < 3:
|
|
397
|
+
continue
|
|
398
|
+
cargs = [p for p in parts[1:] if not p.startswith("--")]
|
|
399
|
+
if len(cargs) < 2:
|
|
400
|
+
continue
|
|
401
|
+
for src in cargs[:-1]:
|
|
402
|
+
sp = src.lstrip("/")
|
|
403
|
+
if "*" in sp or "?" in sp:
|
|
404
|
+
if not _glob_module.glob(str(br / sp)):
|
|
405
|
+
es.append(f"Dockerfile line {no}: COPY source glob not found: {sp}")
|
|
406
|
+
elif not (br / sp.rstrip("/")).exists():
|
|
407
|
+
es.append(f"Dockerfile line {no}: COPY source not found: {sp}")
|
|
408
|
+
return es
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _verify_vendor_checksums(bundle_root: Path) -> List[str]:
|
|
412
|
+
es = []
|
|
413
|
+
cf = bundle_root / "vendor" / "checksums.txt"
|
|
414
|
+
if not cf.is_file():
|
|
415
|
+
return ["vendor/checksums.txt missing from bundle"]
|
|
416
|
+
for no, line in enumerate(cf.read_text(encoding="utf-8").splitlines(), 1):
|
|
417
|
+
s = line.strip()
|
|
418
|
+
if not s or s.startswith("#"):
|
|
419
|
+
continue
|
|
420
|
+
m = re.match(r'^([0-9a-fA-F]{64})\s+(.+)$', s)
|
|
421
|
+
if not m:
|
|
422
|
+
es.append(f"vendor/checksums.txt line {no}: malformed: {s[:80]}")
|
|
423
|
+
continue
|
|
424
|
+
eh, rp = m.group(1).lower(), m.group(2).strip()
|
|
425
|
+
if rp.startswith("/") or ".." in rp.replace("\\", "/").split("/"):
|
|
426
|
+
es.append(f"vendor/checksums.txt line {no}: unsafe path: {rp}")
|
|
427
|
+
continue
|
|
428
|
+
target = (bundle_root / rp).resolve()
|
|
429
|
+
try:
|
|
430
|
+
target.relative_to(bundle_root.resolve())
|
|
431
|
+
except ValueError:
|
|
432
|
+
es.append(f"vendor/checksums.txt line {no}: path escapes bundle: {rp}")
|
|
433
|
+
continue
|
|
434
|
+
if not target.is_file():
|
|
435
|
+
es.append(f"vendor/checksums line {no}: file not found: {rp}")
|
|
436
|
+
continue
|
|
437
|
+
ah = hashlib.sha256(target.read_bytes()).hexdigest()
|
|
438
|
+
if ah != eh:
|
|
439
|
+
es.append(f"vendor/checksums line {no}: hash mismatch for {rp}: expected {eh[:16]}..., got {ah[:16]}...")
|
|
440
|
+
return es
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def bundle_compatible(bundle_root: Path, cli_version: str) -> Tuple[bool, str]:
|
|
444
|
+
"""Runtime manifest gate (guide 3.3.2 — the check ADR-001 described but
|
|
445
|
+
never shipped). Fail-closed: missing/corrupt manifest, wrong schema, or
|
|
446
|
+
a version not in ``compatible_cli_versions`` are ALL incompatible.
|
|
447
|
+
|
|
448
|
+
Note the semantic split: ``verify_staged_bundle`` enforces the PACKAGING
|
|
449
|
+
invariant (bundle's own VERSION must be in the allowlist — self-
|
|
450
|
+
consistency); this is the RUNTIME gate (the RUNNING cli must be in the
|
|
451
|
+
allowlist). First version set: exact match only."""
|
|
452
|
+
mp = bundle_root / "manifest.json"
|
|
453
|
+
try:
|
|
454
|
+
m = json.loads(mp.read_text(encoding="utf-8"))
|
|
455
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
456
|
+
return False, f"manifest unreadable: {exc}"
|
|
457
|
+
if m.get("schema_version") != 1:
|
|
458
|
+
return False, f"manifest schema_version={m.get('schema_version')!r}, expected 1"
|
|
459
|
+
compat = m.get("compatible_cli_versions")
|
|
460
|
+
if not isinstance(compat, list) or not all(isinstance(v, str) for v in compat):
|
|
461
|
+
return False, "manifest compatible_cli_versions malformed"
|
|
462
|
+
if cli_version in compat:
|
|
463
|
+
return True, ""
|
|
464
|
+
return False, (
|
|
465
|
+
f"bundle {bundle_version(bundle_root) or '?'} requires CLI "
|
|
466
|
+
f"{'/'.join(compat)} (running {cli_version})"
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
# ---------------------------------------------------------------------------
|
|
471
|
+
# GitHub Releases asset resolution + streaming download
|
|
472
|
+
# ---------------------------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
#: JSON metadata transport: (url, headers, timeout) -> (status, headers, body).
|
|
475
|
+
#: Same shape as cc_switch_resolver.Transport so fakes are interchangeable.
|
|
476
|
+
Transport = Callable[[str, Dict[str, str], float], Tuple[int, Dict[str, str], Any]]
|
|
477
|
+
|
|
478
|
+
#: Streaming binary transport: (url, headers, timeout, sink) -> status.
|
|
479
|
+
#: ``sink(chunk: bytes)`` receives the body in bounded chunks — the 60MB
|
|
480
|
+
#: asset must never ride a full-body read.
|
|
481
|
+
DownloadTransport = Callable[[str, Dict[str, str], float, Callable[[bytes], None]], int]
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def default_transport(url: str, headers: Dict[str, str], timeout: float) -> Tuple[int, Dict[str, str], Any]:
|
|
485
|
+
req = urllib.request.Request(url, headers={"User-Agent": "aisc-bundle-fetch", **headers})
|
|
486
|
+
try:
|
|
487
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
488
|
+
raw = resp.read()
|
|
489
|
+
except urllib.error.HTTPError as exc:
|
|
490
|
+
raw = exc.read() if exc.fp else b""
|
|
491
|
+
try:
|
|
492
|
+
body: Any = json.loads(raw.decode("utf-8", errors="replace")) if raw else {}
|
|
493
|
+
except json.JSONDecodeError:
|
|
494
|
+
body = {"message": raw.decode("utf-8", errors="replace")}
|
|
495
|
+
return int(exc.code), {k.lower(): v for k, v in exc.headers.items()}, body
|
|
496
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
497
|
+
raise BundleFetchError(
|
|
498
|
+
BUNDLE_FETCH_ERROR_NETWORK, _network_error_copy(f"github api unreachable: {exc}")) from exc
|
|
499
|
+
try:
|
|
500
|
+
body = json.loads(raw.decode("utf-8"))
|
|
501
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
502
|
+
raise BundleFetchError(
|
|
503
|
+
BUNDLE_FETCH_ERROR_NETWORK, _network_error_copy(f"github api body unparseable: {exc}")) from exc
|
|
504
|
+
return int(resp.status), {k.lower(): v for k, v in resp.headers.items()}, body
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def default_download_transport(url: str, headers: Dict[str, str], timeout: float,
|
|
508
|
+
sink: Callable[[bytes], None]) -> int:
|
|
509
|
+
req = urllib.request.Request(url, headers={"User-Agent": "aisc-bundle-fetch", **headers})
|
|
510
|
+
try:
|
|
511
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
512
|
+
while True:
|
|
513
|
+
chunk = resp.read(1 << 16)
|
|
514
|
+
if not chunk:
|
|
515
|
+
return int(resp.status)
|
|
516
|
+
sink(chunk)
|
|
517
|
+
except urllib.error.HTTPError as exc:
|
|
518
|
+
raise BundleFetchError(
|
|
519
|
+
BUNDLE_FETCH_ERROR_NETWORK,
|
|
520
|
+
_network_error_copy(f"asset download failed: HTTP {exc.code}")) from exc
|
|
521
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
522
|
+
raise BundleFetchError(
|
|
523
|
+
BUNDLE_FETCH_ERROR_NETWORK, _network_error_copy(f"asset download failed: {exc}")) from exc
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _network_error_copy(detail: str) -> str:
|
|
527
|
+
return (
|
|
528
|
+
f"{detail}. Three manual paths: (1) download the release archive "
|
|
529
|
+
f"from {AISC_RELEASE_REPO} releases and run `aisc bundle fetch "
|
|
530
|
+
f"--from-file <archive> --sha256 <digest>`; (2) point --aisc-root at "
|
|
531
|
+
f"an extracted bundle; (3) retry with network/proxy access. "
|
|
532
|
+
f"(AISC_GH_API_BASE / AISC_GH_API_TOKEN knobs apply.)"
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _sha256_file(path: Path) -> str:
|
|
537
|
+
h = hashlib.sha256()
|
|
538
|
+
with open(path, "rb") as f:
|
|
539
|
+
for chunk in iter(lambda: f.read(1 << 16), b""):
|
|
540
|
+
h.update(chunk)
|
|
541
|
+
return h.hexdigest()
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
class ReleaseAssets:
|
|
545
|
+
"""One release page's worth of parsed assets (name -> {digest, size, url})."""
|
|
546
|
+
|
|
547
|
+
def __init__(self, tag: str, assets: Dict[str, Dict[str, str]]):
|
|
548
|
+
self.tag = tag
|
|
549
|
+
self.assets = assets
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
class BundleFetcher:
|
|
553
|
+
"""Resolve + download + verify + install release bundle archives.
|
|
554
|
+
|
|
555
|
+
Every I/O seam is injectable (fake-transport test matrix, guide 3.3.3).
|
|
556
|
+
"""
|
|
557
|
+
|
|
558
|
+
def __init__(
|
|
559
|
+
self,
|
|
560
|
+
*,
|
|
561
|
+
transport: Transport = default_transport,
|
|
562
|
+
download: DownloadTransport = default_download_transport,
|
|
563
|
+
repo: str = AISC_RELEASE_REPO,
|
|
564
|
+
api_base: Optional[str] = None,
|
|
565
|
+
token: Optional[str] = None,
|
|
566
|
+
timeout: float = DEFAULT_TIMEOUT_S,
|
|
567
|
+
max_pages: int = DEFAULT_MAX_PAGES,
|
|
568
|
+
):
|
|
569
|
+
self._transport = transport
|
|
570
|
+
self._download = download
|
|
571
|
+
self._repo = repo
|
|
572
|
+
self._api_base = (api_base or os.environ.get("AISC_GH_API_BASE") or DEFAULT_API_BASE).rstrip("/")
|
|
573
|
+
self._token = token or os.environ.get("AISC_GH_API_TOKEN") or ""
|
|
574
|
+
self._timeout = timeout
|
|
575
|
+
self._max_pages = max_pages
|
|
576
|
+
|
|
577
|
+
def _headers(self) -> Dict[str, str]:
|
|
578
|
+
h = {"Accept": "application/vnd.github+json"}
|
|
579
|
+
if self._token:
|
|
580
|
+
h["Authorization"] = f"Bearer {self._token}"
|
|
581
|
+
return h
|
|
582
|
+
|
|
583
|
+
def _rate_limited(self, status: int, headers: Dict[str, str], body: Any) -> bool:
|
|
584
|
+
if status == 403 or status == 429:
|
|
585
|
+
remaining = headers.get("x-ratelimit-remaining", "")
|
|
586
|
+
if remaining == "0" or status == 429:
|
|
587
|
+
return True
|
|
588
|
+
msg = str(body.get("message", "") if isinstance(body, dict) else "")
|
|
589
|
+
return "rate limit" in msg.lower()
|
|
590
|
+
return False
|
|
591
|
+
|
|
592
|
+
def list_release_assets(self) -> List[ReleaseAssets]:
|
|
593
|
+
"""All releases (newest first), each with parsed asset metadata."""
|
|
594
|
+
out: List[ReleaseAssets] = []
|
|
595
|
+
for page in range(1, self._max_pages + 1):
|
|
596
|
+
url = f"{self._api_base}/repos/{self._repo}/releases?per_page=50&page={page}"
|
|
597
|
+
status, headers, body = self._transport(url, self._headers(), self._timeout)
|
|
598
|
+
if self._rate_limited(status, headers, body):
|
|
599
|
+
raise BundleFetchError(
|
|
600
|
+
BUNDLE_FETCH_ERROR_NETWORK,
|
|
601
|
+
_network_error_copy("github api rate limited (set AISC_GH_API_TOKEN to raise the cap)"))
|
|
602
|
+
if status != 200:
|
|
603
|
+
raise BundleFetchError(
|
|
604
|
+
BUNDLE_FETCH_ERROR_NETWORK,
|
|
605
|
+
_network_error_copy(f"github api listing failed: HTTP {status}"))
|
|
606
|
+
releases = body if isinstance(body, list) else []
|
|
607
|
+
if not releases:
|
|
608
|
+
break
|
|
609
|
+
for rel in releases:
|
|
610
|
+
assets: Dict[str, Dict[str, str]] = {}
|
|
611
|
+
for a in rel.get("assets", []) or []:
|
|
612
|
+
name = a.get("name", "")
|
|
613
|
+
if _parse_archive_name(name) is None:
|
|
614
|
+
continue # exe/sha256/SBOM assets are not bundle archives
|
|
615
|
+
assets[name] = {
|
|
616
|
+
"digest": str(a.get("digest") or ""),
|
|
617
|
+
"size": str(a.get("size") or ""),
|
|
618
|
+
"url": str(a.get("url") or ""),
|
|
619
|
+
"browser_download_url": str(a.get("browser_download_url") or ""),
|
|
620
|
+
}
|
|
621
|
+
out.append(ReleaseAssets(str(rel.get("tag_name", "")), assets))
|
|
622
|
+
if len(releases) < 50:
|
|
623
|
+
break
|
|
624
|
+
return out
|
|
625
|
+
|
|
626
|
+
def resolve_asset(self, version: str, platform: str = _PLAT_TAG, arch: str = ARCH_TAG) -> Tuple[str, Dict[str, str], List[str]]:
|
|
627
|
+
"""Exact-version asset lookup. Returns (asset_name, meta, recent_versions)."""
|
|
628
|
+
releases = self.list_release_assets()
|
|
629
|
+
recent: List[str] = []
|
|
630
|
+
for rel in releases:
|
|
631
|
+
pv, pp, pa = _parse_archive_name(next(iter(rel.assets))) if len(rel.assets) == 1 else (None, None, None)
|
|
632
|
+
for name, meta in rel.assets.items():
|
|
633
|
+
parsed = _parse_archive_name(name)
|
|
634
|
+
if not parsed:
|
|
635
|
+
continue
|
|
636
|
+
v, p, a = parsed
|
|
637
|
+
if v not in recent:
|
|
638
|
+
recent.append(v)
|
|
639
|
+
if v == version and p == platform and a == arch:
|
|
640
|
+
return name, meta, recent
|
|
641
|
+
raise BundleFetchError(
|
|
642
|
+
BUNDLE_FETCH_ERROR_NOT_FOUND,
|
|
643
|
+
f"no release asset for version {version} ({platform}/{arch}). "
|
|
644
|
+
f"Recent available versions: {', '.join(recent[:5]) or '(none)'}. "
|
|
645
|
+
f"Use --version <ver> to pick one explicitly.")
|
|
646
|
+
|
|
647
|
+
# --- install lifecycle ------------------------------------------------
|
|
648
|
+
|
|
649
|
+
def bundles_root(self, data_root: Path) -> Path:
|
|
650
|
+
return data_root / "bundles"
|
|
651
|
+
|
|
652
|
+
def installed_versions(self, data_root: Path) -> List[str]:
|
|
653
|
+
root = self.bundles_root(data_root)
|
|
654
|
+
if not root.is_dir():
|
|
655
|
+
return []
|
|
656
|
+
out = []
|
|
657
|
+
for child in root.iterdir():
|
|
658
|
+
if child.is_dir() and (child / "aisc-bundle" / "VERSION").is_file():
|
|
659
|
+
out.append(child.name)
|
|
660
|
+
return sorted(out)
|
|
661
|
+
|
|
662
|
+
def sweep_tmp(self, data_root: Path) -> List[str]:
|
|
663
|
+
"""Remove half-installed ``.tmp-*`` leftovers from crashed fetches."""
|
|
664
|
+
root = self.bundles_root(data_root)
|
|
665
|
+
removed = []
|
|
666
|
+
if not root.is_dir():
|
|
667
|
+
return removed
|
|
668
|
+
for child in root.iterdir():
|
|
669
|
+
if child.is_dir() and child.name.startswith(".tmp-"):
|
|
670
|
+
shutil.rmtree(child, ignore_errors=True)
|
|
671
|
+
removed.append(child.name)
|
|
672
|
+
return removed
|
|
673
|
+
|
|
674
|
+
def download_archive(self, version: str, dest_dir: Path) -> Path:
|
|
675
|
+
"""Resolve + stream-download the verified archive for *version*.
|
|
676
|
+
|
|
677
|
+
Shared by ``fetch`` (bundle store) and the A4 updater (needs the exe
|
|
678
|
+
too). Raises BundleFetchError fail-closed; returns the verified
|
|
679
|
+
archive path inside *dest_dir* (caller owns cleanup).
|
|
680
|
+
"""
|
|
681
|
+
name, meta, _recent = self.resolve_asset(version)
|
|
682
|
+
digest = meta.get("digest", "").lower()
|
|
683
|
+
if not digest.startswith("sha256:"):
|
|
684
|
+
raise BundleFetchError(
|
|
685
|
+
BUNDLE_FETCH_ERROR_INTEGRITY,
|
|
686
|
+
f"release asset {name} has no API digest (refusing unverified download)")
|
|
687
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
688
|
+
archive = dest_dir / name
|
|
689
|
+
expected = digest.split(":", 1)[1]
|
|
690
|
+
hasher = hashlib.sha256()
|
|
691
|
+
|
|
692
|
+
def sink(chunk: bytes) -> None:
|
|
693
|
+
hasher.update(chunk)
|
|
694
|
+
with open(archive, "ab") as f:
|
|
695
|
+
f.write(chunk)
|
|
696
|
+
|
|
697
|
+
archive.write_bytes(b"") # truncate before streaming append
|
|
698
|
+
url = meta.get("browser_download_url") or meta.get("url")
|
|
699
|
+
self._download(url, {"Accept": "application/octet-stream"}, self._timeout, sink)
|
|
700
|
+
actual = hasher.hexdigest()
|
|
701
|
+
if actual != expected:
|
|
702
|
+
raise BundleFetchError(
|
|
703
|
+
BUNDLE_FETCH_ERROR_INTEGRITY,
|
|
704
|
+
f"download digest mismatch: API said {expected[:16]}..., got {actual[:16]}... "
|
|
705
|
+
f"(transport integrity check — re-run the fetch)")
|
|
706
|
+
return archive
|
|
707
|
+
|
|
708
|
+
def latest_final_version(self) -> Optional[str]:
|
|
709
|
+
"""Highest FINAL X.Y.Z across release tags (D-17: prereleases and
|
|
710
|
+
dev tags never count — pip users must not be pointed at them)."""
|
|
711
|
+
best: Optional[Tuple[Tuple[int, int, int], str]] = None
|
|
712
|
+
for rel in self.list_release_assets():
|
|
713
|
+
tag = rel.tag or ""
|
|
714
|
+
v = tag[1:] if tag.startswith("v") else tag
|
|
715
|
+
if not re.fullmatch(r"\d+\.\d+\.\d+", v):
|
|
716
|
+
continue
|
|
717
|
+
key = tuple(int(p) for p in v.split("."))
|
|
718
|
+
if best is None or key > best[0]:
|
|
719
|
+
best = (key, v) # type: ignore[assignment]
|
|
720
|
+
return best[1] if best else None
|
|
721
|
+
|
|
722
|
+
def fetch(
|
|
723
|
+
self,
|
|
724
|
+
data_root: Path,
|
|
725
|
+
cli_version: str,
|
|
726
|
+
*,
|
|
727
|
+
version: Optional[str] = None,
|
|
728
|
+
from_file: Optional[Path] = None,
|
|
729
|
+
sha256: Optional[str] = None,
|
|
730
|
+
allow_mismatch: bool = False,
|
|
731
|
+
) -> Dict[str, Any]:
|
|
732
|
+
"""Download (or take from ``--from-file``), verify, and install.
|
|
733
|
+
|
|
734
|
+
Returns a report dict (idempotent no-op included). Fail-closed on
|
|
735
|
+
every integrity check; nothing is installed on any error.
|
|
736
|
+
"""
|
|
737
|
+
target_version = version or cli_version
|
|
738
|
+
bundles = self.bundles_root(data_root)
|
|
739
|
+
bundles.mkdir(parents=True, exist_ok=True)
|
|
740
|
+
self.sweep_tmp(data_root)
|
|
741
|
+
|
|
742
|
+
dest = bundles / target_version
|
|
743
|
+
if dest.is_dir() and (dest / "aisc-bundle" / "VERSION").is_file():
|
|
744
|
+
# Idempotent: same-version verified install already present.
|
|
745
|
+
errs = verify_staged_bundle(dest / "aisc-bundle")
|
|
746
|
+
if errs:
|
|
747
|
+
raise BundleFetchError(
|
|
748
|
+
BUNDLE_FETCH_ERROR_CONFLICT,
|
|
749
|
+
f"existing install at {dest} failed verification: {errs[0]} "
|
|
750
|
+
f"(remove it via `aisc bundle remove {target_version}` and re-fetch)")
|
|
751
|
+
return {"status": "already-installed", "version": target_version, "path": str(dest)}
|
|
752
|
+
|
|
753
|
+
tmp = Path(tempfile.mkdtemp(prefix=".tmp-", dir=str(bundles)))
|
|
754
|
+
try:
|
|
755
|
+
if from_file is not None:
|
|
756
|
+
archive = Path(from_file).resolve()
|
|
757
|
+
if not archive.is_file():
|
|
758
|
+
raise BundleFetchError(BUNDLE_FETCH_ERROR_INVALID, f"--from-file not found: {archive}")
|
|
759
|
+
expected = (sha256 or "").strip().lower()
|
|
760
|
+
if not expected:
|
|
761
|
+
raise BundleFetchError(
|
|
762
|
+
BUNDLE_FETCH_ERROR_INTEGRITY,
|
|
763
|
+
"--from-file requires --sha256 (same strength as the online path: "
|
|
764
|
+
"the GitHub API digest is unavailable offline)")
|
|
765
|
+
actual = _sha256_file(archive)
|
|
766
|
+
if actual != expected:
|
|
767
|
+
raise BundleFetchError(
|
|
768
|
+
BUNDLE_FETCH_ERROR_INTEGRITY,
|
|
769
|
+
f"--from-file digest mismatch: expected {expected[:16]}..., got {actual[:16]}...")
|
|
770
|
+
parsed = _parse_archive_name(archive.name)
|
|
771
|
+
archive_version = parsed[0] if parsed else None
|
|
772
|
+
else:
|
|
773
|
+
archive = self.download_archive(target_version, tmp)
|
|
774
|
+
archive_version = _parse_archive_name(archive.name)[0]
|
|
775
|
+
|
|
776
|
+
if archive_version is not None and archive_version != target_version and not allow_mismatch:
|
|
777
|
+
raise BundleFetchError(
|
|
778
|
+
BUNDLE_FETCH_ERROR_CONFLICT,
|
|
779
|
+
f"archive is version {archive_version}, requested {target_version} "
|
|
780
|
+
f"(manifest gate would reject it; pass --allow-mismatch to override)")
|
|
781
|
+
|
|
782
|
+
extracted = tmp / "extracted"
|
|
783
|
+
errs = safe_extract_archive(archive, extracted)
|
|
784
|
+
if errs:
|
|
785
|
+
raise BundleFetchError(BUNDLE_FETCH_ERROR_INVALID, f"archive failed validation: {errs[0]}")
|
|
786
|
+
|
|
787
|
+
tops = [c for c in extracted.iterdir() if c.is_dir()]
|
|
788
|
+
if len(tops) != 1:
|
|
789
|
+
raise BundleFetchError(
|
|
790
|
+
BUNDLE_FETCH_ERROR_INVALID,
|
|
791
|
+
f"archive must contain exactly one top-level directory, found {len(tops)}")
|
|
792
|
+
inner = tops[0] / "aisc-bundle"
|
|
793
|
+
if not inner.is_dir():
|
|
794
|
+
raise BundleFetchError(
|
|
795
|
+
BUNDLE_FETCH_ERROR_INVALID, "archive has no aisc-bundle/ directory")
|
|
796
|
+
|
|
797
|
+
errs = verify_staged_bundle(inner)
|
|
798
|
+
if errs:
|
|
799
|
+
raise BundleFetchError(BUNDLE_FETCH_ERROR_INVALID, f"bundle verification failed: {errs[0]}")
|
|
800
|
+
|
|
801
|
+
bundle_ver = bundle_version(inner) or "?"
|
|
802
|
+
if bundle_ver != target_version and not allow_mismatch:
|
|
803
|
+
raise BundleFetchError(
|
|
804
|
+
BUNDLE_FETCH_ERROR_CONFLICT,
|
|
805
|
+
f"bundle VERSION {bundle_ver} != requested {target_version} (--allow-mismatch to override)")
|
|
806
|
+
|
|
807
|
+
ok, reason = bundle_compatible(inner, cli_version)
|
|
808
|
+
if not ok and not allow_mismatch:
|
|
809
|
+
raise BundleFetchError(BUNDLE_FETCH_ERROR_CONFLICT, f"manifest gate: {reason}")
|
|
810
|
+
|
|
811
|
+
final = bundles / bundle_ver
|
|
812
|
+
if final.exists():
|
|
813
|
+
# Race or version switch: verified bundle already present.
|
|
814
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
815
|
+
return {"status": "already-installed", "version": bundle_ver, "path": str(final)}
|
|
816
|
+
os.replace(inner.parent, final)
|
|
817
|
+
return {"status": "installed", "version": bundle_ver, "path": str(final)}
|
|
818
|
+
finally:
|
|
819
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
820
|
+
|
|
821
|
+
def remove(self, data_root: Path, version: str, active_root: Optional[Path]) -> Dict[str, Any]:
|
|
822
|
+
target = self.bundles_root(data_root) / version
|
|
823
|
+
bundle = target / "aisc-bundle"
|
|
824
|
+
if not bundle.is_dir():
|
|
825
|
+
raise BundleFetchError(BUNDLE_FETCH_ERROR_NOT_FOUND, f"no installed bundle for version {version}")
|
|
826
|
+
if active_root is not None:
|
|
827
|
+
try:
|
|
828
|
+
active_root.resolve().relative_to(bundle.resolve())
|
|
829
|
+
raise BundleFetchError(
|
|
830
|
+
BUNDLE_FETCH_ERROR_CONFLICT,
|
|
831
|
+
f"version {version} is the ACTIVE resolution root — refusing; "
|
|
832
|
+
f"fetch another version first or unset AISC_ROOT")
|
|
833
|
+
except ValueError:
|
|
834
|
+
pass # active root lives elsewhere — safe to remove
|
|
835
|
+
shutil.rmtree(target)
|
|
836
|
+
return {"status": "removed", "version": version}
|