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.
Files changed (92) hide show
  1. aisc/VERSION +1 -0
  2. aisc/__init__.py +49 -0
  3. aisc/__main__.py +6 -0
  4. aisc/adapters/__init__.py +1 -0
  5. aisc/adapters/config_reader.py +215 -0
  6. aisc/adapters/container_registry.py +718 -0
  7. aisc/adapters/data_root_store.py +296 -0
  8. aisc/adapters/docker_.py +1183 -0
  9. aisc/adapters/docker_gateway.py +1244 -0
  10. aisc/adapters/docker_sdk_backed.py +274 -0
  11. aisc/adapters/maintenance_lock.py +97 -0
  12. aisc/adapters/state_file.py +184 -0
  13. aisc/adapters/system.py +101 -0
  14. aisc/adapters/windows_config_reader.py +249 -0
  15. aisc/adapters/workspace_lease_store.py +189 -0
  16. aisc/application/__init__.py +1 -0
  17. aisc/application/artifact.py +265 -0
  18. aisc/application/bundle_fetch.py +836 -0
  19. aisc/application/cc_switch_provider.py +245 -0
  20. aisc/application/cc_switch_resolver.py +363 -0
  21. aisc/application/config_service.py +452 -0
  22. aisc/application/conversation.py +516 -0
  23. aisc/application/data_migration.py +440 -0
  24. aisc/application/data_root.py +275 -0
  25. aisc/application/docker_lifecycle.py +659 -0
  26. aisc/application/doctor.py +821 -0
  27. aisc/application/legacy_scan.py +252 -0
  28. aisc/application/network_subscription.py +642 -0
  29. aisc/application/profile_service.py +105 -0
  30. aisc/application/provider.py +92 -0
  31. aisc/application/repair.py +387 -0
  32. aisc/application/resources.py +270 -0
  33. aisc/application/runtime.py +1826 -0
  34. aisc/application/session.py +320 -0
  35. aisc/application/tool_versions.py +210 -0
  36. aisc/application/toolchain.py +91 -0
  37. aisc/application/update.py +286 -0
  38. aisc/application/usage.py +305 -0
  39. aisc/application/version.py +133 -0
  40. aisc/application/web_gateway.py +459 -0
  41. aisc/application/workspace_reconcile.py +318 -0
  42. aisc/applog.py +103 -0
  43. aisc/cli/__init__.py +1 -0
  44. aisc/cli/build_progress_parser.py +217 -0
  45. aisc/cli/commands/__init__.py +1 -0
  46. aisc/cli/commands/agents.py +117 -0
  47. aisc/cli/commands/artifact.py +210 -0
  48. aisc/cli/commands/build.py +566 -0
  49. aisc/cli/commands/bundle.py +107 -0
  50. aisc/cli/commands/cc_switch.py +158 -0
  51. aisc/cli/commands/config.py +104 -0
  52. aisc/cli/commands/container.py +787 -0
  53. aisc/cli/commands/conversation.py +72 -0
  54. aisc/cli/commands/data_root.py +84 -0
  55. aisc/cli/commands/logs.py +89 -0
  56. aisc/cli/commands/network.py +132 -0
  57. aisc/cli/commands/profile.py +52 -0
  58. aisc/cli/commands/provider.py +49 -0
  59. aisc/cli/commands/run.py +494 -0
  60. aisc/cli/commands/runs.py +125 -0
  61. aisc/cli/commands/runtime.py +450 -0
  62. aisc/cli/commands/serve.py +550 -0
  63. aisc/cli/commands/serve_fs.py +369 -0
  64. aisc/cli/commands/session.py +167 -0
  65. aisc/cli/commands/update.py +145 -0
  66. aisc/cli/commands/usage.py +74 -0
  67. aisc/cli/commands/wizard.py +130 -0
  68. aisc/cli/commands/workspaces.py +162 -0
  69. aisc/cli/help_i18n.py +385 -0
  70. aisc/cli/main.py +2926 -0
  71. aisc/cli/output.py +239 -0
  72. aisc/cli/tutorial.py +109 -0
  73. aisc/domain/__init__.py +1 -0
  74. aisc/domain/artifacts.py +279 -0
  75. aisc/domain/cc_switch_release.py +303 -0
  76. aisc/domain/config.py +64 -0
  77. aisc/domain/data_migration.py +259 -0
  78. aisc/domain/data_root.py +124 -0
  79. aisc/domain/docker_ownership.py +148 -0
  80. aisc/domain/gateway.py +168 -0
  81. aisc/domain/models.py +674 -0
  82. aisc/domain/toolchain.py +72 -0
  83. aisc/domain/web_services.py +355 -0
  84. aisc/domain/workspace_lease.py +139 -0
  85. aisc/schemas/__init__.py +1 -0
  86. aisc/schemas/config_schema.py +145 -0
  87. aisc_cli-0.1.0.dist-info/METADATA +420 -0
  88. aisc_cli-0.1.0.dist-info/RECORD +92 -0
  89. aisc_cli-0.1.0.dist-info/WHEEL +5 -0
  90. aisc_cli-0.1.0.dist-info/entry_points.txt +2 -0
  91. aisc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  92. 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}