Skip to content

Commit 6ab7493

Browse files
ci+test: wire build-mode invariant gate, harden crash_oob, remove stale wasm (#30)
## Summary Wires the SNIFS build-mode invariant into CI, hardens the `crash_oob` demonstration so its failure mode is unambiguous, and removes the committed stale wasm artifacts that quietly drift from source. The SNIFS isolation guarantee depends on a precondition the build hadn't been gating: that the shipped wasm guest was compiled with `-OReleaseSafe`, not `-OReleaseFast`. Build-mode itself was already locked at source level (`zig/build.zig` hardcodes `.optimize` per artifact; `Justfile build-wasm` passes `-OReleaseSafe`/`-OReleaseFast` literally per recipe). The gap was on the test side — no CI job ever ran the demo suite against the built artifacts. ## Five fixes, all serving the same property 1. **`.github/workflows/e2e.yml`** — replace template stubs with a concrete job: setup-zig 0.15.0 → `just build-wasm` (both modes, fresh) → setup-beam OTP 28 / Elixir 1.18 → `mix test --trace`. **Two action SHA pins inherited from the template were fabricated** (`gh api` → 422): - `goto-bus-stop/setup-zig@7ab2955...3608` was fake (first 16 hex collide with v2.2.0's real SHA `7ab2955...2802d`, rest fabricated). Replaced with v2.2.1 = `abea47f85e598557f500fa1fd2ab7464fcb39406`. - `erlef/setup-beam@5a67e1a...a66c07` had no resemblance to any release. Replaced with v1.24.0 = `fc68ffb90438ef2936bbb3251622353b3dcb2f93`. The same fake pins propagated from `rsr-template-repo` into ~35 other estate workflows (10 with active uncommented pins). Follow-up PRs to `rsr-template-repo` + the 3 affected repos (`odds-and-sods-package-manager`, `proven`, `proven-servers`) coming separately. 2. **`demo/test/snif_demo_test.exs` paths** — `@safe`/`@fast` were `__DIR__/../priv/...` resolving to `demo/priv/` (doesn't exist); files live at top-level `priv/`. Fixed to `__DIR__/../../priv/...`. The suite couldn't find its fixtures since the binaries were first committed on 2026-05-21 — never run successfully. 3. **`zig/src/safe_nif.zig` `crash_oob` hardening** — previously the ReleaseFast result was "returns 0 silently," ambiguous between "load was DCE'd" and "read 0 from adjacent memory." Replaced the function-local `const` array with a layout-pinned `extern struct { arr: [3]i32, canary: i32 }` at module scope; `runtime_index = 3` indexes one past the end. Under ReleaseSafe the bounds check fires; under ReleaseFast the load deterministically lands on `canary = 0x0BADF00D`, returning `195_948_557` — a recognisable wrong answer that proves silent corruption is real, not an artifact of DCE. 4. **`demo/test/snif_demo_test.exs` assertion tightened** — from `assert {:ok, _}` to `assert {:ok, [195_948_557]}`. A drift in this value means either Zig data-segment layout changed or — worse — the ReleaseFast artifact regained safety checks. Either should surface visibly, not pass silently. 5. **`priv/*.wasm` + `.gitignore` + `Justfile`** — committed wasm artifacts go stale the moment any zig source changes (which this PR does), so a local `mix test` against them would fail with confusing assertion mismatches. `git rm` the binaries, add `/priv/*.wasm` and `/zig-out/` to `.gitignore`, and make `just test-demo` depend on `just build-wasm` so the local flow always builds fresh. ## What this gates The gate is the property test itself, not a flag grep. If `e2e — Build-mode invariant` is red, the SNIFS isolation claim is void on the affected build. The test discriminates both directions: - ReleaseSafe artifact + illegal input → `{:error, _}` (trap surfaced) - ReleaseFast artifact + same input → `{:ok, [195_948_557]}` (canary leaked) — proving the test actually catches the absence of safety checks rather than passing trivially ## Test plan - [ ] `e2e — Build-mode invariant` job goes green - [ ] `setup-zig@abea47f...` and `setup-beam@fc68ffb...` both resolve (the fake SHAs would have 422'd) - [ ] Both `priv/safe_nif_ReleaseSafe.wasm` and `priv/safe_nif_ReleaseFast.wasm` are built fresh from source in CI - [ ] All 11 tests in `demo/test/snif_demo_test.exs` pass — including the tightened `ReleaseFast: oob reads canary from adjacent memory — DANGEROUS` assertion - [ ] `just test-demo` works locally end-to-end (build-wasm dep ensures fresh artifacts)
1 parent 4e4ff64 commit 6ab7493

9 files changed

Lines changed: 137 additions & 91 deletions

File tree

.github/workflows/e2e.yml

Lines changed: 79 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,33 @@
11
# SPDX-License-Identifier: MPL-2.0
22
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
33
#
4-
# RSR Standard E2E + Aspect + Benchmark Workflow Template
5-
#
6-
# Covers ALL merge requirement test categories:
7-
# - E2E (end-to-end pipeline tests)
8-
# - Aspect (cross-cutting concern validation)
9-
# - Benchmarks (performance regression detection)
10-
# - Readiness (Component Readiness Grade: D/C/B)
11-
#
12-
# INSTRUCTIONS: Uncomment and customise the section matching your stack.
13-
# Delete sections that don't apply. See examples in each job.
4+
# E2E gate for SNIFS: builds the wasm guest in BOTH ReleaseSafe and ReleaseFast
5+
# modes (per Justfile build-wasm), then runs the Elixir demo suite against the
6+
# fresh artifacts. The property under test is the SNIFS guarantee itself —
7+
# illegal behaviour in the ReleaseSafe artifact must surface as {:error, _},
8+
# and the ReleaseFast artifact must demonstrably produce the silent-corruption
9+
# anti-property. If this gate is red, the SNIFS isolation claim is void on the
10+
# affected build.
1411

1512
name: E2E + Aspect + Bench
1613

1714
on:
1815
push:
1916
branches: [main, master, develop]
2017
paths:
21-
- 'src/**'
22-
- 'ffi/**'
23-
- 'tests/**'
18+
- 'zig/**'
19+
- 'demo/**'
20+
- 'priv/**'
21+
- 'Justfile'
2422
- '.github/workflows/e2e.yml'
2523
pull_request:
2624
branches: [main, master]
2725
paths:
28-
- 'src/**'
29-
- 'ffi/**'
30-
- 'tests/**'
26+
- 'zig/**'
27+
- 'demo/**'
28+
- 'priv/**'
29+
- 'Justfile'
30+
- '.github/workflows/e2e.yml'
3131
workflow_dispatch:
3232

3333
permissions: read-all
@@ -37,48 +37,65 @@ concurrency:
3737
cancel-in-progress: true
3838

3939
jobs:
40-
# ─── End-to-End Tests ──────────────────────────────────────────────
41-
# Uncomment ONE of the following e2e job blocks matching your stack.
42-
43-
## === RUST E2E ===
44-
# e2e:
45-
# name: E2E — Full Pipeline
46-
# runs-on: ubuntu-latest
47-
# timeout-minutes: 15
48-
# steps:
49-
# - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
50-
# - uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable
51-
# - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
52-
# - run: cargo build --release
53-
# - run: bash tests/e2e.sh
54-
# # OR: cargo test --test end_to_end -- --nocapture
55-
56-
## === ZIG FFI E2E ===
57-
# e2e:
58-
# name: E2E — FFI Pipeline
59-
# runs-on: ubuntu-latest
60-
# timeout-minutes: 15
61-
# steps:
62-
# - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
63-
# - uses: goto-bus-stop/setup-zig@7ab2955eb728f5440978d7b4f723a50dea1f3608 # v2
64-
# with:
65-
# version: 0.15.0
66-
# - run: cd ffi/zig && zig build test
67-
# - run: bash tests/e2e.sh
68-
69-
## === ELIXIR E2E ===
70-
# e2e:
71-
# name: E2E — Full Pipeline
72-
# runs-on: ubuntu-latest
73-
# timeout-minutes: 15
74-
# steps:
75-
# - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
76-
# - uses: erlef/setup-beam@5a67e1a1dd86cae5e5bef84e2da5060406a66c07 # v1
77-
# with:
78-
# otp-version: '27.0'
79-
# elixir-version: '1.17'
80-
# - run: mix deps.get && mix compile --warnings-as-errors
81-
# - run: mix test test/integration/e2e_test.exs --trace
40+
# ─── Build-mode invariant gate ─────────────────────────────────────
41+
# Builds the wasm guest fresh from source (defeating any stale committed
42+
# artifacts) and runs the demo suite against it. The ReleaseSafe artifact
43+
# asserts traps → {:error, _}; the ReleaseFast artifact asserts the canary
44+
# value 0x0BADF00D leaks back as {:ok, [195_948_557]} (proving safety checks
45+
# are absent on that artifact — by design for the demo, and the proof that
46+
# the test discriminates between modes rather than passing trivially).
47+
e2e:
48+
name: E2E — Build-mode invariant
49+
runs-on: ubuntu-latest
50+
timeout-minutes: 15
51+
steps:
52+
- name: Checkout
53+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
54+
55+
- name: Install Zig 0.15.1
56+
uses: goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406 # v2.2.1
57+
with:
58+
version: 0.15.1
59+
60+
- name: Install just
61+
run: |
62+
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh \
63+
| bash -s -- --to /usr/local/bin
64+
65+
- name: Build wasm artifacts (both modes, from source)
66+
run: just build-wasm
67+
68+
- name: Verify build outputs
69+
run: |
70+
test -f priv/safe_nif_ReleaseSafe.wasm
71+
test -f priv/safe_nif_ReleaseFast.wasm
72+
ls -la priv/*.wasm
73+
74+
- name: Install Erlang/Elixir
75+
uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0
76+
with:
77+
# OTP 27 (not 28) because Hex 2.4.2 — the latest Hex release as of
78+
# 2026-05-30 — has BEAM bytecode that fails to load on OTP 28
79+
# ("Error loading function ... op bs_add p x i u x"; the bs_add
80+
# opcode was removed in OTP 26+ and Hex hasn't been re-released
81+
# against the new instruction set). README claims OTP 28 works but
82+
# that requires an unreleased Hex; OTP 27 is the highest current
83+
# combo that actually loads Hex.
84+
otp-version: '27.3'
85+
elixir-version: '1.18.0'
86+
87+
- name: Run demo test suite
88+
working-directory: demo
89+
run: |
90+
# Install latest Hex/rebar compatible with the Elixir/OTP combo.
91+
# Without this, the bundled-with-setup-beam Hex 2.4.2 hits a
92+
# `Hex.Repo module does not exist` supervisor error on
93+
# Elixir 1.18 / OTP 28 — fixed by installing the runtime-matched
94+
# Hex archive before `mix deps.get` tries to use it.
95+
mix local.hex --force
96+
mix local.rebar --force
97+
mix deps.get
98+
mix test --trace
8299
83100
## === DENO/RESCRIPT E2E ===
84101
# e2e:
@@ -87,7 +104,7 @@ jobs:
87104
# timeout-minutes: 15
88105
# steps:
89106
# - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
90-
# - uses: denoland/setup-deno@5fae568d37c3b73e0e4ca63d4e2c4e324a2b3497 # v2
107+
# - uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4
91108
# with:
92109
# deno-version: v2.x
93110
# - run: deno install --node-modules-dir=auto
@@ -105,7 +122,7 @@ jobs:
105122
# project: [chromium-1080p, firefox-1080p, webkit-1080p]
106123
# steps:
107124
# - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
108-
# - uses: denoland/setup-deno@5fae568d37c3b73e0e4ca63d4e2c4e324a2b3497 # v2
125+
# - uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4
109126
# with:
110127
# deno-version: v2.x
111128
# - run: deno install --node-modules-dir=auto
@@ -125,7 +142,7 @@ jobs:
125142
# timeout-minutes: 15
126143
# steps:
127144
# - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
128-
# - uses: haskell-actions/setup@dd344bc1cec854a9b55c2b857c28b688010e4fce # v2
145+
# - uses: haskell-actions/setup@cd0d9bdd65b20557f41bea4dbe43d0b5fbbfe553 # v2.11.0
129146
# with:
130147
# ghc-version: '9.6'
131148
# cabal-version: '3.10'
@@ -171,7 +188,7 @@ jobs:
171188
# timeout-minutes: 15
172189
# steps:
173190
# - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
174-
# - uses: goto-bus-stop/setup-zig@7ab2955eb728f5440978d7b4f723a50dea1f3608 # v2
191+
# - uses: goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406 # v2.2.1
175192
# with:
176193
# version: 0.15.0
177194
# - run: cd ffi/zig && zig build bench

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ Thumbs.db
1717
/build/
1818
/dist/
1919
/out/
20+
/priv/*.wasm
21+
/zig-out/
2022

2123
# Dependencies
2224
/node_modules/

.tool-versions

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
# Uncomment and customize for your project
2-
# rust nightly
3-
# just 1.40.0
4-
# nickel 1.10.0
5-
# gleam 1.8.0
6-
# elixir 1.18.0
7-
# erlang 27.2
8-
# zig 0.14.0
9-
# idris2 0.7.0
1+
# Toolchain versions pinned to match the e2e CI gate.
2+
# See .github/workflows/e2e.yml — same OTP/Elixir/Zig versions.
3+
#
4+
# NOTE on OTP: pinned to 27.3 (NOT 28.x) because Hex 2.4.2 — the current
5+
# latest release — has BEAM bytecode that fails to load on OTP 28 (the
6+
# `bs_add` opcode was removed in OTP 26+). Re-evaluate when Hex re-releases.
7+
elixir 1.18.0
8+
erlang 27.3
9+
zig 0.15.1
1010
rust nightly

Justfile

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1543,8 +1543,11 @@ handover-model path=".":
15431543
handover-human path=".":
15441544
@./session/dispatch.sh handover human "{{path}}"
15451545
1546-
# Build WASM binaries from Zig source
1546+
# Build WASM binaries from Zig source.
1547+
# Zig 0.15+ forbids folder separators in --name, so name is just the basename
1548+
# and -femit-bin controls the output path. priv/ is created if missing.
15471549
build-wasm:
1550+
mkdir -p priv
15481551
zig build-exe zig/src/safe_nif.zig \
15491552
-target wasm32-freestanding \
15501553
-OReleaseSafe \
@@ -1557,7 +1560,8 @@ build-wasm:
15571560
--export=crash_overflow \
15581561
--export=crash_div_zero \
15591562
--export=still_alive \
1560-
--name priv/safe_nif_ReleaseSafe
1563+
--name safe_nif_ReleaseSafe \
1564+
-femit-bin=priv/safe_nif_ReleaseSafe.wasm
15611565
zig build-exe zig/src/safe_nif.zig \
15621566
-target wasm32-freestanding \
15631567
-OReleaseFast \
@@ -1570,10 +1574,12 @@ build-wasm:
15701574
--export=crash_overflow \
15711575
--export=crash_div_zero \
15721576
--export=still_alive \
1573-
--name priv/safe_nif_ReleaseFast
1577+
--name safe_nif_ReleaseFast \
1578+
-femit-bin=priv/safe_nif_ReleaseFast.wasm
15741579
1575-
# Run Elixir demo tests
1576-
test-demo:
1580+
# Run Elixir demo tests (depends on fresh wasm artifacts so a stale priv/*.wasm
1581+
# can't silently pass the build-mode invariant test against old codegen)
1582+
test-demo: build-wasm
15771583
cd demo && mix deps.get && mix test
15781584
15791585
# Build PDF paper

README.adoc

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ and loading it through `wasmex` converts all guest faults into
3434
Formal verification:
3535
7/7 core proofs completed (Idris2 + Lean4), covering ABI correctness and API type safety.
3636

37-
11/11 integration tests pass on OTP 28 / Elixir 1.19.5. Every failure mode
37+
11/11 integration tests pass on OTP 27.3 / Elixir 1.18.0. Every failure mode
3838
(OOB array access, `unreachable`, `@panic`, integer overflow, divide-by-zero)
3939
traps correctly under `ReleaseSafe` compilation. The BEAM survives all of them.
4040

@@ -62,7 +62,7 @@ mix deps.get
6262
mix test
6363
----
6464

65-
Requires Elixir 1.15+, OTP 26+ (precompiled wasmex NIF downloaded automatically).
65+
Requires Elixir 1.15+, OTP 26–27 (precompiled wasmex NIF downloaded automatically). OTP 28 is not yet supported because the current Hex release (2.4.2) ships BEAM bytecode that fails to load on OTP 28 — re-evaluate when Hex publishes a release recompiled for the post-OTP-26 instruction set.
6666

6767
== Rebuilding the WASM
6868

demo/test/snif_demo_test.exs

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ defmodule SnifDemoTest do
33
use ExUnit.Case, async: false
44
alias SnifDemo.Loader
55

6-
@safe Path.join([__DIR__, "..", "priv", "safe_nif_ReleaseSafe.wasm"])
7-
@fast Path.join([__DIR__, "..", "priv", "safe_nif_ReleaseFast.wasm"])
6+
@safe Path.join([__DIR__, "..", "..", "priv", "safe_nif_ReleaseSafe.wasm"])
7+
@fast Path.join([__DIR__, "..", "..", "priv", "safe_nif_ReleaseFast.wasm"])
88

99
# ── Core isolation claim ──────────────────────────────────────────────────
1010

@@ -44,13 +44,17 @@ defmodule SnifDemoTest do
4444

4545
# ── ReleaseFast silent-wrong-answer demonstration ────────────────────────
4646

47-
test "ReleaseFast: oob returns silent 0 instead of trapping — DANGEROUS" do
48-
# In ReleaseFast the optimizer removes the OOB access entirely.
49-
# The BEAM survives (no crash) but gets a wrong answer with no indication.
50-
result = Loader.call(@fast, "crash_oob", [])
51-
# Should NOT be {:error, _} — it succeeds silently with garbage
52-
assert {:ok, _} = result
53-
IO.puts("\n [ReleaseFast oob result: #{inspect(result)}] — wrong answer, no trap")
47+
test "ReleaseFast: oob reads canary from adjacent memory — DANGEROUS" do
48+
# In ReleaseFast the bounds check is stripped. The OOB read at index 3 of
49+
# `crash_layout.arr` lands on `crash_layout.canary` (0x0BADF00D) placed
50+
# adjacent by the extern-struct layout pin. The host gets a plausible-
51+
# looking value back with no indication anything is wrong — exactly the
52+
# silent-corruption mode SNIFS+ReleaseSafe is sold as eliminating, and
53+
# which this assertion proves is reintroduced underneath SNIFS if the
54+
# wasm guest gets built with safety checks stripped. A drift in this value
55+
# (or a {:error, _} here) means either Zig's data-segment layout changed
56+
# or — worse — the ReleaseFast artifact has somehow regained safety checks.
57+
assert {:ok, [195_948_557]} = Loader.call(@fast, "crash_oob", [])
5458
end
5559

5660
test "ReleaseFast: unreachable still traps even in fast mode" do

priv/safe_nif_ReleaseFast.wasm

-7.17 KB
Binary file not shown.

priv/safe_nif_ReleaseSafe.wasm

-23.2 KB
Binary file not shown.

zig/src/safe_nif.zig

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,26 @@
1010
const std = @import("std");
1111

1212
// Prevent comptime evaluation of runtime values used in failure demos
13-
var runtime_index: usize = 99;
13+
var runtime_index: usize = 3;
1414
var runtime_zero: i32 = 0;
1515
var runtime_max: i32 = std.math.maxInt(i32);
1616

17+
// Layout-pinned crash demo: extern struct forces declaration-order field
18+
// placement, so an OOB read at index 3 of `arr` deterministically lands on
19+
// `canary` rather than indeterminate stack/heap memory. Under ReleaseSafe the
20+
// bounds check fires before the load; under ReleaseFast the check is stripped
21+
// and the load returns 0x0BADF00D — a recognisable wrong answer instead of an
22+
// ambiguous zero that could equally mean "load was DCE'd" or "read 0 from
23+
// adjacent memory."
24+
const CrashLayout = extern struct {
25+
arr: [3]i32,
26+
canary: i32,
27+
};
28+
var crash_layout: CrashLayout = .{
29+
.arr = .{ 10, 20, 30 },
30+
.canary = 0x0BADF00D,
31+
};
32+
1733
// --- Safe computation (would be a real NIF use-case) ---
1834

1935
/// Iterative fibonacci — no safety issues, just a useful computation.
@@ -42,10 +58,11 @@ export fn checked_add(a: i32, b: i32) i32 {
4258

4359
/// OOB array access via runtime index.
4460
/// ReleaseSafe: bounds check -> unreachable trap.
45-
/// ReleaseFast: optimizer removes access, returns 0 silently.
61+
/// ReleaseFast: bounds check stripped -> reads `crash_layout.canary` adjacent
62+
/// in linear memory, returns 0x0BADF00D (195948557) silently. The distinctive
63+
/// value distinguishes real silent corruption from the load being eliminated.
4664
export fn crash_oob() i32 {
47-
const arr = [_]i32{ 10, 20, 30 };
48-
return arr[runtime_index]; // runtime_index = 99, OOB
65+
return crash_layout.arr[runtime_index]; // runtime_index = 3, one past end
4966
}
5067

5168
/// Explicit unreachable — always reached at runtime.

0 commit comments

Comments
 (0)