From b19b1ab218848f9a8e9179c51b02650b322952b2 Mon Sep 17 00:00:00 2001 From: w4ffl35 <25737761+w4ffl35@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:54:36 -0600 Subject: [PATCH 1/2] Add the model-hub verification sandbox runner (issue #48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A GitHub Actions workflow, triggered by repository_dispatch from spikeforge-hub-api, that fetches one community-uploaded artifact from a signed URL and runs it through the existing verification pipeline on a GitHub-hosted (never self-hosted) runner: bundle checksum/manifest verification and the weights_only=True load already used by bundle.py, spikeforge_hub.inspect and .compat, the NIR export and reference- interpreter drift check spikeforge_hub.import_model already runs for the curated catalog, and a spikeforge_targets.energy report. It posts a signed pass/fail report to the hub API's (not yet built) POST /internal/v1/verifications/{version_id}, naming which step failed and why on rejection rather than a generic failure message. Also adds a decompressed-size cap to bundle.py's archive reader, which had none: a zip bomb was previously an unbounded read into memory rather than a clean, named rejection. The self-hosted spikeforge-ci runner (deploy-hetzner.yml) is deliberately never used here, since it holds the Hetzner deploy key and running torch.load on a stranger's bytes there is the plan's own worst-case scenario (plans/hub_accounts_plan.md §7.2). --- .github/workflows/hub-verify.yml | 112 ++++++++++++++++++++++++ pytest.ini | 2 +- scripts/hub_verify/__init__.py | 27 ++++++ scripts/hub_verify/callback.py | 56 ++++++++++++ scripts/hub_verify/cli.py | 117 +++++++++++++++++++++++++ scripts/hub_verify/energy.py | 29 +++++++ scripts/hub_verify/errors.py | 30 +++++++ scripts/hub_verify/fetch.py | 59 +++++++++++++ scripts/hub_verify/fixtures.py | 22 +++++ scripts/hub_verify/funnel.py | 69 +++++++++++++++ scripts/hub_verify/pipeline.py | 34 ++++++++ scripts/hub_verify/report.py | 57 ++++++++++++ scripts/hub_verify/signing.py | 39 +++++++++ spikeforge/serving/bundle.py | 19 ++++ spikeforge/serving/bundle_manifest.py | 9 ++ tests/test_hub_verify_callback.py | 91 ++++++++++++++++++++ tests/test_hub_verify_cli.py | 119 ++++++++++++++++++++++++++ tests/test_hub_verify_fetch.py | 84 ++++++++++++++++++ tests/test_hub_verify_pipeline.py | 74 ++++++++++++++++ tests/test_hub_verify_report.py | 44 ++++++++++ tests/test_hub_verify_signing.py | 33 +++++++ tests/test_serving_bundle.py | 17 ++++ 22 files changed, 1142 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/hub-verify.yml create mode 100644 scripts/hub_verify/__init__.py create mode 100644 scripts/hub_verify/callback.py create mode 100644 scripts/hub_verify/cli.py create mode 100644 scripts/hub_verify/energy.py create mode 100644 scripts/hub_verify/errors.py create mode 100644 scripts/hub_verify/fetch.py create mode 100644 scripts/hub_verify/fixtures.py create mode 100644 scripts/hub_verify/funnel.py create mode 100644 scripts/hub_verify/pipeline.py create mode 100644 scripts/hub_verify/report.py create mode 100644 scripts/hub_verify/signing.py create mode 100644 tests/test_hub_verify_callback.py create mode 100644 tests/test_hub_verify_cli.py create mode 100644 tests/test_hub_verify_fetch.py create mode 100644 tests/test_hub_verify_pipeline.py create mode 100644 tests/test_hub_verify_report.py create mode 100644 tests/test_hub_verify_signing.py diff --git a/.github/workflows/hub-verify.yml b/.github/workflows/hub-verify.yml new file mode 100644 index 0000000..5f401a9 --- /dev/null +++ b/.github/workflows/hub-verify.yml @@ -0,0 +1,112 @@ +name: Hub artifact verification (sandbox) + +# The community-upload verification sandbox (issue #48; the design of +# record is plans/hub_accounts_plan.md §7). Dispatched by +# spikeforge-hub-api once a committed upload enters its `verifying` +# state. That hub-api endpoint (spikeforge-hub-api#4) is not built as of +# this writing, so the `hub-artifact-verify` event type and the +# client_payload shape below are this repo's half of a contract the two +# repositories have to agree on -- see the pull request that introduced +# this file for the coordination note. +# +# SECURITY -- read before changing `runs-on` or the egress allowlist: +# +# This job runs `torch.load` on bytes a stranger uploaded. It MUST stay +# on a GitHub-hosted, ephemeral runner. It must NEVER run on the +# self-hosted `spikeforge-ci` runner that `deploy-hetzner.yml` uses -- +# that runner holds the Hetzner deploy key, and running untrusted-content +# code on a persistent self-hosted runner is named in the plan (§7.2) as +# the single most predictable way this whole design could be +# compromised. This is deliberately the first workflow in this repository +# that does not use `spikeforge-ci`. +on: + repository_dispatch: + types: [hub-artifact-verify] + +permissions: + contents: read + +# One verification at a time per version; a second dispatch for the same +# version (a retry) should not race the first, but must not cancel it +# silently either -- a cancelled run still owes the hub API a report, or +# the version is stuck in `verifying` forever (the exact bug +# spikeforge-hub-api#4 exists to fix). +concurrency: + group: hub-verify-${{ github.event.client_payload.version_id }} + cancel-in-progress: false + +env: + # CPU wheels keep this light; mirrors ci.yml's TORCH_INDEX_URL. + TORCH_INDEX_URL: https://download.pytorch.org/whl/cpu + +jobs: + verify: + # GitHub-hosted only -- see the header comment. Do not add + # `spikeforge-ci` or any other self-hosted label to this job. + runs-on: ubuntu-latest + # A wall-clock ceiling independent of any one step's own timeout, per + # plans/hub_accounts_plan.md §7.2 ("a wall-clock timeout"). + timeout-minutes: 20 + steps: + # Restricts this job's DNS/network egress to exactly what it needs: + # GitHub's own checkout/runner endpoints, PyPI + the CPU wheel + # index for installing dependencies, and the two hub-api endpoints + # this job talks to (the artifact CDN and the verification + # callback) -- plans/hub_accounts_plan.md §7.2's "no network egress + # beyond the two endpoints this job actually needs", extended by + # the toolchain-setup endpoints every job on this runner requires + # regardless. If this allowlist ever needs to change, flip + # `egress-policy` to `audit` first, read the resulting job summary + # for what was actually contacted, then return it to `block`. + - name: Harden the runner's network egress + uses: step-security/harden-runner@v2 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + api.github.com:443 + codeload.github.com:443 + objects.githubusercontent.com:443 + results-receiver.actions.githubusercontent.com:443 + pypi.org:443 + files.pythonhosted.org:443 + download.pytorch.org:443 + hub.spikeforge.net:443 + cdn.spikeforge.net:443 + + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + # No `cache: pip` here, deliberately: this job's whole point is + # to be a disposable sandbox for untrusted content, and a pip + # cache is one more thing that could be poisoned across runs. + + - name: Install torch CPU wheels + run: | + python -m pip install --upgrade pip + pip install torch torchvision \ + --index-url ${{ env.TORCH_INDEX_URL }} + + - name: Install core + targets + hub editable + run: | + pip install -e "./packages/spikeforge[dev,nir]" \ + -e ./packages/spikeforge-targets \ + -e ./packages/spikeforge-hub + + - name: Run the verification pipeline and post the signed report + env: + PYTHONPATH: ${{ github.workspace }}/scripts + SPIKEFORGE_HUB_VERIFY_VERSION_ID: >- + ${{ github.event.client_payload.version_id }} + SPIKEFORGE_HUB_VERIFY_ARTIFACT_URL: >- + ${{ github.event.client_payload.artifact_url }} + # Fixed to this repository's own configuration -- never read + # from the dispatch payload. The callback destination must not + # be data a compromised or malformed dispatch could redirect; + # see hub_verify/cli.py's module docstring. + SPIKEFORGE_HUB_API_BASE_URL: ${{ vars.SPIKEFORGE_HUB_API_BASE_URL }} + SPIKEFORGE_HUB_VERIFICATION_SECRET: >- + ${{ secrets.SPIKEFORGE_HUB_VERIFICATION_SECRET }} + run: python -m hub_verify.cli diff --git a/pytest.ini b/pytest.ini index 7ae7e0c..cbe04ac 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,6 +1,6 @@ [pytest] testpaths = tests -pythonpath = . +pythonpath = . scripts addopts = --cov=spikeforge --cov-report=term-missing markers = network: fetches a dataset, so it reaches the network on a cold diff --git a/scripts/hub_verify/__init__.py b/scripts/hub_verify/__init__.py new file mode 100644 index 0000000..2fa2202 --- /dev/null +++ b/scripts/hub_verify/__init__.py @@ -0,0 +1,27 @@ +"""The community-upload verification sandbox runner (issue #48, P4). + +Invoked by ``.github/workflows/hub-verify.yml`` on a ``repository_dispatch`` +from ``spikeforge-hub-api``, on a GitHub-hosted (never self-hosted) runner: +the self-hosted ``spikeforge-ci`` runner holds the Hetzner deploy key, and +running ``torch.load`` on a stranger's bytes there is the single most +predictable way this design could be compromised +(``plans/hub_accounts_plan.md`` §7.2). + +This package is CI scaffolding, not a distributed package: nothing under +``spikeforge*`` imports it, and it ships in no wheel. It reuses the existing, +already-shipped pipeline rather than reimplementing any of it: + +* :mod:`spikeforge.serving.bundle` -- checksum/manifest verification and the + ``weights_only=True`` load already used for every bundle. +* :mod:`spikeforge_hub.inspect` / :mod:`spikeforge_hub.compat` -- the same + structural inspection and preset classification the curated catalog uses. +* :mod:`spikeforge.nir_bridge` -- the same NIR export and independent + reference-interpreter drift check invariant 4 of ``rules.md`` requires. +* :mod:`spikeforge_targets.energy` -- the same SOP/MAC/AC accounting used + elsewhere in the project. + +See :mod:`hub_verify.cli` for the orchestration entry point and +:mod:`hub_verify.report` for the report/callback contract this runner +implements against ``spikeforge-hub-api``'s (not yet built, as of this +writing) ``POST /internal/v1/verifications/{version_id}``. +""" diff --git a/scripts/hub_verify/callback.py b/scripts/hub_verify/callback.py new file mode 100644 index 0000000..423e506 --- /dev/null +++ b/scripts/hub_verify/callback.py @@ -0,0 +1,56 @@ +"""Post the signed verification report back to the hub API. + +Follows the same ``urllib``-only request pattern already used in this +monorepo (:class:`spikeforge_clients.transport.HttpTransport`) rather than +adding a new HTTP dependency: this script installs into a throwaway CI +job, but the project's stated convention is stdlib-only HTTP regardless. +""" + +import json +import urllib.error +import urllib.request +from typing import Final + +from hub_verify.errors import CallbackError +from hub_verify.signing import SIGNATURE_HEADER, sign_body + +_TIMEOUT_SECONDS: Final[int] = 30 + + +def _url(base_url: str, version_id: str) -> str: + """Return the internal verification-callback URL for ``version_id``.""" + return f"{base_url.rstrip('/')}/internal/v1/verifications/{version_id}" + + +def post_report( + base_url: str, version_id: str, report: dict, secret: str +) -> None: + """POST ``report`` for ``version_id``, signed with ``secret``. + + Raises :class:`CallbackError` on any failure to deliver it -- unlike an + artifact rejection, a delivery failure is not something this job can + itself report to the hub, so it must fail the job loudly instead of + silently leaving the version stuck in ``verifying``. + """ + body = json.dumps(report, sort_keys=True).encode("utf-8") + request = urllib.request.Request( + _url(base_url, version_id), + data=body, + method="POST", + headers={ + "Content-Type": "application/json", + SIGNATURE_HEADER: sign_body(body, secret), + }, + ) + try: + with urllib.request.urlopen( + request, timeout=_TIMEOUT_SECONDS + ) as reply: + if reply.status >= 400: + raise CallbackError(f"hub API replied {reply.status}") + except urllib.error.HTTPError as error: + raise CallbackError( + f"hub API replied {error.code}: {error.read()!r}" + ) from error + except (urllib.error.URLError, OSError) as error: + raise CallbackError(f"callback delivery failed: {error}") from error diff --git a/scripts/hub_verify/cli.py b/scripts/hub_verify/cli.py new file mode 100644 index 0000000..caf0101 --- /dev/null +++ b/scripts/hub_verify/cli.py @@ -0,0 +1,117 @@ +"""Fetch, verify, report, and post one dispatched artifact. + +Invoked by ``.github/workflows/hub-verify.yml`` as +``python -m hub_verify.cli``. Every input is an environment variable the +workflow sets from the ``repository_dispatch`` payload and this +repository's own secrets/variables -- see the workflow file for exactly +which ones, and for why the callback base URL is a repository-configured +constant rather than something read out of the dispatch payload (a fixed +destination this job trusts, not attacker-influenced data). +""" + +import os +import sys +import tempfile +from typing import Dict, List, Tuple + +from hub_verify.callback import post_report +from hub_verify.energy import energy_reports +from hub_verify.errors import CallbackError, VerificationStepError +from hub_verify.fetch import fetch_artifact +from hub_verify.funnel import run_funnel +from hub_verify.pipeline import load_and_build +from hub_verify.report import build_report, rejection_summary + +_EMPTY_CHECKS: Dict[str, object] = { + "bundle": None, + "inspect": None, + "compat": None, + "drift": None, + "energy": None, +} + + +def _env(name: str) -> str: + """Return the required environment variable ``name`` or exit loudly.""" + value = os.environ.get(name, "") + if not value: + sys.exit(f"hub_verify: missing required env var {name}") + return value + + +def _run_url() -> str: + """Return this job's run URL for the report, or an empty string.""" + server = os.environ.get("GITHUB_SERVER_URL", "") + repo = os.environ.get("GITHUB_REPOSITORY", "") + run_id = os.environ.get("GITHUB_RUN_ID", "") + if not (server and repo and run_id): + return "" + return f"{server}/{repo}/actions/runs/{run_id}" + + +def _run_checks( + tmp_dir: str, artifact_path: str +) -> Tuple[Dict[str, object], List[str]]: + """Run the bundle/funnel/energy stages; return ``(checks, reasons)``. + + A stage that raises :class:`VerificationStepError` stops the pipeline + there -- a later stage needs the module the failed stage would have + produced -- and its message becomes the sole rejection reason. + """ + checks = dict(_EMPTY_CHECKS) + try: + bundle, module = load_and_build(artifact_path) + checks["bundle"] = { + "ok": True, "topology": bundle.manifest.get("topology") + } + funnel = run_funnel(bundle, module, tmp_dir) + checks["inspect"] = funnel["inspect"] + checks["compat"] = funnel["compat"] + checks["drift"] = funnel["drift"] + checks["energy"] = energy_reports(bundle.spec, module) + except VerificationStepError as error: + checks[error.step] = {"ok": False, "detail": error.reason} + return checks, [f"{error.step}: {error.reason}"] + return checks, [] + + +def _verify(artifact_url: str) -> Tuple[Dict[str, object], List[str]]: + """Fetch the artifact and run every check against it.""" + with tempfile.TemporaryDirectory() as tmp_dir: + try: + artifact_path = fetch_artifact( + artifact_url, os.path.join(tmp_dir, "artifact.spkf") + ) + except VerificationStepError as error: + return dict(_EMPTY_CHECKS), [f"{error.step}: {error.reason}"] + return _run_checks(tmp_dir, artifact_path) + + +def main() -> int: + """Verify the dispatched artifact and post its signed report.""" + version_id = _env("SPIKEFORGE_HUB_VERIFY_VERSION_ID") + artifact_url = _env("SPIKEFORGE_HUB_VERIFY_ARTIFACT_URL") + base_url = _env("SPIKEFORGE_HUB_API_BASE_URL") + secret = _env("SPIKEFORGE_HUB_VERIFICATION_SECRET") + + checks, reasons = _verify(artifact_url) + passed = not reasons + summary = "passed every check" if passed else rejection_summary(reasons) + report = build_report( + version_id=version_id, + passed=passed, + reasons=reasons, + checks=checks, + summary=summary, + run_url=_run_url(), + ) + try: + post_report(base_url, version_id, report, secret) + except CallbackError as error: + sys.exit(f"hub_verify: could not deliver the report: {error}") + print(summary) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/hub_verify/energy.py b/scripts/hub_verify/energy.py new file mode 100644 index 0000000..f8590d1 --- /dev/null +++ b/scripts/hub_verify/energy.py @@ -0,0 +1,29 @@ +"""Produce the SOP/MAC/AC energy/latency report for every known target. + +Reuses :func:`spikeforge_targets.energy.accounting.account_spikes` exactly +as elsewhere in the project; a target whose SDK is not installed on this +runner reports ``basis: "unavailable"`` rather than a fabricated number -- +the same honest-degradation convention ``ci.yml``'s ``test-deploy`` job +checks for the curated catalog. Energy is descriptive here, never a pass +gate: an artifact is not rejected for costing more than some threshold. +""" + +from typing import Any, Dict, List + +from hub_verify.fixtures import probe_spikes +from spikeforge.topology.spec import TopologySpec +from spikeforge.topology.stage_module import StageModule +from spikeforge_targets import registry +from spikeforge_targets.energy.accounting import account_spikes + + +def energy_reports( + spec: TopologySpec, module: StageModule +) -> List[Dict[str, Any]]: + """Return one energy/latency report per registered target.""" + spikes = probe_spikes(spec) + reports = [] + for name in registry.target_names(): + _, report = account_spikes(module, spikes, name) + reports.append(report.to_dict()) + return reports diff --git a/scripts/hub_verify/errors.py b/scripts/hub_verify/errors.py new file mode 100644 index 0000000..304a9f0 --- /dev/null +++ b/scripts/hub_verify/errors.py @@ -0,0 +1,30 @@ +"""Typed errors naming which verification step failed, and why.""" + + +class VerificationStepError(Exception): + """Raised by one pipeline stage with an actionable, named reason. + + ``step`` identifies which stage of the pipeline rejected the artifact + (e.g. ``"fetch"``, ``"bundle"``, ``"drift"``) and ``reason`` is the + human-readable detail shown to the uploader -- "weights are not + loadable" rather than "verification failed" (the honesty bar + ``docs/model-hub.md`` states for every rejection in this project). + """ + + def __init__(self, step: str, reason: str) -> None: + """Record ``step`` and ``reason`` and build a clear message.""" + super().__init__(f"{step}: {reason}") + self.step: str = step + self.reason: str = reason + + +class FetchError(VerificationStepError): + """Raised when the artifact cannot be fetched from its signed URL.""" + + def __init__(self, reason: str) -> None: + """Record the fetch failure's ``reason``.""" + super().__init__("fetch", reason) + + +class CallbackError(Exception): + """Raised when the signed report cannot be posted back to the hub.""" diff --git a/scripts/hub_verify/fetch.py b/scripts/hub_verify/fetch.py new file mode 100644 index 0000000..ce846e0 --- /dev/null +++ b/scripts/hub_verify/fetch.py @@ -0,0 +1,59 @@ +"""Fetch the one artifact this job was dispatched to verify. + +The URL is a signed, read-only, single-object URL the hub API mints for +this job alone (``plans/hub_accounts_plan.md`` §7.2); this module does not +authenticate to anything else and never reuses the URL beyond one GET. +""" + +import urllib.error +import urllib.request +from typing import Any, Final + +from hub_verify.errors import FetchError + +#: Matches the community-upload single-artifact cap +#: (``plans/hub_accounts_plan.md`` §5.3). A ``Content-Length`` claim is +#: never trusted alone -- the stream itself is cut off past this many bytes. +MAX_ARTIFACT_BYTES: Final[int] = 256 * 1024 * 1024 + +#: Refuse to hang on a stalled or hostile server. +_TIMEOUT_SECONDS = 60 +_CHUNK_SIZE = 1 << 20 + + +def _read_capped(response: Any, dest: str) -> int: + """Stream ``response`` into ``dest``, raising past the byte cap.""" + written = 0 + with open(dest, "wb") as handle: + while True: + chunk = response.read(_CHUNK_SIZE) + if not chunk: + return written + written += len(chunk) + if written > MAX_ARTIFACT_BYTES: + raise FetchError( + f"artifact exceeds the {MAX_ARTIFACT_BYTES} byte cap" + ) + handle.write(chunk) + + +def fetch_artifact(url: str, dest: str) -> str: + """Download ``url`` to ``dest`` and return ``dest``. + + Raises :class:`FetchError` naming the reason on any network failure or + a payload past :data:`MAX_ARTIFACT_BYTES`, so a hostile or broken + signed URL is reported like any other named rejection rather than an + unhandled traceback. + """ + try: + with urllib.request.urlopen( + url, timeout=_TIMEOUT_SECONDS + ) as response: + _read_capped(response, dest) + except FetchError: + raise + except urllib.error.HTTPError as error: + raise FetchError(f"HTTP {error.code} fetching artifact") from error + except (urllib.error.URLError, OSError) as error: + raise FetchError(f"network fetch failed: {error}") from error + return dest diff --git a/scripts/hub_verify/fixtures.py b/scripts/hub_verify/fixtures.py new file mode 100644 index 0000000..e586a55 --- /dev/null +++ b/scripts/hub_verify/fixtures.py @@ -0,0 +1,22 @@ +"""The one deterministic spike probe shared by the drift and energy checks. + +Both checks need some input to run the module over; using the same seeded, +low-density probe for both means one fixture to reason about instead of +two, and matches the fixture :mod:`spikeforge_targets.energy.accounting` +already uses for its own topology fixtures. +""" + +import torch + +from spikeforge.topology.spec import TopologySpec +from spikeforge_targets.event_runtime.spike_view import synthetic_spikes + +#: Matches ``spikeforge_targets/energy/accounting.py``'s own defaults. +STEPS = 8 +BATCH = 2 +SEED = 0 + + +def probe_spikes(spec: TopologySpec) -> torch.Tensor: + """Return a seeded, low-density spike probe shaped for ``spec``.""" + return synthetic_spikes(spec, STEPS, BATCH, SEED) diff --git a/scripts/hub_verify/funnel.py b/scripts/hub_verify/funnel.py new file mode 100644 index 0000000..5c9a633 --- /dev/null +++ b/scripts/hub_verify/funnel.py @@ -0,0 +1,69 @@ +"""Run the inspect -> compat -> NIR-export-and-drift funnel on a bundle. + +Reuses :mod:`spikeforge_hub.inspect`, :mod:`spikeforge_hub.compat`, and +:func:`spikeforge.nir_bridge.validate` exactly as +:mod:`spikeforge_hub.import_model` runs them for the curated catalog +(``rules.md`` invariant 4: validation stays independent of the module it +checks, so a drift check is a genuine cross-check). A bundle already +carries its own explicit :class:`~spikeforge.topology.spec.TopologySpec`, +so unlike a bare downloaded artifact this funnel never has to guess a +topology before it can build a module: ``compat`` here is informational +(does this map onto a *known, registered* preset?), not a gate -- the +drift check always runs against the bundle's own declared architecture. +""" + +import os +from typing import Any, Dict + +import torch + +from hub_verify.errors import VerificationStepError +from hub_verify.fixtures import probe_spikes +from spikeforge.nir_bridge import validate +from spikeforge.serving.bundle import DeploymentBundle +from spikeforge_hub.compat import classify +from spikeforge_hub.inspect import inspect_artifact + + +def _write_weights(bundle: DeploymentBundle, tmp_dir: str) -> str: + """Write the bundle's already-verified weights to a bare ``.pt`` file. + + The bytes have already passed a ``weights_only=True`` load in + :func:`hub_verify.pipeline.load_and_build`; re-loading the identical + bytes through :mod:`spikeforge_hub.inspect`'s bare-torch reader is + safe, because that reload can only reconstruct the same objects + already proven benign by the stricter load. + """ + path = os.path.join(tmp_dir, "weights.pt") + torch.save(dict(bundle.weights), path) + return path + + +def _drift_check(bundle: DeploymentBundle, module: Any) -> Dict[str, Any]: + """Run the reference-interpreter drift check, raising on drift.""" + spikes = probe_spikes(bundle.spec) + result = validate(bundle.spec, module, spikes) + if not result["within_tolerance"]: + worst = result.get("worst") or {} + where = worst.get("layer", "unknown layer") + what = worst.get("quantity", "unknown quantity") + raise VerificationStepError( + "drift", + "the exported NIR graph drifts from the trained module " + f"at {where} ({what})", + ) + return dict(result) + + +def run_funnel( + bundle: DeploymentBundle, module: Any, tmp_dir: str +) -> Dict[str, Any]: + """Return the inspect/compat/drift block of the verification report.""" + weights_path = _write_weights(bundle, tmp_dir) + report = inspect_artifact(weights_path) + verdict = classify(report, bundle.manifest.get("topology")) + return { + "inspect": {"kind": report.kind}, + "compat": verdict.to_dict(), + "drift": _drift_check(bundle, module), + } diff --git a/scripts/hub_verify/pipeline.py b/scripts/hub_verify/pipeline.py new file mode 100644 index 0000000..129e1a6 --- /dev/null +++ b/scripts/hub_verify/pipeline.py @@ -0,0 +1,34 @@ +"""Load and build the ``.spkf`` bundle under verification. + +Wraps :class:`spikeforge.serving.bundle.DeploymentBundle` so a load or +build failure becomes a named +:class:`~hub_verify.errors.VerificationStepError` instead of an unhandled +traceback. The checksum/manifest verification and the +``weights_only=True`` weight load are exactly the ones ``bundle.py`` +already performs for every bundle load (non-negotiable for untrusted +content, per ``plans/hub_accounts_plan.md`` §7.2) -- nothing here +reimplements them. +""" + +from typing import Any, Tuple + +from hub_verify.errors import VerificationStepError +from spikeforge.serving.bundle import DeploymentBundle +from spikeforge.serving.errors import BundleError + + +def load_and_build(path: str) -> Tuple[DeploymentBundle, Any]: + """Return ``(bundle, module)`` for the ``.spkf`` archive at ``path``. + + :meth:`DeploymentBundle.load` runs the checksum/manifest verification + and the ``weights_only=True`` weight load; :meth:`build_module` then + strictly loads those weights into the module the manifest's own spec + describes -- a second, independent proof the weights actually fit the + declared architecture, not just that they deserialize. + """ + try: + bundle = DeploymentBundle.load(path, strict=True) + module = bundle.build_module() + except BundleError as error: + raise VerificationStepError("bundle", error.detail) from error + return bundle, module diff --git a/scripts/hub_verify/report.py b/scripts/hub_verify/report.py new file mode 100644 index 0000000..58a64e9 --- /dev/null +++ b/scripts/hub_verify/report.py @@ -0,0 +1,57 @@ +"""Assemble the verification report posted to the hub API. + +The one binding field is ``passed``: ``spikeforge-hub-api``'s +``hub_api/catalog/trust.py::label_for`` reads +``version.verification["passed"]`` and shows the ``machine-checked`` trust +label only when it is the JSON boolean ``true``. Everything else in this +report (``reasons``, ``checks``, ``summary``) is this runner's proposed +shape for the rest of the contract issue #48 and hub-api issue #4 both +describe in prose ("pass/fail, reasons, NIR/compat/energy summary") but +neither repository had committed code for as of this writing -- see the +pull request description for the coordination note. +""" + +import datetime +from typing import Any, Dict, List, Optional + + +def _now_iso() -> str: + """Return the current UTC time as an ISO-8601 string.""" + return ( + datetime.datetime.now(datetime.timezone.utc) + .isoformat(timespec="seconds") + ) + + +def build_report( + *, + version_id: str, + passed: bool, + reasons: List[str], + checks: Dict[str, Optional[Any]], + summary: str, + run_url: str, +) -> Dict[str, Any]: + """Return the JSON-able report body for one verification run. + + ``reasons`` names every failing step in the uploader's own words (see + :mod:`hub_verify.errors`); it is empty exactly when ``passed`` is True. + ``checks`` carries the bundle/inspect/compat/drift/energy detail a + listing page can render even for a passing artifact. + """ + return { + "version_id": version_id, + "passed": bool(passed), + "reasons": list(reasons), + "checks": checks, + "summary": summary, + "generated_at": _now_iso(), + "runner": {"workflow": "hub-verify.yml", "run_url": run_url}, + } + + +def rejection_summary(reasons: List[str]) -> str: + """Return a one-line summary naming the first rejection reason.""" + if not reasons: + return "rejected with no named reason (this is itself a bug)" + return f"rejected: {reasons[0]}" diff --git a/scripts/hub_verify/signing.py b/scripts/hub_verify/signing.py new file mode 100644 index 0000000..8677c89 --- /dev/null +++ b/scripts/hub_verify/signing.py @@ -0,0 +1,39 @@ +"""Sign the outgoing verification report for the hub API callback. + +Mirrors the HMAC-SHA256 pattern already used in this repository for a +signed record (:func:`spikeforge_hub.registry.sign_entry`), adapted for an +HTTP callback: the signature covers the exact request-body bytes, the same +convention GitHub itself uses for webhook signatures +(``X-Hub-Signature-256``), so the receiving side only needs to hash the raw +body it read -- no canonical re-serialization step to keep in sync across +two repositories. +""" + +import hashlib +import hmac + +#: The callback header carrying the signature, as ``sha256=``. +SIGNATURE_HEADER = "X-Spikeforge-Hub-Signature" + + +def _as_key(secret: str) -> bytes: + """Return ``secret`` as bytes, refusing an empty one.""" + if not secret: + raise ValueError("verification secret must not be empty") + return secret.encode("utf-8") + + +def sign_body(body: bytes, secret: str) -> str: + """Return the ``sha256=`` signature of ``body`` under ``secret``.""" + digest = hmac.new(_as_key(secret), body, hashlib.sha256).hexdigest() + return f"sha256={digest}" + + +def signature_matches(body: bytes, secret: str, header_value: str) -> bool: + """Return True when ``header_value`` is ``body``'s signature. + + Provided for the hub-api side (or a test standing in for it) to verify + a callback with the same constant-time comparison this repository uses + elsewhere for signed records. + """ + return hmac.compare_digest(sign_body(body, secret), header_value) diff --git a/spikeforge/serving/bundle.py b/spikeforge/serving/bundle.py index 1d50d1f..8cbb64a 100644 --- a/spikeforge/serving/bundle.py +++ b/spikeforge/serving/bundle.py @@ -185,6 +185,24 @@ def _meta_input_size(meta: Mapping[str, Any]) -> Optional[Tuple[int, int]]: return None +def _check_decompressed_size( + path: str, archive: zipfile.ZipFile, names: Any +) -> None: + """Raise when ``names``' total decompressed size exceeds the cap. + + Checked against each entry's recorded ``file_size`` before any entry is + decompressed, so a small compressed payload declaring an enormous + decompressed size (a zip bomb) is refused rather than read into memory. + """ + total = sum(archive.getinfo(name).file_size for name in names) + if total > bm.MAX_DECOMPRESSED_BYTES: + raise BundleFormatError( + path, + f"decompressed size {total} exceeds the " + f"{bm.MAX_DECOMPRESSED_BYTES} byte cap", + ) + + def _read_archive(path: str) -> Dict[str, bytes]: """Return every entry of the zip at ``path``, or raise a typed error.""" if not os.path.exists(path): @@ -197,6 +215,7 @@ def _read_archive(path: str) -> Dict[str, bytes]: raise BundleFormatError( path, f"missing required entries: {missing}" ) + _check_decompressed_size(path, archive, names) return {name: archive.read(name) for name in names} except zipfile.BadZipFile as error: raise BundleFormatError( diff --git a/spikeforge/serving/bundle_manifest.py b/spikeforge/serving/bundle_manifest.py index 4c4e169..e3a5675 100644 --- a/spikeforge/serving/bundle_manifest.py +++ b/spikeforge/serving/bundle_manifest.py @@ -39,6 +39,15 @@ #: Entries a bundle may additionally carry. OPTIONAL_ENTRIES = (GRAPH_NAME,) +#: Refuse a bundle whose entries decompress past this many bytes in total. +#: Checked against each entry's recorded ``file_size`` before any entry is +#: read, so a small compressed payload declaring an enormous decompressed +#: size (a zip bomb) is rejected rather than read into memory. Matches the +#: community-upload single-artifact cap in +#: ``plans/hub_accounts_plan.md`` §5.3; real artifacts today are +#: 46 KB-414 KB (§1), so this is generous headroom, not a tight fit. +MAX_DECOMPRESSED_BYTES = 256 * 1024 * 1024 # 256 MiB + def checksum(payload: bytes) -> str: """Return the lowercase hex SHA-256 of ``payload``.""" diff --git a/tests/test_hub_verify_callback.py b/tests/test_hub_verify_callback.py new file mode 100644 index 0000000..894a16a --- /dev/null +++ b/tests/test_hub_verify_callback.py @@ -0,0 +1,91 @@ +"""Posting the signed report back to the hub API.""" + +import json +from typing import Any +from urllib import error as urlerror + +import pytest +from hub_verify import callback +from hub_verify.errors import CallbackError +from hub_verify.signing import SIGNATURE_HEADER, signature_matches + + +class _FakeReply: + """A minimal stand-in for the ``urlopen`` context manager.""" + + def __init__(self, status: int) -> None: + """Record the reply's HTTP ``status``.""" + self.status = status + + def __enter__(self) -> "_FakeReply": + """Support ``with urlopen(...) as reply``.""" + return self + + def __exit__(self, *exc_info: Any) -> None: + """Nothing to release for a fake reply.""" + + +def test_posts_a_correctly_signed_body( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The delivered request body is signed under the given secret.""" + sent = {} + + def _urlopen(request: Any, timeout: float) -> _FakeReply: + sent["url"] = request.full_url + sent["body"] = request.data + # ``Request.add_header`` stores every key through ``.capitalize()`` + # (first character up, the rest down), so the header must be read + # back the same way rather than by its original spelling. + sent["signature"] = request.headers[SIGNATURE_HEADER.capitalize()] + return _FakeReply(200) + + monkeypatch.setattr(callback.urllib.request, "urlopen", _urlopen) + report = {"version_id": "v1", "passed": True} + callback.post_report( + "https://hub.spikeforge.net", "v1", report, "sekrit" + ) + assert sent["url"] == ( + "https://hub.spikeforge.net/internal/v1/verifications/v1" + ) + assert json.loads(sent["body"]) == report + assert signature_matches(sent["body"], "sekrit", sent["signature"]) + + +def test_error_status_is_a_callback_error( + monkeypatch: pytest.MonkeyPatch +) -> None: + """A 4xx/5xx reply is reported by name, not swallowed.""" + monkeypatch.setattr( + callback.urllib.request, "urlopen", lambda *a, **k: _FakeReply(500) + ) + with pytest.raises(CallbackError, match="500"): + callback.post_report("https://hub.spikeforge.net", "v1", {}, "s") + + +def test_http_error_is_a_callback_error( + monkeypatch: pytest.MonkeyPatch +) -> None: + """An HTTPError from urlopen is wrapped, not left to propagate raw.""" + + def _raise(*_args: Any, **_kwargs: Any) -> None: + raise urlerror.HTTPError( + "url", 404, "not found", {}, __import__("io").BytesIO(b"nope") + ) + + monkeypatch.setattr(callback.urllib.request, "urlopen", _raise) + with pytest.raises(CallbackError, match="404"): + callback.post_report("https://hub.spikeforge.net", "v1", {}, "s") + + +def test_network_error_is_a_callback_error( + monkeypatch: pytest.MonkeyPatch +) -> None: + """A connection failure is a named ``CallbackError``, not a crash.""" + + def _raise(*_args: Any, **_kwargs: Any) -> None: + raise urlerror.URLError("unreachable") + + monkeypatch.setattr(callback.urllib.request, "urlopen", _raise) + with pytest.raises(CallbackError, match="callback delivery failed"): + callback.post_report("https://hub.spikeforge.net", "v1", {}, "s") diff --git a/tests/test_hub_verify_cli.py b/tests/test_hub_verify_cli.py new file mode 100644 index 0000000..572e6fa --- /dev/null +++ b/tests/test_hub_verify_cli.py @@ -0,0 +1,119 @@ +"""CLI orchestration: env reading, stage sequencing, always-posts-a-report.""" + +from typing import Any, Dict, List, Tuple + +import pytest +from hub_verify import cli +from hub_verify.errors import CallbackError, VerificationStepError + +_ENV = { + "SPIKEFORGE_HUB_VERIFY_VERSION_ID": "v1", + "SPIKEFORGE_HUB_VERIFY_ARTIFACT_URL": "https://cdn.example/a.spkf", + "SPIKEFORGE_HUB_API_BASE_URL": "https://hub.example", + "SPIKEFORGE_HUB_VERIFICATION_SECRET": "sekrit", +} + + +def _set_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Populate every required environment variable for ``main()``.""" + for key, value in _ENV.items(): + monkeypatch.setenv(key, value) + + +class _FakeBundle: + """A stand-in with just the attributes ``_run_checks`` reads.""" + + manifest = {"topology": "fc_small"} + spec = None + + +def test_missing_env_var_exits_loudly( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A missing required setting exits rather than crashing obscurely.""" + monkeypatch.delenv( + "SPIKEFORGE_HUB_VERIFY_VERSION_ID", raising=False + ) + with pytest.raises(SystemExit, match="SPIKEFORGE_HUB_VERIFY_VERSION_ID"): + cli.main() + + +def test_a_passing_artifact_posts_passed_true( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Every stage succeeding posts ``passed: true`` with no reasons.""" + _set_env(monkeypatch) + monkeypatch.setattr( + cli, "fetch_artifact", lambda url, dest: dest + ) + monkeypatch.setattr( + cli, "load_and_build", lambda path: (_FakeBundle(), object()) + ) + monkeypatch.setattr( + cli, "run_funnel", + lambda bundle, module, tmp: { + "inspect": {"kind": "state_dict"}, + "compat": {"verdict": "incompatible"}, + "drift": {"within_tolerance": True}, + }, + ) + monkeypatch.setattr(cli, "energy_reports", lambda spec, module: []) + + posted: Dict[str, Any] = {} + monkeypatch.setattr( + cli, + "post_report", + lambda base, vid, report, secret: posted.update(report=report), + ) + assert cli.main() == 0 + assert posted["report"]["passed"] is True + assert posted["report"]["reasons"] == [] + + +def test_a_failing_stage_posts_the_named_reason( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A stage failure posts ``passed: false`` naming which step and why.""" + _set_env(monkeypatch) + monkeypatch.setattr(cli, "fetch_artifact", lambda url, dest: dest) + + def _raise(path: str) -> Tuple[Any, Any]: + raise VerificationStepError("bundle", "weights are not loadable") + + monkeypatch.setattr(cli, "load_and_build", _raise) + + posted: Dict[str, Any] = {} + monkeypatch.setattr( + cli, + "post_report", + lambda base, vid, report, secret: posted.update(report=report), + ) + assert cli.main() == 0 + assert posted["report"]["passed"] is False + reasons: List[str] = posted["report"]["reasons"] + assert reasons == ["bundle: weights are not loadable"] + + +def test_a_failed_callback_exits_loudly( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The job fails when the signed report cannot be delivered at all.""" + _set_env(monkeypatch) + monkeypatch.setattr(cli, "fetch_artifact", lambda url, dest: dest) + monkeypatch.setattr( + cli, "load_and_build", lambda path: (_FakeBundle(), object()) + ) + monkeypatch.setattr( + cli, "run_funnel", + lambda bundle, module, tmp: { + "inspect": {}, "compat": {}, "drift": {"within_tolerance": True} + }, + ) + monkeypatch.setattr(cli, "energy_reports", lambda spec, module: []) + + def _raise(*_args: Any) -> None: + raise CallbackError("hub API unreachable") + + monkeypatch.setattr(cli, "post_report", _raise) + with pytest.raises(SystemExit, match="could not deliver"): + cli.main() diff --git a/tests/test_hub_verify_fetch.py b/tests/test_hub_verify_fetch.py new file mode 100644 index 0000000..d9171f8 --- /dev/null +++ b/tests/test_hub_verify_fetch.py @@ -0,0 +1,84 @@ +"""Fetching the dispatched artifact, capped and named on failure.""" + +import io +from typing import Any +from urllib import error as urlerror + +import pytest +from hub_verify import fetch +from hub_verify.errors import FetchError + + +class _FakeResponse: + """A minimal stand-in for ``http.client.HTTPResponse``.""" + + def __init__(self, payload: bytes) -> None: + """Wrap ``payload`` behind a chunked ``read``.""" + self._buffer = io.BytesIO(payload) + + def read(self, size: int) -> bytes: + """Return up to ``size`` bytes, matching the real response API.""" + return self._buffer.read(size) + + def __enter__(self) -> "_FakeResponse": + """Support the ``with urlopen(...) as response`` pattern.""" + return self + + def __exit__(self, *exc_info: Any) -> None: + """Nothing to release for an in-memory buffer.""" + + +def test_fetch_writes_the_payload( + tmp_path: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + """A normal response is streamed to ``dest`` unchanged.""" + payload = b"spkf-bytes" + monkeypatch.setattr( + fetch.urllib.request, + "urlopen", + lambda *a, **k: _FakeResponse(payload), + ) + dest = str(tmp_path / "artifact.spkf") + fetch.fetch_artifact("https://cdn.example/x", dest) + with open(dest, "rb") as handle: + assert handle.read() == payload + + +def test_fetch_refuses_past_the_byte_cap( + tmp_path: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + """A payload larger than the cap is refused, not buffered in full.""" + monkeypatch.setattr(fetch, "MAX_ARTIFACT_BYTES", 4) + monkeypatch.setattr( + fetch.urllib.request, + "urlopen", + lambda *a, **k: _FakeResponse(b"way too much data"), + ) + with pytest.raises(FetchError, match="byte cap"): + fetch.fetch_artifact("https://cdn.example/x", str(tmp_path / "a")) + + +def test_http_error_is_a_named_fetch_error( + tmp_path: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + """A non-2xx signed-URL response is a named, not a generic, failure.""" + + def _raise(*_args: Any, **_kwargs: Any) -> None: + raise urlerror.HTTPError("url", 403, "forbidden", {}, None) + + monkeypatch.setattr(fetch.urllib.request, "urlopen", _raise) + with pytest.raises(FetchError, match="HTTP 403"): + fetch.fetch_artifact("https://cdn.example/x", str(tmp_path / "a")) + + +def test_network_error_is_a_named_fetch_error( + tmp_path: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + """A connection failure is reported by name, not as a raw traceback.""" + + def _raise(*_args: Any, **_kwargs: Any) -> None: + raise urlerror.URLError("no route to host") + + monkeypatch.setattr(fetch.urllib.request, "urlopen", _raise) + with pytest.raises(FetchError, match="network fetch failed"): + fetch.fetch_artifact("https://cdn.example/x", str(tmp_path / "a")) diff --git a/tests/test_hub_verify_pipeline.py b/tests/test_hub_verify_pipeline.py new file mode 100644 index 0000000..844a186 --- /dev/null +++ b/tests/test_hub_verify_pipeline.py @@ -0,0 +1,74 @@ +"""End-to-end: load, funnel, and energy-account a real bundle.""" + +import zipfile +from typing import Any, Dict + +import pytest +import torch +from hub_verify.energy import energy_reports +from hub_verify.errors import VerificationStepError +from hub_verify.funnel import run_funnel +from hub_verify.pipeline import load_and_build + +from spikeforge.network import model_store +from spikeforge.serving import bundle_manifest as bm +from spikeforge.serving.bundle import build +from spikeforge.training.training_engine import TrainingEngine + +_NAME = "hub_verify_ckpt" + + +@pytest.fixture(autouse=True) +def _model_dir(tmp_path: Any, monkeypatch: pytest.MonkeyPatch) -> None: + """Redirect checkpoint reads and writes into a per-test directory.""" + monkeypatch.setattr(model_store, "MODEL_DIR", str(tmp_path)) + + +def _written(tmp_path: Any) -> str: + """Save a small checkpoint, build its bundle, and return its path.""" + torch.manual_seed(0) + engine = TrainingEngine( + dataset="mnist", + num_steps=4, + device="cpu", + topology="fc_small", + topology_params={"hidden": 5, "num_classes": 3}, + ) + engine.save(_NAME) + out = str(tmp_path / "model.spkf") + build(_NAME, out=out) + return out + + +def _tamper_weights(path: str) -> None: + """Flip a byte of the stored weights, invalidating its checksum.""" + with zipfile.ZipFile(path) as archive: + entries = {n: archive.read(n) for n in archive.namelist()} + entries[bm.WEIGHTS_NAME] += b"\x00" + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive: + for name, payload in entries.items(): + archive.writestr(name, payload) + + +def test_a_good_bundle_passes_the_whole_funnel(tmp_path: Any) -> None: + """A real, untampered bundle loads, funnels, and accounts cleanly.""" + out = _written(tmp_path) + bundle, module = load_and_build(out) + funnel: Dict[str, Any] = run_funnel(bundle, module, str(tmp_path)) + assert funnel["inspect"]["kind"] == "state_dict" + assert funnel["drift"]["within_tolerance"] is True + reports = energy_reports(bundle.spec, module) + assert reports + assert all("target" in report for report in reports) + + +def test_a_tampered_bundle_is_rejected_at_the_bundle_stage( + tmp_path: Any, +) -> None: + """A checksum mismatch names the bundle stage, not a generic failure.""" + out = _written(tmp_path) + _tamper_weights(out) + with pytest.raises(VerificationStepError) as info: + load_and_build(out) + assert info.value.step == "bundle" + assert info.value.reason diff --git a/tests/test_hub_verify_report.py b/tests/test_hub_verify_report.py new file mode 100644 index 0000000..63d7251 --- /dev/null +++ b/tests/test_hub_verify_report.py @@ -0,0 +1,44 @@ +"""The verification report's shape and the hub-api trust contract.""" + +from hub_verify.report import build_report, rejection_summary + + +def test_passed_report_carries_a_true_boolean() -> None: + """``passed`` is the JSON boolean hub-api's ``trust.label_for`` reads. + + ``hub_api/catalog/trust.py::label_for`` only shows ``machine-checked`` + when ``version.verification["passed"] is True`` -- not truthy, the + literal boolean -- so this is the one field this report must never get + wrong. + """ + report = build_report( + version_id="v1", + passed=True, + reasons=[], + checks={"bundle": {"ok": True}}, + summary="passed every check", + run_url="https://github.com/x/y/actions/runs/1", + ) + assert report["passed"] is True + assert report["reasons"] == [] + assert report["version_id"] == "v1" + + +def test_failed_report_names_the_reason() -> None: + """A rejection carries the actionable reason, not just a flag.""" + reasons = ["bundle: weights are not loadable: bad magic number"] + report = build_report( + version_id="v2", + passed=False, + reasons=reasons, + checks={"bundle": {"ok": False}}, + summary=rejection_summary(reasons), + run_url="", + ) + assert report["passed"] is False + assert "weights are not loadable" in report["summary"] + + +def test_rejection_summary_of_no_reasons_names_the_bug() -> None: + """An empty reason list on a rejection is itself flagged, not hidden.""" + assert "bug" in rejection_summary([]) diff --git a/tests/test_hub_verify_signing.py b/tests/test_hub_verify_signing.py new file mode 100644 index 0000000..68d32d7 --- /dev/null +++ b/tests/test_hub_verify_signing.py @@ -0,0 +1,33 @@ +"""HMAC signing of the outgoing verification report.""" + +import pytest +from hub_verify import signing + + +def test_matching_secret_verifies() -> None: + """A signature computed and checked with the same secret matches.""" + body = b'{"passed": true}' + header = signing.sign_body(body, "sekrit") + assert header.startswith("sha256=") + assert signing.signature_matches(body, "sekrit", header) + + +def test_wrong_secret_does_not_verify() -> None: + """A signature checked under a different secret does not match.""" + body = b'{"passed": true}' + header = signing.sign_body(body, "sekrit") + assert not signing.signature_matches(body, "wrong", header) + + +def test_tampered_body_does_not_verify() -> None: + """A body byte changed after signing breaks the signature.""" + body = b'{"passed": true}' + header = signing.sign_body(body, "sekrit") + tampered = b'{"passed": false}' + assert not signing.signature_matches(tampered, "sekrit", header) + + +def test_empty_secret_is_refused() -> None: + """Signing under an empty secret is refused rather than silently weak.""" + with pytest.raises(ValueError): + signing.sign_body(b"{}", "") diff --git a/tests/test_serving_bundle.py b/tests/test_serving_bundle.py index c50e0c5..4d53f4d 100644 --- a/tests/test_serving_bundle.py +++ b/tests/test_serving_bundle.py @@ -193,6 +193,23 @@ def test_rejects_missing_required_entry(tmp_path: Any) -> None: DeploymentBundle.load(out) +def test_rejects_oversized_decompressed_bundle( + tmp_path: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + """A bundle whose entries decompress past the cap is refused unread. + + The cap is checked against each entry's declared size before any entry + is decompressed, so a normal small bundle is enough to trigger it once + the cap itself is lowered below the bundle's real total -- no need to + construct an actual multi-hundred-megabyte payload to prove the guard + works. + """ + monkeypatch.setattr(bm, "MAX_DECOMPRESSED_BYTES", 8) + out = _written(tmp_path) + with pytest.raises(BundleFormatError, match="decompressed size"): + DeploymentBundle.load(out) + + def test_rejects_non_zip_payload(tmp_path: Any) -> None: """A file that is not a zip archive is a format error.""" path = tmp_path / "broken.spkf" From 2bfd001d718b94584a8d983bb21cef7803bbf883 Mon Sep 17 00:00:00 2001 From: w4ffl35 <25737761+w4ffl35@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:32:04 -0600 Subject: [PATCH 2/2] Stop CI failing on a pip cache that cannot find the repository Every job here sets up Python with `cache: pip`, and on this fleet that step fails outright: "No file ... matched to [**/requirements.txt or **/pyproject.toml], make sure you have checked out the target repository". All seven pyproject.toml files are present in `packages/` at that exact path -- checked on the runner's own workspace while a job sat failed. The runners keep `_work` on another drive behind a symlink, and the action's dependency-file glob does not see through it. It has taken out `blocked-deps`, `client`, `docs` and all five `extras` jobs, and it took out the dashboard's release workflow earlier today for the same reason. It is not reliably reproducible -- an earlier run on main globbed fine -- which makes it worse, not better: a pipeline that merges on green will see spurious red it cannot distinguish from a real failure. Removing it costs nothing. A self-hosted runner keeps `~/.cache/pip` between jobs by itself, so the action was caching a cache. --- .github/workflows/ci.yml | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5c8e22..d85b9cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,10 +21,16 @@ jobs: steps: - uses: actions/checkout@v4 + # No `cache: pip` anywhere in this file. This fleet's runners keep + # `_work` on another drive behind a symlink, and setup-python's + # dependency-file glob does not see through it: it reports that no + # pyproject.toml exists and fails the job before anything is built, + # while all seven of them sit in `packages/`. A self-hosted runner also + # keeps `~/.cache/pip` between jobs on its own, so the action's cache + # was buying nothing here even when it worked. - uses: actions/setup-python@v5 with: python-version: "3.12" - cache: pip - name: Install the core dev extra run: | @@ -56,7 +62,6 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - cache: pip - name: Install torch CPU wheels run: | @@ -89,7 +94,6 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - cache: pip - name: Install torch CPU wheels run: | @@ -141,7 +145,6 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - cache: pip - name: Install torch CPU wheels run: | @@ -182,7 +185,6 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - cache: pip - name: Install torch CPU wheels run: | @@ -243,7 +245,6 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - cache: pip - name: Install torch CPU wheels run: | @@ -310,7 +311,6 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - cache: pip - name: Install docs dependencies run: | @@ -337,7 +337,6 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - cache: pip - name: Install the renderer PyPI itself uses run: | @@ -405,7 +404,6 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - cache: pip - name: Build the core wheel with no extras run: |