From 80f25da8ad71d71363e740c2d11ffb9dff9ac390 Mon Sep 17 00:00:00 2001 From: Ahnaf Shahriar Date: Thu, 15 Oct 2026 18:06:53 -0700 Subject: [PATCH 1/4] install: read releases via get-release signed URLs The releases bucket is going private. Manifests and artifacts now resolve the object path from the /public/ URL (host-checked), trade the stored PAT for a signed URL at get-release, and download that. 401/403/404 and a missing token give explicit errors. $SIMANTIC_RELEASES_URL still reads directly for staging mirrors. Co-Authored-By: Claude Sonnet 5.5 --- src/simantic/install.py | 68 +++++++++++++++++++++++++++++++++++++++-- tests/test_install.py | 66 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 3 deletions(-) diff --git a/src/simantic/install.py b/src/simantic/install.py index 3e0b858..ffb8a34 100644 --- a/src/simantic/install.py +++ b/src/simantic/install.py @@ -15,7 +15,9 @@ written into site-packages: an installed package may be read-only, and a binary there would vanish on the next upgrade. -Releases are public objects, keyed by version, so fetching needs no account. +The releases bucket is private. Every read goes through the `get-release` edge +function: the object path is traded, with the stored personal access token +(`simantic auth`), for a 300 s signed URL, and that is what is downloaded. Trust comes from two checks: the manifest is signed with a release key whose public half is below, and every artifact is verified against the checksum the manifest gives for it. A manifest that is unsigned, or an artifact without a @@ -44,10 +46,70 @@ from dataclasses import dataclass from pathlib import Path +from . import auth + RELEASES_URL = "https://drjdhqfvrttolueolzif.supabase.co/storage/v1/object/public/releases" +_PUBLIC_PREFIX = "/storage/v1/object/public/releases/" + + +def object_path(url: str) -> str: + """The bucket object path behind a manifest `/public/` URL, host-checked + so a manifest cannot make us send the token's signing request elsewhere.""" + parts = urllib.parse.urlsplit(url) + if ( + parts.scheme != "https" + or parts.netloc != urllib.parse.urlsplit(RELEASES_URL).netloc + or not parts.path.startswith(_PUBLIC_PREFIX) + ): + raise InstallError(f"refusing release URL outside the releases bucket: {url}") + return urllib.parse.unquote(parts.path[len(_PUBLIC_PREFIX):]) + + +def signed_url(path: str, *, timeout: float = 30) -> str: + """Trade the stored token for a signed URL to `path` via get-release.""" + credentials = auth.load() # NotAuthenticated says to run `simantic auth` + origin = RELEASES_URL.split("/storage/", 1)[0] + request = urllib.request.Request( + f"{origin}/functions/v1/get-release?path={urllib.parse.quote(path)}", + headers={"Authorization": f"Bearer {credentials.api_key}"}, + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + url = json.loads(response.read()).get("url") + except urllib.error.HTTPError as exc: + try: + message = json.loads(exc.read()).get("error", "") + except (ValueError, AttributeError, OSError): + message = "" + if exc.code == 401: + raise InstallError( + "release server rejected your token; run `simantic auth` to log in again" + ) from None + if exc.code == 403: + raise InstallError(f"release access denied: {message or 'account not approved'}") from None + if exc.code == 404: + raise InstallError(f"no release object at {path!r}") from None + raise InstallError(f"release signing failed (HTTP {exc.code})") from None + except urllib.error.URLError as exc: + raise InstallError(f"cannot reach the release server: {exc.reason}") from None + except ValueError as exc: + raise InstallError(f"release server returned invalid JSON: {exc}") from None + if not isinstance(url, str) or not url: + raise InstallError("release server returned no signed URL") + return url + + +def _release_url(url: str) -> str: + """The URL to actually fetch. $SIMANTIC_RELEASES_URL (staging, a local + directory) is read directly; the real bucket is read via a signed URL.""" + if "SIMANTIC_RELEASES_URL" in os.environ: + return url + return signed_url(object_path(url)) + + def releases_url() -> str: """Where manifests are served from. $SIMANTIC_RELEASES_URL overrides. @@ -135,7 +197,7 @@ def _verified_manifest(body: bytes) -> dict: def _fetch_manifest(url: str, missing: str, timeout: float) -> dict: try: - with urllib.request.urlopen(urllib.request.Request(url), timeout=timeout) as response: + with urllib.request.urlopen(urllib.request.Request(_release_url(url)), timeout=timeout) as response: body = response.read() except urllib.error.HTTPError as exc: raise InstallError(f"{missing} (HTTP {exc.code} from {url})") from None @@ -236,7 +298,7 @@ def _resolve(manifest: dict, binary: str, rid: str | None) -> Artifact: def download(artifact: Artifact, *, timeout: float = 300) -> bytes: """Fetch the artifact and verify its checksum before it is trusted.""" - request = urllib.request.Request(artifact.url) + request = urllib.request.Request(_release_url(artifact.url)) try: with urllib.request.urlopen(request, timeout=timeout) as response: payload = response.read() diff --git a/tests/test_install.py b/tests/test_install.py index 84452f9..b8c5be4 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -11,6 +11,9 @@ from simantic import auth, install from simantic._locate import BinaryNotFound, locate +REAL_RELEASE_URL = install._release_url +PUBLIC = install.RELEASES_URL + "/cli/0.4.0/osx-arm64/sim.zip" + MANIFEST = { "version": "0.4.0", "artifacts": { @@ -26,6 +29,7 @@ def home(tmp_path, monkeypatch): monkeypatch.delenv("SIMANTIC_HOME", raising=False) # Fetching requires an account, so the common case here is authenticated. auth.save("smtc_" + "a" * 32, "dev@example.com") + monkeypatch.setattr(install, "_release_url", lambda url: url) # Most of this file is about what happens to a URL once it is known — # channels, products, checksums, placement. The gate that decides *which* # URL is exercised in "the release gate" below, and turned off here so @@ -43,6 +47,68 @@ def zipped(name: str, body: bytes) -> bytes: # --- the account gate --- +def _http_error(code, body=b""): + return install.urllib.error.HTTPError("u", code, "x", {}, io.BytesIO(body)) + + +def test_object_path_strips_public_prefix(): + assert install.object_path(PUBLIC) == "cli/0.4.0/osx-arm64/sim.zip" + + +@pytest.mark.parametrize("url", [ + "https://evil.example/storage/v1/object/public/releases/cli/latest.json", + "http://drjdhqfvrttolueolzif.supabase.co/storage/v1/object/public/releases/x", + install.RELEASES_URL.replace("/public/", "/sign/") + "/x", +]) +def test_object_path_refuses_foreign_urls(url): + with pytest.raises(install.InstallError): + install.object_path(url) + + +def test_release_url_signs_with_the_stored_token(monkeypatch): + monkeypatch.delenv("SIMANTIC_RELEASES_URL", raising=False) + seen = [] + + def urlopen(request, **k): + seen.append(request) + return io.BytesIO(b'{"url": "https://signed.example/o?t=1", "expires_in": 300}') + + monkeypatch.setattr(install.urllib.request, "urlopen", urlopen) + assert REAL_RELEASE_URL(PUBLIC) == "https://signed.example/o?t=1" + assert seen[0].full_url.endswith( + "/functions/v1/get-release?path=cli/0.4.0/osx-arm64/sim.zip") + assert seen[0].get_header("Authorization") == "Bearer smtc_" + "a" * 32 + + +def test_403_shows_the_server_message(monkeypatch): + monkeypatch.delenv("SIMANTIC_RELEASES_URL", raising=False) + + def urlopen(request, **k): + raise _http_error(403, b'{"error": "account not approved yet"}') + + monkeypatch.setattr(install.urllib.request, "urlopen", urlopen) + with pytest.raises(install.InstallError, match="account not approved yet"): + REAL_RELEASE_URL(PUBLIC) + + +def test_401_says_to_log_in(monkeypatch): + monkeypatch.delenv("SIMANTIC_RELEASES_URL", raising=False) + + def urlopen(request, **k): + raise _http_error(401, b'{"error": "bad"}') + + monkeypatch.setattr(install.urllib.request, "urlopen", urlopen) + with pytest.raises(install.InstallError, match="simantic auth"): + REAL_RELEASE_URL(PUBLIC) + + +def test_no_token_says_how_to_log_in(monkeypatch, home): + monkeypatch.delenv("SIMANTIC_RELEASES_URL", raising=False) + (home / ".sim_id").unlink() + with pytest.raises(auth.NotAuthenticated, match="simantic auth"): + REAL_RELEASE_URL(PUBLIC) + + # --- release channels --- From 274d7e16c9c640573da4883d1d15e42fc87ed13a Mon Sep 17 00:00:00 2001 From: Ahnaf Shahriar Date: Thu, 15 Oct 2026 22:32:18 -0700 Subject: [PATCH 2/4] install: manifests carry bucket `path`, not `url`; drop public-URL compat Artifacts are read from `path` and passed straight to get-release. Old public-URL manifests and clients are intentionally unsupported. Co-Authored-By: Claude Sonnet 5.5 --- src/simantic/install.py | 80 +++++++++++++++++++---------------------- tests/test_install.py | 59 ++++++++++++++++-------------- 2 files changed, 68 insertions(+), 71 deletions(-) diff --git a/src/simantic/install.py b/src/simantic/install.py index ffb8a34..fa1dda9 100644 --- a/src/simantic/install.py +++ b/src/simantic/install.py @@ -5,7 +5,7 @@ releases//.json {"version": "0.4.0", - "artifacts": {"osx-arm64": {"url": ..., "sha256": ...}, ...}} + "artifacts": {"osx-arm64": {"path": ..., "sha256": ...}, ...}} The channel is just the manifest name, so publishing a pre-release means uploading a second pointer beside latest.json rather than standing up @@ -49,31 +49,30 @@ from . import auth -RELEASES_URL = "https://drjdhqfvrttolueolzif.supabase.co/storage/v1/object/public/releases" +SUPABASE_ORIGIN = "https://drjdhqfvrttolueolzif.supabase.co" -_PUBLIC_PREFIX = "/storage/v1/object/public/releases/" - - -def object_path(url: str) -> str: - """The bucket object path behind a manifest `/public/` URL, host-checked - so a manifest cannot make us send the token's signing request elsewhere.""" - parts = urllib.parse.urlsplit(url) +def checked_path(path) -> str: + """A manifest `path` is a bare object path in the releases bucket; anything + else (empty, absolute, scheme, `..`) is refused before it reaches get-release.""" if ( - parts.scheme != "https" - or parts.netloc != urllib.parse.urlsplit(RELEASES_URL).netloc - or not parts.path.startswith(_PUBLIC_PREFIX) + not isinstance(path, str) + or not path + or path.startswith("/") + or "\\" in path + or "://" in path + or ".." in path.split("/") ): - raise InstallError(f"refusing release URL outside the releases bucket: {url}") - return urllib.parse.unquote(parts.path[len(_PUBLIC_PREFIX):]) + raise InstallError(f"refusing invalid release path: {path!r}") + return path def signed_url(path: str, *, timeout: float = 30) -> str: """Trade the stored token for a signed URL to `path` via get-release.""" + path = checked_path(path) credentials = auth.load() # NotAuthenticated says to run `simantic auth` - origin = RELEASES_URL.split("/storage/", 1)[0] request = urllib.request.Request( - f"{origin}/functions/v1/get-release?path={urllib.parse.quote(path)}", + f"{SUPABASE_ORIGIN}/functions/v1/get-release?path={urllib.parse.quote(path)}", headers={"Authorization": f"Bearer {credentials.api_key}"}, ) try: @@ -97,26 +96,19 @@ def signed_url(path: str, *, timeout: float = 30) -> str: raise InstallError(f"cannot reach the release server: {exc.reason}") from None except ValueError as exc: raise InstallError(f"release server returned invalid JSON: {exc}") from None - if not isinstance(url, str) or not url: - raise InstallError("release server returned no signed URL") + if not isinstance(url, str) or not url.startswith("https://"): + raise InstallError("release server returned no https signed URL") return url -def _release_url(url: str) -> str: - """The URL to actually fetch. $SIMANTIC_RELEASES_URL (staging, a local - directory) is read directly; the real bucket is read via a signed URL.""" - if "SIMANTIC_RELEASES_URL" in os.environ: - return url - return signed_url(object_path(url)) - - -def releases_url() -> str: - """Where manifests are served from. $SIMANTIC_RELEASES_URL overrides. - - Overridable so a release can be rehearsed against a staging host (or a - plain directory served over HTTP) before it is published. - """ - return os.environ.get("SIMANTIC_RELEASES_URL", RELEASES_URL).rstrip("/") +def _release_url(path: str) -> str: + """The URL to fetch for a bucket object path. $SIMANTIC_RELEASES_URL + (staging, a local directory served over HTTP) is read directly as + /; the real bucket goes through get-release.""" + base = os.environ.get("SIMANTIC_RELEASES_URL") + if base is not None: + return f"{base.rstrip('/')}/{checked_path(path)}" + return signed_url(path) #: The manifest to read. $SIMANTIC_CHANNEL selects a pre-release channel. @@ -195,12 +187,12 @@ def _verified_manifest(body: bytes) -> dict: raise InstallError(f"release manifest is not valid JSON: {exc}") from None -def _fetch_manifest(url: str, missing: str, timeout: float) -> dict: +def _fetch_manifest(path: str, missing: str, timeout: float) -> dict: try: - with urllib.request.urlopen(urllib.request.Request(_release_url(url)), timeout=timeout) as response: + with urllib.request.urlopen(urllib.request.Request(_release_url(path)), timeout=timeout) as response: body = response.read() except urllib.error.HTTPError as exc: - raise InstallError(f"{missing} (HTTP {exc.code} from {url})") from None + raise InstallError(f"{missing} (HTTP {exc.code} from {path})") from None except urllib.error.URLError as exc: raise InstallError(f"cannot reach the release server: {exc.reason}") from None return _verified_manifest(body) @@ -209,7 +201,7 @@ def _fetch_manifest(url: str, missing: str, timeout: float) -> dict: @dataclass(frozen=True) class Artifact: version: str - url: str + path: str sha256: str @@ -264,8 +256,8 @@ def fetch_manifest( f"unknown binary {binary!r}; expected one of {sorted(PRODUCTS)}" ) channel = channel or default_channel() - url = f"{releases_url()}/{product}/{channel}.signed.json" - return _fetch_manifest(url, f"no published releases for {binary!r}", timeout) + path = f"{product}/{channel}.signed.json" + return _fetch_manifest(path, f"no published releases for {binary!r}", timeout) def resolve( @@ -283,7 +275,7 @@ def _resolve(manifest: dict, binary: str, rid: str | None) -> Artifact: rid = rid or current_rid() entry = artifacts.get(rid) - if not entry or not entry.get("url"): + if not entry or not entry.get("path"): available = ", ".join(sorted(artifacts)) or "none" raise InstallError( f"no {rid} build in {binary} release {version} (available: {available})" @@ -293,12 +285,12 @@ def _resolve(manifest: dict, binary: str, rid: str | None) -> Artifact: raise InstallError( f"{rid} build in {binary} release {version} has no sha256; refusing to install" ) - return Artifact(version=version, url=entry["url"], sha256=sha256.strip().lower()) + return Artifact(version=version, path=checked_path(entry["path"]), sha256=sha256.strip().lower()) def download(artifact: Artifact, *, timeout: float = 300) -> bytes: """Fetch the artifact and verify its checksum before it is trusted.""" - request = urllib.request.Request(_release_url(artifact.url)) + request = urllib.request.Request(_release_url(artifact.path)) try: with urllib.request.urlopen(request, timeout=timeout) as response: payload = response.read() @@ -484,8 +476,8 @@ def installed_rust_engine() -> Path | None: def fetch_rust_manifest(*, channel: str | None = None, timeout: float = 30) -> dict: channel = channel or default_channel() - url = f"{releases_url()}/{RUST_ENGINE_PRODUCT}/{channel}.signed.json" - return _fetch_manifest(url, "no published Rust engine", timeout) + path = f"{RUST_ENGINE_PRODUCT}/{channel}.signed.json" + return _fetch_manifest(path, "no published Rust engine", timeout) def install_rust_engine(*, force: bool = False, channel: str | None = None) -> Path: diff --git a/tests/test_install.py b/tests/test_install.py index b8c5be4..3334fc0 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -12,13 +12,13 @@ from simantic._locate import BinaryNotFound, locate REAL_RELEASE_URL = install._release_url -PUBLIC = install.RELEASES_URL + "/cli/0.4.0/osx-arm64/sim.zip" +PATH = "cli/0.4.0/osx-arm64/sim.zip" MANIFEST = { "version": "0.4.0", "artifacts": { - "osx-arm64": {"url": "https://example.invalid/sim.zip", "sha256": "ab" * 32}, - "linux-x64": {"url": "https://example.invalid/sim-linux.zip"}, + "osx-arm64": {"path": "cli/0.4.0/osx-arm64/sim.zip", "sha256": "ab" * 32}, + "linux-x64": {"path": "cli/0.4.0/linux-x64/sim.zip"}, }, } @@ -29,7 +29,7 @@ def home(tmp_path, monkeypatch): monkeypatch.delenv("SIMANTIC_HOME", raising=False) # Fetching requires an account, so the common case here is authenticated. auth.save("smtc_" + "a" * 32, "dev@example.com") - monkeypatch.setattr(install, "_release_url", lambda url: url) + monkeypatch.setattr(install, "_release_url", lambda path: "https://releases.invalid/" + path) # Most of this file is about what happens to a URL once it is known — # channels, products, checksums, placement. The gate that decides *which* # URL is exercised in "the release gate" below, and turned off here so @@ -51,18 +51,22 @@ def _http_error(code, body=b""): return install.urllib.error.HTTPError("u", code, "x", {}, io.BytesIO(body)) -def test_object_path_strips_public_prefix(): - assert install.object_path(PUBLIC) == "cli/0.4.0/osx-arm64/sim.zip" - - -@pytest.mark.parametrize("url", [ - "https://evil.example/storage/v1/object/public/releases/cli/latest.json", - "http://drjdhqfvrttolueolzif.supabase.co/storage/v1/object/public/releases/x", - install.RELEASES_URL.replace("/public/", "/sign/") + "/x", +@pytest.mark.parametrize("path", [ + "", None, "../cli/x", "cli/../x", "/cli/x", "https://evil.example/x", "a\\b", ]) -def test_object_path_refuses_foreign_urls(url): +def test_invalid_paths_are_rejected(path): + with pytest.raises(install.InstallError): + install.checked_path(path) with pytest.raises(install.InstallError): - install.object_path(url) + install._resolve({"version": "1", "artifacts": {"r": {"path": path, "sha256": "0" * 64}}}, "sim", "r") + + +def test_signed_url_must_be_https(monkeypatch): + monkeypatch.delenv("SIMANTIC_RELEASES_URL", raising=False) + monkeypatch.setattr(install.urllib.request, "urlopen", + lambda r, **k: io.BytesIO(b'{"url": "http://signed.example/o"}')) + with pytest.raises(install.InstallError, match="https"): + REAL_RELEASE_URL(PATH) def test_release_url_signs_with_the_stored_token(monkeypatch): @@ -74,7 +78,7 @@ def urlopen(request, **k): return io.BytesIO(b'{"url": "https://signed.example/o?t=1", "expires_in": 300}') monkeypatch.setattr(install.urllib.request, "urlopen", urlopen) - assert REAL_RELEASE_URL(PUBLIC) == "https://signed.example/o?t=1" + assert REAL_RELEASE_URL(PATH) == "https://signed.example/o?t=1" assert seen[0].full_url.endswith( "/functions/v1/get-release?path=cli/0.4.0/osx-arm64/sim.zip") assert seen[0].get_header("Authorization") == "Bearer smtc_" + "a" * 32 @@ -88,7 +92,7 @@ def urlopen(request, **k): monkeypatch.setattr(install.urllib.request, "urlopen", urlopen) with pytest.raises(install.InstallError, match="account not approved yet"): - REAL_RELEASE_URL(PUBLIC) + REAL_RELEASE_URL(PATH) def test_401_says_to_log_in(monkeypatch): @@ -99,14 +103,14 @@ def urlopen(request, **k): monkeypatch.setattr(install.urllib.request, "urlopen", urlopen) with pytest.raises(install.InstallError, match="simantic auth"): - REAL_RELEASE_URL(PUBLIC) + REAL_RELEASE_URL(PATH) def test_no_token_says_how_to_log_in(monkeypatch, home): monkeypatch.delenv("SIMANTIC_RELEASES_URL", raising=False) (home / ".sim_id").unlink() with pytest.raises(auth.NotAuthenticated, match="simantic auth"): - REAL_RELEASE_URL(PUBLIC) + REAL_RELEASE_URL(PATH) # --- release channels --- @@ -135,6 +139,7 @@ def test_explicit_channel_beats_the_environment(monkeypatch): def test_releases_host_can_be_redirected(monkeypatch): """Rehearse a release against staging before publishing it.""" + monkeypatch.setattr(install, "_release_url", REAL_RELEASE_URL) url = _requested_url(monkeypatch, {"SIMANTIC_RELEASES_URL": "http://localhost:8765/"}) assert url == "http://localhost:8765/cli/latest.signed.json" @@ -224,7 +229,7 @@ def test_checksum_mismatch_refuses_the_binary(monkeypatch): monkeypatch.setattr( install.urllib.request, "urlopen", _fake_urlopen(b"tampered") ) - artifact = install.Artifact("0.4.0", "https://example.invalid/x", "00" * 32) + artifact = install.Artifact("0.4.0", "cli/x", "00" * 32) with pytest.raises(install.InstallError, match="checksum mismatch"): install.download(artifact) @@ -233,7 +238,7 @@ def test_matching_checksum_is_accepted(monkeypatch): payload = b"genuine" monkeypatch.setattr(install.urllib.request, "urlopen", _fake_urlopen(payload)) artifact = install.Artifact( - "0.4.0", "https://example.invalid/x", hashlib.sha256(payload).hexdigest() + "0.4.0", "cli/x", hashlib.sha256(payload).hexdigest() ) assert install.download(artifact) == payload @@ -241,7 +246,7 @@ def test_matching_checksum_is_accepted(monkeypatch): def test_manifest_without_a_checksum_is_refused(): """The checksum is the only thing that makes the bucket trustworthy, so a manifest entry that omits it cannot install anything.""" - manifest = {"version": "0.4.0", "artifacts": {"osx-arm64": {"url": "https://example.invalid/x"}}} + manifest = {"version": "0.4.0", "artifacts": {"osx-arm64": {"path": "cli/x"}}} with pytest.raises(install.InstallError, match="no sha256"): install._resolve(manifest, "sim", "osx-arm64") @@ -342,7 +347,7 @@ def test_a_manifest_signed_by_another_key_is_refused(): def test_a_tampered_manifest_is_refused(test_key): envelope = json.loads(signed(MANIFEST)) - envelope["manifest"] = envelope["manifest"].replace("example.invalid", "evil.invalid") + envelope["manifest"] = envelope["manifest"].replace("cli/0.4.0", "cli/6.6.6") with pytest.raises(install.InstallError, match="signature is not valid"): install._verified_manifest(json.dumps(envelope).encode()) @@ -425,7 +430,7 @@ def test_install_writes_an_executable_and_is_found(monkeypatch, home): install, "resolve", lambda b, **k: install.Artifact( - "0.4.0", "https://example.invalid/x", hashlib.sha256(payload).hexdigest() + "0.4.0", "cli/x", hashlib.sha256(payload).hexdigest() ), ) monkeypatch.setattr(install.urllib.request, "urlopen", _fake_urlopen(payload)) @@ -532,7 +537,7 @@ def engine_zip(files: dict[str, bytes]) -> bytes: def fake_release(monkeypatch, files=ENGINE_FILES, version="0.9.0"): seen = [] monkeypatch.setattr(install, "resolve", lambda binary, rid=None, channel=None: ( - seen.append(rid), install.Artifact(version=version, url="https://releases.example/engine.zip", sha256="00" * 32))[1]) + seen.append(rid), install.Artifact(version=version, path="engine/e.zip", sha256="00" * 32))[1]) monkeypatch.setattr(install, "download", lambda artifact, timeout=300: engine_zip(files)) return seen @@ -576,7 +581,7 @@ def test_installed_engine_picks_the_newest_version(home, monkeypatch): def test_engine_archive_paths_must_stay_inside(home, monkeypatch): monkeypatch.setattr(install, "resolve", lambda binary, rid=None, channel=None: install.Artifact( - version="0.9.0", url="u", sha256="00" * 32)) + version="0.9.0", path="u", sha256="00" * 32)) monkeypatch.setattr(install, "download", lambda artifact, timeout=300: engine_zip({"../escape": b"x"})) with pytest.raises(install.InstallError): install.install_engine() @@ -598,8 +603,8 @@ def test_engine_dir_explains_when_no_release_is_reachable(home, monkeypatch): RUST_MANIFEST = { "version": "0.3.0", "artifacts": { - "engine-rust-osx-arm64": {"url": "https://releases.example/r.whl", "sha256": "00" * 32}, - "engine-rust-linux-x64": {"url": "https://releases.example/r.whl", "sha256": "00" * 32}, + "engine-rust-osx-arm64": {"path": "pyrite/0.3.0/r.whl", "sha256": "00" * 32}, + "engine-rust-linux-x64": {"path": "pyrite/0.3.0/r.whl", "sha256": "00" * 32}, }, } From 5c587f566f09a53213b946d0b6bbf286bed747fb Mon Sep 17 00:00:00 2001 From: Ahnaf Shahriar Date: Thu, 15 Oct 2026 22:37:14 -0700 Subject: [PATCH 3/4] install: drop the retired C# cli product; sim resolves from pyrite/ PRODUCTS["sim"] is now "pyrite". Remove install_engine, ENGINE_KEY and the engine_dir fetch fallback (they read the cli/ manifest). Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- docs/session-api.md | 3 +- src/simantic/_cli.py | 10 ++--- src/simantic/engine.py | 14 ++---- src/simantic/install.py | 24 +---------- src/simantic/session.py | 6 +-- tests/test_install.py | 95 +++++++++++------------------------------ tests/test_session.py | 4 +- 8 files changed, 41 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index 507cea2..62fc854 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ expect_frames_absent: ["CAN Dropped"] Single-machine manifests (`mcu:` at the top level) are the one-machine case. Models resolve through your account; no checkout of ours is needed. A model can require a minimum engine version; if yours is older, the failure names the -version and the fix (`simantic install engine --force`). +version and the fix (use a newer engine build). Three more keys cover fixtures that need files or peers on the host side: `sparse_files` creates blank files of a given size before the run (a blank SD diff --git a/docs/session-api.md b/docs/session-api.md index ab5ff33..1eba883 100644 --- a/docs/session-api.md +++ b/docs/session-api.md @@ -43,8 +43,7 @@ medium), `sd-card` (`bus`, `size` such as `"16MiB"`, `image`: a formatted card i list cannot express. Needs engine 0.5.17+. A model can require a newer engine than the one installed (the STM32H7 parts -need 0.6.3). `Sim` then raises `SimError` naming the version it needs; update -with `simantic install engine --force`. +need 0.6.3). `Sim` then raises `SimError` naming the version it needs; use a newer engine build (the C# engine is no longer published). `symbols_elf=` (single-machine form) / `symbolsElfPath` (scenario machine entries — same key as the CLI's scenario YAML, so a scenario dict is diff --git a/src/simantic/_cli.py b/src/simantic/_cli.py index 290115d..9468b28 100644 --- a/src/simantic/_cli.py +++ b/src/simantic/_cli.py @@ -40,13 +40,11 @@ def _auth(args) -> int: def _install(args) -> int: - names = args.binary or [name for name, _ in BINARIES] + [install.ENGINE_KEY, install.RUST_ENGINE_KEY] + names = args.binary or [name for name, _ in BINARIES] + [install.RUST_ENGINE_KEY] failures = 0 for name in names: try: - if name == install.ENGINE_KEY: - path = install.install_engine(force=args.force, channel=args.channel) - elif name == install.RUST_ENGINE_KEY: + if name == install.RUST_ENGINE_KEY: path = install.install_rust_engine(force=args.force, channel=args.channel) else: path = install.install(name, force=args.force, channel=args.channel) @@ -74,7 +72,7 @@ def _status(args) -> int: except BinaryNotFound: print(f" {name}: not found (run `simantic install {name}`)") engine = install.installed_engine() - print(f" engine: {engine if engine else 'not found (fetched on first use, or `simantic install engine`)'}") + print(f" engine: {engine if engine else 'not found (no longer installable; local builds only)'}") rust = install.installed_rust_engine() missing = 'not found (fetched on first use of backend="rust", or `simantic install engine-rust`)' print(f" engine-rust: {rust if rust else missing}") @@ -138,7 +136,7 @@ def main(argv: list[str] | None = None) -> int: p_auth.set_defaults(func=_auth) p_install = sub.add_parser("install", help="download simulator binaries") - p_install.add_argument("binary", nargs="*", help="defaults to all known binaries and both engines") + p_install.add_argument("binary", nargs="*", help="defaults to `sim` and `engine-rust`") p_install.add_argument( "--force", action="store_true", help="re-download even if already present" ) diff --git a/src/simantic/engine.py b/src/simantic/engine.py index 9444e5f..8bd34e2 100644 --- a/src/simantic/engine.py +++ b/src/simantic/engine.py @@ -56,7 +56,7 @@ def check_engine_version(d: Path) -> None: have = ".".join(str(p) for p in found) raise EngineTooOld( f"engine {have} at {d} is older than {want}, which this package needs. " - f"Run `simantic install engine --force` to fetch the current one." + f"Use a newer engine build." ) @@ -73,9 +73,8 @@ def engine_dir(explicit: str | os.PathLike[str] | None = None, *, fetch: bool = Order: an explicit path, $SIMANTIC_ENGINE_DIR, a development `sim` whose publish directory is beside it ($SIMANTIC_SIM), then the managed install - under ~/.simantic/engine. When nothing is there, the engine is fetched - from the public release — so the first `Sim(...)` after `pip install` - just works. + under ~/.simantic/engine. The C# engine is no longer published or + fetched; the Rust engine is the installable one. """ candidates = [] if explicit is not None: @@ -92,13 +91,8 @@ def engine_dir(explicit: str | os.PathLike[str] | None = None, *, fetch: bool = managed = install.installed_engine() if managed is not None: return managed - if fetch: - try: - return install.install_engine() - except install.InstallError as exc: - raise EngineNotFound(f"could not fetch the engine: {exc}") from None raise EngineNotFound( - f"Simantic.Core.dll not found. Run `simantic install engine`, or set ${ENV_DIR}." + f"Simantic.Core.dll not found. Set ${ENV_DIR}." ) diff --git a/src/simantic/install.py b/src/simantic/install.py index fa1dda9..8145f12 100644 --- a/src/simantic/install.py +++ b/src/simantic/install.py @@ -243,7 +243,7 @@ def current_rid() -> str: #: Binary name -> release product prefix. PRODUCTS = { - "sim": "cli", + "sim": "pyrite", } @@ -375,9 +375,6 @@ def install(binary: str, *, force: bool = False, channel: str | None = None) -> # -- the engine: Simantic.Core + a private .NET runtime, for in-process use ---- -ENGINE_KEY = "engine" - - def engine_root() -> Path: """Where engine releases live: ~/.simantic/engine//.""" return simantic_home() / "engine" @@ -416,25 +413,6 @@ def _version_dir(root: Path, version: str) -> Path: return target -def install_engine(*, force: bool = False, channel: str | None = None) -> Path: - """Download the engine for this machine into engine_root()/. - - One zip from the `sim` release manifest, key `engine-`: the - Simantic.Core publish directory plus a private .NET runtime under - `dotnet/`, laid out exactly as published. - """ - artifact = resolve("sim", rid=f"{ENGINE_KEY}-{current_rid()}", channel=channel) - target = _version_dir(engine_root(), artifact.version) - if (target / "Simantic.Core.dll").exists() and not force: - return target - - payload = download(artifact) - if not payload.startswith(b"PK\x03\x04"): - raise InstallError("engine artifact is not a zip archive") - _unpack_zip(payload, target) - return target - - # -- the Rust engine: the simantic_rust extension module ---------------------- RUST_ENGINE_KEY = "engine-rust" diff --git a/src/simantic/session.py b/src/simantic/session.py index 7623285..90a359f 100644 --- a/src/simantic/session.py +++ b/src/simantic/session.py @@ -146,7 +146,7 @@ def _start_error(exc: Exception) -> str: text = str(exc) if "Could not find suitable constructor" in text: return ("could not start the simulation: this model needs a newer engine " - "than the one installed.\n\n simantic install engine --force\n\n" + "than the one installed.\n\n (the C# engine is no longer published; use a newer build)\n\n" + text.splitlines()[0]) return f"could not start the simulation: {text}" @@ -171,7 +171,7 @@ def _needs_newer_engine(mcus, engine: Path) -> str | None: continue if needed > have: return (f"{mcu} needs engine {want} or newer; " - f"{'.'.join(map(str, have))} is installed.\n\n simantic install engine --force") + f"{'.'.join(map(str, have))} is installed.\n\n (the C# engine is no longer published; use a newer build)") return None class Sim: @@ -521,7 +521,7 @@ def _fragment(self, ns, parts, overlay) -> str | None: text = "" if parts: if not hasattr(ns, "Parts"): - raise SimError("parts= needs engine 0.5.17 or newer: run `simantic install engine`") + raise SimError("parts= needs engine 0.5.17 or newer: use a newer engine build") parts = [_resolve_paths(p, self._base) for p in parts] text = ns.Parts.Fragment(json.dumps(parts)) if overlay: diff --git a/tests/test_install.py b/tests/test_install.py index 3334fc0..daf2bcb 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -12,13 +12,13 @@ from simantic._locate import BinaryNotFound, locate REAL_RELEASE_URL = install._release_url -PATH = "cli/0.4.0/osx-arm64/sim.zip" +PATH = "pyrite/0.4.0/osx-arm64/sim.zip" MANIFEST = { "version": "0.4.0", "artifacts": { - "osx-arm64": {"path": "cli/0.4.0/osx-arm64/sim.zip", "sha256": "ab" * 32}, - "linux-x64": {"path": "cli/0.4.0/linux-x64/sim.zip"}, + "osx-arm64": {"path": "pyrite/0.4.0/osx-arm64/sim.zip", "sha256": "ab" * 32}, + "linux-x64": {"path": "pyrite/0.4.0/linux-x64/sim.zip"}, }, } @@ -52,7 +52,7 @@ def _http_error(code, body=b""): @pytest.mark.parametrize("path", [ - "", None, "../cli/x", "cli/../x", "/cli/x", "https://evil.example/x", "a\\b", + "", None, "../pyrite/x", "pyrite/../x", "/pyrite/x", "https://evil.example/x", "a\\b", ]) def test_invalid_paths_are_rejected(path): with pytest.raises(install.InstallError): @@ -80,7 +80,7 @@ def urlopen(request, **k): monkeypatch.setattr(install.urllib.request, "urlopen", urlopen) assert REAL_RELEASE_URL(PATH) == "https://signed.example/o?t=1" assert seen[0].full_url.endswith( - "/functions/v1/get-release?path=cli/0.4.0/osx-arm64/sim.zip") + "/functions/v1/get-release?path=pyrite/0.4.0/osx-arm64/sim.zip") assert seen[0].get_header("Authorization") == "Bearer smtc_" + "a" * 32 @@ -117,31 +117,31 @@ def test_no_token_says_how_to_log_in(monkeypatch, home): def test_latest_is_the_default_channel(monkeypatch): - assert _requested_url(monkeypatch, {}) .endswith("/cli/latest.signed.json") + assert _requested_url(monkeypatch, {}) .endswith("/pyrite/latest.signed.json") def test_channel_argument_selects_the_manifest(monkeypatch): url = _requested_url(monkeypatch, {}, channel="testing") - assert url.endswith("/cli/testing.signed.json") + assert url.endswith("/pyrite/testing.signed.json") def test_environment_selects_the_channel(monkeypatch): """So a tester can opt in once instead of on every command.""" assert _requested_url(monkeypatch, {"SIMANTIC_CHANNEL": "testing"}).endswith( - "/cli/testing.signed.json" + "/pyrite/testing.signed.json" ) def test_explicit_channel_beats_the_environment(monkeypatch): url = _requested_url(monkeypatch, {"SIMANTIC_CHANNEL": "testing"}, channel="latest") - assert url.endswith("/cli/latest.signed.json") + assert url.endswith("/pyrite/latest.signed.json") def test_releases_host_can_be_redirected(monkeypatch): """Rehearse a release against staging before publishing it.""" monkeypatch.setattr(install, "_release_url", REAL_RELEASE_URL) url = _requested_url(monkeypatch, {"SIMANTIC_RELEASES_URL": "http://localhost:8765/"}) - assert url == "http://localhost:8765/cli/latest.signed.json" + assert url == "http://localhost:8765/pyrite/latest.signed.json" def _requested_url(monkeypatch, env, *, binary="sim", **kwargs) -> str: @@ -229,7 +229,7 @@ def test_checksum_mismatch_refuses_the_binary(monkeypatch): monkeypatch.setattr( install.urllib.request, "urlopen", _fake_urlopen(b"tampered") ) - artifact = install.Artifact("0.4.0", "cli/x", "00" * 32) + artifact = install.Artifact("0.4.0", "pyrite/x", "00" * 32) with pytest.raises(install.InstallError, match="checksum mismatch"): install.download(artifact) @@ -238,7 +238,7 @@ def test_matching_checksum_is_accepted(monkeypatch): payload = b"genuine" monkeypatch.setattr(install.urllib.request, "urlopen", _fake_urlopen(payload)) artifact = install.Artifact( - "0.4.0", "cli/x", hashlib.sha256(payload).hexdigest() + "0.4.0", "pyrite/x", hashlib.sha256(payload).hexdigest() ) assert install.download(artifact) == payload @@ -246,7 +246,7 @@ def test_matching_checksum_is_accepted(monkeypatch): def test_manifest_without_a_checksum_is_refused(): """The checksum is the only thing that makes the bucket trustworthy, so a manifest entry that omits it cannot install anything.""" - manifest = {"version": "0.4.0", "artifacts": {"osx-arm64": {"path": "cli/x"}}} + manifest = {"version": "0.4.0", "artifacts": {"osx-arm64": {"path": "pyrite/x"}}} with pytest.raises(install.InstallError, match="no sha256"): install._resolve(manifest, "sim", "osx-arm64") @@ -347,7 +347,7 @@ def test_a_manifest_signed_by_another_key_is_refused(): def test_a_tampered_manifest_is_refused(test_key): envelope = json.loads(signed(MANIFEST)) - envelope["manifest"] = envelope["manifest"].replace("cli/0.4.0", "cli/6.6.6") + envelope["manifest"] = envelope["manifest"].replace("pyrite/0.4.0", "pyrite/6.6.6") with pytest.raises(install.InstallError, match="signature is not valid"): install._verified_manifest(json.dumps(envelope).encode()) @@ -430,7 +430,7 @@ def test_install_writes_an_executable_and_is_found(monkeypatch, home): install, "resolve", lambda b, **k: install.Artifact( - "0.4.0", "cli/x", hashlib.sha256(payload).hexdigest() + "0.4.0", "pyrite/x", hashlib.sha256(payload).hexdigest() ), ) monkeypatch.setattr(install.urllib.request, "urlopen", _fake_urlopen(payload)) @@ -527,36 +527,6 @@ def engine_zip(files: dict[str, bytes]) -> bytes: return buf.getvalue() -ENGINE_FILES = { - "Simantic.Core.dll": b"dll", - "sim.runtimeconfig.json": b"{}", - "dotnet/host/fxr/10.0.0/libhostfxr.dylib": b"fxr", -} - - -def fake_release(monkeypatch, files=ENGINE_FILES, version="0.9.0"): - seen = [] - monkeypatch.setattr(install, "resolve", lambda binary, rid=None, channel=None: ( - seen.append(rid), install.Artifact(version=version, path="engine/e.zip", sha256="00" * 32))[1]) - monkeypatch.setattr(install, "download", lambda artifact, timeout=300: engine_zip(files)) - return seen - - -def test_install_engine_unpacks_under_a_version_dir(home, monkeypatch): - fake_release(monkeypatch) - target = install.install_engine() - assert target == install.engine_root() / "0.9.0" - assert (target / "Simantic.Core.dll").read_bytes() == b"dll" - assert (target / "dotnet" / "host" / "fxr" / "10.0.0" / "libhostfxr.dylib").exists() - assert install.installed_engine() == target - - -def test_install_engine_asks_for_the_engine_rid(home, monkeypatch): - seen = fake_release(monkeypatch) - install.install_engine() - assert seen == [f"engine-{install.current_rid()}"] - - def test_version_order_ignores_junk_and_prereleases(): names = ["zzz", "0.6.2-rc1", "0.6.2", "0.10.0", "0.9.1"] assert max(names, key=install._version_key) == "0.10.0" @@ -564,12 +534,6 @@ def test_version_order_ignores_junk_and_prereleases(): assert max(["zzz", "0.6.2"], key=install._version_key) == "0.6.2" -def test_engine_version_must_be_a_plain_directory_name(home, monkeypatch): - fake_release(monkeypatch, version="../../lib") - with pytest.raises(install.InstallError, match="not a valid directory name"): - install.install_engine() - - def test_installed_engine_picks_the_newest_version(home, monkeypatch): for v in ("0.9.0", "0.10.0", "0.9.1", "zzz"): d = install.engine_root() / v @@ -579,25 +543,6 @@ def test_installed_engine_picks_the_newest_version(home, monkeypatch): assert install.installed_engine().name == "0.10.0" -def test_engine_archive_paths_must_stay_inside(home, monkeypatch): - monkeypatch.setattr(install, "resolve", lambda binary, rid=None, channel=None: install.Artifact( - version="0.9.0", path="u", sha256="00" * 32)) - monkeypatch.setattr(install, "download", lambda artifact, timeout=300: engine_zip({"../escape": b"x"})) - with pytest.raises(install.InstallError): - install.install_engine() - - -def test_engine_dir_explains_when_no_release_is_reachable(home, monkeypatch): - from simantic import engine - - monkeypatch.delenv("SIMANTIC_SIM", raising=False) - monkeypatch.delenv("SIMANTIC_ENGINE_DIR", raising=False) - monkeypatch.setenv("PATH", "") - monkeypatch.setattr(install, "install_engine", lambda **kw: (_ for _ in ()).throw(install.InstallError("offline"))) - with pytest.raises(engine.EngineNotFound, match="could not fetch the engine: offline"): - engine.engine_dir() - - # -- the Rust engine ---------------------------------------------------------- RUST_MANIFEST = { @@ -660,3 +605,13 @@ def test_a_directory_without_the_module_is_not(tmp_path): (tmp_path / "simantic_rust-0.3.0.dist-info").mkdir() (tmp_path / "simantic_rust").mkdir() # no __init__.py: not importable assert not install.is_rust_engine(tmp_path) + + +def test_engine_dir_does_not_fetch_the_retired_csharp_engine(home, monkeypatch): + from simantic import engine + + monkeypatch.delenv("SIMANTIC_SIM", raising=False) + monkeypatch.delenv("SIMANTIC_ENGINE_DIR", raising=False) + monkeypatch.setenv("PATH", "") + with pytest.raises(engine.EngineNotFound, match="Simantic.Core.dll not found"): + engine.engine_dir() diff --git a/tests/test_session.py b/tests/test_session.py index 5d896fd..76c2d9b 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -84,7 +84,7 @@ def test_start_error_names_the_fix_for_a_stale_engine(): from simantic.session import _start_error stale = _start_error(Exception( "Error E25: Could not find suitable constructor for type 'STM32F4_RCC'.\nConstructor selection report: ...")) - assert "simantic install engine --force" in stale + assert "use a newer build" in stale assert "STM32F4_RCC" in stale and "selection report" not in stale assert _start_error(Exception("no such file")) == "could not start the simulation: no such file" @@ -98,7 +98,7 @@ def test_needs_newer_engine_reads_the_models_minimum(tmp_path, monkeypatch): old, new = tmp_path / "0.6.1", tmp_path / "0.6.3" msg = _needs_newer_engine(["STM32F401RE", "STM32H753ZI"], old) assert "STM32H753ZI needs engine 0.6.3 or newer; 0.6.1 is installed" in msg - assert "simantic install engine --force" in msg + assert "use a newer build" in msg assert _needs_newer_engine(["STM32H753ZI"], new) is None assert _needs_newer_engine(["NOT-CACHED"], old) is None assert _needs_newer_engine(["STM32H753ZI"], tmp_path / "publish") is None # dev engine: no version From fbac0f81afd3eb30b1ab0f0615a8957fd019de83 Mon Sep 17 00:00:00 2001 From: Ahnaf Shahriar Date: Thu, 15 Oct 2026 22:38:10 -0700 Subject: [PATCH 4/4] install: resolve sim from pyrite's `sim-` manifest key Co-Authored-By: Claude Sonnet 5.5 --- src/simantic/install.py | 3 ++- tests/test_install.py | 19 ++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/simantic/install.py b/src/simantic/install.py index 8145f12..6720753 100644 --- a/src/simantic/install.py +++ b/src/simantic/install.py @@ -264,7 +264,8 @@ def resolve( binary: str, *, rid: str | None = None, channel: str | None = None ) -> Artifact: """The artifact this machine should download.""" - return _resolve(fetch_manifest(binary, channel=channel), binary, rid) + # pyrite keys artifacts `-`, e.g. `sim-osx-arm64`. + return _resolve(fetch_manifest(binary, channel=channel), binary, f"{binary}-{rid or current_rid()}") def _resolve(manifest: dict, binary: str, rid: str | None) -> Artifact: diff --git a/tests/test_install.py b/tests/test_install.py index daf2bcb..8f7e031 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -17,8 +17,8 @@ MANIFEST = { "version": "0.4.0", "artifacts": { - "osx-arm64": {"path": "pyrite/0.4.0/osx-arm64/sim.zip", "sha256": "ab" * 32}, - "linux-x64": {"path": "pyrite/0.4.0/linux-x64/sim.zip"}, + "sim-osx-arm64": {"path": "pyrite/0.4.0/osx-arm64/sim.zip", "sha256": "ab" * 32}, + "sim-linux-x64": {"path": "pyrite/0.4.0/linux-x64/sim.zip"}, }, } @@ -197,7 +197,7 @@ def test_resolves_the_artifact_for_this_machine(monkeypatch): def test_missing_rid_lists_what_is_available(monkeypatch): monkeypatch.setattr(install, "fetch_manifest", lambda b, **k: MANIFEST) - with pytest.raises(install.InstallError, match="available: linux-x64, osx-arm64"): + with pytest.raises(install.InstallError, match="available: sim-linux-x64, sim-osx-arm64"): install.resolve("sim", rid="win-x64") @@ -615,3 +615,16 @@ def test_engine_dir_does_not_fetch_the_retired_csharp_engine(home, monkeypatch): monkeypatch.setenv("PATH", "") with pytest.raises(engine.EngineNotFound, match="Simantic.Core.dll not found"): engine.engine_dir() + + +def test_pyrite_manifest_resolves_sim_and_rust_engine(home, monkeypatch): + h = "cd" * 32 + manifest = {"version": "1.2.3", "artifacts": { + "sim-osx-arm64": {"path": "pyrite/1.2.3/sim-osx-arm64", "sha256": h}, + "engine-rust-osx-arm64": {"path": "pyrite/1.2.3/r.whl", "sha256": h}, + }} + monkeypatch.setattr(install, "current_rid", lambda: "osx-arm64") + monkeypatch.setattr(install, "_fetch_manifest", lambda *a, **k: manifest) + assert install.resolve("sim").path == "pyrite/1.2.3/sim-osx-arm64" + monkeypatch.setattr(install, "download", lambda a, timeout=300: engine_zip({"simantic_rust.abi3.so": b"x"})) + assert install.install_rust_engine() == install.rust_engine_root() / "1.2.3"