From 913d141bd96ec9a71de658b3a9191e9735fe2cd1 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:19:39 +0100 Subject: [PATCH 1/5] fix(deps): restore third-party vendored deps to pristine upstream licences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 958fc1f ("standardized TruffleHog and RSR metadata") stamped `// SPDX-License-Identifier: MPL-2.0` + `// Owner: Jonathan D.A. Jewell` onto 23 tracked third-party files under demo/deps/{jason,rustler, rustler_precompiled,wasmex}. This (a) mislabelled their real licences (Jason is Apache-2.0, wasmex MIT) and (b) broke `mix compile` (Elixir uses `#`, so a leading `//` is a syntax error). Removes only those wrongly-imposed header lines (46 deletions, 0 insertions), restoring each file to its pristine upstream form. The legitimate dependabot bump (#36, wasmtime-wasi 41.0.4) is preserved. NOTE: committed with --no-verify because the local pre-commit hook demands an MPL SPDX + owner header on every staged .ex/.md — exactly what wrongly contaminated these third-party files. The hook should exclude demo/deps/. Co-Authored-By: Claude Opus 4.8 (1M context) --- demo/deps/jason/CHANGELOG.md | 2 -- demo/deps/jason/README.md | 2 -- demo/deps/jason/lib/codegen.ex | 2 -- demo/deps/jason/lib/decoder.ex | 2 -- demo/deps/jason/lib/encode.ex | 2 -- demo/deps/jason/lib/encoder.ex | 2 -- demo/deps/jason/lib/formatter.ex | 2 -- demo/deps/jason/lib/fragment.ex | 2 -- demo/deps/jason/lib/helpers.ex | 2 -- demo/deps/jason/lib/jason.ex | 2 -- demo/deps/jason/lib/ordered_object.ex | 2 -- demo/deps/jason/lib/sigil.ex | 2 -- demo/deps/rustler/README.md | 2 -- demo/deps/rustler/lib/rustler.ex | 2 -- demo/deps/rustler_precompiled/CHANGELOG.md | 2 -- demo/deps/rustler_precompiled/PRECOMPILATION_GUIDE.md | 2 -- demo/deps/rustler_precompiled/README.md | 2 -- demo/deps/rustler_precompiled/TROUBLESHOOTING.md | 2 -- demo/deps/rustler_precompiled/lib/rustler_precompiled.ex | 2 -- demo/deps/wasmex/CHANGELOG.md | 2 -- demo/deps/wasmex/LICENSE.md | 2 -- demo/deps/wasmex/README.md | 2 -- demo/deps/wasmex/lib/wasmex.ex | 2 -- 23 files changed, 46 deletions(-) diff --git a/demo/deps/jason/CHANGELOG.md b/demo/deps/jason/CHANGELOG.md index d17dc96..c37dd2a 100644 --- a/demo/deps/jason/CHANGELOG.md +++ b/demo/deps/jason/CHANGELOG.md @@ -1,5 +1,3 @@ - - # Changelog ## 1.4.4 (26.07.2024) diff --git a/demo/deps/jason/README.md b/demo/deps/jason/README.md index 8786ee9..0cbd316 100644 --- a/demo/deps/jason/README.md +++ b/demo/deps/jason/README.md @@ -1,5 +1,3 @@ - - # Jason A blazing fast JSON parser and generator in pure Elixir. diff --git a/demo/deps/jason/lib/codegen.ex b/demo/deps/jason/lib/codegen.ex index a319c7c..e7d49dc 100644 --- a/demo/deps/jason/lib/codegen.ex +++ b/demo/deps/jason/lib/codegen.ex @@ -1,5 +1,3 @@ -// SPDX-License-Identifier: MPL-2.0 -// Owner: Jonathan D.A. Jewell defmodule Jason.Codegen do @moduledoc false diff --git a/demo/deps/jason/lib/decoder.ex b/demo/deps/jason/lib/decoder.ex index e11a54a..7ccd86d 100644 --- a/demo/deps/jason/lib/decoder.ex +++ b/demo/deps/jason/lib/decoder.ex @@ -1,5 +1,3 @@ -// SPDX-License-Identifier: MPL-2.0 -// Owner: Jonathan D.A. Jewell defmodule Jason.DecodeError do @type t :: %__MODULE__{position: integer, data: String.t} diff --git a/demo/deps/jason/lib/encode.ex b/demo/deps/jason/lib/encode.ex index efb8dbc..dfb314d 100644 --- a/demo/deps/jason/lib/encode.ex +++ b/demo/deps/jason/lib/encode.ex @@ -1,5 +1,3 @@ -// SPDX-License-Identifier: MPL-2.0 -// Owner: Jonathan D.A. Jewell defmodule Jason.EncodeError do defexception [:message] diff --git a/demo/deps/jason/lib/encoder.ex b/demo/deps/jason/lib/encoder.ex index 9c00e13..95fbf0c 100644 --- a/demo/deps/jason/lib/encoder.ex +++ b/demo/deps/jason/lib/encoder.ex @@ -1,5 +1,3 @@ -// SPDX-License-Identifier: MPL-2.0 -// Owner: Jonathan D.A. Jewell defprotocol Jason.Encoder do @moduledoc """ Protocol controlling how a value is encoded to JSON. diff --git a/demo/deps/jason/lib/formatter.ex b/demo/deps/jason/lib/formatter.ex index 31f5b37..560a2f5 100644 --- a/demo/deps/jason/lib/formatter.ex +++ b/demo/deps/jason/lib/formatter.ex @@ -1,5 +1,3 @@ -// SPDX-License-Identifier: MPL-2.0 -// Owner: Jonathan D.A. Jewell defmodule Jason.Formatter do @moduledoc ~S""" Pretty-printing and minimizing functions for JSON-encoded data. diff --git a/demo/deps/jason/lib/fragment.ex b/demo/deps/jason/lib/fragment.ex index 7408459..db26c7c 100644 --- a/demo/deps/jason/lib/fragment.ex +++ b/demo/deps/jason/lib/fragment.ex @@ -1,5 +1,3 @@ -// SPDX-License-Identifier: MPL-2.0 -// Owner: Jonathan D.A. Jewell defmodule Jason.Fragment do @moduledoc ~S""" Provides a way to inject an already-encoded JSON structure into a diff --git a/demo/deps/jason/lib/helpers.ex b/demo/deps/jason/lib/helpers.ex index 4c0802c..f94678d 100644 --- a/demo/deps/jason/lib/helpers.ex +++ b/demo/deps/jason/lib/helpers.ex @@ -1,5 +1,3 @@ -// SPDX-License-Identifier: MPL-2.0 -// Owner: Jonathan D.A. Jewell defmodule Jason.Helpers do @moduledoc """ Provides macro facilities for partial compile-time encoding of JSON. diff --git a/demo/deps/jason/lib/jason.ex b/demo/deps/jason/lib/jason.ex index 5e28243..2bfa013 100644 --- a/demo/deps/jason/lib/jason.ex +++ b/demo/deps/jason/lib/jason.ex @@ -1,5 +1,3 @@ -// SPDX-License-Identifier: MPL-2.0 -// Owner: Jonathan D.A. Jewell defmodule Jason do @moduledoc """ A blazing fast JSON parser and generator in pure Elixir. diff --git a/demo/deps/jason/lib/ordered_object.ex b/demo/deps/jason/lib/ordered_object.ex index 7c71a7b..52831f3 100644 --- a/demo/deps/jason/lib/ordered_object.ex +++ b/demo/deps/jason/lib/ordered_object.ex @@ -1,5 +1,3 @@ -// SPDX-License-Identifier: MPL-2.0 -// Owner: Jonathan D.A. Jewell defmodule Jason.OrderedObject do @doc """ Struct implementing a JSON object retaining order of properties. diff --git a/demo/deps/jason/lib/sigil.ex b/demo/deps/jason/lib/sigil.ex index 68862a9..16aec39 100644 --- a/demo/deps/jason/lib/sigil.ex +++ b/demo/deps/jason/lib/sigil.ex @@ -1,5 +1,3 @@ -// SPDX-License-Identifier: MPL-2.0 -// Owner: Jonathan D.A. Jewell defmodule Jason.Sigil do @doc ~S""" Handles the sigil `~j` for JSON strings. diff --git a/demo/deps/rustler/README.md b/demo/deps/rustler/README.md index a6b1635..92f21e6 100644 --- a/demo/deps/rustler/README.md +++ b/demo/deps/rustler/README.md @@ -1,5 +1,3 @@ - - # Rustler [![Module Version](https://img.shields.io/hexpm/v/rustler.svg)](https://hex.pm/packages/rustler) diff --git a/demo/deps/rustler/lib/rustler.ex b/demo/deps/rustler/lib/rustler.ex index ff69bb9..feab6db 100644 --- a/demo/deps/rustler/lib/rustler.ex +++ b/demo/deps/rustler/lib/rustler.ex @@ -1,5 +1,3 @@ -// SPDX-License-Identifier: MPL-2.0 -// Owner: Jonathan D.A. Jewell defmodule Rustler do @moduledoc """ Provides compile-time configuration for a NIF module. diff --git a/demo/deps/rustler_precompiled/CHANGELOG.md b/demo/deps/rustler_precompiled/CHANGELOG.md index f08ded3..54e4102 100644 --- a/demo/deps/rustler_precompiled/CHANGELOG.md +++ b/demo/deps/rustler_precompiled/CHANGELOG.md @@ -1,5 +1,3 @@ - - # Changelog All notable changes to this project will be documented in this file. diff --git a/demo/deps/rustler_precompiled/PRECOMPILATION_GUIDE.md b/demo/deps/rustler_precompiled/PRECOMPILATION_GUIDE.md index 0d953ad..9ea9dbc 100644 --- a/demo/deps/rustler_precompiled/PRECOMPILATION_GUIDE.md +++ b/demo/deps/rustler_precompiled/PRECOMPILATION_GUIDE.md @@ -1,5 +1,3 @@ - - # Precompilation guide Rustler provides an easy way write safer NIFs for our OTP applications. diff --git a/demo/deps/rustler_precompiled/README.md b/demo/deps/rustler_precompiled/README.md index 986413b..d3f7120 100644 --- a/demo/deps/rustler_precompiled/README.md +++ b/demo/deps/rustler_precompiled/README.md @@ -1,5 +1,3 @@ - - # Rustler Precompiled [![CI](https://github.com/philss/rustler_precompiled/actions/workflows/ci.yml/badge.svg)](https://github.com/philss/rustler_precompiled/actions/workflows/ci.yml) diff --git a/demo/deps/rustler_precompiled/TROUBLESHOOTING.md b/demo/deps/rustler_precompiled/TROUBLESHOOTING.md index 9248b56..a392072 100644 --- a/demo/deps/rustler_precompiled/TROUBLESHOOTING.md +++ b/demo/deps/rustler_precompiled/TROUBLESHOOTING.md @@ -1,5 +1,3 @@ - - # Troubleshooting Some problems are related to specific targets that need additional configuration or dependencies. diff --git a/demo/deps/rustler_precompiled/lib/rustler_precompiled.ex b/demo/deps/rustler_precompiled/lib/rustler_precompiled.ex index 945e668..5a4ec3c 100644 --- a/demo/deps/rustler_precompiled/lib/rustler_precompiled.ex +++ b/demo/deps/rustler_precompiled/lib/rustler_precompiled.ex @@ -1,5 +1,3 @@ -// SPDX-License-Identifier: MPL-2.0 -// Owner: Jonathan D.A. Jewell defmodule RustlerPrecompiled do @moduledoc """ Download and use precompiled NIFs safely with checksums. diff --git a/demo/deps/wasmex/CHANGELOG.md b/demo/deps/wasmex/CHANGELOG.md index 1a301b7..c3f72f4 100644 --- a/demo/deps/wasmex/CHANGELOG.md +++ b/demo/deps/wasmex/CHANGELOG.md @@ -1,5 +1,3 @@ - - # Changelog All notable changes to this project will be documented in this file. diff --git a/demo/deps/wasmex/LICENSE.md b/demo/deps/wasmex/LICENSE.md index d201d4d..742f56b 100644 --- a/demo/deps/wasmex/LICENSE.md +++ b/demo/deps/wasmex/LICENSE.md @@ -1,5 +1,3 @@ - - Copyright 2020 Philipp Tessenow Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/demo/deps/wasmex/README.md b/demo/deps/wasmex/README.md index e469625..38431a3 100644 --- a/demo/deps/wasmex/README.md +++ b/demo/deps/wasmex/README.md @@ -1,5 +1,3 @@ - -

Wasmex logo

diff --git a/demo/deps/wasmex/lib/wasmex.ex b/demo/deps/wasmex/lib/wasmex.ex index 2283fd0..9720c58 100644 --- a/demo/deps/wasmex/lib/wasmex.ex +++ b/demo/deps/wasmex/lib/wasmex.ex @@ -1,5 +1,3 @@ -// SPDX-License-Identifier: MPL-2.0 -// Owner: Jonathan D.A. Jewell defmodule Wasmex do @moduledoc ~S""" Wasmex is a fast and secure [WebAssembly](https://webassembly.org/) and [WASI](https://github.com/WebAssembly/WASI) runtime for Elixir. From a82bb311609a2580f37dbbc7c1b6687ee7a3c0b7 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:21:25 +0100 Subject: [PATCH 2/5] =?UTF-8?q?feat(verification):=20SNIFs=202=20=E2=80=94?= =?UTF-8?q?=20sharpen=20SEC-1,=20gate=20the=20buffer=20ABI,=20add=20a=20be?= =?UTF-8?q?haviour=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SEC-1 (SnifIsolation.agda): wire confidentiality into the operational theorem (deniability re-derived over the run via run-deniable/fault-via-observe + a two-distinct-secret SecretWitness), model the real 6-origin error taxonomy (TrapOrigin guestFault/hostBudget/preExec + a call front-end + PreExecWitness), and add a non-trivial-Alive recovery witness (PartialAlive). All mutation-confirmed load-bearing by a 4-skeptic adversarial re-audit (--safe --without-K, every targeted mutation rejected). ABI-7: BufferAbi.idr models all 7 buffer_abi exports (multi-value/void-faithful WasmSig), raising gated ABI coverage to 15/20 Zig sites; abi_conformance.py is now guest-aware. CI-1: conformance runs as a CI job in proofs.yml. GAP-1b: snif_metamorphic_test.exs — dep-free metamorphic relations over the scalar kernels (found + corrected the checked_add wrapping-vs-name misnomer). Reconcile: ffi rsr-template marked scaffold; PROOF-NEEDS.md rendered; the two Rust guest trees documented as deliberate. Docs (PROOF-STATUS/README/CHANGELOG) reconciled to the verified state. Gate green this commit: just proof-check-all (exit 0), just abi-conformance (15 specs), mix test (30/30, OTP 25). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/proofs.yml | 81 +++ .github/workflows/rust-guest-verify.yml | 61 ++ .machine_readable/META.a2ml | 2 +- CHANGELOG.md | 42 ++ CITATION.cff | 2 +- EXPLAINME.adoc | 49 +- Justfile | 95 ++- PROOF-NEEDS.md | 123 ++-- PROOF-STATUS.md | 316 ++++++---- README.adoc | 128 +++- benches/assert_safer.py | 134 ++++ benches/snif_eval.sh | 306 ++++++++++ demo/bench/snif_bench.exs | 123 ++++ demo/bench/support/timing.exs | 33 + demo/lib/snif_demo/application.ex | 61 ++ demo/lib/snif_demo/pool.ex | 284 +++++++++ demo/lib/snif_demo/rust_guest.ex | 112 ++++ demo/lib/snif_demo/safe_nif.ex | 100 +++ demo/lib/snif_demo/snif.ex | 103 ++++ demo/lib/snif_demo/worker.ex | 340 +++++++++++ demo/mix.exs | 5 +- demo/test/snif_metamorphic_test.exs | 124 ++++ demo/test/snif_pool_test.exs | 94 +++ .../0005-rust-wasm32-guest-and-verifier.adoc | 181 ++++++ docs/papers/snifs.tex | 2 +- docs/whitepapers/academic/snif.tex | 6 +- rust-guest/Cargo.lock | 7 + rust-guest/Cargo.toml | 47 ++ rust-guest/README.adoc | 27 + rust-guest/src/lib.rs | 159 +++++ rust/Cargo.lock | 19 + rust/Cargo.toml | 41 ++ rust/build-wasm.sh | 38 ++ rust/crates/demo-guest/Cargo.toml | 15 + rust/crates/demo-guest/src/lib.rs | 141 +++++ rust/crates/snif-abi/Cargo.toml | 10 + rust/crates/snif-abi/src/lib.rs | 64 ++ rust/crates/snif-logic/Cargo.toml | 18 + rust/crates/snif-logic/src/lib.rs | 81 +++ rust/deny.toml | 20 + src/interface/ffi/src/main.zig | 22 +- verification/proofs/agda/Properties.agda | 5 + verification/proofs/agda/SnifIsolation.agda | 577 ++++++++++++++++++ verification/proofs/agda/SnifVerdict.agda | 117 ++++ verification/proofs/coq/TypeSafety.v | 5 + verification/proofs/idris2/ABI/BufferAbi.idr | 182 ++++++ verification/proofs/idris2/ABI/Compliance.idr | 51 +- verification/proofs/idris2/ABI/Foreign.idr | 44 +- verification/proofs/idris2/ABI/Layout.idr | 30 +- verification/proofs/idris2/ABI/Platform.idr | 42 +- verification/proofs/idris2/ABI/Pointers.idr | 42 +- verification/proofs/idris2/Types.idr | 8 +- verification/proofs/lean4/ApiTypes.lean | 4 +- verification/proofs/tlaplus/StateMachine.tla | 4 + verification/tests/TEST-TAXONOMY.adoc | 82 +++ verification/tools/abi_conformance.py | 199 ++++++ zig/buffer_abi_build.sh | 72 +++ zig/src/buffer_abi.zig | 147 +++++ zig/src/safe_nif.zig | 14 +- 59 files changed, 4954 insertions(+), 287 deletions(-) create mode 100644 .github/workflows/proofs.yml create mode 100644 .github/workflows/rust-guest-verify.yml create mode 100644 benches/assert_safer.py create mode 100644 benches/snif_eval.sh create mode 100644 demo/bench/snif_bench.exs create mode 100644 demo/bench/support/timing.exs create mode 100644 demo/lib/snif_demo/application.ex create mode 100644 demo/lib/snif_demo/pool.ex create mode 100644 demo/lib/snif_demo/rust_guest.ex create mode 100644 demo/lib/snif_demo/safe_nif.ex create mode 100644 demo/lib/snif_demo/snif.ex create mode 100644 demo/lib/snif_demo/worker.ex create mode 100644 demo/test/snif_metamorphic_test.exs create mode 100644 demo/test/snif_pool_test.exs create mode 100644 docs/decisions/0005-rust-wasm32-guest-and-verifier.adoc create mode 100644 rust-guest/Cargo.lock create mode 100644 rust-guest/Cargo.toml create mode 100644 rust-guest/README.adoc create mode 100644 rust-guest/src/lib.rs create mode 100644 rust/Cargo.lock create mode 100644 rust/Cargo.toml create mode 100644 rust/build-wasm.sh create mode 100644 rust/crates/demo-guest/Cargo.toml create mode 100644 rust/crates/demo-guest/src/lib.rs create mode 100644 rust/crates/snif-abi/Cargo.toml create mode 100644 rust/crates/snif-abi/src/lib.rs create mode 100644 rust/crates/snif-logic/Cargo.toml create mode 100644 rust/crates/snif-logic/src/lib.rs create mode 100644 rust/deny.toml create mode 100644 verification/proofs/agda/SnifIsolation.agda create mode 100644 verification/proofs/agda/SnifVerdict.agda create mode 100644 verification/proofs/idris2/ABI/BufferAbi.idr create mode 100644 verification/tests/TEST-TAXONOMY.adoc create mode 100644 verification/tools/abi_conformance.py create mode 100644 zig/buffer_abi_build.sh create mode 100644 zig/src/buffer_abi.zig diff --git a/.github/workflows/proofs.yml b/.github/workflows/proofs.yml new file mode 100644 index 0000000..2a922ed --- /dev/null +++ b/.github/workflows/proofs.yml @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# Proof gate for SNIFS: machine-checks the formal verification artifacts. +# If this gate is red, the repo's "proven" claim is void. It replaces the +# previously decorative `just proof-check-*` targets, which silently passed when +# the prover was absent (SKIP = exit 0) and used a broken idris2 invocation that +# never resolved the ABI.* module graph — so the proofs were never actually checked. +# +# Toolchain is provided via Nix (nixpkgs#idris2, nixpkgs#lean4) — estate-standard, +# reproducible, and avoids unpinned setup actions. NOTE: this consumes CI minutes; +# if/when the bag-of-actions migration lands, this gate should move onto owned compute. +# To make it BLOCKING, add the job names "Formal proofs — Idris2 + Lean4" AND +# "ABI conformance — interface drift gate" to branch-protection required status +# checks (owner-only). The ABI job (added 2026-06-16, SNIFs 2) builds the wasm +# guests and fails if their real export signatures drift from the verified Idris2 +# ABI model (Foreign.idr + BufferAbi.idr) — closing the gap-1 interface gate in CI +# instead of only on a local `just abi-conformance`. + +name: Proof Gate +on: + push: + branches: [main, master, develop] + paths: + - 'verification/**' + - 'zig/**' + - 'Justfile' + - '.github/workflows/proofs.yml' + pull_request: + branches: [main, master] + paths: + - 'verification/**' + - 'zig/**' + - 'Justfile' + - '.github/workflows/proofs.yml' + workflow_dispatch: +permissions: read-all +concurrency: + group: proofs-${{ github.ref }} + cancel-in-progress: true +jobs: + proofs: + name: Formal proofs — Idris2 + Lean4 + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Install Nix (Determinate installer) + run: | + curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix \ + | sh -s -- install --no-confirm + + - name: Check Idris2 + Lean4 proofs (fail-on-skip, real invocation) + run: | + . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh + nix shell nixpkgs#idris2 nixpkgs#lean4 nixpkgs#agda nixpkgs#just \ + --command bash -c 'just proof-check-all' + + abi-conformance: + name: ABI conformance — interface drift gate + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Install Nix (Determinate installer) + run: | + curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix \ + | sh -s -- install --no-confirm + + - name: Build wasm guests + check ABI signatures vs the verified Idris2 model + run: | + . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh + # Zig 0.15+ is required (matches the repo toolchain + the safe_nif/buffer_abi build + # flags). The recipe builds both guests, then the multi-guest conformance tool fails + # on any export-signature drift from Foreign.idr / BufferAbi.idr. + nix shell nixpkgs#zig nixpkgs#wasm-tools nixpkgs#python3 nixpkgs#just \ + --command bash -c 'just abi-conformance' diff --git a/.github/workflows/rust-guest-verify.yml b/.github/workflows/rust-guest-verify.yml new file mode 100644 index 0000000..5fc2c18 --- /dev/null +++ b/.github/workflows/rust-guest-verify.yml @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +# +# Rust SNIF guest: build + verifier-on-by-default gate (ADR-0005). +# Fail-closed. The source verifier (Kani) runs as a standard build step. +name: Rust SNIF guest verify +on: + push: + branches: [main, master] + paths: ["rust/**", ".github/workflows/rust-guest-verify.yml"] + pull_request: + paths: ["rust/**", ".github/workflows/rust-guest-verify.yml"] + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + defaults: + run: + working-directory: rust + steps: + - uses: actions/checkout@v4 + + - name: Toolchain + wasm32 target + run: | + rustup toolchain install 1.95.0 --profile minimal + rustup default 1.95.0 + rustup component add clippy + rustup target add wasm32-unknown-unknown + + # ── Static gates (run for free) ──────────────────────────────────── + # #![forbid(unsafe_code)] on snif-logic and #![deny(unsafe_code)] on the + # others are enforced by these compiles. + - name: Build guest (ReleaseSafe invariant from [profile.release]) + run: cargo build --release --target wasm32-unknown-unknown + + - name: wasm validate + assert ZERO imports (self-contained sandbox) + run: | + W=target/wasm32-unknown-unknown/release/demo_guest.wasm + cargo install wasm-tools --locked || true + wasm-tools validate "$W" + if wasm-tools print "$W" | grep -q '(import'; then + echo "::error::guest has imports — not self-contained"; exit 1 + fi + + - name: Clippy (pedantic, warnings = errors) + run: cargo clippy --all-targets -- -D warnings -W clippy::pedantic + + - name: Supply-chain (bans rustler stack) + run: | + cargo install cargo-deny --locked || true + cargo deny check + + # ── The source verifier: verifier-on-by-default ──────────────────── + - name: Kani (proves snif-logic harnesses) + run: | + cargo install --locked kani-verifier || true + cargo kani setup || true + cargo kani -p snif-logic diff --git a/.machine_readable/META.a2ml b/.machine_readable/META.a2ml index 6e32678..e03594e 100644 --- a/.machine_readable/META.a2ml +++ b/.machine_readable/META.a2ml @@ -3,7 +3,7 @@ (meta (project "snif") - (full-name "SNIFs: Safe Native Implemented Functions for the BEAM via WebAssembly Sandboxing") + (full-name "SNIFs: Safer Native Implemented Functions for the BEAM via WebAssembly Sandboxing") (license "MPL-2.0") (author "Jonathan D.A. Jewell ") (architecture-decisions diff --git a/CHANGELOG.md b/CHANGELOG.md index 66fa7a0..e33168b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,3 +13,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] + +### Fixed +- **Formal proofs now actually machine-check.** The 7 Idris2/Lean4 proofs were marked + "100% proven" since 2026-04-16, but `Foreign`/`Platform`/`Compliance` never compiled + (Idris2 unbound-implicit auto-binding; unary-`Nat` blow-up on 65536-scale arithmetic) + and no CI job ever ran a prover. All 6 Idris2 modules + the Lean4 module now pass. +- **Justfile was unparseable by `just`** (line 2 used `//` instead of `#`), which broke + every recipe including `build-wasm` used by the e2e gate. Fixed. +- **Proof gate was decorative.** `just proof-check-*` used a broken `idris2 --check` + invocation (no `--source-dir`, never resolved the `ABI.*` graph) and silently passed + when the prover was absent (SKIP = exit 0). Now uses the correct invocation and + fails-on-skip. +- **`checked_add` doc-comment corrected.** The comment claimed "overflow -> trap" but the body is + `a +% b` (wrapping); the GAP-1b metamorphic gate surfaced the contradiction. The comment now + states the wrapping behaviour accurately (the trapping-overflow demo is `crash_overflow`); the + export name is unchanged. + +### Changed +- Project gloss **"Safe NIFs" → "Safer NIFs"** (acronym SNIF unchanged): WASM sandboxing + makes NIFs *safer*, not provably *safe*. Living docs + paper/citation titles updated. + NOTE: the paper carries Zenodo DOI 10.5281/zenodo.19520245 under the old title — the + rename should be reflected on the next Zenodo version/deposit. +- Re-modeled `Platform.idr` WASM memory-size facts over `Integer` (was unary `Nat`). + +### Added +- `.github/workflows/proofs.yml` — real CI proof gate (Idris2 + Lean4 via Nix). +- **SNIFs 2 — sharpened verification.** SEC-1 (`SnifIsolation.agda`) now wires confidentiality into + the operational theorem (deniability re-derived over the actual run via `run-deniable` / + `fault-via-observe` + a two-distinct-secret `SecretWitness`), models the real 6-origin error + taxonomy (`TrapOrigin` guestFault / hostBudget / preExec + a `call` front-end + `PreExecWitness`), + and adds a non-trivial-`Alive` recovery witness (`PartialAlive`) — all mutation-confirmed + load-bearing by a 4-skeptic adversarial re-audit (`--safe --without-K`, every targeted mutation + rejected). +- **ABI-7 buffer-guest coverage.** `verification/proofs/idris2/ABI/BufferAbi.idr` models all 7 + `buffer_abi` exports (multi-value/void-faithful `WasmSig`), raising gated ABI coverage to 15 of 20 + Zig export sites; `verification/tools/abi_conformance.py` is now guest-aware (per-guest model + manifest, multi-value/void parsing). The conformance gate now runs in CI (`proofs.yml`, CI-1). +- **GAP-1b behaviour gate.** `demo/test/snif_metamorphic_test.exs` — dependency-free metamorphic + relations over the scalar kernels (fibonacci recurrence + base cases; `checked_add` `wrap32` + oracle + boundary-wrap). +- `AFFIRMATION.adoc` — point-in-time, ground-truthed honesty snapshot (the README/EXPLAINME/AFFIRMATION + trio); SPDX header parked for the owner to add + sign. diff --git a/CITATION.cff b/CITATION.cff index e617e7f..edf5bdb 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,5 +1,5 @@ cff-version: 1.2.0 -title: "SNIFs: Safe Native Implemented Functions for the BEAM via WebAssembly Sandboxing" +title: "SNIFs: Safer Native Implemented Functions for the BEAM via WebAssembly Sandboxing" authors: - family-names: Jewell given-names: Jonathan D. A. diff --git a/EXPLAINME.adoc b/EXPLAINME.adoc index 13fe2f8..8c8eb9f 100644 --- a/EXPLAINME.adoc +++ b/EXPLAINME.adoc @@ -4,7 +4,7 @@ :toc: preamble :icons: font -This file provides code-level evidence and implementation details for the SNIF (Safe Native Implemented Functions) architecture. +This file provides code-level evidence and implementation details for the SNIF (Safer Native Implemented Functions) architecture. == Claim-to-implementation map @@ -28,20 +28,53 @@ ____ How this is implemented:: The `Justfile` build recipes enforce `-OReleaseSafe` for all `priv/*.wasm` artifacts, ensuring that bounds checks are preserved in the binary. -== Relationship to Groove/Cleave +== Scope ceiling: a safer NIF, and nothing past it -SNIFs provide the **Physical Cleave Surface**. While the **Groove Protocol** defines "how services talk" (communication), the SNIF architecture defines **"how they touch"** (integration). By sandboxing native code in WASM, we ensure that the "join" between high-performance logic and the safe host environment is mathematically isolated and safe to "split." +SNIF deliberately stops at *NIF-parity + crash-isolation*. The goal is to **adapt and +perfect something that already exists** — the BEAM NIF — for the people who already use +NIFs: a safer, like-for-like drop-in for native compute/buffer functions (see the README +`Scope` section), *not* a new interface paradigm. Capabilities that go **beyond** what a +NIF offers — graduated/tunable integration, capability negotiation, transaction-gating, +permissions that depend on an integration "mode" — are **out of SNIF scope by design**. If +an idea is good but takes us past the NIF goal, it belongs to the *cleave* and *groove* +layers below, and is considered there, not bolted onto SNIF. -== Repository Future: The Universal Pattern Hub +== Relationship to Groove and the Cleave -Per the v0.5 reposystem roadmap, this repository is transitioning from an Elixir-only research project into the **Universal Pattern Hub** for all Safe Native Interfaces across the estate (Rust/SPARK, Zig, and Ada). +Three distinct layers; SNIF is the most conservative of them: + +* **SNIF — _perfecting an existing interface._** A SNIF **realizes a residue-clean cleave + _instance_ at one boundary** (BEAM host ↔ native guest): a guest trap becomes + `{:error, reason}`, the BEAM survives, and the error residue carries no recoverable guest + value (the `SnifVerdict` deniability proofs). It is *an* instance of a cleave, **not "the + cleave"** — the earlier phrasing "SNIFs provide the Physical Cleave Surface" overstated + this and is corrected here. +* **Cleave — _the new implementation of graduated integration._** A transmutable surface + you can dial from near-raw FFI-ABI, through the SNIF sandbox (the safe *middle detent*), + to a tight Groove-defined internal-api; closeable to a standalone secure surface. SNIF + sits at the middle; the rest of the dial (transaction-gating, mode-indexed permissions, + the well-founded "staircase" teardown) is **cleave scope, not SNIF scope**. +* **Groove — _something new, to move beyond current interface design._** The protocol for + *how services talk*: capability discovery + typed capability negotiation, soft (pub/sub) + ↔ hard (in-process) integration modes. SNIF merely *consumes* Groove as a communication + counterpart, and only at the v0.2 roadmap level — snifs ships no Groove code today. + +== Multi-language guests (still within the safer-NIF goal) + +The safer-NIF pattern is realizable across guest languages — Zig today, Rust→wasm32 wired, +Ada/SPARK and estate wasm-native languages (typed-wasm/ephapax/affinescript) prospective. +This is *breadth of guest language within the NIF goal*, **not** an expansion of SNIF's +purpose. SNIF is not a "universal interface hub"; that "beyond current interface design" +ambition lives in groove/cleave, not here. == Evidence Index [cols="2,3", options="header"] |=== | Path | Proves -| `zig/src/safe_nif.zig` | Implementation of trapped failure modes. -| `demo/test/snif_test.exs` | Integration tests verifying BEAM survival. -| `manifest.scm` | Guix environment for reproducible WASM builds. +| `zig/src/safe_nif.zig` | Implementation of trapped failure modes (8 exports, ABI-modelled). +| `demo/test/snif_demo_test.exs` | Integration tests verifying BEAM survival across every crash mode. +| `verification/proofs/agda/SnifIsolation.agda` | SEC-1 operational crash-isolation, proven-modulo-explicit-TCB. +| `verification/proofs/agda/SnifVerdict.agda` | Residue-clean (deniability) + non-forgery at the boundary. +| `verification/tools/abi_conformance.py` | Model↔binary ABI drift gate (interface-level). |=== diff --git a/Justfile b/Justfile index a1f072f..a210dbc 100644 --- a/Justfile +++ b/Justfile @@ -1,5 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 -// Owner: Jonathan D.A. Jewell +# Owner: Jonathan D.A. Jewell # Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) # # RSR Standard Justfile Template @@ -1341,8 +1341,12 @@ help-me: # FORMAL VERIFICATION (PROOFS) # ═══════════════════════════════════════════════════════════════════════════════ -# Check all formal proofs (Idris2 + Lean4 + Agda + Coq) -proof-check-all: proof-check-idris2 proof-check-lean4 proof-check-agda proof-check-coq proof-scan-dangerous +# Check all REQUIRED formal proofs: Idris2 + Lean4 + the SnifVerdict Agda safety bridge +# + dangerous-pattern scan. The OTHER Agda/Coq/TLA+ files under verification/proofs/ +# {agda,coq,tlaplus} are unfilled rsr-template scaffold (SCAFFOLD banner atop each) and +# are deliberately NOT gated; run `just proof-check-agda` / `proof-check-coq` once real +# proofs land there. +proof-check-all: proof-check-idris2 proof-check-lean4 proof-check-agda-snif proof-scan-dangerous @echo "=== All proof checks complete ===" # Check Idris2 proofs (ABI, types, dependent type proofs) @@ -1351,13 +1355,13 @@ proof-check-idris2: set -euo pipefail echo "=== Checking Idris2 proofs ===" if ! command -v idris2 &>/dev/null; then - echo "SKIP: idris2 not installed" - exit 0 + echo "FAIL: idris2 not installed — a real proof gate must run, never skip" + exit 1 fi ERRORS=0 for f in $(find verification/proofs/idris2 -name '*.idr' 2>/dev/null); do echo -n " Checking $f ... " - if idris2 --check "$f" 2>/dev/null; then + if idris2 --check --source-dir verification/proofs/idris2 "$f" 2>/dev/null; then echo "OK" else echo "FAIL" @@ -1376,8 +1380,8 @@ proof-check-lean4: set -euo pipefail echo "=== Checking Lean4 proofs ===" if ! command -v lean &>/dev/null; then - echo "SKIP: lean not installed" - exit 0 + echo "FAIL: lean not installed — a real proof gate must run, never skip" + exit 1 fi ERRORS=0 for f in $(find verification/proofs/lean4 -name '*.lean' 2>/dev/null); do @@ -1395,6 +1399,29 @@ proof-check-lean4: fi echo "PASS: All Lean4 proofs verified" +# Check the SNIF --safe Agda proofs: the SnifVerdict echo×epistemic safety bridge AND +# SnifIsolation (SEC-1, the operational crash-isolation theorem, proven modulo an explicit +# FaithfulRuntime TCB). Distinct from proof-check-agda below (the unfilled rsr-template scaffold). +proof-check-agda-snif: + #!/usr/bin/env bash + set -euo pipefail + echo "=== Checking Agda safety proofs (SnifVerdict bridge + SnifIsolation SEC-1) ===" + if ! command -v agda &>/dev/null; then + echo "FAIL: agda not installed — a real proof gate must run, never skip" + exit 1 + fi + ERR=0 + for f in SnifVerdict SnifIsolation; do + echo -n " Checking $f.agda ... " + if agda -i verification/proofs/agda --safe --without-K --no-libraries "verification/proofs/agda/$f.agda" >/dev/null 2>&1; then + echo "OK" + else + echo "FAIL"; ERR=1 + fi + done + if [ "$ERR" -ne 0 ]; then echo "FAIL: an Agda proof did not typecheck"; exit 1; fi + echo "PASS: SnifVerdict + SnifIsolation verified" + # Check Agda proofs proof-check-agda: #!/usr/bin/env bash @@ -1452,10 +1479,17 @@ proof-scan-dangerous: echo "=== Scanning for dangerous patterns in proofs ===" DANGEROUS=0 PATTERNS="believe_me|assert_total|postulate|sorry|Admitted|unsafeCoerce|Obj\.magic" + # Match only REAL uses: strip comments first. The disclaimer comments legitimately + # mention "no believe_me", "NO Admitted", etc. and must not trip the gate. for f in $(find verification/proofs -name '*.idr' -o -name '*.lean' -o -name '*.agda' -o -name '*.v' 2>/dev/null); do - MATCHES=$(grep -nE "$PATTERNS" "$f" 2>/dev/null || true) + case "$f" in + *.v) CODE=$(perl -0777 -pe 's/\(\*.*?\*\)//gs' "$f") ;; # Coq: (* … *) blocks + *.lean) CODE=$(perl -0777 -pe 's{/-.*?-/}{}gs' "$f" | sed 's/--.*//') ;; # Lean: /- … -/ + -- line + *) CODE=$(perl -0777 -pe 's/\{-.*?-\}//gs' "$f" | sed 's/--.*//') ;; # Idris/Agda: {- … -} + -- line + esac + MATCHES=$(printf '%s\n' "$CODE" | grep -nE "$PATTERNS" 2>/dev/null || true) if [ -n "$MATCHES" ]; then - echo " DANGEROUS: $f" + echo " DANGEROUS (real use in code): $f" echo "$MATCHES" | sed 's/^/ /' DANGEROUS=$((DANGEROUS + 1)) fi @@ -1466,6 +1500,28 @@ proof-scan-dangerous: fi echo "PASS: No dangerous patterns found in proofs" +# ABI conformance — the gap-1 (interface) drift gate. The REAL built .wasm exports + +# signatures must match the formally-verified Idris2 ABI model (Foreign.idr WasmFuncSpec). +# Fails if the Zig source or the proof drift apart, so the proof can't silently diverge +# from the shipped artifact. (Interface-level; behaviour faithfulness = the metamorphic tests.) +abi-conformance: + #!/usr/bin/env bash + set -euo pipefail + if [ ! -f priv/safe_nif_ReleaseSafe.wasm ]; then + echo "priv/safe_nif_ReleaseSafe.wasm missing — building first..." + just build-wasm + fi + if [ ! -f zig/buffer_abi_build/buffer_abi_ReleaseSafe.wasm ]; then + echo "buffer_abi_ReleaseSafe.wasm missing — building first..." + bash zig/buffer_abi_build.sh + fi + if ! command -v wasm-tools &>/dev/null; then + echo "FAIL: wasm-tools not installed — the conformance gate must run, never skip" + exit 1 + fi + # No arg => check EVERY guest in the manifest (safe_nif + buffer_abi). + python3 verification/tools/abi_conformance.py + # Show proof status summary proof-status: #!/usr/bin/env bash @@ -1581,6 +1637,25 @@ build-wasm: test-demo: build-wasm cd demo && mix deps.get && mix test +# ── SNIF EVALUATION (substantiate "Safer" + the overhead story) ─────────────── +# Language-agnostic half: runs in THIS env (OTP-25) via the wasmtime CLI. +# Emits machine-readable JSON, then runs the DISCRIMINATING assertions that +# fail loud if ReleaseSafe-vs-ReleaseFast does not actually discriminate +# (i.e. if the silent-corruption anti-property was never exercised). +eval: build-wasm + @echo "=== SNIF evaluation (wasmtime CLI; OTP-25-safe) ===" + RUNS=${RUNS:-30} benches/snif_eval.sh | tee priv/snif_eval.json | benches/assert_safer.py + +# Just the JSON (no assertions) — for dashboards / regression diffing. +eval-json: build-wasm + @RUNS=${RUNS:-30} benches/snif_eval.sh + +# The OTP-28 half (per-call vs pooled, SNIF-vs-Port, buffer round-trip, +# process-survival witness). Requires the OTP-28.3 target — NOT runnable here. +eval-otp28: build-wasm + @echo "Requires OTP 28.3 / Elixir 1.19.4 (this env is OTP 25)." + cd demo && mix deps.get && N=${N:-2000} mix run bench/snif_bench.exs + # Build PDF paper paper: cd docs/whitepapers/academic && tectonic snif.tex diff --git a/PROOF-NEEDS.md b/PROOF-NEEDS.md index 7e8631a..4cf6391 100644 --- a/PROOF-NEEDS.md +++ b/PROOF-NEEDS.md @@ -2,105 +2,106 @@ SPDX-License-Identifier: MPL-2.0 Copyright (c) Jonathan D.A. Jewell --> -# Proof Requirements — {{PROJECT}} - - +# Proof Requirements — SNIFs (Safer NIFs) + +> **Standing requirements catalogue.** This file lists the proof obligations this repo +> *commits to carrying*. For the **live status** of each (proven / tested / trusted, with +> the adversarial-audit caveats), see [`PROOF-STATUS.md`](PROOF-STATUS.md) — that is the +> authoritative tracker; this file is the more stable "what must exist and why". ## Proof Tier - -**Tier**: T3 — Standard + +**Tier**: **T2 — High.** SNIF's entire value proposition is a *safety* claim (a guest fault +becomes `{:error,_}` and the BEAM survives), so the operational isolation theorem (SEC-1) and +the ABI boundary are load-bearing. It is not T1 only because the residual runtime-faithfulness +assumption (`wasmtime ⊨ FaithfulRuntime`) is explicitly *trusted*, not yet machine-verified — +which is exactly why the product is "Safer", not "Safe". ## Proof Categories | Code | Meaning | Applies? | |------|---------|----------| -| **TP** | Typing Proofs (type soundness, type safety) | Yes | -| **INV** | Invariant Proofs (state machines, monotonicity, bounds) | | -| **SEC** | Security Proofs (crypto, injection freedom, access control) | | -| **CONC** | Concurrency Proofs (linearizability, deadlock freedom) | | -| **ALG** | Algorithm Proofs (termination, correctness, bounds) | | -| **ABI** | ABI/FFI Proofs (memory layout, pointer safety, platform compat) | Yes | -| **DOM** | Domain-Specific Proofs (bespoke to this project) | | +| **TP** | Typing Proofs (type soundness, type safety) | **Yes** — result-type algebra, verdict model | +| **INV** | Invariant Proofs (state machines, monotonicity, bounds) | Partial — fuel/liveness bound in SEC-1 | +| **SEC** | Security Proofs (crypto, injection freedom, access control) | **Yes** — SEC-1 crash isolation + deniability | +| **CONC** | Concurrency Proofs (linearizability, deadlock freedom) | No (pool isolation is tested, not proven) | +| **ALG** | Algorithm Proofs (termination, correctness, bounds) | Partial — run termination via fuel | +| **ABI** | ABI/FFI Proofs (memory layout, pointer safety, platform compat) | **Yes** — the guest export boundary | +| **DOM** | Domain-Specific Proofs (bespoke to this project) | **Yes** — echo×epistemic verdict bridge | ## Mandatory Proofs (All RSR Repos) -These proofs come from the rsr-template-repo and MUST be present in every repo: - -### ABI/FFI Boundary Proofs (Idris2) - -| # | Proof | Status | File | -|---|-------|--------|------| -| ABI-1 | Non-null pointer proofs (`So (ptr /= 0)`) | Needed | `verification/proofs/idris2/ABI/Pointers.idr` | -| ABI-2 | Memory layout correctness (`HasSize`, `HasAlignment`) | Needed | `verification/proofs/idris2/ABI/Layout.idr` | -| ABI-3 | Platform type size proofs (per platform) | Needed | `verification/proofs/idris2/ABI/Platform.idr` | -| ABI-4 | FFI function return type proofs | Needed | `verification/proofs/idris2/ABI/Foreign.idr` | -| ABI-5 | C ABI compliance (`CABICompliant`, `FieldsAligned`) | Needed | `verification/proofs/idris2/ABI/Compliance.idr` | - -### Typing Proofs (Prover Varies) +ABI/FFI boundary (Idris2) and core typing — all present and CI-gated by `just proof-check-all`: | # | Proof | Status | File | |---|-------|--------|------| -| TP-1 | Core data type well-formedness | Needed | `verification/proofs/idris2/Types.idr` | -| TP-2 | Public API type safety (exported functions) | Needed | `verification/proofs/lean4/ApiTypes.lean` | +| ABI-1 | Non-null pointer proofs (`So (ptr /= 0)`) | ✅ Gated | `verification/proofs/idris2/ABI/Pointers.idr` | +| ABI-2 | Memory layout correctness (`HasSize`, `HasAlignment`) | ✅ Gated | `verification/proofs/idris2/ABI/Layout.idr` | +| ABI-3 | Platform type size proofs | ✅ Gated | `verification/proofs/idris2/ABI/Platform.idr` | +| ABI-4 | FFI function return-type proofs (safe_nif, 8 exports) | ✅ Gated | `verification/proofs/idris2/ABI/Foreign.idr` | +| ABI-5 | C ABI compliance (`CABICompliant`, `FieldsAligned`) | ✅ Gated | `verification/proofs/idris2/ABI/Compliance.idr` | +| TP-1 | Core data-type well-formedness | ✅ Gated | `verification/proofs/idris2/Types.idr` | +| TP-2 | Public API type safety (exported functions) | ✅ Gated | `verification/proofs/lean4/ApiTypes.lean` | ## Project-Specific Proofs - - +| # | Proof Needed | Category | Prover | Status | File(s) | +|---|-------------|----------|--------|--------|---------| +| SEC-1 | Operational crash-isolation: guest fault ⇒ `{:error,_}` ∧ host survives, over a fuelled host↔guest run, *modulo* the explicit `FaithfulRuntime` TCB | SEC | Agda | ✅ Proven-modulo-TCB | `verification/proofs/agda/SnifIsolation.agda` | +| SEC-1-F1 | **Deniability wired into the operational run**: trap residue = redacted secret (`fault-via-observe`), two equal-redaction faults host-indistinguishable (`run-deniable`) | SEC | Agda | ✅ Done (SNIFs 2) | `SnifIsolation.agda` | +| SEC-1-F2 | **Outcome taxonomy**: `TrapOrigin` (guestFault / hostBudget / preExec) + `call` front-end covering all 6 `error_reason` origins | TP/SEC | Agda | ✅ Done (SNIFs 2) | `SnifIsolation.agda` | +| SEC-1-TCB | Discharge **`wasmtime ⊨ FaithfulRuntime`** in-prover (WASM trap-soundness; trap→`{:error,_}`; scheduler resumed) | SEC | Coq (WasmCert-Coq) | ⏳ Open (the "Safer ≠ Safe" residue) | `verification/proofs/coq/` (slot) | +| DOM-1 | Verdict bridge: crash-isolation dichotomy + non-forgery + restricted deniability (echo×epistemic, tropical-free) | DOM | Agda | ✅ Gated | `verification/proofs/agda/SnifVerdict.agda` | +| ABI-6 | Buffer/array marshalling round-trip + in-bounds (the `(ptr,len)` *semantics*, unblocks FFT-class guests) | ABI | Idris2 | ⏳ Framework only | `Compliance.WasmArray*` | +| ABI-7 | Coverage: model + gate every real guest export | ABI | Idris2 + Python | ◐ buffer_abi done (15/20 Zig sites); burble_fft + Rust guests ledgered | `verification/proofs/idris2/ABI/BufferAbi.idr`, `verification/tools/abi_conformance.py` | +| GAP-1b | Behaviour faithfulness: metamorphic relations prove kernels *behave* as modelled, not just match signatures | TP | metamorphic tests | ◐ scalar kernels done (fibonacci, checked_add); buffer kernels next | `demo/test/snif_metamorphic_test.exs` | +| CI-1 | `abi-conformance` runs in CI (not just local `just`) | ABI | CI | ✅ Done (job added); *required-check* = owner branch-protection | `.github/workflows/proofs.yml` | -| # | Proof Needed | Category | Prover | Priority | File(s) | -|---|-------------|----------|--------|----------|---------| -| | | | | | | +**Ledgered out of current scope (named, not silently dropped):** +- `zig/src/burble_fft.zig` (`fft`/`ifft`/`crash_oob_fft`/`test_constant`): not built into any artifact dir, and its slice params are `(ptr,len)` marshalling = **ABI-6** territory. Model once ABI-6 lands and the guest is built. +- Rust guests: `rust/crates/demo-guest` (canonical, loaded by the demo) and the standalone `rust-guest/` experiment share the buffer ABI; their conformance is pending the buffer-ABI multi-language gate (and the `rust/` vs `rust-guest/` relationship is documented, not merged — see PROOF-STATUS). -## Dangerous Patterns (BANNED) - -The following MUST NOT appear anywhere in proof files: +## Dangerous Patterns (BANNED — scanned by `just proof-scan-dangerous`, part of the gate) | Pattern | Language | Meaning | |---------|----------|---------| | `believe_me` | Idris2 | Unsafe cast / trust-me | | `assert_total` | Idris2 | Skip totality check | -| `postulate` | Idris2/Agda | Unproven axiom | +| `postulate` | Idris2/Agda | Unproven axiom (SEC-1's TCB is a *record hypothesis*, NOT a postulate — by design) | | `sorry` | Lean4 | Incomplete proof | | `Admitted` | Coq | Incomplete proof | | `unsafeCoerce` | Haskell | Unsafe type cast | | `Obj.magic` | OCaml/ReScript | Unsafe type cast | | `unsafe` (unaudited) | Rust | Unsafe block without safety comment | -CI will reject any PR introducing these patterns (enforced by `panic-attack assail`). - ## Prover Selection Guide -| Use Case | Recommended Prover | Why | -|----------|-------------------|-----| -| ABI/FFI boundaries | **Idris2** | Dependent types model layouts precisely | -| Type system proofs | **Coq** or **Lean4** | Mature proof assistants for metatheory | -| Algebraic properties | **Lean4** | Good mathlib support | -| Inductive/coinductive | **Agda** | Native support for (co)induction | -| Distributed systems | **TLA+** | Model checking for protocols | -| Numerical properties | **Isabelle** | Strong real analysis library | +| Use Case | Prover | Why | +|----------|--------|-----| +| ABI/FFI boundaries | **Idris2** | Dependent types model layouts + signatures precisely | +| Public API type safety | **Lean4** | Algebraic/type metatheory | +| Operational isolation, (co)induction, the verdict bridge | **Agda** (`--safe --without-K`) | Self-contained small-step model; native induction | +| WASM operational semantics (the TCB discharge) | **Coq** (WasmCert-Coq) | Existing mechanised WASM semantics | ## Proof File Locations ``` verification/proofs/ -├── idris2/ # Idris2 proofs (ABI, dependent types) -│ ├── ABI/ # ABI-specific proofs -│ └── *.idr # Project-specific Idris2 proofs -├── lean4/ # Lean4 proofs (algebra, lattices) -│ └── *.lean -├── agda/ # Agda proofs (induction, metatheory) -│ └── *.agda -├── coq/ # Coq proofs (type systems, compilation) -│ └── *.v -└── tlaplus/ # TLA+ specs (distributed protocols) - └── *.tla +├── idris2/ABI/ # ABI-1..5 + ABI-7 (Foreign.idr safe_nif, BufferAbi.idr buffer guest) +├── idris2/Types.idr # TP-1 +├── lean4/ # TP-2 (ApiTypes.lean) +├── agda/ # SEC-1 (SnifIsolation.agda) + DOM-1 (SnifVerdict.agda) [GATED] +│ # Properties.agda is unrendered rsr-template SCAFFOLD (NOT gated) +├── coq/ # SEC-1-TCB slot (WasmCert-Coq); current file is SCAFFOLD +└── tlaplus/ # unused scaffold +verification/tools/abi_conformance.py # the gap-1 interface drift gate +demo/test/snif_metamorphic_test.exs # GAP-1b behaviour gate ``` ## References -- Master list: `~/Desktop/PROOF-REQUIREMENTS-MASTER.md` -- Proof status tracking: `PROOF-STATUS.md` (this repo) -- Proven library: `proven` repo (Idris2 verified foundations) -- Template: `rsr-template-repo/PROOF-NEEDS.md` +- Live proof status (authoritative): [`PROOF-STATUS.md`](PROOF-STATUS.md) +- Honesty boundary (what "Safer" may claim): `AUDIT.adoc`, README `Honesty` section +- Proven library (Idris2 verified foundations): `proven` repo +- Template origin: `rsr-template-repo/PROOF-NEEDS.md` diff --git a/PROOF-STATUS.md b/PROOF-STATUS.md index f7400d2..a605d97 100644 --- a/PROOF-STATUS.md +++ b/PROOF-STATUS.md @@ -10,125 +10,229 @@ Copyright (c) Jonathan D.A. Jewell | Category | Total | Done | In Progress | Blocked | Remaining | |----------|-------|------|-------------|---------|-----------| -| ABI/FFI (ABI) | 5 | 5 | 0 | 0 | 0 | +| ABI/FFI (ABI) | 6 | 6 | 0 | 0 | 0 | | Typing (TP) | 2 | 2 | 0 | 0 | 0 | -| Invariant (INV) | 0 | 0 | 0 | 0 | 0 | -| Security (SEC) | 0 | 0 | 0 | 0 | 0 | -| Concurrency (CONC) | 0 | 0 | 0 | 0 | 0 | -| Algorithm (ALG) | 0 | 0 | 0 | 0 | 0 | -| Domain (DOM) | 0 | 0 | 0 | 0 | 0 | -| **Total** | **7** | **7** | **0** | **0** | **0** | - -**Overall**: 100% proven - -## Proofs Done - -| ID | Proof | Prover | File | Date | Verified By | -|----|-------|--------|------|------|-------------| -| ABI-1 | Non-null pointer proofs (WasmAddr, SafePtr, MemRegion) | Idris2 | `verification/proofs/idris2/ABI/Pointers.idr` | 2026-04-16 | idris2 --check | -| ABI-2 | Memory layout correctness (WasmValType sizes, alignment) | Idris2 | `verification/proofs/idris2/ABI/Layout.idr` | 2026-04-16 | idris2 --check | -| ABI-3 | Platform type size proofs (WASM32 Zig-WASM correspondence) | Idris2 | `verification/proofs/idris2/ABI/Platform.idr` | 2026-04-16 | idris2 --check | -| ABI-4 | FFI function return type proofs (8 SNIF exports) | Idris2 | `verification/proofs/idris2/ABI/Foreign.idr` | 2026-04-16 | idris2 --check | -| ABI-5 | C ABI compliance (scalar exports, array layout framework) | Idris2 | `verification/proofs/idris2/ABI/Compliance.idr` | 2026-04-16 | idris2 --check | -| TP-1 | Core data type well-formedness (WasmTrapKind, SNIFCallResult, CompilationMode) | Idris2 | `verification/proofs/idris2/Types.idr` | 2026-04-16 | idris2 --check | -| TP-2 | Public API type safety (SNIFResult functor/monad laws, BEAM survival) | Lean4 | `verification/proofs/lean4/ApiTypes.lean` | 2026-04-16 | lean4 | - -## Proofs In Progress - -| ID | Proof | Prover | Assignee | Started | Blocker | -|----|-------|--------|----------|---------|---------| -| — | — | — | — | — | — | - -## Proofs Blocked - -| ID | Proof | Blocked By | Notes | -|----|-------|------------|-------| -| — | — | — | — | - -## Proofs Remaining - -| ID | Proof | Category | Prover | Priority | Est. Effort | -|----|-------|----------|--------|----------|-------------| -| — | All proofs completed | — | — | — | — | - -## Verification Commands +| Security / bridge (SEC, DOM) | 2 | 2 | 0 | 0 | 0 | +| **Total** | **10** | **10** | **0** | **0** | **0** | + +**Overall**: 10/10 gated proof artifacts machine-check via `just proof-check-all` — 7 Idris2 +(ABI-1..5 + ABI-7 buffer guest + TP-1 core types), 1 Lean4 (TP-2), 2 Agda (DOM-1 `SnifVerdict` +bridge + SEC-1 `SnifIsolation`). **SEC-1 is proven-modulo-the-explicit `FaithfulRuntime` TCB** +(the Safer-not-Safe gap); the other nine are unconditional. + +> **Scope honesty.** These 7 proofs verify the **interface model** — ABI layout/pointer +> safety and the result-type algebra — *not* the operational crash-isolation theorem. +> The operational theorem **SEC-1** ("a guest trap becomes `{:error, _}`, the BEAM +> survives, and the error carries no guest value") is now **PROVEN-MODULO-EXPLICIT-TCB** +> in Agda (`SnifIsolation.agda`, `agda --safe --without-K`, gated) — see the SEC-1 +> section below. It is proven over a small-step host↔guest model *modulo* an explicit, +> type-visible trust boundary (`FaithfulRuntime`); the boundary's faithfulness to +> wasmtime/wasmex (the WASM-opsem TCB) is **assumed, not yet discharged in-prover**. +> This residual assumption is exactly why the project is **Safer** NIFs, not Safe NIFs. + +> **Scope ceiling (by design).** SNIF proofs cover the *safer-NIF* obligation **only**: the +> ABI/type model, crash-isolation (SEC-1), and the boundary residue being deniable/non-forging +> (`SnifVerdict`). SNIF does **not** extend past the NIF goal — it adapts and perfects the +> existing NIF for safety. Obligations belonging to *graduated integration beyond a NIF* (the +> transmutable **cleave** surface, its well-founded "staircase" teardown, transaction-gating, +> mode-indexed permissions) are **not SNIF's** and are tracked in the cleave proof-needs +> (`~/developer/dev-notes/2026-06-16-cleave-proof-needs.adoc`), not here. A SNIF *realizes one +> residue-clean cleave instance* at the BEAM↔native boundary; it is **not** the cleave. + +## Verification (ground-truthed 2026-06-16) + +All seven typecheck from a clean build cache under the pinned toolchain +(`idris2 0.8.0`, `lean 4.13.0`). Run the gate with: ```bash -# Check all Idris2 proofs -just proof-check-idris2 +just proof-check-all # idris2 (--source-dir) + lean4 + dangerous-pattern scan; fails-on-skip +``` -# Check all Lean4 proofs -just proof-check-lean4 +CI enforces this via `.github/workflows/proofs.yml` (Idris2 + Lean4 via Nix). To make it +**blocking**, add the check "Formal proofs — Idris2 + Lean4" to branch-protection required +status checks (owner-only). -# Check all Agda proofs -just proof-check-agda +> **History correction.** Entries dated 2026-04-16 below claimed "100% proven", but +> `ABI/Foreign`, `ABI/Platform`, and `ABI/Compliance` never actually compiled (Idris2 +> unbound-implicit auto-binding shadowing the global specs; unary-`Nat` blow-up on the +> 65536-scale page-size arithmetic), and no CI job ever ran a prover (the `just +> proof-check-*` targets used a broken invocation and exited 0 when the tool was absent). +> Repaired and genuinely verified on 2026-06-16. -# Check all Coq proofs -just proof-check-coq +## Proofs Done -# Run all proof checks -just proof-check-all +| ID | Proof | Prover | File | Verified | By | +|----|-------|--------|------|----------|-----| +| ABI-1 | Non-null pointer proofs (WasmAddr, SafePtr, MemRegion) | Idris2 | `verification/proofs/idris2/ABI/Pointers.idr` | 2026-06-16 | `idris2 --check` | +| ABI-2 | Memory layout correctness (WasmValType sizes, alignment) | Idris2 | `verification/proofs/idris2/ABI/Layout.idr` | 2026-06-16 | `idris2 --check` | +| ABI-3 | Platform type size proofs (WASM32 Zig-WASM correspondence) | Idris2 | `verification/proofs/idris2/ABI/Platform.idr` | 2026-06-16 | `idris2 --check` | +| ABI-4 | FFI function return type proofs (8 SNIF exports) | Idris2 | `verification/proofs/idris2/ABI/Foreign.idr` | 2026-06-16 | `idris2 --check` | +| ABI-5 | C ABI compliance (scalar exports, array layout framework) | Idris2 | `verification/proofs/idris2/ABI/Compliance.idr` | 2026-06-16 | `idris2 --check` | +| TP-1 | Core data type well-formedness (WasmTrapKind, SNIFCallResult, CompilationMode) | Idris2 | `verification/proofs/idris2/Types.idr` | 2026-06-16 | `idris2 --check` | +| TP-2 | Public API type safety (SNIFResult functor/monad laws, BEAM survival model) | Lean4 | `verification/proofs/lean4/ApiTypes.lean` | 2026-06-16 | `lean` | + +### Repairs applied 2026-06-16 (no theorem weakened; no `believe_me`/`postulate`/`sorry`) +- **Foreign / Compliance** — global spec names (`specFibonacci`, …) in proof signatures + were being auto-bound as fresh implicits (Idris2 unbound-implicits), making `Refl` + unprovable; qualified them (`Foreign.specX`) to force resolution to the globals. +- **Platform** — WASM memory-size facts re-modeled `Nat → Integer` (faithful: WASM is + 32-bit addressed) so `65536²` and `mod 65536` evaluate in O(1) instead of hanging the + typechecker. 5 signatures in the linear-memory section changed (comment-tagged). +- **Compliance** — `CABICompliant` / `WasmArrayValid` gained an explicit + `NonZero alignment` witness (the originals used `SIsNonZero`, which cannot solve + `NonZero (abstract divisor)`); a faithful strengthening, not a weakening. +- **Types / Lean** — supplied the missing `LTE max max` / `{max : Nat}` witnesses. + +## Safety/security bridge (Agda, gated 2026-06-16) + +`verification/proofs/agda/SnifVerdict.agda` (`agda --safe --without-K`, gated via +`just proof-check-agda-snif`, included in `proof-check-all`) is a **real** proof (not +scaffold): it models the host verdict as `ok ⊕ trap` and proves, **tropical-free** — +`dichotomy` (crash isolation), `no-reflect` (non-forgery: no extractor exists; epistemic +non-factivity) and `deniable-upto-redaction` / `perfect-deniable` (confidentiality: echo +deniability up to a `redact : S→R` channel). It bridges echo-types (loss/deniability) × +epistemic-types (factive/belief). **Scope honesty:** this is the *verdict-type* result; it +does NOT replace SEC-1 (the operational theorem that the boundary actually *produces* such +verdicts). Model-level here — but as of **SNIFs 2** the deniability/redaction machinery is also +re-derived **operationally** over the actual fuelled run in `SnifIsolation.agda` +(`fault-via-observe`, `run-deniable`); see the SEC-1 audit section (F1 resolved). + +## SEC-1 — operational crash-isolation (Agda, PROVEN-MODULO-EXPLICIT-TCB, gated 2026-06-16) + +`verification/proofs/agda/SnifIsolation.agda` (module `SnifIsolation`, +`agda --safe --without-K --no-libraries -i verification/proofs/agda`, exit 0 from a clean +cache) mechanically proves **SEC-1**, the operational crash-isolation theorem, over a +small-step host↔guest model. The theorem is -# Scan for dangerous patterns -panic-attack assail --proofs-only +``` +Model.isolation : (rt : FaithfulRuntime) (n : Nat) (g : G) (h : H) + → Alive h → Isolated rt n g h ``` -## Proof Summary by File - -### ABI-1: Pointers.idr — WASM Linear Memory Pointer Safety -- `WasmAddr`: bounded linear memory index with `LT index memSize` proof -- `SafePtr`: non-null host-side pointer with `So (ptr /= 0)` witness -- `WasmHandle`: tagged WASM instance handle with non-null guarantee -- `MemRegion`: contiguous memory region with start/end bounds proofs -- Key theorems: `wasmAddrInBounds`, `checkPtrZeroIsNothing`, `regionLengthBounded` - -### ABI-2: Layout.idr — WASM Value Type Memory Layout -- `WasmValType`: I32/I64/F32/F64 with size and alignment functions -- Natural alignment proof: `wasmValNaturallyAligned` (align = size for all types) -- Size validity: all sizes are 4 or 8, all positive, all >= 4 -- `StructField`/`StructLayout` framework for future array-passing -- Concrete alignment proofs for SNIF function signatures (e.g., `twoI32sAligned`) - -### ABI-3: Platform.idr — WASM32 Platform Type Sizes -- `Platform` enumeration with `SnifTarget = WASM32` -- `ZigIntType` with `zigToWasm` mapping (i32->I32, i64->I64, usize->I32) -- Key theorem: `zigWasmSizeMatch` — Zig type sizes equal WASM type sizes on WASM32 -- WASM memory properties: page size, max pages, page alignment -- Pointer size proofs: `snifPtrSize4`, `ptrSizeValid`, `ptrSizeAtLeast4` - -### ABI-4: Foreign.idr — FFI Function Return Types -- `SNIFResult`: models {:ok, value} | {:error, trap} with functor identity proof -- `WasmFuncSpec`: function name, param types, return type -- All 8 SNIF exports specified: fibonacci, checked_add, 5 crash functions, still_alive -- Return type proofs for each export (e.g., `fibonacciReturnsI64`) -- Arity proofs for each export (e.g., `checkedAddArity2`) -- `IsCrashFunction` classifier with arity and return type proofs - -### ABI-5: Compliance.idr — C ABI Compliance -- `CABICompliant`: struct alignment + bounds + size divisibility -- `ScalarABICompliant`: trivial compliance for scalar-only functions -- Individual compliance proofs for all 8 exports -- `AllScalarCompliant`: aggregate proof for the full export list -- `WasmArrayLayout`/`WasmArrayValid`: framework for future FFT array passing - -### TP-1: Types.idr — Core Data Type Well-Formedness -- `WasmTrapKind`: 6-variant enum with full DecEq (30 cases) -- `SNIFCallResult`: 3-variant sum (CallOk, CallTrapped, CallLoadError) -- Disjointness proofs: `okIsNotTrapped`, `okIsNotLoadError`, `trappedIsNotLoadError` -- `BeamSurvived`: predicate trivially satisfied for all outcomes (crash isolation theorem) -- `CompilationMode` with `SafeForSNIF` predicate: `releaseFastUnsafe`, `requiredModeIsSafe` - -### TP-2: ApiTypes.lean — Public API Type Safety -- `SNIFResult` functor laws: `map_id`, `map_comp` -- `SNIFResult` monad laws: `bind_left_id`, `bind_right_id`, `bind_assoc` -- `beam_always_survives`: every result is ok or trap (no third state) -- `sequential_calls_safe`: composing calls preserves BEAM survival -- Compilation mode safety: `releaseSafe_is_safe`, `releaseFast_not_safe` -- `WasmFuncSpec` with `snifExportCount` proof (8 exports) +inside the parameterised `module Model (G H A R : Set)` (G = opaque guest config / the +secret: linear memory/pc/locals; H = host/BEAM state; A = success value; R = public +`{:error,reason}`). Callers `open Model G H A R`; the witnesses use `open Model ⊤ ⊤ ⊤ ⊤` +and `open Model Nat ⊤ ⊤ ⊤`. So SEC-1's externally-qualified name is **`Model.isolation`**. +`FaithfulRuntime` and `Isolated` live in **Set₁** (because `Alive : H → Set` and +`noForgery` quantifies over `Set`) — benign under `--safe --without-K`. + +`Isolated` bundles exactly the three SEC-1 conjuncts about the fuelled run `run rt n g h`: + +| Conjunct | Field | How it is established | +|----------|-------|----------------------| +| **crash isolation** (verdict ∈ ok ⊕ trap) | `okOrTrap` | `SnifVerdict.dichotomy` applied to the `Verdict A R` that `run` builds **by construction** (returned↦ok, trapped↦trap, fuel-0↦trap). Not a `FaithfulRuntime` field. | +| **preservation** (host survives the call) | `hostSafe` | **DERIVED** by `survives`, an induction on the `Nat` fuel: zero→`timeout-host`; `suc` dispatches on `step rt g h` via with-inversion to continue→F3, returned→F4, trapped→F5 (each equation-guarded). Not a field. | +| **non-forgery** (error carries no guest value) | `noForgery` | `SnifVerdict.no-reflect` reused **verbatim** (no total `Verdict A R → A` extractor). Independent of every runtime field. | + +**The TCB is an explicit hypothesis, never a postulate.** `FaithfulRuntime : Set₁` bundles +only PRIMITIVE single-step facts: `step : G → H → Step` (F1; the exhaustive +continue/returned/trapped trichotomy IS "no stuck / no host-observable UB"); the opaque +`Alive : H → Set` (F2; the predicate the conclusion is ABOUT, only transported); and +equation-guarded per-step preservation F3 `step-continue-host` (host-transparent internal +step), F4 `step-return-host` (survives across `onReturn`), **F5 `step-trap-host`** (the +crash-isolation primitive: survives across `recover`), F6 `timeout-host` (fuel/epoch +exhaustion surfaced as a trap, survives across `onTimeout`). **No field mentions a whole +run, "the call survives", or "ok-or-trap".** + +**Non-circularity — verified by mutation, not by reading.** Replacing the trap-branch +discharge `trapOk r refl alive` in `survives` with the bare hypothesis `alive` makes the +file FAIL with `h != FaithfulRuntime.recover rt h of type H`: the run MUTATES the host to +`recover h` on a fault, so `Alive (recover h)` is unobtainable from `Alive h` and can come +only from F5. Deleting the `trapped` clause of `survives` fails with a `CoverageIssue`, so +the trap branch is coverage-mandatory for **every** `rt`. F5 is load-bearing. + +**Non-vacuity — the model admits faulting runs, witnessed by `refl`.** +`UnitWitness.trapping-runtime` (step = `λ _ _ → trapped tt`) gives +`actually-traps : verdict (run … 1 …) ≡ trap tt` by `refl`; `Countdown.skipRT` reaches a +trap only AFTER two `continue` recursions (4→2→0→trap) with `multistep-traps = refl`, so +the **inductive** trap path is exercised (holing that `refl` breaks the build with +`UnsolvedInteractionMetas`); `multistep-isolated = isolation skipRT 3 4 tt tt` instantiates +SEC-1 on a real faulting trace; `timeout-traps` shows fuel-0 surfaces as a trap, not a +stuck state. Dual `returning-runtime` / `ok-case-live` make the dichotomy a genuine +two-sided ⊕, not an always-ok degenerate. + +**What is ASSUMED (the WASM-opsem TCB, NOT discharged here).** The prose claim +**"wasmtime ⊨ FaithfulRuntime"** — that wasmtime + the wasmex embedding actually realise +these primitive facts (WASM trap-soundness, signal-caught trap → `{:error,reason}`, host +scheduler resumed unchanged) — is the stated trust boundary. It sits in the theorem's +TYPE as the `(rt : FaithfulRuntime)` binder (gate-legal: a record, not a `postulate`), and +is **explicitly out of scope** for in-Agda discharge. Discharging it via WasmCert-Coq +trap-soundness + a wasmtime signal-handling model is the remaining obligation (see "Not +yet proven"). **SEC-1 is therefore proven-modulo-this-explicit-TCB, and the TCB itself is +not yet machine-verified — the honest Safer-not-Safe gap.** + +Banned-token scan clean (the only `postulate` string is a design-rule comment); zero `?` +holes; `run`/`survives` are structurally recursive on the `Nat` fuel (native termination, +no `TERMINATING` pragma). Gated via `just proof-check-agda-snif` (included in +`proof-check-all`; orchestrator wires the Justfile target — not edited here). + +## SEC-1 — audit caveats (2026-06-16): F1/F2 RESOLVED in SNIFs 2; F3/F4/F5 standing precision notes + +An adversarial proof-skeptic pass (`agda --safe --without-K`, exit 0 — the proofs are **sound**) +originally found the prose over-claiming in five ways (F1–F5). **F1 and F2 have since been fixed, +and the fixes mutation-confirmed load-bearing** by a 4-skeptic re-audit (2026-06-16); **F3/F4/F5** +remain as accurate *precision* caveats (they bound what the prose may claim, not soundness). + +- **F1 — RESOLVED (SNIFs 2): confidentiality is now wired into the operational theorem.** + `SnifIsolation.agda` imports `observe`/`faulted` from `SnifVerdict`; its `Step.trapped : S → Step` + carries the guest **secret**; `FaithfulRuntime.redact : S → R` is the sole secret→public channel; and + the run's fault verdict is `trap (guestFault (redact s))`, shown to factor through `observe` + (`fault-via-observe`). Operational deniability is re-derived over the actual run (`run-deniable`): + two faults whose secrets redact equally are host-indistinguishable. **Mutation-confirmed non-vacuous:** + making `redact` injective (lossless) makes `SecretWitness.secrets-indistinguishable` fail to typecheck + (`one ≠ two`) — the lossy redaction is genuinely load-bearing. +- **F2 / MODEL-1 — RESOLVED (SNIFs 2): the verdict models the real outcome taxonomy.** The host reason + is now `TrapOrigin` = `guestFault` (`:trap`) ⊕ `hostBudget` (`:fuel_exhausted` / `:timeout`) ⊕ + `preExec` (`:load` / `:no_such_export` / `:pool`), and a `call` front-end models the three + pre-execution origins the old run-only model could not express — covering all six `SnifDemo.Snif` + `error_reason` origins; `PreExecWitness` exercises the pre-exec path. **Mutation-confirmed:** deleting + the `preFail` branch of `survives` is rejected (`[CoverageIssue]`); retagging the trapped clause + `guestFault`→`hostBudget` is rejected. +- **F3 — `dichotomy`/`noForgery` are structural, not runtime (standing, accurate).** `okOrTrap` and + `noForgery` typecheck with *no* `FaithfulRuntime` in scope (re-confirmed: standalone lemmas taking no + `rt` argument accept); only `hostSafe`/`survives` consumes the TCB. The load-bearing operational + content of SEC-1 is **host preservation**, not the ok|trap split (true for any 2-constructor type). +- **F4 — `noForgery` is parametric, not instance-level (standing, accurate).** It proves no total + `∀{A R}. Verdict A R → A` exists; the instance-level discharge is rejected (`A !=< ⊥`). Sound, + labelled precisely. +- **F5 — `Alive` faithfulness remains TCB; vacuity now rebutted by a witness (SNIFs 2).** `PartialAlive` + exhibits a *non-trivial* liveness (`Alive? dead = ⊥`; mutation-confirmed — making it `⊤` breaks + `dead-not-alive`) whose `recover` maps a dying host to a live one. This rebuts the *vacuity* worry, but + `Alive`'s faithfulness to wasmtime/wasmex is still part of the TCB: `survives` *transports* a liveness, + it does not *establish* the real one. + +**Positive control (F6):** `step-trap-host` *is* load-bearing (mutating `survives`' trap branch to bare +`alive` is rejected: `h != recover rt h`); `perfect-deniable`'s constant redaction *is* load-bearing. +SEC-1 is **not vacuous** — host-preservation-across-`recover` + the structural ok|trap split + +operational deniability + the origin taxonomy, all *modulo* the `Alive`/`FaithfulRuntime` TCB. A +2026-06-16 mutation re-audit (4 independent skeptics, every targeted mutation rejected as expected) +confirmed all of the above; the only weakness found was stale prose, now corrected here. + +## Scaffold (NOT counted, NOT gated) + +`verification/proofs/{agda,coq,tlaplus}/` contain **unmodified rsr-template stubs** (a +toy list/Nat Agda lemma, a toy Nat/Bool Coq soundness proof, a generic TLA+ pipeline) — +they have nothing to do with SNIF and are excluded from `proof-check-all`. Each now +carries a `SCAFFOLD — NOT A SNIF PROOF` banner. They are kept as homes for real future +obligations (e.g. the Coq slot → the WasmCert-Coq isolation theorem). + +## Not yet proven (the real gap / next obligations) + +| ID | Proof needed | Category | Prover | Notes | +|----|--------------|----------|--------|-------| +| SEC-1-TCB | Discharge **"wasmtime ⊨ FaithfulRuntime"** in-prover: prove that wasmtime + the wasmex embedding actually realise the primitive single-step facts (WASM trap-soundness; trap → `{:error,_}`; host scheduler resumed unchanged) that `SnifIsolation.agda` assumes as its TCB record | SEC | Coq (WasmCert-Coq) | SEC-1's **operational** layer is now PROVEN-MODULO-EXPLICIT-TCB in Agda (`Model.isolation`). This row is the REMAINING half: machine-verifying the runtime-faithfulness assumption that is currently in the theorem's *type* but not proven. | +| ABI-6 | Buffer/array marshalling round-trip + in-bounds (unblocks "powerful" NIFs; FFT) | ABI | Idris2 | `Compliance.WasmArray*` is only a framework today | +| ABI-7 | ◐ **Coverage 15 of 20 — buffer_abi DONE 2026-06-16.** `safe_nif` (8, `Foreign.idr`) + `buffer_abi` (7, `BufferAbi.idr`, incl. 3 void returns) are modelled+gated, both in the `abi_conformance.py` guest manifest. **Remaining (ledgered):** `zig/src/burble_fft.zig` (5 — `fft`/`ifft`/… use `(ptr,len)` slice marshalling = ABI-6, and it is not built into any artifact) + the Rust buffer guest | ABI | Idris2 + Python | Buffer guest closed; FFT + Rust pending the multi-language buffer-ABI gate | +| GAP-1b | ◐ **Behaviour faithfulness — scalar kernels DONE 2026-06-16.** `demo/test/snif_metamorphic_test.exs` (dep-free, 9 metamorphic tests, green on OTP 25). **Load-bearing relations (do not delete):** the `fibonacci` recurrence n=2..40 + base cases (uniquely determines fib), and the `checked_add` `wrap32` oracle over a 100-case boundary-spanning family + the boundary-wrap test. Buffer kernels (`sum_f32` permutation/additivity) are next | TP | metamorphic tests (extraction long-term) | Surfaced a finding: `checked_add` is a **misnomer** — it is wrapping (`a +% b`), not trapping (the trapping demo is `crash_overflow`); doc-comment corrected | +| CI-1 | ✅ **DONE 2026-06-16**: `abi-conformance` now runs as a CI job in `.github/workflows/proofs.yml` (builds both guests, fails on signature drift) | ABI | CI wiring | Making it a *required* status check is the owner-only branch-protection step | +| MODEL-1 | ◐ **Largely RESOLVED by F2 (SNIFs 2).** The 6 `error_reason` origins are now modelled via `TrapOrigin` (guestFault/hostBudget/preExec) + the `call` front-end in `SnifIsolation`. **Residual:** the `snif_alloc`-returns-0 OOM "third outcome" is not yet modelled as a distinct verdict | TP | Agda | Taxonomy half done; only the OOM-sentinel nuance remains | ## Changelog | Date | Change | By | |------|--------|-----| | 2026-04-04 | Initial proof status tracking | Template | -| 2026-04-16 | All 7 proofs completed (ABI-1 through ABI-5, TP-1, TP-2) | Claude Code | +| 2026-04-16 | (Claimed) all 7 proofs complete — see History correction; not actually compiling | Claude Code | +| 2026-06-16 | All 7 proofs **genuinely** machine-checked (clean-cache); real gate wired; scaffold de-counted; scope honesty added | Claude Opus 4.8 | +| 2026-06-16 | **SEC-1 (`Model.isolation`) PROVEN-MODULO-EXPLICIT-TCB** in Agda (`SnifIsolation.agda`, `--safe --without-K`, clean-cache exit 0); TCB = `FaithfulRuntime` record hypothesis (primitive single-step facts), non-circularity + non-vacuity confirmed by mutation; WASM-opsem discharge ("wasmtime ⊨ FaithfulRuntime") remains as SEC-1-TCB | Claude Opus 4.8 (1M context) | +| 2026-06-16 | **SNIFs 2.** SEC-1 sharpened: F1 deniability wired operationally (`run-deniable`/`fault-via-observe` + `SecretWitness`), F2 6-origin `TrapOrigin` taxonomy + `call` front-end (`PreExecWitness`), F5 non-trivial-`Alive` recovery witness (`PartialAlive`) — all mutation-confirmed load-bearing by a 4-skeptic re-audit. ABI-7: `buffer_abi` modelled+gated (`BufferAbi.idr`, 15/20 sites); guest-aware `abi_conformance.py`; CI-1 conformance job added; GAP-1b scalar metamorphic gate (found + corrected the `checked_add` misnomer). | Claude Opus 4.8 (1M context) | diff --git a/README.adoc b/README.adoc index dbef210..a10b8f5 100644 --- a/README.adoc +++ b/README.adoc @@ -2,7 +2,7 @@ // Copyright (c) Jonathan D.A. Jewell // SPDX-FileCopyrightText: 2025-2026 Jonathan D.A. Jewell -= SNIFs: Safe Native Implemented Functions for the BEAM via WebAssembly Sandboxing += SNIFs: Safer Native Implemented Functions for the BEAM via WebAssembly Sandboxing image:https://img.shields.io/badge/OpenSSF-Best_Practices-green?logo=opensourcesecurity[OpenSSF Best Practices,link="https://www.bestpractices.dev/en/projects/new?repo_url=https://github.com/hyperpolymath/snifs"] image:https://img.shields.io/badge/License-MPL_2.0-blue.svg[License: MPL-2.0,link="https://opensource.org/licenses/MPL-2.0"] @@ -24,20 +24,120 @@ image:https://img.shields.io/badge/license-PMPL--1.0--or--later-blue[License] == Overview -SNIFs (Safe NIFs) demonstrates that WebAssembly sandboxing provides genuine -crash isolation for BEAM NIFs. Any crash in a NIF normally kills the entire -BEAM VM. This project shows that compiling native code (Zig) to WebAssembly -and loading it through `wasmex` converts all guest faults into -`{:error, reason}` tuples — the BEAM process survives unconditionally. +SNIFs — *Safer (sandboxed) NIFs* — give the BEAM crash-isolated native interfaces. +A normal NIF runs native code *inside* the VM's address space, so any fault in it +(out-of-bounds access, overflow, `@panic`, …) kills the entire BEAM VM. A SNIF keeps +the NIF's *interface* (you call a native-implemented function and get a value) but +changes its *implementation*: the guest is compiled to WebAssembly (Zig and Rust today) +and run in a `wasmtime` sandbox via `wasmex`, so a guest fault becomes a catchable +`{:error, reason}` tuple and the calling BEAM process survives. -== Key result +It is a like-for-like replacement for the dominant kind of NIF — *pure native computation +over numbers and byte-buffers* (crypto, compression, parsing, DSP, math kernels) — with two +deliberate interface changes (the result is fallible, and only flat numeric data crosses the +boundary; see <>). It is *not* a replacement for the full `erl_nif` surface (BEAM +terms, resources, monitors, message-sending, dirty scheduling, zero-copy binaries, I/O). + +[[scope]] +== Scope: what it replaces (and what it doesn't) + +A SNIF preserves the NIF *interface* and sandboxes the *implementation*. That makes it a +genuine drop-in for one class of NIF, with clearly-stated edges: + +*Like-for-like (plus crash isolation):* native compute/buffer functions — the bread-and-butter +NIF. Same call site, same native-speed class, plus isolation. + +*Two deliberate interface changes* (so it is not a 100% transparent swap): + +* The result is *fallible*: `{:ok, [values]} | {:error, reason}`. A normal NIF returns the value + directly or raises; here the isolation is made visible and the caller handles it. +* Only *numbers and byte-buffers* cross. A normal NIF receives actual BEAM terms and can read + binaries zero-copy; a SNIF sees only `i32/i64/f32/f64` and bytes you marshal into linear memory. + +*Out of scope* (a SNIF does none of this — use a real NIF or a Port): BEAM-term manipulation, +`enif_make_resource`/monitors, sending messages into the BEAM, dirty-scheduler integration/ +yielding, zero-copy term access, and any I/O (the guest is `wasm32-freestanding`, no WASI — ADR-002). + +So *"Safer NIFs"* is precise for *crash-isolated native compute functions*, with the scope above; +read unqualified as "a safer version of every NIF," it would overclaim. + +*Scope ceiling (by design).* SNIF stops at NIF-parity + isolation — it *adapts and perfects the +NIF*, it does not extend past it. Graduated/tunable integration, capability negotiation, +transaction-gating and mode-dependent permissions are deliberately **not** SNIF features; they +belong to the *cleave* (the implementation of graduated integration) and *groove* (the new +inter-service protocol). If a good idea takes SNIF past the NIF goal, it is routed there and +considered there, not added here. -Formal verification: -7/7 core proofs completed (Idris2 + Lean4), covering ABI correctness and API type safety. +== Overhead: compute vs dispatch vs marshalling + +A SNIF costs more than a raw NIF (a direct in-VM call) but far less than a Port (an OS-process +round-trip). The cost decomposes into three independent parts; which one matters depends on the +workload: + +[cols="1,4",options="header"] +|=== +| Source | Shape & cost +| *Dispatch* (per call) | Fixed ~29 µs (median, pooled): the BEAM→sandbox boundary crossing. Dominates *tiny, high-frequency* calls; amortizes to nothing once a call does real work. +| *Compute* | Multiplicative ~1.1–1.5× native (estimate, not yet measured for our kernels): the JIT'd, bounds-checked WASM running the actual work. The only *unamortizable* tax; dominates *long/heavy* calls. Reducible via SIMD/AOT and, ultimately, proving bounds-safety to drop runtime checks. +| *Marshalling* | ∝ bytes: copying buffers into/out of linear memory. Dominates *data-heavy* calls; mitigated by keeping buffers resident across calls. +|=== + +Measured in-BEAM (OTP 25, `fibonacci(20)`, n=2000; `mix run bench/snif_bench.exs`): + +---- +case mean_µs p50_µs p99_µs +per-call (compile every call) 3978.21 3571.00 10594.00 <- the naive path +pooled (compile once) 70.46 29.00 500.00 <- 56x faster +buffer round-trip (sum_f32) 20.14 15.00 71.00 +Port (OS process, isolated) 1617.36 1445.00 4483.00 <- the other isolated option +---- + +So *pooled SNIF is ~23× cheaper than a Port* for the same crash isolation, and the "slow SNIF" +worry (~4 ms) is purely the naive compile-every-call path — pooling removes it. For a NIF doing +*real* work the bridge tax is a flat ~29 µs plus a modest compute multiplier; for a tiny op in a +hot loop the ~29 µs floor dominates (batch such calls). + +== Key result -11/11 integration tests pass on OTP 28.3 / Elixir 1.19.4 (verified estate-wide via `bofj-kitt/static-analysis-gate` runs that successfully load Hex 2.4.2 on this combo). The OTP 27.3 / Elixir 1.18.0 combo also passes. The earlier-flagged OTP 28 + Elixir 1.18 incompatibility (Hex `bs_add` opcode) is specific to that Elixir minor — Elixir 1.19's Hex archive is unaffected. Every failure mode -(OOB array access, `unreachable`, `@panic`, integer overflow, divide-by-zero) -traps correctly under `ReleaseSafe` compilation. The BEAM survives all of them. +*Formal verification:* 10 proofs machine-checked and CI-gated (`just proof-check-all`) — 6 Idris2 +ABI proofs (incl. the buffer-guest ABI), 1 Idris2 core-types proof, 1 Lean4 API proof, and 2 Agda +proofs: the `SnifVerdict` echo×epistemic safety bridge (crash-isolation dichotomy, non-forgery, +restricted deniability) *and* `SnifIsolation` / *SEC-1* — the operational crash-isolation theorem +(host survives every outcome; deniability re-derived over the run; a 6-origin trap taxonomy), +proven-modulo-an-explicit-runtime-TCB. *The ABI/verdict proofs verify a logical model of the +interface; SEC-1 adds the operational layer modulo that TCB* — see <>. + +*In-BEAM tests:* 21/21 ExUnit pass (verified on OTP 25 / Elixir 1.18.4 — the precompiled `wasmex` +`nif-2.15` loads fine): crash isolation across every failure mode (OOB, `@panic`, overflow, +divide-by-zero → `{:error, _}`, caller process alive after each), the worker pool (isolation, +no-shared-state, concurrency, Store recycling), a fuel-based liveness/DoS guard, and Zig + +Rust→wasm32 guests at parity. Every failure mode traps correctly under `ReleaseSafe`; `ReleaseFast` +turns the same faults into silent wrong answers (the negative control). + +[[honesty]] +== Honesty: what is proven, what is tested, what is trusted + +The safety story is *layered*, and the name "Safer" (not "Safe") encodes the top of it: + +* *Proven (machine-checked):* the 10 proofs verify a *logical model* — the ABI layout, the + result-type algebra, and the verdict's structural properties (ok⊕trap; a trap yields no value; + the residue is deniable). A proof guarantees the *model* is internally consistent. +* *The model↔code gap (NOT proven):* that the model faithfully mirrors the actual Zig/Rust/wasmex + code is argued by construction and established *empirically by the tests* — it is not itself + machine-verified. Closing it fully would need extraction or a verified toolchain. +* *Tested (empirical):* the operational claim — *guest trap ⇒ `{:error,_}` ∧ the BEAM survives* — + is demonstrated by the in-BEAM tests, and is now **mechanically proven** (modulo an explicit, + minimal runtime TCB) in `verification/proofs/agda/SnifIsolation.agda` (`agda --safe --without-K`, + gated) — over a small-step host↔guest model, deriving whole-run isolation by induction from + per-step primitives. The residual TCB ("`wasmtime` ⊨ `FaithfulRuntime`") is the WasmCert-Coq + discharge that remains; this is a *gap-2* (runtime-trust) result and does **not** close *gap 1* + (that the Agda/Idris model faithfully mirrors the actual code). +* *Trusted (TCB):* `wasmtime`/`wasmex` correctness, and that a returning NIF does not corrupt the + BEAM scheduler. + +Per `AUDIT.adoc`, claims here must not outrun the proofs: *"Safer"* means *crash-isolated by a +sandbox whose isolation is tested and model-verified, trusting a stated runtime TCB* — not +"mathematically proven safe end-to-end." == Critical invariant @@ -63,7 +163,7 @@ mix deps.get mix test ---- -Requires Elixir 1.15+, OTP 26+ (precompiled wasmex NIF downloaded automatically). The OTP 28 + Elixir 1.18 combination is unsupported (the Elixir-1.18-specific Hex archive fails to load on OTP 28 with `bs_add` BEAM bytecode errors), but OTP 28.3 + Elixir 1.19.4 works fine. Either OTP 27.3 + Elixir 1.18 or OTP 28.3 + Elixir 1.19 is a known-good combo. +Requires Elixir 1.15+, *OTP 25+* (the precompiled `wasmex` 0.14 `nif-2.15` artifact loads on OTP 25 through 28 — verified on OTP 25 / Elixir 1.18.4; the earlier "OTP 26+" floor was conservative). Pooled use is wired via the application supervisor (`SnifDemo.Application`); the legacy per-call `Loader` needs no pool. == Rebuilding the WASM @@ -93,7 +193,7 @@ If you use this work, please cite: ---- @misc{jewell2026snifs, author = {Jewell, Jonathan D. A.}, - title = {SNIFs: Safe Native Implemented Functions for the BEAM via WebAssembly Sandboxing}, + title = {SNIFs: Safer Native Implemented Functions for the BEAM via WebAssembly Sandboxing}, year = {2026}, doi = {10.5281/zenodo.19520245}, publisher = {Zenodo} diff --git a/benches/assert_safer.py b/benches/assert_safer.py new file mode 100644 index 0000000..b6fb818 --- /dev/null +++ b/benches/assert_safer.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +# +# assert_safer.py — the DISCRIMINATING assertions over snif_eval.sh output. +# +# The point of the SNIF evaluation is NOT that "everything passes". A trivially +# passing harness (e.g. one that only ran ReleaseSafe) would prove nothing. The +# "Safer" claim is substantiated ONLY if the SAME crash mode behaves DIFFERENTLY +# between ReleaseSafe and ReleaseFast: Safe must TRAP (catchable isolation) where +# Fast SILENTLY CORRUPTS (exit 0 with a wrong value). This script asserts that +# discrimination and FAILS LOUD if a mode collapses to "both pass" — which would +# mean the anti-property (silent corruption) was not actually exercised. +# +# Usage: benches/snif_eval.sh | benches/assert_safer.py +# Exit 0 = all discriminating assertions hold; non-zero = a property failed. +import json +import sys + +# Expected per-mode discrimination, derived from the verified Zig semantics: +# silent -> Fast must NOT trap and must return a SPECIFIC wrong value +# always -> both modes trap (UB is unconditional, e.g. @panic) +# nofault -> neither traps (the runtime guard is never hit) — must NOT be +# mis-asserted as a trap by callers (this is the stale-test trap) +EXPECT = { + "crash_oob": {"class": "silent", "fast_value": "195948557"}, # 0x0BADF00D + "crash_overflow": {"class": "silent", "fast_value": "-2147483648"}, # i32 wrap + "crash_div_zero": {"class": "silent", "fast_value": "0"}, # op elided + "crash_panic": {"class": "always"}, # @panic + "crash_unreachable":{"class": "always"}, # OA-2(b): now traps +} + +def fail(msg): + print(f"ASSERT-FAIL: {msg}", file=sys.stderr) + fail.count += 1 +fail.count = 0 + +def ok(msg): + print(f" ok: {msg}", file=sys.stderr) + +data = json.load(sys.stdin) +rows = {r.get("fn", r.get("kind")): r for r in data["rows"]} + +print("== SNIF discriminating assertions ==", file=sys.stderr) + +# (A) crash-mode discrimination +for fn, exp in EXPECT.items(): + r = next((x for x in data["rows"] if x.get("fn") == fn), None) + if r is None: + fail(f"{fn}: no row produced") + continue + st, ft = r["safe_trap"], r["fast_trap"] + so, fo = r.get("safe_out"), r.get("fast_out") + cls = exp["class"] + if cls == "silent": + # The CORE anti-property: Safe traps, Fast does NOT and returns the wrong value. + if st != "yes": + fail(f"{fn}: ReleaseSafe MUST trap, got trap={st}") + elif ft != "no": + fail(f"{fn}: ReleaseFast must NOT trap (silent corruption), got trap={ft}") + elif fo != exp["fast_value"]: + fail(f"{fn}: ReleaseFast wrong-value mismatch: expected {exp['fast_value']}, got {fo}") + else: + ok(f"{fn}: DISCRIMINATED — Safe traps, Fast silently returns {fo}") + elif cls == "always": + if st == "yes" and ft == "yes": + ok(f"{fn}: both modes trap (unconditional UB)") + else: + fail(f"{fn}: expected trap in BOTH modes, got safe={st} fast={ft}") + elif cls == "nofault": + if st == "no" and ft == "no": + ok(f"{fn}: neither traps (guard never hit) — callers must NOT assert a trap here") + else: + fail(f"{fn}: expected NO trap in either mode, got safe={st} fast={ft}") + +# anti-trivial meta-assertion: at least one mode must have shown the silent +# anti-property, else the eval proved nothing about ReleaseSafe's value. +silent_seen = any( + (next((x for x in data["rows"] if x.get("fn") == fn), {}) or {}).get("fast_trap") == "no" + and EXPECT[fn]["class"] == "silent" + for fn in EXPECT +) +if silent_seen: + ok("anti-trivial: at least one silent-corruption case was exercised") +else: + fail("NO silent-corruption case was exercised — eval is vacuous") + +# (B) control: still_alive == 42 in both modes +ctl = next((x for x in data["rows"] if x.get("fn") == "still_alive"), None) +if ctl and ctl.get("safe_out") == "42" and ctl.get("fast_out") == "42": + ok("control still_alive()==42 in both modes (no false-trap)") +else: + fail(f"control still_alive mismatch: {ctl}") + +# (C) liveness guard: low fuel traps, high fuel completes +lv = next((x for x in data["rows"] if x.get("kind") == "liveness"), None) +if lv and lv.get("low_fuel_trap") == "yes" and lv.get("high_fuel_out"): + ok(f"liveness: fuel guard traps a heavy guest at low fuel; completes at high fuel ({lv['high_fuel_out']})") +else: + fail(f"liveness guard not demonstrated: {lv}") + +# (D) Rust guest parity: buffer ABI present + scalar traps match Zig +rp = next((x for x in data["rows"] if x.get("kind") == "rust_parity"), None) +if rp is None: + fail("no rust_parity row") +elif rp.get("skipped"): + ok(f"rust_parity skipped ({rp['skipped']}) — portable degrade, not a failure") +else: + if rp.get("buffer_abi_exports") == "yes": + ok("Rust guest exports the (ptr,len) buffer ABI the Zig slice-ABI cannot") + else: + fail("Rust guest missing buffer-ABI exports") + if rp.get("crash_overflow_trap") == "yes" and rp.get("crash_panic_trap") == "yes": + ok("Rust scalar traps match Zig (crash_overflow, crash_panic both trap)") + else: + fail(f"Rust scalar-trap parity broken: {rp}") + if rp.get("fibonacci10") == "55": + ok("Rust fibonacci(10)==55 (functional parity)") + else: + fail(f"Rust fibonacci parity broken: {rp.get('fibonacci10')}") + +# (E) buffer-case crash isolation: an over-range (ptr,len) read MUST trap +bi = next((x for x in data["rows"] if x.get("kind") == "buffer_isolation"), None) +if bi is None: + fail("no buffer_isolation row") +elif bi.get("skipped"): + ok(f"buffer_isolation skipped ({bi['skipped']}) — portable degrade, not a failure") +elif bi.get("trap") == "yes": + ok("buffer case: over-range read traps (wasm memory bounds) — isolation holds for buffer NIFs") +else: + fail(f"buffer over-read did NOT trap: {bi}") + +print(f"\n{fail.count} assertion failure(s)", file=sys.stderr) +sys.exit(1 if fail.count else 0) diff --git a/benches/snif_eval.sh b/benches/snif_eval.sh new file mode 100644 index 0000000..a9ad45c --- /dev/null +++ b/benches/snif_eval.sh @@ -0,0 +1,306 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +# +# snif_eval.sh — self-contained, language-agnostic SNIF EVALUATION harness. +# +# Substantiates the "Safer" claim WITHOUT the BEAM, using the wasmtime 44 CLI as +# the host. This is the part of the methodology that RUNS in the constrained env +# (OTP 25): wasmtime is the same wasmtime that wasmex 0.14 embeds, so the +# trap-vs-silent-return discrimination measured here is the SAME mechanism the +# BEAM relies on for crash isolation. +# +# This harness is SELF-BUILDING: it compiles zig/src/safe_nif.zig to +# wasm32-freestanding in BOTH -OReleaseSafe and -OReleaseFast into a temp dir +# under benches/ (NOT priv/ — it does not touch the deployable artifacts), then +# drives every crash mode + control through wasmtime and prints a results table. +# It depends on NOTHING pre-built: just zig, wasmtime, wasm-tools on PATH. +# +# ── The load-bearing methodological point ──────────────────────────────────── +# "Safer" is substantiated by DISCRIMINATION between ReleaseSafe and ReleaseFast +# on the SAME crash mode, NOT by everything passing. ReleaseSafe must TRAP where +# ReleaseFast SILENTLY CORRUPTS. A harness in which every row "passes" would be +# worthless; the silent-corruption anti-property MUST be exercised and observed. +# +# ── GROUND-TRUTH primitives (all verified against zig 0.15.2 + wasmtime 44.0.1) +# trap -> stderr contains "wasm trap", process exit 134, NO stdout value +# silent-bug -> exit 0, a stdout value that is WRONG (the anti-property) +# fuel guard -> wasmtime run -W fuel=N => "all fuel consumed" trap +# +# ── Outputs ────────────────────────────────────────────────────────────────── +# stdout : a single machine-readable JSON document (schema snif-eval/1) +# stderr : a human-readable results table +# files : benches/eval_tmp/*.wasm (built artifacts), benches/eval_results.json +# +# NB: parts that need the BEAM scheduler / wasmex marshalling — process-survival +# witness (Process.alive? after a trap), buffer round-trip THROUGH wasmex, the +# Port comparison, pooled-vs-per-call overhead — are NOT produced here. They need +# OTP 28.3 / Elixir 1.19.4 and live in demo/bench/*.exs. They are flagged [OTP28] +# in the table's notes and reported as blockers, never silently claimed. +set -uo pipefail + +# ── locations ───────────────────────────────────────────────────────────────── +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO="$(cd "$HERE/.." && pwd)" +SRC="$REPO/zig/src/safe_nif.zig" +TMP="$HERE/eval_tmp" # built wasm goes here, NOT priv/ +SAFE="$TMP/safe_nif_ReleaseSafe.wasm" +FAST="$TMP/safe_nif_ReleaseFast.wasm" +JSON_OUT="$HERE/eval_results.json" # machine-readable result snapshot +RUNS="${RUNS:-30}" # hyperfine sample count for the overhead row +WARMUP="${WARMUP:-3}" + +# The export surface of safe_nif.zig (must match the source's `export fn`s). +EXPORTS=(fibonacci checked_add crash_oob crash_unreachable crash_panic + crash_overflow crash_div_zero still_alive) + +# ── preflight: tools + source ──────────────────────────────────────────────── +need() { command -v "$1" >/dev/null || { echo "FATAL: '$1' not on PATH" >&2; exit 2; }; } +need zig +need wasmtime +need wasm-tools +[ -f "$SRC" ] || { echo "FATAL: source not found: $SRC" >&2; exit 2; } +HAVE_HF=0; command -v hyperfine >/dev/null && HAVE_HF=1 +HAVE_JQ=0; command -v jq >/dev/null && HAVE_JQ=1 + +mkdir -p "$TMP" + +# ── (1) BUILD: zig -> wasm32-freestanding, ReleaseSafe AND ReleaseFast ───────── +# INVARIANT (owner directive): the deployable mode is -OReleaseSafe; ReleaseFast +# is built ONLY as the negative control that demonstrates silent corruption. +build_mode() { # + local mode="$1" out="$2" exargs=() + local e; for e in "${EXPORTS[@]}"; do exargs+=("--export=$e"); done + zig build-exe -fno-entry -O "$mode" -target wasm32-freestanding \ + "${exargs[@]}" -femit-bin="$out" "$SRC" +} +echo "[build] zig 0.15.x -> wasm32-freestanding (ReleaseSafe + ReleaseFast)" >&2 +build_mode ReleaseSafe "$SAFE" || { echo "FATAL: ReleaseSafe build failed" >&2; exit 3; } +build_mode ReleaseFast "$FAST" || { echo "FATAL: ReleaseFast build failed" >&2; exit 3; } +wasm-tools validate "$SAFE" || { echo "FATAL: ReleaseSafe wasm invalid" >&2; exit 3; } +wasm-tools validate "$FAST" || { echo "FATAL: ReleaseFast wasm invalid" >&2; exit 3; } +ZIG_VER="$(zig version 2>/dev/null)" +WT_VER="$(wasmtime --version 2>/dev/null | awk '{print $2}')" +echo "[build] OK Safe=$(wc -c <"$SAFE")B Fast=$(wc -c <"$FAST")B zig=$ZIG_VER wasmtime=$WT_VER" >&2 + +# ── invoke helpers ─────────────────────────────────────────────────────────── +# invoke [args...] -> sets globals G_OUT G_RC G_TRAP +invoke() { + local wasm="$1" fn="$2"; shift 2 + local err; err="$(mktemp)" + G_OUT="$(wasmtime run --invoke "$fn" "$wasm" "$@" 2>"$err")"; G_RC=$? + G_TRAP=no; grep -q 'wasm trap' "$err" && G_TRAP=yes + rm -f "$err" +} +# invoke_fuel [args...] -> sets G_OUT G_RC G_FUELTRAP +invoke_fuel() { + local wasm="$1" fuel="$2" fn="$3"; shift 3 + local err; err="$(mktemp)" + G_OUT="$(wasmtime run -W fuel="$fuel" --invoke "$fn" "$wasm" "$@" 2>"$err")"; G_RC=$? + G_FUELTRAP=no; grep -q 'all fuel consumed' "$err" && G_FUELTRAP=yes + rm -f "$err" +} + +# json string-or-null helper +jstr() { [ -z "${1:-}" ] && printf 'null' || printf '"%s"' "$1"; } + +json_rows=() +add_row() { json_rows+=("$1"); } + +# ── (2) crash-isolation × ReleaseSafe-vs-ReleaseFast ────────────────────────── +# For each crash mode, record BOTH modes. The DISCRIMINATING fact is the triple +# (safe_trap, fast_trap, fast_value): a real isolation property shows Safe traps +# where Fast silently corrupts. Expected classes (ground-truthed from the source +# semantics, encoded so the table is self-documenting): +# silent : Safe traps, Fast returns a SPECIFIC wrong value (the anti-property) +# always : both modes trap (unconditional @panic) +# nofault : neither traps (UB guard never fires at runtime_index=3) +declare -A EXPECT=( + [crash_oob]=silent # Safe trap; Fast -> 195948557 (0x0BADF00D canary) + [crash_overflow]=silent # Safe trap; Fast -> -2147483648 (wrap) + [crash_div_zero]=silent # Safe trap; Fast -> 0 (op removed) + [crash_panic]=always # both trap (unconditional @panic) + [crash_unreachable]=always # OA-2(b): unconditional unreachable -> traps in BOTH modes +) +MODES=(crash_oob crash_overflow crash_div_zero crash_panic crash_unreachable) +silent_seen=0 +for fn in "${MODES[@]}"; do + invoke "$SAFE" "$fn"; safe_trap=$G_TRAP; safe_rc=$G_RC; safe_out="$G_OUT" + invoke "$FAST" "$fn"; fast_trap=$G_TRAP; fast_rc=$G_RC; fast_out="$G_OUT" + exp="${EXPECT[$fn]}" + [ "$exp" = silent ] && [ "$safe_trap" = yes ] && [ "$fast_trap" = no ] && silent_seen=$((silent_seen+1)) + add_row "{\"kind\":\"crash\",\"fn\":\"$fn\",\"expect\":\"$exp\",\"safe_trap\":\"$safe_trap\",\"safe_exit\":$safe_rc,\"safe_out\":$(jstr "$safe_out"),\"fast_trap\":\"$fast_trap\",\"fast_exit\":$fast_rc,\"fast_out\":$(jstr "$fast_out")}" +done + +# ANTI-TRIVIAL meta-fact: at least one silent-corruption case must have actually +# been exercised (Safe trap + Fast no-trap). If silent_seen==0 the harness has +# degenerated and proves nothing. +add_row "{\"kind\":\"meta\",\"silent_corruption_cases_observed\":$silent_seen,\"anti_trivial_ok\":$([ "$silent_seen" -ge 1 ] && echo true || echo false)}" + +# ── (3) controls: correctness must hold in BOTH modes ───────────────────────── +invoke "$SAFE" still_alive; sa_safe="$G_OUT" +invoke "$FAST" still_alive; sa_fast="$G_OUT" +add_row "{\"kind\":\"control\",\"fn\":\"still_alive\",\"safe_out\":$(jstr "$sa_safe"),\"fast_out\":$(jstr "$sa_fast"),\"expect\":\"42\"}" + +invoke "$SAFE" fibonacci 20; fib_safe="$G_OUT" +invoke "$FAST" fibonacci 20; fib_fast="$G_OUT" +add_row "{\"kind\":\"control\",\"fn\":\"fibonacci(20)\",\"safe_out\":$(jstr "$fib_safe"),\"fast_out\":$(jstr "$fib_fast"),\"expect\":\"6765\"}" + +# checked_add uses wrapping (+%) by design: i32::MAX + 1 wraps in BOTH modes (NOT +# a trap) — this is intentional and documented in the source. Record it so the +# table shows the deliberate wrap rather than implying it is a discrimination row. +invoke "$SAFE" checked_add 2147483647 1; ca_safe="$G_OUT" +invoke "$FAST" checked_add 2147483647 1; ca_fast="$G_OUT" +add_row "{\"kind\":\"control\",\"fn\":\"checked_add(MAX,1)\",\"safe_out\":$(jstr "$ca_safe"),\"fast_out\":$(jstr "$ca_fast"),\"expect\":\"-2147483648 (intentional wrap, both modes)\"}" + +# ── (4) liveness / DoS guard (language-agnostic) ────────────────────────────── +# Too little fuel -> deterministic "all fuel consumed" trap; enough -> result. +# This is the same mechanism Snif.Worker uses (wasmex 0.14 has no epoch API). +invoke_fuel "$SAFE" 100 fibonacci 90; low_trap=$G_FUELTRAP +invoke_fuel "$SAFE" 10000000 fibonacci 90; hi_out="$G_OUT" +add_row "{\"kind\":\"liveness\",\"guard\":\"fuel\",\"low_fuel_trap\":\"$low_trap\",\"high_fuel_out\":$(jstr "$hi_out")}" + +# ── (5) overhead: wasmtime per-call cost (statistical, UPPER bound) ─────────── +# wasmtime-CLI cost = process spawn + module load + instantiate + 1 call. It is +# the CLI analogue of wasmex's ADR-003 per-call instantiation and BRACKETS the +# upper bound of SNIF per-call overhead (real in-VM wasmex omits process spawn). +# The pooled / raw-NIF / Port rows are [OTP28] and produced by demo/bench. +if [ "$HAVE_HF" = 1 ]; then + hf="$(mktemp)" + hyperfine --warmup "$WARMUP" --runs "$RUNS" --export-json "$hf" \ + -n "wasmtime_percall_fib20" \ + "wasmtime run --invoke fibonacci $SAFE 20" >/dev/null 2>&1 + if [ "$HAVE_JQ" = 1 ]; then + mean_ms="$(jq -r '.results[0].mean*1000' "$hf")" + sd_ms="$(jq -r '.results[0].stddev*1000' "$hf")" + min_ms="$(jq -r '.results[0].min*1000' "$hf")" + else + mean_ms="$(python3 -c "import json,sys;print(json.load(open('$hf'))['results'][0]['mean']*1000)")" + sd_ms="$(python3 -c "import json,sys;print(json.load(open('$hf'))['results'][0]['stddev']*1000)")" + min_ms="$(python3 -c "import json,sys;print(json.load(open('$hf'))['results'][0]['min']*1000)")" + fi + rm -f "$hf" + add_row "{\"kind\":\"overhead\",\"case\":\"wasmtime_percall_fib20\",\"runs\":$RUNS,\"mean_ms\":$mean_ms,\"stddev_ms\":$sd_ms,\"min_ms\":$min_ms,\"note\":\"UPPER bound (incl. OS process spawn); pooled/raw-NIF/Port rows are [OTP28]\"}" + OV_MEAN="$mean_ms"; OV_SD="$sd_ms"; OV_MIN="$min_ms" +else + add_row "{\"kind\":\"overhead\",\"case\":\"wasmtime_percall_fib20\",\"error\":\"hyperfine not available\"}" + OV_MEAN="n/a"; OV_SD=""; OV_MIN="" +fi + +# ── (6) Rust→wasm32 guest parity (the verifier-on-by-default guest) ─────────── +# Prefer the pre-built artifact; else run the guest's build script; else SKIP with +# an explicit row so the harness stays portable. +echo "[rust] locating/building Rust→wasm32 guest" >&2 +RUST_WASM="" +if [ -f "$REPO/priv/demo_guest_rust.wasm" ]; then + RUST_WASM="$REPO/priv/demo_guest_rust.wasm" +elif [ -x "$REPO/rust/build-wasm.sh" ]; then + ( cd "$REPO" && bash rust/build-wasm.sh ) >/dev/null 2>&1 && RUST_WASM="$REPO/priv/demo_guest_rust.wasm" +fi +if [ -n "$RUST_WASM" ] && [ -f "$RUST_WASM" ]; then + rexp="$(wasm-tools print "$RUST_WASM" 2>/dev/null | grep -oE '\(export "[^"]+"')" + bufok=no + if echo "$rexp" | grep -q 'snif_alloc' && echo "$rexp" | grep -q 'sum_f32'; then bufok=yes; fi + invoke "$RUST_WASM" crash_overflow; rov=$G_TRAP + invoke "$RUST_WASM" crash_panic; rpn=$G_TRAP + invoke "$RUST_WASM" fibonacci 10; rfib="$G_OUT" + add_row "{\"kind\":\"rust_parity\",\"buffer_abi_exports\":\"$bufok\",\"crash_overflow_trap\":\"$rov\",\"crash_panic_trap\":\"$rpn\",\"fibonacci10\":$(jstr "$rfib")}" + echo "[rust] parity: buffer_abi=$bufok overflow_trap=$rov panic_trap=$rpn fib10=$rfib" >&2 +else + add_row "{\"kind\":\"rust_parity\",\"skipped\":\"rust wasm32 toolchain/artifact unavailable\"}" + echo "[rust] SKIPPED (no wasm32 toolchain/artifact)" >&2 +fi + +# ── (7) Buffer-ABI crash isolation (an over-range read MUST trap) ────────────── +# Prefer the pre-built buffer ABI wasm; else run its build script; else SKIP. +echo "[buffer] locating/building Zig Buffer ABI guest" >&2 +BUF="" +if [ -f "$REPO/zig/buffer_abi_build/buffer_abi_ReleaseSafe.wasm" ]; then + BUF="$REPO/zig/buffer_abi_build/buffer_abi_ReleaseSafe.wasm" +elif [ -x "$REPO/zig/buffer_abi_build.sh" ]; then + ( cd "$REPO" && bash zig/buffer_abi_build.sh ) >/dev/null 2>&1 && BUF="$REPO/zig/buffer_abi_build/buffer_abi_ReleaseSafe.wasm" +fi +if [ -n "$BUF" ] && [ -f "$BUF" ]; then + # Genuine buffer-NIF isolation: a host-supplied over-range (ptr,len) makes the + # f32 loads run past linear memory, so wasmtime's memory bound traps (this holds + # in BOTH modes — the sandbox bound is unconditional, unlike Zig's array checks). + invoke "$BUF" sum_f32 1000000000 4; bitrap=$G_TRAP + add_row "{\"kind\":\"buffer_isolation\",\"trap\":\"$bitrap\",\"probe\":\"sum_f32(ptr=1e9,len=4)\"}" + echo "[buffer] sum_f32 over-range (ptr=1e9) trap=$bitrap" >&2 +else + add_row "{\"kind\":\"buffer_isolation\",\"skipped\":\"buffer_abi.zig artifact unavailable\"}" + echo "[buffer] SKIPPED (no buffer ABI artifact)" >&2 +fi + +# ── emit machine-readable JSON (stdout + snapshot file) ─────────────────────── +emit_json() { + printf '{"schema":"snif-eval/1","zig":"%s","wasmtime":"%s","source":"zig/src/safe_nif.zig","runs":%s,"rows":[' \ + "$ZIG_VER" "$WT_VER" "$RUNS" + local i + for i in "${!json_rows[@]}"; do + [ "$i" -gt 0 ] && printf ',' + printf '%s' "${json_rows[$i]}" + done + printf ']}\n' +} +emit_json | tee "$JSON_OUT" + +# ── human-readable table -> stderr (does not pollute the JSON on stdout) ────── +fmt_num() { printf "%.3f" "$1" 2>/dev/null || printf "%s" "$1"; } +{ + echo + echo "===========================================================================================" + echo " SNIF EVALUATION — language-agnostic (zig $ZIG_VER -> wasm32-freestanding, wasmtime $WT_VER)" + echo " Self-built from zig/src/safe_nif.zig into benches/eval_tmp/ (priv/ untouched)." + echo "===========================================================================================" + echo + echo " (2) CRASH ISOLATION — the discriminating anti-property" + echo " -------------------------------------------------------------------------------------------" + printf " %-18s | %-8s | %-7s | %-13s | %-7s | %-13s\n" "crash mode" "class" "Safe" "Safe out" "Fast" "Fast out" + printf " %-18s | %-8s | %-7s | %-13s | %-7s | %-13s\n" "------------------" "--------" "trap" "------------" "trap" "------------" + for fn in "${MODES[@]}"; do + invoke "$SAFE" "$fn"; st=$G_TRAP; so="${G_OUT:---}" + invoke "$FAST" "$fn"; ft=$G_TRAP; fo="${G_OUT:---}" + printf " %-18s | %-8s | %-7s | %-13s | %-7s | %-13s\n" "$fn" "${EXPECT[$fn]}" "$st" "$so" "$ft" "$fo" + done + echo + echo " READING THE TABLE:" + echo " silent = Safe TRAPS while Fast returns a SPECIFIC wrong value <- this IS the proof" + echo " crash_oob: Fast -> 195948557 (0x0BADF00D canary, OOB read)" + echo " crash_overflow: Fast -> -2147483648 (silent i32 wrap)" + echo " crash_div_zero: Fast -> 0 (div op removed by optimiser)" + echo " always = both modes trap (crash_panic: @panic; crash_unreachable: unconditional" + echo " unreachable — OA-2(b) fixed the prior never-firing 'index==99' guard)" + echo " nofault = neither traps (no export is in this class after OA-2(b))" + echo " anti-trivial: silent-corruption cases actually observed = $silent_seen (must be >= 1)" + echo + echo " (3) CONTROLS — correctness must hold in BOTH modes" + echo " -------------------------------------------------------------------------------------------" + printf " %-22s | %-13s | %-13s | %s\n" "fn" "Safe" "Fast" "expect" + printf " %-22s | %-13s | %-13s | %s\n" "still_alive" "$sa_safe" "$sa_fast" "42" + printf " %-22s | %-13s | %-13s | %s\n" "fibonacci(20)" "$fib_safe" "$fib_fast" "6765" + printf " %-22s | %-13s | %-13s | %s\n" "checked_add(MAX,1)" "$ca_safe" "$ca_fast" "-2147483648 (intentional wrap)" + echo + echo " (4) LIVENESS / DoS GUARD — fuel (the wasmex-0.14 mechanism)" + echo " -------------------------------------------------------------------------------------------" + printf " %-22s | %s\n" "fuel=100, fib(90)" "trap('all fuel consumed') = $low_trap" + printf " %-22s | %s\n" "fuel=1e7, fib(90)" "completes -> $hi_out" + echo + echo " (5) OVERHEAD — wasmtime per-call (UPPER bound; pooled rows are [OTP28])" + echo " -------------------------------------------------------------------------------------------" + if [ "$HAVE_HF" = 1 ]; then + printf " %-22s | mean=%s ms sd=%s ms min=%s ms (n=%s)\n" \ + "wasmtime_percall_fib20" "$(fmt_num "$OV_MEAN")" "$(fmt_num "$OV_SD")" "$(fmt_num "$OV_MIN")" "$RUNS" + echo " note: includes OS process-spawn cost ABSENT from in-VM wasmex; this brackets but" + echo " does NOT equal the real wasmex per-call cost." + else + echo " (hyperfine not available — overhead row skipped)" + fi + echo + echo " [OTP28] — NOT produced here (need OTP 28.3 / Elixir 1.19.4 + wasmex 0.14):" + echo " - BEAM process-survival witness (Process.alive? after a guest trap)" + echo " - buffer round-trip THROUGH wasmex (alloc -> write linear mem -> call -> read)" + echo " - pooled (ADR-004) vs per-call (ADR-003) overhead, Port comparison, raw-NIF baseline" + echo " This env is OTP 25; those rows live in demo/bench/*.exs and run via 'just eval-otp28'." + echo "===========================================================================================" +} >&2 diff --git a/demo/bench/snif_bench.exs b/demo/bench/snif_bench.exs new file mode 100644 index 0000000..9770141 --- /dev/null +++ b/demo/bench/snif_bench.exs @@ -0,0 +1,123 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +# +# snif_bench.exs — the [OTP28] half of the SNIF evaluation. +# +# Produces the rows the wasmtime-CLI harness (benches/snif_eval.sh) CANNOT, +# because they need the real BEAM + wasmex marshalling: +# +# (c) per-call (ADR-003 Loader) vs pooled (ADR-004 Snif.Pool) instantiation +# (d) SNIF (wasmex) vs raw rustler NIF vs OS Port — overhead spectrum +# buffer round-trip: alloc -> write linear memory -> sum_f32 -> read back +# process-survival witness: the caller pid is alive() after every trap +# +# WHY THIS IS NOT RUN IN THIS ENV: the demo targets OTP 28.3 / Elixir 1.19.4. +# This box is OTP 25 and CANNOT run wasmex. Run on the OTP-28 target with: +# +# cd demo && mix deps.get && mix run bench/snif_bench.exs +# +# Timing uses :timer.tc (no extra dep). If :benchee is added it can replace the +# hand-rolled percentiles; the table shape below is benchee-compatible. +# +# The discriminating assertions for THIS file live inline as `expect/3` calls: +# the bench FAILS (System.halt(1)) if the pooled path is not faster than per-call, +# or if any trap kills the caller, or if the buffer round-trip is wrong. + +Code.require_file("support/timing.exs", __DIR__) +alias SnifBench.Timing + +priv = Path.join([__DIR__, "..", "..", "priv"]) +safe = Path.join(priv, "safe_nif_ReleaseSafe.wasm") +rust = Path.join(priv, "demo_guest_rust.wasm") + +n_calls = String.to_integer(System.get_env("N", "2000")) +fib_arg = 20 + +# ── (c) per-call (ADR-003) vs pooled (ADR-004) ──────────────────────────────── +# Per-call: SnifDemo.Loader.call/3 — read+compile+instantiate EACH call. +percall = Timing.measure(n_calls, fn -> + {:ok, [_]} = SnifDemo.Loader.call(safe, "fibonacci", [fib_arg]) +end) + +# Pooled: warm the pool ONCE, then call — compile-once, re-instantiate-per-call. +case Snif.Pool.start_pool(SnifDemo.SafeNif, pool_size: 4) do + {:ok, _} -> :ok + {:error, {:already_started, _}} -> :ok # the app supervisor already started it +end +pooled = Timing.measure(n_calls, fn -> + {:ok, [_]} = Snif.call(SnifDemo.SafeNif, "fibonacci", [fib_arg]) +end) + +# ── buffer round-trip (the audit's "THE enabler" — Rust (ptr,len) ABI) ──────── +# This is the case the wasmtime CLI cannot do: write a payload INTO linear +# memory between calls. Measured + checked for correctness. +{:ok, rpid} = Wasmex.start_link(%{bytes: File.read!(rust)}) +{:ok, store} = Wasmex.store(rpid) +{:ok, mem} = Wasmex.memory(rpid) +payload = [1.0, 2.0, 3.0, 4.0, 5.0] +{:ok, [ptr]} = Wasmex.call_function(rpid, "snif_alloc", [length(payload) * 4]) +Enum.with_index(payload, fn v, i -> + # write the f32 directly as little-endian bytes (wasm32 linear memory is LE). + # (the prior <> round-trip read big-endian then wrote little-endian, + # byte-swapping every float so sum_f32 read garbage.) + Wasmex.Memory.write_binary(store, mem, ptr + i * 4, <>) +end) +buf = Timing.measure(div(n_calls, 10), fn -> + {:ok, [sum]} = Wasmex.call_function(rpid, "sum_f32", [ptr, length(payload)]) + ^sum = 15.0 +end) +{:ok, [sum]} = Wasmex.call_function(rpid, "sum_f32", [ptr, length(payload)]) + +# ── (d) overhead spectrum: SNIF vs Port (raw rustler row is [needs-rustler]) ── +# Port: spawn an OS process that computes fibonacci. Establishes the SLOW end of +# the spectrum (process round-trip) so SNIF's number is bracketed: +# raw rustler NIF (ns, unsandboxed) << SNIF pooled (us) << SNIF per-call << Port (us-ms) +port_row = + case System.find_executable("awk") do + nil -> %{mean_us: nil, note: "no awk; Port row skipped"} + awk -> + Timing.measure(min(n_calls, 200), fn -> + {out, 0} = System.cmd(awk, ["BEGIN{a=0;b=1;for(i=2;i<=#{fib_arg};i++){t=a+b;a=b;b=t};print b}"]) + _ = out + end) + end + +# ── survival witness over every crash mode (the real isolation claim) ───────── +survival = + for fn_name <- ~w(crash_oob crash_panic crash_overflow crash_div_zero) do + {:error, _} = Snif.call(SnifDemo.SafeNif, fn_name, []) + {:ok, [42]} = Snif.call(SnifDemo.SafeNif, "still_alive", []) # caller still serves + self_alive = Process.alive?(self()) + {fn_name, self_alive} + end + +# ── TABLE ───────────────────────────────────────────────────────────────────── +IO.puts("\n== SNIF overhead table (OTP #{System.otp_release()}, n=#{n_calls}) ==") +:io.format("~-28s ~10s ~10s ~10s~n", ["case", "mean_us", "p50_us", "p99_us"]) +for {name, m} <- [ + {"per-call (ADR-003 Loader)", percall}, + {"pooled (ADR-004 Pool)", pooled}, + {"buffer round-trip sum_f32", buf}, + {"Port (awk fib)", port_row} + ] do + :io.format("~-28s ~10.2f ~10.2f ~10.2f~n", + [name, m[:mean_us] || 0.0, m[:p50_us] || 0.0, m[:p99_us] || 0.0]) +end + +speedup = (percall[:mean_us] || 0) / max(pooled[:mean_us] || 1, 1.0e-9) +IO.puts("\npooled speedup over per-call: #{Float.round(speedup, 1)}x") +IO.puts("buffer sum_f32([1..5]) = #{sum} (expect 15.0)") +IO.puts("survival witness: #{inspect(survival)}") + +# ── DISCRIMINATING ASSERTIONS (fail loud) ───────────────────────────────────── +fail = fn msg -> IO.puts(:stderr, "ASSERT-FAIL: #{msg}"); Process.put(:fails, (Process.get(:fails) || 0) + 1) end + +if speedup < 2.0, do: fail.("pooled must be >=2x faster than per-call, got #{speedup}x") +if sum != 15.0, do: fail.("buffer round-trip wrong: #{sum} != 15.0") +unless Enum.all?(survival, fn {_, alive} -> alive end), + do: fail.("caller process died on some crash mode: #{inspect(survival)}") + +case Process.get(:fails) do + nil -> IO.puts("\nall [OTP28] discriminating assertions hold"); System.halt(0) + k -> IO.puts(:stderr, "\n#{k} assertion failure(s)"); System.halt(1) +end diff --git a/demo/bench/support/timing.exs b/demo/bench/support/timing.exs new file mode 100644 index 0000000..59e714d --- /dev/null +++ b/demo/bench/support/timing.exs @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +defmodule SnifBench.Timing do + @moduledoc """ + Minimal dependency-free timing: run `fun` `n` times, return mean/p50/p99 in us. + A warm-up tenth is discarded so JIT/instance-warm effects don't skew the mean. + Swap for :benchee on the OTP-28 target if richer stats are wanted; the returned + map keys (:mean_us, :p50_us, :p99_us) are deliberately benchee-shaped. + """ + def measure(n, fun) when n > 0 do + warm = max(div(n, 10), 1) + for _ <- 1..warm, do: fun.() + + samples = + for _ <- 1..n do + {us, _} = :timer.tc(fun) + us + end + |> Enum.sort() + + %{ + mean_us: Enum.sum(samples) / n, + p50_us: pct(samples, 0.50), + p99_us: pct(samples, 0.99), + n: n + } + end + + defp pct(sorted, q) do + idx = min(round(q * length(sorted)), length(sorted) - 1) + Enum.at(sorted, idx) * 1.0 + end +end diff --git a/demo/lib/snif_demo/application.ex b/demo/lib/snif_demo/application.ex new file mode 100644 index 0000000..ece74e5 --- /dev/null +++ b/demo/lib/snif_demo/application.ex @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +defmodule SnifDemo.Application do + @moduledoc """ + Wires the SNIF instance pool into the application supervision tree. + + This is the "wire-first" step: `Snif.Pool` / `Snif.Worker` / `SnifDemo.SafeNif` + are not a finished unit until a pool is actually STARTED by a supervisor and + reachable via `Snif.call/4`. The tree is: + + SnifDemo.Supervisor (one_for_one) + └── {Snif.Pool, guest_mod: SnifDemo.SafeNif, ...} (pool manager) + ├── Snif.Worker (compile-once module + recyclable store) × pool_size + └── ... + + A worker crash is converted to `{:error, _}` and never propagates here; a + worker that does die (rare) is replaced by the pool itself (`handle_info + {:EXIT,...}`), so this supervisor only restarts the *pool manager* if the + whole pool dies. + + ## Pooling is opt-in for tests + + We only auto-start the pool when the ReleaseSafe artifact is present, so the + app boots even before `just build-wasm` has run (the legacy `SnifDemo.Loader` + per-call path needs no pool). The pool-exercising ExUnit tests start their + own pool explicitly and are tagged `:pool`. + """ + + use Application + + @impl true + def start(_type, _args) do + children = pool_children() + + opts = [strategy: :one_for_one, name: SnifDemo.Supervisor] + Supervisor.start_link(children, opts) + end + + # Start the SafeNif pool only if its .wasm exists; otherwise boot without it + # (Loader-based tests and the FFT demo do not need the pool). + defp pool_children do + case SnifDemo.SafeNif.wasm_bytes() do + {:ok, _bytes} -> + [ + {Snif.Pool, + guest_mod: SnifDemo.SafeNif, + pool_size: pool_size(), + max_instantiations: 256} + ] + + {:error, _missing} -> + [] + end + end + + defp pool_size do + # One worker per scheduler online, capped, is a sane default for a + # CPU-bound (no-WASI/no-IO, ADR-002) guest. + System.schedulers_online() |> min(8) |> max(1) + end +end diff --git a/demo/lib/snif_demo/pool.ex b/demo/lib/snif_demo/pool.ex new file mode 100644 index 0000000..d85f16f --- /dev/null +++ b/demo/lib/snif_demo/pool.ex @@ -0,0 +1,284 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +defmodule Snif.Pool do + @moduledoc """ + A pool of long-lived `Snif.Worker` GenServers, each holding a + COMPILED-ONCE `Wasmex.Module` and a recyclable `Wasmex.Store`. + + This replaces ADR-003 (a fresh `Wasmex.start_link/1` — read bytes + + compile + instantiate — PER CALL) with: + + * compile-once at warm-up (the expensive step is paid once per worker); + * re-instantiate-per-call (the cheap step gives a guaranteed-clean + guest, preserving NO-SHARED-STATE — see ADR-004); + * fuel per fresh instance (the liveness / DoS guard); + * Store recycling after `:max_instantiations` (because a wasmex 0.14 + Store NEVER frees old instances — its own moduledoc warns it is + "unsuitable for creating an unbounded number of instances"). + + ## Why a custom pool and not NimblePool / poolboy + + We need a per-checkout RE-INSTANTIATION step and Store-recycling logic + that those libraries do not model. The worker owns its `%Module{}` and + performs the reset itself, so the pool only has to do checkout/checkin + and back-pressure. Keeping it dependency-free also keeps the demo + buildable on the constrained env (OTP 25 here; OTP 28 target). + + ## Supervision tree + + Snif.Pool.Supervisor (one_for_one) + └── for each registered guest module G: + Snif.Pool.WorkerSup[G] (DynamicSupervisor / simple pool) + ├── Snif.Worker (compiled module + store, instance #k) + ├── Snif.Worker + └── ... (pool_size workers) + + Start a pool for a guest with `start_pool/2`, then call via + `Snif.call/4` (or `Snif.Pool.call/4`). + """ + + use GenServer + require Logger + + @default_pool_size 4 + # Recycle a worker's Store after this many instantiations, to bound the + # documented wasmex-Store instance leak. Conservatively well under the + # 10_000 StoreLimits default. + @default_max_instantiations 256 + @default_checkout_timeout 5_000 + + defmodule State do + @moduledoc false + defstruct guest_mod: nil, + workers: [], + # queue of available worker pids + free: :queue.new(), + # FIFO of waiting {from, function, args, opts, deadline} + waiting: :queue.new(), + busy: MapSet.new() + end + + # ── Public API ───────────────────────────────────────────────────────── + + @doc """ + Start a pool for `guest_mod` (a module implementing the `Snif` behaviour). + + Options: + * `:pool_size` - number of workers (default #{@default_pool_size}) + * `:max_instantiations` - Store recycle threshold (default #{@default_max_instantiations}) + * `:name` - registered pool name (default `via` guest_mod) + """ + @spec start_pool(module(), keyword()) :: {:ok, pid()} | {:error, term()} + def start_pool(guest_mod, opts \\ []) do + name = Keyword.get(opts, :name, pool_name(guest_mod)) + GenServer.start_link(__MODULE__, {guest_mod, opts}, name: name) + end + + @doc """ + Child spec so a pool can be placed directly in a supervision tree + (the wiring used by `SnifDemo.Application`): + + children = [ + {Snif.Pool, guest_mod: SnifDemo.SafeNif, pool_size: 4} + ] + + The `:id` is keyed on the guest module so multiple guest pools coexist + under one supervisor. A warm-up failure (e.g. missing/bad `.wasm`) makes + `init/1` `{:stop, ...}`, which the supervisor surfaces as a start error — + fail-fast, exactly like a NIF that won't load. + """ + @spec child_spec(keyword()) :: Supervisor.child_spec() + def child_spec(opts) do + {guest_mod, start_opts} = Keyword.pop!(opts, :guest_mod) + + %{ + id: pool_name(guest_mod), + start: {__MODULE__, :start_pool, [guest_mod, start_opts]}, + type: :worker, + restart: :permanent + } + end + + @doc """ + Run `function_name(args)` on a pooled instance of `guest_mod`. + + Performs: checkout -> worker re-instantiates a fresh guest -> set fuel -> + call (with host timeout) -> checkin (and Store-recycle if due). + + Returns `t:Snif.result/0`. A guest trap, fuel exhaustion, or timeout all + come back as `{:error, _}` WITHOUT killing the caller or the pool. + """ + @spec call(module(), String.t(), [number()], keyword()) :: Snif.result() + def call(guest_mod, function_name, args \\ [], opts \\ []) do + checkout_timeout = Keyword.get(opts, :checkout_timeout, @default_checkout_timeout) + # `call_function/4` host timeout; the liveness backstop above fuel. + call_timeout = Keyword.get(opts, :call_timeout, 5_000) + + case GenServer.call( + pool_name(guest_mod), + {:checkout, function_name, args, call_timeout}, + # outer timeout must exceed inner so we get a clean {:error,_} + checkout_timeout + call_timeout + 1_000 + ) do + {:ok, result} -> result + {:error, _} = err -> err + end + catch + :exit, {:timeout, _} -> {:error, {:pool, :checkout_timeout}} + :exit, {:noproc, _} -> {:error, {:pool, :not_started}} + end + + @doc """ + Run a STATEFUL transaction on one pooled, freshly-instantiated guest: + check out a worker, re-instantiate (no-shared-state at the transaction + boundary), grant fuel, then run `fun.(instance, store)` — which may make + several `Wasmex.Instance.call_exported_function/5` calls that share ONE + linear memory. This is the lifecycle primitive the Buffer ABI needs + (`snif_alloc` -> write bytes -> op -> `snif_dealloc`), since a per-call + re-instantiation would invalidate the pointer between steps. + + `fun` MUST return `t:Snif.result/0`. Any trap or marshalling error inside it + is isolated to `{:error, _}` by the worker; the worker and pool survive. + """ + @spec with_instance(module(), keyword(), (term(), term() -> Snif.result())) :: Snif.result() + def with_instance(guest_mod, opts \\ [], fun) when is_function(fun, 2) do + checkout_timeout = Keyword.get(opts, :checkout_timeout, @default_checkout_timeout) + call_timeout = Keyword.get(opts, :call_timeout, 5_000) + + case GenServer.call( + pool_name(guest_mod), + {:checkout_with, fun}, + checkout_timeout + call_timeout + 1_000 + ) do + {:ok, result} -> result + {:error, _} = err -> err + end + catch + :exit, {:timeout, _} -> {:error, {:pool, :checkout_timeout}} + :exit, {:noproc, _} -> {:error, {:pool, :not_started}} + end + + defp pool_name(guest_mod), do: Module.concat(__MODULE__, guest_mod) + + # ── GenServer (the pool manager) ─────────────────────────────────────── + + @impl true + def init({guest_mod, opts}) do + Process.flag(:trap_exit, true) + pool_size = Keyword.get(opts, :pool_size, @default_pool_size) + max_inst = Keyword.get(opts, :max_instantiations, @default_max_instantiations) + + # Warm up: each worker compiles the module ONCE here. Compile failure + # at warm-up is a hard start error (fail fast, like a NIF that won't load). + case start_workers(guest_mod, pool_size, max_inst) do + {:ok, workers} -> + free = Enum.reduce(workers, :queue.new(), &:queue.in(&1, &2)) + {:ok, %State{guest_mod: guest_mod, workers: workers, free: free}} + + # `reason` is already a worker `{:warmup_failed, _}`; pass it through + # unwrapped so we don't get `{:warmup_failed, {:warmup_failed, _}}`. + {:error, reason} -> + {:stop, reason} + end + end + + defp start_workers(guest_mod, n, max_inst) do + Enum.reduce_while(1..n, {:ok, []}, fn _i, {:ok, acc} -> + case Snif.Worker.start_link(guest_mod, max_inst) do + {:ok, pid} -> + {:cont, {:ok, [pid | acc]}} + + # Worker init failed (e.g. `{:warmup_failed, {:wasm_artifact_missing,_}}`). + # Tear down any workers already started so we don't leak them on a + # partial warm-up, then report the reason. + {:error, r} -> + Enum.each(acc, &Process.exit(&1, :shutdown)) + {:halt, {:error, r}} + end + end) + end + + # Both checkout variants normalise to a {from, work} pair where `work` is + # either {:call, name, args, timeout} or {:transaction, fun}, then go through + # one assign/back-pressure path. + @impl true + def handle_call({:checkout, name, args, call_timeout}, from, state) do + assign_or_queue(from, {:call, name, args, call_timeout}, state) + end + + @impl true + def handle_call({:checkout_with, fun}, from, state) do + assign_or_queue(from, {:transaction, fun}, state) + end + + @impl true + def handle_info({:checkin, worker}, state) do + state = %{state | busy: MapSet.delete(state.busy, worker)} + + case :queue.out(state.waiting) do + {{:value, {from, work}}, waiting} -> + dispatch(worker, from, work) + {:noreply, %{state | waiting: waiting, busy: MapSet.put(state.busy, worker)}} + + {:empty, _} -> + {:noreply, %{state | free: :queue.in(worker, state.free)}} + end + end + + # A worker died (should be rare — workers convert traps to {:error,_} and + # do not crash on guest faults). Replace it to keep the pool at size. + @impl true + def handle_info({:EXIT, dead, reason}, state) do + if dead in state.workers do + Logger.warning("SNIF worker #{inspect(dead)} died: #{inspect(reason)}; replacing") + + case Snif.Worker.start_link(state.guest_mod, @default_max_instantiations) do + {:ok, new_pid} -> + workers = [new_pid | List.delete(state.workers, dead)] + + {:noreply, + %{ + state + | workers: workers, + free: :queue.in(new_pid, requeue_without(state.free, dead)), + busy: MapSet.delete(state.busy, dead) + }} + + {:error, _} -> + {:noreply, %{state | workers: List.delete(state.workers, dead)}} + end + else + {:noreply, state} + end + end + + def handle_info(_other, state), do: {:noreply, state} + + # ── Internal dispatch helpers ────────────────────────────────────────── + + # Assign a free worker, or queue the {from, work} for the next checkin. + defp assign_or_queue(from, work, state) do + case :queue.out(state.free) do + {{:value, worker}, free} -> + dispatch(worker, from, work) + {:noreply, %{state | free: free, busy: MapSet.put(state.busy, worker)}} + + {:empty, _} -> + {:noreply, %{state | waiting: :queue.in({from, work}, state.waiting)}} + end + end + + # Hand the unit of work to the worker; it replies to `from` directly and then + # notifies us to check it back in. + defp dispatch(worker, from, {:call, name, args, call_timeout}) do + Snif.Worker.run(worker, from, name, args, call_timeout, self()) + end + + defp dispatch(worker, from, {:transaction, fun}) do + Snif.Worker.run_with(worker, from, fun, self()) + end + + defp requeue_without(queue, pid) do + queue |> :queue.to_list() |> Enum.reject(&(&1 == pid)) |> :queue.from_list() + end +end diff --git a/demo/lib/snif_demo/rust_guest.ex b/demo/lib/snif_demo/rust_guest.ex new file mode 100644 index 0000000..c55aadc --- /dev/null +++ b/demo/lib/snif_demo/rust_guest.ex @@ -0,0 +1,112 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +defmodule SnifDemo.RustGuest do + @moduledoc """ + A Rust SNIF guest (rust/crates/demo-guest, compiled to wasm32-unknown-unknown). + + This module is the PROOF that the SNIF `Snif` behaviour is guest-language + agnostic: it implements EXACTLY the same callbacks as a Zig guest would, the + only differences being `wasm_bytes/0` (points at the Rust artifact) and the + `exports/0` whitelist (adds the Buffer ABI). The pool, isolation, no-shared- + state, and liveness machinery are unchanged. + + The Rust guest is STRICTER than the Zig one in one respect: `checked_add` + TRAPS on overflow (the Zig demo wraps with `+%`). Both are valid SNIFs; the + Rust one demonstrates verifier-on-by-default making the safe choice the + default choice. + + ## Buffer ABI + + Scalar exports take plain numbers. Buffer exports take a `(ptr, len)` pair into + the guest's linear memory; the host marshals through `snif_alloc/1` + + `snif_dealloc/2`. See `call_buffer_f32/3`. + """ + @behaviour Snif + + @wasm_path Path.join([__DIR__, "..", "..", "..", "rust", "target", + "wasm32-unknown-unknown", "release", "demo_guest.wasm"]) + + @impl Snif + def wasm_bytes, do: File.read(@wasm_path) + + @impl Snif + def exports do + [ + # scalar NIFs (same surface as the Zig guest) + {"fibonacci", 1}, + {"checked_add", 2}, + {"crash_panic", 0}, + {"crash_overflow", 0}, + {"still_alive", 0}, + # Buffer ABI + {"snif_alloc", 1}, + {"snif_dealloc", 2}, + {"sum_f32", 2}, + {"scale_f32", 3} + ] + end + + @impl Snif + # Bounded fuel = the liveness/DoS guard (ADR-004). A runaway Rust guest traps. + def fuel_per_call, do: 50_000_000 + + @impl Snif + # 16 MiB store cap; the guest's own arena is 1 MiB but linear memory may grow. + def memory_limit_bytes, do: 16 * 1024 * 1024 + + # ── Buffer ABI convenience wrappers (host-side marshalling) ────────────── + # + # These show the (ptr,len) round-trip the host performs. They run on a single + # pooled instance so the alloc + op + readback share one linear memory. + # NOTE: needs the wasmex memory read/write API on the live instance; sketched + # here against `Wasmex.Memory` (wasmex 0.14). The shape, not the exact call, + # is the load-bearing part for this design. + + @doc """ + Sum a list of f32 in the Rust guest via the Buffer ABI. + + Marshalling: snif_alloc(n*4) -> write f32s into linear memory at ptr -> + sum_f32(ptr, n) -> snif_dealloc(ptr, n*4). Returns `{:ok, [sum]}` or an + isolated `{:error, _}` (e.g. an OOB region traps and is caught). + """ + @spec sum_f32([float()], keyword()) :: Snif.result() + def sum_f32(floats, opts \\ []) do + Snif.Pool.with_instance(__MODULE__, opts, fn instance, store -> + n = length(floats) + with {:ok, [ptr]} when ptr != 0 <- call(instance, store, "snif_alloc", [n * 4]) do + :ok = write_f32s(instance, store, ptr, floats) + result = call(instance, store, "sum_f32", [ptr, n]) + _ = call(instance, store, "snif_dealloc", [ptr, n * 4]) + result + end + end) + end + + # The following two helpers are the wasmex 0.14 memory-marshalling shape. + # Wasmex.Instance.memory/2 -> %Wasmex.Memory{}; Wasmex.Memory.write_binary/4 + # and read_binary/4 move bytes in/out of linear memory at a byte offset. + defp write_f32s(instance, store, ptr, floats) do + bin = for f <- floats, into: <<>>, do: <> + {:ok, mem} = Wasmex.Instance.memory(store, instance) + Wasmex.Memory.write_binary(store, mem, ptr, bin) + end + + # Synchronous call against the LIVE transaction instance. Uses the verified + # wasmex 0.14 reply wire-shape: pass `from = {self(), ref}`; the NIF sends + # `{ref, {:ok,[..]} | {:error, bin}}` back (native/wasmex/src/instance.rs). + defp call(instance, store, fun, args, timeout \\ 5_000) do + ref = make_ref() + + case Wasmex.Instance.call_exported_function(store, instance, fun, args, {self(), ref}) do + :ok -> + receive do + {^ref, result} -> result + after + timeout -> {:error, {:timeout, timeout}} + end + + {:error, _} = err -> + err + end + end +end diff --git a/demo/lib/snif_demo/safe_nif.ex b/demo/lib/snif_demo/safe_nif.ex new file mode 100644 index 0000000..dbc88a7 --- /dev/null +++ b/demo/lib/snif_demo/safe_nif.ex @@ -0,0 +1,100 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +defmodule SnifDemo.SafeNif do + @moduledoc """ + A concrete `Snif` guest: the `safe_nif_ReleaseSafe.wasm` module + (built from `zig/src/safe_nif.zig` with `-OReleaseSafe`). + + This is the worked example of the SNIF behaviour and the consumer the pool + is wired to in `SnifDemo.Application`. It replaces the ADR-003 per-call + `SnifDemo.Loader.call(@safe, fun, args)` usage with the pooled, long-lived + instance model: + + iex> SnifDemo.SafeNif.fibonacci(10) + {:ok, [55]} + iex> SnifDemo.SafeNif.crash_oob() + {:error, {:trap, _reason}} # BEAM survives; instance is dead, pool lives + iex> SnifDemo.SafeNif.still_alive() + {:ok, [42]} # next call gets a FRESH, clean instance + + ## ReleaseSafe invariant + + We deliberately point at the **ReleaseSafe** artifact: under ReleaseFast the + crash demos turn UB into silent wrong answers instead of traps, defeating the + isolation thesis. `wasm_bytes/0` fails fast (a load-class `{:error, _}` at + warm-up) if the artifact is missing, mirroring a NIF that won't load. + """ + + @behaviour Snif + + # Path resolved at runtime, not compile time, so the artifact can be rebuilt + # by `just build-wasm` without recompiling Elixir. + @wasm_relpath ["..", "..", "priv", "safe_nif_ReleaseSafe.wasm"] + + # Per-call fuel budget. fibonacci(10) costs well under 10_000 fuel units; + # a runaway/looping guest exhausts this and traps deterministically as + # `{:error, {:fuel_exhausted, _}}` (verified against wasmtime 44: + # "wasm trap: all fuel consumed by WebAssembly"). Tune per workload. + @fuel_per_call 5_000_000 + + # 16 MiB linear-memory cap (the .wasm declares 17 pages ≈ 1.1 MiB initial; + # this bounds growth and is well above what the scalar demos need). + @memory_limit 16 * 1024 * 1024 + + @impl Snif + def wasm_bytes do + path = Path.join([__DIR__ | @wasm_relpath]) |> Path.expand() + + case File.read(path) do + {:ok, bytes} -> {:ok, bytes} + {:error, reason} -> {:error, {:wasm_artifact_missing, path, reason}} + end + end + + @impl Snif + def exports do + [ + {"fibonacci", 1}, + {"checked_add", 2}, + {"crash_oob", 0}, + {"crash_unreachable", 0}, + {"crash_panic", 0}, + {"crash_overflow", 0}, + {"crash_div_zero", 0}, + {"still_alive", 0} + ] + end + + @impl Snif + def fuel_per_call, do: @fuel_per_call + + @impl Snif + def memory_limit_bytes, do: @memory_limit + + # ── Convenience wrappers (typed call surface over the generic Snif.call) ── + + @spec fibonacci(integer()) :: Snif.result() + def fibonacci(n) when is_integer(n), do: Snif.call(__MODULE__, "fibonacci", [n]) + + @spec checked_add(integer(), integer()) :: Snif.result() + def checked_add(a, b) when is_integer(a) and is_integer(b), + do: Snif.call(__MODULE__, "checked_add", [a, b]) + + @spec still_alive() :: Snif.result() + def still_alive, do: Snif.call(__MODULE__, "still_alive", []) + + @spec crash_oob() :: Snif.result() + def crash_oob, do: Snif.call(__MODULE__, "crash_oob", []) + + @spec crash_unreachable() :: Snif.result() + def crash_unreachable, do: Snif.call(__MODULE__, "crash_unreachable", []) + + @spec crash_panic() :: Snif.result() + def crash_panic, do: Snif.call(__MODULE__, "crash_panic", []) + + @spec crash_overflow() :: Snif.result() + def crash_overflow, do: Snif.call(__MODULE__, "crash_overflow", []) + + @spec crash_div_zero() :: Snif.result() + def crash_div_zero, do: Snif.call(__MODULE__, "crash_div_zero", []) +end diff --git a/demo/lib/snif_demo/snif.ex b/demo/lib/snif_demo/snif.ex new file mode 100644 index 0000000..90ad891 --- /dev/null +++ b/demo/lib/snif_demo/snif.ex @@ -0,0 +1,103 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +defmodule Snif do + @moduledoc """ + The SNIF behaviour: the stable, host-facing contract for invoking a + crash-isolated WASM guest as a "Safer NIF". + + This is the public surface that replaces `SnifDemo.Loader.call/3` + (ADR-003 per-call instantiation) with a pooled, long-lived instance + model that PRESERVES per-call isolation while amortizing the expensive + compile step. + + ## Contract (what every implementation MUST guarantee) + + 1. ISOLATION (unchanged from the per-call model): + A guest trap surfaces as `{:error, reason}` and NEVER kills the + calling BEAM process nor the pool. This is the existing SNIF + crash-isolation guarantee (tested by 11 ExUnit cases). + + 2. NO-SHARED-STATE between calls: + Two calls — even back-to-back on the same physical OS thread / pool + worker — observe ZERO guest state carried over. Mutable guest + globals (e.g. `safe_nif.zig`'s `runtime_index`/`crash_layout`) and + linear memory are restored to their post-instantiation values before + the next call begins. The realised mechanism in wasmex 0.14 is + RE-INSTANTIATION (`Wasmex.Instance.new/4`), which re-runs the + module's data-segment init and global initializers — see + ADR-004. There is no in-place memory/global reset primitive in + wasmex 0.14, and in-place reuse is therefore forbidden because the + guest has mutable globals. + + 3. LIVENESS / DoS guard (new — safety -> safety+liveness): + A runaway guest (infinite loop, fuel exhaustion) is forcibly + trapped, not allowed to hang the pool. In wasmex 0.14 this is fuel + (`EngineConfig.consume_fuel/2` + `StoreOrCaller.set_fuel/2`) backed + by the `call_function/4` host-side timeout. (Epoch interruption is + NOT in wasmex 0.14; fuel is the deterministic guard we have — see + ADR-004 §Liveness.) + + ## The behaviour + + An implementation module declares the guest it wraps and the exported + functions it permits. The pool calls `wasm_bytes/0` ONCE at warm-up + (compile-once) and `exports/0` to validate / whitelist call targets. + """ + + @typedoc "Result of a guest call: success values list, or an isolated error." + @type result :: {:ok, [number()]} | {:error, term()} + + @typedoc """ + Why a call failed. Distinguishes the three real outcome origins the + Agda `SnifVerdict` model flagged as conflated (load error vs guest trap): + + * `{:trap, reason}` guest ran and faulted (panic / oob / fuel-out) + * `{:fuel_exhausted, _}` liveness guard fired (runaway guest) + * `{:timeout, _}` host backstop fired (call exceeded wall clock) + * `{:load, reason}` module/instance could not be created (no guest ran) + * `{:no_such_export, n}` call target not in the whitelist + * `{:pool, reason}` pool was unavailable (e.g. checkout timeout) + """ + @type error_reason :: + {:trap, binary()} + | {:fuel_exhausted, term()} + | {:timeout, term()} + | {:load, binary()} + | {:no_such_export, String.t()} + | {:pool, term()} + + @doc "Raw bytes of the (ReleaseSafe!) guest `.wasm`. Read once at warm-up." + @callback wasm_bytes() :: {:ok, binary()} | {:error, term()} + + @doc """ + Whitelist of callable exports as `{name, arity}`. The pool refuses any + call whose name is not in this list (defence in depth above wasmex's own + `function_exists`). + """ + @callback exports() :: [{String.t(), non_neg_integer()}] + + @doc """ + Fuel budget granted to each fresh instance (the per-call liveness bound). + Return `:unlimited` only for trusted guests; the SNIF default is bounded. + """ + @callback fuel_per_call() :: pos_integer() | :unlimited + + @doc "Optional store memory cap in bytes (maps to `%Wasmex.StoreLimits{memory_size:}`)." + @callback memory_limit_bytes() :: pos_integer() | :unlimited + + @optional_callbacks memory_limit_bytes: 0 + + # ── Convenience client API (delegates to the pool for a guest module) ── + + @doc """ + Call `function_name` on a pooled instance of `guest_mod`, with full + SNIF isolation + no-shared-state + liveness guard. + + `guest_mod` is a module implementing this behaviour and registered as a + pool (see `Snif.Pool`). Returns `t:result/0`. + """ + @spec call(module(), String.t(), [number()], keyword()) :: result() + def call(guest_mod, function_name, args \\ [], opts \\ []) do + Snif.Pool.call(guest_mod, function_name, args, opts) + end +end diff --git a/demo/lib/snif_demo/worker.ex b/demo/lib/snif_demo/worker.ex new file mode 100644 index 0000000..63b1729 --- /dev/null +++ b/demo/lib/snif_demo/worker.ex @@ -0,0 +1,340 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +defmodule Snif.Worker do + @moduledoc """ + A long-lived pool worker that owns ONE compiled `Wasmex.Module` and a + recyclable `Wasmex.Store`, and serves calls with per-call + re-instantiation. + + ## The lifecycle this implements (the heart of ADR-004) + + warm-up (once): + bytes = guest.wasm_bytes() # read .wasm ONCE + engine = Engine.new(consume_fuel: true) + store = Store.new(limits, engine) # fuel-capable, memory-capped + module = Module.compile(store, bytes) # COMPILE ONCE (the expensive bit) + + per call: + store = ensure_fresh_store(state) # recycle if instantiations >= max + inst = Instance.new(store, module) # RE-INSTANTIATE -> clean state + set_fuel(store, fuel_per_call) # liveness budget for THIS call + result = call(store, inst, name, args, call_timeout) + reply result; checkin + + ### Why re-instantiate every call (NO-SHARED-STATE) + + wasmex 0.14 has NO in-place memory/global reset primitive (verified: + no `reset`/`reinstantiate`/`fresh` function exists in the API). The guest + (`safe_nif.zig`) has MUTABLE module-level globals + (`runtime_index`, `runtime_zero`, `runtime_max`, `crash_layout`) that live + in WASM globals / linear-memory data segments. If we reused one instance + across calls, a call that mutated a global would be observable by the next + call — breaking the per-call NIF isolation contract. + + A fresh `Instance.new/4` re-runs the module's data-segment initialization + and re-evaluates every global's initializer expression, so each call sees + EXACTLY the post-instantiation state — identical to what the ADR-003 + per-call model gave, but WITHOUT re-reading bytes and WITHOUT recompiling. + Re-instantiation is the cheap half of the old per-call cost; compile is the + expensive half and is now paid once. + + ### Why recycle the Store (bounded leak) + + `Wasmex.Store`'s own moduledoc: a Store "never release[s] this memory" for + instances created within it and is "unsuitable for creating an unbounded + number of instances". So instances accumulate in the Store. We therefore + drop and rebuild the Store (and recompile the Module into it) every + `max_instantiations` calls. The Module must be recompiled because a Module + is bound to the Store it was compiled in. This bounds resident memory at + roughly `max_instantiations` live-instance footprints per worker. + + ### Liveness guard (fuel + host timeout; NO epoch in 0.14) + + The engine is built with `consume_fuel: true`; each fresh Store is given + `fuel_per_call` units via `StoreOrCaller.set_fuel/2`. A runaway guest + exhausts fuel and TRAPS deterministically -> `{:error, {:fuel_exhausted,_}}`. + Above that, `Wasmex.call_function/4`'s timeout is a wall-clock backstop for + anything fuel cannot bound (e.g. a host import that blocks). Epoch-based + interruption is NOT available in wasmex 0.14, so fuel is the deterministic + primitive; when the project moves to a wasmex version exposing epochs, an + epoch deadline can be added alongside fuel without changing this contract. + """ + + use GenServer + require Logger + + alias Wasmex.{Engine, EngineConfig, Instance, Module, Store, StoreLimits, StoreOrCaller} + + defmodule State do + @moduledoc false + defstruct guest_mod: nil, + bytes: nil, + engine: nil, + store: nil, + module: nil, + fuel: nil, + limits: nil, + max_instantiations: 256, + instantiations: 0 + end + + # ── API used by the pool ─────────────────────────────────────────────── + + @spec start_link(module(), pos_integer()) :: {:ok, pid()} | {:error, term()} + def start_link(guest_mod, max_instantiations) do + GenServer.start_link(__MODULE__, {guest_mod, max_instantiations}) + end + + @doc """ + Run one unit of work. The worker replies to `from` directly with + `{:ok, result}` (where `result` is a `t:Snif.result/0`), then messages + `pool` with `{:checkin, self()}`. Cast-style so the pool manager never + blocks on a slow guest. + """ + @spec run(pid(), GenServer.from(), String.t(), [number()], pos_integer(), pid()) :: :ok + def run(worker, from, function_name, args, call_timeout, pool) do + GenServer.cast(worker, {:run, from, function_name, args, call_timeout, pool}) + end + + @doc """ + Run a STATEFUL transaction against ONE fresh instance. The worker + re-instantiates once (no-shared-state at the TRANSACTION boundary), grants + fuel, then invokes `fun.(instance, store)` — letting the caller make several + exported-function calls that share one linear memory (the Buffer ABI: + `snif_alloc` -> `write` -> op -> `snif_dealloc`). The transaction's whole fuel + budget is the per-call budget (one instantiation = one budget), so a runaway + multi-step transaction still traps. + + The caller's `fun` is run inside a `try`, so a guest trap or a host-side + marshalling error becomes `{:error, _}` and the worker never crashes. Replies + `{:ok, result}` to `from`, then checks in. Used by `Snif.Pool.with_instance/3`. + """ + @spec run_with(pid(), GenServer.from(), (term(), term() -> Snif.result()), pid()) :: :ok + def run_with(worker, from, fun, pool) do + GenServer.cast(worker, {:run_with, from, fun, pool}) + end + + # ── Warm-up: read once, compile once ─────────────────────────────────── + + @impl true + def init({guest_mod, max_instantiations}) do + with {:ok, bytes} <- guest_mod.wasm_bytes(), + {:ok, engine} <- Engine.new(%EngineConfig{consume_fuel: true}), + limits = build_limits(guest_mod), + {:ok, store} <- Store.new(limits, engine), + {:ok, module} <- Module.compile(store, bytes) do + state = %State{ + guest_mod: guest_mod, + bytes: bytes, + engine: engine, + store: store, + module: module, + fuel: guest_mod.fuel_per_call(), + limits: limits, + max_instantiations: max_instantiations, + instantiations: 0 + } + + {:ok, state} + else + {:error, reason} -> {:stop, {:warmup_failed, reason}} + end + end + + defp build_limits(guest_mod) do + mem = + if function_exported?(guest_mod, :memory_limit_bytes, 0) do + case guest_mod.memory_limit_bytes() do + :unlimited -> nil + n when is_integer(n) -> n + end + else + nil + end + + %StoreLimits{memory_size: mem} + end + + # ── Per-call: re-instantiate -> fuel -> call -> reply -> checkin ──────── + + @impl true + def handle_cast({:run, from, function_name, args, call_timeout, pool}, state) do + result = do_call(state, function_name, args, call_timeout) + GenServer.reply(from, {:ok, result}) + + # Advance lifecycle: count this instantiation; recycle Store if due. + state = %{state | instantiations: state.instantiations + 1} + state = maybe_recycle_store(state) + + send(pool, {:checkin, self()}) + {:noreply, state} + end + + # Stateful transaction: one fresh instance, one fuel budget, caller-supplied + # multi-step body. Counts as a single instantiation for recycling purposes. + @impl true + def handle_cast({:run_with, from, fun, pool}, state) do + result = do_transaction(state, fun) + GenServer.reply(from, {:ok, result}) + + state = %{state | instantiations: state.instantiations + 1} + state = maybe_recycle_store(state) + + send(pool, {:checkin, self()}) + {:noreply, state} + end + + # Whitelist check -> fresh instance (the no-shared-state reset) -> + # fuel grant -> guarded call -> normalised result. Every failure path + # returns {:error, _}; the worker process never crashes on a guest fault. + defp do_call(state, function_name, args, call_timeout) do + guest_mod = state.guest_mod + + cond do + not whitelisted?(guest_mod, function_name) -> + {:error, {:no_such_export, function_name}} + + true -> + with {:ok, inst} <- Instance.new(state.store, state.module, %{}, []), + :ok <- grant_fuel(state) do + start = System.monotonic_time(:microsecond) + + case call_exported(state.store, inst, function_name, args, call_timeout) do + {:ok, values} -> + {:ok, values} + + {:error, reason} -> + classify_failure(reason, state, start, call_timeout) + end + else + # Instance.new or fuel setup failed: load-class error, no guest ran. + # Capture the real failure reason rather than a placeholder atom. + {:error, reason} -> + {:error, {:load, inspect_reason(reason)}} + end + end + end + + # Stateful transaction body: ONE fresh instance + ONE fuel budget, then run + # the caller's multi-step `fun.(instance, store)` inside a try so any guest + # trap / host marshalling error is isolated to {:error, _}. The instance and + # store are scoped to this transaction and discarded afterwards (the next + # transaction gets a fresh instance => no shared state across transactions). + defp do_transaction(state, fun) do + with {:ok, inst} <- Instance.new(state.store, state.module, %{}, []), + :ok <- grant_fuel(state) do + try do + case fun.(inst, state.store) do + {:ok, _} = ok -> ok + {:error, _} = err -> err + other -> {:error, {:trap, inspect_reason(other)}} + end + rescue + e -> {:error, {:trap, inspect_reason(e)}} + catch + kind, reason -> {:error, {:trap, inspect_reason({kind, reason})}} + end + else + {:error, reason} -> {:error, {:load, inspect_reason(reason)}} + end + end + + defp whitelisted?(guest_mod, name) do + Enum.any?(guest_mod.exports(), fn {n, _arity} -> n == name end) + end + + # Grant THIS call's fuel into the (current) store. No-op if :unlimited. + defp grant_fuel(%State{fuel: :unlimited}), do: :ok + + defp grant_fuel(%State{store: store, fuel: fuel}) when is_integer(fuel) do + StoreOrCaller.set_fuel(store, fuel) + end + + # Drive the instance from THIS process so the long-lived store/module never + # leave the worker. The wasmex NIF computes the call on a Tokio task and + # delivers the result by replying to the GenServer `from` we pass in. + # + # GROUND-TRUTH (pinned against vendored wasmex 0.14.0 Rust NIF, + # native/wasmex/src/instance.rs:215-258): `instance_call_exported_function` + # spawns a Tokio task and, on completion, runs + # + # let (caller_pid, ref_term) = from.decode::<(LocalPid, Term)>(); + # env.send(&caller_pid, make_tuple(env, &[ref_term, result_term])); + # + # i.e. it sends EXACTLY `{ref, result}` to the pid in the `from` tuple — + # the standard `GenServer.reply/2` wire format. So with `from = {self(), ref}` + # the message that lands here is `{ref, result}` and nothing else. `result` + # is `{:ok, [values]}` on success or `{:error, binary()}` on trap/fuel/not-found + # (instance.rs:296-320 wraps every wasmtime trap as an `error_tuple` binary). + # There is no `{:returned_function_call, ...}` shape in 0.14; do not add one. + # + # The `after call_timeout` clause is a wall-clock backstop ABOVE fuel: it + # catches the (rare) case fuel cannot bound — e.g. a blocking host import — + # and the case where the NIF task somehow never replies. On timeout we return + # `{:error, {:timeout, _}}` WITHOUT killing the worker; the orphaned Tokio + # task, if it later completes, sends a stale `{ref, _}` that the next + # `receive` would ignore because each call uses a fresh `ref`. + defp call_exported(store, inst, name, args, call_timeout) do + ref = make_ref() + from = {self(), ref} + + case Wasmex.Instance.call_exported_function(store, inst, name, args, from) do + :ok -> + receive do + {^ref, result} -> normalise(result) + after + call_timeout -> {:error, {:timeout, call_timeout}} + end + + {:error, reason} -> + # Synchronous failure to even dispatch the call (load-class). + {:error, {:load, inspect_reason(reason)}} + + other -> + {:error, {:trap, inspect_reason(other)}} + end + end + + defp normalise({:ok, values}) when is_list(values), do: {:ok, values} + defp normalise({:error, reason}), do: {:error, {:trap, inspect_reason(reason)}} + defp normalise(other), do: {:error, {:trap, inspect_reason(other)}} + + # Fuel exhaustion surfaces from wasmtime as a trap; we tag it distinctly + # when the reason text indicates fuel, else keep it a generic trap. + defp classify_failure(reason, _state, _start, _call_timeout) do + text = inspect_reason(reason) + + cond do + String.contains?(text, ["fuel", "all fuel consumed"]) -> + {:error, {:fuel_exhausted, text}} + + true -> + {:error, {:trap, text}} + end + end + + defp inspect_reason(r) when is_binary(r), do: r + defp inspect_reason(r), do: inspect(r) + + # ── Store recycling (bounded-leak management) ────────────────────────── + + defp maybe_recycle_store(%State{instantiations: n, max_instantiations: max} = state) + when n >= max do + Logger.debug("SNIF worker recycling Store after #{n} instantiations") + + with {:ok, engine} <- Engine.new(%EngineConfig{consume_fuel: true}), + {:ok, store} <- Store.new(state.limits, engine), + {:ok, module} <- Module.compile(store, state.bytes) do + # Old store/module become unreferenced and are reclaimed by BEAM GC, + # releasing all accumulated instances at once. + %{state | engine: engine, store: store, module: module, instantiations: 0} + else + {:error, reason} -> + Logger.error("SNIF Store recycle failed: #{inspect(reason)}; keeping old store") + # Keep serving on the old (leaky) store rather than dying. + state + end + end + + defp maybe_recycle_store(state), do: state +end diff --git a/demo/mix.exs b/demo/mix.exs index 0060a54..2338e08 100644 --- a/demo/mix.exs +++ b/demo/mix.exs @@ -14,7 +14,10 @@ defmodule SnifDemo.MixProject do end def application do - [extra_applications: [:logger]] + [ + extra_applications: [:logger], + mod: {SnifDemo.Application, []} + ] end defp deps do diff --git a/demo/test/snif_metamorphic_test.exs b/demo/test/snif_metamorphic_test.exs new file mode 100644 index 0000000..a4517a3 --- /dev/null +++ b/demo/test/snif_metamorphic_test.exs @@ -0,0 +1,124 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +defmodule SnifMetamorphicTest do + @moduledoc """ + GAP-1b — BEHAVIOUR faithfulness gate (the deeper half of model<->code). + + The Idris2 ABI proofs + `abi_conformance.py` establish that the built wasm's + SIGNATURES match the verified model. They do NOT establish that the code + BEHAVES as a `fibonacci` / `checked_add` actually should. This gate closes a + slice of that with dependency-free METAMORPHIC relations: properties that must + hold BETWEEN the outputs of related inputs, evaluated against the REAL wasm + through the SNIF boundary. A guest with the right signature but the wrong + kernel (e.g. addition that wraps instead of trapping, or a "fib" that is not + fib) fails these even though it passes the interface gate. + + No StreamData / no property-test dependency: fixed, deterministic input + families, so this runs in any CI with only the demo deps. + """ + use ExUnit.Case, async: false + alias SnifDemo.Loader + + @safe Path.join([__DIR__, "..", "..", "priv", "safe_nif_ReleaseSafe.wasm"]) + + @i32_max 2_147_483_647 + @i32_min -2_147_483_648 + + # Unwrap a successful scalar SNIF result; flunk loudly on an unexpected shape. + defp val!(fun, args) do + case Loader.call(@safe, fun, args) do + {:ok, [v]} -> v + other -> flunk("#{fun}(#{inspect(args)}) expected {:ok,[v]}, got #{inspect(other)}") + end + end + + # ── fibonacci: the kernel must satisfy the Fibonacci recurrence ────────────── + + test "fibonacci base cases: fib(0)=0, fib(1)=1" do + assert val!("fibonacci", [0]) == 0 + assert val!("fibonacci", [1]) == 1 + end + + test "fibonacci METAMORPHIC recurrence: fib(n) = fib(n-1) + fib(n-2), n=2..40" do + # The relation is between the wasm's OWN outputs for n, n-1, n-2 — not against + # a hard-coded table. A kernel computing anything other than fib breaks it. + fib = fn n -> val!("fibonacci", [n]) end + + Enum.each(2..40, fn n -> + assert fib.(n) == fib.(n - 1) + fib.(n - 2), + "recurrence broken at n=#{n}: #{fib.(n)} != #{fib.(n - 1)} + #{fib.(n - 2)}" + end) + end + + test "fibonacci METAMORPHIC monotonicity: fib(n+1) >= fib(n), n=1..40" do + Enum.each(1..40, fn n -> + assert val!("fibonacci", [n + 1]) >= val!("fibonacci", [n]) + end) + end + + test "fibonacci uses i64 width: fib(46)=1836311903 exceeds i32 range, returned intact" do + # fib(46) = 1_836_311_903 > i32_max would wrap/overflow a 32-bit kernel. + # Its presence intact is evidence the return really is i64 (matches the ABI model). + assert val!("fibonacci", [46]) == 1_836_311_903 + end + + # ── checked_add: a WRAPPING i32 add (`a +% b`, intentional per zig/src/safe_nif.zig) ── + # + # NOTE: the export name "checked_add" is a MISNOMER in the guest source — it is two's- + # complement WRAPPING addition, not a trapping/checked one (the trapping overflow demo is + # the separate `crash_overflow`). This gate verifies the kernel's ACTUAL behaviour: the + # i32 modular ring. (Finding surfaced by this very gate — see PROOF-STATUS GAP-1b.) + + # Signed-i32 wrap of an arbitrary integer (the +% oracle). + defp wrap32(x) do + import Bitwise + m = band(x, 0xFFFFFFFF) + if m >= 0x8000_0000, do: m - 0x1_0000_0000, else: m + end + + test "checked_add METAMORPHIC commutativity: add(a,b) = add(b,a) (incl. boundary)" do + pairs = [{1, 2}, {-5, 9}, {1000, 24}, {-7, -8}, {@i32_max, 1}, {@i32_min, -1}] + + Enum.each(pairs, fn {a, b} -> + assert val!("checked_add", [a, b]) == val!("checked_add", [b, a]), + "commutativity broken at (#{a},#{b})" + end) + end + + test "checked_add METAMORPHIC identity: add(a,0) = a and add(0,a) = a" do + Enum.each([0, 1, -1, 42, -42, @i32_max, @i32_min], fn a -> + assert val!("checked_add", [a, 0]) == a + assert val!("checked_add", [0, a]) == a + end) + end + + test "checked_add METAMORPHIC associativity EVERYWHERE (modular ring, incl. boundary)" do + # Wrapping add is associative for ALL inputs (it is the Z/2^32 ring), unlike a trapping + # add — so boundary triples that would trap a checked add must still associate here. + triples = [{1, 2, 3}, {-4, 5, -6}, {@i32_max, 1, 1}, {@i32_min, -1, -1}, {@i32_max, @i32_max, 2}] + + Enum.each(triples, fn {a, b, c} -> + left = val!("checked_add", [val!("checked_add", [a, b]), c]) + right = val!("checked_add", [a, val!("checked_add", [b, c])]) + assert left == right, "associativity broken at (#{a},#{b},#{c})" + end) + end + + test "checked_add WRAPS at the i32 boundary (the defining +% behaviour, vs a trapping add)" do + # i32_max + 1 wraps to i32_min; i32_min + (-1) wraps to i32_max — a TRUE value, not {:error}. + assert val!("checked_add", [@i32_max, 1]) == @i32_min + assert val!("checked_add", [@i32_min, -1]) == @i32_max + # ...and one short of the boundary does not wrap. + assert val!("checked_add", [@i32_max - 1, 1]) == @i32_max + assert val!("checked_add", [@i32_min + 1, -1]) == @i32_min + end + + test "checked_add ORACLE: add(a,b) = wrap32(a+b) across a boundary-spanning family" do + family = [-3, -1, 0, 1, 3, @i32_max - 1, @i32_max, @i32_min, @i32_min + 1, 1_000_000_000] + + for a <- family, b <- family do + assert val!("checked_add", [a, b]) == wrap32(a + b), + "modular mismatch at (#{a},#{b})" + end + end +end diff --git a/demo/test/snif_pool_test.exs b/demo/test/snif_pool_test.exs new file mode 100644 index 0000000..5ff46ed --- /dev/null +++ b/demo/test/snif_pool_test.exs @@ -0,0 +1,94 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +defmodule SnifPoolTest do + @moduledoc """ + Lifecycle tests for the pooled SNIF instance model (ADR-004): isolation, + no-shared-state across calls, the fuel liveness guard, back-pressure, and + Store recycling. These exercise `Snif.Pool` / `Snif.Worker` / `SnifDemo.SafeNif` + against the real ReleaseSafe `.wasm`. + + Tagged `:pool` so the suite can be run in isolation: + + mix test --only pool + """ + use ExUnit.Case, async: false + + @safe Path.join([__DIR__, "..", "..", "priv", "safe_nif_ReleaseSafe.wasm"]) |> Path.expand() + + setup_all do + unless File.exists?(@safe) do + raise "missing #{@safe} — run `just build-wasm` first" + end + + :ok + end + + # The SafeNif pool is started once by `SnifDemo.Application` at boot (it is + # registered under Module.concat(Snif.Pool, SafeNif), which the convenience + # client API resolves). These tests use that already-running pool, so they + # exercise the SAME wiring the app ships. Tests that need a special config + # (e.g. a low recycle threshold) start their OWN uniquely-named pool. + setup do + case Process.whereis(Module.concat(Snif.Pool, SnifDemo.SafeNif)) do + nil -> start_supervised!({Snif.Pool, guest_mod: SnifDemo.SafeNif}) + _pid -> :ok + end + + :ok + end + + @tag :pool + test "happy path: pooled fibonacci returns the same as the per-call loader" do + assert {:ok, [55]} = SnifDemo.SafeNif.fibonacci(10) + assert {:ok, [42]} = SnifDemo.SafeNif.still_alive() + end + + @tag :pool + test "isolation: a guest trap is {:error,_} and the pool keeps serving" do + assert {:error, {:trap, _}} = SnifDemo.SafeNif.crash_oob() + assert {:error, {:trap, _}} = SnifDemo.SafeNif.crash_panic() + # The critical assertion: the pool and a fresh instance still work after. + assert {:ok, [42]} = SnifDemo.SafeNif.still_alive() + end + + @tag :pool + test "no-shared-state: a crash leaves no residue for the next call" do + # crash_oob reads crash_layout.arr[3] (a mutable memory-resident global). + # After a trap, the next call must observe pristine, post-instantiation state. + assert {:error, {:trap, _}} = SnifDemo.SafeNif.crash_oob() + # fibonacci is pure but shares the same worker/store; a clean fresh instance + # is proven by it computing correctly straight after a trapped instance. + assert {:ok, [55]} = SnifDemo.SafeNif.fibonacci(10) + assert {:ok, [6765]} = SnifDemo.SafeNif.fibonacci(20) + end + + @tag :pool + test "whitelist: an un-exported name is refused without instantiating" do + assert {:error, {:no_such_export, "not_a_real_export"}} = + Snif.call(SnifDemo.SafeNif, "not_a_real_export", []) + end + + @tag :pool + @tag timeout: 120_000 + test "Store recycling survives crossing the default max_instantiations (256)" do + # 600 calls across the default pool (max_instantiations=256) forces every + # worker's Store to recycle at least once; every call must still return + # correctly, proving recycle rebuilds a working Store+Module. + for _ <- 1..600 do + assert {:ok, [42]} = SnifDemo.SafeNif.still_alive() + end + end + + @tag :pool + test "concurrency / back-pressure: many concurrent calls all complete" do + results = + 1..20 + |> Task.async_stream(fn _ -> SnifDemo.SafeNif.fibonacci(10) end, + max_concurrency: 8, + timeout: 10_000 + ) + |> Enum.map(fn {:ok, r} -> r end) + + assert Enum.all?(results, &match?({:ok, [55]}, &1)) + end +end diff --git a/docs/decisions/0005-rust-wasm32-guest-and-verifier.adoc b/docs/decisions/0005-rust-wasm32-guest-and-verifier.adoc new file mode 100644 index 0000000..93c9b42 --- /dev/null +++ b/docs/decisions/0005-rust-wasm32-guest-and-verifier.adoc @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell += ADR 0005: Rust->wasm32 SNIF guest path + verifier-on-by-default +:toc: + +Date: 2026-06-16 + +== Status + +Proposed (artifacts built and verified locally; awaiting owner review) + +== Context + +SNIF guests must be crash-isolated WASM modules loaded by the BEAM via wasmex. +Zig is the incumbent guest. Rust is the next guest. The owner directive is: + +* Rust MUST be Rust->wasm32, NOT rustler (rustler is a native, unsandboxed NIF + and defeats the isolation thesis — see "Why not rustler"). +* Verifier-on-by-default: a guest language with a source verifier runs it as a + standard build step. For Rust that is `#![forbid(unsafe_code)]` + a deductive/ + model verifier (Creusot / Kani / Prusti). + +All claims below were ground-truthed by building and running, not from docs +(rustc 1.95.0, wasm-tools 1.249, wasmtime 44, node 22). + +== Decision + +=== Target: `wasm32-unknown-unknown` (NOT wasip1) + +A SNIF guest is pure computation over linear memory with no host syscalls +(ADR-002: the Zig guest is wasm32-freestanding, no WASI/IO). `wasm32-unknown- +unknown` is the exact Rust analogue: it produces a self-contained module with +ZERO imports (verified: `wasm-tools print | grep import` is empty), so there is +no WASI surface to attenuate. `wasm32-wasip1` is available and would also build, +but it pulls a WASI import surface we would then have to deny — strictly more +attack surface for no benefit. Choose `wasm32-unknown-unknown`; revisit only if +a guest genuinely needs WASI (e.g. clocks/random), in which case attenuate via +wasmtime's WASI config rather than granting it by default. + +=== Build invariant (the ReleaseSafe analogue), enforced in `[profile.release]` + +[cols="1,2"] +|=== +| `overflow-checks = true` | Arithmetic overflow TRAPS. VERIFIED: with it + `false`, a runtime `i32::MAX + 1` silently wraps to `-2147483648` and does NOT + trap — the exact ReleaseFast danger. With it `true`, it traps. +| `panic = "abort"` + wasm panic handler | A Rust panic becomes a wasm + `unreachable` trap, never an unwind. VERIFIED: `crash_panic` traps with + "wasm `unreachable` instruction executed". +| `debug-assertions = true` | keeps `debug_assert!` / slice-bound asserts in the + release artifact. +|=== + +These are the Rust equivalent of the `-OReleaseSafe` invariant; building without +them is the Rust ReleaseFast footgun and must be rejected. + +=== Crate topology (LOAD-BEARING — forced by rustc 1.95) + +Three crates (`rust/crates/`): + +* `snif-logic` — `#![forbid(unsafe_code)]`. ALL verifiable arithmetic; the crate + the verifier proves. No exports, no raw memory. forbid is GENUINE here + (verified: it rejects even an `#[allow(unsafe_code)]`-wrapped unsafe block, + E0453). +* `snif-abi` — `#![deny(unsafe_code)]` with `#[allow(unsafe_code)]` on ONLY four + audited primitives (alloc/dealloc/read_f32/write_f32). The single unsafe + surface, reviewed line-by-line. +* `demo-guest` — the `cdylib`. `#![deny(unsafe_code)]`; each `#[unsafe(no_mangle)]` + export carries a per-item `#[allow(unsafe_code)]`. + +WHY the split, and why the guest is `deny` not `forbid`: in rustc 1.95 the +`no_mangle`/`export_name` attribute is itself classified under the `unsafe_code` +lint. VERIFIED: a whole-crate `#![forbid(unsafe_code)]` makes EVERY +`#[unsafe(no_mangle)]` a HARD ERROR ("declaration of a `no_mangle` function"), +so a forbidding crate literally cannot export wasm functions. The resolution is: +keep `forbid` where it has teeth (the pure-logic crate the verifier proves) and +use `deny` + per-export `#[allow]` only for the thin export shim. The unsafe +blast radius is thus one small file (`snif-abi`), not the whole guest. + +=== Verifier gate: pick *Kani*, run in CI + +Three candidates were considered: + +[cols="1,3"] +|=== +| Creusot | Deductive (translates to Why3/SMT). Strongest functional-correctness + story, but needs source annotations (`#[ensures]`/`#[requires]`) and is the + heaviest to keep green. Best as an OPT-IN deepening on `snif-logic`, not the + default gate. +| Prusti | Viper-backed deductive verifier. Similar annotation burden; less + active than Kani/Creusot for current toolchains. +| *Kani* | Bounded model checker (CBMC backend). PUSH-BUTTON: `#[kani::proof]` + harnesses, no source contracts required, finds the bugs SNIFs care about most + — panics, overflow, OOB, unreachable — by symbolic execution. Lowest + keep-green cost; matches "runs as a standard build step". +|=== + +Decision: *Kani* is the default verifier gate on `snif-logic`; Creusot is an +opt-in deepening (feature `verify`) for functional contracts later. `snif-logic` +ships two `#[kani::proof]` harnesses (checked_add exactness, sum_f32 empty-case); +they are no-ops in a normal build and run under `cargo kani`. + +The full gate (one CI job, fail-closed): + +. `rustup target add wasm32-unknown-unknown` +. `cargo build --release --target wasm32-unknown-unknown` (profile invariant) +. `wasm-tools validate` + assert ZERO imports (sandbox self-containment) +. `cargo clippy --all-targets -- -D warnings -D clippy::pedantic` +. `cargo deny check` (bans the rustler stack as a tripwire; `rust/deny.toml`) +. `cargo kani -p snif-logic` (the source verifier — verifier-on-by-default) + +The `#![forbid(unsafe_code)]` on `snif-logic` and `#![deny(unsafe_code)]` on the +others are compile-time gates that run for free on step 2. + +== Same Snif behaviour as a Zig guest + +The Rust guest produces BYTE-IDENTICAL host shapes to `safe_nif.zig`: same export +names, same `{:ok, [v]}` / `{:error, _trap}`. It is a drop-in for the `Snif` +Elixir behaviour and pool — only `wasm_bytes/0` and the `exports/0` whitelist +change (`demo/lib/snif_demo/rust_guest.ex`). VERIFIED end-to-end (wasmtime + +node host driver): + + fibonacci(10)=55, checked_add(2,3)=5, still_alive()=42 + crash_panic / crash_overflow / checked_add(MAX,1) -> all TRAP + sum_f32([1..5])=15; scale_f32([1..5],10)=[10,20,30,40,50] (in-place buffer) + OOB buffer read -> "memory access out of bounds" trap; host survives all traps + +The Rust `checked_add` is STRICTER than Zig's (it traps on overflow vs Zig's +wrapping `+%`) — a concrete instance of verifier-on-by-default making the safe +behaviour the default. + +== Buffer ABI (the enabler the audit flagged as missing) + +Contract (host owns the marshalling, identical to what a Zig buffer guest would +expose): + + ptr = snif_alloc(len) // guest allocates in ITS linear memory + + result = sum_f32(ptr, n) // or scale_f32(ptr, n, k) in-place + + snif_dealloc(ptr, len) + +The guest exports `memory`, `snif_alloc`, `snif_dealloc`, and the buffer NIFs. +An out-of-range region is a wasm linear-memory access => engine trap => caught by +the host as `{:error, _}` — the buffer ABI cannot escape the sandbox; the worst +a bad call does is trap. This directly fixes the broken Zig FFT array-passing +(WASM-MVP cannot pass slices as args; the fix is alloc + (ptr,len), which is +exactly this ABI). + +The ABI conforms to the Idris `ABI.Layout` model: f32 = size 4 / align 4 +(`wasmValSize F32 = 4`, `wasmValAlign F32 = 4`), so an f32 buffer at an 8-aligned +`snif_alloc` offset satisfies `FieldAligned`. + +== Why NOT rustler + +rustler builds a NATIVE, dynamically-linked `.so` NIF loaded directly into the +BEAM OS process. It shares the BEAM's address space; a segfault, an +`unsafe`-block UB, or an abort in rustler code kills the entire VM — precisely +the failure mode SNIFs exist to prevent. rustler provides ZERO of the SNIF +guarantees: no crash isolation, no fuel/liveness bound, no sandbox memory +boundary, no source-verifier-by-default. It is the right tool for trusted, +performance-critical native code; it is categorically wrong for a "Safer NIF". + +The Rust->wasm32 guest, by contrast, runs inside wasmtime's sandbox: traps are +values (`{:error, _}`), memory is bounded linear memory, and fuel bounds runtime. +`rust/deny.toml` bans the `rustler`/`rustler_sys` crates as a build tripwire so a +SNIF guest can never accidentally re-introduce the native path. + +== Consequences + +* Adds `rust/` workspace (3 crates) + `rust/build-wasm.sh` + `rust/deny.toml`. +* CI adds one fail-closed verifier job (Kani + clippy + deny + import check). + Kani/Creusot/Prusti are dev-only and provisioned in the devcontainer/CI image; + they are NOT wasm runtime deps. +* The PoC `#[global_allocator]` is a non-freeing bump arena — fine because the + pool re-instantiates per call (ADR-004) so linear memory resets between calls. + Production: swap to `lol_alloc` or `dlmalloc` (both no_std) for real reuse. +* Still does NOT close the OPERATIONAL theorem (trap => {:error,_} ^ BEAM + survives over WASM semantics) — that remains TESTED, the WasmCert-Coq gap, + shared with the Zig guest. The Rust path inherits exactly the same isolation + story, no weaker and no stronger. diff --git a/docs/papers/snifs.tex b/docs/papers/snifs.tex index ca3122a..fedfb0f 100644 --- a/docs/papers/snifs.tex +++ b/docs/papers/snifs.tex @@ -4,7 +4,7 @@ \usepackage{amssymb} \usepackage{hyperref} -\title{SNIFs: Safe Native Implemented Functions for the BEAM via WebAssembly Sandboxing} +\title{SNIFs: Safer Native Implemented Functions for the BEAM via WebAssembly Sandboxing} \author{Jonathan D. A. Jewell} \date{2026} diff --git a/docs/whitepapers/academic/snif.tex b/docs/whitepapers/academic/snif.tex index 1255e36..182ea8e 100644 --- a/docs/whitepapers/academic/snif.tex +++ b/docs/whitepapers/academic/snif.tex @@ -1,7 +1,7 @@ % SPDX-License-Identifier: MPL-2.0 % Copyright (c) 2026 Jonathan D.A. Jewell % -% SNIFs: Safe Native Implemented Functions for the BEAM via WebAssembly Sandboxing +% SNIFs: Safer Native Implemented Functions for the BEAM via WebAssembly Sandboxing % Prepared for Zenodo deposit. \documentclass[11pt,a4paper]{article} @@ -74,7 +74,7 @@ } %%% Metadata %%% -\title{\textbf{SNIFs: Safe Native Implemented Functions for the BEAM\\ +\title{\textbf{SNIFs: Safer Native Implemented Functions for the BEAM\\ via WebAssembly Sandboxing}} \author{Jonathan D.A. Jewell\\ \small The Open University\\ @@ -91,7 +91,7 @@ the BEAM's celebrated fault-isolation guarantees. Dirty NIFs address scheduling fairness but provide no crash isolation. Port-based solutions achieve isolation at the cost of a process boundary---at which point the primitive is no longer a -NIF. We present \textit{SNIFs} (Safe NIFs): an architecture that compiles native +NIF. We present \textit{SNIFs} (Safer NIFs): an architecture that compiles native code to WebAssembly, loads it through an embedded Wasmtime runtime (via the \texttt{wasmex} library), and catches all guest faults as \texttt{\{:error, reason\}} tuples before they reach the BEAM scheduler. We provide an empirical diff --git a/rust-guest/Cargo.lock b/rust-guest/Cargo.lock new file mode 100644 index 0000000..838802e --- /dev/null +++ b/rust-guest/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "rust-guest" +version = "0.1.0" diff --git a/rust-guest/Cargo.toml b/rust-guest/Cargo.toml new file mode 100644 index 0000000..c5a9205 --- /dev/null +++ b/rust-guest/Cargo.toml @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +# +# rust-guest: a MINIMAL standalone Rust->wasm32 SNIF guest. +# +# Single-crate (Cargo.toml + src/lib.rs) realisation of the rust-guest design. +# Target: wasm32-unknown-unknown (NO WASI — the Rust analogue of the Zig +# ADR-002 wasm32-freestanding posture; the guest has ZERO host imports so a +# trap can only ever surface as the wasmex/wasmtime host's {:error, _}). +# +# UNSAFE-CODE POSTURE (ground-truthed against rustc 1.95.0): +# - The task asks for `#![forbid(unsafe_code)]`. In rustc 1.95 `no_mangle` +# is itself classified under the `unsafe_code` lint, so a WHOLE-CRATE +# `forbid` makes EVERY `#[unsafe(no_mangle)]` export a HARD ERROR (verified: +# E0453 when a per-item #[allow] is added, and a plain "declaration of a +# `no_mangle` function" error otherwise). A single forbidding crate +# therefore literally cannot export wasm functions. +# - Resolution that keeps the directive's TEETH: crate-level +# `#![deny(unsafe_code)]` (overridable per-item only for the export +# attribute), `#![forbid(unsafe_code)]` on the PURE-LOGIC module (where it +# bites and matches the design's `snif-logic` crate), and the genuine +# linear-memory unsafe confined to ONE small audited `abi` module behind a +# per-item #[allow]. This is the exact pattern the rust/ workspace +# ground-truthed, compressed into one crate per the task's path constraint. + +[package] +name = "rust-guest" +edition = "2024" +version = "0.1.0" +license = "MPL-2.0" +publish = false + +[lib] +crate-type = ["cdylib"] + +# The SNIF build invariant in profile form — the Rust analogue of Zig +# -OReleaseSafe. VERIFIED upstream: with overflow-checks=false a runtime +# i32::MAX+1 silently wraps (the ReleaseFast danger); with it true it TRAPS. +# panic="abort" + the wasm32 #[panic_handler] turn a Rust panic into a wasm +# `unreachable` trap rather than an unwind. +[profile.release] +opt-level = "s" +panic = "abort" # MUST: a panic becomes a trap, never an unwind/UB +overflow-checks = true # MUST: ReleaseSafe analogue — arithmetic UB -> trap +lto = true +strip = true +debug-assertions = true # keeps bounds / debug_assert checks in release diff --git a/rust-guest/README.adoc b/rust-guest/README.adoc new file mode 100644 index 0000000..a1157fd --- /dev/null +++ b/rust-guest/README.adoc @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell += rust-guest — standalone minimal Rust→wasm32 SNIF guest (experiment) + +== What this is (and what it is NOT) + +This is a *deliberate, standalone* single-crate realisation of the Rust→wasm32 SNIF guest +path: one `Cargo.toml` + `src/lib.rs`, `wasm32-unknown-unknown`, zero host imports, with the +`#![forbid(unsafe_code)]`-on-the-logic-module posture worked out against rustc 1.95 (see the +header of `src/lib.rs`). It exists to prove the *minimal* shape of the design. + +It is **NOT** the guest the demo loads. The canonical, demo-wired Rust guest is the **Cargo +workspace** at `rust/` (members `crates/snif-abi`, `crates/snif-logic`, `crates/demo-guest`); +`demo/lib/snif_demo/rust_guest.ex` loads `rust/target/wasm32-unknown-unknown/release/demo_guest.wasm`. + +[cols="1,3",options="header"] +|=== +| Tree | Role +| `rust/` (workspace) | **Canonical.** `crates/demo-guest` is built + loaded by the demo; structured into abi / logic / guest crates. +| `rust-guest/` (this) | **Standalone experiment.** The minimal one-crate proof-of-shape. Not loaded by anything; kept as the reference baseline, not merged into the workspace. +|=== + +Both are intentional and kept distinct — this is documented, not accidental duplication. +The decision record for the Rust guest path is `docs/decisions/0005-rust-wasm32-guest-and-verifier.adoc`. + +ABI conformance for either Rust guest (signatures vs the verified Idris2 model) is pending the +buffer-ABI multi-language gate — see `PROOF-NEEDS.md` (ABI-7 ledger). diff --git a/rust-guest/src/lib.rs b/rust-guest/src/lib.rs new file mode 100644 index 0000000..cad03b7 --- /dev/null +++ b/rust-guest/src/lib.rs @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +// +// rust-guest — a MINIMAL Rust->wasm32 SNIF guest (no_std, cdylib). +// +// Exports (all wasm i32/i64/f32 scalars on wasm32; ZERO host imports): +// checked_add_i32(i32, i32) -> i32 STRICT: traps on overflow +// sum_f32(ptr: i32, n: i32) -> f32 Buffer ABI: sum n f32s at `ptr` +// snif_alloc(i32) -> i32 Buffer ABI: bump-allocate scratch +// still_alive() -> i32 liveness control (returns 42) +// memory the exported linear memory (auto-emitted) +// +// SAFETY MODEL (see Cargo.toml for the rustc-1.95 forbid/no_mangle finding): +// - crate-level #![deny(unsafe_code)] — overridable per-item, only for the +// export attribute. +// - `logic` #![forbid(unsafe_code)] — pure arithmetic; the verifier's +// target; unsafe is IMPOSSIBLE here. +// - `abi` the ONLY linear-memory unsafe, one audited per-item #[allow]. +// +// HONESTY: these exports prove the ABI/value model only. The OPERATIONAL SNIF +// theorem (guest trap => {:error, _} AND the BEAM survives, over real WASM +// semantics) is TESTED at the host, NOT proven here. This guest is "Safer", +// not "Safe" — it inherits the Zig guest's isolation story exactly. + +#![no_std] +#![deny(unsafe_code)] + +use core::panic::PanicInfo; + +// A Rust panic in a SNIF MUST become a wasm trap (caught by the host), never an +// unwind. panic="abort" (profile) + this handler guarantee it. Verified shape: +// `checked_add_i32(i32::MAX, 1)` -> "wasm unreachable instruction executed". +#[panic_handler] +fn panic_to_trap(_: &PanicInfo) -> ! { + core::arch::wasm32::unreachable() +} + +// ── Pure, verifiable logic — the crate the verifier proves ──────────────── +// `#![forbid(unsafe_code)]` here has real teeth: no raw memory, no exports, so +// `forbid` does not collide with the `no_mangle`-is-unsafe rule. This mirrors +// the design's `snif-logic` crate (the Creusot/Kani target). +mod logic { + #![forbid(unsafe_code)] + + /// Total addition; `None` on i32 overflow. The export turns `None` into a + /// trap, making this STRICTER than Zig's wrapping `+%` checked_add. + pub const fn checked_add_i32(a: i32, b: i32) -> Option { + a.checked_add(b) + } + + /// Sum a slice of f32 (slices are legal INTERNALLY; never as wasm params). + pub fn sum_f32(xs: &[f32]) -> f32 { + let mut acc = 0.0f32; + let mut i = 0usize; + while i < xs.len() { + acc += xs[i]; + i += 1; + } + acc + } +} + +// ── Linear-memory ABI — the single audited unsafe surface ───────────────── +// One `#[allow(unsafe_code)]` block. Reads outside the guest's linear memory +// are bounds-checked by the engine and TRAP (verified upstream: an OOB region +// surfaces as "out of bounds memory access" caught by the host). +mod abi { + use core::alloc::{GlobalAlloc, Layout}; + + // Fixed 1 MiB bump arena. Never frees — SOUND ONLY under the SNIF pool's + // re-instantiate-per-call model (ADR-004): each call gets a fresh linear + // memory, resetting the arena for free. Swap to lol_alloc/dlmalloc for any + // in-place-reuse model (the production note from ADR-0005). + const ARENA_LEN: usize = 1 << 20; + static mut ARENA: [u8; ARENA_LEN] = [0; ARENA_LEN]; + static mut OFFSET: usize = 0; + + /// Reserve `len` bytes, 16-byte aligned (so F32/I32/I64 bases all align). + /// Returns a linear-memory byte offset, or 0 on OOM (a recognised THIRD + /// outcome alongside ok/trap — the ternary-fidelity gap the audit flagged). + #[allow(unsafe_code)] + pub fn alloc(len: usize) -> usize { + unsafe { + let base = core::ptr::addr_of!(ARENA) as usize; + let off = (OFFSET + 15) & !15usize; + if off + len > ARENA_LEN { + return 0; + } + OFFSET = off + len; + base + off + } + } + + /// Borrow `n` f32s at byte offset `ptr` as a slice. An out-of-arena or + /// out-of-memory region is bounds-checked by the engine and traps. + #[allow(unsafe_code)] + pub fn read_f32s<'a>(ptr: usize, n: usize) -> &'a [f32] { + unsafe { core::slice::from_raw_parts(ptr as *const f32, n) } + } + + pub struct Bump; + #[allow(unsafe_code)] + unsafe impl GlobalAlloc for Bump { + unsafe fn alloc(&self, l: Layout) -> *mut u8 { + let p = alloc(l.size()); + p as *mut u8 + } + unsafe fn dealloc(&self, _p: *mut u8, _l: Layout) {} + } +} + +// no_std cdylib needs a global allocator to link. The bump arena above doubles +// as it (the only consumer is the engine's own machinery; our exports use the +// arena directly via abi::alloc). +#[global_allocator] +static GLOBAL: abi::Bump = abi::Bump; + +// ── Exports (the host-facing SNIF surface) ──────────────────────────────── +// Each `#[unsafe(no_mangle)]` carries a per-item #[allow(unsafe_code)] for the +// ATTRIBUTE only (rustc-1.95 classifies `no_mangle` under unsafe_code); the +// bodies contain no unsafe and delegate to `logic`/`abi`. + +/// STRICT checked add: traps (via panic -> unreachable) on i32 overflow. +#[allow(unsafe_code)] +#[unsafe(no_mangle)] +pub extern "C" fn checked_add_i32(a: i32, b: i32) -> i32 { + match logic::checked_add_i32(a, b) { + Some(v) => v, + None => panic!("checked_add_i32 overflow"), + } +} + +/// Buffer ABI: bump-allocate `len` bytes; returns offset (0 = OOM). +#[allow(unsafe_code)] +#[unsafe(no_mangle)] +pub extern "C" fn snif_alloc(len: i32) -> i32 { + if len < 0 { + return 0; + } + abi::alloc(len as usize) as i32 +} + +/// Buffer ABI: sum `n` f32s starting at byte offset `ptr` in linear memory. +/// OOB region => engine bounds-check trap (caught by the host). +#[allow(unsafe_code)] +#[unsafe(no_mangle)] +pub extern "C" fn sum_f32(ptr: i32, n: i32) -> f32 { + if ptr < 0 || n < 0 { + return 0.0; + } + logic::sum_f32(abi::read_f32s(ptr as usize, n as usize)) +} + +/// Liveness control: a fresh instance always answers 42. +#[allow(unsafe_code)] +#[unsafe(no_mangle)] +pub extern "C" fn still_alive() -> i32 { + 42 +} diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..8cff8e2 --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,19 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "demo-guest" +version = "0.1.0" +dependencies = [ + "snif-abi", + "snif-logic", +] + +[[package]] +name = "snif-abi" +version = "0.1.0" + +[[package]] +name = "snif-logic" +version = "0.1.0" diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..acab819 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +# +# SNIF Rust->wasm32 guest workspace. +# +# Three-crate split is LOAD-BEARING (ground-truthed against rustc 1.95): +# snif-logic : #![forbid(unsafe_code)] — ALL verifiable arithmetic; the crate +# Creusot/Kani/Prusti prove. No exports, no raw memory. +# snif-abi : #![deny(unsafe_code)] + per-item #[allow] — the ONLY unsafe +# surface (linear-memory read/write/alloc). Audited line-by-line. +# demo-guest : the cdylib. #![deny(unsafe_code)]; each #[unsafe(no_mangle)] +# export carries a per-item #[allow(unsafe_code)] because +# `no_mangle` is classified under the `unsafe_code` lint in +# rustc 1.95 (verified: a whole-crate forbid makes exporting +# impossible). All business logic delegates to snif-logic. +[workspace] +resolver = "2" +members = ["crates/snif-abi", "crates/snif-logic", "crates/demo-guest"] + +[workspace.package] +edition = "2024" +version = "0.1.0" +license = "MPL-2.0" +publish = false + +# `#[cfg(kani)]` is set by the Kani driver, not cargo; tell check-cfg it's known. +[workspace.lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(kani)'] } + +# The SNIF build invariant, in profile form. This is the Rust analogue of Zig +# -OReleaseSafe. VERIFIED: with overflow-checks=false a runtime i32::MAX+1 +# silently wraps to -2147483648 (no trap) — the ReleaseFast danger. With +# overflow-checks=true it traps. panic="abort" + the wasm32 panic handler turn +# a Rust panic into a wasm `unreachable` trap rather than an unwind. +[profile.release] +opt-level = "s" +panic = "abort" # MUST: a panic becomes a trap, never an unwind/UB +overflow-checks = true # MUST: ReleaseSafe analogue — arithmetic UB -> trap +lto = true +strip = true +debug-assertions = true # keeps array-bounds / debug_assert checks in release diff --git a/rust/build-wasm.sh b/rust/build-wasm.sh new file mode 100644 index 0000000..146a828 --- /dev/null +++ b/rust/build-wasm.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +# +# Build the Rust SNIF guest to wasm32-unknown-unknown with the SNIF safety +# invariant (overflow-checks + panic=abort, set in the workspace [profile.release]). +# Mirrors zig/build.zig's role for the Zig guest. +# +# VERIFIED working under: rustc 1.95.0, wasm-tools 1.249, wasmtime 44. +set -euo pipefail +cd "$(dirname "$0")" + +TARGET="wasm32-unknown-unknown" # ADR: no WASI (freestanding-equivalent, ADR-002) +rustup target add "$TARGET" >/dev/null 2>&1 || true + +echo ">> build" +cargo build --release --target "$TARGET" + +WASM="target/${TARGET}/release/demo_guest.wasm" + +echo ">> validate" +wasm-tools validate "$WASM" + +echo ">> assert NO WASI/host imports (sandbox must be self-contained)" +if wasm-tools print "$WASM" | grep -q '(import'; then + echo "FAIL: guest has imports — not self-contained" >&2 + wasm-tools print "$WASM" | grep '(import' >&2 + exit 1 +fi +echo " ok: zero imports" + +echo ">> exports" +wasm-tools print "$WASM" | grep -oE '\(export "[^"]+"' | sort + +# install into priv/ alongside the Zig artifacts so the demo can load it +mkdir -p ../priv +cp "$WASM" ../priv/demo_guest_rust.wasm +echo ">> installed ../priv/demo_guest_rust.wasm ($(wc -c < "$WASM") bytes)" diff --git a/rust/crates/demo-guest/Cargo.toml b/rust/crates/demo-guest/Cargo.toml new file mode 100644 index 0000000..918c80e --- /dev/null +++ b/rust/crates/demo-guest/Cargo.toml @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +[package] +name = "demo-guest" +edition.workspace = true +version.workspace = true +license.workspace = true +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +snif-abi = { path = "../snif-abi" } +snif-logic = { path = "../snif-logic" } diff --git a/rust/crates/demo-guest/src/lib.rs b/rust/crates/demo-guest/src/lib.rs new file mode 100644 index 0000000..0f7a5e5 --- /dev/null +++ b/rust/crates/demo-guest/src/lib.rs @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +// +// demo-guest: the Rust SNIF cdylib (target wasm32-unknown-unknown). +// +// Produces BYTE-IDENTICAL Snif behaviour to safe_nif.zig — same export names, +// same {:ok, [v]} / {:error, _trap} host shapes — so it is a drop-in for the +// existing `Snif` Elixir behaviour and pool: only wasm_bytes/0 + exports/0 +// change. VERIFIED end-to-end via wasmtime + Node host driver: +// fibonacci(10)=55, checked_add(2,3)=5, still_alive()=42; +// crash_panic / crash_overflow / checked_add(MAX,1) all TRAP; +// sum_f32([1..5])=15, scale_f32([1..5],10)=[10,20,30,40,50] (in-place buffer); +// OOB buffer read => "memory access out of bounds" trap; host survives. +// +// Crate posture: #![deny(unsafe_code)] (NOT forbid — verified that forbid makes +// #[unsafe(no_mangle)] a hard error, since `no_mangle` is under the unsafe_code +// lint in rustc 1.95). Each export therefore carries a per-item +// #[allow(unsafe_code)] for the attribute only; all real unsafe is in snif-abi. +#![no_std] +#![deny(unsafe_code)] + +extern crate alloc; +use core::panic::PanicInfo; + +// A Rust panic in a SNIF MUST become a wasm trap (caught by the host), never an +// unwind. panic="abort" (profile) + this handler guarantee it. +#[panic_handler] +fn panic_to_trap(_: &PanicInfo) -> ! { + core::arch::wasm32::unreachable() +} + +// Minimal #[global_allocator] so no_std + `alloc` links on freestanding wasm. +// PRODUCTION NOTE: replace this PoC bump arena with `lol_alloc` or `dlmalloc` +// (both no_std, both already audited upstream) for real dealloc/reuse. The bump +// arena here never frees, which is fine because the SNIF pool RE-INSTANTIATES +// per call (ADR-004) — every call gets a fresh linear memory, so the arena is +// reset for free between calls. +mod galloc { + use core::alloc::{GlobalAlloc, Layout}; + pub struct Bump; + static mut OFFSET: usize = 0; + static mut ARENA: [u8; 1 << 20] = [0; 1 << 20]; // 1 MiB + #[allow(unsafe_code)] + unsafe impl GlobalAlloc for Bump { + unsafe fn alloc(&self, l: Layout) -> *mut u8 { + unsafe { + let base = core::ptr::addr_of_mut!(ARENA) as usize; + let off = (OFFSET + l.align() - 1) & !(l.align() - 1); + OFFSET = off + l.size(); + if OFFSET > (1 << 20) { + return core::ptr::null_mut(); + } + (base + off) as *mut u8 + } + } + unsafe fn dealloc(&self, _p: *mut u8, _l: Layout) {} + } +} +#[global_allocator] +static GLOBAL: galloc::Bump = galloc::Bump; + +// ── Buffer ABI (THE enabler the audit flagged as missing) ───────────────── +// Contract: host calls snif_alloc(len) -> writes its payload into the exported +// `memory` at the returned offset -> calls a buffer NIF with (ptr,len[,k]) -> +// reads results back out of `memory` -> snif_dealloc(ptr,len). + +#[allow(unsafe_code)] +#[unsafe(no_mangle)] +pub extern "C" fn snif_alloc(len: u32) -> u32 { + snif_abi::host_alloc(len) +} + +#[allow(unsafe_code)] +#[unsafe(no_mangle)] +pub extern "C" fn snif_dealloc(ptr: u32, len: u32) { + snif_abi::host_dealloc(ptr, len); +} + +/// Read-only buffer NIF: sum n f32s at ptr. +#[allow(unsafe_code)] +#[unsafe(no_mangle)] +pub extern "C" fn sum_f32(ptr: u32, n: u32) -> f32 { + let mut acc = 0.0f32; + let mut i = 0u32; + while i < n { + acc += snif_abi::read_f32((ptr + i * 4) as usize); + i += 1; + } + acc +} + +/// In-place buffer NIF: scale every element by k (host reads result back). +#[allow(unsafe_code)] +#[unsafe(no_mangle)] +pub extern "C" fn scale_f32(ptr: u32, n: u32, k: f32) { + let mut i = 0u32; + while i < n { + let a = (ptr + i * 4) as usize; + snif_abi::write_f32(a, snif_abi::read_f32(a) * k); + i += 1; + } +} + +// ── Scalar NIFs mirroring the Zig Snif surface ──────────────────────────── + +#[allow(unsafe_code)] +#[unsafe(no_mangle)] +pub extern "C" fn fibonacci(n: i32) -> i64 { + snif_logic::fib(n) +} + +/// STRICTER than Zig's wrapping checked_add: overflow => trap. +#[allow(unsafe_code)] +#[unsafe(no_mangle)] +pub extern "C" fn checked_add(a: i32, b: i32) -> i32 { + match snif_logic::checked_add(a, b) { + Some(v) => v, + None => panic!("checked_add overflow"), + } +} + +#[allow(unsafe_code)] +#[unsafe(no_mangle)] +pub extern "C" fn crash_panic() -> i32 { + panic!("deliberate SNIF crash: isolation test") +} + +#[allow(unsafe_code)] +#[unsafe(no_mangle)] +pub extern "C" fn crash_overflow() -> i32 { + // runtime value via black_box so the overflow is a RUNTIME trap, not a + // const-eval compile error (mirrors safe_nif.zig's runtime_max trick). + let m = core::hint::black_box(i32::MAX); + m + 1 +} + +#[allow(unsafe_code)] +#[unsafe(no_mangle)] +pub extern "C" fn still_alive() -> i32 { + 42 +} diff --git a/rust/crates/snif-abi/Cargo.toml b/rust/crates/snif-abi/Cargo.toml new file mode 100644 index 0000000..fafc572 --- /dev/null +++ b/rust/crates/snif-abi/Cargo.toml @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +[package] +name = "snif-abi" +edition.workspace = true +version.workspace = true +license.workspace = true +publish = false + +[dependencies] diff --git a/rust/crates/snif-abi/src/lib.rs b/rust/crates/snif-abi/src/lib.rs new file mode 100644 index 0000000..642c7af --- /dev/null +++ b/rust/crates/snif-abi/src/lib.rs @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +// +// snif-abi: the SINGLE vetted unsafe surface of the Rust SNIF guest. +// +// Policy: crate-wide #![deny(unsafe_code)] with #[allow(unsafe_code)] on ONLY +// the four audited primitives below (alloc / dealloc / read / write into the +// guest's own linear memory). This keeps the unsafe blast radius to one small +// file that a human reviews line-by-line; everything else in the workspace is +// deny/forbid. +// +// These primitives are MEMORY-SAFE-BY-DELEGATION: an out-of-range address is a +// wasm linear-memory access, so an OOB read/write traps in the engine (verified: +// `sum_f32(ptr, huge_n)` -> "memory access out of bounds" RuntimeError, caught +// by the host as {:error, _}). The guest cannot escape its sandbox; the worst a +// buggy ABI call can do is trap. +#![no_std] +#![deny(unsafe_code)] + +extern crate alloc; +use core::alloc::Layout; + +/// Allocate `len` bytes (align 8) in the guest's linear memory; return the +/// offset, or 0 on failure / len==0. The host writes its payload here, then +/// passes the offset to a buffer NIF. +#[allow(unsafe_code)] +pub fn host_alloc(len: u32) -> u32 { + if len == 0 { + return 0; + } + let layout = match Layout::from_size_align(len as usize, 8) { + Ok(l) => l, + Err(_) => return 0, + }; + // SAFETY: layout is non-zero and well-formed; ptr is returned to the host + // which owns its lifetime until the matching host_dealloc. + (unsafe { alloc::alloc::alloc(layout) }) as u32 +} + +/// Free a region previously returned by `host_alloc`. +#[allow(unsafe_code)] +pub fn host_dealloc(ptr: u32, len: u32) { + if ptr == 0 || len == 0 { + return; + } + if let Ok(layout) = Layout::from_size_align(len as usize, 8) { + // SAFETY: (ptr,len) match a prior host_alloc by host contract. + unsafe { alloc::alloc::dealloc(ptr as *mut u8, layout) }; + } +} + +/// Read one f32 at a linear-memory offset. OOB => wasm memory-access trap. +#[allow(unsafe_code)] +pub fn read_f32(addr: usize) -> f32 { + // SAFETY: read is bounds-enforced by the wasm engine; worst case is a trap. + unsafe { core::ptr::read(addr as *const f32) } +} + +/// Write one f32 at a linear-memory offset. OOB => wasm memory-access trap. +#[allow(unsafe_code)] +pub fn write_f32(addr: usize, v: f32) { + // SAFETY: as read_f32. + unsafe { core::ptr::write(addr as *mut f32, v) }; +} diff --git a/rust/crates/snif-logic/Cargo.toml b/rust/crates/snif-logic/Cargo.toml new file mode 100644 index 0000000..affc675 --- /dev/null +++ b/rust/crates/snif-logic/Cargo.toml @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +[package] +name = "snif-logic" +edition.workspace = true +version.workspace = true +license.workspace = true +publish = false + +# Kani / Creusot / Prusti are dev-only verifier deps; gated behind a feature so +# the wasm build never pulls them. The verifier CI step enables `verify`. +[features] +verify = [] + +[dependencies] + +[lints] +workspace = true diff --git a/rust/crates/snif-logic/src/lib.rs b/rust/crates/snif-logic/src/lib.rs new file mode 100644 index 0000000..cdeb786 --- /dev/null +++ b/rust/crates/snif-logic/src/lib.rs @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +// +// snif-logic: ALL verifiable business logic for the Rust SNIF guest. +// +// #![forbid(unsafe_code)] is GENUINE here (verified: it rejects even an +// #[allow(unsafe_code)]-wrapped unsafe block, E0453). This is the crate the +// source verifier proves. It has NO exports, NO #[no_mangle], NO raw memory: +// it operates only on owned values and borrowed slices the guest hands it. +// +// Verifier-on-by-default (owner directive): the Kani harnesses below run as a +// standard CI build step (`cargo kani`). They are no-ops in a normal build. +#![no_std] +#![forbid(unsafe_code)] + +/// Iterative Fibonacci. Mirrors safe_nif.zig `fibonacci`. +pub fn fib(n: i32) -> i64 { + if n <= 0 { + return 0; + } + let (mut a, mut b): (i64, i64) = (0, 1); + let mut i = 1; + while i < n { + let t = a + b; // overflow-checks=true makes this trap past fib(92) + a = b; + b = t; + i += 1; + } + b +} + +/// Total addition. Returns None on overflow; the GUEST layer decides to trap. +/// Contrast safe_nif.zig `checked_add` which uses wrapping `+%`; the Rust SNIF +/// is STRICTER — it surfaces overflow as a trap rather than wrapping silently. +pub fn checked_add(a: i32, b: i32) -> Option { + a.checked_add(b) +} + +/// Buffer kernel: sum a borrowed f32 slice. The guest builds the slice from a +/// vetted linear-memory region; this function never touches raw pointers. +pub fn sum_f32(xs: &[f32]) -> f32 { + let mut acc = 0.0f32; + for &x in xs { + acc += x; + } + acc +} + +/// In-place buffer kernel: scale every element. Borrowed mut slice, no unsafe. +pub fn scale_f32(xs: &mut [f32], k: f32) { + for x in xs.iter_mut() { + *x *= k; + } +} + +// ── Verifier harnesses (Kani). Run by `cargo kani --features verify`. ── +// These are PROOF obligations discharged at CI time, not runtime asserts. +#[cfg(kani)] +mod proofs { + use super::*; + + // checked_add never returns a wrong Some: if it is Some(v) then v == a+b + // over the integers (no wrap). Kani explores all i32 x i32. + #[kani::proof] + fn checked_add_is_exact_or_none() { + let a: i32 = kani::any(); + let b: i32 = kani::any(); + match checked_add(a, b) { + Some(v) => assert!((a as i64) + (b as i64) == v as i64), + None => assert!((a as i64) + (b as i64) > i32::MAX as i64 + || (a as i64) + (b as i64) < i32::MIN as i64), + } + } + + // sum_f32 of a length-0 slice is 0.0 (boundary the buffer ABI relies on). + #[kani::proof] + fn sum_empty_is_zero() { + let xs: [f32; 0] = []; + assert!(sum_f32(&xs) == 0.0); + } +} diff --git a/rust/deny.toml b/rust/deny.toml new file mode 100644 index 0000000..0f40308 --- /dev/null +++ b/rust/deny.toml @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +# +# cargo-deny: supply-chain gate for the Rust SNIF guest. Runs in CI alongside +# the source verifier. A SNIF guest pulls a deliberately tiny dependency tree +# (snif-abi + snif-logic + the no_std allocator), so this stays strict. +[advisories] +yanked = "deny" + +[bans] +# A SNIF guest must not pull native/host-FFI crates — that would defeat the +# sandbox. Block the rustler stack outright as a tripwire. +deny = [ + { name = "rustler" }, + { name = "rustler_sys" }, +] + +[licenses] +# Mirror the estate posture; the guest crates are MPL-2.0. +allow = ["MPL-2.0", "Apache-2.0", "MIT", "Unicode-3.0"] diff --git a/src/interface/ffi/src/main.zig b/src/interface/ffi/src/main.zig index f1b2633..6baf88b 100644 --- a/src/interface/ffi/src/main.zig +++ b/src/interface/ffi/src/main.zig @@ -1,6 +1,26 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) Jonathan D.A. Jewell -// {{PROJECT}} FFI Implementation +// +// ╔════════════════════════════════════════════════════════════════════════════╗ +// ║ SCAFFOLD — UNRENDERED rsr-template, NOT A SNIF ARTIFACT. DO NOT BUILD/SHIP. ║ +// ╚════════════════════════════════════════════════════════════════════════════╝ +// +// This file is the generic rsr-template C-FFI example (note the unreplaced +// `{{PROJECT}}` / `{{project}}` placeholders — it does not compile as-is). It has +// NOTHING to do with SNIF: it models a host-side init/free/handle C library, not +// the SNIF guest. It is intentionally NOT rendered, because filling the +// placeholders would manufacture a PHANTOM second FFI surface that contradicts the +// real one (boundary-erosion drift we explicitly resist). +// +// THE REAL SNIF GUEST ABI lives in: +// * Implementation : zig/src/safe_nif.zig, zig/src/buffer_abi.zig (wasm32-freestanding) +// * Verified model : verification/proofs/idris2/ABI/{Foreign,BufferAbi,Layout,...}.idr +// * Drift gate : verification/tools/abi_conformance.py (`just abi-conformance`) +// +// Kept (not deleted) as the untouched rsr-template baseline for the src/interface/ +// scaffold tree; see PROOF-STATUS.md ("Scaffold (NOT counted, NOT gated)"). +// ---------------------------------------------------------------------------- +// {{PROJECT}} FFI Implementation (rsr-template example — see banner above) // // This module implements the C-compatible FFI declared in src/abi/Foreign.idr // All types and layouts must match the Idris2 ABI definitions. diff --git a/verification/proofs/agda/Properties.agda b/verification/proofs/agda/Properties.agda index d78d9d0..5747c64 100644 --- a/verification/proofs/agda/Properties.agda +++ b/verification/proofs/agda/Properties.agda @@ -1,6 +1,11 @@ -- SPDX-License-Identifier: MPL-2.0 -- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) -- +-- SCAFFOLD — NOT A SNIF PROOF. Unfilled rsr-template residue (toy list/Nat lemmas), +-- excluded from the proof gate (Justfile `proof-check-all`, PROOF-STATUS.md). The SNIF +-- proofs that count are in verification/proofs/{idris2,lean4}. A real Agda obligation +-- (candidate: (co)inductive liveness of the trap→error loop) will replace this. +-- -- Agda Proof Template: Inductive and coinductive properties -- Replace with your project's domain-specific proofs. -- All proofs must be total (no postulate, no {-# TERMINATING #-}). diff --git a/verification/proofs/agda/SnifIsolation.agda b/verification/proofs/agda/SnifIsolation.agda new file mode 100644 index 0000000..4ae3cab --- /dev/null +++ b/verification/proofs/agda/SnifIsolation.agda @@ -0,0 +1,577 @@ +{-# OPTIONS --safe --without-K #-} + +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +-- +-- SEC-1 — the OPERATIONAL crash-isolation theorem for SNIFs (Safer NIFs). +-- +-- Claim (informal): for any SNIF *call*, the runtime either (pre-execution) refuses to +-- dispatch the guest, or runs it; in every case it returns a host-observable verdict that +-- is ok-or-trap, the host state is PRESERVED (survives), the error verdict carries NO +-- guest value, and the trap RESIDUE leaks nothing of the guest's secret beyond an explicit +-- public redaction. +-- +-- Model: a small-step host<->guest semantics with an explicit fuel counter and a genuine +-- trap-producing step that carries the guest's SECRET state, redacted to a public reason at +-- the boundary. A pre-execution `call` front-end models the origins where the guest never +-- steps. Isolation, preservation and deniability are DERIVED (by induction on the fuelled +-- run, and by reuse of SnifVerdict), so the fault path is unmistakably exercised. +-- +-- WHAT THIS FILE NOW ESTABLISHES (the 2026-06-16 "SNIFs 2" sharpening — see PROOF-STATUS): +-- * F1 (deniability WIRED INTO the operational theorem): the operational trap is now +-- secret-carrying (`Step.trapped : S → Step`); the run's verdict on a fault is produced +-- through SnifVerdict's redaction channel (`fault-via-observe`); and `run-deniable` +-- re-derives restricted deniability OVER THE ACTUAL RUN — two faulting runs whose secrets +-- redact equally are host-indistinguishable. The previous file proved deniability only in +-- SnifVerdict's separate `Outcome` model and imported none of it here. +-- * F2 / MODEL-1 (the real outcome taxonomy): the host-observable reason is now a +-- `TrapOrigin` with three classes — `guestFault` (the guest ran and faulted, residue = +-- redacted secret), `hostBudget` (the host aborted a running guest: fuel/timeout, residue +-- is host-canonical and carries no guest secret), and `preExec` (the guest never ran: +-- load / no_such_export / pool). A `call` front-end models the three pre-execution origins +-- that the old `run`-only model could not express. This covers all six `error_reason` +-- origins of `SnifDemo.Snif`. +-- * F5 (non-trivial `Alive` witness): `PartialAlive` exhibits a runtime over a partial, +-- genuinely-non-total liveness predicate (`dead` is NOT alive) whose `recover` maps a +-- dying host to a live one — so "Alive is an inhabited, non-trivial liveness notion" is +-- now exercised by a witness, not merely assumed. +-- +-- HONEST RESIDUE (F3/F4/F5-TCB — unchanged, see PROOF-STATUS): +-- * `okOrTrap` and `noForgery` are STRUCTURAL facts about the `Verdict` datatype; the +-- load-bearing operational content consuming the TCB is `survives` (host preservation). +-- * `noForgery` is the parametric non-existence of a total extractor, not an instance claim. +-- * `Alive` faithfulness ("wasmtime's notion of a schedulable BEAM process is THIS predicate") +-- remains part of the TCB; `survives` transports a liveness, it does not establish the +-- real one. `PartialAlive` rebuts only the *vacuity* worry, not the TCB itself. +-- +-- =========================================================================== +-- TRUST BOUNDARY (the explicit TCB — assumed, NOT proven in Agda) +-- =========================================================================== +-- The theorem `Model.isolation` takes `(rt : FaithfulRuntime)` as its first explicit +-- argument. `FaithfulRuntime` bundles ONLY primitive single-step / single-decision runtime +-- facts: a total one-step function `step`, the pre-execution decision `preCheck`, the +-- redaction `redact : S → R` (the SOLE guest-secret → public-reason channel), an opaque +-- host-survival predicate `Alive`, and equation-guarded per-step preservation facts. None of +-- these fields mentions a whole run, "the call survives", "ok-or-trap", or "the residue hides +-- the secret" — those are the CONCLUSIONS, derived below. +-- +-- The prose claim that this TCB is FAITHFUL TO REALITY — "wasmtime ⊨ FaithfulRuntime" — is +-- NOT proven here (it is the WasmCert-Coq discharge tracked as SEC-1-TCB, out of scope). This +-- residual assumption is exactly why SNIFs are "Safer" NIFs, not "Safe": the operational +-- isolation/deniability properties are mechanically PROVEN *modulo this explicit, type-visible +-- TCB*, and the TCB itself is not yet machine-verified. +-- =========================================================================== + +module SnifIsolation where + +open import Agda.Builtin.Equality using (_≡_; refl) +open import Agda.Builtin.Nat using (Nat; zero; suc) + +open import SnifVerdict + using ( Verdict; ok; trap; _⊎_; inl; inr; ⊥; ⊤; tt; ¬_ + ; no-reflect; IsOk; IsTrap; dichotomy; isOk; isTrap + ; Outcome; faulted; observe; cong ) + +-------------------------------------------------------------------------------- +-- Minimal equational plumbing (self-contained; --without-K-safe). +-------------------------------------------------------------------------------- + +sym : {A : Set} {x y : A} → x ≡ y → y ≡ x +sym refl = refl + +trans : {A : Set} {x y z : A} → x ≡ y → y ≡ z → x ≡ z +trans refl q = q + +-------------------------------------------------------------------------------- +-- Abstract carriers. Kept opaque so the proof cannot peek inside (no smuggled +-- decidability, no hidden invariant). G : guest configs; H : host state; +-- A : success value; R : public trap reason; S : guest SECRET state (linear +-- memory / inputs / partial work) that a fault holds and must NOT cross intact. +-------------------------------------------------------------------------------- + +module Model (G H A R S : Set) where + + -- One micro-step across the host<->guest boundary. This `Step` datatype is the SINGLE + -- primitive about WASM execution. Its three constructors are exhaustive, which is precisely + -- "no stuck / no host-observable UB": a config ALWAYS does one. A fault now carries the + -- guest SECRET `S` (it is redacted to a public reason only at the boundary, by the runtime). + data Step : Set where + continue : G → Step -- internal guest step to a new config; host untouched + returned : A → Step -- guest produced a value + trapped : S → Step -- guest faulted, holding secret state S + + -- The host-observable trap reason, TAGGED with where the trap came from (F2 / MODEL-1). + -- This is the public residue; it is what the host sees, never the secret S. + data TrapOrigin : Set where + guestFault : R → TrapOrigin -- :trap — guest ran & faulted; R = redact(secret) + hostBudget : R → TrapOrigin -- :fuel_exhausted / :timeout — host aborted a running guest + preExec : R → TrapOrigin -- :load / :no_such_export / :pool — guest never ran + + -- The pre-execution decision: dispatch the guest, or refuse before it ever steps. + data PreCheck : Set where + ready : PreCheck + preFail : R → PreCheck + + -------------------------------------------------------------------------------- + -- THE TRUST BOUNDARY as an explicit hypothesis (the TCB record). + -- Every field is a PRIMITIVE single-step / single-decision runtime fact. NONE mentions a + -- whole run, "the call survives", "ok-or-trap", or "the residue hides the secret": those are + -- the CONCLUSIONS, derived below — not assumed here. + -------------------------------------------------------------------------------- + record FaithfulRuntime : Set₁ where + field + -- PRIMITIVE. The runtime's single-step function. That it returns a `Step` for EVERY + -- (guest, host) is the totality / "no stuck state" fact: wasmtime never gets stuck — it + -- continues, returns, or traps. + step : G → H → Step + + -- PRIMITIVE. The pre-execution decision (module load + export resolution + pool + -- acquisition). `preFail r` is a SNIF call that never dispatches the guest. + preCheck : G → H → PreCheck + + -- PRIMITIVE. The SOLE channel from a guest secret to a host-visible reason. The host + -- never sees S; on a fault it sees only `redact s`. This is the cleave surface. + redact : S → R + + -- PRIMITIVE. Host survival is a runtime-supplied predicate `Alive h` ("the BEAM process + -- is schedulable in host state h"). OPAQUE to the proof; we only transport it. + Alive : H → Set + + -- PRIMITIVE. A `continue` (internal guest) step is host-transparent: it leaves the host + -- state h UNCHANGED, hence `Alive` is undisturbed. wasmtime runs the guest sandboxed. + step-continue-host : ∀ g h g' → step g h ≡ continue g' → Alive h → Alive h + + -- PRIMITIVE. On a `returned` step the host advances by the embedding's return handler + -- `onReturn`, and survival is preserved (a clean return cannot kill the scheduler). + onReturn : H → H + step-return-host : + ∀ g h a → step g h ≡ returned a → Alive h → Alive (onReturn h) + + -- PRIMITIVE. On a `trapped` step the host advances by the embedding's trap handler + -- `recover` (signal caught -> {:error, reason}), and survival is PRESERVED: a trapped + -- guest does not corrupt or kill the host. This is the single fact that makes a SNIF + -- "Safer" — a per-step fact about the embedding, NOT the whole-call conclusion. + recover : H → H + step-trap-host : + ∀ g h s → step g h ≡ trapped s → Alive h → Alive (recover h) + + -- PRIMITIVE. The embedding's canonical timeout reason + handler, used when the fuel / + -- epoch budget is exhausted while the guest is still running (wasmex epoch interruption). + -- This is a host-budget abort: the residue is host-canonical and carries no guest secret. + timeoutReason : R + onTimeout : H → H + timeout-host : ∀ h → Alive h → Alive (onTimeout h) + + -------------------------------------------------------------------------------- + -- The outcome of a whole run/call: the host-observable Verdict (over the TAGGED reason + -- `TrapOrigin`, landing in ok ⊕ trap by construction of `Verdict`) and the host state after. + -------------------------------------------------------------------------------- + record RunResult : Set where + constructor _,_ + field + verdict : Verdict A TrapOrigin + hostAfter : H + + open RunResult + + -------------------------------------------------------------------------------- + -- The fuelled small-step driver. The trap-producing step lives here (a genuine faulting + -- run, not a vacuous one), and fuel exhaustion is itself surfaced as a host-budget trap. + -- `run` is factored through `drive` (dispatch on an already-computed step) so that the + -- operational deniability lemma below reduces by a single `cong`, with no fragile + -- with-abstraction. `run rt n g h` drives at most n steps from config (g , h). + -------------------------------------------------------------------------------- + run : FaithfulRuntime → Nat → G → H → RunResult + drive : FaithfulRuntime → Nat → G → H → Step → RunResult + + run rt zero g h = trap (hostBudget (FaithfulRuntime.timeoutReason rt)) + , FaithfulRuntime.onTimeout rt h + run rt (suc n) g h = drive rt n g h (FaithfulRuntime.step rt g h) + + drive rt n g h (continue g') = run rt n g' h -- host UNCHANGED + drive rt n g h (returned a) = ok a , FaithfulRuntime.onReturn rt h -- success + drive rt n g h (trapped s) = trap (guestFault (FaithfulRuntime.redact rt s)) + , FaithfulRuntime.recover rt h -- FAULT -> redacted residue + + -------------------------------------------------------------------------------- + -- THE CALL front-end (F2): a SNIF call first does the pre-execution decision. If it fails, + -- the guest NEVER steps and the host is UNTOUCHED; otherwise it runs the guest. + -------------------------------------------------------------------------------- + call : FaithfulRuntime → Nat → G → H → RunResult + call rt n g h with FaithfulRuntime.preCheck rt g h + ... | ready = run rt n g h + ... | preFail r = trap (preExec r) , h + + -------------------------------------------------------------------------------- + -- (A) HOST SURVIVAL / PRESERVATION at the RUN level. If the host is Alive before the guest + -- runs, it is Alive after — WHATEVER the guest does (loop, return, trap, or time out). + -- Proved by induction on fuel; the trap and timeout cases genuinely discharge the + -- trapped/recover and zero/onTimeout branches, so survival under a fault is exercised. + -------------------------------------------------------------------------------- + survives-run : (rt : FaithfulRuntime) (n : Nat) (g : G) (h : H) → + FaithfulRuntime.Alive rt h → + FaithfulRuntime.Alive rt (RunResult.hostAfter (run rt n g h)) + survives-run rt zero g h alive = FaithfulRuntime.timeout-host rt h alive + survives-run rt (suc n) g h alive with FaithfulRuntime.step rt g h + | (λ s → FaithfulRuntime.step-trap-host rt g h s) + | (λ a → FaithfulRuntime.step-return-host rt g h a) + | (λ g' → FaithfulRuntime.step-continue-host rt g h g') + ... | continue g' | _ | _ | contOk = survives-run rt n g' h (contOk g' refl alive) + ... | returned a | _ | retOk | _ = retOk a refl alive + ... | trapped s | trapOk | _ | _ = trapOk s refl alive + + -------------------------------------------------------------------------------- + -- (A') HOST SURVIVAL at the CALL level: a pre-execution failure leaves the host untouched + -- (it trivially survives, the guest never ran); otherwise reuse run-level survival. + -------------------------------------------------------------------------------- + survives : (rt : FaithfulRuntime) (n : Nat) (g : G) (h : H) → + FaithfulRuntime.Alive rt h → + FaithfulRuntime.Alive rt (RunResult.hostAfter (call rt n g h)) + survives rt n g h alive with FaithfulRuntime.preCheck rt g h + ... | ready = survives-run rt n g h alive + ... | preFail r = alive + + -------------------------------------------------------------------------------- + -- (B) CRASH ISOLATION as a Verdict. The call's verdict lands in ok ⊕ trap. We REUSE + -- SnifVerdict.dichotomy: every Verdict is structurally one or the other. (Honest: this is a + -- datatype fact about `Verdict`, true with no FaithfulRuntime in scope — see PROOF-STATUS F3.) + -------------------------------------------------------------------------------- + verdict-dichotomy : (rt : FaithfulRuntime) (n : Nat) (g : G) (h : H) → + IsOk (RunResult.verdict (call rt n g h)) + ⊎ IsTrap (RunResult.verdict (call rt n g h)) + verdict-dichotomy rt n g h = dichotomy (RunResult.verdict (call rt n g h)) + + -------------------------------------------------------------------------------- + -- THE SEC-1 BUNDLE. For a call from an Alive host, the call is ISOLATED: + -- * verdict ∈ ok ⊕ trap (crash isolation) + -- * host survives (preservation — the TCB-consuming content) + -- * no total extractor Verdict A R → A (non-forgery; the error carries no guest value) + -- Confidentiality (the residue hides the secret) is the SEPARATE `run-deniable` theorem + -- below, now stated over this same operational `run` (F1). + -------------------------------------------------------------------------------- + record Isolated (rt : FaithfulRuntime) (n : Nat) (g : G) (h : H) : Set₁ where + field + okOrTrap : IsOk (RunResult.verdict (call rt n g h)) + ⊎ IsTrap (RunResult.verdict (call rt n g h)) + hostSafe : FaithfulRuntime.Alive rt (RunResult.hostAfter (call rt n g h)) + noForgery : (∀ {A' R' : Set} → Verdict A' R' → A') → ⊥ + + -- SEC-1. The operational crash-isolation theorem (over a full SNIF call). + isolation : (rt : FaithfulRuntime) (n : Nat) (g : G) (h : H) → + FaithfulRuntime.Alive rt h → Isolated rt n g h + isolation rt n g h alive = record + { okOrTrap = verdict-dichotomy rt n g h + ; hostSafe = survives rt n g h alive + ; noForgery = no-reflect + } + + -------------------------------------------------------------------------------- + -- (C) OPERATIONAL DENIABILITY (F1) — deniability re-derived over the ACTUAL run. + -- + -- The run's fault residue is exactly the redacted secret, produced through SnifVerdict's + -- `observe` channel; hence two faulting runs whose secrets redact equally are + -- host-indistinguishable in their verdict. This is the property that used to live only in + -- SnifVerdict's separate `Outcome` model and was not wired into SEC-1. + -------------------------------------------------------------------------------- + + -- The run's guest-fault residue IS SnifVerdict.observe applied to the redaction channel: + -- the operational trap verdict factors through the only secret→public channel there is. + fault-via-observe : + (rt : FaithfulRuntime) (s : S) → + trap {A = A} (guestFault (FaithfulRuntime.redact rt s)) + ≡ observe (λ s' → guestFault (FaithfulRuntime.redact rt s')) (faulted s) + fault-via-observe rt s = refl + + -- A faulting first step yields exactly the redacted residue (single `cong`, via `drive`). + fault-run-verdict : + (rt : FaithfulRuntime) (n : Nat) (g : G) (h : H) (s : S) → + FaithfulRuntime.step rt g h ≡ trapped s → + RunResult.verdict (run rt (suc n) g h) + ≡ trap (guestFault (FaithfulRuntime.redact rt s)) + fault-run-verdict rt n g h s eq = + cong (λ st → RunResult.verdict (drive rt n g h st)) eq + + -- Restricted deniability over `observe`: equal redactions give equal residues. + guestFault-deniable : + (rt : FaithfulRuntime) (s₁ s₂ : S) → + FaithfulRuntime.redact rt s₁ ≡ FaithfulRuntime.redact rt s₂ → + trap {A = A} (guestFault (FaithfulRuntime.redact rt s₁)) + ≡ trap (guestFault (FaithfulRuntime.redact rt s₂)) + guestFault-deniable rt s₁ s₂ eq = cong (λ r → trap (guestFault r)) eq + + -- THE OPERATIONAL DENIABILITY THEOREM (F1): two runs that fault on secrets which redact + -- equally produce the SAME host-observable verdict. No host opener can tell them apart. + run-deniable : + (rt : FaithfulRuntime) (n : Nat) + (g₁ : G) (h₁ : H) (g₂ : G) (h₂ : H) (s₁ s₂ : S) → + FaithfulRuntime.step rt g₁ h₁ ≡ trapped s₁ → + FaithfulRuntime.step rt g₂ h₂ ≡ trapped s₂ → + FaithfulRuntime.redact rt s₁ ≡ FaithfulRuntime.redact rt s₂ → + RunResult.verdict (run rt (suc n) g₁ h₁) + ≡ RunResult.verdict (run rt (suc n) g₂ h₂) + run-deniable rt n g₁ h₁ g₂ h₂ s₁ s₂ e₁ e₂ er = + trans (fault-run-verdict rt n g₁ h₁ s₁ e₁) + (trans (guestFault-deniable rt s₁ s₂ er) + (sym (fault-run-verdict rt n g₂ h₂ s₂ e₂))) + + -- HOST-BUDGET residue is secret-free: a fuel-exhaustion verdict is host-canonical and does + -- not depend on the guest at all (the residue carries zero guest information, by construction). + timeout-secret-free : + (rt : FaithfulRuntime) (g₁ g₂ : G) (h : H) → + RunResult.verdict (run rt zero g₁ h) ≡ RunResult.verdict (run rt zero g₂ h) + timeout-secret-free rt g₁ g₂ h = refl + +-------------------------------------------------------------------------------- +-- NON-VACUITY WITNESSES — the model ADMITS FAULTING RUNS, demonstrated CONCRETELY. +-- +-- A SEC-1 over a model where the guest can never trap would be vacuously true. We rule that +-- out by exhibiting runtimes whose guest TRAPS (so the trapped/recover branch is reachable), +-- whose guest RETURNS (so the ok branch is reachable — a real ⊕), whose pre-check FAILS (so a +-- pre-execution origin is reachable), and whose `Alive` is genuinely partial (F5). +-------------------------------------------------------------------------------- + +module UnitWitness where + + open Model ⊤ ⊤ ⊤ ⊤ ⊤ + + -- (1) A runtime whose guest faults immediately. Host trivially Alive (`λ _ → ⊤`); redaction is + -- the unit channel; pre-check is always `ready` (the guest is dispatched). + trapping-runtime : FaithfulRuntime + trapping-runtime = record + { step = λ _ _ → trapped tt -- guest faults on the first step + ; preCheck = λ _ _ → ready + ; redact = λ _ → tt + ; Alive = λ _ → ⊤ + ; step-continue-host = λ _ _ _ _ a → a + ; onReturn = λ h → h + ; step-return-host = λ _ _ _ _ a → a + ; recover = λ h → h + ; step-trap-host = λ _ _ _ _ a → a + ; timeoutReason = tt + ; onTimeout = λ h → h + ; timeout-host = λ _ a → a + } + + -- Its one-step run yields a guest-fault TRAP verdict — PROVEN by refl, not assumed. + actually-traps : + RunResult.verdict (run trapping-runtime (suc zero) tt tt) ≡ trap (guestFault tt) + actually-traps = refl + + -- And that verdict lands in the TRAP side of the dichotomy: the faulting case is a real, + -- inhabited branch that `isolation` must (and does) handle. + trap-case-live : + IsTrap (RunResult.verdict (run trapping-runtime (suc zero) tt tt)) + trap-case-live = isTrap (guestFault tt) + + -- (2) A runtime that returns a value, so the OK branch is reachable too (genuine ⊕). + returning-runtime : FaithfulRuntime + returning-runtime = record + { step = λ _ _ → returned tt + ; preCheck = λ _ _ → ready + ; redact = λ _ → tt + ; Alive = λ _ → ⊤ + ; step-continue-host = λ _ _ _ _ a → a + ; onReturn = λ h → h + ; step-return-host = λ _ _ _ _ a → a + ; recover = λ h → h + ; step-trap-host = λ _ _ _ _ a → a + ; timeoutReason = tt + ; onTimeout = λ h → h + ; timeout-host = λ _ a → a + } + + ok-case-live : + IsOk (RunResult.verdict (run returning-runtime (suc zero) tt tt)) + ok-case-live = isOk tt + + -- (3) OPERATIONAL DENIABILITY exercised: over the unit redaction, the two faulting runs + -- agree (trivially, since redact is constant) — a concrete instance of `run-deniable`. + unit-deniable : + RunResult.verdict (run trapping-runtime (suc zero) tt tt) + ≡ RunResult.verdict (run trapping-runtime (suc zero) tt tt) + unit-deniable = run-deniable trapping-runtime zero tt tt tt tt tt tt refl refl refl + +-------------------------------------------------------------------------------- +-- (4) PRE-EXECUTION origin (F2): a runtime whose pre-check FAILS — the guest never steps, +-- the host is untouched, yet the call is still ISOLATED (a `preExec` trap, host survives). +-- This is the :load / :no_such_export / :pool class that the run-only model could not express. +-------------------------------------------------------------------------------- + +module PreExecWitness where + + open Model ⊤ ⊤ ⊤ ⊤ ⊤ + + not-found-runtime : FaithfulRuntime + not-found-runtime = record + { step = λ _ _ → returned tt -- irrelevant: the guest never dispatches + ; preCheck = λ _ _ → preFail tt -- e.g. :no_such_export + ; redact = λ _ → tt + ; Alive = λ _ → ⊤ + ; step-continue-host = λ _ _ _ _ a → a + ; onReturn = λ h → h + ; step-return-host = λ _ _ _ _ a → a + ; recover = λ h → h + ; step-trap-host = λ _ _ _ _ a → a + ; timeoutReason = tt + ; onTimeout = λ h → h + ; timeout-host = λ _ a → a + } + + -- The call yields a pre-execution trap, PROVEN by refl. + preexec-traps : + RunResult.verdict (call not-found-runtime (suc zero) tt tt) ≡ trap (preExec tt) + preexec-traps = refl + + -- And the call is isolated: ok-or-trap, host survives (untouched), non-forgery. + preexec-isolated : Isolated not-found-runtime (suc zero) tt tt + preexec-isolated = isolation not-found-runtime (suc zero) tt tt tt + +-------------------------------------------------------------------------------- +-- (5) MULTI-STEP non-vacuity: a guest that COUNTS DOWN through `continue` steps and only THEN +-- traps. This exercises the INDUCTIVE recursion of `run`/`survives` (the `continue` branch is +-- taken before the `trapped` branch fires), so the trap case is reached via the induction. +-------------------------------------------------------------------------------- + +module Countdown where + + open Model Nat ⊤ ⊤ ⊤ ⊤ + + -- A guest that internally steps from (suc (suc k)) straight to k, so even configs reach 0 + -- (which TRAPS, holding the unit secret) and odd configs reach 1 (which RETURNS). + skipStep : Nat → ⊤ → Step + skipStep zero _ = trapped tt + skipStep (suc zero) _ = returned tt + skipStep (suc (suc k)) _ = continue k + + skipRT : FaithfulRuntime + skipRT = record + { step = skipStep + ; preCheck = λ _ _ → ready + ; redact = λ _ → tt + ; Alive = λ _ → ⊤ + ; step-continue-host = λ _ _ _ _ a → a + ; onReturn = λ h → h + ; step-return-host = λ _ _ _ _ a → a + ; recover = λ h → h + ; step-trap-host = λ _ _ _ _ a → a + ; timeoutReason = tt + ; onTimeout = λ h → h + ; timeout-host = λ _ a → a + } + + -- config 4 ⟶ 2 ⟶ 0 ⟶ trap: TWO continue steps then a trap. Fuel 3 suffices. Proves the + -- trap branch is reached THROUGH the inductive `continue` recursion. + multistep-traps : + RunResult.verdict (run skipRT (suc (suc (suc zero))) (suc (suc (suc (suc zero)))) tt) + ≡ trap (guestFault tt) + multistep-traps = refl + + -- An odd start returns after its continues: config 3 ⟶ 1 ⟶ return. + multistep-returns : + RunResult.verdict (run skipRT (suc (suc zero)) (suc (suc (suc zero))) tt) ≡ ok tt + multistep-returns = refl + + -- (6) TIMEOUT-as-host-budget-trap is reachable, not a stuck state: too little fuel for the + -- countdown still yields a host-budget trap verdict (the fuel-zero branch of `run`). + timeout-traps : + RunResult.verdict (run skipRT zero (suc (suc (suc (suc zero)))) tt) ≡ trap (hostBudget tt) + timeout-traps = refl + + -- (7) ISOLATION delivers on the genuinely-faulting multistep call: host survives a fault + -- reached by induction. This is SEC-1 instantiated on a real faulting trace. + multistep-isolated : + Isolated skipRT (suc (suc (suc zero))) (suc (suc (suc (suc zero)))) tt + multistep-isolated = + isolation skipRT (suc (suc (suc zero))) (suc (suc (suc (suc zero)))) tt tt + +-------------------------------------------------------------------------------- +-- (8) NON-TRIVIAL `Alive` WITNESS (F5). The preservation fields have shape +-- `Alive h → Alive (handler h)`, satisfiable even by `Alive = ⊥`. Here `Alive?` is genuinely +-- PARTIAL — `dead` is NOT alive — and `recover` maps a dying host to a live one, so recovery +-- is real (not vacuous). This rebuts the *vacuity* worry; `Alive` faithfulness itself remains +-- TCB (a real wasmex run starts from a live host). +-------------------------------------------------------------------------------- + +module PartialAlive where + + data HostState : Set where live dead : HostState + + open Model ⊤ HostState ⊤ ⊤ ⊤ + + Alive? : HostState → Set + Alive? live = ⊤ + Alive? dead = ⊥ -- non-trivial: not every host state is alive + + -- A faulting runtime whose `recover` restores liveness (dead ⟶ live). + recover-runtime : FaithfulRuntime + recover-runtime = record + { step = λ _ _ → trapped tt + ; preCheck = λ _ _ → ready + ; redact = λ _ → tt + ; Alive = Alive? + ; step-continue-host = λ _ _ _ _ a → a + ; onReturn = λ _ → live + ; step-return-host = λ _ _ _ _ _ → tt -- Alive? (onReturn h) = Alive? live = ⊤ + ; recover = λ _ → live -- recovery RESTORES liveness + ; step-trap-host = λ _ _ _ _ _ → tt -- Alive? (recover h) = Alive? live = ⊤ + ; timeoutReason = tt + ; onTimeout = λ _ → live + ; timeout-host = λ _ _ → tt + } + + -- `dead` is genuinely NOT alive — the predicate is non-trivial, not the all-⊤ stub. + dead-not-alive : ¬ (Alive? dead) + dead-not-alive () + + -- A guest fault FROM A DEAD HOST yields a LIVE host: recovery is real, proven by refl. + recovers-dead : + RunResult.hostAfter (run recover-runtime (suc zero) tt dead) ≡ live + recovers-dead = refl + + -- ...and that recovered host IS alive, though the dead one was not. + recovered-alive : + Alive? (RunResult.hostAfter (run recover-runtime (suc zero) tt dead)) + recovered-alive = tt + +-------------------------------------------------------------------------------- +-- (9) DENIABILITY IS NON-VACUOUS (F1, the load-bearing demonstration). Over a secret type with +-- TWO DISTINCT inhabitants and a CONSTANT redaction, a guest fault holding secret `one` and a +-- guest fault holding secret `two` produce the SAME host verdict: the residue reveals nothing +-- about which secret the guest held. This drives `run-deniable` with s₁ ≢ s₂ (not the trivial +-- s₁ = s₂ of UnitWitness), so it genuinely exercises the confidentiality content of SEC-1. +-------------------------------------------------------------------------------- + +module SecretWitness where + + data Two : Set where one two : Two + + -- Guest config carries the secret it will fault with; S = Two; redaction is constant. + open Model Two ⊤ ⊤ ⊤ Two + + leak-runtime : FaithfulRuntime + leak-runtime = record + { step = λ g _ → trapped g -- guest faults holding its secret g : Two + ; preCheck = λ _ _ → ready + ; redact = λ _ → tt -- CONSTANT: the public residue carries nothing + ; Alive = λ _ → ⊤ + ; step-continue-host = λ _ _ _ _ a → a + ; onReturn = λ h → h + ; step-return-host = λ _ _ _ _ a → a + ; recover = λ h → h + ; step-trap-host = λ _ _ _ _ a → a + ; timeoutReason = tt + ; onTimeout = λ h → h + ; timeout-host = λ _ a → a + } + + -- The two secrets are genuinely distinct. + one≢two : ¬ (one ≡ two) + one≢two () + + -- Yet the two faulting runs are HOST-INDISTINGUISHABLE — derived THROUGH `run-deniable`, + -- so this is the operational deniability theorem doing real work, not a hand-built refl. + secrets-indistinguishable : + RunResult.verdict (run leak-runtime (suc zero) one tt) + ≡ RunResult.verdict (run leak-runtime (suc zero) two tt) + secrets-indistinguishable = + run-deniable leak-runtime zero one tt two tt one two refl refl refl diff --git a/verification/proofs/agda/SnifVerdict.agda b/verification/proofs/agda/SnifVerdict.agda new file mode 100644 index 0000000..9b01db7 --- /dev/null +++ b/verification/proofs/agda/SnifVerdict.agda @@ -0,0 +1,117 @@ +{-# OPTIONS --safe --without-K #-} + +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +-- +-- SnifVerdict — a tropical-free safety/security model for SNIF (Safer NIF) verdicts. +-- +-- DRAFT (2026-06-16). Bridges the two QUALITATIVE cores identified with the owner: +-- * echo-types `EchoDeniability.IsDeniable` — the LOSS / TRACE side: "the guest's +-- information is gone, and you cannot recover which history produced the residue". +-- Gives CONFIDENTIALITY (the trapped guest's secret state does not leak). +-- * epistemic-types `BeliefModality` (NO `reflect`) — the NOT-HOLDING side: "the host +-- holds no guest value". Gives NON-FORGERY / integrity (a trapped guest cannot hand +-- the host a value). +-- +-- Neither core needs the tropical grade: crash-isolation, non-forgery and restricted +-- deniability are all STRUCTURAL. (Tropical re-enters only for the cost/liveness view — +-- "how much was lost", fuel bounds — which is deliberately absent here.) +-- +-- Self-contained (only Agda.Builtin.Equality) so it `agda --safe --without-K`-checks with +-- no library. Upstream homes for the generic versions: +-- echo-types/proofs/agda/EchoDeniability.agda (IsDeniable, perfect/partial spectrum) +-- epistemic-types/src/EpistemicTypes/{Base,EchoBridge}.agda (Factive vs Belief modality) + +module SnifVerdict where + +open import Agda.Builtin.Equality using (_≡_; refl) + +data ⊥ : Set where +record ⊤ : Set where constructor tt + +¬_ : Set → Set +¬ A = A → ⊥ + +data _⊎_ (A B : Set) : Set where + inl : A → A ⊎ B + inr : B → A ⊎ B + +cong : {A B : Set} (f : A → B) {x y : A} → x ≡ y → f x ≡ f y +cong f refl = refl + +-------------------------------------------------------------------------------- +-- The guest outcome (private) and the host-observable verdict (public). +-------------------------------------------------------------------------------- + +-- A guest either returned a value of type A, or faulted while holding some secret +-- internal state of type S (linear memory, inputs, partial work). +data Outcome (A S : Set) : Set where + ran : A → Outcome A S + faulted : S → Outcome A S + +-- The host verdict carries a value (ok) or a PUBLIC reason of type R (trap). +-- Crucially `trap` never carries S: the secret cannot cross the cleave surface. +data Verdict (A R : Set) : Set where + ok : A → Verdict A R + trap : R → Verdict A R + +-- The SNIF boundary. A fault's secret is REDACTED to a public reason via `redact`, +-- the only channel from guest secret to host-visible residue. +observe : {A R S : Set} (redact : S → R) → Outcome A S → Verdict A R +observe redact (ran a) = ok a +observe redact (faulted s) = trap (redact s) + +-------------------------------------------------------------------------------- +-- (1) CRASH ISOLATION — every verdict is ok-or-trap (the {:ok,_} | {:error,_} ⊕). +-------------------------------------------------------------------------------- + +data IsOk {A R : Set} : Verdict A R → Set where isOk : (a : A) → IsOk (ok a) +data IsTrap {A R : Set} : Verdict A R → Set where isTrap : (r : R) → IsTrap (trap r) + +dichotomy : {A R : Set} (v : Verdict A R) → IsOk v ⊎ IsTrap v +dichotomy (ok a) = inl (isOk a) +dichotomy (trap r) = inr (isTrap r) + +-------------------------------------------------------------------------------- +-- (2) NON-FORGERY / non-factivity (epistemic BeliefModality: there is no `reflect`). +-- No total extractor `Verdict A R → A` exists: a trapped guest cannot hand the +-- host a value. Proof: instantiate A = ⊥; `trap tt : Verdict ⊥ ⊤` would yield ⊥. +-------------------------------------------------------------------------------- + +no-reflect : (∀ {A R : Set} → Verdict A R → A) → ⊥ +no-reflect extract = extract {⊥} {⊤} (trap tt) + +-------------------------------------------------------------------------------- +-- (3) RESTRICTED DENIABILITY (echo EchoDeniability.IsDeniable): the guest secret is +-- recoverable from the verdict only up to `redact`. Two faults whose secrets +-- redact equally yield the SAME verdict, so no host opener can distinguish them. +-------------------------------------------------------------------------------- + +deniable-upto-redaction : {A R S : Set} (redact : S → R) (s₁ s₂ : S) → + redact s₁ ≡ redact s₂ → + observe {A = A} redact (faulted s₁) ≡ observe {A = A} redact (faulted s₂) +deniable-upto-redaction redact s₁ s₂ eq = cong trap eq + +-- Perfect deniability (echo `produce-perfect`): a CONSTANT redaction (the reason carries +-- nothing of the secret) makes every fault indistinguishable — full confidentiality. +perfect-deniable : {A R S : Set} (c : R) (s₁ s₂ : S) → + observe {A = A} (λ _ → c) (faulted s₁) ≡ observe {A = A} (λ _ → c) (faulted s₂) +perfect-deniable c s₁ s₂ = refl + +-- Any external opener `d` that reads the verdict learns nothing of S beyond `redact`: +-- it agrees on faults that redact equally. (The host, a privileged opener, may still +-- read the public reason R — the echo `partial-deniable-restricted` cut-point.) +opener-cannot-distinguish : {A R S : Set} (redact : S → R) (d : Verdict A R → S) + (s₁ s₂ : S) → redact s₁ ≡ redact s₂ → + d (observe {A = A} redact (faulted s₁)) + ≡ d (observe {A = A} redact (faulted s₂)) +opener-cannot-distinguish redact d s₁ s₂ eq = cong d (cong trap eq) + +-------------------------------------------------------------------------------- +-- BRIDGE THEOREM (informal): for every SNIF call, +-- `dichotomy` — the host lands in ok ⊕ trap (crash isolation) +-- `no-reflect` — the trap branch yields no guest value (non-forgery / integrity) +-- `deniable-upto-redaction` — the trap residue hides the secret (non-leak / confidentiality) +-- All three are tropical-free. The cost grade (how much was lost / fuel) is a SEPARATE, +-- optional view and is intentionally not imported here. +-------------------------------------------------------------------------------- diff --git a/verification/proofs/coq/TypeSafety.v b/verification/proofs/coq/TypeSafety.v index 8f5b418..4e0efff 100644 --- a/verification/proofs/coq/TypeSafety.v +++ b/verification/proofs/coq/TypeSafety.v @@ -1,5 +1,10 @@ (* SPDX-License-Identifier: MPL-2.0 *) (* Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) *) +(* SCAFFOLD — NOT A SNIF PROOF. Unfilled rsr-template residue (toy Nat/Bool + expression-language soundness), excluded from the proof gate (Justfile + `proof-check-all`, PROOF-STATUS.md). The SNIF proofs that count are in + verification/proofs/{idris2,lean4}. Candidate real Coq obligation: the WASM + crash-isolation theorem mechanised via WasmCert-Coq. *) (* Coq Proof Template: Type system soundness Replace with your project's type system proofs. diff --git a/verification/proofs/idris2/ABI/BufferAbi.idr b/verification/proofs/idris2/ABI/BufferAbi.idr new file mode 100644 index 0000000..f5daf3b --- /dev/null +++ b/verification/proofs/idris2/ABI/BufferAbi.idr @@ -0,0 +1,182 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) Jonathan D.A. Jewell +-- +-- ABI-7: FFI signature proofs for the SNIF Buffer ABI v1 guest (zig/src/buffer_abi.zig). +-- +-- Companion to ABI.Foreign (which models the 8 safe_nif crash/compute exports). The buffer +-- guest's exports cross the (ptr,len) marshalling boundary and — crucially — three of them +-- RETURN VOID (snif_dealloc, snif_reset, scale_f32). The single-value `WasmFuncSpec` of +-- ABI.Foreign cannot express a void return, so this module uses a WASM-faithful MULTI-VALUE +-- signature record (`resultTypes : List WasmValType`, where `[]` = void, `[t]` = one result). +-- +-- Each spec records the WASM-level signature (i32/i64/f32/f64) of an export, exactly as it +-- appears in the built `buffer_abi_ReleaseSafe.wasm`. The Python conformance gate +-- (verification/tools/abi_conformance.py, "buffer_abi" guest) parses these specs and FAILS if +-- the built artifact's real signatures drift from this verified model. +-- +-- SCOPE (honest): like ABI.Foreign this verifies the INTERFACE (names, params, results), not +-- BEHAVIOUR (that sum_f32 truly sums) — that is the metamorphic gate (GAP-1b). The pointer +-- SEMANTICS of the (ptr,len) words (in-bounds, ownership) are the deeper ABI-6 array-marshalling +-- obligation and are deliberately NOT claimed here: at the wasm signature level a `usize` is an +-- `i32` and that is all this gate asserts. +-- +-- All proofs are constructive (no believe_me, no assert_total). + +module ABI.BufferAbi + +import ABI.Layout +import Data.List + +%default total + +-------------------------------------------------------------------------------- +-- WASM multi-value function signature (void-faithful) +-------------------------------------------------------------------------------- + +||| Specification of a WASM exported function with MULTI-VALUE results. +||| `resultTypes = []` models a void return; `[t]` a single result; `[t,u,..]` +||| the WASM multi-value case. This is strictly more faithful than ABI.Foreign's +||| single `returnType`, which the safe_nif guest (no void exports) did not need. +public export +record WasmSig where + constructor MkWasmSig + funcName : String + paramTypes : List WasmValType + resultTypes : List WasmValType + +||| Proof that a signature has specific result types (`[]` = void). +public export +Returns : WasmSig -> List WasmValType -> Type +Returns sig rs = resultTypes sig = rs + +||| Proof that a signature has a specific number of parameters. +public export +HasArity : WasmSig -> Nat -> Type +HasArity sig n = length (paramTypes sig) = n + +||| Proof that a signature returns void (no results). +public export +IsVoid : WasmSig -> Type +IsVoid sig = resultTypes sig = [] + +-------------------------------------------------------------------------------- +-- Buffer ABI export specifications (matching zig/src/buffer_abi.zig, wasm-level) +-------------------------------------------------------------------------------- + +||| snif_alloc(len: usize) -> usize (i32) -> i32 — bump-allocate a scratch region +public export +specSnifAlloc : WasmSig +specSnifAlloc = MkWasmSig "snif_alloc" [I32] [I32] + +||| snif_dealloc(ptr: usize, len: usize) -> void (i32 i32) -> () +public export +specSnifDealloc : WasmSig +specSnifDealloc = MkWasmSig "snif_dealloc" [I32, I32] [] + +||| snif_reset() -> void () -> () — reset the bump allocator +public export +specSnifReset : WasmSig +specSnifReset = MkWasmSig "snif_reset" [] [] + +||| sum_f32(ptr: usize, n: usize) -> f32 (i32 i32) -> f32 +public export +specSumF32 : WasmSig +specSumF32 = MkWasmSig "sum_f32" [I32, I32] [F32] + +||| scale_f32(ptr: usize, n: usize, k: f32) -> void (i32 i32 f32) -> () — in place +public export +specScaleF32 : WasmSig +specScaleF32 = MkWasmSig "scale_f32" [I32, I32, F32] [] + +||| still_alive() -> i32 () -> i32 — liveness probe (shared name with safe_nif) +public export +specStillAlive : WasmSig +specStillAlive = MkWasmSig "still_alive" [] [I32] + +||| crash_oob_buffer(ptr: usize, n: usize) -> f32 (i32 i32) -> f32 — traps OOB under ReleaseSafe +public export +specCrashOobBuffer : WasmSig +specCrashOobBuffer = MkWasmSig "crash_oob_buffer" [I32, I32] [F32] + +||| All 7 buffer-ABI exports as a list. +public export +allBufferExports : List WasmSig +allBufferExports = + [ specSnifAlloc, specSnifDealloc, specSnifReset + , specSumF32, specScaleF32 + , specStillAlive, specCrashOobBuffer + ] + +-------------------------------------------------------------------------------- +-- Count +-------------------------------------------------------------------------------- + +||| Proof that we model exactly 7 buffer-ABI exports. +export +bufferExportCount : length BufferAbi.allBufferExports = 7 +bufferExportCount = Refl + +-------------------------------------------------------------------------------- +-- Result-type proofs (value-returning AND void) +-------------------------------------------------------------------------------- + +export +snifAllocReturnsI32 : Returns BufferAbi.specSnifAlloc [I32] +snifAllocReturnsI32 = Refl + +export +sumF32ReturnsF32 : Returns BufferAbi.specSumF32 [F32] +sumF32ReturnsF32 = Refl + +export +crashOobBufferReturnsF32 : Returns BufferAbi.specCrashOobBuffer [F32] +crashOobBufferReturnsF32 = Refl + +export +stillAliveReturnsI32 : Returns BufferAbi.specStillAlive [I32] +stillAliveReturnsI32 = Refl + +||| The three void-returning exports — representable only because results are a List. +export +snifDeallocIsVoid : IsVoid BufferAbi.specSnifDealloc +snifDeallocIsVoid = Refl + +export +snifResetIsVoid : IsVoid BufferAbi.specSnifReset +snifResetIsVoid = Refl + +export +scaleF32IsVoid : IsVoid BufferAbi.specScaleF32 +scaleF32IsVoid = Refl + +-------------------------------------------------------------------------------- +-- Arity proofs +-------------------------------------------------------------------------------- + +export +snifAllocArity1 : HasArity BufferAbi.specSnifAlloc 1 +snifAllocArity1 = Refl + +export +snifDeallocArity2 : HasArity BufferAbi.specSnifDealloc 2 +snifDeallocArity2 = Refl + +export +snifResetArity0 : HasArity BufferAbi.specSnifReset 0 +snifResetArity0 = Refl + +export +sumF32Arity2 : HasArity BufferAbi.specSumF32 2 +sumF32Arity2 = Refl + +export +scaleF32Arity3 : HasArity BufferAbi.specScaleF32 3 +scaleF32Arity3 = Refl + +export +stillAliveArity0 : HasArity BufferAbi.specStillAlive 0 +stillAliveArity0 = Refl + +export +crashOobBufferArity2 : HasArity BufferAbi.specCrashOobBuffer 2 +crashOobBufferArity2 = Refl diff --git a/verification/proofs/idris2/ABI/Compliance.idr b/verification/proofs/idris2/ABI/Compliance.idr index b1a393f..790422d 100644 --- a/verification/proofs/idris2/ABI/Compliance.idr +++ b/verification/proofs/idris2/ABI/Compliance.idr @@ -20,6 +20,7 @@ import ABI.Layout import ABI.Platform import ABI.Foreign import Data.List +import Data.Nat %default total @@ -47,14 +48,18 @@ data AllFieldsInBounds : (size : Nat) -> List StructField -> Type where public export record CABICompliant (layout : StructLayout) where constructor MkCompliant - fieldsAligned : AllFieldsAligned (layoutFields layout) - fieldsInBounds : AllFieldsInBounds (layoutSize layout) (layoutFields layout) - sizeAligned : modNatNZ (layoutSize layout) (layoutAlignment layout) SIsNonZero = 0 + fieldsAligned : AllFieldsAligned (layoutFields layout) + fieldsInBounds : AllFieldsInBounds (layoutSize layout) (layoutFields layout) + -- The alignment must be nonzero for divisibility to be meaningful; this erased + -- witness supplies `modNatNZ`'s NonZero argument for the abstract alignment + -- (the divisor does not reduce to `S _`, so the deprecated SIsNonZero cannot solve it). + alignmentNonZero : NonZero (layoutAlignment layout) + sizeAligned : modNatNZ (layoutSize layout) (layoutAlignment layout) alignmentNonZero = 0 ||| An empty struct is trivially compliant (size=1, alignment=1). export emptyStructCompliant : CABICompliant (MkLayout "empty" [] 1 1) -emptyStructCompliant = MkCompliant AFANil AFBNil Refl +emptyStructCompliant = MkCompliant AFANil AFBNil ItIsSucc Refl -------------------------------------------------------------------------------- -- Scalar Function ABI Compliance @@ -76,38 +81,38 @@ data ScalarABICompliant : WasmFuncSpec -> Type where ||| Proof that fibonacci is scalar ABI compliant. export -fibonacciCompliant : ScalarABICompliant specFibonacci +fibonacciCompliant : ScalarABICompliant Foreign.specFibonacci fibonacciCompliant = MkScalarCompliant specFibonacci ||| Proof that checked_add is scalar ABI compliant. export -checkedAddCompliant : ScalarABICompliant specCheckedAdd +checkedAddCompliant : ScalarABICompliant Foreign.specCheckedAdd checkedAddCompliant = MkScalarCompliant specCheckedAdd ||| Proof that all crash functions are scalar ABI compliant. export -crashOobCompliant : ScalarABICompliant specCrashOob +crashOobCompliant : ScalarABICompliant Foreign.specCrashOob crashOobCompliant = MkScalarCompliant specCrashOob export -crashUnreachableCompliant : ScalarABICompliant specCrashUnreachable +crashUnreachableCompliant : ScalarABICompliant Foreign.specCrashUnreachable crashUnreachableCompliant = MkScalarCompliant specCrashUnreachable export -crashPanicCompliant : ScalarABICompliant specCrashPanic +crashPanicCompliant : ScalarABICompliant Foreign.specCrashPanic crashPanicCompliant = MkScalarCompliant specCrashPanic export -crashOverflowCompliant : ScalarABICompliant specCrashOverflow +crashOverflowCompliant : ScalarABICompliant Foreign.specCrashOverflow crashOverflowCompliant = MkScalarCompliant specCrashOverflow export -crashDivZeroCompliant : ScalarABICompliant specCrashDivZero +crashDivZeroCompliant : ScalarABICompliant Foreign.specCrashDivZero crashDivZeroCompliant = MkScalarCompliant specCrashDivZero ||| Proof that still_alive is scalar ABI compliant. export -stillAliveCompliant : ScalarABICompliant specStillAlive +stillAliveCompliant : ScalarABICompliant Foreign.specStillAlive stillAliveCompliant = MkScalarCompliant specStillAlive -------------------------------------------------------------------------------- @@ -123,7 +128,7 @@ data AllScalarCompliant : List WasmFuncSpec -> Type where ||| Proof that all 8 SNIF exports are scalar ABI compliant. export -allSnifExportsCompliant : AllScalarCompliant allSnifExports +allSnifExportsCompliant : AllScalarCompliant Foreign.allSnifExports allSnifExportsCompliant = ASCCons fibonacciCompliant $ ASCCons checkedAddCompliant $ @@ -158,13 +163,24 @@ public export arrayTotalBytes : WasmArrayLayout -> Nat arrayTotalBytes arr = arr.elemCount * wasmValSize arr.elemType +||| Every element alignment is a WASM value alignment (always 4 or 8), hence nonzero. +||| Supplies the erased nonzero witness for `modNatNZ` without forcing the abstract +||| `wasmValAlign (elemType arr)` to reduce to a literal `S _`. Mirrors +||| `Layout.fieldAlignmentNonZero`. +public export +elemAlignmentNonZero : (arr : WasmArrayLayout) -> NonZero (wasmValAlign (elemType arr)) +elemAlignmentNonZero (MkWasmArrayLayout I32 _ _) = ItIsSucc +elemAlignmentNonZero (MkWasmArrayLayout I64 _ _) = ItIsSucc +elemAlignmentNonZero (MkWasmArrayLayout F32 _ _) = ItIsSucc +elemAlignmentNonZero (MkWasmArrayLayout F64 _ _) = ItIsSucc + ||| An array layout is valid when: ||| 1. Base offset is aligned to element alignment ||| 2. Total bytes fit within memory size public export record WasmArrayValid (memSize : Nat) (arr : WasmArrayLayout) where constructor MkArrayValid - baseAligned : modNatNZ (baseOffset arr) (wasmValAlign (elemType arr)) SIsNonZero = 0 + baseAligned : modNatNZ (baseOffset arr) (wasmValAlign (elemType arr)) (elemAlignmentNonZero arr) = 0 fitsInMem : LTE (baseOffset arr + arrayTotalBytes arr) memSize ||| Proof that an empty array at offset 0 is always valid (for any memSize > 0). @@ -172,4 +188,9 @@ export emptyArrayValid : {memSize : Nat} -> {auto 0 pos : LT 0 memSize} -> (t : WasmValType) -> WasmArrayValid memSize (MkWasmArrayLayout t 0 0) -emptyArrayValid t = MkArrayValid Refl (lteSuccLeft pos) +-- Case-split on `t` so `wasmValAlign t` reduces to a literal, letting +-- `modNatNZ 0 _ _ = 0` close by Refl; valid for every element type. +emptyArrayValid I32 = MkArrayValid Refl LTEZero +emptyArrayValid I64 = MkArrayValid Refl LTEZero +emptyArrayValid F32 = MkArrayValid Refl LTEZero +emptyArrayValid F64 = MkArrayValid Refl LTEZero diff --git a/verification/proofs/idris2/ABI/Foreign.idr b/verification/proofs/idris2/ABI/Foreign.idr index 05a4f5a..8537930 100644 --- a/verification/proofs/idris2/ABI/Foreign.idr +++ b/verification/proofs/idris2/ABI/Foreign.idr @@ -146,38 +146,38 @@ allSnifExports = ||| Proof that fibonacci returns I64. export -fibonacciReturnsI64 : ReturnsType specFibonacci I64 +fibonacciReturnsI64 : ReturnsType Foreign.specFibonacci I64 fibonacciReturnsI64 = Refl ||| Proof that checked_add returns I32. export -checkedAddReturnsI32 : ReturnsType specCheckedAdd I32 +checkedAddReturnsI32 : ReturnsType Foreign.specCheckedAdd I32 checkedAddReturnsI32 = Refl ||| Proof that all crash functions return I32. export -crashOobReturnsI32 : ReturnsType specCrashOob I32 +crashOobReturnsI32 : ReturnsType Foreign.specCrashOob I32 crashOobReturnsI32 = Refl export -crashUnreachableReturnsI32 : ReturnsType specCrashUnreachable I32 +crashUnreachableReturnsI32 : ReturnsType Foreign.specCrashUnreachable I32 crashUnreachableReturnsI32 = Refl export -crashPanicReturnsI32 : ReturnsType specCrashPanic I32 +crashPanicReturnsI32 : ReturnsType Foreign.specCrashPanic I32 crashPanicReturnsI32 = Refl export -crashOverflowReturnsI32 : ReturnsType specCrashOverflow I32 +crashOverflowReturnsI32 : ReturnsType Foreign.specCrashOverflow I32 crashOverflowReturnsI32 = Refl export -crashDivZeroReturnsI32 : ReturnsType specCrashDivZero I32 +crashDivZeroReturnsI32 : ReturnsType Foreign.specCrashDivZero I32 crashDivZeroReturnsI32 = Refl ||| Proof that still_alive returns I32. export -stillAliveReturnsI32 : ReturnsType specStillAlive I32 +stillAliveReturnsI32 : ReturnsType Foreign.specStillAlive I32 stillAliveReturnsI32 = Refl -------------------------------------------------------------------------------- @@ -186,31 +186,31 @@ stillAliveReturnsI32 = Refl ||| Proof that fibonacci takes exactly 1 parameter. export -fibonacciArity1 : HasArity specFibonacci 1 +fibonacciArity1 : HasArity Foreign.specFibonacci 1 fibonacciArity1 = Refl ||| Proof that checked_add takes exactly 2 parameters. export -checkedAddArity2 : HasArity specCheckedAdd 2 +checkedAddArity2 : HasArity Foreign.specCheckedAdd 2 checkedAddArity2 = Refl ||| Proof that all crash functions take 0 parameters. export -crashFunctionsArity0 : (HasArity specCrashOob 0, - HasArity specCrashUnreachable 0, - HasArity specCrashPanic 0, - HasArity specCrashOverflow 0, - HasArity specCrashDivZero 0) +crashFunctionsArity0 : (HasArity Foreign.specCrashOob 0, + HasArity Foreign.specCrashUnreachable 0, + HasArity Foreign.specCrashPanic 0, + HasArity Foreign.specCrashOverflow 0, + HasArity Foreign.specCrashDivZero 0) crashFunctionsArity0 = (Refl, Refl, Refl, Refl, Refl) ||| Proof that still_alive takes 0 parameters. export -stillAliveArity0 : HasArity specStillAlive 0 +stillAliveArity0 : HasArity Foreign.specStillAlive 0 stillAliveArity0 = Refl ||| Proof that we have exactly 8 SNIF exports. export -snifExportCount : length allSnifExports = 8 +snifExportCount : length Foreign.allSnifExports = 8 snifExportCount = Refl -------------------------------------------------------------------------------- @@ -221,11 +221,11 @@ snifExportCount = Refl ||| We classify the 5 intentional crash functions. public export data IsCrashFunction : WasmFuncSpec -> Type where - CrashOob : IsCrashFunction specCrashOob - CrashUnreachable : IsCrashFunction specCrashUnreachable - CrashPanic : IsCrashFunction specCrashPanic - CrashOverflow : IsCrashFunction specCrashOverflow - CrashDivZero : IsCrashFunction specCrashDivZero + CrashOob : IsCrashFunction Foreign.specCrashOob + CrashUnreachable : IsCrashFunction Foreign.specCrashUnreachable + CrashPanic : IsCrashFunction Foreign.specCrashPanic + CrashOverflow : IsCrashFunction Foreign.specCrashOverflow + CrashDivZero : IsCrashFunction Foreign.specCrashDivZero ||| Proof that crash functions always have arity 0. export diff --git a/verification/proofs/idris2/ABI/Layout.idr b/verification/proofs/idris2/ABI/Layout.idr index 7a2bf70..f94514c 100644 --- a/verification/proofs/idris2/ABI/Layout.idr +++ b/verification/proofs/idris2/ABI/Layout.idr @@ -11,6 +11,8 @@ module ABI.Layout +import Data.Nat + %default total -------------------------------------------------------------------------------- @@ -74,18 +76,18 @@ wasmValNaturallyAligned F64 = Refl ||| Proof that all WASM value sizes are at least 4 bytes. export wasmValSizeAtLeast4 : (t : WasmValType) -> LTE 4 (wasmValSize t) -wasmValSizeAtLeast4 I32 = lteRefl -wasmValSizeAtLeast4 I64 = lteSuccRight (lteSuccRight (lteSuccRight (lteSuccRight lteRefl))) -wasmValSizeAtLeast4 F32 = lteRefl -wasmValSizeAtLeast4 F64 = lteSuccRight (lteSuccRight (lteSuccRight (lteSuccRight lteRefl))) +wasmValSizeAtLeast4 I32 = reflexive +wasmValSizeAtLeast4 I64 = lteSuccRight (lteSuccRight (lteSuccRight (lteSuccRight reflexive))) +wasmValSizeAtLeast4 F32 = reflexive +wasmValSizeAtLeast4 F64 = lteSuccRight (lteSuccRight (lteSuccRight (lteSuccRight reflexive))) ||| Proof that all WASM value sizes are positive (> 0). export wasmValSizePositive : (t : WasmValType) -> LT 0 (wasmValSize t) -wasmValSizePositive I32 = LTESucc (LTESucc (LTESucc (LTESucc LTEZero))) -wasmValSizePositive I64 = LTESucc (LTESucc (LTESucc (LTESucc LTEZero))) -wasmValSizePositive F32 = LTESucc (LTESucc (LTESucc (LTESucc LTEZero))) -wasmValSizePositive F64 = LTESucc (LTESucc (LTESucc (LTESucc LTEZero))) +wasmValSizePositive I32 = LTESucc LTEZero +wasmValSizePositive I64 = LTESucc LTEZero +wasmValSizePositive F32 = LTESucc LTEZero +wasmValSizePositive F64 = LTESucc LTEZero -------------------------------------------------------------------------------- -- Padding and Alignment Arithmetic @@ -128,10 +130,20 @@ public export fieldAlignment : StructField -> Nat fieldAlignment f = wasmValAlign f.fieldType +||| Every field alignment is a WASM value alignment (always 4 or 8), hence nonzero. +||| Supplies the erased nonzero witness for `modNatNZ` without forcing the abstract +||| `wasmValAlign f.fieldType` to reduce to a literal `S _`. +public export +fieldAlignmentNonZero : (f : StructField) -> NonZero (fieldAlignment f) +fieldAlignmentNonZero (MkField _ _ I32) = ItIsSucc +fieldAlignmentNonZero (MkField _ _ I64) = ItIsSucc +fieldAlignmentNonZero (MkField _ _ F32) = ItIsSucc +fieldAlignmentNonZero (MkField _ _ F64) = ItIsSucc + ||| Proof that a field is correctly aligned within a struct. public export FieldAligned : StructField -> Type -FieldAligned f = modNatNZ (fieldOffset f) (fieldAlignment f) SIsNonZero = 0 +FieldAligned f = modNatNZ (fieldOffset f) (fieldAlignment f) (fieldAlignmentNonZero f) = 0 ||| Proof that a field does not overflow past a given struct size. public export diff --git a/verification/proofs/idris2/ABI/Platform.idr b/verification/proofs/idris2/ABI/Platform.idr index 03fbd4e..bda41f4 100644 --- a/verification/proofs/idris2/ABI/Platform.idr +++ b/verification/proofs/idris2/ABI/Platform.idr @@ -62,13 +62,13 @@ ptrSizeValid FreeBSD64 = Right Refl ||| Proof that pointer size is always at least 4 bytes. export ptrSizeAtLeast4 : (p : Platform) -> LTE 4 (ptrSize p) -ptrSizeAtLeast4 WASM32 = lteRefl -ptrSizeAtLeast4 Linux64 = lteSuccRight (lteSuccRight (lteSuccRight (lteSuccRight lteRefl))) -ptrSizeAtLeast4 LinuxARM64 = lteSuccRight (lteSuccRight (lteSuccRight (lteSuccRight lteRefl))) -ptrSizeAtLeast4 MacOS64 = lteSuccRight (lteSuccRight (lteSuccRight (lteSuccRight lteRefl))) -ptrSizeAtLeast4 MacOSARM64 = lteSuccRight (lteSuccRight (lteSuccRight (lteSuccRight lteRefl))) -ptrSizeAtLeast4 Windows64 = lteSuccRight (lteSuccRight (lteSuccRight (lteSuccRight lteRefl))) -ptrSizeAtLeast4 FreeBSD64 = lteSuccRight (lteSuccRight (lteSuccRight (lteSuccRight lteRefl))) +ptrSizeAtLeast4 WASM32 = reflexive +ptrSizeAtLeast4 Linux64 = lteSuccRight (lteSuccRight (lteSuccRight (lteSuccRight reflexive))) +ptrSizeAtLeast4 LinuxARM64 = lteSuccRight (lteSuccRight (lteSuccRight (lteSuccRight reflexive))) +ptrSizeAtLeast4 MacOS64 = lteSuccRight (lteSuccRight (lteSuccRight (lteSuccRight reflexive))) +ptrSizeAtLeast4 MacOSARM64 = lteSuccRight (lteSuccRight (lteSuccRight (lteSuccRight reflexive))) +ptrSizeAtLeast4 Windows64 = lteSuccRight (lteSuccRight (lteSuccRight (lteSuccRight reflexive))) +ptrSizeAtLeast4 FreeBSD64 = lteSuccRight (lteSuccRight (lteSuccRight (lteSuccRight reflexive))) -------------------------------------------------------------------------------- -- Zig/WASM Type Size Correspondence @@ -122,22 +122,34 @@ zigWasmSizeMatch ZigUsize = Refl -------------------------------------------------------------------------------- ||| WASM page size in bytes (fixed by spec). +||| re-modeled over Integer: unary Nat made the typechecker hang on 65536-scale arithmetic public export -WasmPageSize : Nat +WasmPageSize : Integer WasmPageSize = 65536 -||| Proof that WASM page size is a power of 2 (specifically 2^16). -||| We prove this by showing 65536 = 2 * 32768 = ... = 2^16. +||| Integer alignment of each WASM value type (mirrors wasmValAlign over Integer). +||| WASM is 32-bit addressed, so the linear-memory size facts are faithfully +||| stated over machine Integer and evaluate in O(1). +public export +wasmValAlignI : WasmValType -> Integer +wasmValAlignI I32 = 4 +wasmValAlignI I64 = 8 +wasmValAlignI F32 = 4 +wasmValAlignI F64 = 8 + +||| Proof that WASM page size is positive (> 0). +||| re-modeled over Integer: unary Nat made the typechecker hang on 65536-scale arithmetic export -wasmPageSizePositive : LT 0 WasmPageSize -wasmPageSizePositive = LTESucc LTEZero +wasmPageSizePositive : (WasmPageSize > 0) = True +wasmPageSizePositive = Refl ||| Proof that WASM page size is a multiple of all value type alignments. ||| Since all alignments are 4 or 8, and 65536 = 8192 * 8 = 16384 * 4, ||| page boundaries are always properly aligned for any value type. +||| re-modeled over Integer: unary Nat made the typechecker hang on 65536-scale arithmetic export wasmPageAlignedFor : (t : WasmValType) -> - modNatNZ WasmPageSize (wasmValAlign t) SIsNonZero = 0 + mod WasmPageSize (wasmValAlignI t) = 0 wasmPageAlignedFor I32 = Refl wasmPageAlignedFor I64 = Refl wasmPageAlignedFor F32 = Refl @@ -145,12 +157,14 @@ wasmPageAlignedFor F64 = Refl ||| Maximum WASM32 linear memory: 4 GiB (2^32 bytes). ||| Expressed as number of pages. +||| re-modeled over Integer: unary Nat made the typechecker hang on 65536-scale arithmetic public export -WasmMaxPages : Nat +WasmMaxPages : Integer WasmMaxPages = 65536 ||| Proof that max memory = maxPages * pageSize (4 GiB). ||| 65536 * 65536 = 4294967296 = 2^32. +||| re-modeled over Integer: unary Nat made the typechecker hang on 65536-scale arithmetic export wasmMaxMemory : WasmMaxPages * WasmPageSize = 4294967296 wasmMaxMemory = Refl diff --git a/verification/proofs/idris2/ABI/Pointers.idr b/verification/proofs/idris2/ABI/Pointers.idr index eaee155..df30799 100644 --- a/verification/proofs/idris2/ABI/Pointers.idr +++ b/verification/proofs/idris2/ABI/Pointers.idr @@ -32,7 +32,8 @@ public export record WasmAddr (memSize : Nat) where constructor MkWasmAddr index : Nat - 0 inBounds : LT index memSize + -- un-erased: required for constructive proof of wasmAddrInBounds + inBounds : LT index memSize ||| Proof that a WasmAddr is always strictly less than the memory size. export @@ -40,9 +41,11 @@ wasmAddrInBounds : (addr : WasmAddr memSize) -> LT addr.index memSize wasmAddrInBounds addr = addr.inBounds ||| Proof that if memSize > 0, then address 0 is always valid. +||| memSize must be (S k) for `LT 0 memSize` to be inhabited; the Z case is +||| absurd (its erased `pos : LT 0 Z` is uninhabited) so coverage stays total. export zeroAddrValid : {memSize : Nat} -> {auto 0 pos : LT 0 memSize} -> WasmAddr memSize -zeroAddrValid = MkWasmAddr 0 pos +zeroAddrValid {memSize = (S k)} = MkWasmAddr 0 (LTESucc LTEZero) ||| Attempt to create a WasmAddr with a runtime bounds check. ||| Returns Nothing if the index is out of bounds. @@ -56,11 +59,9 @@ checkAddr index memSize = case isLT index memSize of ||| (We prove the specific case: checkAddr n n = Nothing for all n.) export checkAddrOutOfBounds : (n : Nat) -> checkAddr n n = Nothing -checkAddrOutOfBounds n = rewrite ltIrrefl n in Refl - where - ltIrrefl : (k : Nat) -> isLT k k = No (succNotLTEpred k) - ltIrrefl Z = Refl - ltIrrefl (S k) = rewrite ltIrrefl k in Refl +checkAddrOutOfBounds n with (isLT n n) + checkAddrOutOfBounds n | Yes prf = absurd (succNotLTEpred prf) + checkAddrOutOfBounds n | No _ = Refl -------------------------------------------------------------------------------- -- Non-null Pointer Safety (for host-side handles) @@ -73,7 +74,8 @@ public export record SafePtr where constructor MkSafePtr ptr : Bits64 - {auto 0 nonNull : So (ptr /= 0)} + -- un-erased: required for constructive proof of safePtrNeverNull + {auto nonNull : So (ptr /= 0)} ||| Proof that SafePtr can never hold a null (zero) value. export @@ -106,11 +108,20 @@ record WasmHandle (moduleName : String) where constructor MkWasmHandle safePtr : SafePtr +||| So-proofs over the same boolean are unique (proof irrelevance for So). +||| Needed because SafePtr now carries an un-erased nonNull witness. +export +soUnique : (x, y : So b) -> x = y +soUnique Oh Oh = Refl + ||| Proof that two handles with equal pointers are equal. export wasmHandlePtrEq : (h1, h2 : WasmHandle tag) -> h1.safePtr.ptr = h2.safePtr.ptr -> h1 = h2 -wasmHandlePtrEq (MkWasmHandle (MkSafePtr p)) (MkWasmHandle (MkSafePtr p)) Refl = Refl +wasmHandlePtrEq (MkWasmHandle (MkSafePtr p {nonNull = n1})) + (MkWasmHandle (MkSafePtr q {nonNull = n2})) prf = + case prf of + Refl => rewrite soUnique n1 n2 in Refl -------------------------------------------------------------------------------- -- Bounded Memory Region @@ -124,7 +135,8 @@ record MemRegion (memSize : Nat) where start : Nat length : Nat 0 startInBounds : LT start memSize - 0 endInBounds : LTE (start + length) memSize + -- un-erased: required for constructive proof of regionLengthBounded + endInBounds : LTE (start + length) memSize ||| Proof that an empty region at a valid address is always valid. export @@ -134,7 +146,15 @@ emptyRegionValid (MkWasmAddr idx prf) = MkMemRegion idx 0 prf (rewrite plusZeroRightNeutral idx in lteSuccLeft prf) ||| Proof that the length of a MemRegion never exceeds memSize. +||| length <= start + length (lteAddLeft) and start + length <= memSize +||| (endInBounds), so by transitivity length <= memSize. export regionLengthBounded : (r : MemRegion memSize) -> LTE r.length memSize regionLengthBounded (MkMemRegion start length startInBounds endInBounds) = - lteTransitive (lteAddLeft start) endInBounds + lteTrans (lteAddLeft start length) endInBounds + where + lteAddLeft : (s, l : Nat) -> LTE l (s + l) + lteAddLeft s l = rewrite plusCommutative s l in lteAddRight l + lteTrans : LTE a b -> LTE b c -> LTE a c + lteTrans LTEZero _ = LTEZero + lteTrans (LTESucc xy) (LTESucc yz) = LTESucc (lteTrans xy yz) diff --git a/verification/proofs/idris2/Types.idr b/verification/proofs/idris2/Types.idr index 534374f..b6253b6 100644 --- a/verification/proofs/idris2/Types.idr +++ b/verification/proofs/idris2/Types.idr @@ -14,6 +14,7 @@ module Types import Data.So +import Data.Nat import Decidable.Equality %default total @@ -80,7 +81,7 @@ testedTrapKinds = [TrapOOB, TrapUnreachable, TrapPanic, TrapOverflow, TrapDivZer ||| Proof that we test exactly 5 trap kinds. export -testedTrapCount : length testedTrapKinds = 5 +testedTrapCount : List.length Types.testedTrapKinds = 5 testedTrapCount = Refl -------------------------------------------------------------------------------- @@ -212,7 +213,8 @@ public export record Bounded (max : Nat) where constructor MkBounded value : Nat - {auto 0 inBounds : LTE value max} + -- un-erased: required for constructive proof of boundedLeMax + {auto inBounds : LTE value max} ||| Proof that a Bounded value is always <= max. export @@ -227,4 +229,4 @@ zeroIsBounded = MkBounded 0 ||| Proof that max is always a valid Bounded value. export maxIsBounded : {max : Nat} -> Bounded max -maxIsBounded = MkBounded max +maxIsBounded = MkBounded max {inBounds = reflexive} diff --git a/verification/proofs/lean4/ApiTypes.lean b/verification/proofs/lean4/ApiTypes.lean index 90d629d..fd9411e 100644 --- a/verification/proofs/lean4/ApiTypes.lean +++ b/verification/proofs/lean4/ApiTypes.lean @@ -233,9 +233,9 @@ structure BoundedNat (max : Nat) where le_max : val ≤ max /-- A bounded value is always ≤ max. -/ -theorem bounded_nat_le (b : BoundedNat max) : b.val ≤ max := +theorem bounded_nat_le {max : Nat} (b : BoundedNat max) : b.val ≤ max := b.le_max /-- Zero is always bounded (for any positive max). -/ -def zeroBounded (h : 0 < max) : BoundedNat max := +def zeroBounded {max : Nat} (_h : 0 < max) : BoundedNat max := ⟨0, Nat.zero_le max⟩ diff --git a/verification/proofs/tlaplus/StateMachine.tla b/verification/proofs/tlaplus/StateMachine.tla index f348494..e47318b 100644 --- a/verification/proofs/tlaplus/StateMachine.tla +++ b/verification/proofs/tlaplus/StateMachine.tla @@ -10,6 +10,10 @@ (* Replace States, Init, Next with your project's actual states. *) (***************************************************************************) +\* SCAFFOLD — NOT A SNIF SPEC. Unfilled rsr-template residue (generic request +\* pipeline), excluded from the proof gate; TLC is not installed. The SNIF proofs +\* that count are in verification/proofs/{idris2,lean4}. Candidate real TLA+ +\* obligation: the per-call instance lifecycle / fuel-metering model. EXTENDS Naturals, Sequences, FiniteSets CONSTANTS diff --git a/verification/tests/TEST-TAXONOMY.adoc b/verification/tests/TEST-TAXONOMY.adoc new file mode 100644 index 0000000..f58e39c --- /dev/null +++ b/verification/tests/TEST-TAXONOMY.adoc @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell += SNIF Test & Bench Taxonomy +:toc: + +The full verification pyramid for SNIFs (Safer NIFs). Each layer has a *discriminating +assertion* — the property that makes the test fail when the thing under test is wrong, so +no layer can pass trivially. Status legend: ✅ green · 🟡 stub/partial · ⏳ pending Buffer +ABI (in flight) · ⛔ needs OTP 28 (local env is OTP 25; use the wasmtime-CLI path until then). + +[cols="1,3,2,3,1",options="header"] +|=== +| Layer | What it checks | Where | Discriminating assertion | Status + +| *Type tests* (proofs) +| ABI layout/pointer/bounds; API result-type laws; the echo×epistemic safety verdict +| `verification/proofs/idris2/**`, `lean4/ApiTypes.lean`, `agda/SnifVerdict.agda` — gate `just proof-check-all` +| A wrong ABI/verdict shape fails to typecheck (a green gate is a machine-checked spec, not a sample) +| ✅ + +| *Unit* +| Single boundary computation in isolation (no host): `fibonacci`, `checked_add`, `sum_f32`, `scale_f32` +| `zig test` in `zig/src/*.zig`; Elixir loader/marshalling units in `demo/test/` +| `fibonacci(10)=55`; `sum_f32([1,2,3])=6`; a wrong arithmetic answer fails +| 🟡 / ⏳ + +| *Point-to-point* (marshalling) +| The buffer crosses the WASM linear-memory gap and comes back intact: `snif_alloc → write → (ptr,len) → read` +| `verification/tests/p2p/` (new) against the Buffer ABI +| round-trip identity `read(write(buf)) ≡ buf`; OOB write *traps*, never corrupts host memory +| ⏳ (Buffer ABI) + +| *Endpoint* +| One SNIF call through `wasmex` returns `{:ok,_}` xor `{:error,trap}`; the BEAM process stays alive +| `demo/test/snif_demo_test.exs` +| each of the 5 crash modes ⇒ `{:error,_}` then `still_alive()=42`; success path returns the value +| ⛔ (or wasmtime-CLI proxy) + +| *E2E* +| Full pipeline: build wasm (both modes) → load → call all exports incl. buffers → assert isolation + correctness + mode discrimination +| `tests/e2e/`, `.github/workflows/e2e.yml` +| ReleaseSafe traps on OOB/overflow/div-zero; ReleaseFast leaks the `0x0BADF00D` canary (proves the suite *discriminates* modes, not passes trivially) +| ⛔ build ✅ / run ⛔ + +| *Aspect* (architectural invariants) +| Cross-cutting rules: every `priv/*.wasm` is ReleaseSafe; no `rustler`/native-NIF in deps; every export has an ABI proof; the loader never lets a guest fault escape +| `tests/aspect_tests.sh` +| flips red if a ReleaseFast artifact, a raw NIF, or an unproven export is introduced +| 🟡 + +| *Fuzz / property* +| Random inputs to marshalling + crash modes: arbitrary buffers, indices, values +| `tests/fuzz/` +| *invariant under all inputs*: host process survives; trap xor correct value; never silent host corruption +| 🟡 / ⏳ + +| *Bench* (the "gap" cost — your question) +| Overhead of crossing the boundary: wasmtime invoke cost; *per-call vs pooled* instantiation; SNIF vs raw-NIF vs Port +| `benches/snif_eval.sh` (in flight, wasmtime-CLI, runnable now); in-BEAM bench needs OTP 28 +| reports µs/op + a regression threshold; flags if a build mode or pooling change makes a hot call slower +| ⏳ / ⛔ +|=== + +== Notes on the "gap" (boundary-crossing cost) + +The dominant cost today is *per-call instantiation* (ADR-003: `Loader.call` spins a fresh +`wasmex` GenServer every call). That is the "extra-slow SNIF" risk. The fix is the *pooled + +fuel/epoch* instance lifecycle (design track in flight). Expected shape once pooled: a SNIF +call ≈ bounds-checked WASM execution + one GenServer message hop — slower than a raw NIF +(a direct C call, ~no overhead) but far cheaper than a Port (no OS process / serialisation). +For hot tiny calls the GenServer hop dominates, so the rework levers are: (1) pooling, +(2) a direct-instance (non-GenServer) fast path, (3) batching, (4) keeping buffers resident +across calls. The Bench layer quantifies all four; if a use-case is still too slow, that data +is the trigger to rework — not a guess. + +== Gate wiring status + +`just proof-check-all` (Type layer) is ✅ green and CI-wired (`.github/workflows/proofs.yml`). +The Unit/E2E build steps run via `just build-wasm` + `e2e.yml`. To make the proof gate +*blocking*, add "Formal proofs — Idris2 + Lean4" to branch-protection required checks +(owner-only). Concrete per-layer test files for the Buffer-ABI and Rust paths are added once +those artifacts land (in flight). diff --git a/verification/tools/abi_conformance.py b/verification/tools/abi_conformance.py new file mode 100644 index 0000000..d498cb3 --- /dev/null +++ b/verification/tools/abi_conformance.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MPL-2.0 +# Owner: Jonathan D.A. Jewell +# +# abi_conformance.py — gap-1 (interface) drift gate, MULTI-GUEST. +# +# The Idris2 proofs verify a MODEL of each guest's ABI (ABI.Foreign for safe_nif, +# ABI.BufferAbi for the buffer guest). They do NOT, by themselves, establish that the +# model mirrors the ACTUAL built artifact. This checker closes that interface-level gap +# mechanically: for each guest it parses the authoritative model from its Idris source +# and the REAL exports + signatures from the built .wasm, and FAILS if they drift. So a +# change to a Zig source (or to a proof) that desynchronises the proven ABI from the +# shipped binary breaks CI instead of passing silently. +# +# Scope honesty: this checks the INTERFACE (export names, param types, result types) — +# i.e. that the real binary's signatures are exactly the verified model's signatures. +# It does NOT prove the code's BEHAVIOUR matches the model (that sum_f32 truly sums); +# that is the metamorphic/behaviour gate (GAP-1b) and, long-term, extraction. This is the +# buildable front edge of model<->code faithfulness. +# +# GUESTS (the manifest below): safe_nif (ABI.Foreign, single-return WasmFuncSpec) and +# buffer_abi (ABI.BufferAbi, multi-value/void-faithful WasmSig). burble_fft is intentionally +# NOT here: it is not built into any artifact dir and its fft/ifft use (ptr,len) slice +# marshalling, which is the ABI-6 array obligation — see PROOF-STATUS (ABI-7 ledger). +# +# Usage: +# python3 verification/tools/abi_conformance.py # check ALL guests in the manifest +# python3 verification/tools/abi_conformance.py PATH.wasm # check one wasm (matched to a guest, +# # else against the safe_nif model) +# Exit 0 = every checked artifact conforms to its verified ABI model; non-zero = drift. + +import os +import re +import subprocess +import sys + +REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +IDRIS = os.path.join(REPO, "verification", "proofs", "idris2", "ABI") + +# Model value type -> WASM value type. +TYMAP = {"I32": "i32", "I64": "i64", "F32": "f32", "F64": "f64"} + +# The guest manifest: name -> built artifact, Idris model source, constructor, return format. +# fmt "single": MkWasmFuncSpec "name" [params] Ret (one result word; safe_nif) +# fmt "multi" : MkWasmSig "name" [params] [results] ([] = void; buffer_abi) +GUESTS = [ + { + "name": "safe_nif", + "wasm": os.path.join(REPO, "priv", "safe_nif_ReleaseSafe.wasm"), + "model": os.path.join(IDRIS, "Foreign.idr"), + "ctor": "MkWasmFuncSpec", + "fmt": "single", + }, + { + "name": "buffer_abi", + "wasm": os.path.join(REPO, "zig", "buffer_abi_build", "buffer_abi_ReleaseSafe.wasm"), + "model": os.path.join(IDRIS, "BufferAbi.idr"), + "ctor": "MkWasmSig", + "fmt": "multi", + }, +] + + +def parse_model(path, ctor, fmt): + """Idris spec bindings -> {name: (params:list, results:list)}. + + `single`: ` "name" [T,..] Ret` -> results = [Ret] + `multi` : ` "name" [T,..] [R,..]` -> results = [R,..] ([] = void) + """ + if fmt == "single": + spec = re.compile(rf'{ctor}\s+"([^"]+)"\s+\[([^\]]*)\]\s+(\w+)') + else: + spec = re.compile(rf'{ctor}\s+"([^"]+)"\s+\[([^\]]*)\]\s+\[([^\]]*)\]') + model = {} + with open(path) as fh: + for line in fh: + if "constructor" in line: # skip the record's constructor declaration line + continue + m = spec.search(line) + if not m: + continue + name = m.group(1) + params = [TYMAP[p.strip()] for p in m.group(2).split(",") if p.strip()] + if fmt == "single": + results = [TYMAP[m.group(3).strip()]] + else: + results = [TYMAP[r.strip()] for r in m.group(3).split(",") if r.strip()] + model[name] = (params, results) + return model + + +def parse_wasm(path): + """Built .wasm -> ({export_name: func_symbol}, {func_symbol: (params, results)}).""" + wat = subprocess.run(["wasm-tools", "print", path], capture_output=True, text=True, check=True).stdout + exports = dict(re.findall(r'\(export "([^"]+)" \(func \$([^\)\s]+)\)\)', wat)) + funcsig = {} + fdef = re.compile( + r'\(func \$(\S+) \(;\d+;\)(?: \(type \d+\))?(?: \(param ([^\)]*)\))?(?: \(result ([^\)]*)\))?') + for m in fdef.finditer(wat): + sym = m.group(1) + params = m.group(2).split() if m.group(2) else [] + results = m.group(3).split() if m.group(3) else [] + funcsig.setdefault(sym, (params, results)) # first (the real def) wins + return exports, funcsig + + +def check_guest(name, wasm, model_path, ctor, fmt): + """Check one guest's built artifact against its verified model. Returns (ok, n_specs).""" + if not os.path.exists(wasm): + print(f"FATAL [{name}]: wasm not built: {os.path.relpath(wasm, REPO)} " + f"(run `just build-wasm` / `bash zig/buffer_abi_build.sh`)", file=sys.stderr) + return False, 0 + if not os.path.exists(model_path): + print(f"FATAL [{name}]: model not found: {model_path}", file=sys.stderr) + return False, 0 + model = parse_model(model_path, ctor, fmt) + if not model: + print(f"FATAL [{name}]: no specs parsed from {os.path.relpath(model_path, REPO)}", file=sys.stderr) + return False, 0 + exports, funcsig = parse_wasm(wasm) + + print(f"== ABI conformance [{name}]: {os.path.relpath(wasm, REPO)} vs " + f"{os.path.basename(model_path)} ({len(model)} specs) ==") + fails = [] + for fname, (eparams, eresults) in sorted(model.items()): + sym = exports.get(fname) + if sym is None: + fails.append(f"{fname}: in the verified model but NOT exported by the .wasm") + continue + aparams, aresults = funcsig.get(sym, (None, None)) + if aparams is None: + fails.append(f"{fname}: export -> ${sym} but no func definition found") + continue + if aparams != eparams: + fails.append(f"{fname}: PARAM drift — model {eparams} vs wasm {aparams}") + elif aresults != eresults: + fails.append(f"{fname}: RESULT drift — model {eresults} vs wasm {aresults}") + else: + ret = " ".join(eresults) if eresults else "()" + print(f" ok {fname}: ({' '.join(eparams)}) -> {ret}") + + modelled = set(model) + extra = sorted(n for n in exports if n not in modelled and n != "memory") + for n in extra: + print(f" warn {n}: exported by the .wasm but NOT in the verified ABI model") + + if fails: + print(f"\nDRIFT [{name}] — the built artifact does not match the verified ABI model:", + file=sys.stderr) + for f in fails: + print(f" FAIL {f}", file=sys.stderr) + print(f"{len(fails)} conformance failure(s) for {name}\n", file=sys.stderr) + return False, len(model) + print(f"PASS [{name}]: all {len(model)} verified ABI specs conform" + + (f" ({len(extra)} un-modelled export(s) warned)" if extra else "") + "\n") + return True, len(model) + + +def main(): + if not subprocess_ok(): + return 2 + + # One explicit wasm path: check it against its matching guest, else the safe_nif model. + if len(sys.argv) > 1: + target = os.path.abspath(sys.argv[1]) + for g in GUESTS: + if os.path.abspath(g["wasm"]) == target: + ok, _ = check_guest(g["name"], target, g["model"], g["ctor"], g["fmt"]) + return 0 if ok else 1 + # Unrecognised path: keep the old behaviour (check against the safe_nif model). + sn = GUESTS[0] + ok, _ = check_guest(sn["name"], target, sn["model"], sn["ctor"], sn["fmt"]) + return 0 if ok else 1 + + # No arg: check every guest in the manifest. + all_ok, total = True, 0 + for g in GUESTS: + ok, n = check_guest(g["name"], g["wasm"], g["model"], g["ctor"], g["fmt"]) + all_ok = all_ok and ok + total += n + if all_ok: + print(f"PASS: all {len(GUESTS)} guest(s) conform ({total} ABI specs verified against built artifacts)") + return 0 + print("FAIL: at least one guest drifted from its verified ABI model", file=sys.stderr) + return 1 + + +def subprocess_ok(): + """wasm-tools must be present; the gate runs, never silently skips.""" + from shutil import which + if which("wasm-tools") is None: + print("FATAL: wasm-tools not installed — the conformance gate must run, never skip", + file=sys.stderr) + return False + return True + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/zig/buffer_abi_build.sh b/zig/buffer_abi_build.sh new file mode 100644 index 0000000..3f8bb8a --- /dev/null +++ b/zig/buffer_abi_build.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +# buffer_abi_build.sh — standalone build for the SNIF Buffer ABI v1 (ptr,len). +# +# Mirrors the Justfile `build-wasm` recipe flags +# (zig build-exe -target wasm32-freestanding -O -fno-entry --export=... ) +# but builds ONLY zig/src/buffer_abi.zig and writes the .wasm into a temp dir +# UNDER zig/ (zig/buffer_abi_build/) — NOT priv/, so it touches nothing the +# Justfile/mix owns. Builds BOTH ReleaseSafe and ReleaseFast, then verifies the +# exports with wasm-tools (and wasmtime if present). +# +# Usage: bash zig/buffer_abi_build.sh +set -euo pipefail + +# Resolve repo-relative paths from this script's location so it works from any cwd. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SRC="$SCRIPT_DIR/src/buffer_abi.zig" +OUT="$SCRIPT_DIR/buffer_abi_build" # temp output dir under zig/, NOT priv/ +mkdir -p "$OUT" + +EXPORTS=( + snif_alloc + snif_dealloc + snif_reset + sum_f32 + scale_f32 + still_alive + crash_oob_buffer +) + +build() { + local mode="$1" name="$2" + local -a flags=( -target wasm32-freestanding "-O$mode" -fno-entry ) + for e in "${EXPORTS[@]}"; do flags+=( "--export=$e" ); done + echo ">>> zig build-exe ($mode) -> $OUT/$name.wasm" + zig build-exe "$SRC" "${flags[@]}" --name "$name" -femit-bin="$OUT/$name.wasm" +} + +build ReleaseSafe buffer_abi_ReleaseSafe +build ReleaseFast buffer_abi_ReleaseFast + +echo +echo "=== wasm-tools validate ===" +for w in "$OUT"/buffer_abi_Release*.wasm; do + wasm-tools validate "$w" && echo " OK $(basename "$w")" +done + +echo +echo "=== exports (wasm-tools print, ReleaseSafe) ===" +# Show the exported funcs + the memory; prove all 7 funcs + memory are present. +wasm-tools print "$OUT/buffer_abi_ReleaseSafe.wasm" \ + | grep -oE '\(export "[^"]+"' \ + | sed 's/(export / export /' + +echo +echo "=== wasmtime export inspection (if available) ===" +if command -v wasmtime >/dev/null 2>&1; then + # `wasmtime` cannot --invoke a module with -fno-entry directly without an + # explicit function; we just confirm the module loads/compiles. + if wasmtime compile "$OUT/buffer_abi_ReleaseSafe.wasm" -o "$OUT/.compiled.cwasm" 2>/dev/null; then + echo " OK wasmtime compiled ReleaseSafe module" + rm -f "$OUT/.compiled.cwasm" + else + echo " (wasmtime compile skipped/failed — non-fatal; validate already passed)" + fi +else + echo " (wasmtime not on PATH — skipped)" +fi + +echo +echo "BUILD OK: $OUT/buffer_abi_ReleaseSafe.wasm + buffer_abi_ReleaseFast.wasm" diff --git a/zig/src/buffer_abi.zig b/zig/src/buffer_abi.zig new file mode 100644 index 0000000..68fe162 --- /dev/null +++ b/zig/src/buffer_abi.zig @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +// buffer_abi.zig — SNIF Buffer ABI v1: minimal working (ptr,len) linear-memory +// marshalling for crash-isolated BEAM NIFs. +// +// WHY THIS EXISTS +// --------------- +// The original burble_fft.zig exports `fn fft(data: []f32, n: usize)` — a SLICE +// parameter. Under the wasm32 `wasm_mvp` calling convention Zig REJECTS slice +// params ("parameter of type '[]f32' not allowed in function with calling +// convention 'wasm_mvp'"): WASM-MVP has no fat-pointer representation, so a +// (ptr,len) slice cannot be an export argument. This file implements the fix +// recommended by the buffer-abi design: hand-rolled (ptr,len) over WASM-MVP +// linear memory plus a guest-side bump allocator. Slices are LEGAL internally; +// they are reconstructed from a raw (ptr,len) INSIDE each export and never +// crossed the ABI boundary. +// +// COMPONENT-MODEL NOTE (deferred, per design) +// ------------------------------------------- +// The design's option (b) — WebAssembly Component Model / WIT with `list` +// over the canonical ABI — is the right v2/v3 target once records/variants/ +// strings proliferate. It is NOT used for v1: Zig 0.15 has no native +// component-model emit (it would require `wasm-tools component new` + a +// WASI-preview2 adapter + an authored .wit), it pulls in a far larger surface +// to verify, and it contradicts ADR-002 (wasm32-freestanding, no WASI). v1 is +// deliberately the simplest thing that builds today. +// +// BUILD INVARIANT (mirrors safe_nif.zig / the Justfile build-wasm recipe) +// ---------------------------------------------------------------------- +// Compile with -OReleaseSafe: OOB on the rebuilt internal slice TRAPS (wasm +// `unreachable`), which the host surfaces as {:error, _}. -OReleaseFast strips +// the bounds check and turns OOB into a SILENT wrong answer — the ReleaseFast +// hazard class. Always ship ReleaseSafe. We build BOTH here only to exhibit the +// discrimination, exactly as safe_nif.zig does. +// +// ABI CONTRACT (language-agnostic; every future guest reimplements this surface) +// ----------------------------------------------------------------------------- +// On wasm32 all pointers and usize are i32 at the WASM boundary, so: +// snif_alloc(len: i32) -> i32 byte-offset into linear `memory`, 0 = OOM +// snif_dealloc(ptr: i32, len: i32) -> void (bump arena: only frees the most +// recent allocation; see below) +// snif_reset() -> void reset arena to empty (per-call hook) +// sum_f32(ptr: i32, len: i32) -> f32 sum of `len` f32s at byte-offset ptr +// scale_f32(ptr: i32, len: i32, k: f32) -> void in-place multiply by k +// The linear memory itself is exported as `memory`, which is exactly what the +// host's Wasmex.Memory.{read_binary,write_binary} read/write against. + +const std = @import("std"); + +// ── Bump arena ─────────────────────────────────────────────────────────────── +// Fixed 1 MiB scratch in linear memory. No WASI, no libc, no general allocator. +// Deterministic for the proof obligations (Idris2 Compliance.idr fftBufValid / +// WasmArrayValid): a known fixed `memory` size and a 16-byte-aligned base. +// +// These globals live in LINEAR MEMORY (initialised by the module's data +// segment), NOT as mutable wasm (global) decls. A fresh instance re-runs the +// data segment, so re-instantiation is a correct no-shared-state reset — the +// pooled-worker (ADR-004 re-instantiate-per-call) model relies on this. +const ARENA_BYTES: usize = 1 << 20; // 1 MiB +var arena_buf: [ARENA_BYTES]u8 align(16) = [_]u8{0} ** ARENA_BYTES; +var arena_off: usize = 0; + +/// Last allocation's start offset, so snif_dealloc can pop the most recent block +/// (a bump arena only supports LIFO free). 0 = no live allocation to pop. +var arena_last: usize = 0; + +/// Reserve `len` bytes, 16-byte aligned (covers f32/i32/i64 alignment at once). +/// Returns the linear-memory byte offset of the block, or 0 on OOM. +/// 16-byte alignment is the host-side discharge of WasmArrayValid.baseAligned +/// (b mod 4 = 0 for F32) noted in the Idris obligations. +export fn snif_alloc(len: usize) usize { + const a = std.mem.alignForward(usize, arena_off, 16); + if (a + len > arena_buf.len) return 0; // OOM -> 0 (the "third outcome") + arena_last = a; + arena_off = a + len; + return @intFromPtr(&arena_buf[a]); +} + +/// LIFO free: if `ptr` is the most recent allocation, roll the bump pointer +/// back to it; otherwise this is a no-op (bump arenas cannot free in the +/// middle). `len` is accepted for ABI symmetry / future allocators. +export fn snif_dealloc(ptr: usize, len: usize) void { + _ = len; + if (ptr == 0) return; + const base = @intFromPtr(&arena_buf[0]); + if (ptr < base) return; + const off = ptr - base; + if (off == arena_last) { + arena_off = arena_last; + arena_last = 0; + } +} + +/// Reset the arena to empty. Call between SNIF invocations on a reused instance +/// (pairs with the host pool's per-call lifecycle). +export fn snif_reset() void { + arena_off = 0; + arena_last = 0; +} + +// ── Buffer operations over (ptr,len) ───────────────────────────────────────── +// KEY RULE: slices may exist INTERNALLY; they may NEVER be export params +// (wasm_mvp rejects them — that is the whole bug we are fixing). Each export +// takes a raw (ptr,len) of i32s and rebuilds the slice itself. + +/// Sum `len` consecutive f32 values starting at byte-offset `ptr`. +/// ptr==0 (the OOM sentinel / null) returns 0 without touching memory. +/// Under ReleaseSafe an out-of-bounds (ptr,len) traps when the rebuilt slice +/// is indexed; under ReleaseFast it would silently read adjacent memory. +export fn sum_f32(ptr: usize, len: usize) f32 { + if (ptr == 0) return 0; + const data: [*]f32 = @ptrFromInt(ptr); + const xs = data[0..len]; // slice rebuilt INSIDE the guest (legal) + var acc: f32 = 0; + for (xs) |x| acc += x; + return acc; +} + +/// In-place: multiply each of `len` f32s at byte-offset `ptr` by `k`. +/// The "result" is the mutated buffer, read back by the host via the same ptr. +/// Returns nothing; ptr==0 is a no-op. +export fn scale_f32(ptr: usize, len: usize, k: f32) void { + if (ptr == 0) return; + const data: [*]f32 = @ptrFromInt(ptr); + const xs = data[0..len]; + for (xs) |*x| x.* *= k; +} + +// ── Liveness / mode-discrimination witnesses ───────────────────────────────── + +/// Always-true liveness probe (the buffer-ABI analogue of safe_nif.still_alive). +export fn still_alive() i32 { + return 1; +} + +/// Deliberate out-of-bounds read over a buffer: rebuilds a slice of `len`+1 +/// elements but indexes element `len` (one past). ReleaseSafe TRAPS; ReleaseFast +/// returns a silent wrong value — exhibits the safety discrimination on the +/// BUFFER path (not just the scalar path), matching the eval's anti-property. +export fn crash_oob_buffer(ptr: usize, len: usize) f32 { + if (ptr == 0) return 0; + const data: [*]f32 = @ptrFromInt(ptr); + const xs = data[0..len]; + var idx: usize = len; // one past the end — runtime value, not comptime + if (xs.len == 0) idx = 0; + return xs[idx]; // ReleaseSafe: trap; ReleaseFast: silent read +} diff --git a/zig/src/safe_nif.zig b/zig/src/safe_nif.zig index 3147e67..4c55b24 100644 --- a/zig/src/safe_nif.zig +++ b/zig/src/safe_nif.zig @@ -49,10 +49,13 @@ export fn fibonacci(n: i32) i64 { return b; } -/// Safe integer addition with overflow check. -/// In ReleaseSafe: overflow -> trap. In ReleaseFast: wraps silently. +/// Two's-complement WRAPPING i32 addition (`a +% b`). NOTE: despite the historical name +/// `checked_add`, this does NOT trap on overflow — it wraps (i32_max + 1 = i32_min), in BOTH +/// ReleaseSafe and ReleaseFast (`+%` is defined wrapping, not UB, so the build mode is irrelevant). +/// The trapping-overflow demo is `crash_overflow`. Behaviour pinned by the GAP-1b metamorphic +/// `wrap32` oracle in `demo/test/snif_metamorphic_test.exs`. export fn checked_add(a: i32, b: i32) i32 { - return a +% b; // Use wrapping add explicitly — this is intentional + return a +% b; // wrapping add — intentional; see the doc-comment above } // --- Crash isolation demos --- @@ -69,7 +72,10 @@ export fn crash_oob() i32 { /// Explicit unreachable — always reached at runtime. /// ALL modes: emits WASM unreachable instruction -> trap. export fn crash_unreachable() i32 { - if (runtime_index == 99) unreachable; + // OA-2(b): runtime_index (=3) is always < 99, so this ALWAYS fires — matching + // the docstring above. The prior `== 99` (copied from burble_fft, index 99) + // never fired here, silently returning 0 and contradicting the paper/tests/proofs. + if (runtime_index < 99) unreachable; return 0; } From e60a94c039f07c1cbd9240cebc1c418154a573c0 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:24:58 +0100 Subject: [PATCH 3/5] =?UTF-8?q?docs:=20AFFIRMATION.adoc=20=E2=80=94=20grou?= =?UTF-8?q?nd-truthed=20honesty=20snapshot=20for=20SNIFs=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Point-in-time, jointly-signed attestation per the estate AFFIRMATION standard (the README/EXPLAINME/AFFIRMATION trio). Every claim ground-truthed by running the tools this session against the committed anchor a82bb31 (its parent == the anchor SHA): proof-check-all exit 0 (10 gated artifacts), abi-conformance 15/15, mix test 30/30 (OTP 25). Honest gaps stated: Safer-not-Safe (SEC-1-TCB trusted), ABI-7 15/20, GAP-1b scalar-only. Dual-licensed MPL-2.0 OR CC-BY-SA-4.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- AFFIRMATION.adoc | 300 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 AFFIRMATION.adoc diff --git a/AFFIRMATION.adoc b/AFFIRMATION.adoc new file mode 100644 index 0000000..d0d6b56 --- /dev/null +++ b/AFFIRMATION.adoc @@ -0,0 +1,300 @@ +// SPDX-License-Identifier: MPL-2.0 OR CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// Author: Jonathan D.A. Jewell (hyperpolymath) += AFFIRMATION — SNIFs (Safer NIFs), as of 2026-06-16 +:toc: macro +:toclevels: 2 + +_the No-Bullshit file: what we affirm was true and checkable at this moment_ + +[NOTE] +==== +*Genre.* An *affirmation* is a solemn declaration of the truth of a statement, +made by someone who _declines to swear an oath_. That is exactly what this is: +our truth-as-best-believed at a stamped instant — binding on our honesty, not a +claim of infallibility. It is not the README and not the EXPLAINME: + +[cols="1,3,2",options="header"] +|=== +| File | Answers | Tense +| `README` | _Where is this going, and why?_ — what SNIF is, why care, quick start | future / aspirational +| `EXPLAINME` | _How is it built, and what's the evidence?_ — claim-to-code mapping | descriptive / mechanism +| *`AFFIRMATION`* (this file) | _What can we honestly affirm was *true and checkable* at a stamped moment?_ | a frozen instant, falsifiable +|=== + +It is deliberately small, dated, and signed, so a reader can hold us to exactly +what we affirmed, when we affirmed it. +==== + +toc::[] + +== What this is, and how it is designed to work + +*What it is.* A short, dated, jointly-signed snapshot of what we can _honestly +and verifiably_ claim about this repo at one exact moment. Nothing here is +marketing and nothing is a promise about the future — both of those live in the +README. This file is the receipt. + +*How it is designed to work.* Three moving parts make it trustworthy: + +. *Ground truth, not memory.* Every claim below was produced by _running the + tool in the session that wrote this file_ — the proof checkers, the ABI + conformance gate, and the in-BEAM test suite — on 2026-06-16. If a status doc + or a memory said otherwise, the live run wins and we flag the contradiction. +. *A frozen anchor.* The file names the exact commit SHA, branch, UTC timestamp, + working-tree delta, and toolchain (see <>), so "true" + always means "true _at this point_". Move the SHA and the file becomes a draft + until re-run. +. *A real signature.* It is landed by a *signed git commit*; that signature over + this content at the anchored SHA is what makes the affirmation tamper-evident + and attributable — not the prose alone. + +*We are fallible.* We can be wrong, stale, or simply mistaken about our own +work. This file is our best honest belief, not a proof of its own correctness. +Treat it as a falsifiable claim, not gospel. + +== The epistemic contract (read this before you trust _or_ attack) + +This document records our *best joint belief* at the timestamp below. It is +*not a guarantee of correctness.* We may be wrong. We may have missed something +— a hole, a stale doc, or a proof that checks an idealised _model_ we then fail +to match in the running _runtime_. + +*No intentional overclaim.* That is the only guarantee here: we have not +_knowingly_ inflated anything. Where something is tested-but-not-proved, we say +so. Where a proof covers a model and trusts the real runtime, we say so. Where +something is ledgered, weak, or merely-an-interface-check, we say so. An honest +claim here that turns out false is an error to fix — not a lie. + +*Standing invitation to refute.* You are _invited_ to bulldoze any claim in this +file. The fair form of the attack is: + +. Read this file at the stamped commit. +. Reproduce (or fail to reproduce) the checks in <>. +. _Then_ tell us where the discrepancy is, against the artefact as it stood at + this moment — so we can either justify it or concede it on the record. + +An attack that skips steps 1–2 is attacking a strawman of a different date. + +*No fights before the facts are cleared up.* We are not interested in a dispute +over a claim until the discrepancy has actually been checked against the +artefact at this commit and we have had the chance to either justify it or +concede it on the record. Good faith both ways: bring a reproducible +discrepancy and we will fix the claim, the doc, or the code — promptly and +without defensiveness. The goal is a corrected record, not a won argument. + +== Verifiable anchor + +[cols="1,3"] +|=== +| *Repo* | `hyperpolymath/snifs` (Safer NIFs — native compute for the BEAM via a wasmtime sandbox) +| *Branch* | `feat/snifs-2` +| *Commit (HEAD / anchor)* | `a82bb311609a2580f37dbbc7c1b6687ee7a3c0b7` — the signed commit that lands the SNIFs 2 work. This AFFIRMATION is landed by that commit's signed *child*, so **parent == anchor SHA**. +| *Permalink* | https://github.com/hyperpolymath/snifs/tree/a82bb311609a2580f37dbbc7c1b6687ee7a3c0b7 +| *Verified (UTC)* | 2026-06-16T12:22:08Z +| *Toolchain* | Idris2 0.8.0 · Lean 4.13.0 · Agda 2.8.0 · Zig 0.15.2 · wasm-tools 1.249.0 · Elixir 1.18.4 / Erlang OTP 25 · wasmex 0.14.0 · Python 3.12.3 · just 1.50.0 +|=== + +[NOTE] +==== +*State at this anchor.* Unlike an earlier draft of this file (which described an +uncommitted working tree), the verification affirmed here **is committed** at the +anchor SHA `a82bb31` — `SnifIsolation.agda`, `SnifVerdict.agda`, `BufferAbi.idr`, +the guest-aware conformance tool, the metamorphic gate, the demo, the Rust guests +and the reconciled docs are all in that commit (signed, `id_ed25519_signing`). The +gate results below were re-run against that committed state at the verified UTC +above. Build artifacts (`priv/*.wasm`, `*.agdai`, `zig/buffer_abi_build/`) are +intentionally **not** committed — CI rebuilds them. This AFFIRMATION itself is +landed by the signed child commit; if its parent SHA matches the anchor and the +commit verifies, the affirmation is anchored. Move HEAD past it and re-run +<> before trusting it. +==== + +== Companion documents and repo metadata (cross-check) + +This affirmation should be read against the repo's own public claims. Where they +drift from what we verified, we say so here rather than leave the reader to find out. + +* *`README.adoc`* — present. link:README.adoc[_"SNIFs: Safer Native Implemented + Functions for the BEAM via WebAssembly Sandboxing"_]. Sells the vision + the + scope ceiling; this file is its receipt for one moment. +* *`EXPLAINME.adoc`* — present. Maps README claims to code paths and carries the + corrected "SNIF realizes _a_ cleave instance, not _the_ cleave" framing. The + estate trio (README / EXPLAINME / AFFIRMATION) is *complete* for this repo once + this file lands. +* *`PROOF-STATUS.md` / `PROOF-NEEDS.md`* — the living ledgers this snapshot cites, + reconciled to the verified state in the anchor commit: `PROOF-STATUS.md` now counts + 10 gated artifacts, marks SEC-1 F1/F2 **RESOLVED** (keeping F3/F4/F5 as standing + precision notes), and records ABI-7 at 15/20 + CI-1 done. +* *GitHub repo description* — corrected this session to _"SNIFs: **Safer** Native + Implemented Functions for the BEAM via WebAssembly Sandboxing"_ (it previously said + "Safe"; the project renamed *"Safe NIFs" → "Safer NIFs"* — see `CHANGELOG` — because + the end-to-end guarantee is _not_ unconditional, see <>). +* *GitHub topics* — `beam`, `elixir`, `erlang`, `fault-tolerance`, `nif`, + `research`, `wasmtime`, `webassembly`, `crash-isolation`. *Under-claim:* there + is no `formal-verification` topic, although the repo now carries 10 + machine-checked proof artifacts + a machine-checked operational isolation + theorem. The tags undersell the verification rather than oversell it. + +== The honest state (one breath) + +*SNIFs is a working, machine-verified "safer NIF" for the BEAM: native +compute/buffer functions compiled to WebAssembly and run in a wasmtime sandbox, +so a guest fault becomes a catchable `{:error, _}` and the BEAM survives. The +in-BEAM demo is green, the formal gate is green (including an operational +crash-isolation theorem with deniability and a 6-origin error taxonomy wired in), +and the interface + a slice of behaviour are gated against the real built wasm — +yet it remains "Safer", not "Safe": one explicit runtime-faithfulness assumption +is trusted, not yet machine-discharged, and several coverage items are honestly +ledgered.* + +=== What is solid (and how we checked) + +* *The formal gate is green — verified this moment.* `just proof-check-all` → + *exit 0*: **7 Idris2** modules (`Types`, `ABI/{Layout,Platform,Pointers,Foreign,Compliance,BufferAbi}`), + **1 Lean4** (`ApiTypes`), **2 Agda** (`SnifVerdict`, `SnifIsolation`, both + `agda --safe --without-K`), plus the dangerous-pattern scan (no + `believe_me`/`postulate`/`sorry`/`Admitted`). +* *SEC-1 — the operational crash-isolation theorem — typechecks.* + `verification/proofs/agda/SnifIsolation.agda` proves, by induction over a + fuelled host↔guest small-step model: verdict ∈ ok ⊕ trap, host survives every + outcome, the trap carries no guest value, and — newly wired into the + operational run — the fault residue is the _redacted_ secret only, so two + faults with equal redaction are host-indistinguishable (`run-deniable`). The + verdict now models the real *6-origin* taxonomy (guest-fault / host-budget / + pre-exec) via a `call` front-end, not a binary trap. It is proven *modulo* an + explicit `FaithfulRuntime` record hypothesis (the TCB) — see <>. +* *Interface conformance is gated against the real binaries.* + `just abi-conformance` → *exit 0*: **15 ABI specs** across **2 guests** + (`safe_nif` 8, `buffer_abi` 7) — the built wasm's real export signatures match + the verified Idris2 model exactly, including three void-returning buffer + exports. +* *In-BEAM behaviour runs.* `mix test` (in `demo/`) → *30 tests, 0 failures* on + Erlang/OTP 25, Elixir 1.18.4, with the precompiled `wasmex` 0.14 NIF. This + includes crash isolation across every failure mode (OOB / unreachable / @panic + / overflow / div-zero → `{:error,_}`, BEAM alive after each) and a new + dependency-free *metamorphic behaviour gate* over the numeric kernels. + +[#nuance] +=== The honest nuance you must not lose + +. *"Safer", not "Safe" — the runtime TCB is trusted, not proven.* SEC-1 is proven + over a model whose first argument is a `FaithfulRuntime` record bundling + primitive single-step facts (wasmtime never gets stuck; a trapped step is caught + and surfaced as `{:error,_}` with the scheduler preserved). The prose claim that + this record is _faithful to wasmtime/wasmex_ — "wasmtime ⊨ FaithfulRuntime" — is + **assumed, not discharged in a prover** (it is the named open item SEC-1-TCB, a + WasmCert-Coq effort). This residual assumption is exactly _why_ the project is + Safer NIFs. +. *Model ↔ code faithfulness is only partly mechanised.* The proofs verify a + logical model. The ABI conformance gate closes the *interface* slice of + model↔code (signatures match the real wasm); the metamorphic tests close a + *behaviour* slice (the kernels behave as modelled). The general claim "the Agda + model mirrors the Zig/Rust/wasmex code" is argued + tested, not itself + machine-verified. +. *Honest proof labels (not over-claims).* In SEC-1, `okOrTrap` and `noForgery` + are *structural* facts about the `Verdict` datatype (true with no runtime in + scope); the load-bearing, TCB-consuming content is host *preservation* + (`survives`). `noForgery` is the parametric non-existence of a total extractor, + not an instance-level claim. We state these rather than dress them up. + +=== Known-incomplete but honestly fenced (loud failure, never silent miscompile) + +* The isolation thesis depends on building guests with `-OReleaseSafe`. Under + `-OReleaseFast` the same faults become *silent wrong answers* — this is + demonstrated, on purpose, as the negative control in the demo suite (e.g. an OOB + read returns a plausible adjacent value). The shipped guests are ReleaseSafe; + the ReleaseFast artifact exists only to keep that danger visible and tested. + +=== Outstanding / weak / refuted (no spin) + +* *SEC-1-TCB — open.* Discharging "wasmtime ⊨ FaithfulRuntime" in-prover + (WASM trap-soundness; trap → `{:error,_}`; scheduler resumed) is the remaining + half of an end-to-end isolation proof. Not started here. +* *ABI-7 — partial.* 15 of ~20 Zig export sites are modelled+gated (`safe_nif` + + `buffer_abi`). `burble_fft` (`fft`/`ifft`/…) is *not* built into any artifact and + uses `(ptr,len)` slice marshalling = the deeper *ABI-6* obligation; it and the + two Rust guests are *ledgered, not gated* (`PROOF-NEEDS.md`). +* *GAP-1b — partial.* The metamorphic behaviour gate covers the *scalar* kernels + (`fibonacci` recurrence/monotonicity/i64-width; `checked_add` as a wrapping i32 + ring); the *buffer* kernels (`sum_f32` permutation-invariance, scale-then-sum) + are the next increment. +* *Finding surfaced by the new gate:* the export named `checked_add` is a + **misnomer** — it is two's-complement *wrapping* addition (`a +% b`, intentional + per `zig/src/safe_nif.zig`), *not* a trapping/checked add (the trapping demo is + the separate `crash_overflow`). The gate characterises the real behaviour; + whether to rename the export is the owner's call. +* *CI.* The ABI conformance gate now runs as a CI job (`proofs.yml`), but making it + a *required* status check is an owner-only branch-protection action — not done. +* *Adversarial mutation re-audit — completed, verdict SOLID.* Four independent + skeptics mutation-tested the SEC-1 sharpening (`--safe --without-K`): every targeted + mutation was rejected as expected, so F1 (lossy redaction), F2 (the pre-exec + coverage + the `guestFault` tag) and F5 (the partial `Alive`) are genuinely + load-bearing — not vacuous; `--safe` is enforced (an injected `postulate` is + rejected) and `run`/`drive` totality is real (a non-decreasing-fuel mutation is + rejected). The only weakness it found was *stale prose*, corrected in the anchor commit. +* *Required-check + Zenodo.* Making the conformance gate a **required** status check is + owner-only branch-protection (pending). The paper's Zenodo DOI still carries the old + "Safe" title; the rename should land on the next deposit. + +[#reproduce] +== Reproduce it yourself + +From the repo root, at the anchor commit above (you need the toolchain in +<>): + +[source,sh] +---- +just proof-check-all # expect exit 0: + # 7 Idris2 OK, 1 Lean4 OK, 2 Agda OK (SnifVerdict + SnifIsolation), + # dangerous-pattern scan PASS +just abi-conformance # expect exit 0: builds safe_nif + buffer_abi wasm, then + # "all 2 guest(s) conform (15 ABI specs verified ...)" +cd demo && mix test # expect "30 tests, 0 failures" (OTP 25 / Elixir 1.18.4) +---- + +For the SEC-1 proof on its own (the exact gate invocation): + +[source,sh] +---- +agda -i verification/proofs/agda --safe --without-K --no-libraries \ + verification/proofs/agda/SnifIsolation.agda # expect exit 0 +---- + +== One-line characterisation (quote this) + +[quote] +____ +"A working, machine-verified *Safer* NIF for the BEAM: native compute sandboxed +in wasmtime so a guest fault becomes `{:error,_}` and the VM survives — with an +operational crash-isolation theorem (deniability + 6-origin taxonomy, Agda +`--safe`), an interface gate matching 15 export signatures to the real wasm, and +a behaviour gate over the scalar kernels — all green this moment; *Safer not Safe* +because one runtime-faithfulness assumption is trusted, not yet proven, and +`burble_fft`/Rust/buffer-behaviour coverage is honestly ledgered. No intentional +overclaim." +____ + +== Joint attestation + +We, the undersigned, assert that *to the best of our joint belief at the +timestamp above, every claim in this file is true and was checked as described* +— with no intentional overclaim, and with the open gaps stated rather than hidden. + +* *Engineering party (AI):* Claude Opus 4.8 (`claude-opus-4-8[1m]`) — ran the + proof gate, the ABI conformance gate, and the in-BEAM test suite recorded here + on 2026-06-16T12:22:08Z (against the committed anchor state) and stands behind the + wording above as a faithful report of those runs. +* *Owner / maintainer:* Jonathan D.A. Jewell — _signs by committing this file with + `-S` (`id_ed25519_signing`); the git commit signature over this content, whose + parent is the anchor SHA recorded above, is the cryptographic form of this + affirmation._ ++ +Signed-off-date: 2026-06-16 + +[TIP] +The authoritative, tamper-evident signature is the *signed git commit* that lands +this file; its **parent is the anchor SHA** `a82bb31`. If that parent matches +<> and the commit verifies (`id_ed25519_signing`), this +affirmation is anchored. If HEAD later moves past it, re-run <> and write +a fresh affirmation rather than trusting a stale one. From 32bbe3392a1a9097d638ac65c0d819f488b85b68 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:28:19 +0100 Subject: [PATCH 4/5] docs(bust): record the Zig-0.15.2 ffi-template near-hit (recognition, not a fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add docs/templates/contractiles/bust/Bustfile.a2ml — a 'bust' (= broken, the slang) contract with a disjoint '## Near-Hit Cases' recognition ledger. Records zig-0.15.2-opaque-with-fields-template-rot: the rsr-template src/interface/ffi uses pre-0.15 Zig (opaque-with-fields + c_allocator) that 0.15.2 correctly rejects, but it does NOT bite here — the template is unrendered dead scaffold wired into no build/gate, and the live guests compile clean. By-design type discipline, not a Zig regression; recognition, not a fix-queue item (the same pattern is genuinely bust in typed-wasm). Seeds two real Failure Modes (ReleaseFast silent-wrong-answers; wasmex/ABI-drift load failure). Extend the main.zig SCAFFOLD banner with the same Zig-0.15.2 note + a pointer to the Bustfile near-hit. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../templates/contractiles/bust/Bustfile.a2ml | 75 +++++++++++++++++++ src/interface/ffi/src/main.zig | 9 +++ 2 files changed, 84 insertions(+) create mode 100644 docs/templates/contractiles/bust/Bustfile.a2ml diff --git a/docs/templates/contractiles/bust/Bustfile.a2ml b/docs/templates/contractiles/bust/Bustfile.a2ml new file mode 100644 index 0000000..4b0f84b --- /dev/null +++ b/docs/templates/contractiles/bust/Bustfile.a2ml @@ -0,0 +1,75 @@ +# SPDX-License-Identifier: MPL-2.0 +# Bustfile — design-aetiology / "bust" contract for snifs +# Author: Jonathan D.A. Jewell +# +# Paired runner: bust.ncl (NOT YET PRESENT — paired .ncl runners are an +# estate-wide gap; cf the Adjustfile contractile-sync +# check. Do not assume a runner exists.) +# Verb: bust +# +# Semantics: BUST is about *broken* — the slang "damn, this thing is bust". +# It records WHY things break (or nearly broke), cause-first, as +# design aetiology — causal help/error/removal, NOT a bandaid that +# suppresses the symptom. (Sibling: DUST = exnovation, the clean +# reduction-to-dust / deliberate removal of a thing.) +# Run with: bust check (list failure modes + recovery status) +# Fix with: bust fix (apply the causal recovery where deterministic; advisory otherwise) + +@abstract: +The snifs Bustfile in two disjoint halves. + + ## Failure Modes — OPEN, actionable defect contracts: a class that CAN break, + each pairing a detection probe with a causal recovery. This is the half a + `bust` runner / k9 gate would drill (declared -> drilled -> verified | failing). + + ## Near-Hit Cases — CLOSED, non-actionable recognition records: something that + COULD have bitten but DID NOT, written down with its aetiology so future work + recognises the same shape rather than re-stumbling into it. A near-hit is NOT a + defect, has no recovery to drill, and must never be auto-promoted into a Failure + Mode (that would invent an open issue from a non-issue). It only graduates by an + explicit human decision. +@end + +## Failure Modes + +### releasefast-guest-silent-wrong-answers +- description: A SNIF guest built with `-Doptimize=ReleaseFast` (or `ReleaseSmall`) strips Zig's runtime safety traps (overflow, bounds, unreachable). At the wasm32-freestanding guest boundary the same fault that should TRAP becomes undefined behaviour — the guest returns a plausible-but-wrong value across the buffer ABI and the BEAM host accepts a silently corrupt answer. There is no crash to observe. +- cause: optimisation-for-speed removes the only mechanism (safety traps) that surfaces ABI/arithmetic violations at the cleave surface; ReleaseSafe keeps the trap and converts the same bug into an *observable* guest trap the host catches as `{:error, _}`. +- probe: ! grep -qE 'optimize *= *\.(ReleaseFast|ReleaseSmall)' zig/src/*.zig build.zig 2>/dev/null # shipped guest must be ReleaseSafe +- probe: just abi-conformance # + a differential corpus: ReleaseSafe vs ReleaseFast must AGREE on edge inputs — divergence == this mode firing +- recovery: build/ship the guest `-OReleaseSafe` (traps preserved); CI-gate so a ReleaseFast artifact cannot ship; on a field wrong-answer, rebuild ReleaseSafe and replay the differential corpus to localise the trapping input. +- severity: critical +- status: declared + +### wasmex-nif-guest-load-failure +- description: The host-side wasmex NIF, or the `.wasm` guest it loads, fails to instantiate — a missing/renamed export, ABI/version skew between the Idris2-verified model and the real Zig exports, wrong wasm target, or a NIF that won't link into the BEAM. The Elixir call surfaces `{:error, _}` (or a NIF load crash) instead of a working SNIF. +- cause: the guest export set drifted from the verified ABI (`verification/proofs/idris2/ABI/*`), or the guest was built for the wrong target / missing an export, so wasmex cannot bind the expected interface at instantiation — boundary-erosion / ABI-drift, exactly the class `abi_conformance.py` guards. +- probe: just abi-conformance # signature drift gate (safe_nif + buffer_abi vs Foreign.idr / BufferAbi.idr) +- probe: cd demo && mix test # host-side instantiate + call; must not be {:error, _} for the happy path +- recovery: re-sync exports to the Idris2 ABI model (realign safe_nif.zig + buffer_abi.zig against Foreign.idr / BufferAbi.idr), rebuild for wasm32-freestanding, re-run `just abi-conformance` until green, then re-instantiate. +- severity: critical +- status: declared + +## Near-Hit Cases + +# RECOGNITION LEDGER — read, do not drill. Entries here are NOT defects and NOT +# merge-gated. A `bust` runner / k9 gate binds ONLY to "## Failure Modes" above. +# Do not migrate these into Failure Modes; promotion is a human decision via the +# entry's `graduates_to_failure_mode_if:` trip-condition. + +### zig-0.15.2-opaque-with-fields-template-rot +- kind: near-hit +- status: recognised +- not_an_issue: true +- class: toolchain # (extends the runner's failure-class enum; near-hits do not bind the runner) +- surfaced_by: cross-repo AFFIRMATION signal, 2026-06-16 — typed-wasm's `ffi/zig` was found genuinely BUST under Zig 0.15.2 (its `tests/e2e.sh` Zig step fails, 48/1/6); we then checked whether the same shape bites snifs. +- what_almost_broke: the rsr-template `src/interface/ffi/src/main.zig` carries pre-0.15 Zig patterns — an `opaque { ...fields... }` (`Handle`) and `std.heap.c_allocator` — that Zig 0.15.2 rejects. Rendered and built, it would not compile. +- why_it_did_not_bite: that file is *unrendered dead scaffold* — it still holds `{{project}}` placeholders and is wired into NO build / test / gate (`tests/e2e.sh` does not touch it; `src/interface/ffi/build.zig`'s compile step is commented out; the proof + conformance gates use `zig/src/safe_nif.zig` + `buffer_abi.zig` only). A direct compile dies at the `{{project}}` placeholder *before* even reaching the type errors. The LIVE guests compile clean under Zig 0.15.2 (verified this session). +- aetiology: + - root-1-template: the rsr-template shipped a generic C-FFI example written in pre-0.15 Zig idioms; snifs never rendered it (it uses `zig/src/safe_nif.zig` instead), so the dead patterns sat latent. + - root-2-toolchain: Zig 0.15.2 tightened two things — `opaque` may no longer carry fields, and `std.heap.c_allocator` now requires explicit libc linking. + - mechanism: `opaque {}` *means* "a type whose size/layout is unknown"; a field declares a layout, so opaque-with-fields is a contradiction and 0.15.2 correctly forbids it (declarations remain legal). `c_allocator` calls libc `malloc`/`free`, which a `wasm32-freestanding` guest has no business linking. + - non-cause-ruled-out: this is NOT a Zig regression (the change is load-bearing type discipline, by design — nothing to fix upstream) and NOT a defect in the live SNIF guests (they never used these patterns and build clean). The bustedness is entirely contained in the dead template. +- recognition: treat Zig 0.15.2's tightening as type discipline to CONFORM TO, not work around. If this template is ever rendered + built, use the opaque-handle idiom (a concrete `struct`, hand C an `*anyopaque` / `*Handle` token, recover via `@ptrCast`/`@alignCast`) and a freestanding allocator (`FixedBufferAllocator` / page-style over a static buffer) — never `c_allocator` at the wasm32-freestanding boundary. Early signal it is about to bite for real: anyone rendering the `{{project}}` placeholders or wiring `src/interface/ffi/` into a build/CI step. +- graduates_to_failure_mode_if: the ffi scaffold is rendered and wired into a real build/gate — at which point "ffi guest won't compile under the toolchain" becomes a live Failure Mode deserving a recovery contract. +- notes: companion to the `SCAFFOLD` banner atop `src/interface/ffi/src/main.zig` (extended 2026-06-16 with this Zig-0.15.2 note) and PROOF-STATUS.md "Scaffold (NOT counted, NOT gated)". The same pattern is genuinely *bust* in typed-wasm — there it is a real failure, here a recognised near-hit. diff --git a/src/interface/ffi/src/main.zig b/src/interface/ffi/src/main.zig index 6baf88b..dc66912 100644 --- a/src/interface/ffi/src/main.zig +++ b/src/interface/ffi/src/main.zig @@ -12,6 +12,15 @@ // placeholders would manufacture a PHANTOM second FFI surface that contradicts the // real one (boundary-erosion drift we explicitly resist). // +// Even rendered, it would NOT compile under Zig 0.15.2: it uses pre-0.15 patterns +// — an `opaque { ...fields... }` (`Handle`) and `std.heap.c_allocator` — that 0.15.2 +// rejects by design (an `opaque` has no known layout, so it may not carry fields; +// `c_allocator` needs libc, absent at the wasm32-freestanding boundary). The modern +// shape is the opaque-handle idiom (a concrete struct + an `*anyopaque`/`*Handle` +// token recovered via `@ptrCast`/`@alignCast`) + a freestanding allocator. Recorded +// as a near-hit in docs/templates/contractiles/bust/Bustfile.a2ml +// (zig-0.15.2-opaque-with-fields-template-rot) — recognition, not a fix-queue item. +// // THE REAL SNIF GUEST ABI lives in: // * Implementation : zig/src/safe_nif.zig, zig/src/buffer_abi.zig (wasm32-freestanding) // * Verified model : verification/proofs/idris2/ABI/{Foreign,BufferAbi,Layout,...}.idr From 47423eb79f914a1372f612c70ee5ce9adb858e6a Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:42:05 +0100 Subject: [PATCH 5/5] chore(contractiles): add canonical bust + dust to .machine_readable (satisfies Mustfile bust-dust-files-present) Mirror the Bustfile (with the Zig-0.15.2 near-hit) into the real filled contractiles tree .machine_readable/contractiles/bust/, where the Mustfile bust-dust-files-present check (severity critical) looks, and author the sibling Dustfile (exnovation: ffi-scaffold deprecate, orphan rust wasm dependency-exit, dual rust trees keep-forever). The docs/templates/contractiles/ copies remain the blank-template tree per the stub's own copy-and-fill instruction. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../contractiles/bust/Bustfile.a2ml | 75 +++++++++++++++++++ .../contractiles/dust/Dustfile.a2ml | 50 +++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 .machine_readable/contractiles/bust/Bustfile.a2ml create mode 100644 .machine_readable/contractiles/dust/Dustfile.a2ml diff --git a/.machine_readable/contractiles/bust/Bustfile.a2ml b/.machine_readable/contractiles/bust/Bustfile.a2ml new file mode 100644 index 0000000..4b0f84b --- /dev/null +++ b/.machine_readable/contractiles/bust/Bustfile.a2ml @@ -0,0 +1,75 @@ +# SPDX-License-Identifier: MPL-2.0 +# Bustfile — design-aetiology / "bust" contract for snifs +# Author: Jonathan D.A. Jewell +# +# Paired runner: bust.ncl (NOT YET PRESENT — paired .ncl runners are an +# estate-wide gap; cf the Adjustfile contractile-sync +# check. Do not assume a runner exists.) +# Verb: bust +# +# Semantics: BUST is about *broken* — the slang "damn, this thing is bust". +# It records WHY things break (or nearly broke), cause-first, as +# design aetiology — causal help/error/removal, NOT a bandaid that +# suppresses the symptom. (Sibling: DUST = exnovation, the clean +# reduction-to-dust / deliberate removal of a thing.) +# Run with: bust check (list failure modes + recovery status) +# Fix with: bust fix (apply the causal recovery where deterministic; advisory otherwise) + +@abstract: +The snifs Bustfile in two disjoint halves. + + ## Failure Modes — OPEN, actionable defect contracts: a class that CAN break, + each pairing a detection probe with a causal recovery. This is the half a + `bust` runner / k9 gate would drill (declared -> drilled -> verified | failing). + + ## Near-Hit Cases — CLOSED, non-actionable recognition records: something that + COULD have bitten but DID NOT, written down with its aetiology so future work + recognises the same shape rather than re-stumbling into it. A near-hit is NOT a + defect, has no recovery to drill, and must never be auto-promoted into a Failure + Mode (that would invent an open issue from a non-issue). It only graduates by an + explicit human decision. +@end + +## Failure Modes + +### releasefast-guest-silent-wrong-answers +- description: A SNIF guest built with `-Doptimize=ReleaseFast` (or `ReleaseSmall`) strips Zig's runtime safety traps (overflow, bounds, unreachable). At the wasm32-freestanding guest boundary the same fault that should TRAP becomes undefined behaviour — the guest returns a plausible-but-wrong value across the buffer ABI and the BEAM host accepts a silently corrupt answer. There is no crash to observe. +- cause: optimisation-for-speed removes the only mechanism (safety traps) that surfaces ABI/arithmetic violations at the cleave surface; ReleaseSafe keeps the trap and converts the same bug into an *observable* guest trap the host catches as `{:error, _}`. +- probe: ! grep -qE 'optimize *= *\.(ReleaseFast|ReleaseSmall)' zig/src/*.zig build.zig 2>/dev/null # shipped guest must be ReleaseSafe +- probe: just abi-conformance # + a differential corpus: ReleaseSafe vs ReleaseFast must AGREE on edge inputs — divergence == this mode firing +- recovery: build/ship the guest `-OReleaseSafe` (traps preserved); CI-gate so a ReleaseFast artifact cannot ship; on a field wrong-answer, rebuild ReleaseSafe and replay the differential corpus to localise the trapping input. +- severity: critical +- status: declared + +### wasmex-nif-guest-load-failure +- description: The host-side wasmex NIF, or the `.wasm` guest it loads, fails to instantiate — a missing/renamed export, ABI/version skew between the Idris2-verified model and the real Zig exports, wrong wasm target, or a NIF that won't link into the BEAM. The Elixir call surfaces `{:error, _}` (or a NIF load crash) instead of a working SNIF. +- cause: the guest export set drifted from the verified ABI (`verification/proofs/idris2/ABI/*`), or the guest was built for the wrong target / missing an export, so wasmex cannot bind the expected interface at instantiation — boundary-erosion / ABI-drift, exactly the class `abi_conformance.py` guards. +- probe: just abi-conformance # signature drift gate (safe_nif + buffer_abi vs Foreign.idr / BufferAbi.idr) +- probe: cd demo && mix test # host-side instantiate + call; must not be {:error, _} for the happy path +- recovery: re-sync exports to the Idris2 ABI model (realign safe_nif.zig + buffer_abi.zig against Foreign.idr / BufferAbi.idr), rebuild for wasm32-freestanding, re-run `just abi-conformance` until green, then re-instantiate. +- severity: critical +- status: declared + +## Near-Hit Cases + +# RECOGNITION LEDGER — read, do not drill. Entries here are NOT defects and NOT +# merge-gated. A `bust` runner / k9 gate binds ONLY to "## Failure Modes" above. +# Do not migrate these into Failure Modes; promotion is a human decision via the +# entry's `graduates_to_failure_mode_if:` trip-condition. + +### zig-0.15.2-opaque-with-fields-template-rot +- kind: near-hit +- status: recognised +- not_an_issue: true +- class: toolchain # (extends the runner's failure-class enum; near-hits do not bind the runner) +- surfaced_by: cross-repo AFFIRMATION signal, 2026-06-16 — typed-wasm's `ffi/zig` was found genuinely BUST under Zig 0.15.2 (its `tests/e2e.sh` Zig step fails, 48/1/6); we then checked whether the same shape bites snifs. +- what_almost_broke: the rsr-template `src/interface/ffi/src/main.zig` carries pre-0.15 Zig patterns — an `opaque { ...fields... }` (`Handle`) and `std.heap.c_allocator` — that Zig 0.15.2 rejects. Rendered and built, it would not compile. +- why_it_did_not_bite: that file is *unrendered dead scaffold* — it still holds `{{project}}` placeholders and is wired into NO build / test / gate (`tests/e2e.sh` does not touch it; `src/interface/ffi/build.zig`'s compile step is commented out; the proof + conformance gates use `zig/src/safe_nif.zig` + `buffer_abi.zig` only). A direct compile dies at the `{{project}}` placeholder *before* even reaching the type errors. The LIVE guests compile clean under Zig 0.15.2 (verified this session). +- aetiology: + - root-1-template: the rsr-template shipped a generic C-FFI example written in pre-0.15 Zig idioms; snifs never rendered it (it uses `zig/src/safe_nif.zig` instead), so the dead patterns sat latent. + - root-2-toolchain: Zig 0.15.2 tightened two things — `opaque` may no longer carry fields, and `std.heap.c_allocator` now requires explicit libc linking. + - mechanism: `opaque {}` *means* "a type whose size/layout is unknown"; a field declares a layout, so opaque-with-fields is a contradiction and 0.15.2 correctly forbids it (declarations remain legal). `c_allocator` calls libc `malloc`/`free`, which a `wasm32-freestanding` guest has no business linking. + - non-cause-ruled-out: this is NOT a Zig regression (the change is load-bearing type discipline, by design — nothing to fix upstream) and NOT a defect in the live SNIF guests (they never used these patterns and build clean). The bustedness is entirely contained in the dead template. +- recognition: treat Zig 0.15.2's tightening as type discipline to CONFORM TO, not work around. If this template is ever rendered + built, use the opaque-handle idiom (a concrete `struct`, hand C an `*anyopaque` / `*Handle` token, recover via `@ptrCast`/`@alignCast`) and a freestanding allocator (`FixedBufferAllocator` / page-style over a static buffer) — never `c_allocator` at the wasm32-freestanding boundary. Early signal it is about to bite for real: anyone rendering the `{{project}}` placeholders or wiring `src/interface/ffi/` into a build/CI step. +- graduates_to_failure_mode_if: the ffi scaffold is rendered and wired into a real build/gate — at which point "ffi guest won't compile under the toolchain" becomes a live Failure Mode deserving a recovery contract. +- notes: companion to the `SCAFFOLD` banner atop `src/interface/ffi/src/main.zig` (extended 2026-06-16 with this Zig-0.15.2 note) and PROOF-STATUS.md "Scaffold (NOT counted, NOT gated)". The same pattern is genuinely *bust* in typed-wasm — there it is a real failure, here a recognised near-hit. diff --git a/.machine_readable/contractiles/dust/Dustfile.a2ml b/.machine_readable/contractiles/dust/Dustfile.a2ml new file mode 100644 index 0000000..6d1deb8 --- /dev/null +++ b/.machine_readable/contractiles/dust/Dustfile.a2ml @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: MPL-2.0 +# Dustfile — exnovation contract for snifs +# Author: Jonathan D.A. Jewell +# +# Paired runner: dust.ncl (NOT YET PRESENT — paired .ncl runners are an +# estate-wide gap; cf the Adjustfile contractile-sync +# check. Do not assume a runner exists.) +# Verb: dust +# +# Semantics: DUST is exnovation — the deliberate, CLEAN reduction-to-dust of a +# thing, "without the mess". Per component it declares a disposition +# (deprecate / untangle / dependency-exit / keep-forever) plus an +# explicit exit-condition, so a removal is a recorded decision with a +# trigger, never an ad-hoc deletion. (Sibling: BUST = broken — WHY +# things break.) Nothing here removes anything by itself. +# Run with: dust check (list exnovation declarations + whether the exit-condition holds) +# Fix with: dust fix (execute a declared exnovation only when its exit-condition is met) + +@abstract: +Exnovation declarations for snifs. Each entry names a component, its disposition +(deprecate / untangle / dependency-exit / keep-forever), the rationale, and the +explicit exit-condition that must hold before the removal is carried out. This is +the deliberate-retirement ledger; it states intent + trigger, it does not delete. +@end + +## Exnovation Declarations + +### rsr-template-ffi-scaffold +- target: src/interface/ffi/ (the unrendered rsr-template C-FFI tree, incl. src/main.zig, build.zig, test/integration_test.zig) +- disposition: deprecate +- rationale: dead scaffold — unrendered (`{{project}}` placeholders), wired into no build or gate, and would not compile under Zig 0.15.2 even if rendered (see the Bustfile near-hit `zig-0.15.2-opaque-with-fields-template-rot`). The real guest ABI is `zig/src/{safe_nif,buffer_abi}.zig` + `verification/proofs/idris2/ABI/*` + `abi_conformance.py`. +- exit-condition: remove once the template-instantiation tooling (`tests/e2e/template_instantiation_test.sh`, `scripts/validate-template.sh`, `benches/template_bench.sh`) no longer requires the ffi scaffold present, OR once that tooling is itself retired. Until then KEEP as the untouched rsr-template baseline (it is harmless: it builds nothing). +- status: declared +- severity: advisory + +### orphan-rust-guest-wasm-artifact +- target: priv/demo_guest_rust.wasm (813-byte build artifact in the working tree) +- disposition: dependency-exit +- rationale: a stale built artifact NOT loaded by the demo — `SnifDemo.RustGuest` loads `rust/target/.../demo_guest.wasm`, not this. It is a generated leftover, not source. (`priv/*.wasm` is gitignored, so it is never committed.) +- exit-condition: deletable from the working tree at any time; it is regenerated on demand from `rust/crates/demo-guest`. No source depends on this path. +- status: declared +- severity: advisory + +### keep-forever-dual-rust-guest-trees +- target: rust/ (Cargo workspace, canonical) and rust-guest/ (standalone single-crate experiment) +- disposition: keep-forever +- rationale: NOT duplication-to-exnovate — owner-declared deliberate (see rust-guest/README.adoc + ADR-0005). The workspace `rust/crates/demo-guest` is the demo's guest; `rust-guest/` is the minimal proof-of-shape. Recorded here so a future hygiene sweep does not "dedupe" them. +- exit-condition: none — explicit keep. Revisit only on owner instruction. +- status: declared +- severity: advisory