diff --git a/.github/workflows/cross-python-cli-proof.yml b/.github/workflows/cross-python-cli-proof.yml new file mode 100644 index 0000000..954f45a --- /dev/null +++ b/.github/workflows/cross-python-cli-proof.yml @@ -0,0 +1,357 @@ +name: Cross-Python public CLI continuation proof + +# Proves the Phase 1 capability through the public CLI only: a program is run +# and frozen on native Linux x86_64 under CPython 3.12.13, the source process +# exits and is reaped, and the unchanged image is verified and resumed on +# native Apple Silicon macOS arm64 under CPython 3.13.14. +# +# The two jobs are separate runners, so the source machine is gone before the +# target starts. Nothing is carried between them except the image, the control +# output, and the evidence documents. + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + - claude/continuum-cross-python-abi-a839mz + +permissions: + contents: read + +env: + SOURCE_PYTHON: 3.12.13 + TARGET_PYTHON: 3.13.14 + WORKLOAD: validation/cross_python/programs/layered_accumulator.py + HOLD_SAFE_POINT: "900" + +jobs: + linux-py312-source: + name: source / Linux x86_64 / CPython 3.12.13 + runs-on: ubuntu-24.04 + outputs: + source_commit: ${{ steps.identity.outputs.source_commit }} + image_sha256: ${{ steps.freeze.outputs.image_sha256 }} + steps: + - name: Check out the exact workflow commit + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.sha }} + + - name: Verify a clean native Linux x86_64 source tree + id: identity + shell: bash + run: | + set -Eeuo pipefail + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + test -z "$(git status --porcelain=v1)" + test "$(uname -s)" = Linux + test "$(uname -m)" = x86_64 + echo "source_commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Provision exact CPython ${{ env.SOURCE_PYTHON }} + shell: bash + run: | + set -Eeuo pipefail + if ! command -v uv >/dev/null 2>&1; then + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + fi + uv python install "$SOURCE_PYTHON" + PYBIN_RESOLVED="$(uv python find "$SOURCE_PYTHON")" + echo "PYBIN=$PYBIN_RESOLVED" >> "$GITHUB_ENV" + test "$("$PYBIN_RESOLVED" -c 'import platform; print(platform.python_version())')" = "$SOURCE_PYTHON" + + - name: Run the full suite on the creator runtime + shell: bash + run: | + set -Eeuo pipefail + PYTHONPATH=. "$PYBIN" -m unittest discover -s tests + + - name: Confirm the public CLI reports this runtime as verified + shell: bash + run: | + set -Eeuo pipefail + PYTHONPATH=. "$PYBIN" -m continuum doctor --json > doctor-source.json + PYTHONPATH=. "$PYBIN" - <<'PY' + import json + report = json.load(open("doctor-source.json")) + assert report["problems"] == [], report["problems"] + assert report["python_version"] == "3.12.13", report + assert "3.13.14" in report["verified_python_versions"], report + assert report["container_format_version"] == "0.2", report + assert report["execution_abi_version"] == "1.0", report + print(json.dumps(report, indent=2, sort_keys=True)) + PY + + - name: Freeze live state through continuum run and continuum freeze + id: freeze + shell: bash + run: | + set -Eeuo pipefail + PYTHONPATH=. "$PYBIN" validation/cross_python/cli_proof.py source \ + --python "$PYBIN" \ + --program "$WORKLOAD" \ + --output "$RUNNER_TEMP/cross-python-source" \ + --hold-safe-point "$HOLD_SAFE_POINT" \ + --expect-python "$SOURCE_PYTHON" \ + --commit "$GITHUB_SHA" + image="$RUNNER_TEMP/cross-python-source/source.cont" + test -s "$image" + test -s "$RUNNER_TEMP/cross-python-source/source-evidence.json" + echo "image_sha256=$(shasum -a 256 "$image" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + + - name: Confirm the source process exited and left four live frames + shell: bash + run: | + set -Eeuo pipefail + PYTHONPATH=. "$PYBIN" - <<'PY' + import json + import os + from pathlib import Path + + evidence = json.loads( + ( + Path(os.environ["RUNNER_TEMP"]) + / "cross-python-source" + / "source-evidence.json" + ).read_text() + ) + process = evidence["source_process"] + assert process["exited_and_reaped_before_target"] is True, evidence + assert process["exit_status"] == 0, evidence + freeze = evidence["freeze"] + assert freeze["source_alive_when_request_published"] is True, freeze + assert freeze["request_published_before_release"] is True, freeze + assert evidence["cli_only"] is True, evidence + assert "Frames: 4" in evidence["inspect_stdout"], evidence["inspect_stdout"] + assert "Execution ABI: 1.0" in evidence["inspect_stdout"], evidence + print(json.dumps(evidence["freeze"], indent=2, sort_keys=True)) + print(evidence["inspect_stdout"]) + PY + + - name: Upload the unchanged image, control, and source evidence + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: cross-python-cli-source-${{ github.run_id }}-${{ github.run_attempt }} + if-no-files-found: error + include-hidden-files: true + path: ${{ runner.temp }}/cross-python-source + retention-days: 90 + + macos-py313-target: + name: target / macOS arm64 / CPython 3.13.14 + needs: linux-py312-source + runs-on: macos-26 + steps: + - name: Check out the exact source commit + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.sha }} + + - name: Verify a clean native Apple Silicon target + shell: bash + run: | + set -Eeuo pipefail + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + test "$GITHUB_SHA" = "${{ needs.linux-py312-source.outputs.source_commit }}" + test -z "$(git status --porcelain=v1)" + test "$(uname -s)" = Darwin + test "$(uname -m)" = arm64 + test "$(arch)" = arm64 + # Refuse a translated process: Rosetta would make this a proof about + # x86_64 emulation rather than about native arm64. + test "$(sysctl -in sysctl.proc_translated 2>/dev/null || echo 0)" = 0 + + - name: Provision exact CPython ${{ env.TARGET_PYTHON }} independently + shell: bash + run: | + set -Eeuo pipefail + if ! command -v uv >/dev/null 2>&1; then + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + fi + uv python install "$TARGET_PYTHON" + PYBIN_RESOLVED="$(uv python find "$TARGET_PYTHON")" + echo "PYBIN=$PYBIN_RESOLVED" >> "$GITHUB_ENV" + test "$("$PYBIN_RESOLVED" -c 'import platform; print(platform.python_version())')" = "$TARGET_PYTHON" + + - name: Run the full suite on the target runtime + shell: bash + run: | + set -Eeuo pipefail + PYTHONPATH=. "$PYBIN" -m unittest discover -s tests + + - name: Download the image after the source job has finished + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: cross-python-cli-source-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/source-artifact + + - name: Confirm the image arrived byte-identical + shell: bash + run: | + set -Eeuo pipefail + source_dir="$RUNNER_TEMP/source-artifact" + if [[ -d "$source_dir/cross-python-source" ]]; then + source_dir="$source_dir/cross-python-source" + fi + echo "SOURCE_DIR=$source_dir" >> "$GITHUB_ENV" + actual="$(shasum -a 256 "$source_dir/source.cont" | cut -d' ' -f1)" + expected="${{ needs.linux-py312-source.outputs.image_sha256 }}" + echo "capture: $expected" + echo "arrival: $actual" + test "$actual" = "$expected" + + - name: Verify and resume through continuum verify and continuum resume + shell: bash + run: | + set -Eeuo pipefail + PYTHONPATH=. "$PYBIN" validation/cross_python/cli_proof.py target \ + --python "$PYBIN" \ + --input "$SOURCE_DIR" \ + --output "$RUNNER_TEMP/cross-python-final" \ + --expect-python "$TARGET_PYTHON" \ + --commit "$GITHUB_SHA" + + - name: Assert every required proof property + shell: bash + run: | + set -Eeuo pipefail + PYTHONPATH=. "$PYBIN" - <<'PY' + import json + import os + from pathlib import Path + + report = json.loads( + ( + Path(os.environ["RUNNER_TEMP"]) + / "cross-python-final" + / "final-report.json" + ).read_text() + ) + + source = report["source"] + target = report["target"] + image = report["image"] + restoration = report["restoration"] + + assert source["os"] == "Linux", report + assert source["architecture"] == "x86_64", report + assert source["python_version"] == "3.12.13", report + assert target["os"] == "Darwin", report + assert target["architecture"] == "arm64", report + assert target["python_version"] == "3.13.14", report + + assert report["cross_python"] is True, report + assert report["cross_os"] is True, report + assert report["cross_architecture"] is True, report + assert report["cli_only"] is True, report + + assert source["exited_and_reaped_before_target"] is True, report + assert image["byte_identical_in_transit"] is True, report + assert image["unchanged_by_restore"] is True, report + + assert restoration["completed_actions_repeated"] == 0, restoration + assert restoration["combined_output_matches_control"] is True, restoration + assert restoration["prefix_is_control_prefix"] is True, restoration + + # The restore must have been decided by the execution ABI, not by + # matching the creator's interpreter. + verify_stdout = restoration["verify_stdout"] + assert "Compatibility policy: execution-abi" in verify_stdout, verify_stdout + assert "Creator Python: 3.12.13" in verify_stdout, verify_stdout + assert "Restoring Python: 3.13.14" in verify_stdout, verify_stdout + assert "Frames: verified (4)" in verify_stdout, verify_stdout + + resume_stderr = restoration["resume_stderr"] + assert "execution ABI 1.0" in resume_stderr, resume_stderr + assert "restoring under Python 3.13.14" in resume_stderr, resume_stderr + assert "Python 3.12.13" in resume_stderr, resume_stderr + + print(json.dumps(report, indent=2, sort_keys=True)) + print("PROOF: cross-OS, cross-ISA, cross-Python continuation via public CLI") + PY + + - name: Upload the complete proof evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: cross-python-cli-final-${{ github.run_id }}-${{ github.run_attempt }} + if-no-files-found: error + include-hidden-files: true + path: ${{ runner.temp }}/cross-python-final + retention-days: 90 + + differential-corpus: + name: differential corpus / Linux x86_64 / 3.12.13 -> 3.13.14 + runs-on: ubuntu-24.04 + steps: + - name: Check out the exact workflow commit + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.sha }} + + - name: Provision both exact interpreters + shell: bash + run: | + set -Eeuo pipefail + if ! command -v uv >/dev/null 2>&1; then + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + fi + uv python install "$SOURCE_PYTHON" "$TARGET_PYTHON" + echo "SOURCE_PYBIN=$(uv python find "$SOURCE_PYTHON")" >> "$GITHUB_ENV" + echo "TARGET_PYBIN=$(uv python find "$TARGET_PYTHON")" >> "$GITHUB_ENV" + + - name: Run the paired cross-Python differential corpus + shell: bash + run: | + set -Eeuo pipefail + PYTHONPATH=. "$SOURCE_PYBIN" validation/cross_python/differential.py \ + --source-python "$SOURCE_PYBIN" \ + --target-python "$TARGET_PYBIN" \ + --checkpoints 6 \ + --workdir "$RUNNER_TEMP/differential" \ + --output "$RUNNER_TEMP/cross-python-corpus.json" + + - name: Enforce the release gate on the corpus result + shell: bash + run: | + set -Eeuo pipefail + "$TARGET_PYBIN" - <<'PY' + import json + import os + from pathlib import Path + + report = json.loads( + (Path(os.environ["RUNNER_TEMP"]) / "cross-python-corpus.json").read_text() + ) + assert report["cross_python"] is True, report + assert report["silent_mismatches"] == 0, report + assert report["infrastructure_failures"] == 0, report + assert report["correctness_among_accepted_cases"] == 1.0, report + accepted = report["counts"].get("accepted-and-correct", 0) + assert accepted > 100, report["counts"] + summary = {k: v for k, v in report.items() if k != "case_records"} + print(json.dumps(summary, indent=2, sort_keys=True)) + PY + + - name: Upload the corpus report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: cross-python-corpus-${{ github.run_id }}-${{ github.run_attempt }} + if-no-files-found: error + path: ${{ runner.temp }}/cross-python-corpus.json + retention-days: 90 diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 751ccfc..2f20ace 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -1,5 +1,97 @@ # Compatibility +## The execution compatibility contract (container format 0.2) + +Container format 0.2 images carry an `execution_contract` block that separates +the axes the 0.1 container collapsed into two fields. Each is versioned +independently: + +| Axis | Meaning | Gates a restore? | +| --- | --- | --- | +| `container_format_version` | archive layout | yes, exact | +| `graph_codec_version` | object-graph encoding | yes, exact | +| `ir_version` | instruction set the frames index | yes, exact | +| `execution_abi_version` | meaning of frame, binding, stack, and control state | yes, exact | +| `creator.continuum_version` | which Continuum wrote the image | no — provenance | +| `creator.python_version` | which interpreter wrote the image | no — provenance | +| `target.runtime_implementations` | which runtimes may restore it | yes | +| `target.python_versions` | interpreters the creator accepts | yes | +| `target.required_capabilities` | named features the target must implement | yes | + +Creator identity is recorded but does not gate the restore. What gates it is +the execution ABI, the capability set, and the interpreter allowlist. + +The interpreter decision has **two independent gates**, and both must pass: + +1. the running interpreter appears in the image's `target.python_versions`; +2. the running interpreter appears in this runtime's + `abi.VERIFIED_PYTHON_VERSIONS`. + +The second gate means an image cannot widen what this runtime accepts by +asserting a version nobody verified. Membership in `VERIFIED_PYTHON_VERSIONS` +requires a green native cross-Python proof run, so it is a record of evidence +rather than an intention. + +The allowlist is **exact and never a range**. `3.13.0` and `3.12.14` are +refused exactly as firmly as `3.9`, even though both sit inside the interval the +verified versions span. Packaging metadata (`requires-python`) is necessarily +coarser than an exact allowlist; it is an install-time filter only, and the +runtime allowlist is the authority. Both halves of that split are tested. + +Every refusal carries a stable machine-readable reason code, so compatibility +policy is asserted on directly rather than by matching prose. + +### Format 0.1 images + +Format 0.1 images carry no contract, so nothing in them would justify a +capability-based decision. They keep their original rule — exact creator Python +*and* exact creator Continuum version — and their refusal messages name the +format version and state that re-freezing under 0.2 is what provides +cross-Python restore. An image cannot obtain the 0.2 policy by declaring 0.1 +while carrying contract fields, nor the reverse. + +## Cross-Python differential corpus + +The corpus below measures *unchanged-source* behavior on one interpreter. A +separate paired suite measures whether live execution state survives a change +of interpreter: `validation/cross_python/differential.py` freezes each program +at safe points spread across its execution, deeply verifies the image without +executing it, restores under the other interpreter, and compares against an +independently run uninterrupted control. + +The comparison covers the logical frame chain, resume positions and opcodes, +locals, lexical cells, operand stacks, control blocks, pending finally state, +module globals, module RNG state, `random.Random` state, file offsets, and +instruction and safe-point counters. Object identity is compared structurally: +each object is labelled on first visit and revisits emit a back-reference, so +shared references and reference cycles are part of the compared value. + +Measured on native Linux x86_64, CPython 3.12.13 → 3.13.14, at commit +`40cc9dd` (Actions run 30658976309). Raw result: +`compatibility/results/cross-python-3.12.13-to-3.13.14-linux-x86_64-2026-07-31.json`. + +| Classification | Cases | +| --- | ---: | +| Accepted and correct | 189 | +| Explicitly refused | 0 | +| Unsupported by the language frontend | 15 | +| Infrastructure failure | 0 | +| **Silent mismatch** | **0** | +| Total | 204 | + +Correctness among accepted cases: **100%**. Refused and frontend-unsupported +cases are reported separately and are not folded into that rate. The 15 +frontend cases are 10 corpus programs the compiler does not accept at all; +they are a language-coverage gap, not a portability result. + +Live frame depth up to 16 was exercised, across 11 distinct frame chains and +40 programs. + +A suite reporting zero mismatches is only meaningful if it can detect one, so +`tests/test_cross_python_differential.py` corrupts each compared dimension in +turn — including replaying a completed action and restarting from program +entry — and asserts every corruption is caught. + ## Method The initial corpus contains 50 unchanged, self-contained, MIT-licensed diff --git a/FORMAT.md b/FORMAT.md index 714a454..a0412b9 100644 --- a/FORMAT.md +++ b/FORMAT.md @@ -1,4 +1,4 @@ -# Continuum Portable Process Image 0.1 +# Continuum Portable Process Image 0.2 ## Container @@ -131,20 +131,30 @@ the author. Version 0.1 does not implement signatures and reports The current writer requires: -- image format 0.1; +- image format 0.2; - IR 0.4; -- Continuum runtime 0.3.1; -- CPython 3.12.13; +- Continuum runtime 0.4.0a1; +- CPython 3.12.13 or 3.13.14, the exact versions in + `abi.VERIFIED_PYTHON_VERSIONS`; - target OS Linux, Darwin, or Windows; - target architecture x86_64 or arm64; -- a target `(OS, architecture)` pair listed in - `target_compatibility.platforms`; +- a target `(OS, architecture)` pair listed in `execution_contract.target` + **and** accepted by the reading runtime; - `native_payload_required: false`; - only the mandatory capabilities implemented by this runtime. -`target_compatibility.platforms` currently lists Linux x86_64, Linux arm64, -Darwin x86_64, Darwin arm64, and Windows x86_64. Windows arm64 is absent and -is rejected on the pair check. +`execution_contract.target.platforms` currently lists Linux x86_64, Linux +arm64, Darwin x86_64, Darwin arm64, and Windows x86_64. Windows arm64 is absent +and is rejected on the pair check. + +The platform pair and the Python version are each decided twice: once against +the list the image carries, and once against the reading runtime's own accepted +list (`abi.VERIFIED_PLATFORMS`, `abi.VERIFIED_PYTHON_VERSIONS`). An image that +adds Windows arm64 to its own lists and recomputes every archive checksum is +still refused, because the runtime never accepted that pair. + +Container format 0.1 images carry `target_compatibility` instead of +`execution_contract` and are read under the legacy exact-version rule. These declarations describe what a reader will attempt to restore. They are not evidence that the pair has been exercised: see diff --git a/LANGUAGE_SUPPORT.md b/LANGUAGE_SUPPORT.md index 4b06255..bf32114 100644 --- a/LANGUAGE_SUPPORT.md +++ b/LANGUAGE_SUPPORT.md @@ -1,6 +1,6 @@ # Python language support -This matrix describes Continuum IR 0.4 (runtime 0.3.1) as verified on +This matrix describes Continuum IR 0.4 (runtime 0.4.0a1) as verified on CPython 3.12.13. The status words are literal: **supported**, **partially supported**, **explicitly rejected**, or **untested**. “Supported” applies only inside the diff --git a/LIMITATIONS.md b/LIMITATIONS.md index c00757b..b97fd13 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -33,7 +33,10 @@ See `LANGUAGE_SUPPORT.md` for the test-backed feature-by-feature matrix. ## Execution model -- CPython 3.12.13 exactly; +- CPython 3.12.13 or 3.13.14, exactly. The allowlist is exact and never a + range: an interpreter that is merely *between* verified versions, such as + 3.13.0, is refused before any execution state is created or reconstructed. + Adding a version requires a green native cross-Python proof run; - one Continuum VM and one application thread; - freeze only at compiler-inserted safe points; - host builtin/module calls are atomic and cannot be suspended internally; diff --git a/PORTABILITY.md b/PORTABILITY.md index b2c0c9a..041c566 100644 --- a/PORTABILITY.md +++ b/PORTABILITY.md @@ -31,12 +31,14 @@ therefore cannot succeed across hosts with different path forms. Resume rejects an image unless all of these checks pass: -- format 0.1 and current IR 0.4 schema validation; -- Continuum runtime implementation and exact runtime version `0.3.1`; -- CPython 3.12.13; +- container format 0.2 (or legacy 0.1) and current IR 0.4 schema validation; +- Continuum runtime implementation. Container format 0.2 images no longer require an exact runtime version `0.4.0a1`; they require the execution ABI and the capability set. Container format 0.1 images still require it exactly; +- CPython 3.12.13 or 3.13.14 for container format 0.2 (the exact verified + allowlist); exactly the creator's CPython for legacy format 0.1; - target OS in `Linux`, `Darwin`, `Windows`; - target architecture in `x86_64`, `arm64`; -- the exact target `(OS, architecture)` pair in the manifest platform list; +- the exact target `(OS, architecture)` pair in the image's platform list + **and** in the reading runtime's own accepted list, `abi.VERIFIED_PLATFORMS`; - `native_payload_required` is false; - every mandatory capability is recognized; - source, IR, module, runtime, resource, frame, heap-count, and checksum @@ -47,6 +49,11 @@ and Windows x86_64. Windows arm64 is not an accepted pair and is rejected by the pair check even though `Windows` and `arm64` each appear in the preceding lists. +That rejection does not depend on the image being honest. The pair is checked +against the reading runtime's own list as well as the image's, so an image that +inserts Windows arm64 into its platform list and recomputes every archive +checksum is still refused. + Accepting a target pair is a format-compatibility decision only. It states that this runtime will attempt the restore, not that the pair has ever been exercised. The tested combinations below are the only evidence of portability. @@ -65,7 +72,9 @@ implementation and can still have platform-specific behavior. | Proof commit, IR 0.2/runtime 0.1.1.dev0 | Native GitHub-hosted Linux x86_64 VM | Native GitHub-hosted Apple Silicon macOS arm64 | **verified**; Actions run 30489463484, 26/26 conditions | | IR 0.4/runtime 0.2.0 | Native Linux x86_64 | Native Apple Silicon macOS arm64 | **verified**; Actions run 30592158078 at commit `21f7b2e`, carrying a class, an instance, a live handler, variadic bindings, and a shared closure cell | | Release IR 0.4/runtime 0.3.0 | Native Linux x86_64 | Native Apple Silicon macOS arm64 | **verified**; Actions run 30596179154 at commit `023f74c` | +| Container format 0.2/IR 0.4/execution ABI 1.0/runtime 0.4.0a1 | Native Linux x86_64, **CPython 3.12.13** | Native Apple Silicon macOS arm64, **CPython 3.13.14** | **verified**; Actions run 30658976309 at commit `40cc9dd`, image SHA-256 `3b564d9d37a9353ebb22027a4b3597d30fc2eef1272c3a220fc7f65e3d939824` identical at capture, on arrival, and after restore; driven entirely through the public CLI; 4 live frames; 0 completed actions repeated | | Any revision | Native Windows x86_64 | Any other platform | unverified; no workflow generates or resumes a cross-host Windows image | +| Container format 0.2 | Any CPython outside `abi.VERIFIED_PYTHON_VERSIONS` | Any | unverified and refused before execution; the allowlist is exact, so 3.13.0 and 3.12.14 are refused as firmly as 3.9 | | Any revision | Any other platform | Native Windows x86_64 | unverified; no workflow generates or resumes a cross-host Windows image | | Any revision | Linux x86_64 host | Second native Linux x86_64 environment | unverified | | Any revision | Any other native cross-architecture pair | Any | unverified | diff --git a/README.md b/README.md index 11ddbcf..6361771 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ # Continuum [![Cross-platform proof](https://github.com/byte271/Continuum/actions/workflows/cross-platform-proof.yml/badge.svg)](https://github.com/byte271/Continuum/actions/workflows/cross-platform-proof.yml) +[![Cross-Python CLI proof](https://github.com/byte271/Continuum/actions/workflows/cross-python-cli-proof.yml/badge.svg)](https://github.com/byte271/Continuum/actions/workflows/cross-python-cli-proof.yml) [![Runtime bundles](https://github.com/byte271/Continuum/actions/workflows/runtime-bundles.yml/badge.svg)](https://github.com/byte271/Continuum/actions/workflows/runtime-bundles.yml) -[![Version](https://img.shields.io/badge/version-0.3.1-blue.svg)](STATUS.md) -[![Python](https://img.shields.io/badge/CPython-3.12.13-3776ab.svg)](#requirements) +[![Version](https://img.shields.io/badge/version-0.4.0a1-blue.svg)](STATUS.md) +[![Python](https://img.shields.io/badge/CPython-3.12.13%20%7C%203.13.14-3776ab.svg)](#requirements) [![Platforms](https://img.shields.io/badge/native-Linux%20%7C%20macOS%20%7C%20Windows-success.svg)](#platform-support) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) @@ -97,12 +98,39 @@ final result hash matched an uninterrupted control run byte for byte, with no repeated completed action. See [PORTABILITY.md](PORTABILITY.md) and the [validation protocol](validation/cross_platform/README.md). -The verified scope remains narrow: CPython 3.12.13 exactly, one thread, a -controlled language subset, Continuum safe points, and read-only regular -files. Classes, closures, generators, native extensions, subprocesses, +The verified scope remains narrow: CPython 3.12.13 and 3.13.14 exactly, one +thread, a controlled language subset, Continuum safe points, and read-only +regular files. Classes, closures, generators, native extensions, subprocesses, sockets, writable files, and arbitrary CPython frames are unsupported. -That proof is immutable evidence for Continuum IR 0.2 at the commit above. +### It has also crossed Python versions + +At commit +[`40cc9dd`](https://github.com/byte271/Continuum/commit/40cc9dd0ed1b2a81dfd265665c1232363d496dc8) +([Actions run 30658976309](https://github.com/byte271/Continuum/actions/runs/30658976309)), +a program was started and frozen on native Linux x86_64 under **CPython +3.12.13**, the source process exited and was reaped, and the unchanged image was +verified and resumed on a native Apple Silicon macOS arm64 runner under +**CPython 3.13.14**. + +The whole path used only the public CLI — `continuum run`, `continuum freeze`, +`continuum verify`, `continuum resume`. The image SHA-256 was +`3b564d9d37a9353ebb22027a4b3597d30fc2eef1272c3a220fc7f65e3d939824` at capture, +on arrival, and after restore. Four live logical frames were restored, zero +completed actions repeated, and source-plus-target output equalled an +independently run uninterrupted control. + +This works because Continuum's execution state lives in its own VM — explicit +frames, logical program counters, operand stacks, and lexical cells — rather +than in CPython frame objects, and because the target restores the IR stored in +the image instead of recompiling the source. The restore is authorized by an +explicit execution ABI plus an exact interpreter allowlist, not by matching the +creator's interpreter. What is **not** claimed: arbitrary Python versions, +arbitrary process migration, or native CPython frame migration. + +The cross-platform proof described two sections above — run 30489463484 at +commit `15bceef` — is immutable evidence for Continuum IR 0.2 at that commit. +It is a separate result from the cross-Python proof at `40cc9dd`. The same two-job workflow was rerun for IR 0.3 at runtime `0.2.0a1`: its `linux-source` and dependent `macos-target` jobs passed at commit @@ -152,12 +180,16 @@ the stage-by-stage hostile audit is [AUDIT.md](AUDIT.md). ## Requirements -- CPython 3.12.13 exactly; +- CPython 3.12.13 or 3.13.14, exactly — these are the versions verified end to + end by native CI; - Linux x86_64, Apple Silicon macOS arm64, or Windows x86_64; - one thread; - only the standard library is needed. -The exact patch version is intentional while the image and IR are unstable. +The allowlist is exact rather than a range, and it is enforced at runtime, not +just at install time. `3.13.0` and `3.12.14` are refused as firmly as `3.9`, +because neither has been proven. Adding a version requires a green cross-Python +proof run, not a version bump. See [COMPATIBILITY.md](COMPATIBILITY.md). ## Run @@ -244,7 +276,7 @@ source host recorded it; `NEW` is resolved on the current host. python3 -m unittest discover -s tests -v ``` -The suite discovers 180 tests and is run natively on Linux x86_64, Apple +The suite discovers 313 tests and is run natively on Linux x86_64, Apple Silicon macOS arm64, and Windows x86_64 by `runtime-bundles.yml`. Tests whose mechanism does not exist on the current host skip explicitly: POSIX signal notification and the shell installer skip on Windows, and the native Apple diff --git a/ROADMAP.md b/ROADMAP.md index 0db47ea..43fbfe6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,7 +8,7 @@ Completed milestones stay listed with their evidence so a later regression is visible as a change, not as a silently dropped line. 1. **Done.** Publish the clean IR 0.3 revision, now runtime 0.2.0, with all - 180 tests and the 50-program corpus reports. + 313 tests and the 50-program corpus reports. 2. **Done.** Generate a new IR 0.3 image on the native Linux x86_64 Actions job and rerun the dependent native Apple Silicon macOS arm64 proof without reusing any IR 0.2 image. Passed in Actions run 30509186641 at commit diff --git a/STATUS.md b/STATUS.md index c949f1a..3181852 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,7 +1,7 @@ # Status -Version: 0.3.1 · IR 0.4 · image format 0.1 · CPython 3.12.13 exactly -Updated: 2026-07-30 +Version: 0.4.0a1 · IR 0.4 · image format 0.2 · execution ABI 1.0 · CPython 3.12.13 and 3.13.14 +Updated: 2026-07-31 ## Platform matrix @@ -18,6 +18,26 @@ has no Windows job. ## WORKING +- **Verified cross-Python continuation, through the public CLI.** A program + frozen on native Linux x86_64 under CPython 3.12.13 was verified and resumed + on a native Apple Silicon macOS arm64 runner under CPython 3.13.14, after the + source process had exited and been reaped. Actions run + [30658976309](https://github.com/byte271/Continuum/actions/runs/30658976309) + at commit `40cc9dd`. The image SHA-256 was + `3b564d9d37a9353ebb22027a4b3597d30fc2eef1272c3a220fc7f65e3d939824` at + capture, on arrival, and after restore; four live logical frames were + restored; zero completed actions repeated; source-plus-target output equalled + an independently run uninterrupted control. Only `continuum run`, + `continuum freeze`, `continuum verify`, and `continuum resume` were used. +- Container format 0.2 with an explicit execution compatibility contract: + container format, graph codec, IR, and execution ABI versioned separately, + creator runtime and Python demoted to provenance, and an exact allowlist of + verified target interpreters. Creator runtime version is no longer a restore + requirement. Format 0.1 images keep their original exact-version rule. +- Cross-Python differential corpus: 204 cases over 50 programs, CPython + 3.12.13 → 3.13.14, 189 accepted and correct, **0 silent mismatches**, 0 + infrastructure failures, live frame depth to 16. 15 cases are language + frontend gaps, reported separately. - Verified cross-platform continuation from a native x86_64 Linux GitHub-hosted VM to a native Apple Silicon macOS arm64 GitHub-hosted runner. Actions run @@ -79,7 +99,7 @@ has no Windows job. all four gates for 35 programs (70.0%), up from 32 (64.0%) before default arguments. That rate is a Linux x86_64 measurement; the suite exercises two corpus programs through all four gates on every host. -- Current full suite: 180 tests discovered. Tests skip only where the host +- Current full suite: 313 tests discovered. Tests skip only where the host lacks the mechanism under test: the native Apple Silicon test skips off macOS arm64, and POSIX signal notification, the shell installer, and the symlink launcher skip on Windows. diff --git a/compatibility/results/cross-python-3.12.13-to-3.13.14-linux-x86_64-2026-07-31.json b/compatibility/results/cross-python-3.12.13-to-3.13.14-linux-x86_64-2026-07-31.json new file mode 100644 index 0000000..656554d --- /dev/null +++ b/compatibility/results/cross-python-3.12.13-to-3.13.14-linux-x86_64-2026-07-31.json @@ -0,0 +1,1892 @@ +{ + "case_records": [ + { + "classification": "unsupported-by-language-frontend", + "detail": "CompileError: assignment_chained.py:1: unsupported syntax Assign (chained assignment)", + "program": "assignment_chained", + "safe_point": null + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__", + "search", + "search", + "search", + "search", + "search" + ], + "frames": 6, + "program": "backtracking_subsets", + "safe_point": 11 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__", + "search", + "search", + "search", + "search" + ], + "frames": 5, + "program": "backtracking_subsets", + "safe_point": 23 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__", + "search", + "search", + "search", + "search" + ], + "frames": 5, + "program": "backtracking_subsets", + "safe_point": 34 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__", + "search", + "search", + "search", + "search" + ], + "frames": 5, + "program": "backtracking_subsets", + "safe_point": 46 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__", + "search", + "search" + ], + "frames": 3, + "program": "backtracking_subsets", + "safe_point": 57 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__", + "search", + "search", + "search", + "search" + ], + "frames": 5, + "program": "backtracking_subsets", + "safe_point": 69 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "bytes_hex_digest", + "safe_point": 1 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "bytes_hex_digest", + "safe_point": 3 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "bytes_hex_digest", + "safe_point": 4 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "bytes_hex_digest", + "safe_point": 6 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "bytes_hex_digest", + "safe_point": 7 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "bytes_hex_digest", + "safe_point": 9 + }, + { + "classification": "unsupported-by-language-frontend", + "detail": "CompileError: class_accumulator.py:6: unsupported syntax AugAssign (augmented assignment to non-name)", + "program": "class_accumulator", + "safe_point": null + }, + { + "classification": "unsupported-by-language-frontend", + "detail": "CompileError: comparison_chained.py:4: unsupported syntax Compare (chained comparison)", + "program": "comparison_chained", + "safe_point": null + }, + { + "classification": "unsupported-by-language-frontend", + "detail": "CompileError: comprehension_dict.py:1: unsupported syntax DictComp (expression)", + "program": "comprehension_dict", + "safe_point": null + }, + { + "classification": "unsupported-by-language-frontend", + "detail": "CompileError: comprehension_list.py:1: unsupported syntax ListComp (expression)", + "program": "comprehension_list", + "safe_point": null + }, + { + "classification": "unsupported-by-language-frontend", + "detail": "CompileError: comprehension_set.py:1: unsupported syntax SetComp (expression)", + "program": "comprehension_set", + "safe_point": null + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "control_nested_loops", + "safe_point": 24 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "control_nested_loops", + "safe_point": 48 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "control_nested_loops", + "safe_point": 72 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "control_nested_loops", + "safe_point": 96 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "control_nested_loops", + "safe_point": 120 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "control_nested_loops", + "safe_point": 144 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "control_try_finally", + "safe_point": 1 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "control_try_finally", + "safe_point": 3 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "control_try_finally", + "safe_point": 4 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "control_try_finally", + "safe_point": 5 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "control_try_finally", + "safe_point": 6 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "control_try_finally", + "safe_point": 8 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "data_grouping", + "safe_point": 3 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "data_grouping", + "safe_point": 5 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "data_grouping", + "safe_point": 8 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "data_grouping", + "safe_point": 11 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "data_grouping", + "safe_point": 14 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "data_grouping", + "safe_point": 16 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "data_histogram", + "safe_point": 4 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "data_histogram", + "safe_point": 8 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "data_histogram", + "safe_point": 12 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "data_histogram", + "safe_point": 15 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "data_histogram", + "safe_point": 19 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "data_histogram", + "safe_point": 23 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "data_rolling_average", + "safe_point": 2 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "data_rolling_average", + "safe_point": 5 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "data_rolling_average", + "safe_point": 7 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "data_rolling_average", + "safe_point": 10 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "data_rolling_average", + "safe_point": 12 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "data_rolling_average", + "safe_point": 15 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "default_clamp_bounds", + "safe_point": 1 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci" + ], + "frames": 16, + "program": "default_memo_fibonacci", + "safe_point": 2 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci" + ], + "frames": 13, + "program": "default_memo_fibonacci", + "safe_point": 5 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci" + ], + "frames": 11, + "program": "default_memo_fibonacci", + "safe_point": 7 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci" + ], + "frames": 8, + "program": "default_memo_fibonacci", + "safe_point": 10 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci", + "fibonacci" + ], + "frames": 6, + "program": "default_memo_fibonacci", + "safe_point": 12 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__", + "fibonacci", + "fibonacci" + ], + "frames": 3, + "program": "default_memo_fibonacci", + "safe_point": 15 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "default_parser_separator", + "safe_point": 1 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__", + "parse_record" + ], + "frames": 2, + "program": "default_parser_separator", + "safe_point": 2 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "dynamic_coin_change", + "safe_point": 27 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "dynamic_coin_change", + "safe_point": 54 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "dynamic_coin_change", + "safe_point": 81 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "dynamic_coin_change", + "safe_point": 107 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "dynamic_coin_change", + "safe_point": 134 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "dynamic_coin_change", + "safe_point": 161 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "dynamic_lcs", + "safe_point": 46 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "dynamic_lcs", + "safe_point": 92 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "dynamic_lcs", + "safe_point": 138 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "dynamic_lcs", + "safe_point": 183 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "dynamic_lcs", + "safe_point": 229 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "dynamic_lcs", + "safe_point": 275 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "exception_handler", + "safe_point": 2 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "exception_handler", + "safe_point": 5 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "exception_handler", + "safe_point": 7 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "exception_handler", + "safe_point": 10 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "exception_handler", + "safe_point": 12 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "exception_handler", + "safe_point": 15 + }, + { + "classification": "unsupported-by-language-frontend", + "detail": "CompileError: fstring_conversion.py:2: unsupported syntax FormattedValue (f-string conversion)", + "program": "fstring_conversion", + "safe_point": null + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "graph_breadth_first", + "safe_point": 4 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "graph_breadth_first", + "safe_point": 9 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "graph_breadth_first", + "safe_point": 13 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "graph_breadth_first", + "safe_point": 17 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "graph_breadth_first", + "safe_point": 21 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "graph_breadth_first", + "safe_point": 26 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "graph_depth_first", + "safe_point": 6 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "graph_depth_first", + "safe_point": 12 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "graph_depth_first", + "safe_point": 18 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "graph_depth_first", + "safe_point": 24 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "graph_depth_first", + "safe_point": 30 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "graph_depth_first", + "safe_point": 36 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "graph_shared_cycle", + "safe_point": 1 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "graph_shared_cycle", + "safe_point": 2 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "graph_shared_cycle", + "safe_point": 3 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "graph_shared_cycle", + "safe_point": 4 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "hash_rolling", + "safe_point": 4 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "hash_rolling", + "safe_point": 9 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "hash_rolling", + "safe_point": 13 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "hash_rolling", + "safe_point": 17 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "hash_rolling", + "safe_point": 21 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "hash_rolling", + "safe_point": 26 + }, + { + "classification": "unsupported-by-language-frontend", + "detail": "UnsupportedObjectError: unsupported live object: _hashlib.HASH", + "program": "hash_sha256_chunks", + "safe_point": 2 + }, + { + "classification": "unsupported-by-language-frontend", + "detail": "UnsupportedObjectError: unsupported live object: _hashlib.HASH", + "program": "hash_sha256_chunks", + "safe_point": 3 + }, + { + "classification": "unsupported-by-language-frontend", + "detail": "UnsupportedObjectError: unsupported live object: _hashlib.HASH", + "program": "hash_sha256_chunks", + "safe_point": 5 + }, + { + "classification": "unsupported-by-language-frontend", + "detail": "UnsupportedObjectError: unsupported live object: _hashlib.HASH", + "program": "hash_sha256_chunks", + "safe_point": 7 + }, + { + "classification": "unsupported-by-language-frontend", + "detail": "UnsupportedObjectError: unsupported live object: _hashlib.HASH", + "program": "hash_sha256_chunks", + "safe_point": 9 + }, + { + "classification": "unsupported-by-language-frontend", + "detail": "UnsupportedObjectError: unsupported live object: _hashlib.HASH", + "program": "hash_sha256_chunks", + "safe_point": 10 + }, + { + "classification": "unsupported-by-language-frontend", + "detail": "ExecutionError: unhandled TypeError at iteration_dictionary.py:3: Continuum cannot checkpoint iteration over dict_items", + "program": "iteration_dictionary", + "safe_point": null + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "json_aggregate", + "safe_point": 2 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "json_aggregate", + "safe_point": 5 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "json_aggregate", + "safe_point": 7 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "json_aggregate", + "safe_point": 9 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "json_aggregate", + "safe_point": 11 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "json_aggregate", + "safe_point": 14 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "json_transform", + "safe_point": 1 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "json_transform", + "safe_point": 2 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "json_transform", + "safe_point": 3 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "json_transform", + "safe_point": 4 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "keyword_only_format", + "safe_point": 1 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "kwargs_merge", + "safe_point": 1 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "math_matrix_multiply", + "safe_point": 10 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "math_matrix_multiply", + "safe_point": 20 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "math_matrix_multiply", + "safe_point": 30 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "math_matrix_multiply", + "safe_point": 41 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "math_matrix_multiply", + "safe_point": 51 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "math_matrix_multiply", + "safe_point": 61 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "math_primes", + "safe_point": 162 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "math_primes", + "safe_point": 324 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "math_primes", + "safe_point": 486 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "math_primes", + "safe_point": 647 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "math_primes", + "safe_point": 809 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "math_primes", + "safe_point": 971 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "math_statistics", + "safe_point": 1 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "math_statistics", + "safe_point": 2 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "math_statistics", + "safe_point": 3 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "math_statistics", + "safe_point": 4 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "math_statistics", + "safe_point": 5 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "random_walk", + "safe_point": 24 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "random_walk", + "safe_point": 47 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "random_walk", + "safe_point": 71 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "random_walk", + "safe_point": 94 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "random_walk", + "safe_point": 118 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "random_walk", + "safe_point": 141 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "recursion_factorial", + "safe_point": 1 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "recursion_fibonacci", + "safe_point": 1 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "recursion_mutual", + "safe_point": 1 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "recursion_mutual", + "safe_point": 2 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "search_binary", + "safe_point": 2 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "search_binary", + "safe_point": 3 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "search_binary", + "safe_point": 5 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "search_binary", + "safe_point": 6 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "search_binary", + "safe_point": 8 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "search_binary", + "safe_point": 9 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "search_linear", + "safe_point": 2 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "search_linear", + "safe_point": 4 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "search_linear", + "safe_point": 6 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "search_linear", + "safe_point": 8 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "search_linear", + "safe_point": 10 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "search_linear", + "safe_point": 12 + }, + { + "classification": "unsupported-by-language-frontend", + "detail": "CompileError: simulation_inventory.py:6: unsupported syntax AugAssign (augmented assignment to non-name)", + "program": "simulation_inventory", + "safe_point": null + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "sort_bubble", + "safe_point": 23 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "sort_bubble", + "safe_point": 45 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "sort_bubble", + "safe_point": 68 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "sort_bubble", + "safe_point": 91 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "sort_bubble", + "safe_point": 114 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "sort_bubble", + "safe_point": 136 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "sort_insertion", + "safe_point": 7 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "sort_insertion", + "safe_point": 13 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "sort_insertion", + "safe_point": 20 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "sort_insertion", + "safe_point": 26 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "sort_insertion", + "safe_point": 33 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "sort_insertion", + "safe_point": 39 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "sort_selection", + "safe_point": 9 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "sort_selection", + "safe_point": 18 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "sort_selection", + "safe_point": 27 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "sort_selection", + "safe_point": 36 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "sort_selection", + "safe_point": 45 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "sort_selection", + "safe_point": 54 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "starred_call", + "safe_point": 1 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "starred_call", + "safe_point": 2 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "text_bracket_balance", + "safe_point": 5 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "text_bracket_balance", + "safe_point": 10 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "text_bracket_balance", + "safe_point": 15 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "text_bracket_balance", + "safe_point": 19 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "text_bracket_balance", + "safe_point": 24 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "text_bracket_balance", + "safe_point": 29 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "text_normalize", + "safe_point": 1 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "text_normalize", + "safe_point": 2 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "text_palindrome_scan", + "safe_point": 2 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "text_palindrome_scan", + "safe_point": 5 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "text_palindrome_scan", + "safe_point": 7 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "text_palindrome_scan", + "safe_point": 9 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "text_palindrome_scan", + "safe_point": 11 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "text_palindrome_scan", + "safe_point": 14 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "text_run_length", + "safe_point": 5 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "text_run_length", + "safe_point": 9 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "text_run_length", + "safe_point": 14 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "text_run_length", + "safe_point": 18 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "text_run_length", + "safe_point": 23 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "text_run_length", + "safe_point": 27 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "text_word_frequency", + "safe_point": 3 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "text_word_frequency", + "safe_point": 6 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "text_word_frequency", + "safe_point": 9 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "text_word_frequency", + "safe_point": 12 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "text_word_frequency", + "safe_point": 15 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "text_word_frequency", + "safe_point": 18 + }, + { + "classification": "accepted-and-correct", + "frame_chain": [ + "__module__" + ], + "frames": 1, + "program": "varargs_total", + "safe_point": 1 + } + ], + "cases": 204, + "checkpoints_per_program": 6, + "correctness_among_accepted_cases": 1.0, + "counts": { + "accepted-and-correct": 189, + "unsupported-by-language-frontend": 15 + }, + "cross_python": true, + "elapsed_seconds": 37.35, + "infrastructure_failures": 0, + "programs": 50, + "silent_mismatches": 0, + "source": { + "machine": "x86_64", + "os": "Linux", + "python_version": "3.12.13" + }, + "target": { + "machine": "x86_64", + "os": "Linux", + "python_version": "3.13.14" + } +} diff --git a/continuum/__init__.py b/continuum/__init__.py index 8e220ca..6143f67 100644 --- a/continuum/__init__.py +++ b/continuum/__init__.py @@ -1,6 +1,8 @@ """Continuum's controlled, portable Python execution runtime.""" -__version__ = "0.3.1" -FORMAT_VERSION = "0.1" +__version__ = "0.4.0a1" +FORMAT_VERSION = "0.2" IR_VERSION = "0.4" +# The interpreter the shipping exact-version path was built and proven against. +# Cross-Python restore is governed by abi.VERIFIED_PYTHON_VERSIONS, not by this. SUPPORTED_PYTHON = "3.12.13" diff --git a/continuum/abi.py b/continuum/abi.py new file mode 100644 index 0000000..55c51d8 --- /dev/null +++ b/continuum/abi.py @@ -0,0 +1,583 @@ +"""The versioned execution compatibility contract. + +Continuum executes its own IR in its own virtual machine. Live execution state +lives in Continuum's `Frame` objects — logical program counters, operand stacks, +lexical cells, and control blocks — never in CPython frame objects. That is the +whole reason an image can outlive the interpreter that produced it. + +The shipping 0.1 container inherited a stricter rule than that design requires: +it demanded the exact creator Python version *and* the exact creator Continuum +version before restoring. Both facts are provenance. Neither is what actually +determines whether a target can reconstruct the state. + +This module separates the axes that were previously collapsed into those two +fields, so a restore decision can be made from the properties that matter: + +====================== =============================================== +container format how the archive is laid out +graph codec how the object graph is encoded +Continuum IR the instruction set the frames refer to +execution ABI the meaning of frame/binding/stack state +creator runtime provenance: which Continuum wrote the image +creator Python provenance: which interpreter wrote the image +target runtimes which runtime implementations may restore it +target Python versions which interpreters are explicitly verified +required capabilities named features the target must implement +====================== =============================================== + +A target may restore an image only when it implements the exact execution ABI +and every required capability, and only when the running interpreter *and* the +running platform pair each appear in both the image's allowlist and this +runtime's own accepted list. + +Both of those decisions are deliberately two-sided. An image declares what its +creator was willing to target; the runtime declares what it accepts. Requiring +agreement means an image cannot widen either set by asserting an entry the +runtime does not accept -- not a Python version nobody verified, and not a +platform pair such as Windows arm64 -- even when every archive checksum has been +recomputed to match. A single-sided check would let the untrusted document +decide its own admissibility. + +Every refusal carries a stable machine-readable reason code so that policy is +testable without string matching. +""" + +from __future__ import annotations + +import platform +from dataclasses import dataclass +from typing import Any, Mapping + +from . import FORMAT_VERSION, IR_VERSION, SUPPORTED_PYTHON, __version__ +from .errors import ImageError + +# The container layout that carries an explicit execution contract. Format 0.1 +# images predate the contract and are read under the legacy rule below. +CONTAINER_FORMAT_VERSION = FORMAT_VERSION +LEGACY_CONTAINER_FORMAT_VERSION = "0.1" + +# The object-graph encoding, versioned independently of the container. Bump this +# whenever an encoded graph from an older writer would decode to a different +# object shape. +GRAPH_CODEC_VERSION = "0.1" + +# The meaning of serialized execution state: what a frame's logical program +# counter indexes, how operand stacks and lexical cells are laid out, and how +# control blocks and pending finally reasons are represented. Bump this whenever +# an older image's frames would be misinterpreted by this runtime. +EXECUTION_ABI_VERSION = "1.0" + +RUNTIME_IMPLEMENTATION = "continuum-vm" +SUPPORTED_RUNTIME_IMPLEMENTATIONS = ("continuum-vm",) + +# Interpreters on which this runtime's execution ABI is verified end to end by +# native CI: freeze on one, restore on another, compare against an independent +# uninterrupted control. Adding an entry requires a green cross-Python proof +# run; it is never a guess and never a range. +VERIFIED_PYTHON_VERSIONS = ("3.12.13", "3.13.14") + +# Named features a target must implement to restore an image. The IR, graph +# codec, and execution ABI appear here as versioned capabilities so that an +# image which needs a newer one is refused by name rather than by accident. +PROVIDED_CAPABILITIES = frozenset( + { + f"continuum-ir-{IR_VERSION}", + f"graph-codec-{GRAPH_CODEC_VERSION}", + f"execution-abi-{EXECUTION_ABI_VERSION}", + "explicit-frames", + "portable-readonly-files", + } +) + +# Capabilities every contract image must require. A contract that omits one of +# these is not describing state this runtime knows how to reconstruct. +MANDATORY_CAPABILITIES = frozenset( + { + f"continuum-ir-{IR_VERSION}", + f"graph-codec-{GRAPH_CODEC_VERSION}", + f"execution-abi-{EXECUTION_ABI_VERSION}", + "explicit-frames", + } +) + +TARGET_OPERATING_SYSTEMS = ("Linux", "Darwin", "Windows") +TARGET_ARCHITECTURES = ("x86_64", "arm64") +TARGET_PLATFORMS = ( + {"os": "Linux", "architecture": "x86_64"}, + {"os": "Linux", "architecture": "arm64"}, + {"os": "Darwin", "architecture": "x86_64"}, + {"os": "Darwin", "architecture": "arm64"}, + {"os": "Windows", "architecture": "x86_64"}, +) +# The same pairs as an order-independent set, for the runtime side of the +# platform decision. An image declares which platforms *its creator* was willing +# to target; this is which platforms *this runtime* accepts. Both must contain +# the running pair, exactly as with Python versions, so an image cannot widen +# the platform set by listing a pair this runtime does not accept. +VERIFIED_PLATFORMS = tuple( + (entry["os"], entry["architecture"]) for entry in TARGET_PLATFORMS +) + +# Bounds for parsing untrusted contract documents. An image is executable +# untrusted content, so every list it declares is length-bounded and every +# string it declares is size-bounded before the values are used. +MAX_LIST_ENTRIES = 64 +MAX_STRING_LENGTH = 128 + +# Stable refusal reason codes. Tests assert on these rather than on prose. +REASON_MALFORMED_CONTRACT = "malformed-contract" +REASON_UNKNOWN_CONTAINER_FORMAT = "unknown-container-format" +REASON_UNKNOWN_GRAPH_CODEC = "unknown-graph-codec" +REASON_UNKNOWN_IR_VERSION = "unknown-ir-version" +REASON_UNKNOWN_EXECUTION_ABI = "unknown-execution-abi" +REASON_UNKNOWN_RUNTIME_IMPLEMENTATION = "unknown-runtime-implementation" +REASON_NATIVE_PAYLOAD_REQUIRED = "native-payload-required" +REASON_MISSING_CAPABILITY = "missing-capability" +REASON_UNKNOWN_CAPABILITY = "unknown-capability" +REASON_MALFORMED_PYTHON_ALLOWLIST = "malformed-python-allowlist" +REASON_PYTHON_NOT_IN_IMAGE_ALLOWLIST = "python-not-in-image-allowlist" +REASON_PYTHON_NOT_VERIFIED_BY_RUNTIME = "python-not-verified-by-runtime" +REASON_UNSUPPORTED_OPERATING_SYSTEM = "unsupported-operating-system" +REASON_UNSUPPORTED_ARCHITECTURE = "unsupported-architecture" +REASON_UNSUPPORTED_PLATFORM = "unsupported-platform" +REASON_INCONSISTENT_PROVENANCE = "inconsistent-provenance" +REASON_POLICY_DOWNGRADE = "policy-downgrade" +REASON_LEGACY_PYTHON_MISMATCH = "legacy-python-mismatch" +REASON_LEGACY_RUNTIME_MISMATCH = "legacy-runtime-mismatch" + +# The compatibility policy an image asks the target to apply. `execution-abi` +# is the contract policy defined here. `exact` is the legacy 0.1 rule. An image +# may not claim a policy weaker than the one its container format defines. +POLICY_EXECUTION_ABI = "execution-abi" +POLICY_EXACT = "exact" +KNOWN_POLICIES = (POLICY_EXECUTION_ABI, POLICY_EXACT) + + +class IncompatibleImage(ImageError): + """A restore was refused before any execution state was reconstructed. + + Carries a stable `reason` code alongside the human-readable message so + policy tests never depend on prose. + """ + + def __init__(self, reason: str, detail: str): + super().__init__(f"{detail} [{reason}]") + self.reason = reason + self.detail = detail + + +@dataclass(frozen=True) +class Host: + """The identity a restore decision is made against. + + Constructed from the live interpreter in production and by hand in tests, so + every refusal path is reachable deterministically on a single interpreter. + """ + + python_version: str + operating_system: str + architecture: str + runtime_implementation: str = RUNTIME_IMPLEMENTATION + continuum_version: str = __version__ + provided_capabilities: frozenset[str] = PROVIDED_CAPABILITIES + verified_python_versions: tuple[str, ...] = VERIFIED_PYTHON_VERSIONS + # Platform pairs this runtime accepts, independent of what an image asks + # for. Defaulted from the runtime's own accepted pairs so the platform + # decision is two-sided like the Python decision. + verified_platforms: tuple[tuple[str, str], ...] = VERIFIED_PLATFORMS + execution_abi_version: str = EXECUTION_ABI_VERSION + graph_codec_version: str = GRAPH_CODEC_VERSION + ir_version: str = IR_VERSION + + +def normalized_architecture(value: str | None = None) -> str: + machine = (value if value is not None else platform.machine()).lower() + return {"amd64": "x86_64", "x64": "x86_64", "aarch64": "arm64"}.get( + machine, machine + ) + + +def current_host() -> Host: + return Host( + python_version=platform.python_version(), + operating_system=platform.system(), + architecture=normalized_architecture(), + ) + + +def _require_string(value: Any, field: str) -> str: + if not isinstance(value, str) or not value or len(value) > MAX_STRING_LENGTH: + raise IncompatibleImage( + REASON_MALFORMED_CONTRACT, f"contract field {field!r} is not a valid string" + ) + return value + + +def _require_string_list(value: Any, field: str) -> list[str]: + """Parse an untrusted allowlist under explicit bounds. + + Empty lists, duplicates, non-strings, and oversized lists are all malformed + rather than merely unsatisfiable: a target must never have to guess what an + ambiguous allowlist meant. + """ + if ( + not isinstance(value, list) + or not value + or len(value) > MAX_LIST_ENTRIES + or any( + not isinstance(item, str) or not item or len(item) > MAX_STRING_LENGTH + for item in value + ) + or len(set(value)) != len(value) + ): + raise IncompatibleImage( + REASON_MALFORMED_CONTRACT, f"contract field {field!r} is not a valid list" + ) + return list(value) + + +def build_contract( + creator_os: str, + creator_architecture: str, + creator_python: str, + creator_continuum_version: str = __version__, +) -> dict[str, Any]: + """Build the execution contract a freshly written image declares. + + Creator identity is recorded as provenance. The target decision is driven by + the execution ABI, the capability list, and the verified Python allowlist. + """ + + return { + "container_format_version": CONTAINER_FORMAT_VERSION, + "graph_codec_version": GRAPH_CODEC_VERSION, + "ir_version": IR_VERSION, + "execution_abi_version": EXECUTION_ABI_VERSION, + "compatibility_policy": POLICY_EXECUTION_ABI, + "creator": { + "continuum_version": creator_continuum_version, + "python_version": creator_python, + "os": creator_os, + "architecture": creator_architecture, + }, + "target": { + "runtime_implementations": list(SUPPORTED_RUNTIME_IMPLEMENTATIONS), + "python_versions": list(VERIFIED_PYTHON_VERSIONS), + "operating_systems": list(TARGET_OPERATING_SYSTEMS), + "architectures": list(TARGET_ARCHITECTURES), + "platforms": [dict(entry) for entry in TARGET_PLATFORMS], + "required_capabilities": sorted(MANDATORY_CAPABILITIES), + "native_payload_required": False, + }, + } + + +def parse_contract(document: Any) -> dict[str, Any]: + """Structurally validate an untrusted contract document. + + Runs before any restore decision and before any execution state is touched, + so a malformed contract is refused rather than partially interpreted. + """ + + if not isinstance(document, Mapping): + raise IncompatibleImage( + REASON_MALFORMED_CONTRACT, "execution contract is not an object" + ) + + container = _require_string( + document.get("container_format_version"), "container_format_version" + ) + codec = _require_string(document.get("graph_codec_version"), "graph_codec_version") + ir_version = _require_string(document.get("ir_version"), "ir_version") + abi = _require_string( + document.get("execution_abi_version"), "execution_abi_version" + ) + policy = _require_string(document.get("compatibility_policy"), "compatibility_policy") + if policy not in KNOWN_POLICIES: + raise IncompatibleImage( + REASON_MALFORMED_CONTRACT, f"unknown compatibility policy {policy!r}" + ) + + creator = document.get("creator") + if not isinstance(creator, Mapping): + raise IncompatibleImage( + REASON_MALFORMED_CONTRACT, "contract creator provenance is not an object" + ) + creator_parsed = { + "continuum_version": _require_string( + creator.get("continuum_version"), "creator.continuum_version" + ), + "python_version": _require_string( + creator.get("python_version"), "creator.python_version" + ), + "os": _require_string(creator.get("os"), "creator.os"), + "architecture": _require_string( + creator.get("architecture"), "creator.architecture" + ), + } + + target = document.get("target") + if not isinstance(target, Mapping): + raise IncompatibleImage( + REASON_MALFORMED_CONTRACT, "contract target section is not an object" + ) + if target.get("native_payload_required") is not False: + raise IncompatibleImage( + REASON_NATIVE_PAYLOAD_REQUIRED, + "image requires a native payload this runtime cannot provide", + ) + + platforms = target.get("platforms") + if ( + not isinstance(platforms, list) + or not platforms + or len(platforms) > MAX_LIST_ENTRIES + or any( + not isinstance(entry, Mapping) + or set(entry) != {"os", "architecture"} + or not isinstance(entry["os"], str) + or not isinstance(entry["architecture"], str) + for entry in platforms + ) + ): + raise IncompatibleImage( + REASON_MALFORMED_CONTRACT, "contract target platform list is malformed" + ) + + try: + python_versions = _require_string_list( + target.get("python_versions"), "target.python_versions" + ) + except IncompatibleImage as exc: + # A malformed Python allowlist gets its own reason code: it is the field + # most likely to be attacked, and callers test it specifically. + raise IncompatibleImage( + REASON_MALFORMED_PYTHON_ALLOWLIST, + "contract target Python allowlist is malformed", + ) from exc + + return { + "container_format_version": container, + "graph_codec_version": codec, + "ir_version": ir_version, + "execution_abi_version": abi, + "compatibility_policy": policy, + "creator": creator_parsed, + "target": { + "runtime_implementations": _require_string_list( + target.get("runtime_implementations"), "target.runtime_implementations" + ), + "python_versions": python_versions, + "operating_systems": _require_string_list( + target.get("operating_systems"), "target.operating_systems" + ), + "architectures": _require_string_list( + target.get("architectures"), "target.architectures" + ), + "platforms": [dict(entry) for entry in platforms], + "required_capabilities": _require_string_list( + target.get("required_capabilities"), "target.required_capabilities" + ), + "native_payload_required": False, + }, + } + + +def decide_restore(document: Any, host: Host) -> dict[str, Any]: + """Decide whether `host` may restore an image declaring `document`. + + Returns the parsed contract on acceptance and raises `IncompatibleImage` + with a stable reason code on refusal. Pure: it reads no global interpreter + state, so every branch is reachable in a test on a single interpreter. + """ + + contract = parse_contract(document) + + if contract["container_format_version"] != CONTAINER_FORMAT_VERSION: + raise IncompatibleImage( + REASON_UNKNOWN_CONTAINER_FORMAT, + f"image container format {contract['container_format_version']!r} is not " + f"supported; this runtime implements {CONTAINER_FORMAT_VERSION!r}", + ) + # The contract policy is defined by the container format. An image that + # carries a 0.2 contract but asks for the weaker legacy rule is trying to + # downgrade the policy, which is refused rather than honoured. + if contract["compatibility_policy"] != POLICY_EXECUTION_ABI: + raise IncompatibleImage( + REASON_POLICY_DOWNGRADE, + f"container format {CONTAINER_FORMAT_VERSION} requires the " + f"{POLICY_EXECUTION_ABI!r} policy, image declares " + f"{contract['compatibility_policy']!r}", + ) + if contract["graph_codec_version"] != host.graph_codec_version: + raise IncompatibleImage( + REASON_UNKNOWN_GRAPH_CODEC, + f"image graph codec {contract['graph_codec_version']!r} is not supported; " + f"this runtime implements {host.graph_codec_version!r}", + ) + if contract["ir_version"] != host.ir_version: + raise IncompatibleImage( + REASON_UNKNOWN_IR_VERSION, + f"image IR version {contract['ir_version']!r} is not supported; this " + f"runtime implements {host.ir_version!r}", + ) + if contract["execution_abi_version"] != host.execution_abi_version: + raise IncompatibleImage( + REASON_UNKNOWN_EXECUTION_ABI, + f"image execution ABI {contract['execution_abi_version']!r} is not " + f"supported; this runtime implements {host.execution_abi_version!r}", + ) + + target = contract["target"] + if host.runtime_implementation not in target["runtime_implementations"]: + raise IncompatibleImage( + REASON_UNKNOWN_RUNTIME_IMPLEMENTATION, + f"image does not accept runtime implementation " + f"{host.runtime_implementation!r}", + ) + + required = set(target["required_capabilities"]) + unknown = sorted(required - host.provided_capabilities) + if unknown: + raise IncompatibleImage( + REASON_MISSING_CAPABILITY, + f"this runtime does not implement required capabilities: {unknown}", + ) + absent = sorted(MANDATORY_CAPABILITIES - required) + if absent: + raise IncompatibleImage( + REASON_UNKNOWN_CAPABILITY, + f"image omits mandatory execution capabilities: {absent}", + ) + + if host.operating_system not in target["operating_systems"]: + raise IncompatibleImage( + REASON_UNSUPPORTED_OPERATING_SYSTEM, + f"image does not accept operating system {host.operating_system!r}", + ) + if host.architecture not in target["architectures"]: + raise IncompatibleImage( + REASON_UNSUPPORTED_ARCHITECTURE, + f"image does not accept architecture {host.architecture!r}", + ) + # Two independent gates on the platform pair, mirroring the Python-version + # decision below. The image says which pairs its creator targeted; this + # runtime says which pairs it accepts. An image that lists a pair this + # runtime does not accept -- Windows arm64, say -- cannot obtain it by + # asserting it, even with every archive checksum recomputed to match. + if { + "os": host.operating_system, + "architecture": host.architecture, + } not in target["platforms"]: + raise IncompatibleImage( + REASON_UNSUPPORTED_PLATFORM, + f"image does not accept platform {host.operating_system} " + f"{host.architecture}", + ) + if (host.operating_system, host.architecture) not in host.verified_platforms: + raise IncompatibleImage( + REASON_UNSUPPORTED_PLATFORM, + f"this runtime does not accept platform {host.operating_system} " + f"{host.architecture}; accepted pairs are " + f"{[f'{name} {machine}' for name, machine in host.verified_platforms]}", + ) + + # Two independent gates. The image says which interpreters its creator was + # willing to target; this runtime says which interpreters it has actually + # verified. An image cannot widen the second set by asserting the first. + if host.python_version not in target["python_versions"]: + raise IncompatibleImage( + REASON_PYTHON_NOT_IN_IMAGE_ALLOWLIST, + f"image does not accept Python {host.python_version}; it accepts " + f"{target['python_versions']}", + ) + if host.python_version not in host.verified_python_versions: + raise IncompatibleImage( + REASON_PYTHON_NOT_VERIFIED_BY_RUNTIME, + f"this runtime has not verified Python {host.python_version}; verified " + f"versions are {list(host.verified_python_versions)}", + ) + + # Creator provenance must be internally coherent even though it does not + # gate the restore: an image whose creator Python is absent from its own + # target allowlist is describing a state no target was ever meant to accept. + if contract["creator"]["python_version"] not in target["python_versions"]: + raise IncompatibleImage( + REASON_INCONSISTENT_PROVENANCE, + "creator Python version is absent from the image's own target allowlist", + ) + + return contract + + +def legacy_decision( + compatibility: Mapping[str, Any], host: Host +) -> None: + """Apply the format 0.1 rule: exact creator Python and exact runtime. + + Format 0.1 images carry no execution contract, so there is nothing to make a + capability-based decision from. Rather than guessing that such an image is + ABI-compatible, this keeps the original strict rule and reports refusals + with the format version named, so the message explains *why* the stricter + rule applied. + """ + + image_python = compatibility.get("python_version") + if image_python != host.python_version: + raise IncompatibleImage( + REASON_LEGACY_PYTHON_MISMATCH, + f"container format {LEGACY_CONTAINER_FORMAT_VERSION} images require the " + f"exact creator Python {image_python!r}; this runtime is " + f"{host.python_version!r}. Re-freeze under container format " + f"{CONTAINER_FORMAT_VERSION} for cross-Python restore", + ) + if compatibility.get("runtime_version") != host.continuum_version: + raise IncompatibleImage( + REASON_LEGACY_RUNTIME_MISMATCH, + f"container format {LEGACY_CONTAINER_FORMAT_VERSION} images require the " + f"exact creator runtime {compatibility.get('runtime_version')!r}; this " + f"runtime is {host.continuum_version!r}. Re-freeze under container " + f"format {CONTAINER_FORMAT_VERSION} for runtime-version independence", + ) + + +def contract_summary(contract: Mapping[str, Any]) -> dict[str, Any]: + """A flat, human-readable view of an accepted contract for `inspect`.""" + + target = contract["target"] + creator = contract["creator"] + return { + "container_format_version": contract["container_format_version"], + "graph_codec_version": contract["graph_codec_version"], + "ir_version": contract["ir_version"], + "execution_abi_version": contract["execution_abi_version"], + "compatibility_policy": contract["compatibility_policy"], + "creator_continuum_version": creator["continuum_version"], + "creator_python_version": creator["python_version"], + "creator_platform": f"{creator['os']} {creator['architecture']}", + "target_python_versions": list(target["python_versions"]), + "target_runtime_implementations": list(target["runtime_implementations"]), + "required_capabilities": list(target["required_capabilities"]), + } + + +__all__ = [ + "CONTAINER_FORMAT_VERSION", + "EXECUTION_ABI_VERSION", + "GRAPH_CODEC_VERSION", + "Host", + "IncompatibleImage", + "LEGACY_CONTAINER_FORMAT_VERSION", + "MANDATORY_CAPABILITIES", + "PROVIDED_CAPABILITIES", + "SUPPORTED_PYTHON", + "VERIFIED_PLATFORMS", + "VERIFIED_PYTHON_VERSIONS", + "build_contract", + "contract_summary", + "current_host", + "decide_restore", + "legacy_decision", + "normalized_architecture", + "parse_contract", +] diff --git a/continuum/cli.py b/continuum/cli.py index 6bd0684..c5d2888 100644 --- a/continuum/cli.py +++ b/continuum/cli.py @@ -14,6 +14,15 @@ from typing import Any from . import IR_VERSION, SUPPORTED_PYTHON, __version__ +from .abi import ( + CONTAINER_FORMAT_VERSION, + EXECUTION_ABI_VERSION, + GRAPH_CODEC_VERSION, + LEGACY_CONTAINER_FORMAT_VERSION, + POLICY_EXECUTION_ABI, + VERIFIED_PYTHON_VERSIONS, + normalized_architecture, +) from ._harness import ( DEFAULT_HOLD_SAFE_POINT, HOLD_SAFE_POINT_ENV, @@ -173,10 +182,23 @@ def main(argv: list[str] | None = None) -> int: def _require_runtime_version() -> None: + """Refuse to operate on an interpreter this runtime has not verified. + + This replaces an equality check against a single hard-coded version. It is + deliberately still an exact allowlist, not a range: membership in + `VERIFIED_PYTHON_VERSIONS` requires a green native cross-Python proof run, + so an interpreter nobody has exercised is refused before any execution state + is created or reconstructed rather than attempted and hoped for. + + Install-time packaging metadata (`requires-python`) is necessarily coarser + than an exact allowlist; this gate, not that metadata, is the authority. + """ + current = platform.python_version() - if current != SUPPORTED_PYTHON: + if current not in VERIFIED_PYTHON_VERSIONS: raise ContinuumError( - f"this Continuum runtime requires Python {SUPPORTED_PYTHON}; current is {current}" + f"this Continuum runtime has not verified Python {current}; verified " + f"versions are {list(VERIFIED_PYTHON_VERSIONS)}" ) @@ -239,10 +261,10 @@ def _doctor(args: argparse.Namespace) -> int: bundle_manifest = None problems = [] - if current_python != SUPPORTED_PYTHON: + if current_python not in VERIFIED_PYTHON_VERSIONS: problems.append( - f"Python {current_python} is incompatible; exact " - f"CPython {SUPPORTED_PYTHON} is required" + f"Python {current_python} is not verified by this runtime; verified " + f"CPython versions are {list(VERIFIED_PYTHON_VERSIONS)}" ) if current_system not in {"Linux", "Darwin", "Windows"}: problems.append(f"unsupported operating system: {current_system}") @@ -278,6 +300,10 @@ def _doctor(args: argparse.Namespace) -> int: "python_implementation": platform.python_implementation(), "python_version": current_python, "required_python_version": SUPPORTED_PYTHON, + "verified_python_versions": list(VERIFIED_PYTHON_VERSIONS), + "container_format_version": CONTAINER_FORMAT_VERSION, + "execution_abi_version": EXECUTION_ABI_VERSION, + "graph_codec_version": GRAPH_CODEC_VERSION, "os": current_system, "architecture": current_machine, "continuum_home": str(continuum_home()), @@ -295,7 +321,7 @@ def _doctor(args: argparse.Namespace) -> int: ), "evidence": { "format_compatible_targets": ( - "image manifest target_compatibility; a listed pair is " + "image manifest execution_contract.target; a listed pair is " "accepted by this runtime and is not evidence that any " "continuation on that pair has been run" ), @@ -862,6 +888,21 @@ def _inspect(args: argparse.Namespace) -> int: print(f"Source OS: {source['os']}") print(f"Source architecture: {source['architecture']}") print(f"Python version: {source['python_version']}") + contract = manifest.get("execution_contract") + if contract is not None: + target = contract["target"] + print(f"Execution ABI: {contract['execution_abi_version']}") + print(f"Graph codec: {contract['graph_codec_version']}") + print(f"IR version: {contract['ir_version']}") + print(f"Compatibility policy: {contract['compatibility_policy']}") + print( + "Accepted target Python versions: " + f"{', '.join(target['python_versions'])}" + ) + print( + "Required capabilities: " + f"{', '.join(target['required_capabilities'])}" + ) print(f"Frames: {manifest['frames']}") print(f"Heap objects: {manifest['heap_objects']}") print(f"Open files: {manifest['open_files']}") @@ -879,6 +920,12 @@ def _verify(args: argparse.Namespace) -> int: print("Verification: passed") print(f"Integrity: {report['integrity']}") print(f"Compatibility: {report['compatibility']}") + contract = report["execution_contract"] + print(f"Compatibility policy: {contract['compatibility_policy']}") + if contract["compatibility_policy"] == POLICY_EXECUTION_ABI: + print(f"Execution ABI: {contract['execution_abi_version']}") + print(f"Creator Python: {contract['creator_python_version']}") + print(f"Restoring Python: {report['restore_python_version']}") print(f"Object graph: {report['graph']}") print(f"Frames: {report['frames']} ({manifest['frames']})") print(f"Resources: {report['resources']} ({manifest['open_files']})") @@ -904,22 +951,38 @@ def _resume(args: argparse.Namespace) -> int: Path(new).expanduser().resolve() ) loaded = load_image(args.image) - loaded.validate_compatibility() - compatibility = loaded.manifest["target_compatibility"] - current_architecture = { - "amd64": "x86_64", - "x64": "x86_64", - "aarch64": "arm64", - }.get(platform.machine().lower(), platform.machine().lower()) - print( - "Compatibility accepted: " - f"runtime {compatibility['runtime_version']}, " - f"Python {compatibility['python_version']}, " - f"{platform.system()} {current_architecture}, " - "portable IR with no native payload.", - file=sys.stderr, - flush=True, - ) + decision = loaded.validate_compatibility() + current_architecture = normalized_architecture() + if decision.get("compatibility_policy") == POLICY_EXECUTION_ABI: + creator = decision["creator"] + # Name both interpreters explicitly. When they differ, this line is the + # operator-visible statement that a cross-Python restore was accepted on + # ABI grounds rather than by matching the creator. + print( + "Compatibility accepted: " + f"execution ABI {decision['execution_abi_version']}, " + f"IR {decision['ir_version']}, " + f"graph codec {decision['graph_codec_version']}, " + f"restoring under Python {platform.python_version()} on " + f"{platform.system()} {current_architecture}; " + f"image created by Continuum {creator['continuum_version']} under " + f"Python {creator['python_version']} on {creator['os']} " + f"{creator['architecture']}; portable IR with no native payload.", + file=sys.stderr, + flush=True, + ) + else: + compatibility = loaded.manifest["target_compatibility"] + print( + "Compatibility accepted: " + f"container format {LEGACY_CONTAINER_FORMAT_VERSION} exact-version " + f"policy, runtime {compatibility['runtime_version']}, " + f"Python {compatibility['python_version']}, " + f"{platform.system()} {current_architecture}, " + "portable IR with no native payload.", + file=sys.stderr, + flush=True, + ) vm = loaded.restore_vm(args.file_policy, relocations) source = loaded.manifest["source"] print( diff --git a/continuum/image.py b/continuum/image.py index 95c2012..011f4ba 100644 --- a/continuum/image.py +++ b/continuum/image.py @@ -13,6 +13,14 @@ from typing import Any from . import FORMAT_VERSION, IR_VERSION, SUPPORTED_PYTHON, __version__ +from . import abi +from .abi import ( + CONTAINER_FORMAT_VERSION, + EXECUTION_ABI_VERSION, + GRAPH_CODEC_VERSION, + LEGACY_CONTAINER_FORMAT_VERSION, + IncompatibleImage, +) from .codec import decode_graph, encode_graph from .errors import ImageError, ResourceError from .resources import ResourceManager @@ -34,24 +42,13 @@ "checksums.json", } STATIC_ENTRIES = REQUIRED_ENTRIES | {"SIGNATURE"} -SUPPORTED_CAPABILITIES = { - f"continuum-ir-{IR_VERSION}", - "explicit-frames", - "graph-codec-0.1", - "portable-readonly-files", -} +SUPPORTED_CAPABILITIES = abi.PROVIDED_CAPABILITIES # The target pairs this runtime will attempt to restore. Membership is a # format-compatibility decision only; it is never evidence that a source or # target platform has been exercised. PORTABILITY.md holds that evidence. -TARGET_OPERATING_SYSTEMS = ("Linux", "Darwin", "Windows") -TARGET_ARCHITECTURES = ("x86_64", "arm64") -TARGET_PLATFORMS = ( - {"os": "Linux", "architecture": "x86_64"}, - {"os": "Linux", "architecture": "arm64"}, - {"os": "Darwin", "architecture": "x86_64"}, - {"os": "Darwin", "architecture": "arm64"}, - {"os": "Windows", "architecture": "x86_64"}, -) +TARGET_OPERATING_SYSTEMS = abi.TARGET_OPERATING_SYSTEMS +TARGET_ARCHITECTURES = abi.TARGET_ARCHITECTURES +TARGET_PLATFORMS = abi.TARGET_PLATFORMS def _json_bytes(value: Any) -> bytes: @@ -65,12 +62,7 @@ def _sha256(content: bytes) -> str: def _normalized_architecture() -> str: - value = platform.machine().lower() - return { - "amd64": "x86_64", - "x64": "x86_64", - "aarch64": "arm64", - }.get(value, value) + return abi.normalized_architecture() def _runtime_python() -> str: @@ -136,6 +128,8 @@ def save_image( "runtime_version": __version__, "python_version": _runtime_python(), "ir_version": vm.ir["ir_version"], + "graph_codec_version": GRAPH_CODEC_VERSION, + "execution_abi_version": EXECUTION_ABI_VERSION, "instructions_executed": vm.instructions_executed, "safe_points_executed": vm.safe_points_executed, "argv": vm.argv, @@ -161,16 +155,16 @@ def save_image( "architecture": source_architecture or _normalized_architecture(), "python_version": _runtime_python(), }, - "target_compatibility": { - "operating_systems": list(TARGET_OPERATING_SYSTEMS), - "architectures": list(TARGET_ARCHITECTURES), - "platforms": [dict(item) for item in TARGET_PLATFORMS], - "python_version": SUPPORTED_PYTHON, - "runtime_implementation": "continuum-vm", - "runtime_version": __version__, - "native_payload_required": False, - "required_capabilities": sorted(SUPPORTED_CAPABILITIES), - }, + # The single authority for whether a target may restore this image. + # Creator identity inside it is provenance; the restore decision comes + # from the execution ABI, the capability list, and the verified target + # Python allowlist. See continuum/abi.py. + "execution_contract": abi.build_contract( + creator_os=source_os or platform.system(), + creator_architecture=source_architecture or _normalized_architecture(), + creator_python=_runtime_python(), + creator_continuum_version=__version__, + ), "entry_program": vm.ir["source_name"], "entry_program_sha256": actual_source_hash, "module_hashes_entry": "modules/hashes.json", @@ -299,46 +293,28 @@ def restore_vm( resource.close() raise - def validate_compatibility(self) -> None: - compatibility = self.manifest["target_compatibility"] - if compatibility.get("runtime_implementation") != "continuum-vm": - raise ImageError("image requires an unsupported runtime implementation") - if compatibility.get("native_payload_required") is not False: - raise ImageError("image requires a native payload") - capabilities = compatibility.get("required_capabilities") - if not isinstance(capabilities, list) or any( - not isinstance(item, str) for item in capabilities - ): - raise ImageError("image has invalid required capabilities") - unknown = set(capabilities) - SUPPORTED_CAPABILITIES - if unknown: - raise ImageError( - f"image requires unknown capabilities: {sorted(unknown)}" - ) - current_os = platform.system() - current_arch = _normalized_architecture() - if current_os not in compatibility["operating_systems"]: - raise ImageError(f"target operating system is unsupported: {current_os}") - if current_arch not in compatibility["architectures"]: - raise ImageError(f"target architecture is unsupported: {current_arch}") - platforms = compatibility.get("platforms") - if platforms is not None and { - "os": current_os, - "architecture": current_arch, - } not in platforms: - raise ImageError( - f"target platform is unsupported: {current_os} {current_arch}" - ) - if _runtime_python() != compatibility["python_version"]: - raise ImageError( - f"Python version mismatch: image requires " - f"{compatibility['python_version']}, current runtime is {_runtime_python()}" - ) - if compatibility["runtime_version"] != __version__: - raise ImageError( - f"runtime version mismatch: image requires " - f"{compatibility['runtime_version']}, installed runtime is {__version__}" - ) + def validate_compatibility(self, host: abi.Host | None = None) -> dict[str, Any]: + """Decide whether this host may restore the image, before touching state. + + Container format 0.2 images carry an explicit execution contract and are + decided by `abi.decide_restore`. Format 0.1 images carry no contract, so + rather than assuming they are ABI-compatible they keep their original + exact-Python, exact-runtime rule. Either way the decision happens before + any execution state is reconstructed. + """ + + target = host if host is not None else abi.current_host() + format_version = self.manifest.get("format_version") + if format_version == LEGACY_CONTAINER_FORMAT_VERSION: + compatibility = self.manifest.get("target_compatibility") + if not isinstance(compatibility, dict): + raise ImageError("legacy image has invalid compatibility metadata") + abi.legacy_decision(compatibility, target) + return { + "container_format_version": LEGACY_CONTAINER_FORMAT_VERSION, + "compatibility_policy": abi.POLICY_EXACT, + } + return abi.decide_restore(self.manifest.get("execution_contract"), target) def load_image(path: str | os.PathLike[str]) -> LoadedImage: @@ -400,7 +376,7 @@ def inspect_image(path: str | os.PathLike[str]) -> dict[str, Any]: def verify_image(path: str | os.PathLike[str]) -> dict[str, Any]: loaded = load_image(path) - loaded.validate_compatibility() + decision = loaded.validate_compatibility() resource_placeholders = { record["resource_id"]: object() for record in loaded.resources_document["resources"] @@ -427,6 +403,15 @@ def verify_image(path: str | os.PathLike[str]) -> dict[str, Any]: "graph": "verified", "frames": "verified", "resources": "metadata-verified-not-opened", + "execution_contract": ( + abi.contract_summary(decision) + if decision.get("compatibility_policy") == abi.POLICY_EXECUTION_ABI + else { + "container_format_version": LEGACY_CONTAINER_FORMAT_VERSION, + "compatibility_policy": abi.POLICY_EXACT, + } + ), + "restore_python_version": _runtime_python(), } @@ -487,23 +472,55 @@ def _parse_json(content: bytes, name: str) -> Any: raise ImageError(f"invalid JSON in {name}") from exc -def _validate_documents( - manifest: Any, - runtime: Any, - ir: Any, - modules: Any, - heap: Any, - resources: Any, - frames: Any, - raw: dict[str, bytes], +def _validate_contract_documents( + manifest: dict[str, Any], + runtime: dict[str, Any], + source_metadata: dict[str, Any], ) -> None: - if not isinstance(manifest, dict) or manifest.get("format_version") != FORMAT_VERSION: - raise ImageError("unsupported image format version") - if manifest.get("security_boundary") != "executable-untrusted-content": - raise ImageError("image omits its executable-content security boundary") + """Cross-check a format 0.2 contract against the rest of the image. + + The contract is parsed under bounds first, then checked for agreement with + `runtime.json` and the manifest's own source provenance. Metadata that + disagrees with itself is refused here, before any compatibility decision, so + an image cannot present one identity to the reader and another to the + restore policy. + """ + + contract = abi.parse_contract(manifest.get("execution_contract")) + creator = contract["creator"] + if creator["python_version"] != source_metadata.get("python_version"): + raise ImageError( + "creator Python provenance disagrees with the manifest source section" + ) + if creator["os"] != source_metadata.get("os") or creator[ + "architecture" + ] != source_metadata.get("architecture"): + raise ImageError( + "creator platform provenance disagrees with the manifest source section" + ) + if creator["python_version"] != runtime.get("python_version"): + raise ImageError("creator Python provenance disagrees with runtime metadata") + if creator["continuum_version"] != runtime.get("runtime_version"): + raise ImageError("creator runtime provenance disagrees with runtime metadata") + if contract["execution_abi_version"] != runtime.get("execution_abi_version"): + raise ImageError("execution ABI metadata is inconsistent") + if contract["graph_codec_version"] != runtime.get("graph_codec_version"): + raise ImageError("graph codec metadata is inconsistent") + + +def _validate_legacy_compatibility( + manifest: dict[str, Any], + runtime: dict[str, Any], + source_metadata: dict[str, Any], +) -> None: + """Validate a format 0.1 image exactly as the 0.1 reader did. + + Kept verbatim rather than relaxed: these images carry no execution contract, + so the original invariants are the only ones that were ever proven for them. + """ + compatibility = manifest.get("target_compatibility") - source_metadata = manifest.get("source") - if not isinstance(compatibility, dict) or not isinstance(source_metadata, dict): + if not isinstance(compatibility, dict): raise ImageError("invalid compatibility metadata") if ( compatibility.get("runtime_implementation") != "continuum-vm" @@ -515,7 +532,7 @@ def _validate_documents( not isinstance(item, str) for item in capabilities ): raise ImageError("invalid required capability list") - unknown = set(capabilities) - SUPPORTED_CAPABILITIES + unknown = set(capabilities) - set(SUPPORTED_CAPABILITIES) if unknown: raise ImageError(f"unknown mandatory image capabilities: {sorted(unknown)}") platforms = compatibility.get("platforms") @@ -530,11 +547,50 @@ def _validate_documents( ) ): raise ImageError("invalid target platform compatibility list") + if ( + runtime.get("runtime_version") != compatibility.get("runtime_version") + or runtime.get("python_version") != source_metadata.get("python_version") + or runtime.get("python_version") != compatibility.get("python_version") + ): + raise ImageError("runtime metadata is inconsistent") + + +def _validate_documents( + manifest: Any, + runtime: Any, + ir: Any, + modules: Any, + heap: Any, + resources: Any, + frames: Any, + raw: dict[str, bytes], +) -> None: + if not isinstance(manifest, dict) or manifest.get("format_version") not in { + CONTAINER_FORMAT_VERSION, + LEGACY_CONTAINER_FORMAT_VERSION, + }: + raise ImageError( + f"unsupported image format version: {manifest.get('format_version')!r} " + f"is neither {CONTAINER_FORMAT_VERSION!r} nor " + f"{LEGACY_CONTAINER_FORMAT_VERSION!r}" + if isinstance(manifest, dict) + else "unsupported image format version" + ) + if manifest.get("security_boundary") != "executable-untrusted-content": + raise ImageError("image omits its executable-content security boundary") + source_metadata = manifest.get("source") + if not isinstance(source_metadata, dict): + raise ImageError("invalid compatibility metadata") if ( not isinstance(runtime, dict) or runtime.get("runtime_implementation") != "continuum-vm" ): raise ImageError("invalid runtime metadata") + + if manifest.get("format_version") == CONTAINER_FORMAT_VERSION: + _validate_contract_documents(manifest, runtime, source_metadata) + else: + _validate_legacy_compatibility(manifest, runtime, source_metadata) validate_ir(ir) if _sha256(raw["code/program.py"]) != manifest.get("entry_program_sha256"): raise ImageError("program hash does not match manifest") @@ -543,12 +599,11 @@ def _validate_documents( or ir.get("source_name") != manifest.get("entry_program") ): raise ImageError("IR source identity does not match manifest") - if ( - runtime.get("runtime_version") != compatibility.get("runtime_version") - or runtime.get("python_version") != source_metadata.get("python_version") - or runtime.get("python_version") != compatibility.get("python_version") - or runtime.get("ir_version") != ir.get("ir_version") - ): + if runtime.get("ir_version") != ir.get("ir_version"): + raise ImageError("runtime metadata is inconsistent") + if manifest.get("format_version") == CONTAINER_FORMAT_VERSION and runtime.get( + "ir_version" + ) != manifest["execution_contract"].get("ir_version"): raise ImageError("runtime metadata is inconsistent") if ( not isinstance(modules, dict) diff --git a/docs/RELEASE_NOTES_0.4.0a1.md b/docs/RELEASE_NOTES_0.4.0a1.md new file mode 100644 index 0000000..8dea15f --- /dev/null +++ b/docs/RELEASE_NOTES_0.4.0a1.md @@ -0,0 +1,106 @@ +# 0.4.0a1 — cross-Python Execution ABI + +Alpha. The image format and IR are still unstable. Nothing here changes what +Continuum can execute; it changes what can restore a Continuum image. + +## The headline + +A program frozen under **CPython 3.12.13** on native Linux x86_64 can be +verified and resumed under **CPython 3.13.14** on native Apple Silicon macOS +arm64, through the ordinary public CLI, after the source process has exited. + +Verified by [Actions run 30658976309](https://github.com/byte271/Continuum/actions/runs/30658976309) +at commit `40cc9dd`: + +| Property | Result | +| --- | --- | +| Source | Linux x86_64, CPython 3.12.13 | +| Target | macOS arm64, CPython 3.13.14 (native, Rosetta refused) | +| Source process exited and reaped before target read the image | yes | +| Image SHA-256 at capture / arrival / after restore | `3b564d9d…39824` / identical / identical | +| Live logical frames restored | 4 | +| Completed actions repeated | 0 | +| Source + target output vs. independent uninterrupted control | identical | +| Commands used | `continuum run`, `freeze`, `verify`, `resume` only | + +## Why this is possible + +Continuum's live execution state is held in its own virtual machine — explicit +frames, logical program counters, operand stacks, lexical cells, and control +blocks — not in CPython frame objects. The target restores the IR stored in the +image rather than recompiling the program, so the creator interpreter's AST and +bytecode details never enter the restore path. + +The previous release nonetheless refused such a restore, because compatibility +was expressed as two fields: the exact creator Python version and the exact +creator Continuum version. Both are provenance. Neither answers whether a target +can reconstruct the state. + +## The execution compatibility contract + +Container format **0.2** carries an `execution_contract` block that separates +the axes 0.1 collapsed: + +- container format version +- graph codec version +- Continuum IR version +- execution ABI version +- creator Continuum version *(provenance)* +- creator Python version *(provenance)* +- accepted target runtime implementations +- explicitly verified target Python versions +- required named capabilities + +A target may restore only when it implements the exact execution ABI and every +required capability. The interpreter decision has two independent gates: the +running interpreter must appear in the image's allowlist **and** in this +runtime's own verified list. An image therefore cannot widen what this runtime +accepts by asserting a version nobody proved. + +The allowlist is exact and never a range. `3.13.0` and `3.12.14` are refused as +firmly as `3.9`. + +Every refusal carries a stable machine-readable reason code. + +## Behavior changes + +- **Creator Continuum version is no longer a restore requirement** for format + 0.2 images. The execution ABI is. +- `continuum run`, `verify`, and `resume` now work on any verified interpreter. + Previously a single hard-coded version check refused everything else, which is + why cross-Python restore had to be demonstrated outside the CLI. +- `inspect`, `verify`, `resume`, and `doctor` report the contract axes. `resume` + names both interpreters, so a cross-Python restore is visible to an operator. +- `requires-python` widened to `>=3.12.13,<3.14`. This is an install-time filter + only; the exact runtime allowlist is the authority, and a version the + specifier admits but CI never proved is still refused. Both halves are tested. +- Container format bumped 0.1 → 0.2. **Format 0.1 images remain readable** and + keep their original exact-Python, exact-runtime rule, with refusal messages + that name the format version and explain how to obtain cross-Python restore. + +## Evidence + +- Native cross-Python public-CLI proof: run 30658976309 (3 jobs green). +- Cross-Python differential corpus, 3.12.13 → 3.13.14: 204 cases over 50 + programs — 189 accepted and correct, **0 silent mismatches**, 0 infrastructure + failures, live frame depth to 16, 11 distinct frame chains. + Correctness among accepted cases: **100%**. The 15 remaining cases are 10 + programs the language frontend does not compile, reported separately and + excluded from that rate. + Raw: `compatibility/results/cross-python-3.12.13-to-3.13.14-linux-x86_64-2026-07-31.json`. +- Full suite: 302 tests, green on CPython 3.12.13 and 3.13.14. +- The differential comparison is itself fault-injected: each compared dimension + is corrupted in turn and the corruption asserted to be caught, so "zero + mismatches" is a statement about Continuum rather than about a blind + comparison. + +## Not claimed + +- arbitrary Python versions — only 3.12.13 and 3.13.14, exactly +- arbitrary process migration +- native CPython frame migration +- arbitrary hot reload or source changes (that is Phase 2, unreleased) +- thread, socket, subprocess, or native-extension-state migration +- any verified cross-platform path involving Windows, in either direction +- cross-Python restore on any platform pair other than + Linux x86_64 → macOS arm64; other pairs are format-compatible but unproven diff --git a/docs/TESTING.md b/docs/TESTING.md index 3288690..065a847 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -58,7 +58,7 @@ $env:PYTHONPATH = "."; python benchmarks\measure.py ` --iterations 10000 --repetitions 5 ``` -The suite discovers 180 tests on every host. Skips are explicit and +The suite discovers 313 tests on every host. Skips are explicit and mechanism-bound rather than platform exclusions: | Host | Skipped | diff --git a/pyproject.toml b/pyproject.toml index 727df11..aaae5b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,15 +4,20 @@ build-backend = "setuptools.build_meta" [project] name = "continuum-state" -version = "0.3.1" +version = "0.4.0a1" description = "Portable continuation images for a controlled pure-Python runtime" readme = "README.md" -requires-python = "==3.12.13" +# Coarse install-time filter only. The authoritative gate is the exact +# allowlist in continuum/abi.py (VERIFIED_PYTHON_VERSIONS): an interpreter that +# satisfies this specifier but has not been verified end to end is still +# refused at runtime. Tests enforce both halves of that contract. +requires-python = ">=3.12.13,<3.14" license = {text = "MIT"} authors = [{name = "Continuum contributors"}] classifiers = [ "Development Status :: 2 - Pre-Alpha", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Operating System :: MacOS :: MacOS X", diff --git a/tests/test_cli.py b/tests/test_cli.py index 48a9678..f8cb094 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -11,6 +11,11 @@ from unittest import mock from continuum import IR_VERSION, SUPPORTED_PYTHON, __version__ +from continuum.abi import ( + CONTAINER_FORMAT_VERSION, + VERIFIED_PYTHON_VERSIONS, + build_contract, +) from continuum.cli import _doctor, _resume @@ -27,7 +32,13 @@ def test_doctor_reports_supported_source_checkout(self): report = json.loads(output.getvalue()) self.assertEqual(report["continuum_version"], __version__) self.assertEqual(report["continuum_ir_version"], IR_VERSION) - self.assertEqual(report["python_version"], SUPPORTED_PYTHON) + # Doctor must succeed on every verified interpreter, not only on the + # one the exact-version path shipped against. + self.assertIn(report["python_version"], VERIFIED_PYTHON_VERSIONS) + self.assertEqual(report["required_python_version"], SUPPORTED_PYTHON) + self.assertEqual( + report["verified_python_versions"], list(VERIFIED_PYTHON_VERSIONS) + ) self.assertEqual( report["verified_cross_platform_paths"], ["Linux x86_64 -> macOS arm64"], @@ -94,7 +105,10 @@ def test_doctor_rejects_wrong_python_version(self): self.assertEqual(result, 2) report = json.loads(output.getvalue()) - self.assertIn("exact CPython 3.12.13 is required", report["problems"][0]) + # 3.12.12 is one patch below a verified version: close is still refused, + # because the allowlist is exact rather than a range. + self.assertIn("is not verified by this runtime", report["problems"][0]) + self.assertIn("3.12.12", report["problems"][0]) def test_doctor_accepts_windows_x86_64(self): output = io.StringIO() @@ -164,14 +178,17 @@ def run(self): class FakeImage: manifest = { "source": {"os": "Windows", "architecture": "x86_64"}, - "target_compatibility": { - "runtime_version": __version__, - "python_version": SUPPORTED_PYTHON, - }, + "format_version": CONTAINER_FORMAT_VERSION, + "execution_contract": build_contract( + "Windows", "x86_64", SUPPORTED_PYTHON, __version__ + ), } def validate_compatibility(self): observed["validated"] = True + return build_contract( + "Windows", "x86_64", SUPPORTED_PYTHON, __version__ + ) def restore_vm(self, policy, relocations): observed["policy"] = policy diff --git a/tests/test_cross_python_differential.py b/tests/test_cross_python_differential.py new file mode 100644 index 0000000..5d7b2dd --- /dev/null +++ b/tests/test_cross_python_differential.py @@ -0,0 +1,429 @@ +"""Sensitivity of the cross-Python differential comparison. + +A differential suite reporting "zero mismatches" proves nothing unless the +comparison can actually detect a mismatch. These tests corrupt each dimension +the suite claims to compare and assert that the corruption is caught, so a +green corpus run is evidence about Continuum rather than evidence that the +comparison is blind. + +They run on a single interpreter: the comparison operates on fingerprints, so +its sensitivity is testable without a second Python. +""" + +from __future__ import annotations + +import copy +import io +import json +import random +import sys +import tempfile +import unittest +from pathlib import Path + +REPOSITORY = Path(__file__).resolve().parents[1] +if str(REPOSITORY) not in sys.path: + sys.path.insert(0, str(REPOSITORY)) + +from validation.cross_python.differential import ( # noqa: E402 + ACCEPTED, + MISMATCH, + Fingerprinter, + compare, + fingerprint, + run_control, + safe_points_for, + source_case, + target_case, +) + +PROGRAM = """ +def make_counter(start): + def bump(step): + return step + start + return bump + + +def leaf(limit, bag, bump, graph): + index = 0 + while index < limit: + bag.append(bump(index)) + graph["shared"].append(index) + print(f"ACTION {index}") + index += 1 + return len(bag) + + +def middle(limit, bag, bump, graph): + return leaf(limit, bag, bump, graph) + + +def outer(limit): + bag = [] + shared = [] + graph = {"left": shared, "right": shared, "shared": shared} + graph["self"] = graph + bump = make_counter(5) + total = middle(limit, bag, bump, graph) + print(f"FINAL {total}") + return total + + +answer = outer(30) +""" + + +class DifferentialFixture(unittest.TestCase): + """One real cross-safepoint case, reused by every sensitivity test.""" + + @classmethod + def setUpClass(cls): + cls.temporary = tempfile.TemporaryDirectory() + image = Path(cls.temporary.name) / "case.cont" + cls.control = run_control(PROGRAM, "p.py") + # A checkpoint deep enough to have a real frame chain and live cells. + cls.source = source_case(PROGRAM, "p.py", 40, image) + assert cls.source["status"] == "frozen", cls.source + cls.target = target_case(image) + assert cls.target["status"] == "restored", cls.target + + @classmethod + def tearDownClass(cls): + cls.temporary.cleanup() + + def parts(self): + return ( + copy.deepcopy(self.source), + copy.deepcopy(self.target), + copy.deepcopy(self.control), + ) + + def assertDetected(self, mutate, expected_substring): + source, target, control = self.parts() + mutate(source, target, control) + differences = compare(source, target, control) + self.assertTrue( + differences, f"corruption was not detected: {expected_substring}" + ) + self.assertTrue( + any(expected_substring in item for item in differences), + f"expected {expected_substring!r} in {differences}", + ) + + +class BaselineTests(DifferentialFixture): + def test_the_unmodified_case_compares_clean(self): + source, target, control = self.parts() + self.assertEqual(compare(source, target, control), []) + + def test_the_case_really_captured_a_live_frame_chain(self): + chain = self.source["fingerprint"]["frame_chain"] + self.assertEqual(chain, ["__module__", "outer", "middle", "leaf"]) + + def test_verification_ran_before_restore_and_accepted_the_contract(self): + self.assertEqual(self.target["verification"]["integrity"], "verified") + self.assertEqual(self.target["verification"]["compatibility"], "accepted") + self.assertEqual(self.target["verification"]["policy"], "execution-abi") + + +class SensitivityTests(DifferentialFixture): + def test_detects_a_changed_frame_chain(self): + self.assertDetected( + lambda s, t, c: t["fingerprint"]["frame_chain"].append("ghost"), + "frame chain", + ) + + def test_detects_a_dropped_frame(self): + self.assertDetected( + lambda s, t, c: t["fingerprint"]["frames"].pop(), + "frame count changed", + ) + + def test_detects_a_moved_resume_position(self): + def mutate(source, target, control): + target["fingerprint"]["frames"][-1]["resume_pc"] += 1 + + self.assertDetected(mutate, "resume position changed") + + def test_detects_a_changed_resume_opcode(self): + def mutate(source, target, control): + target["fingerprint"]["frames"][-1]["resume_op"] = "NOPE" + + self.assertDetected(mutate, "resume opcode changed") + + def test_detects_changed_locals(self): + def mutate(source, target, control): + target["fingerprint"]["frames"][-1]["locals"][0][1] = {"t": "int", "v": -1} + + self.assertDetected(mutate, "locals changed") + + def test_the_closure_cell_is_part_of_the_compared_structure(self): + """`make_counter` has returned, so its cell hangs off the function value. + + The cell is still compared -- it is reachable from the live `bump` + binding -- so corrupting it is detected as a change to that frame's + locals rather than going unnoticed. + """ + + rendered = json.dumps(self.source["fingerprint"]) + self.assertIn('"cell"', rendered) + + def mutate(source, target, control): + for frame in target["fingerprint"]["frames"]: + for name, value in frame["locals"]: + if name == "bump": + value["closure"] = [ + {"t": "cell", "id": "tampered", "value": {"t": "int", "v": 0}} + ] + return + raise AssertionError("fixture lost its closure binding") + + self.assertDetected(mutate, "locals changed") + + def test_detects_a_changed_operand_stack(self): + def mutate(source, target, control): + frames = target["fingerprint"]["frames"] + for frame in frames: + if frame["operand_stack"]: + frame["operand_stack"].append({"t": "int", "v": 99}) + return + # Every checkpoint here has at least one non-empty operand stack in + # some frame; if not, force the dimension to differ anyway. + frames[-1]["operand_stack"].append({"t": "int", "v": 99}) + + self.assertDetected(mutate, "operand stack changed") + + def test_detects_changed_control_blocks(self): + def mutate(source, target, control): + target["fingerprint"]["frames"][-1]["control_blocks"].append( + {"kind": "invented"} + ) + + self.assertDetected(mutate, "control blocks changed") + + def test_detects_changed_pending_finally_state(self): + def mutate(source, target, control): + target["fingerprint"]["frames"][-1]["finally_reasons"].append( + {"kind": "invented"} + ) + + self.assertDetected(mutate, "pending finally state changed") + + def test_detects_changed_module_rng_state(self): + def mutate(source, target, control): + target["fingerprint"]["module_random_state"] = {"t": "tuple", "items": []} + + self.assertDetected(mutate, "module RNG state changed") + + def test_detects_changed_globals(self): + def mutate(source, target, control): + target["fingerprint"]["globals"].append(["injected", {"t": "int", "v": 1}]) + + self.assertDetected(mutate, "module globals changed") + + def test_detects_a_changed_instruction_counter(self): + def mutate(source, target, control): + target["fingerprint"]["instructions_executed"] += 1 + + self.assertDetected(mutate, "instruction counter changed") + + def test_detects_a_changed_safe_point_counter(self): + def mutate(source, target, control): + target["fingerprint"]["safe_points_executed"] += 1 + + self.assertDetected(mutate, "safe-point counter changed") + + def test_detects_replayed_completed_work(self): + """The anti-replay control: re-emitting a completed action is caught.""" + + def mutate(source, target, control): + first_action = next( + line + for line in source["stdout"].splitlines() + if line.startswith("ACTION") + ) + target["stdout"] = first_action + "\n" + target["stdout"] + + self.assertDetected(mutate, "completed actions repeated") + + def test_detects_a_restart_from_program_entry(self): + """A target that reran the whole program is caught, not accepted.""" + + def mutate(source, target, control): + target["stdout"] = control["stdout"] + + differences = self.detect_all(mutate) + self.assertTrue(differences) + + def test_detects_a_wrong_final_result(self): + def mutate(source, target, control): + target["result"] = "'not the answer'" + + self.assertDetected(mutate, "final result differed") + + def test_detects_a_truncated_suffix(self): + def mutate(source, target, control): + target["stdout"] = target["stdout"].split("\n", 1)[1] + + self.assertDetected(mutate, "did not match the control") + + def test_detects_changed_stderr(self): + def mutate(source, target, control): + target["stderr"] = target["stderr"] + "unexpected diagnostic\n" + + self.assertDetected(mutate, "stderr did not match the control") + + def detect_all(self, mutate): + source, target, control = self.parts() + mutate(source, target, control) + return compare(source, target, control) + + +LIVE_CELL_PROGRAM = """ +def driver(limit): + tally = 0 + + def bump(value): + nonlocal tally + tally = tally + value + return tally + + index = 0 + while index < limit: + print(f"STEP {index} {bump(index)}") + index += 1 + return tally + + +total = driver(30) +print(f"TOTAL {total}") +""" + + +class LiveEnclosingCellTests(unittest.TestCase): + """A cell held by a frame that is still on the stack is also compared.""" + + @classmethod + def setUpClass(cls): + cls.temporary = tempfile.TemporaryDirectory() + image = Path(cls.temporary.name) / "live-cell.cont" + cls.control = run_control(LIVE_CELL_PROGRAM, "live.py") + cls.source = source_case(LIVE_CELL_PROGRAM, "live.py", 40, image) + assert cls.source["status"] == "frozen", cls.source + cls.target = target_case(image) + assert cls.target["status"] == "restored", cls.target + + @classmethod + def tearDownClass(cls): + cls.temporary.cleanup() + + def test_a_live_frame_actually_holds_the_cell(self): + holders = [ + frame["function_name"] + for frame in self.source["fingerprint"]["frames"] + if frame["cells"] + ] + self.assertIn("driver", holders) + + def test_the_case_compares_clean_across_the_crossing(self): + self.assertEqual( + compare(self.source, self.target, self.control), [] + ) + + def test_nonlocal_mutation_through_the_cell_survives(self): + """The resumed run must keep accumulating into the same binding.""" + combined = self.source["stdout"] + self.target["stdout"] + self.assertEqual(combined, self.control["stdout"]) + self.assertIn("TOTAL 435", combined) + + def test_corrupting_the_live_cell_is_detected(self): + source = copy.deepcopy(self.source) + target = copy.deepcopy(self.target) + for frame in target["fingerprint"]["frames"]: + if frame["cells"]: + frame["cells"][0][1] = { + "t": "cell", + "id": "tampered", + "value": {"t": "int", "v": -1}, + } + break + differences = compare(source, target, copy.deepcopy(self.control)) + self.assertTrue( + any("lexical cells changed" in item for item in differences), + differences, + ) + + +class IdentityFingerprintTests(unittest.TestCase): + """Sharing and cycles must be structural, not merely equal-valued.""" + + def test_shared_reference_differs_from_two_equal_copies(self): + shared: list[int] = [1, 2] + together = {"left": shared, "right": shared} + apart = {"left": [1, 2], "right": [1, 2]} + self.assertNotEqual( + Fingerprinter().walk(together), Fingerprinter().walk(apart) + ) + + def test_a_shared_reference_emits_a_back_reference(self): + shared: list[int] = [7] + encoded = Fingerprinter().walk({"a": shared, "b": shared}) + rendered = json.dumps(encoded) + self.assertIn('"ref"', rendered) + + def test_a_reference_cycle_terminates_and_is_recorded(self): + cycle: dict[str, object] = {} + cycle["self"] = cycle + encoded = Fingerprinter().walk(cycle) + self.assertIn('"ref"', json.dumps(encoded)) + + def test_distinct_random_states_differ(self): + first = random.Random(1) + second = random.Random(2) + self.assertNotEqual( + Fingerprinter().walk(first), Fingerprinter().walk(second) + ) + + def test_equal_random_states_match(self): + self.assertEqual( + Fingerprinter().walk(random.Random(5)), + Fingerprinter().walk(random.Random(5)), + ) + + def test_int_and_float_and_bool_are_distinguished(self): + walker = Fingerprinter() + self.assertNotEqual(walker.walk(1), walker.walk(1.0)) + self.assertNotEqual(walker.walk(1), walker.walk(True)) + + def test_dict_insertion_order_is_compared(self): + self.assertNotEqual( + Fingerprinter().walk({"a": 1, "b": 2}), + Fingerprinter().walk({"b": 2, "a": 1}), + ) + + def test_bytes_survive_as_hex(self): + self.assertEqual( + Fingerprinter().walk(b"\x00\xff"), {"t": "bytes", "v": "00ff"} + ) + + +class SafePointSamplingTests(unittest.TestCase): + def test_sampling_stays_inside_the_run(self): + for total in (2, 5, 37, 1000): + with self.subTest(total=total): + points = safe_points_for(total, 6) + self.assertTrue(all(1 <= point < total for point in points)) + + def test_sampling_is_deterministic(self): + self.assertEqual(safe_points_for(500, 6), safe_points_for(500, 6)) + + def test_a_run_with_no_interior_safe_point_yields_nothing(self): + self.assertEqual(safe_points_for(1, 6), []) + self.assertEqual(safe_points_for(0, 6), []) + + def test_short_runs_use_every_interior_safe_point(self): + self.assertEqual(safe_points_for(4, 10), [1, 2, 3]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_documentation_consistency.py b/tests/test_documentation_consistency.py index 8cb4b10..e3abc62 100644 --- a/tests/test_documentation_consistency.py +++ b/tests/test_documentation_consistency.py @@ -9,13 +9,16 @@ from __future__ import annotations +import platform import re import unittest from pathlib import Path +from unittest import mock -from continuum import FORMAT_VERSION, IR_VERSION, SUPPORTED_PYTHON, __version__ +from continuum import FORMAT_VERSION, IR_VERSION, SUPPORTED_PYTHON, __version__, abi +from continuum.cli import _require_runtime_version from continuum.compiler import compile_source -from continuum.errors import CompileError +from continuum.errors import CompileError, ContinuumError ROOT = Path(__file__).resolve().parents[1] @@ -24,11 +27,82 @@ def read(name: str) -> str: return (ROOT / name).read_text(encoding="utf-8") +def version_tuple(value: str) -> tuple[int, ...]: + return tuple(int(part) for part in value.split(".")) + + +def requires_python() -> list[tuple[str, tuple[int, ...]]]: + """Parse the project's requires-python into comparable clauses. + + Deliberately hand-rolled: Continuum has no runtime or test dependencies, + and pulling in a packaging library to read one field would add one. + """ + + match = re.search(r'^requires-python = "([^"]+)"', read("pyproject.toml"), re.M) + assert match is not None, "pyproject has no requires-python" + clauses = [] + for part in match.group(1).split(","): + clause = part.strip() + operator = re.match(r"^(>=|<=|==|<|>)", clause) + assert operator is not None, f"unsupported specifier clause {clause!r}" + symbol = operator.group(1) + clauses.append((symbol, version_tuple(clause[len(symbol) :]))) + return clauses + + +def admitted_by_requires_python(value: str) -> bool: + candidate = version_tuple(value) + for symbol, bound in requires_python(): + if symbol == ">=" and not candidate >= bound: + return False + if symbol == ">" and not candidate > bound: + return False + if symbol == "<=" and not candidate <= bound: + return False + if symbol == "<" and not candidate < bound: + return False + if symbol == "==" and candidate != bound: + return False + return True + + class VersionConsistencyTests(unittest.TestCase): def test_package_metadata_matches_the_runtime(self): pyproject = read("pyproject.toml") self.assertIn(f'version = "{__version__}"', pyproject) - self.assertIn(f'requires-python = "=={SUPPORTED_PYTHON}"', pyproject) + + def test_requires_python_admits_every_verified_interpreter(self): + """Packaging metadata must not exclude an interpreter CI has proven. + + This replaces an equality check against one hard-coded version. It is + strictly stronger: it requires the specifier to admit every verified + version, and the companion test below requires the runtime to refuse a + version the specifier admits but nobody verified. An exact allowlist + cannot be written as a PEP 440 specifier, so the two halves are tested + separately rather than pretending one field can express both. + """ + + for version in abi.VERIFIED_PYTHON_VERSIONS: + with self.subTest(version=version): + self.assertTrue( + admitted_by_requires_python(version), + f"requires-python excludes verified Python {version}", + ) + self.assertTrue(admitted_by_requires_python(SUPPORTED_PYTHON)) + + def test_runtime_refuses_a_version_packaging_would_admit(self): + """The runtime allowlist, not requires-python, is the authority.""" + + # A real interpreter inside the install range that CI has never proven. + unverified = "3.13.0" + self.assertTrue(admitted_by_requires_python(unverified)) + self.assertNotIn(unverified, abi.VERIFIED_PYTHON_VERSIONS) + with mock.patch.object( + platform, "python_version", return_value=unverified + ): + with self.assertRaises(ContinuumError) as caught: + _require_runtime_version() + self.assertIn("has not verified", str(caught.exception)) def test_readme_version_badge_matches_the_runtime(self): badge = re.search(r"badge/version-([0-9a-z.]+)-", read("README.md")) diff --git a/tests/test_execution_abi.py b/tests/test_execution_abi.py new file mode 100644 index 0000000..abcbc6e --- /dev/null +++ b/tests/test_execution_abi.py @@ -0,0 +1,416 @@ +"""Acceptance and refusal boundaries of the execution compatibility contract. + +Every case here runs on a single interpreter. `Host` is injectable precisely so +that refusals which would otherwise need an unavailable Python version are still +exercised deterministically, and so a passing suite means the policy was tested +rather than the machine it happened to run on. +""" + +from __future__ import annotations + +import copy +import unittest + +from continuum import IR_VERSION, abi +from continuum.abi import Host, IncompatibleImage + + +LINUX_312 = Host("3.12.13", "Linux", "x86_64") +MACOS_313 = Host("3.13.14", "Darwin", "arm64") + + +def contract() -> dict: + return abi.build_contract("Linux", "x86_64", "3.12.13") + + +class AcceptanceTests(unittest.TestCase): + def test_creator_host_accepts_its_own_image(self): + self.assertEqual( + abi.decide_restore(contract(), LINUX_312)["execution_abi_version"], + abi.EXECUTION_ABI_VERSION, + ) + + def test_the_cross_python_cross_os_cross_isa_target_is_accepted(self): + """The exact migration Phase 1 must support, decided by policy alone.""" + accepted = abi.decide_restore(contract(), MACOS_313) + self.assertEqual(accepted["creator"]["python_version"], "3.12.13") + self.assertIn("3.13.14", accepted["target"]["python_versions"]) + + def test_every_verified_python_version_is_accepted(self): + for version in abi.VERIFIED_PYTHON_VERSIONS: + with self.subTest(version=version): + host = Host(version, "Linux", "x86_64") + abi.decide_restore(contract(), host) + + def test_a_different_creator_continuum_version_is_still_accepted(self): + """Creator runtime version is provenance, not a restore requirement.""" + document = contract() + document["creator"]["continuum_version"] = "0.0.1-something-else" + accepted = abi.decide_restore(document, MACOS_313) + self.assertEqual( + accepted["creator"]["continuum_version"], "0.0.1-something-else" + ) + + def test_creator_provenance_is_preserved_exactly(self): + document = abi.build_contract("Linux", "x86_64", "3.12.13", "0.4.0a1") + accepted = abi.decide_restore(document, MACOS_313) + self.assertEqual( + accepted["creator"], + { + "continuum_version": "0.4.0a1", + "python_version": "3.12.13", + "os": "Linux", + "architecture": "x86_64", + }, + ) + + +class RefusalTests(unittest.TestCase): + def assertRefused(self, document, host, reason): + with self.assertRaises(IncompatibleImage) as caught: + abi.decide_restore(document, host) + self.assertEqual(caught.exception.reason, reason) + return caught.exception + + def test_unknown_execution_abi_is_refused(self): + document = contract() + document["execution_abi_version"] = "2.0" + document["target"]["required_capabilities"] = sorted( + set(document["target"]["required_capabilities"]) + - {f"execution-abi-{abi.EXECUTION_ABI_VERSION}"} + | {"execution-abi-2.0"} + ) + self.assertRefused(document, LINUX_312, abi.REASON_UNKNOWN_EXECUTION_ABI) + + def test_unknown_ir_version_is_refused(self): + document = contract() + document["ir_version"] = "9.9" + self.assertRefused(document, LINUX_312, abi.REASON_UNKNOWN_IR_VERSION) + + def test_unknown_graph_codec_version_is_refused(self): + document = contract() + document["graph_codec_version"] = "9.9" + self.assertRefused(document, LINUX_312, abi.REASON_UNKNOWN_GRAPH_CODEC) + + def test_unknown_container_format_is_refused(self): + document = contract() + document["container_format_version"] = "0.99" + self.assertRefused(document, LINUX_312, abi.REASON_UNKNOWN_CONTAINER_FORMAT) + + def test_unverified_python_version_is_refused(self): + """A version nobody proved is refused even though it is newer.""" + document = contract() + document["target"]["python_versions"] = ["3.12.13", "3.13.14", "3.14.0"] + self.assertRefused( + document, + Host("3.14.0", "Linux", "x86_64"), + abi.REASON_PYTHON_NOT_VERIFIED_BY_RUNTIME, + ) + + def test_an_image_cannot_widen_the_runtime_verified_set(self): + """The image's allowlist never overrides what the runtime has verified.""" + document = contract() + document["target"]["python_versions"] = ["3.11.0"] + document["creator"]["python_version"] = "3.11.0" + self.assertRefused( + document, + Host("3.11.0", "Linux", "x86_64"), + abi.REASON_PYTHON_NOT_VERIFIED_BY_RUNTIME, + ) + + def test_python_outside_the_image_allowlist_is_refused(self): + document = contract() + document["target"]["python_versions"] = ["3.12.13"] + self.assertRefused( + document, MACOS_313, abi.REASON_PYTHON_NOT_IN_IMAGE_ALLOWLIST + ) + + def test_missing_required_capability_is_refused(self): + document = contract() + document["target"]["required_capabilities"] = sorted( + set(document["target"]["required_capabilities"]) | {"time-travel-1.0"} + ) + exception = self.assertRefused( + document, LINUX_312, abi.REASON_MISSING_CAPABILITY + ) + self.assertIn("time-travel-1.0", str(exception)) + + def test_omitting_a_mandatory_capability_is_refused(self): + document = contract() + document["target"]["required_capabilities"] = [ + item + for item in document["target"]["required_capabilities"] + if item != "explicit-frames" + ] + self.assertRefused(document, LINUX_312, abi.REASON_UNKNOWN_CAPABILITY) + + def test_unknown_runtime_implementation_is_refused(self): + document = contract() + document["target"]["runtime_implementations"] = ["someone-elses-vm"] + self.assertRefused( + document, LINUX_312, abi.REASON_UNKNOWN_RUNTIME_IMPLEMENTATION + ) + + def test_native_payload_requirement_is_refused(self): + document = contract() + document["target"]["native_payload_required"] = True + self.assertRefused(document, LINUX_312, abi.REASON_NATIVE_PAYLOAD_REQUIRED) + + def test_policy_downgrade_is_refused(self): + """A contract image may not ask for the weaker legacy rule.""" + document = contract() + document["compatibility_policy"] = abi.POLICY_EXACT + self.assertRefused(document, LINUX_312, abi.REASON_POLICY_DOWNGRADE) + + def test_unknown_policy_is_refused(self): + document = contract() + document["compatibility_policy"] = "trust-me" + self.assertRefused(document, LINUX_312, abi.REASON_MALFORMED_CONTRACT) + + def test_unsupported_operating_system_is_refused(self): + document = contract() + document["target"]["operating_systems"] = ["Linux"] + self.assertRefused( + document, MACOS_313, abi.REASON_UNSUPPORTED_OPERATING_SYSTEM + ) + + def test_unsupported_architecture_is_refused(self): + document = contract() + document["target"]["architectures"] = ["x86_64"] + self.assertRefused(document, MACOS_313, abi.REASON_UNSUPPORTED_ARCHITECTURE) + + def test_unsupported_platform_pair_is_refused(self): + """OS and ISA may each be listed while the pair is still not verified.""" + document = contract() + document["target"]["platforms"] = [ + entry + for entry in document["target"]["platforms"] + if entry != {"os": "Darwin", "architecture": "arm64"} + ] + self.assertRefused(document, MACOS_313, abi.REASON_UNSUPPORTED_PLATFORM) + + def test_inconsistent_creator_provenance_is_refused(self): + document = contract() + document["creator"]["python_version"] = "3.9.1" + self.assertRefused(document, LINUX_312, abi.REASON_INCONSISTENT_PROVENANCE) + + +class MalformedAllowlistTests(unittest.TestCase): + """Bounded parsing: an ambiguous allowlist is refused, never guessed at.""" + + def assertMalformedAllowlist(self, value): + document = contract() + document["target"]["python_versions"] = value + with self.assertRaises(IncompatibleImage) as caught: + abi.decide_restore(document, LINUX_312) + self.assertEqual( + caught.exception.reason, abi.REASON_MALFORMED_PYTHON_ALLOWLIST + ) + + def test_empty_allowlist(self): + self.assertMalformedAllowlist([]) + + def test_duplicated_allowlist_entries(self): + self.assertMalformedAllowlist(["3.12.13", "3.12.13"]) + + def test_non_string_allowlist_entries(self): + self.assertMalformedAllowlist(["3.12.13", 313]) + + def test_allowlist_is_not_a_list(self): + self.assertMalformedAllowlist("3.12.13") + + def test_allowlist_with_null_entry(self): + self.assertMalformedAllowlist(["3.12.13", None]) + + def test_oversized_allowlist(self): + self.assertMalformedAllowlist( + [f"3.12.{index}" for index in range(abi.MAX_LIST_ENTRIES + 1)] + ) + + def test_oversized_allowlist_string(self): + self.assertMalformedAllowlist(["3.12.13", "9" * (abi.MAX_STRING_LENGTH + 1)]) + + +class MalformedContractTests(unittest.TestCase): + def assertMalformed(self, mutate, reason=abi.REASON_MALFORMED_CONTRACT): + document = contract() + mutate(document) + with self.assertRaises(IncompatibleImage) as caught: + abi.decide_restore(document, LINUX_312) + self.assertEqual(caught.exception.reason, reason) + + def test_contract_is_not_an_object(self): + with self.assertRaises(IncompatibleImage) as caught: + abi.decide_restore(["not", "a", "contract"], LINUX_312) + self.assertEqual(caught.exception.reason, abi.REASON_MALFORMED_CONTRACT) + + def test_contract_is_none(self): + with self.assertRaises(IncompatibleImage) as caught: + abi.decide_restore(None, LINUX_312) + self.assertEqual(caught.exception.reason, abi.REASON_MALFORMED_CONTRACT) + + def test_missing_creator_section(self): + self.assertMalformed(lambda document: document.pop("creator")) + + def test_creator_is_not_an_object(self): + self.assertMalformed(lambda document: document.update(creator="linux")) + + def test_missing_target_section(self): + self.assertMalformed(lambda document: document.pop("target")) + + def test_missing_execution_abi_field(self): + self.assertMalformed(lambda document: document.pop("execution_abi_version")) + + def test_malformed_platform_entry(self): + self.assertMalformed( + lambda document: document["target"].update(platforms=[{"os": "Linux"}]) + ) + + def test_platform_entry_with_extra_keys(self): + self.assertMalformed( + lambda document: document["target"].update( + platforms=[{"os": "Linux", "architecture": "x86_64", "extra": 1}] + ) + ) + + def test_empty_capability_list(self): + self.assertMalformed( + lambda document: document["target"].update(required_capabilities=[]) + ) + + +class LegacyContractTests(unittest.TestCase): + """Format 0.1 keeps its original strict rule, with a versioned message.""" + + def legacy(self): + return {"python_version": "3.12.13", "runtime_version": "0.3.1"} + + def test_matching_legacy_host_is_accepted(self): + abi.legacy_decision( + self.legacy(), Host("3.12.13", "Linux", "x86_64", continuum_version="0.3.1") + ) + + def test_legacy_image_refuses_a_different_python(self): + with self.assertRaises(IncompatibleImage) as caught: + abi.legacy_decision( + self.legacy(), + Host("3.13.14", "Linux", "x86_64", continuum_version="0.3.1"), + ) + self.assertEqual(caught.exception.reason, abi.REASON_LEGACY_PYTHON_MISMATCH) + # The message must explain why the stricter rule applied and what to do. + self.assertIn(abi.LEGACY_CONTAINER_FORMAT_VERSION, str(caught.exception)) + self.assertIn(abi.CONTAINER_FORMAT_VERSION, str(caught.exception)) + + def test_legacy_image_refuses_a_different_runtime_version(self): + with self.assertRaises(IncompatibleImage) as caught: + abi.legacy_decision( + self.legacy(), + Host("3.12.13", "Linux", "x86_64", continuum_version="0.4.0a1"), + ) + self.assertEqual(caught.exception.reason, abi.REASON_LEGACY_RUNTIME_MISMATCH) + self.assertIn(abi.CONTAINER_FORMAT_VERSION, str(caught.exception)) + + +class ContractShapeTests(unittest.TestCase): + def test_mandatory_capabilities_are_all_provided_by_this_runtime(self): + self.assertLessEqual(abi.MANDATORY_CAPABILITIES, abi.PROVIDED_CAPABILITIES) + + def test_capability_names_carry_their_versions(self): + self.assertIn(f"continuum-ir-{IR_VERSION}", abi.PROVIDED_CAPABILITIES) + self.assertIn( + f"graph-codec-{abi.GRAPH_CODEC_VERSION}", abi.PROVIDED_CAPABILITIES + ) + self.assertIn( + f"execution-abi-{abi.EXECUTION_ABI_VERSION}", abi.PROVIDED_CAPABILITIES + ) + + def test_verified_versions_are_exact_and_never_ranges(self): + for version in abi.VERIFIED_PYTHON_VERSIONS: + with self.subTest(version=version): + self.assertRegex(version, r"^\d+\.\d+\.\d+$") + + def test_the_shipping_exact_python_remains_verified(self): + """Cross-Python support must not drop the version main already shipped.""" + self.assertIn(abi.SUPPORTED_PYTHON, abi.VERIFIED_PYTHON_VERSIONS) + + def test_parse_contract_does_not_mutate_its_input(self): + document = contract() + before = copy.deepcopy(document) + abi.parse_contract(document) + self.assertEqual(document, before) + + def test_decide_restore_returns_a_detached_contract(self): + document = contract() + accepted = abi.decide_restore(document, LINUX_312) + accepted["target"]["python_versions"].append("9.9.9") + self.assertNotIn("9.9.9", document["target"]["python_versions"]) + + +if __name__ == "__main__": + unittest.main() + + +class PlatformDoubleGateTests(unittest.TestCase): + """The platform pair is decided by the image and the runtime, not either alone. + + Before this gate existed the pair was checked only against the image's own + lists, so an image that named Windows arm64 was accepted on a Windows arm64 + host even though this runtime never accepted that pair. The untrusted + document decided its own admissibility. + """ + + def widened(self) -> dict: + """A contract that claims Windows arm64 for itself.""" + document = contract() + target = document["target"] + target["operating_systems"] = ["Linux", "Darwin", "Windows"] + target["architectures"] = ["x86_64", "arm64"] + target["platforms"] = target["platforms"] + [ + {"os": "Windows", "architecture": "arm64"} + ] + return document + + def test_the_runtime_refuses_a_pair_it_does_not_accept(self): + with self.assertRaises(IncompatibleImage) as caught: + abi.decide_restore(self.widened(), Host("3.12.13", "Windows", "arm64")) + self.assertEqual(caught.exception.reason, abi.REASON_UNSUPPORTED_PLATFORM) + self.assertIn("this runtime does not accept platform", str(caught.exception)) + + def test_the_image_still_refuses_a_pair_it_does_not_list(self): + """The image-side half of the gate must remain in force.""" + document = contract() + document["target"]["platforms"] = [ + entry + for entry in document["target"]["platforms"] + if entry != {"os": "Darwin", "architecture": "arm64"} + ] + with self.assertRaises(IncompatibleImage) as caught: + abi.decide_restore(document, MACOS_313) + self.assertEqual(caught.exception.reason, abi.REASON_UNSUPPORTED_PLATFORM) + self.assertIn("image does not accept platform", str(caught.exception)) + + def test_a_restricted_runtime_refuses_a_pair_the_image_allows(self): + """Narrowing the runtime's own list is enough to refuse, by itself.""" + host = Host( + "3.12.13", "Darwin", "arm64", verified_platforms=(("Linux", "x86_64"),) + ) + with self.assertRaises(IncompatibleImage) as caught: + abi.decide_restore(contract(), host) + self.assertEqual(caught.exception.reason, abi.REASON_UNSUPPORTED_PLATFORM) + + def test_every_runtime_accepted_pair_is_still_accepted(self): + for name, machine in abi.VERIFIED_PLATFORMS: + with self.subTest(platform=f"{name} {machine}"): + abi.decide_restore(contract(), Host("3.12.13", name, machine)) + + def test_the_runtime_list_matches_the_declared_target_platforms(self): + self.assertEqual( + sorted(abi.VERIFIED_PLATFORMS), + sorted( + (entry["os"], entry["architecture"]) for entry in abi.TARGET_PLATFORMS + ), + ) + + def test_windows_arm64_is_absent_from_the_runtime_list(self): + """The pair the project has always documented as unsupported.""" + self.assertNotIn(("Windows", "arm64"), abi.VERIFIED_PLATFORMS) diff --git a/tests/test_image.py b/tests/test_image.py index 403c6bb..3402ad5 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -10,6 +10,8 @@ from pathlib import Path from unittest.mock import patch +from continuum import abi +from continuum.abi import Host, IncompatibleImage, REASON_MISSING_CAPABILITY from continuum.compiler import compile_source from continuum.errors import ImageError, UnsupportedObjectError from continuum.image import load_image, save_image, verify_image @@ -90,30 +92,23 @@ def test_new_image_declares_only_supported_platform_pairs(self): image = make_live_image(Path(temporary)) loaded = load_image(image) - self.assertIn( - {"os": "Windows", "architecture": "x86_64"}, - loaded.manifest["target_compatibility"]["platforms"], - ) - self.assertNotIn( - {"os": "Windows", "architecture": "arm64"}, - loaded.manifest["target_compatibility"]["platforms"], - ) + platforms = loaded.manifest["execution_contract"]["target"]["platforms"] + self.assertIn({"os": "Windows", "architecture": "x86_64"}, platforms) + self.assertNotIn({"os": "Windows", "architecture": "arm64"}, platforms) def test_unsupported_windows_arm64_pair_is_rejected(self): with tempfile.TemporaryDirectory() as temporary: image = make_live_image(Path(temporary)) loaded = load_image(image) - with ( - patch("continuum.image.platform.system", return_value="Windows"), - patch( - "continuum.image._normalized_architecture", - return_value="arm64", - ), - ): - with self.assertRaisesRegex( - ImageError, "target platform is unsupported" - ): - loaded.validate_compatibility() + # The contract takes the deciding host explicitly, so an + # unsupported platform pair is testable without monkeypatching + # the platform module out from under the runtime. + windows_arm64 = Host("3.12.13", "Windows", "arm64") + with self.assertRaises(IncompatibleImage) as caught: + loaded.validate_compatibility(windows_arm64) + self.assertEqual( + caught.exception.reason, abi.REASON_UNSUPPORTED_PLATFORM + ) def test_verify_deeply_checks_image_without_executing_program(self): with tempfile.TemporaryDirectory() as temporary: @@ -189,13 +184,20 @@ def test_incompatible_runtime_version_is_rejected(self): def alter(entries): manifest = json.loads(entries["manifest.json"]) - manifest["target_compatibility"]["python_version"] = "0.0.0" + manifest["execution_contract"]["creator"][ + "python_version" + ] = "0.0.0" replace_json_and_rehash( entries, "manifest.json", manifest ) rewrite_archive(image, incompatible, alter) - with self.assertRaisesRegex(ImageError, "runtime metadata is inconsistent"): + # Rewriting creator provenance and recomputing every archive + # checksum still fails: the contract must agree with runtime.json + # and the manifest source section, not merely hash correctly. + with self.assertRaisesRegex( + ImageError, "creator Python provenance disagrees" + ): load_image(incompatible) def test_image_has_no_native_executable_payload(self): @@ -257,14 +259,19 @@ def test_unknown_mandatory_capability_is_rejected(self): def alter(entries): manifest = json.loads(entries["manifest.json"]) - manifest["target_compatibility"]["required_capabilities"].append( - "execute-native-pointer-table" - ) + manifest["execution_contract"]["target"][ + "required_capabilities" + ].append("execute-native-pointer-table") replace_json_and_rehash(entries, "manifest.json", manifest) rewrite_archive(image, altered, alter) - with self.assertRaisesRegex(ImageError, "unknown mandatory"): - load_image(altered) + # Structural load succeeds; the capability this runtime cannot + # provide is refused by the compatibility decision, with a reason + # code rather than only prose. + loaded = load_image(altered) + with self.assertRaises(IncompatibleImage) as caught: + loaded.validate_compatibility() + self.assertEqual(caught.exception.reason, REASON_MISSING_CAPABILITY) def test_truncated_zip_is_rejected(self): with tempfile.TemporaryDirectory() as temporary: diff --git a/tests/test_image_refusals.py b/tests/test_image_refusals.py new file mode 100644 index 0000000..3539fda --- /dev/null +++ b/tests/test_image_refusals.py @@ -0,0 +1,736 @@ +"""Deterministic refusals at the image boundary. + +An image is executable untrusted content. Every case here builds a real image, +tampers with exactly one thing, recomputes every archive checksum so the +tampering is internally consistent, and asserts the image is still refused. + +Recomputing the checksums is the point. An attacker who edits a manifest will +also fix the hashes, so integrity checking alone proves nothing about metadata +that must agree with the rest of the image. These tests fail if the runtime ever +starts trusting a well-formed lie. + +Nothing here executes the frozen program. +""" + +from __future__ import annotations + +import contextlib +import io +import json +import tempfile +import unittest +import zipfile +from pathlib import Path + +from continuum import IR_VERSION, __version__, abi +from continuum.abi import ( + CONTAINER_FORMAT_VERSION, + LEGACY_CONTAINER_FORMAT_VERSION, + Host, + IncompatibleImage, +) +from continuum.compiler import compile_source +from continuum.errors import ImageError +from continuum.image import load_image, save_image, verify_image +from continuum.vm import VirtualMachine + +SOURCE = """ +def inner(limit, bag): + index = 0 + while index < limit: + bag.append(index) + print(f"WORK {index}") + index += 1 + return len(bag) + + +def outer(limit): + bag = [] + shared = {"a": bag, "b": bag} + shared["self"] = shared + total = inner(limit, bag) + print(f"DONE {total}") + return total + + +answer = outer(25) +""" + + +def other_verified_python() -> str: + """A verified interpreter version that is not the one running the tests. + + Derived rather than hard-coded: a literal would silently become a no-op + whenever the suite happens to run on that exact interpreter, which would + turn a refusal test into a test that asserts nothing. + """ + + import platform + + current = platform.python_version() + for version in abi.VERIFIED_PYTHON_VERSIONS: + if version != current: + return version + raise AssertionError("no second verified Python version to contrast against") + + +# A version this runtime will never accept, for cases that only need the +# creator provenance to disagree with the rest of the image. +UNVERIFIED_PYTHON = "3.7.99" + + +def _json_bytes(value) -> bytes: + return json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") + + +def _sha256(content: bytes) -> str: + import hashlib + + return hashlib.sha256(content).hexdigest() + + +def step_to_checkpoint(source: str, name: str, sentinel: int) -> VirtualMachine: + """Advance a fresh VM to a live checkpoint, without leaking its output.""" + + vm = VirtualMachine(compile_source(source, name), [name], name) + with contextlib.redirect_stdout(io.StringIO()): + while len(vm.frames) < 2 or vm.frames[-1].locals.get("index") != sentinel: + vm.step() + return vm + + +def make_image(root: Path, name: str = "valid.cont") -> Path: + image = root / name + vm = step_to_checkpoint(SOURCE, "refusal_test.py", 6) + save_image(image, vm, SOURCE) + return image + + +def rewrite(source: Path, target: Path, transform) -> Path: + """Rewrite an archive, then recompute every covered checksum. + + The result is an image whose integrity document is fully correct for its + tampered contents -- the situation a real attacker produces. + """ + + with zipfile.ZipFile(source, "r") as archive: + entries = {name: archive.read(name) for name in archive.namelist()} + transform(entries) + covered = { + name: _sha256(content) + for name, content in sorted(entries.items()) + if name not in {"checksums.json", "SIGNATURE"} + } + entries["checksums.json"] = _json_bytes( + {"algorithm": "sha256", "entries": covered} + ) + with zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as archive: + for name, content in sorted(entries.items()): + archive.writestr(name, content) + return target + + +def patch_manifest(entries: dict[str, bytes], mutate) -> None: + manifest = json.loads(entries["manifest.json"]) + mutate(manifest) + entries["manifest.json"] = _json_bytes(manifest) + + +def patch_runtime(entries: dict[str, bytes], mutate) -> None: + runtime = json.loads(entries["runtime.json"]) + mutate(runtime) + entries["runtime.json"] = _json_bytes(runtime) + + +class ImageRefusalCase(unittest.TestCase): + def setUp(self): + self._temporary = tempfile.TemporaryDirectory() + self.root = Path(self._temporary.name) + self.image = make_image(self.root) + + def tearDown(self): + self._temporary.cleanup() + + def tampered(self, transform, name="tampered.cont") -> Path: + return rewrite(self.image, self.root / name, transform) + + def assertLoadRefused(self, transform, pattern): + target = self.tampered(transform) + with self.assertRaisesRegex(ImageError, pattern): + load_image(target) + + def assertCompatibilityRefused(self, transform, reason, host=None): + target = self.tampered(transform) + loaded = load_image(target) + with self.assertRaises(IncompatibleImage) as caught: + loaded.validate_compatibility(host) + self.assertEqual(caught.exception.reason, reason) + + +class ContractVersionRefusalTests(ImageRefusalCase): + def test_unknown_execution_abi_is_refused(self): + def transform(entries): + patch_manifest( + entries, + lambda m: m["execution_contract"].update( + execution_abi_version="99.0" + ), + ) + patch_runtime(entries, lambda r: r.update(execution_abi_version="99.0")) + + self.assertCompatibilityRefused( + transform, abi.REASON_UNKNOWN_EXECUTION_ABI + ) + + def test_unknown_ir_version_is_refused(self): + def transform(entries): + patch_manifest( + entries, lambda m: m["execution_contract"].update(ir_version="99.0") + ) + + self.assertLoadRefused(transform, "runtime metadata is inconsistent") + + def test_unknown_graph_codec_version_is_refused(self): + def transform(entries): + patch_manifest( + entries, + lambda m: m["execution_contract"].update(graph_codec_version="99.0"), + ) + patch_runtime(entries, lambda r: r.update(graph_codec_version="99.0")) + + self.assertCompatibilityRefused(transform, abi.REASON_UNKNOWN_GRAPH_CODEC) + + def test_unknown_container_format_is_refused(self): + def transform(entries): + patch_manifest(entries, lambda m: m.update(format_version="99.0")) + + self.assertLoadRefused(transform, "unsupported image format version") + + def test_a_contract_claiming_the_legacy_policy_is_refused(self): + def transform(entries): + patch_manifest( + entries, + lambda m: m["execution_contract"].update( + compatibility_policy=abi.POLICY_EXACT + ), + ) + + self.assertCompatibilityRefused(transform, abi.REASON_POLICY_DOWNGRADE) + + def test_an_unknown_policy_name_is_refused(self): + def transform(entries): + patch_manifest( + entries, + lambda m: m["execution_contract"].update( + compatibility_policy="whatever-you-say" + ), + ) + + self.assertLoadRefused(transform, "unknown compatibility policy") + + +class PythonAllowlistRefusalTests(ImageRefusalCase): + def test_an_unverified_python_version_is_refused(self): + """Widening the image allowlist does not widen what the runtime accepts.""" + + def transform(entries): + def mutate(manifest): + contract = manifest["execution_contract"] + contract["target"]["python_versions"] = ["3.12.13", "3.99.0"] + + patch_manifest(entries, mutate) + + self.assertCompatibilityRefused( + transform, + abi.REASON_PYTHON_NOT_VERIFIED_BY_RUNTIME, + host=Host("3.99.0", "Linux", "x86_64"), + ) + + def test_a_python_outside_the_image_allowlist_is_refused(self): + def transform(entries): + patch_manifest( + entries, + lambda m: m["execution_contract"]["target"].update( + python_versions=["3.12.13"] + ), + ) + + self.assertCompatibilityRefused( + transform, + abi.REASON_PYTHON_NOT_IN_IMAGE_ALLOWLIST, + host=Host("3.13.14", "Linux", "x86_64"), + ) + + def test_a_malformed_allowlist_is_refused(self): + for value in ([], ["3.12.13", "3.12.13"], "3.12.13", ["3.12.13", 313]): + with self.subTest(value=value): + + def transform(entries, value=value): + patch_manifest( + entries, + lambda m: m["execution_contract"]["target"].update( + python_versions=value + ), + ) + + self.assertLoadRefused( + transform, "target Python allowlist is malformed" + ) + + def test_an_empty_capability_list_is_refused(self): + def transform(entries): + patch_manifest( + entries, + lambda m: m["execution_contract"]["target"].update( + required_capabilities=[] + ), + ) + + self.assertLoadRefused(transform, "is not a valid list") + + +class CapabilityRefusalTests(ImageRefusalCase): + def test_a_capability_this_runtime_lacks_is_refused(self): + def transform(entries): + patch_manifest( + entries, + lambda m: m["execution_contract"]["target"][ + "required_capabilities" + ].append("native-pointer-table-1.0"), + ) + + self.assertCompatibilityRefused(transform, abi.REASON_MISSING_CAPABILITY) + + def test_omitting_a_mandatory_capability_is_refused(self): + def transform(entries): + def mutate(manifest): + target = manifest["execution_contract"]["target"] + target["required_capabilities"] = [ + item + for item in target["required_capabilities"] + if item != "explicit-frames" + ] + + patch_manifest(entries, mutate) + + self.assertCompatibilityRefused(transform, abi.REASON_UNKNOWN_CAPABILITY) + + def test_requiring_a_native_payload_is_refused(self): + def transform(entries): + patch_manifest( + entries, + lambda m: m["execution_contract"]["target"].update( + native_payload_required=True + ), + ) + + self.assertLoadRefused(transform, "requires a native payload") + + +class ProvenanceConsistencyTests(ImageRefusalCase): + """Creator metadata is provenance, so it must still be internally true.""" + + def test_rewritten_creator_python_is_refused(self): + def transform(entries): + patch_manifest( + entries, + lambda m: m["execution_contract"]["creator"].update( + python_version=UNVERIFIED_PYTHON + ), + ) + + self.assertLoadRefused(transform, "creator Python provenance disagrees") + + def test_rewritten_creator_runtime_version_is_refused(self): + def transform(entries): + patch_manifest( + entries, + lambda m: m["execution_contract"]["creator"].update( + continuum_version="9.9.9" + ), + ) + + self.assertLoadRefused(transform, "creator runtime provenance disagrees") + + def test_rewritten_creator_platform_is_refused(self): + def transform(entries): + patch_manifest( + entries, + lambda m: m["execution_contract"]["creator"].update(os="Plan9"), + ) + + self.assertLoadRefused(transform, "creator platform provenance disagrees") + + def test_rewriting_both_creator_and_source_sections_is_still_refused(self): + """Fixing one half of the inconsistency is not enough.""" + + def transform(entries): + def mutate(manifest): + manifest["execution_contract"]["creator"][ + "python_version" + ] = UNVERIFIED_PYTHON + manifest["source"]["python_version"] = UNVERIFIED_PYTHON + + patch_manifest(entries, mutate) + + # The manifest now agrees with itself, so runtime.json is what catches it. + self.assertLoadRefused(transform, "creator Python provenance disagrees") + + def test_rewriting_manifest_and_runtime_together_is_caught_by_the_allowlist(self): + """Making every document agree still cannot invent a creator identity. + + With the manifest, its source section, and runtime.json all rewritten, + the remaining check is that the creator version appears in the image's + own target allowlist. + """ + + def transform(entries): + def mutate_manifest(manifest): + contract = manifest["execution_contract"] + contract["creator"]["python_version"] = "3.7.0" + contract["target"]["python_versions"] = ["3.12.13", "3.13.14"] + manifest["source"]["python_version"] = "3.7.0" + + patch_manifest(entries, mutate_manifest) + patch_runtime(entries, lambda r: r.update(python_version="3.7.0")) + + self.assertCompatibilityRefused( + transform, abi.REASON_INCONSISTENT_PROVENANCE + ) + + def test_execution_abi_disagreement_between_documents_is_refused(self): + def transform(entries): + patch_runtime(entries, lambda r: r.update(execution_abi_version="0.9")) + + self.assertLoadRefused(transform, "execution ABI metadata is inconsistent") + + def test_graph_codec_disagreement_between_documents_is_refused(self): + def transform(entries): + patch_runtime(entries, lambda r: r.update(graph_codec_version="0.9")) + + self.assertLoadRefused(transform, "graph codec metadata is inconsistent") + + +class StructuralRefusalTests(ImageRefusalCase): + def test_a_missing_contract_is_refused(self): + def transform(entries): + patch_manifest(entries, lambda m: m.pop("execution_contract")) + + self.assertLoadRefused(transform, "execution contract is not an object") + + def test_a_contract_that_is_not_an_object_is_refused(self): + def transform(entries): + patch_manifest(entries, lambda m: m.update(execution_contract=[1, 2])) + + self.assertLoadRefused(transform, "execution contract is not an object") + + def test_a_tampered_program_body_is_refused(self): + """Editing the frozen source is refused even with correct checksums.""" + + def transform(entries): + entries["code/program.py"] = SOURCE.replace( + "WORK", "TAMPERED" + ).encode("utf-8") + + self.assertLoadRefused(transform, "program hash does not match manifest") + + def test_a_tampered_ir_document_is_refused(self): + def transform(entries): + ir = json.loads(entries["code/ir.json"]) + ir["source_sha256"] = "0" * 64 + entries["code/ir.json"] = _json_bytes(ir) + + self.assertLoadRefused(transform, "IR source identity does not match manifest") + + def test_a_frame_count_disagreement_is_refused(self): + def transform(entries): + patch_manifest(entries, lambda m: m.update(frames=99)) + + self.assertLoadRefused(transform, "frame count does not match manifest") + + def test_a_heap_count_disagreement_is_refused(self): + def transform(entries): + patch_manifest(entries, lambda m: m.update(heap_objects=99999)) + + self.assertLoadRefused(transform, "heap object count does not match manifest") + + def test_an_invalid_frame_document_is_refused(self): + def transform(entries): + entries["frames/frames.json"] = _json_bytes({"frames": "not-a-list"}) + + self.assertLoadRefused(transform, "invalid frame metadata") + + def test_a_dropped_security_boundary_is_refused(self): + def transform(entries): + patch_manifest(entries, lambda m: m.pop("security_boundary")) + + self.assertLoadRefused( + transform, "omits its executable-content security boundary" + ) + + def test_a_broken_checksum_is_refused(self): + """Without recomputing hashes, ordinary integrity checking catches it.""" + + with zipfile.ZipFile(self.image, "r") as archive: + entries = {name: archive.read(name) for name in archive.namelist()} + entries["heap/objects.json"] = _json_bytes({"objects": []}) + broken = self.root / "broken.cont" + with zipfile.ZipFile(broken, "w", zipfile.ZIP_DEFLATED) as archive: + for name, content in sorted(entries.items()): + archive.writestr(name, content) + with self.assertRaisesRegex(ImageError, "integrity check failed"): + load_image(broken) + + def test_a_duplicate_archive_entry_is_refused(self): + duplicate = self.root / "duplicate.cont" + with zipfile.ZipFile(self.image, "r") as archive: + entries = {name: archive.read(name) for name in archive.namelist()} + with zipfile.ZipFile(duplicate, "w", zipfile.ZIP_DEFLATED) as archive: + for name, content in sorted(entries.items()): + archive.writestr(name, content) + archive.writestr("manifest.json", entries["manifest.json"]) + with self.assertRaises(ImageError): + load_image(duplicate) + + +class LegacyFormatTests(unittest.TestCase): + """Format 0.1 images keep the rule they were proven under.""" + + def setUp(self): + self._temporary = tempfile.TemporaryDirectory() + self.root = Path(self._temporary.name) + self.image = make_image(self.root) + + def tearDown(self): + self._temporary.cleanup() + + def legacy_image(self) -> Path: + """Convert a 0.2 image into an equivalent 0.1 image. + + This synthesizes the shape a 0.3.1 writer produced, so the legacy path + is exercised end to end rather than only unit-tested. + """ + + def transform(entries): + def mutate(manifest): + contract = manifest["execution_contract"] + manifest["format_version"] = LEGACY_CONTAINER_FORMAT_VERSION + manifest["target_compatibility"] = { + "operating_systems": list(contract["target"]["operating_systems"]), + "architectures": list(contract["target"]["architectures"]), + "platforms": [ + dict(entry) for entry in contract["target"]["platforms"] + ], + "python_version": contract["creator"]["python_version"], + "runtime_implementation": "continuum-vm", + "runtime_version": contract["creator"]["continuum_version"], + "native_payload_required": False, + "required_capabilities": sorted( + contract["target"]["required_capabilities"] + ), + } + manifest.pop("execution_contract") + + patch_manifest(entries, mutate) + + return rewrite(self.image, self.root / "legacy.cont", transform) + + def test_a_legacy_image_still_loads(self): + loaded = load_image(self.legacy_image()) + self.assertEqual( + loaded.manifest["format_version"], LEGACY_CONTAINER_FORMAT_VERSION + ) + + def test_a_legacy_image_is_accepted_on_its_exact_creator_host(self): + loaded = load_image(self.legacy_image()) + decision = loaded.validate_compatibility( + Host( + loaded.manifest["source"]["python_version"], + "Linux", + "x86_64", + continuum_version=__version__, + ) + ) + self.assertEqual(decision["compatibility_policy"], abi.POLICY_EXACT) + + def test_a_legacy_image_refuses_a_different_python_with_a_versioned_message(self): + loaded = load_image(self.legacy_image()) + with self.assertRaises(IncompatibleImage) as caught: + loaded.validate_compatibility( + Host( + other_verified_python(), + "Linux", + "x86_64", + continuum_version=__version__, + ) + ) + self.assertEqual( + caught.exception.reason, abi.REASON_LEGACY_PYTHON_MISMATCH + ) + message = str(caught.exception) + # The message must name both formats and say what to do about it. + self.assertIn(LEGACY_CONTAINER_FORMAT_VERSION, message) + self.assertIn(CONTAINER_FORMAT_VERSION, message) + self.assertIn("Re-freeze", message) + + def test_a_legacy_image_refuses_a_different_runtime_version(self): + loaded = load_image(self.legacy_image()) + with self.assertRaises(IncompatibleImage) as caught: + loaded.validate_compatibility( + Host( + loaded.manifest["source"]["python_version"], + "Linux", + "x86_64", + continuum_version="0.9.9", + ) + ) + self.assertEqual( + caught.exception.reason, abi.REASON_LEGACY_RUNTIME_MISMATCH + ) + + def test_a_legacy_image_cannot_smuggle_in_a_contract_policy(self): + """Declaring 0.1 while carrying a contract does not get the ABI rule.""" + + def transform(entries): + def mutate(manifest): + manifest["format_version"] = LEGACY_CONTAINER_FORMAT_VERSION + manifest["target_compatibility"] = { + "operating_systems": ["Linux"], + "architectures": ["x86_64"], + "platforms": [{"os": "Linux", "architecture": "x86_64"}], + "python_version": other_verified_python(), + "runtime_implementation": "continuum-vm", + "runtime_version": __version__, + "native_payload_required": False, + "required_capabilities": sorted( + manifest["execution_contract"]["target"][ + "required_capabilities" + ] + ), + } + + patch_manifest(entries, mutate) + + target = rewrite(self.image, self.root / "smuggled.cont", transform) + # runtime.json still records the real creator Python, so the legacy + # consistency check refuses the mismatch before any policy is applied. + with self.assertRaisesRegex(ImageError, "runtime metadata is inconsistent"): + load_image(target) + + +class VerificationDoesNotExecuteTests(unittest.TestCase): + def test_verify_reconstructs_state_without_running_the_program(self): + marker = "SIDE-EFFECT-MUST-NOT-HAPPEN" + source = f""" +def work(limit): + index = 0 + while index < limit: + index += 1 + print("STEP") + print("{marker}") + return index + + +answer = work(20) +""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + image = root / "verify.cont" + vm = step_to_checkpoint(source, "verify_test.py", 3) + save_image(image, vm, source) + + captured = io.StringIO() + with contextlib.redirect_stdout(captured): + report = verify_image(image) + + self.assertEqual(report["compatibility"], "accepted") + self.assertEqual(report["execution_contract"]["compatibility_policy"], "execution-abi") + # Neither the loop body nor the trailing marker may run. + self.assertNotIn(marker, captured.getvalue()) + self.assertNotIn("STEP", captured.getvalue()) + + +if __name__ == "__main__": + unittest.main() + + +class AdversarialPlatformWideningTests(ImageRefusalCase): + """An image cannot grant itself a platform this runtime does not accept. + + This is the whole-image version of the contract-level gate: a real image is + edited to add Windows arm64 to every platform list it carries, and every + covered archive checksum is recomputed so the artifact is internally + consistent. Integrity checking passes and the image still must not restore. + """ + + def widened(self, name: str = "windows-arm64.cont") -> Path: + def transform(entries): + def mutate(manifest): + target = manifest["execution_contract"]["target"] + target["operating_systems"] = sorted( + set(target["operating_systems"]) | {"Windows"} + ) + target["architectures"] = sorted( + set(target["architectures"]) | {"arm64"} + ) + target["platforms"] = target["platforms"] + [ + {"os": "Windows", "architecture": "arm64"} + ] + + patch_manifest(entries, mutate) + + return rewrite(self.image, self.root / name, transform) + + def test_the_tampered_image_is_internally_consistent(self): + """Guard the premise: this must fail on policy, not on a broken hash.""" + loaded = load_image(self.widened("premise.cont")) + platforms = loaded.manifest["execution_contract"]["target"]["platforms"] + self.assertIn({"os": "Windows", "architecture": "arm64"}, platforms) + + def test_windows_arm64_is_refused_deterministically(self): + loaded = load_image(self.widened()) + for attempt in range(3): + with self.subTest(attempt=attempt): + with self.assertRaises(IncompatibleImage) as caught: + loaded.validate_compatibility( + Host("3.12.13", "Windows", "arm64") + ) + self.assertEqual( + caught.exception.reason, abi.REASON_UNSUPPORTED_PLATFORM + ) + self.assertIn( + "this runtime does not accept platform", str(caught.exception) + ) + + def test_the_refusal_survives_a_verified_python_version(self): + """Widening the platform cannot be smuggled in on a verified interpreter.""" + loaded = load_image(self.widened("with-verified-python.cont")) + for version in abi.VERIFIED_PYTHON_VERSIONS: + with self.subTest(python=version): + with self.assertRaises(IncompatibleImage) as caught: + loaded.validate_compatibility( + Host(version, "Windows", "arm64") + ) + self.assertEqual( + caught.exception.reason, abi.REASON_UNSUPPORTED_PLATFORM + ) + + def test_the_widened_image_still_restores_on_an_accepted_platform(self): + """The tampering must not break unrelated, legitimate targets.""" + loaded = load_image(self.widened("still-good.cont")) + decision = loaded.validate_compatibility(Host("3.12.13", "Linux", "x86_64")) + self.assertEqual(decision["compatibility_policy"], abi.POLICY_EXECUTION_ABI) + + def test_verify_image_refuses_the_widened_image_on_that_platform(self): + """The public deep-verify path refuses it too, not just the decision.""" + from unittest import mock + + target = self.widened("verify-path.cont") + with mock.patch.object( + abi, "current_host", return_value=Host("3.12.13", "Windows", "arm64") + ): + with self.assertRaises(IncompatibleImage) as caught: + verify_image(target) + self.assertEqual(caught.exception.reason, abi.REASON_UNSUPPORTED_PLATFORM) diff --git a/validation/cross_python/cli_proof.py b/validation/cross_python/cli_proof.py new file mode 100644 index 0000000..770e742 --- /dev/null +++ b/validation/cross_python/cli_proof.py @@ -0,0 +1,449 @@ +#!/usr/bin/env python3 +"""Cross-Python continuation proof driven entirely through the public CLI. + +This proves the Phase 1 capability using only commands a user can run: +`continuum run`, `continuum freeze`, `continuum verify`, `continuum resume`. +No private image reader, no in-process VM driving, and no proof-only +compatibility path. The source and target roles run as separate processes under +separate interpreters, and the source is fully exited and reaped before the +target reads the image. + +Synchronization uses the shared safe-point hold primitive rather than watching +for output, so the checkpoint lands at the same execution position on every +host regardless of speed. The freeze itself is still an ordinary external +`continuum freeze` client observing a genuinely published request. + +The workload is supplied as a file rather than embedded here, and the +checkpoint is a safe-point index rather than a predicate over program +variables, so nothing in this harness recognizes any particular program. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import platform +import subprocess +import sys +from pathlib import Path +from typing import Any + +REPOSITORY = Path(__file__).resolve().parents[2] +if str(REPOSITORY) not in sys.path: + sys.path.insert(0, str(REPOSITORY)) + +from continuum import _harness # noqa: E402 +from continuum.abi import ( # noqa: E402 + CONTAINER_FORMAT_VERSION, + EXECUTION_ABI_VERSION, + normalized_architecture, +) + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for block in iter(lambda: handle.read(1 << 20), b""): + digest.update(block) + return digest.hexdigest() + + +def write_json(path: Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + + +def cli(python: str) -> list[str]: + return [python, "-m", "continuum"] + + +def host_identity(python: str) -> dict[str, Any]: + """Ask the named interpreter to describe itself, rather than assuming.""" + + probe = ( + "import json,platform,sys;" + "print(json.dumps({" + "'python_version': platform.python_version()," + "'python_implementation': platform.python_implementation()," + "'os': platform.system()," + "'machine': platform.machine()," + "'executable': sys.executable}))" + ) + output = subprocess.run( + [python, "-c", probe], + check=True, + capture_output=True, + text=True, + cwd=str(REPOSITORY), + ).stdout + identity = json.loads(output) + identity["architecture"] = normalized_architecture(identity["machine"]) + return identity + + +def environment(home: Path, extra: dict[str, str] | None = None) -> dict[str, str]: + env = dict(os.environ) + env["PYTHONPATH"] = str(REPOSITORY) + env["CONTINUUM_HOME"] = str(home) + if extra: + env.update(extra) + return env + + +def run_control(python: str, program: Path, home: Path) -> dict[str, Any]: + """Run the workload to completion, uninterrupted, as the oracle. + + This is an independent execution of the same program through the same public + entry point. It never reads the image and never shares state with the + checkpointed run. + """ + + completed = subprocess.run( + [*cli(python), "run", str(program)], + cwd=str(REPOSITORY), + env=environment(home), + capture_output=True, + text=True, + ) + if completed.returncode != 0: + raise RuntimeError( + f"control run failed ({completed.returncode}): {completed.stderr}" + ) + return {"stdout": completed.stdout, "stderr": completed.stderr} + + +def freeze_source( + python: str, + program: Path, + image: Path, + home: Path, + sync_dir: Path, + hold_safe_point: int, +) -> dict[str, Any]: + """Run the program under the public CLI and freeze it from outside. + + Returns evidence about the source process, including proof that it had + exited and been reaped before this function returned. + """ + + sync_dir.mkdir(parents=True, exist_ok=True) + env = _harness.environment_for( + sync_dir, + base=environment(home), + hold_safe_point=hold_safe_point, + ) + source = subprocess.Popen( + [*cli(python), "run", str(program)], + cwd=str(REPOSITORY), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + try: + ready = _harness.wait_for_ready(source, sync_dir) + session_id = ready["session_id"] + freeze_evidence = _harness.freeze_held_source( + cli(python), + session_id, + image, + source, + sync_dir, + ready, + cwd=REPOSITORY, + env=env, + ) + if freeze_evidence["returncode"] != 0: + raise RuntimeError( + f"continuum freeze failed: {freeze_evidence['stderr']}" + ) + stdout, stderr = source.communicate(timeout=180) + except BaseException: + if source.poll() is None: + source.kill() + source.communicate(timeout=30) + raise + + # The source has been waited on. Its exit status is available, which is + # only true of a process that has terminated and been reaped. + exit_status = source.poll() + if exit_status is None: + raise RuntimeError("source process did not exit") + + return { + "session_id": session_id, + "pid": source.pid, + "exit_status": exit_status, + "exited_and_reaped_before_target": True, + "stdout": stdout, + "stderr": stderr, + "freeze": { + key: value + for key, value in freeze_evidence.items() + if key not in {"stdout", "stderr"} + }, + # freeze_held_source pipes bytes; decode for the JSON evidence record. + "freeze_stdout": _text(freeze_evidence["stdout"]), + "freeze_stderr": _text(freeze_evidence["stderr"]), + } + + +def _text(value: bytes | str) -> str: + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return value + + +def inspect_image(python: str, image: Path, home: Path) -> dict[str, Any]: + completed = subprocess.run( + [*cli(python), "inspect", str(image)], + cwd=str(REPOSITORY), + env=environment(home), + capture_output=True, + text=True, + ) + return { + "returncode": completed.returncode, + "stdout": completed.stdout, + "stderr": completed.stderr, + } + + +def verify_image(python: str, image: Path, home: Path) -> dict[str, Any]: + completed = subprocess.run( + [*cli(python), "verify", str(image)], + cwd=str(REPOSITORY), + env=environment(home), + capture_output=True, + text=True, + ) + if completed.returncode != 0: + raise RuntimeError(f"continuum verify failed: {completed.stderr}") + return {"stdout": completed.stdout, "stderr": completed.stderr} + + +def resume_image(python: str, image: Path, home: Path) -> dict[str, Any]: + completed = subprocess.run( + [*cli(python), "resume", str(image)], + cwd=str(REPOSITORY), + env=environment(home), + capture_output=True, + text=True, + ) + if completed.returncode != 0: + raise RuntimeError(f"continuum resume failed: {completed.stderr}") + return {"stdout": completed.stdout, "stderr": completed.stderr} + + +def source_role(args: argparse.Namespace) -> int: + output = Path(args.output).resolve() + output.mkdir(parents=True, exist_ok=True) + program = Path(args.program).resolve() + identity = host_identity(args.python) + if args.expect_python and identity["python_version"] != args.expect_python: + raise RuntimeError( + f"source requires Python {args.expect_python}, got " + f"{identity['python_version']}" + ) + + image = output / "source.cont" + home = output / "home" + evidence = freeze_source( + args.python, + program, + image, + home, + output / "sync", + args.hold_safe_point, + ) + image_sha = sha256_file(image) + + # The control is produced on the source interpreter and carried alongside + # the image so the target compares against a run it did not influence. + control = run_control(args.python, program, output / "control-home") + inspected = inspect_image(args.python, image, home) + + (output / "program.py").write_bytes(program.read_bytes()) + (output / "source-stdout.log").write_text(evidence["stdout"], encoding="utf-8") + (output / "source-stderr.log").write_text(evidence["stderr"], encoding="utf-8") + (output / "control-stdout.log").write_text(control["stdout"], encoding="utf-8") + + write_json( + output / "source-evidence.json", + { + "role": "source", + "repository_commit": args.commit, + "container_format_version": CONTAINER_FORMAT_VERSION, + "execution_abi_version": EXECUTION_ABI_VERSION, + "host": identity, + "program_sha256": hashlib.sha256(program.read_bytes()).hexdigest(), + "image": { + "name": image.name, + "sha256_at_capture": image_sha, + "bytes": image.stat().st_size, + }, + "source_process": { + "session_id": evidence["session_id"], + "pid": evidence["pid"], + "exit_status": evidence["exit_status"], + "exited_and_reaped_before_target": evidence[ + "exited_and_reaped_before_target" + ], + }, + "freeze": evidence["freeze"], + "freeze_stdout": evidence["freeze_stdout"], + "inspect_stdout": inspected["stdout"], + "hold_safe_point": args.hold_safe_point, + "cli_only": True, + }, + ) + print(json.dumps({"image_sha256": image_sha, "image": str(image)}, indent=2)) + return 0 + + +def target_role(args: argparse.Namespace) -> int: + source_dir = Path(args.input).resolve() + output = Path(args.output).resolve() + output.mkdir(parents=True, exist_ok=True) + identity = host_identity(args.python) + if args.expect_python and identity["python_version"] != args.expect_python: + raise RuntimeError( + f"target requires Python {args.expect_python}, got " + f"{identity['python_version']}" + ) + + source_evidence = json.loads( + (source_dir / "source-evidence.json").read_text(encoding="utf-8") + ) + image = source_dir / source_evidence["image"]["name"] + sha_on_arrival = sha256_file(image) + if sha_on_arrival != source_evidence["image"]["sha256_at_capture"]: + raise RuntimeError( + "image changed in transit: " + f"{source_evidence['image']['sha256_at_capture']} -> {sha_on_arrival}" + ) + + home = output / "home" + verified = verify_image(args.python, image, home) + inspected = inspect_image(args.python, image, home) + resumed = resume_image(args.python, image, home) + + # The image must be untouched by verification and by resuming it. + sha_after = sha256_file(image) + + source_stdout = (source_dir / "source-stdout.log").read_text(encoding="utf-8") + control_stdout = (source_dir / "control-stdout.log").read_text(encoding="utf-8") + combined = source_stdout + resumed["stdout"] + + source_lines = source_stdout.splitlines() + resumed_lines = resumed["stdout"].splitlines() + repeated = sorted(set(source_lines) & set(resumed_lines)) + + report = { + "role": "target", + "repository_commit": args.commit, + "container_format_version": CONTAINER_FORMAT_VERSION, + "execution_abi_version": EXECUTION_ABI_VERSION, + "source": { + "os": source_evidence["host"]["os"], + "architecture": source_evidence["host"]["architecture"], + "python_version": source_evidence["host"]["python_version"], + "exited_and_reaped_before_target": source_evidence["source_process"][ + "exited_and_reaped_before_target" + ], + }, + "target": { + "os": identity["os"], + "architecture": identity["architecture"], + "python_version": identity["python_version"], + }, + "cross_python": ( + source_evidence["host"]["python_version"] != identity["python_version"] + ), + "cross_os": source_evidence["host"]["os"] != identity["os"], + "cross_architecture": ( + source_evidence["host"]["architecture"] != identity["architecture"] + ), + "image": { + "sha256_at_capture": source_evidence["image"]["sha256_at_capture"], + "sha256_on_arrival": sha_on_arrival, + "sha256_after_restore": sha_after, + "byte_identical_in_transit": ( + sha_on_arrival == source_evidence["image"]["sha256_at_capture"] + ), + "unchanged_by_restore": sha_after == sha_on_arrival, + }, + "restoration": { + "verify_stdout": verified["stdout"], + "inspect_stdout": inspected["stdout"], + "resume_stderr": resumed["stderr"], + "completed_actions_repeated": len(repeated), + "repeated_lines": repeated, + "combined_output_matches_control": combined == control_stdout, + "prefix_is_control_prefix": control_stdout.startswith(source_stdout), + "suffix_completes_control": control_stdout.endswith(resumed["stdout"]), + }, + "cli_only": True, + } + write_json(output / "final-report.json", report) + (output / "combined-stdout.log").write_text(combined, encoding="utf-8") + (output / "resume-stdout.log").write_text(resumed["stdout"], encoding="utf-8") + (output / "control-stdout.log").write_text(control_stdout, encoding="utf-8") + write_json(output / "source-evidence.json", source_evidence) + + failures = [] + if not report["image"]["byte_identical_in_transit"]: + failures.append("image was not byte-identical in transit") + if not report["image"]["unchanged_by_restore"]: + failures.append("image was modified by restore") + if not report["source"]["exited_and_reaped_before_target"]: + failures.append("source did not exit before the target resumed") + if report["restoration"]["completed_actions_repeated"] != 0: + failures.append( + f"{report['restoration']['completed_actions_repeated']} completed " + "actions repeated" + ) + if not report["restoration"]["combined_output_matches_control"]: + failures.append("source plus target output did not match the control") + print(json.dumps(report, indent=2, sort_keys=True)) + if failures: + for failure in failures: + print(f"PROOF FAILURE: {failure}", file=sys.stderr) + return 1 + return 0 + + +def parser() -> argparse.ArgumentParser: + root = argparse.ArgumentParser(description=__doc__) + roles = root.add_subparsers(dest="role", required=True) + + source = roles.add_parser("source", help="run and freeze through the public CLI") + source.add_argument("--python", default=sys.executable) + source.add_argument("--program", required=True) + source.add_argument("--output", required=True) + source.add_argument("--hold-safe-point", type=int, required=True) + source.add_argument("--expect-python", default="") + source.add_argument("--commit", default="") + source.set_defaults(handler=source_role) + + target = roles.add_parser("target", help="verify and resume through the CLI") + target.add_argument("--python", default=sys.executable) + target.add_argument("--input", required=True) + target.add_argument("--output", required=True) + target.add_argument("--expect-python", default="") + target.add_argument("--commit", default="") + target.set_defaults(handler=target_role) + return root + + +def main() -> int: + args = parser().parse_args() + return args.handler(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/validation/cross_python/differential.py b/validation/cross_python/differential.py new file mode 100644 index 0000000..3ce243f --- /dev/null +++ b/validation/cross_python/differential.py @@ -0,0 +1,738 @@ +#!/usr/bin/env python3 +"""Paired cross-Python differential suite over the compatibility corpus. + +For every accepted program and every reachable safe point this freezes live +execution under one interpreter, deeply verifies the image without executing it, +restores it under another interpreter, and compares the result against an +independently run uninterrupted control. + +The comparison is not limited to output. Both sides compute a canonical +*state fingerprint* of the live VM, covering the logical frame chain, resume +positions, locals, lexical cells, operand stacks, control blocks, pending +finally reasons, module RNG state, `random.Random` instances, file offsets, and +— crucially — object identity. Identity is captured by labelling each object on +first visit and emitting a back-reference on revisit, so shared references and +reference cycles are part of the compared structure rather than something the +comparison has to be told about. Two fingerprints are equal only if the graph +shapes match, not merely the values. + +Every case is classified. Refused and frontend-unsupported cases are reported +separately and never counted as successes. +""" + +from __future__ import annotations + +import argparse +import contextlib +import io +import json +import os +import random +import subprocess +import sys +import time +from pathlib import Path +from typing import Any + +REPOSITORY = Path(__file__).resolve().parents[2] +if str(REPOSITORY) not in sys.path: + sys.path.insert(0, str(REPOSITORY)) + +from continuum.abi import IncompatibleImage # noqa: E402 +from continuum.compiler import compile_source # noqa: E402 +from continuum.errors import ( # noqa: E402 + CompileError, + ContinuumError, + ExecutionError, + ImageError, + UnsupportedObjectError, +) +from continuum.image import load_image, save_image, verify_image # noqa: E402 +from continuum.resources import PortableFile # noqa: E402 +from continuum.values import ( # noqa: E402 + EMPTY, + BoundAttrRef, + BoundMethodValue, + BuiltinRef, + Cell, + ClassValue, + FunctionValue, + InstanceValue, + ModuleAttrRef, + ModuleRef, + VMIterator, +) +from continuum.vm import VirtualMachine # noqa: E402 + +CORPUS = REPOSITORY / "compatibility" / "programs" + +# Classifications. Only ACCEPTED contributes to the correctness rate; a silent +# mismatch is the one outcome that must never occur. +ACCEPTED = "accepted-and-correct" +REFUSED = "explicitly-refused" +UNSUPPORTED = "unsupported-by-language-frontend" +INFRASTRUCTURE = "infrastructure-failure" +MISMATCH = "silent-mismatch" + + +class Fingerprinter: + """Canonical, identity-preserving view of live VM state. + + Objects are labelled on first visit; a revisit emits `{"ref": label}`. That + makes sharing and cycles structural features of the fingerprint, so a + restore that duplicated a shared list or broke a cycle produces a different + fingerprint even when every scalar value matches. + """ + + def __init__(self) -> None: + self._labels: dict[int, str] = {} + self._live: list[Any] = [] + + def _label(self, value: Any) -> tuple[str, bool]: + key = id(value) + if key in self._labels: + return self._labels[key], True + label = f"o{len(self._labels)}" + self._labels[key] = label + # Hold a reference so no object is collected and its id reused while + # the walk is still in progress. + self._live.append(value) + return label, False + + def walk(self, value: Any) -> Any: + if isinstance(value, bytes): + # Hex so the fingerprint stays JSON-serializable without losing a + # single byte of the compared value. + return {"t": "bytes", "v": value.hex()} + if value is None or isinstance(value, (bool, int, float, str)): + # Distinguish types that compare equal across types (1 == 1.0 == + # True) so a changed type is never invisible. + return {"t": type(value).__name__, "v": value} + if value is EMPTY: + return {"t": "empty-cell"} + + label, seen = self._label(value) + if seen: + return {"ref": label} + + if isinstance(value, list): + return {"t": "list", "id": label, "items": [self.walk(i) for i in value]} + if isinstance(value, tuple): + return {"t": "tuple", "id": label, "items": [self.walk(i) for i in value]} + if isinstance(value, set): + # Sets have no portable order; compare a sorted canonical form. + return { + "t": "set", + "id": label, + "items": sorted( + json.dumps(self.walk(i), sort_keys=True) for i in value + ), + } + if isinstance(value, dict): + # Insertion order is semantically observable in Python, so it is + # compared rather than sorted away. + return { + "t": "dict", + "id": label, + "items": [[self.walk(k), self.walk(v)] for k, v in value.items()], + } + if isinstance(value, Cell): + return {"t": "cell", "id": label, "value": self.walk(value.value)} + if isinstance(value, FunctionValue): + return { + "t": "function", + "id": label, + "function_id": value.function_id, + "defaults": [self.walk(i) for i in value.defaults], + "kw_defaults": [self.walk(i) for i in value.kw_defaults], + "closure": [self.walk(i) for i in value.closure], + } + if isinstance(value, ClassValue): + return { + "t": "class", + "id": label, + "class_id": value.class_id, + "name": value.name, + "members": [ + [k, self.walk(v)] for k, v in sorted(value.members.items()) + ], + } + if isinstance(value, InstanceValue): + return { + "t": "instance", + "id": label, + "cls": self.walk(value.cls), + "attributes": [ + [k, self.walk(v)] for k, v in sorted(value.attributes.items()) + ], + } + if isinstance(value, BoundMethodValue): + return { + "t": "bound-method", + "id": label, + "instance": self.walk(value.instance), + "function": self.walk(value.function), + } + if isinstance(value, VMIterator): + return { + "t": "iterator", + "id": label, + "index": value.index, + "iterable": self.walk(value.iterable), + "dict_keys": ( + None + if value.dict_keys is None + else [self.walk(k) for k in value.dict_keys] + ), + } + if isinstance(value, BuiltinRef): + return {"t": "builtin", "name": value.name} + if isinstance(value, ModuleRef): + return {"t": "module", "name": value.name} + if isinstance(value, ModuleAttrRef): + return {"t": "module-attr", "module": value.module, "attr": value.attr} + if isinstance(value, BoundAttrRef): + return { + "t": "bound-attr", + "id": label, + "receiver": self.walk(value.receiver), + "attr": value.attr, + } + if isinstance(value, random.Random): + # A Random instance's full MT19937 state, so a resumed generator + # must produce the identical stream, not merely be a Random. + return {"t": "random", "id": label, "state": self.walk(value.getstate())} + if isinstance(value, PortableFile): + return { + "t": "file", + "id": label, + "path": str(value.path), + "mode": value.mode, + "offset": value.offset, + "closed": value.closed, + } + if isinstance(value, BaseException): + return { + "t": "exception", + "id": label, + "type": type(value).__name__, + "args": [self.walk(a) for a in value.args], + } + return {"t": "opaque", "id": label, "repr": type(value).__name__} + + def frame(self, vm: VirtualMachine, frame: Any, depth: int) -> dict[str, Any]: + function = vm.ir["functions"][frame.function_id] + instruction = function["code"][frame.pc] + return { + "depth": depth, + "function_id": frame.function_id, + "function_name": function["name"], + "resume_pc": frame.pc, + "resume_op": instruction["op"], + "resume_line": instruction["line"], + "locals": [ + [name, self.walk(value)] + for name, value in sorted(frame.locals.items()) + ], + "cells": [ + [name, self.walk(cell)] for name, cell in sorted(frame.cells.items()) + ], + "operand_stack": [self.walk(item) for item in frame.stack], + "control_blocks": [ + { + key: (self.walk(value) if key == "exception" else value) + for key, value in sorted(block.items()) + } + for block in frame.blocks + ], + "finally_reasons": [ + { + key: (self.walk(value) if key in {"exception", "value"} else value) + for key, value in sorted(reason.items()) + } + for reason in frame.finally_reasons + ], + "discard_result": frame.discard_result, + } + + def vm_state(self, vm: VirtualMachine) -> dict[str, Any]: + return { + "frame_chain": [ + vm.ir["functions"][frame.function_id]["name"] for frame in vm.frames + ], + "frames": [ + self.frame(vm, frame, depth) + for depth, frame in enumerate(vm.frames) + ], + "globals": [ + [name, self.walk(value)] + for name, value in sorted(vm.globals.items()) + if name != "__args__" + ], + "module_random_state": self.walk(random.getstate()), + "instructions_executed": vm.instructions_executed, + "safe_points_executed": vm.safe_points_executed, + "argv": list(vm.argv), + } + + +def fingerprint(vm: VirtualMachine) -> dict[str, Any]: + return Fingerprinter().vm_state(vm) + + +def build_vm(source: str, name: str) -> VirtualMachine: + return VirtualMachine(compile_source(source, name), [name], name) + + +def run_control(source: str, name: str) -> dict[str, Any]: + """Run the program to completion, uninterrupted, as the oracle.""" + + saved = random.getstate() + try: + vm = build_vm(source, name) + stream = io.StringIO() + errors = io.StringIO() + with contextlib.redirect_stdout(stream), contextlib.redirect_stderr(errors): + result = vm.run() + return { + "stdout": stream.getvalue(), + "stderr": errors.getvalue(), + "result": repr(result), + "safe_points": vm.safe_points_executed, + } + finally: + random.setstate(saved) + + +def source_case( + source: str, name: str, safe_point: int, image_path: Path +) -> dict[str, Any]: + """Freeze at `safe_point` and record the pre-transfer fingerprint.""" + + saved = random.getstate() + try: + vm = build_vm(source, name) + stream = io.StringIO() + errors = io.StringIO() + # Step to the checkpoint rather than raising out of the safe-point + # callback: an exception thrown from the callback is observable by the + # running program's own handlers, which would change the very state this + # is trying to capture. + with contextlib.redirect_stdout(stream), contextlib.redirect_stderr(errors): + while vm.frames and not vm.completed: + if vm.safe_points_executed >= safe_point: + break + vm.step() + if vm.completed or not vm.frames: + return {"status": "completed-before-safe-point"} + + state = fingerprint(vm) + save_image(image_path, vm, source) + return { + "status": "frozen", + "fingerprint": state, + "stdout": stream.getvalue(), + "stderr": errors.getvalue(), + } + finally: + random.setstate(saved) + + +def target_case(image_path: Path) -> dict[str, Any]: + """Deeply verify, then restore and finish, recording both fingerprints.""" + + saved = random.getstate() + try: + # Verification must not execute the program. It runs before restore and + # its report is retained so the ordering is visible in the evidence. + verification = verify_image(image_path) + loaded = load_image(image_path) + vm = loaded.restore_vm() + state = fingerprint(vm) + stream = io.StringIO() + errors = io.StringIO() + with contextlib.redirect_stdout(stream), contextlib.redirect_stderr(errors): + result = vm.run() + return { + "status": "restored", + "fingerprint": state, + "stdout": stream.getvalue(), + "stderr": errors.getvalue(), + "result": repr(result), + "verification": { + "integrity": verification["integrity"], + "compatibility": verification["compatibility"], + "policy": verification["execution_contract"]["compatibility_policy"], + }, + } + finally: + random.setstate(saved) + + +# --------------------------------------------------------------------------- +# Worker protocol. The coordinator runs this file under each interpreter; the +# worker reads one JSON request on stdin and writes one JSON reply on stdout. +# --------------------------------------------------------------------------- + + +def worker() -> int: + request = json.loads(sys.stdin.read()) + action = request["action"] + try: + if action == "control": + payload = run_control(request["source"], request["name"]) + elif action == "source": + payload = source_case( + request["source"], + request["name"], + request["safe_point"], + Path(request["image"]), + ) + elif action == "target": + payload = target_case(Path(request["image"])) + elif action == "identity": + import platform + + payload = { + "python_version": platform.python_version(), + "os": platform.system(), + "machine": platform.machine(), + } + else: + raise ValueError(f"unknown action {action!r}") + print(json.dumps({"ok": True, "payload": payload})) + return 0 + except ( + CompileError, + UnsupportedObjectError, + ) as exc: + print( + json.dumps( + {"ok": False, "kind": UNSUPPORTED, "error": f"{type(exc).__name__}: {exc}"} + ) + ) + return 0 + except (IncompatibleImage, ImageError) as exc: + print( + json.dumps( + {"ok": False, "kind": REFUSED, "error": f"{type(exc).__name__}: {exc}"} + ) + ) + return 0 + except (ExecutionError, ContinuumError) as exc: + print( + json.dumps( + {"ok": False, "kind": UNSUPPORTED, "error": f"{type(exc).__name__}: {exc}"} + ) + ) + return 0 + except Exception as exc: # noqa: BLE001 - reported as infrastructure + import traceback + + print( + json.dumps( + { + "ok": False, + "kind": INFRASTRUCTURE, + "error": f"{type(exc).__name__}: {exc}", + "traceback": traceback.format_exc(), + } + ) + ) + return 0 + + +def call(python: str, request: dict[str, Any], timeout: float) -> dict[str, Any]: + completed = subprocess.run( + [python, str(Path(__file__).resolve()), "worker"], + input=json.dumps(request), + capture_output=True, + text=True, + timeout=timeout, + cwd=str(REPOSITORY), + env={**os.environ, "PYTHONPATH": str(REPOSITORY)}, + ) + if completed.returncode != 0 or not completed.stdout.strip(): + return { + "ok": False, + "kind": INFRASTRUCTURE, + "error": ( + f"worker exited {completed.returncode}: " + f"{completed.stderr.strip()[:2000]}" + ), + } + try: + return json.loads(completed.stdout) + except json.JSONDecodeError as exc: + return {"ok": False, "kind": INFRASTRUCTURE, "error": f"bad worker reply: {exc}"} + + +# --------------------------------------------------------------------------- +# Coordinator. +# --------------------------------------------------------------------------- + + +def compare( + source: dict[str, Any], target: dict[str, Any], control: dict[str, Any] +) -> list[str]: + """Every semantic difference between a migrated run and the control. + + An empty list is the only accepted outcome. Each entry names the specific + dimension that differed, so a failure identifies what broke rather than + only that something did. + """ + + differences: list[str] = [] + source_state = source["fingerprint"] + target_state = target["fingerprint"] + + if source_state["frame_chain"] != target_state["frame_chain"]: + differences.append( + f"frame chain: {source_state['frame_chain']} -> " + f"{target_state['frame_chain']}" + ) + if len(source_state["frames"]) != len(target_state["frames"]): + differences.append("frame count changed across the crossing") + else: + for before, after in zip(source_state["frames"], target_state["frames"]): + where = f"frame {before['depth']} ({before['function_name']})" + if before["resume_pc"] != after["resume_pc"]: + differences.append(f"{where}: resume position changed") + if before["resume_op"] != after["resume_op"]: + differences.append(f"{where}: resume opcode changed") + if before["locals"] != after["locals"]: + differences.append(f"{where}: locals changed") + if before["cells"] != after["cells"]: + differences.append(f"{where}: lexical cells changed") + if before["operand_stack"] != after["operand_stack"]: + differences.append(f"{where}: operand stack changed") + if before["control_blocks"] != after["control_blocks"]: + differences.append(f"{where}: control blocks changed") + if before["finally_reasons"] != after["finally_reasons"]: + differences.append(f"{where}: pending finally state changed") + if before["discard_result"] != after["discard_result"]: + differences.append(f"{where}: result disposition changed") + + if source_state["globals"] != target_state["globals"]: + differences.append("module globals changed across the crossing") + if source_state["module_random_state"] != target_state["module_random_state"]: + differences.append("module RNG state changed across the crossing") + if source_state["instructions_executed"] != target_state["instructions_executed"]: + differences.append("instruction counter changed across the crossing") + if source_state["safe_points_executed"] != target_state["safe_points_executed"]: + differences.append("safe-point counter changed across the crossing") + if source_state["argv"] != target_state["argv"]: + differences.append("argv changed across the crossing") + + combined = source["stdout"] + target["stdout"] + if combined != control["stdout"]: + differences.append("source plus target stdout did not match the control") + if not control["stdout"].startswith(source["stdout"]): + differences.append("source stdout is not a prefix of the control stdout") + if source["stderr"] + target["stderr"] != control["stderr"]: + differences.append("source plus target stderr did not match the control") + if target["result"] != control["result"]: + differences.append( + f"final result differed: {target['result']} != {control['result']}" + ) + + prefix_lines = [line for line in source["stdout"].splitlines() if line] + suffix_lines = [line for line in target["stdout"].splitlines() if line] + repeated = sorted(set(prefix_lines) & set(suffix_lines)) + if repeated: + differences.append(f"completed actions repeated: {repeated[:5]}") + + return differences + + +def safe_points_for(total: int, count: int) -> list[int]: + """Spread checkpoints across a run, always including its first and last. + + Sampling by execution position rather than by program feature keeps the + corpus free of workload-specific knowledge. + """ + + if total <= 1: + return [] + if total <= count: + return list(range(1, total)) + stride = total / (count + 1) + points = sorted({max(1, int(round(stride * (index + 1)))) for index in range(count)}) + return [point for point in points if 1 <= point < total] + + +def run_corpus(args: argparse.Namespace) -> int: + programs = sorted(CORPUS.glob("*.py")) + if args.program: + wanted = set(args.program) + programs = [path for path in programs if path.stem in wanted] + if not programs: + raise SystemExit("no corpus programs selected") + + source_identity = call(args.source_python, {"action": "identity"}, args.timeout) + target_identity = call(args.target_python, {"action": "identity"}, args.timeout) + if not source_identity.get("ok") or not target_identity.get("ok"): + raise SystemExit("cannot identify the source or target interpreter") + + workdir = Path(args.workdir).resolve() + workdir.mkdir(parents=True, exist_ok=True) + + cases: list[dict[str, Any]] = [] + started = time.monotonic() + + for path in programs: + source_text = path.read_text(encoding="utf-8") + name = path.name + + # The control runs on the target interpreter: the oracle for a migrated + # run is what the program does when it is never interrupted at all. + control = call( + args.target_python, + {"action": "control", "source": source_text, "name": name}, + args.timeout, + ) + if not control.get("ok"): + cases.append( + { + "program": path.stem, + "safe_point": None, + "classification": control.get("kind", INFRASTRUCTURE), + "detail": control.get("error", ""), + } + ) + continue + control_payload = control["payload"] + total = control_payload["safe_points"] + + for safe_point in safe_points_for(total, args.checkpoints): + image = workdir / f"{path.stem}-{safe_point}.cont" + record: dict[str, Any] = {"program": path.stem, "safe_point": safe_point} + + frozen = call( + args.source_python, + { + "action": "source", + "source": source_text, + "name": name, + "safe_point": safe_point, + "image": str(image), + }, + args.timeout, + ) + if not frozen.get("ok"): + record["classification"] = frozen.get("kind", INFRASTRUCTURE) + record["detail"] = frozen.get("error", "") + cases.append(record) + continue + source_payload = frozen["payload"] + if source_payload["status"] != "frozen": + record["classification"] = REFUSED + record["detail"] = source_payload["status"] + cases.append(record) + image.unlink(missing_ok=True) + continue + + restored = call( + args.target_python, + {"action": "target", "image": str(image)}, + args.timeout, + ) + if not restored.get("ok"): + record["classification"] = restored.get("kind", INFRASTRUCTURE) + record["detail"] = restored.get("error", "") + cases.append(record) + image.unlink(missing_ok=True) + continue + + differences = compare(source_payload, restored["payload"], control_payload) + if differences: + record["classification"] = MISMATCH + record["differences"] = differences + else: + record["classification"] = ACCEPTED + record["frames"] = len(source_payload["fingerprint"]["frames"]) + record["frame_chain"] = source_payload["fingerprint"]["frame_chain"] + cases.append(record) + if not args.keep_images: + image.unlink(missing_ok=True) + + elapsed = time.monotonic() - started + counts: dict[str, int] = {} + for case in cases: + counts[case["classification"]] = counts.get(case["classification"], 0) + 1 + accepted = counts.get(ACCEPTED, 0) + mismatches = counts.get(MISMATCH, 0) + infrastructure = counts.get(INFRASTRUCTURE, 0) + decided = accepted + mismatches + + report = { + "source": source_identity["payload"], + "target": target_identity["payload"], + "cross_python": ( + source_identity["payload"]["python_version"] + != target_identity["payload"]["python_version"] + ), + "programs": len(programs), + "checkpoints_per_program": args.checkpoints, + "cases": len(cases), + "counts": counts, + # Refusals and frontend gaps are reported separately and are never + # folded into the correctness rate. + "correctness_among_accepted_cases": ( + 1.0 if decided == 0 else accepted / decided + ), + "silent_mismatches": mismatches, + "infrastructure_failures": infrastructure, + "elapsed_seconds": round(elapsed, 2), + "case_records": cases, + } + output = Path(args.output).resolve() + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text( + json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + + summary = {key: value for key, value in report.items() if key != "case_records"} + print(json.dumps(summary, indent=2, sort_keys=True)) + for case in cases: + if case["classification"] == MISMATCH: + print( + f"SILENT MISMATCH {case['program']}@{case['safe_point']}: " + f"{case.get('differences')}", + file=sys.stderr, + ) + elif case["classification"] == INFRASTRUCTURE: + print( + f"INFRASTRUCTURE {case['program']}@{case['safe_point']}: " + f"{case.get('detail')}", + file=sys.stderr, + ) + if mismatches or infrastructure: + return 1 + return 0 + + +def parser() -> argparse.ArgumentParser: + root = argparse.ArgumentParser(description=__doc__) + root.add_argument("--source-python", default=sys.executable) + root.add_argument("--target-python", default=sys.executable) + root.add_argument("--checkpoints", type=int, default=5) + root.add_argument("--program", action="append", default=[]) + root.add_argument("--workdir", default="/tmp/continuum-differential") + root.add_argument( + "--output", + default=str(REPOSITORY / "compatibility" / "results" / "cross-python.json"), + ) + root.add_argument("--timeout", type=float, default=300.0) + root.add_argument("--keep-images", action="store_true") + return root + + +def main() -> int: + if len(sys.argv) > 1 and sys.argv[1] == "worker": + return worker() + return run_corpus(parser().parse_args()) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/validation/cross_python/programs/layered_accumulator.py b/validation/cross_python/programs/layered_accumulator.py new file mode 100644 index 0000000..cef1464 --- /dev/null +++ b/validation/cross_python/programs/layered_accumulator.py @@ -0,0 +1,41 @@ +class Accumulator: + def __init__(self, seed): + self.total = seed + + def add(self, value): + self.total = self.total + value + + +def make_bias(base): + def bias(value): + return value + base + return bias + + +def leaf(limit, accumulator, bias, graph): + index = 0 + while index < limit: + value = bias(index) + accumulator.add(value) + graph["shared"].append(value) + print(f"ACTION {index} {accumulator.total}") + index += 1 + return accumulator.total + + +def middle(limit, accumulator, bias, graph): + return leaf(limit, accumulator, bias, graph) + + +def outer(limit): + shared = [] + graph = {"left": shared, "right": shared, "shared": shared} + graph["self"] = graph + accumulator = Accumulator(7) + bias = make_bias(3) + answer = middle(limit, accumulator, bias, graph) + print(f"FINAL {answer} {len(shared)}") + return answer + + +result = outer(400)