|
| 1 | +// SPDX-License-Identifier: CC-BY-SA-4.0 |
| 2 | +// Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> |
| 3 | +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> |
| 4 | + |
| 5 | += Zig FFI Formal Verification |
| 6 | +:toc: |
| 7 | +:toclevels: 3 |
| 8 | +:sectnums: |
| 9 | + |
| 10 | +== Overview |
| 11 | + |
| 12 | +This document describes the formal verification approach for the BoJ Server Zig FFI |
| 13 | +layer. No standard Zig verifier exists; instead the methodology exploits Zig's |
| 14 | +`comptime` evaluation as a sound proof mechanism: any `comptime {}` block that reaches |
| 15 | +`@compileError` is a *compile-time theorem* — the binary cannot be built unless the |
| 16 | +theorem holds. |
| 17 | + |
| 18 | +=== Trust Chain |
| 19 | + |
| 20 | +---- |
| 21 | +Idris2 proofs (src/abi/) |
| 22 | + │ [ASSUMED] cross-language ABI contract (unverifiable by either tool) |
| 23 | + ▼ |
| 24 | +abi_axioms.zig — compile-time theorems about every ABI constant |
| 25 | + │ [CROSS] comptime cross-module assertions in abi_verify.zig |
| 26 | + ▼ |
| 27 | +abi_verify.zig — live Zig enum/constant values match the axioms |
| 28 | + │ [RUNTIME] exhaustive boundary tests at every C-ABI entry point |
| 29 | + ▼ |
| 30 | +catalogue.zig, safety.zig, cartridge_shim.zig — the live FFI layer |
| 31 | +---- |
| 32 | + |
| 33 | +=== Evidence Taxonomy |
| 34 | + |
| 35 | +Every claim in this verification is tagged with one of four evidence levels: |
| 36 | + |
| 37 | +[cols="1,4",options="header"] |
| 38 | +|=== |
| 39 | +| Tag | Meaning |
| 40 | + |
| 41 | +| [STATIC] |
| 42 | +| `comptime` assertion in the *same* file. A compiling binary cannot violate it. |
| 43 | + |
| 44 | +| [CROSS] |
| 45 | +| `comptime` assertion in `abi_verify.zig` that imports a live module and |
| 46 | + compares its value against the axiom. Fires at compile time of the test binary; |
| 47 | + a build failure here means the live module and the axiom have diverged. |
| 48 | + |
| 49 | +| [RUNTIME] |
| 50 | +| Exhaustive boundary test exercised at run time. Proves the C-ABI function |
| 51 | + *behaves* as specified, not just that types align. |
| 52 | + |
| 53 | +| [ASSUMED] |
| 54 | +| Cross-language invariant that neither Zig nor Idris2 can prove in isolation |
| 55 | + (e.g., layout agreement between the C header and both implementations). |
| 56 | + Documented as an axiom; any violation would be discovered by runtime tests or |
| 57 | + by a mismatch caught in CI. |
| 58 | +|=== |
| 59 | + |
| 60 | +== Instruments |
| 61 | + |
| 62 | +=== `ffi/zig/src/abi_axioms.zig` |
| 63 | + |
| 64 | +A *standalone* file with no imports. Every ABI-relevant numeric constant is |
| 65 | +declared here as an untyped `comptime_int`, enabling universal coercion across |
| 66 | +types without casting. Each section follows its constant declarations with one |
| 67 | +or more `comptime {}` blocks that call `@compileError` on any violation. |
| 68 | + |
| 69 | +This file is the *single source of truth* for all ABI numeric values visible to |
| 70 | +the Zig layer. |
| 71 | + |
| 72 | +==== Sections |
| 73 | + |
| 74 | +[cols="1,3",options="header"] |
| 75 | +|=== |
| 76 | +| Section | Contents |
| 77 | + |
| 78 | +| §1 Invocation ABI |
| 79 | +| `RC_*` return codes (ADR-0006): `RC_SUCCESS=0`, `RC_UNKNOWN_TOOL=-1`, ..., |
| 80 | + `RC_AUTH_DENIED=-6`. Theorems ax.1.1–ax.1.4: success is zero, all errors are |
| 81 | + strictly negative, the range is dense in `[-6,-1]`, and `RC_CODE_COUNT` is |
| 82 | + consistent. |
| 83 | + |
| 84 | +| §2 Catalogue ABI |
| 85 | +| Status codes, protocol range, domain range, tier values, capacity limits, |
| 86 | + buffer bounds, and catalogue return codes. Critical theorem ax.2.2: |
| 87 | + `MOUNT_GATE_STATUS == STATUS_READY`, i.e., only `STATUS_READY` cartridges |
| 88 | + may be mounted. Theorem ax.2.7: `CAT_OK == RC_SUCCESS` (both are 0). |
| 89 | + |
| 90 | +| §3 Loader ABI |
| 91 | +| `HASH_LEN=32`, `HASH_HEX_LEN=64` (SHA-256 hex digest), path limits, and |
| 92 | + loader return codes. Critical theorem ax.3.2: `LOADER_MATCH=1 ≠ CAT_OK=0` |
| 93 | + — the loader's "match" result must *not* be conflated with "success" in |
| 94 | + catalogue conventions. |
| 95 | + |
| 96 | +| §4 Safety ABI |
| 97 | +| `SAFETY_SAFE=1`, rejection codes `[-8,-1]`, buffer size limits. |
| 98 | + Theorem ax.4.6: `SAFETY_SAFE=1 ≠ RC_SUCCESS=0` — a "safe" result must not |
| 99 | + be conflated with "success" in the invocation convention. |
| 100 | +|=== |
| 101 | + |
| 102 | +==== Running |
| 103 | + |
| 104 | +---- |
| 105 | +zig build abi-axioms |
| 106 | +---- |
| 107 | + |
| 108 | +A successful build proves every `comptime` theorem in the file holds. There are |
| 109 | +no runtime tests in this step; it is purely a compile-time proof. |
| 110 | + |
| 111 | +=== `ffi/zig/src/abi_verify.zig` |
| 112 | + |
| 113 | +Cross-checks the *live* Zig modules against the axioms, then exercises every |
| 114 | +C-ABI entry point exhaustively at the boundaries declared in `abi_axioms.zig`. |
| 115 | + |
| 116 | +==== Section A — Comptime Cross-Module Proofs [CROSS] |
| 117 | + |
| 118 | +Each sub-section imports a live module and checks that its enum integer values |
| 119 | +equal the corresponding axiom constants via `@compileError`: |
| 120 | + |
| 121 | +[cols="1,4",options="header"] |
| 122 | +|=== |
| 123 | +| Sub-section | Live module | Claims verified |
| 124 | + |
| 125 | +| A.1 | `catalogue` | `CartridgeStatus` enum: `.development=0`, `.ready=1`, |
| 126 | + `.deprecated=2`, `.faulty=3` match `STATUS_*` axioms. |
| 127 | + |
| 128 | +| A.2 | `catalogue` | `ProtocolType` enum: MCP=1 through REST=9 match `PROTO_*` |
| 129 | + axioms. Count theorem: exactly 9 protocols. |
| 130 | + |
| 131 | +| A.3 | `catalogue` | `CapabilityDomain` enum: spot-checks on CLOUD=1, FLEET=12, |
| 132 | + NESY=13. |
| 133 | + |
| 134 | +| A.4 | `catalogue` | `MenuTier` enum: TERANGA=0, SHIELD=1, AYO=2 match |
| 135 | + `TIER_*` axioms. |
| 136 | + |
| 137 | +| A.5 | `cartridge_shim` | `RC_*` constants in the shim match §1 axioms. |
| 138 | + |
| 139 | +| A.6 | _(gap)_ | `loader.zig` cannot be imported as a named module because it |
| 140 | + uses `@import("catalogue.zig")` (relative path), which conflicts with the named |
| 141 | + `catalogue` module in a shared compilation. `HASH_LEN` / `HASH_HEX_LEN` |
| 142 | + coverage is instead provided by §D axiom-level tests. *Fix:* update |
| 143 | + `loader.zig` to `const catalogue = @import("catalogue");` (named module import). |
| 144 | + |
| 145 | +| A.7 | `safety` | `SafetyError` enum: `shell_injection=-1`, `json_unsafe=-8` |
| 146 | + match `SAFETY_*` axioms. |
| 147 | +|=== |
| 148 | + |
| 149 | +==== Section B — Catalogue Boundary Tests [RUNTIME] |
| 150 | + |
| 151 | +Exhaustive black-box tests via `catalogue`'s `pub export fn` symbols: |
| 152 | + |
| 153 | +[cols="1,3",options="header"] |
| 154 | +|=== |
| 155 | +| Test | What it proves |
| 156 | + |
| 157 | +| B.1 | `boj_catalogue_init()` returns `CAT_OK=0`; state is clean. |
| 158 | + |
| 159 | +| B.2 | `boj_catalogue_deinit()` is idempotent. |
| 160 | + |
| 161 | +| B.3 | Registering exactly `MAX_CARTRIDGES=128` entries succeeds; the 129th |
| 162 | + returns `CAT_ERR=-1`. |
| 163 | + |
| 164 | +| B.4 | `boj_catalogue_mount()` succeeds only for `STATUS_READY` cartridges; |
| 165 | + returns `-1` for development/deprecated/faulty. Directly exercises the |
| 166 | + mount-gate invariant (ax.2.2). |
| 167 | + |
| 168 | +| B.5/B.6 | Name boundary: 64-byte name accepted; 65-byte name rejected. |
| 169 | + |
| 170 | +| B.7/B.8 | Version boundary: 16-byte version accepted; 17-byte version rejected. |
| 171 | + |
| 172 | +| B.9–B.11 | Mount/unmount/is-mounted return conventions. |
| 173 | + |
| 174 | +| B.12 | `MAX_ORDER_SIZE=16` slot boundary. |
| 175 | + |
| 176 | +| B.13/B.14 | Backend string boundary: 32 bytes accepted; 33 bytes rejected. |
| 177 | + |
| 178 | +| B.15 | `boj_catalogue_version()` returns a non-null, non-empty string. |
| 179 | +|=== |
| 180 | + |
| 181 | +==== Section C — Invocation ABI Tests [RUNTIME] |
| 182 | + |
| 183 | +Tests via `cartridge_shim`'s public Zig functions: |
| 184 | + |
| 185 | +* `RC_SUCCESS=0` on successful invocation. |
| 186 | +* `RC_UNKNOWN_TOOL=-1` for an unknown tool name. |
| 187 | +* `RC_BAD_ARGS=-2` for a malformed argument buffer. |
| 188 | +* `RC_BUFFER_TOO_SMALL=-3` when the output buffer is undersized. |
| 189 | +* `RC_RUNTIME_ERROR=-4`, `RC_PANIC=-5`, `RC_AUTH_DENIED=-6`. |
| 190 | +* Two-phase call pattern (C.8): probe with zero-length buffer to discover |
| 191 | + required size, then allocate and call with the real buffer. |
| 192 | + |
| 193 | +==== Section D — Hash Constant Boundary Tests [RUNTIME] |
| 194 | + |
| 195 | +Axiom-level verification that does not import `loader.zig` (see §A.6 GAP): |
| 196 | + |
| 197 | +* `HASH_HEX_LEN == HASH_LEN * 2` (SHA-256 hex digest size relationship). |
| 198 | +* `LOADER_MATCH=1 ≠ CAT_OK=0` (trap: loader's "match" must not be confused with |
| 199 | + catalogue "ok"). |
| 200 | + |
| 201 | +==== Section E — Safety Boundary Tests [RUNTIME] |
| 202 | + |
| 203 | +Tests via `extern fn` declarations (because `safety.zig` exports its functions |
| 204 | +without `pub`, so they are only accessible via C linkage): |
| 205 | + |
| 206 | +---- |
| 207 | +extern fn boj_safety_check_shell_arg(ptr: [*]const u8, len: usize) c_int; |
| 208 | +extern fn boj_safety_check_sql_value(ptr: [*]const u8, len: usize) c_int; |
| 209 | +extern fn boj_safety_check_path(ptr: [*]const u8, len: usize) c_int; |
| 210 | +extern fn boj_safety_check_url_scheme(ptr: [*]const u8, len: usize) c_int; |
| 211 | +extern fn boj_safety_check_json_string(ptr: [*]const u8, len: usize) c_int; |
| 212 | +---- |
| 213 | + |
| 214 | +Boundary exercises (E.2/E.3): `MAX_SHELL_ARG=4096` bytes is accepted; |
| 215 | +4097 bytes is rejected. |
| 216 | + |
| 217 | +==== Section F — Thread-Safety Spot Check [RUNTIME] |
| 218 | + |
| 219 | +Eight threads × 4 catalogue operations each (32 total concurrent operations). |
| 220 | +Verifies that the module-level `Mutex` wrapper provides mutual exclusion: no |
| 221 | +race conditions or assertion failures under concurrent load. |
| 222 | + |
| 223 | +==== Running |
| 224 | + |
| 225 | +---- |
| 226 | +zig build abi-verify |
| 227 | +---- |
| 228 | + |
| 229 | +All 40 tests must pass. A failure indicates either a live-module value has |
| 230 | +diverged from an axiom (§A, compile-time), or a C-ABI boundary contract has |
| 231 | +been violated (§B–§F, runtime). |
| 232 | + |
| 233 | +== Known Gaps and Assumptions |
| 234 | + |
| 235 | +=== [ASSUMED] C Header / Zig Alignment |
| 236 | + |
| 237 | +The `generated/abi/boj_catalogue.h` constants (e.g., `BOJ_STATUS_READY=1`, |
| 238 | +`BOJ_PROTO_MCP=1`) are declared to match the Idris2 ABI but are not |
| 239 | +automatically cross-checked against `abi_axioms.zig`. A divergence between |
| 240 | +the C header and the Zig axioms would go undetected until runtime. |
| 241 | + |
| 242 | +*Mitigation:* The axioms and the C header constants were cross-checked manually |
| 243 | +at the time of writing. Future: add a CI step that extracts the `#define` values |
| 244 | +from the header and diffs them against the axiom constants. |
| 245 | + |
| 246 | +=== [ASSUMED] Idris2 / Zig Integer Layout Agreement |
| 247 | + |
| 248 | +The Idris2 `statusToInt`, `protocolToInt`, and `domainToInt` functions produce |
| 249 | +values that this layer *assumes* match the `STATUS_*`, `PROTO_*`, and |
| 250 | +`DOMAIN_*` axioms. This is a cross-language boundary that neither side can |
| 251 | +prove without a shared proof obligation. |
| 252 | + |
| 253 | +=== [GAP] Loader Module Import Conflict (§A.6) |
| 254 | + |
| 255 | +`loader.zig` uses `@import("catalogue.zig")` (relative path), which prevents |
| 256 | +it from being imported as a named module alongside `catalogue` in the same |
| 257 | +compilation unit. The `HASH_LEN` / `HASH_HEX_LEN` constants in `loader.zig` |
| 258 | +are therefore verified only at the axiom level (§D), not by cross-module |
| 259 | +comptime assertion. |
| 260 | + |
| 261 | +*Fix:* Change `loader.zig` line 181 from `@import("catalogue.zig")` to |
| 262 | +`@import("catalogue")`. Once applied, add `abi_verify_mod.addImport("loader", loader_mod)` |
| 263 | +in `build.zig` and a §A.6 cross-module check in `abi_verify.zig`. |
| 264 | + |
| 265 | +=== [ASSUMED] Safety Private Constants |
| 266 | + |
| 267 | +`MAX_SHELL_ARG_LEN` and `MAX_PATH_LEN` in `safety.zig` are private (not `pub`). |
| 268 | +The axioms declare `MAX_SHELL_ARG=4096` and `MAX_PATH=4096` as assumed values; |
| 269 | +if `safety.zig` is changed, the axioms will not detect the divergence. |
| 270 | + |
| 271 | +*Mitigation:* §E tests exercise the boundary directly, so a size change in |
| 272 | +`safety.zig` will cause E.2/E.3 to fail. |
| 273 | + |
| 274 | +== Zig 0.16 Mutex Migration |
| 275 | + |
| 276 | +`std.Thread.Mutex` was removed in Zig 0.16 as part of the `Io`-based async |
| 277 | +redesign. The replacement (`std.Io.Mutex`) requires an `Io` context for every |
| 278 | +`lock()` call, which is incompatible with synchronous C-ABI exports. |
| 279 | + |
| 280 | +The nine affected FFI modules (`catalogue.zig`, `loader.zig`, `sla.zig`, |
| 281 | +`coprocessor.zig`, `community.zig`, `sdp.zig`, `guardian.zig`, `verisimdb.zig`, |
| 282 | +`federation.zig`) were migrated to a local `Mutex` wrapper: |
| 283 | + |
| 284 | +---- |
| 285 | +const Mutex = struct { |
| 286 | + state: std.atomic.Mutex = .unlocked, |
| 287 | + pub fn lock(m: *Mutex) void { |
| 288 | + while (!m.state.tryLock()) std.atomic.spinLoopHint(); |
| 289 | + } |
| 290 | + pub fn unlock(m: *Mutex) void { |
| 291 | + m.state.unlock(); |
| 292 | + } |
| 293 | +}; |
| 294 | +---- |
| 295 | + |
| 296 | +`std.atomic.Mutex` is a compare-and-swap TAS lock; `spinLoopHint()` emits a |
| 297 | +CPU pause hint on x86-64 to reduce power consumption and memory bus traffic |
| 298 | +during contention. This is appropriate for the FFI layer's usage pattern: |
| 299 | +all critical sections are sub-microsecond struct field updates with very low |
| 300 | +contention probability. |
| 301 | + |
| 302 | +If a blocking (sleep-based) OS mutex is needed in future, the recommended |
| 303 | +migration path on Linux is `std.c.pthread_mutex_t` with `link_libc = true` |
| 304 | +in each module's `build.zig` entry. |
| 305 | + |
| 306 | +== How to Run All Verification Steps |
| 307 | + |
| 308 | +---- |
| 309 | +# Step 1: Compile-time ABI axiom proofs (pure comptime, no runtime) |
| 310 | +zig build abi-axioms |
| 311 | +
|
| 312 | +# Step 2: Cross-module proofs + exhaustive boundary tests (40 tests) |
| 313 | +zig build abi-verify |
| 314 | +
|
| 315 | +# Run both together |
| 316 | +zig build abi-axioms abi-verify |
| 317 | +---- |
| 318 | + |
| 319 | +Expected output: |
| 320 | + |
| 321 | +---- |
| 322 | +Build Summary: 6/6 steps succeeded; 40/40 tests passed |
| 323 | +abi-axioms success |
| 324 | ++- run test success |
| 325 | +abi-verify success |
| 326 | ++- run test 40 pass (40 total) |
| 327 | +---- |
0 commit comments