From 375e594c8548764417e2378506bc6484458dff47 Mon Sep 17 00:00:00 2001 From: foxsplendid <62349701+foxsplendid@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:17:54 +0800 Subject: [PATCH] Publish Capture v1.0.0 release asset with checksum-pinned download Add hardened HTTPS download for release-asset components (host allowlist, size cap, redirect boundary check). Fix stale CLI test that assumed the capture asset stayed unpublished. --- CHANGELOG.md | 4 +- src/scriptorium/assets/component-catalog.toml | 5 +- src/scriptorium/components.py | 20 +++++ src/scriptorium/installer.py | 77 ++++++++++++++++++- tests/test_cli_install.py | 39 ++++++---- tests/test_components.py | 2 + tests/test_installer.py | 34 ++++++-- 7 files changed, 157 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51bac37..7b0d1ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog -## 0.2.0 (candidate) — 2026-07-22 +## 0.2.0 — 2026-08-10 + +- Publish the checksum-pinned Capture v1.0.0 asset for direct `capture` profile installation. - Add `scriptorium resume`, backed by Provenance's bounded, read-only Context Capsule. It restores approved project state, explicitly labels auto-applied diff --git a/src/scriptorium/assets/component-catalog.toml b/src/scriptorium/assets/component-catalog.toml index a0732a4..8d0b9e3 100644 --- a/src/scriptorium/assets/component-catalog.toml +++ b/src/scriptorium/assets/component-catalog.toml @@ -54,5 +54,6 @@ delivery = "release-asset" directory = "capture" owner = "provenance" artifact_name = "scriptorium-local-conversation-export-1.0.0.zip" -artifact_status = "source-build-only" -artifact_sha256 = "1a85ec282f54bc9cff0c53bb595d7fb5ed37c5ce0121cb036b9fc80574998db8" +artifact_status = "published" +artifact_url = "https://github.com/foxsplendid/Provenance/releases/download/capture-v1.0.0/scriptorium-local-conversation-export-1.0.0.zip" +artifact_sha256 = "9517abd5d1482ae7a5b8b9b015dc8770aa00c2bac1278b2d061e36ad8cbe5270" diff --git a/src/scriptorium/components.py b/src/scriptorium/components.py index 517ff0f..b16cc5a 100644 --- a/src/scriptorium/components.py +++ b/src/scriptorium/components.py @@ -15,8 +15,12 @@ REVISION_RE = re.compile(r"^[0-9a-f]{40}$") SHA256_RE = re.compile(r"^[0-9a-f]{64}$") DIRECTORY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +ARTIFACT_URL_RE = re.compile( + r"^https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/releases/download/[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$" +) STABILITIES = {"stable", "candidate", "experimental"} DELIVERIES = {"source", "python-source", "workspace-source", "release-asset"} +ARTIFACT_STATUSES = {"source-build-only", "published"} class ComponentCatalogError(RuntimeError): @@ -36,6 +40,7 @@ class Component: owner: str | None = None artifact_name: str | None = None artifact_status: str | None = None + artifact_url: str | None = None artifact_sha256: str | None = None @@ -69,6 +74,7 @@ def _load_component(component_id: str, raw: object) -> Component: "owner", "artifact_name", "artifact_status", + "artifact_url", "artifact_sha256", } extra = set(data) - allowed @@ -89,22 +95,34 @@ def _load_component(component_id: str, raw: object) -> Component: owner = data.get("owner") artifact_name = data.get("artifact_name") artifact_status = data.get("artifact_status") + artifact_url = data.get("artifact_url") artifact_sha256 = data.get("artifact_sha256") for name, value in ( ("owner", owner), ("artifact_name", artifact_name), ("artifact_status", artifact_status), + ("artifact_url", artifact_url), ("artifact_sha256", artifact_sha256), ): if value is not None and (not isinstance(value, str) or not value.strip()): raise ComponentCatalogError(f"{where}.{name} must be a non-empty string") if delivery == "release-asset" and not artifact_name: raise ComponentCatalogError(f"{where}.artifact_name is required") + if delivery == "release-asset" and artifact_status not in ARTIFACT_STATUSES: + raise ComponentCatalogError(f"{where}.artifact_status is invalid") if delivery == "release-asset" and ( not isinstance(artifact_sha256, str) or not SHA256_RE.fullmatch(artifact_sha256) ): raise ComponentCatalogError(f"{where}.artifact_sha256 is required") + if artifact_url is not None and ( + not ARTIFACT_URL_RE.fullmatch(artifact_url) + or not isinstance(artifact_name, str) + or not artifact_url.endswith(f"/{artifact_name}") + ): + raise ComponentCatalogError(f"{where}.artifact_url is invalid") + if delivery == "release-asset" and artifact_status == "published" and artifact_url is None: + raise ComponentCatalogError(f"{where}.artifact_url is required for a published asset") directory = _required_string(data, "directory", where=where) if not DIRECTORY_RE.fullmatch(directory) or directory in {".", ".."}: raise ComponentCatalogError(f"{where}.directory must be one safe path segment") @@ -120,6 +138,7 @@ def _load_component(component_id: str, raw: object) -> Component: owner=owner, artifact_name=artifact_name, artifact_status=artifact_status, + artifact_url=artifact_url, artifact_sha256=artifact_sha256, ) @@ -189,6 +208,7 @@ def build_component_report(profile: str | None = None) -> dict[str, object]: "owner": component.owner, "artifact_name": component.artifact_name, "artifact_status": component.artifact_status, + "artifact_url": component.artifact_url, "artifact_sha256": component.artifact_sha256, "repository": component.repository, "revision": component.revision, diff --git a/src/scriptorium/installer.py b/src/scriptorium/installer.py index f6472a8..0120845 100644 --- a/src/scriptorium/installer.py +++ b/src/scriptorium/installer.py @@ -11,6 +11,10 @@ import zipfile from pathlib import Path from pathlib import PurePosixPath +from tempfile import TemporaryDirectory +from urllib.error import URLError +from urllib.parse import urlsplit +from urllib.request import Request, urlopen from uuid import uuid4 from . import __version__ @@ -29,6 +33,8 @@ MAX_ASSET_ENTRIES = 100 MAX_ASSET_BYTES = 10 * 1024 * 1024 COMMAND_TIMEOUT_SECONDS = 300 +DOWNLOAD_TIMEOUT_SECONDS = 60 +DOWNLOAD_CHUNK_BYTES = 64 * 1024 class InstallError(RuntimeError): @@ -157,6 +163,63 @@ def _file_sha256(path: Path) -> str: return digest.hexdigest() +def _download_release_asset(component: Component, destination: Path) -> None: + if component.artifact_url is None: + raise InstallError("release asset is unpublished", code="artifact_unpublished") + request = Request( + component.artifact_url, + headers={"User-Agent": f"scriptorium/{__version__}"}, + ) + try: + with urlopen(request, timeout=DOWNLOAD_TIMEOUT_SECONDS) as response: + final = urlsplit(response.geturl()) + hostname = final.hostname or "" + if ( + final.scheme != "https" + or ( + hostname != "github.com" + and not hostname.endswith(".githubusercontent.com") + ) + or final.username is not None + or final.password is not None + or final.port not in {None, 443} + ): + raise InstallError( + "release asset redirected outside the trusted host boundary", + code="asset_download", + ) + declared_size = response.headers.get("Content-Length") + if declared_size is not None: + try: + parsed_size = int(declared_size) + except ValueError as exc: + raise InstallError( + "release asset size is invalid", code="asset_download" + ) from exc + if parsed_size < 0 or parsed_size > MAX_ASSET_BYTES: + raise InstallError( + "release asset is too large", code="asset_invalid" + ) + total = 0 + with destination.open("xb") as stream: + while True: + chunk = response.read(DOWNLOAD_CHUNK_BYTES) + if not chunk: + break + total += len(chunk) + if total > MAX_ASSET_BYTES: + raise InstallError( + "release asset is too large", code="asset_invalid" + ) + stream.write(chunk) + except InstallError: + raise + except (OSError, URLError, ValueError) as exc: + raise InstallError( + "release asset download failed", code="asset_download" + ) from exc + + def _validate_local_asset(component: Component, asset: Path) -> Path: requested = _absolute(asset) if _is_linklike(requested) or not requested.is_file(): @@ -525,8 +588,18 @@ def execute_install( continue if component.delivery == "release-asset": if asset is None: - raise InstallError("release-asset download is unavailable", code="artifact_unpublished") - _install_release_asset(component, _validate_local_asset(component, asset), destination) + with TemporaryDirectory(prefix="scriptorium-asset-") as temporary: + downloaded = Path(temporary) / str(component.artifact_name) + _download_release_asset(component, downloaded) + _install_release_asset( + component, + _validate_local_asset(component, downloaded), + destination, + ) + else: + _install_release_asset( + component, _validate_local_asset(component, asset), destination + ) else: _install_source(component, destination) _write_environment_scripts(resolved_target, components) diff --git a/tests/test_cli_install.py b/tests/test_cli_install.py index a11cffa..dc839c8 100644 --- a/tests/test_cli_install.py +++ b/tests/test_cli_install.py @@ -6,8 +6,10 @@ import tempfile import unittest from pathlib import Path +from unittest import mock from scriptorium import cli +from scriptorium.installer import InstallError class InstallCliTests(unittest.TestCase): @@ -30,23 +32,34 @@ def test_core_preview_is_zero_write_json(self): self.assertEqual(report["writes"], "none") self.assertFalse(target.exists()) - def test_capture_run_fails_without_creating_target_until_asset_exists(self): + def test_capture_run_marks_target_failed_when_download_fails(self): with tempfile.TemporaryDirectory() as temporary: target = Path(temporary) / "capture" - code, report, stderr = self.invoke( - [ - "install", - "capture", - "--target", - str(target), - "--run", - "--json", - ] - ) + + def failing_download(_component, _destination): + raise InstallError("release asset download failed", code="asset_download") + + with mock.patch( + "scriptorium.installer._download_release_asset", + side_effect=failing_download, + ): + code, report, stderr = self.invoke( + [ + "install", + "capture", + "--target", + str(target), + "--run", + "--json", + ] + ) self.assertEqual(code, 2) self.assertEqual(stderr, "") - self.assertEqual(report["errors"], [{"code": "artifact_unpublished"}]) - self.assertFalse(target.exists()) + self.assertEqual(report["errors"], [{"code": "asset_download"}]) + marker = json.loads( + (target / ".scriptorium-install.json").read_text(encoding="utf-8") + ) + self.assertEqual(marker["state"], "failed") def test_unknown_profile_is_a_stable_json_error(self): with tempfile.TemporaryDirectory() as temporary: diff --git a/tests/test_components.py b/tests/test_components.py index 8c6afc5..629448f 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -14,6 +14,8 @@ def test_catalog_keeps_capture_owned_by_provenance(self): capture = catalog.components["capture"] self.assertEqual(capture.owner, "provenance") self.assertEqual(capture.delivery, "release-asset") + self.assertEqual(capture.artifact_status, "published") + self.assertTrue(capture.artifact_url.endswith(capture.artifact_name)) self.assertNotIn("capture", catalog.profiles["core"]) def test_core_profile_selects_only_contract_and_memory_core(self): diff --git a/tests/test_installer.py b/tests/test_installer.py index 1b84ac1..fd2af34 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -40,17 +40,39 @@ def test_preview_writes_nothing(self): ) self.assertFalse(target.exists()) - def test_capture_preview_is_honest_about_unpublished_asset(self): + def test_capture_preview_reports_published_asset_as_available(self): with tempfile.TemporaryDirectory() as temporary: target = Path(temporary) / "capture" report = plan_install(profile="capture", target=target) - self.assertEqual(report["status"], "action-required") - self.assertEqual(report["summary"]["unavailable"], 1) - self.assertFalse(report["actions"][0]["available"]) - with self.assertRaisesRegex(InstallError, "unpublished"): - execute_install(profile="capture", target=target) + self.assertEqual(report["status"], "planned") + self.assertEqual(report["summary"]["unavailable"], 0) + self.assertTrue(report["actions"][0]["available"]) self.assertFalse(target.exists()) + def test_published_capture_download_installs_without_provenance(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + asset = root / "source.zip" + with zipfile.ZipFile(asset, "w") as archive: + archive.writestr("manifest.json", '{"manifest_version": 3}') + catalog = self.capture_catalog(asset) + target = root / "capture-only" + + def fake_download(_component, destination): + destination.write_bytes(asset.read_bytes()) + + with mock.patch( + "scriptorium.installer._download_release_asset", + side_effect=fake_download, + ): + report = execute_install( + profile="capture", target=target, catalog=catalog + ) + + self.assertEqual(report["status"], "installed") + self.assertTrue((target / "capture" / "unpacked" / "manifest.json").is_file()) + self.assertFalse((target / "Provenance").exists()) + def test_downloaded_capture_asset_installs_without_cloning_provenance(self): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary)