Skip to content

Commit cac5c4c

Browse files
feat(ffi): contract-test the idris2↔zig token-buffer ABI (seams) (#334)
## Seam-contract tests for the idris2↔zig token buffer Adapts boj-server's seam pattern (`ffi/zig/src/seams.zig` + the `proof-debt.md` axiom/external-validation discipline, standards#203) to ephapax's lexer FFI boundary: `tokbuf.zig` ↔ `tokbuf.h` ↔ `ZigBuffer.idr`'s `%foreign` bindings. ### The headline `eph_tokbuf_str_ptr` ends in a `@ptrCast` that **asserts** (does not check) the NUL sentinel — the `// SAFETY:` comment argues it holds because `copyStr` allocates `len+1` and writes a trailing `0`. That's a class-(J) assumption (true by construction, unprovable at the cast site). The `str_ptr` seams **externally validate** it: push real strings, read them back through the cast, assert byte-round-trip + NUL-termination. A regression in `copyStr` now **fails a seam loudly** instead of becoming UB at an Idris `String` read. → turns the `zig_ptr_cast` finding from *suppressed* into *tested*. ### 10 seams (all green locally, zig 0.15.2) - `@ptrCast`/NUL-sentinel invariant ×3 (incl. the `""` empty-string sentinel path) - marshalling round-trips: `tag` (u32), `bool`, `line`/`col` incl. negatives - `i32 → usize` clamping: `new(cap ≤ 0)`, `str_len < 0` → `0` + `""` (no over-read) - realloc growth (push past cap) + new/free lifecycle ### Changes | File | What | |---|---| | `tokbuf.zig` | `export fn` → `pub export fn` ×10 so `seams.zig` can `@import`. **Purely additive — emitted C symbols unchanged.** | | `seams.zig` *(new)* | the 10 tests + methodology + an ABI finding in its header. Run: `zig test -lc idris2/ffi/zig/seams.zig` | | `ffi-seams.yml` *(new)* | CI gate; `setup-zig` 0.15.2, `zig test -lc`. Dedicated workflow (not `abi-verify.yml`, which is Idris2-container/`src/abi`-scoped). Follows the estate no-path-filter `changes`+`needs`+`if` convention so it can't strand PRs. | ### Finding (documented, not auto-fixed) Integer **type-width drift** across the three layers — `tag`/`bool_val`/`kind` declared `Int` (Idris) / `int32_t` (`.h`) / `u32`|`u8` (zig). Functions for current value ranges but not type-clean (sharpest: `bool_val`, 1-byte vs declared 4-byte). Recommend regenerating `tokbuf.h` from the zig sigs + `Bits32`/`Bits8` in `ZigBuffer.idr` — left for owner triage (an FFI sig edit can shift the binding). ### Notes - **Out of scope:** the Rust-side `from_raw` (`ephapax-runtime`/`-vram-cache`) + `ephapax-interp` `unsafe`/`as_ptr` — different boundary, already `// SAFETY:`-documented. - The methodology lives in the `seams.zig` header rather than a CC-BY-SA-4.0 `SEAMS.adoc` because this repo's `.git/hooks/pre-commit` is the **pre-fix version** that demands MPL-2.0 even for `.adoc` (the `standards` hook was fixed to allow CC-BY-SA-4.0 for docs; ephapax's copy is stale). Minor follow-up: sync ephapax's hook to the standards version. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6a9a91c commit cac5c4c

3 files changed

Lines changed: 240 additions & 10 deletions

File tree

.github/workflows/ffi-seams.yml

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
# Owner: Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
# ffi-seams.yml — contract tests for the idris2 <-> zig token-buffer ABI.
4+
#
5+
# Runs `zig test` on idris2/ffi/zig/seams.zig, which exercises the @ptrCast
6+
# NUL-sentinel invariant + integer/bool marshalling + i32<->usize clamping
7+
# across the C-ABI seam (see the header of idris2/ffi/zig/seams.zig).
8+
#
9+
# Convention (per the estate CI rule): NO workflow-level `on.*.paths` on a
10+
# gate — a path-filtered required check is reported "Expected" forever and
11+
# strands PRs as blocked. Instead an always-run `changes` job decides whether
12+
# the heavy `test` job runs; a job skipped via `if:` counts as a passing
13+
# required check.
14+
15+
name: FFI Seam Tests
16+
17+
on:
18+
push:
19+
branches: [main]
20+
pull_request:
21+
branches: [main]
22+
workflow_dispatch:
23+
24+
permissions:
25+
contents: read
26+
27+
concurrency:
28+
group: ${{ github.workflow }}-${{ github.ref }}
29+
cancel-in-progress: true
30+
31+
jobs:
32+
changes:
33+
name: Detect relevant changes
34+
runs-on: ubuntu-latest
35+
timeout-minutes: 5
36+
outputs:
37+
run: ${{ steps.detect.outputs.run }}
38+
steps:
39+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
40+
with:
41+
fetch-depth: 0
42+
- id: detect
43+
env:
44+
EVENT: ${{ github.event_name }}
45+
BASE: ${{ github.base_ref }}
46+
BEFORE: ${{ github.event.before }}
47+
run: |
48+
# Fail-safe: default to running; only set run=false when a successful
49+
# diff shows nothing under idris2/ffi/zig/ changed.
50+
set -uo pipefail
51+
run=true
52+
if [ "$EVENT" = pull_request ]; then
53+
git fetch --no-tags --depth=200 origin "$BASE" 2>/dev/null \
54+
&& changed=$(git diff --name-only "origin/${BASE}...HEAD" 2>/dev/null) \
55+
&& { printf '%s\n' "$changed" | grep -qE '^idris2/ffi/zig/' && run=true || run=false; }
56+
elif [ "$EVENT" = push ] && [ -n "$BEFORE" ] && [ "$BEFORE" != 0000000000000000000000000000000000000000 ]; then
57+
changed=$(git diff --name-only "${BEFORE}...${GITHUB_SHA}" 2>/dev/null) \
58+
&& { printf '%s\n' "$changed" | grep -qE '^idris2/ffi/zig/' && run=true || run=false; }
59+
fi
60+
printf 'run=%s\n' "$run" >> "$GITHUB_OUTPUT"
61+
echo "relevant=$run"
62+
63+
test:
64+
name: Zig FFI seam tests
65+
needs: changes
66+
if: needs.changes.outputs.run == 'true'
67+
runs-on: ubuntu-latest
68+
timeout-minutes: 15
69+
steps:
70+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
71+
72+
- name: Install Zig
73+
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2
74+
with:
75+
version: 0.15.2
76+
77+
- name: Run token-buffer seam tests
78+
# -lc: tokbuf.zig allocates via std.heap.c_allocator.
79+
run: zig test -lc idris2/ffi/zig/seams.zig

idris2/ffi/zig/seams.zig

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// Owner: 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+
// seams.zig — integration-contract tests for the ephapax idris2 <-> zig token
6+
// buffer ABI: tokbuf.zig <-> tokbuf.h <-> the %foreign bindings in
7+
// src/Ephapax/Parse/ZigBuffer.idr. Pattern adapted from
8+
// boj-server/ffi/zig/src/seams.zig (the estate trusted-base reference impl).
9+
//
10+
// Each `test "seam: ..."` pins one promise the Idris2 side relies on across the
11+
// C-ABI boundary. A failure here is a genuine ABI/contract defect, not a flaky
12+
// test. In particular, the str_ptr seams EXERCISE the @ptrCast at tokbuf.zig:99
13+
// and assert the NUL-sentinel invariant that the `// SAFETY:` comment there only
14+
// *asserts* — turning asserted-safe into tested-safe (see METHODOLOGY below).
15+
//
16+
// Run: zig test -lc idris2/ffi/zig/seams.zig
17+
// (-lc is required because tokbuf.zig allocates via std.heap.c_allocator.)
18+
// CI: .github/workflows/ffi-seams.yml (setup-zig 0.15.2).
19+
//
20+
// ── METHODOLOGY ────────────────────────────────────────────────────────────
21+
// Adapted from boj-server/ffi/zig/src/seams.zig + its docs/proof-debt.md
22+
// "axiom + external-validation" discipline (estate trusted-base reference,
23+
// standards#203). `eph_tokbuf_str_ptr` ends in a @ptrCast that ASSERTS (does
24+
// not check) the NUL sentinel; the sentinel holds because the producer
25+
// `copyStr` allocates len+1 and writes a trailing 0. That is a class-(J)-style
26+
// assumption (true by construction, not provable at the cast site). These seams
27+
// are its external validation: a regression in copyStr fails a seam loudly
28+
// instead of becoming UB at an Idris `String` read.
29+
//
30+
// ── FINDING: integer type-width drift across the three contract layers ──────
31+
// The seams pass (tokbuf.zig is internally consistent) but the DECLARED types
32+
// drift: field ZigBuffer.idr tokbuf.h tokbuf.zig
33+
// tag Int (64-bit) int32_t u32
34+
// bool_val Int (64-bit) int32_t u8
35+
// kind (ret) Int (64-bit) int32_t u32
36+
// It functions for the value ranges used (small token kinds, 0/1 booleans)
37+
// because each callee reads the width it declares, but it is not type-clean
38+
// (most sharply bool_val: 1-byte u8 vs declared 4-byte int32_t). Recommended
39+
// follow-up (NOT done here, to keep this test-only): regenerate tokbuf.h from
40+
// the Zig signatures and consider Bits32/Bits8 in ZigBuffer.idr. Left for owner
41+
// triage — an FFI signature edit can shift the binding.
42+
//
43+
// OUT OF SCOPE: the Rust-side from_raw (ephapax-runtime / -vram-cache) and
44+
// ephapax-interp unsafe/as_ptr are a different boundary (Rust FFI / dlopen),
45+
// documented with // SAFETY: comments at their call sites; not exercised here.
46+
47+
const std = @import("std");
48+
const tb = @import("tokbuf.zig");
49+
50+
// ─── @ptrCast / NUL-sentinel invariant (the headline) ──────────────────────
51+
52+
test "seam: str_ptr returns a NUL-terminated copy equal to the pushed string" {
53+
const buf = tb.eph_tokbuf_new(4);
54+
defer tb.eph_tokbuf_free(buf);
55+
tb.eph_tokbuf_push(buf, 7, "hello", 5, 0, 1, 1);
56+
// eph_tokbuf_str_ptr performs the @ptrCast under test (tokbuf.zig:99).
57+
const p = tb.eph_tokbuf_str_ptr(buf, 0);
58+
try std.testing.expectEqualStrings("hello", std.mem.span(p));
59+
try std.testing.expectEqual(@as(i32, 5), tb.eph_tokbuf_str_len(buf, 0));
60+
}
61+
62+
test "seam: str_ptr is independently NUL-terminated for each of several tokens" {
63+
const buf = tb.eph_tokbuf_new(0);
64+
defer tb.eph_tokbuf_free(buf);
65+
tb.eph_tokbuf_push(buf, 1, "a", 1, 0, 1, 1);
66+
tb.eph_tokbuf_push(buf, 2, "bb", 2, 0, 1, 2);
67+
tb.eph_tokbuf_push(buf, 3, "ccc", 3, 0, 1, 3);
68+
try std.testing.expectEqualStrings("a", std.mem.span(tb.eph_tokbuf_str_ptr(buf, 0)));
69+
try std.testing.expectEqualStrings("bb", std.mem.span(tb.eph_tokbuf_str_ptr(buf, 1)));
70+
try std.testing.expectEqualStrings("ccc", std.mem.span(tb.eph_tokbuf_str_ptr(buf, 2)));
71+
}
72+
73+
test "seam: empty string yields the \"\" sentinel, never a dangling cast" {
74+
const buf = tb.eph_tokbuf_new(2);
75+
defer tb.eph_tokbuf_free(buf);
76+
tb.eph_tokbuf_push(buf, 0, "", 0, 0, 1, 1);
77+
// str_ptr falls through the optional-null branch to the "" literal.
78+
try std.testing.expectEqualStrings("", std.mem.span(tb.eph_tokbuf_str_ptr(buf, 0)));
79+
try std.testing.expectEqual(@as(i32, 0), tb.eph_tokbuf_str_len(buf, 0));
80+
}
81+
82+
// ─── marshalling round-trips ───────────────────────────────────────────────
83+
84+
test "seam: tag (u32) round-trips through kind" {
85+
const buf = tb.eph_tokbuf_new(1);
86+
defer tb.eph_tokbuf_free(buf);
87+
tb.eph_tokbuf_push(buf, 0xABCD_1234, "x", 1, 0, 1, 1);
88+
try std.testing.expectEqual(@as(u32, 0xABCD_1234), tb.eph_tokbuf_kind(buf, 0));
89+
}
90+
91+
test "seam: bool_val round-trips (truthy + falsy)" {
92+
const buf = tb.eph_tokbuf_new(2);
93+
defer tb.eph_tokbuf_free(buf);
94+
tb.eph_tokbuf_push(buf, 0, "t", 1, 1, 1, 1);
95+
tb.eph_tokbuf_push(buf, 0, "f", 1, 0, 1, 1);
96+
try std.testing.expectEqual(@as(u8, 1), tb.eph_tokbuf_bool(buf, 0));
97+
try std.testing.expectEqual(@as(u8, 0), tb.eph_tokbuf_bool(buf, 1));
98+
}
99+
100+
test "seam: line/col round-trip including negatives" {
101+
const buf = tb.eph_tokbuf_new(1);
102+
defer tb.eph_tokbuf_free(buf);
103+
tb.eph_tokbuf_push(buf, 0, "x", 1, 0, -3, 42);
104+
try std.testing.expectEqual(@as(i32, -3), tb.eph_tokbuf_line(buf, 0));
105+
try std.testing.expectEqual(@as(i32, 42), tb.eph_tokbuf_col(buf, 0));
106+
}
107+
108+
// ─── i32 <-> usize clamping at the boundary ────────────────────────────────
109+
110+
test "seam: new(cap <= 0) yields a usable empty buffer that grows on push" {
111+
const buf = tb.eph_tokbuf_new(-5);
112+
defer tb.eph_tokbuf_free(buf);
113+
try std.testing.expectEqual(@as(i32, 0), tb.eph_tokbuf_len(buf));
114+
tb.eph_tokbuf_push(buf, 9, "g", 1, 0, 1, 1);
115+
try std.testing.expectEqual(@as(i32, 1), tb.eph_tokbuf_len(buf));
116+
try std.testing.expectEqual(@as(u32, 9), tb.eph_tokbuf_kind(buf, 0));
117+
}
118+
119+
test "seam: negative str_len clamps to 0 and a \"\" sentinel (no over-read)" {
120+
const buf = tb.eph_tokbuf_new(1);
121+
defer tb.eph_tokbuf_free(buf);
122+
tb.eph_tokbuf_push(buf, 0, "ignored", -1, 0, 1, 1);
123+
try std.testing.expectEqual(@as(i32, 0), tb.eph_tokbuf_str_len(buf, 0));
124+
try std.testing.expectEqualStrings("", std.mem.span(tb.eph_tokbuf_str_ptr(buf, 0)));
125+
}
126+
127+
// ─── lifecycle / growth ────────────────────────────────────────────────────
128+
129+
test "seam: push beyond initial cap reallocs and preserves every token" {
130+
const buf = tb.eph_tokbuf_new(2); // cap 2; push 100 to force realloc growth
131+
defer tb.eph_tokbuf_free(buf);
132+
var i: i32 = 0;
133+
while (i < 100) : (i += 1) {
134+
tb.eph_tokbuf_push(buf, @as(u32, @intCast(i)), "n", 1, 0, i, i);
135+
}
136+
try std.testing.expectEqual(@as(i32, 100), tb.eph_tokbuf_len(buf));
137+
try std.testing.expectEqual(@as(u32, 0), tb.eph_tokbuf_kind(buf, 0));
138+
try std.testing.expectEqual(@as(u32, 99), tb.eph_tokbuf_kind(buf, 99));
139+
try std.testing.expectEqual(@as(i32, 99), tb.eph_tokbuf_col(buf, 99));
140+
try std.testing.expectEqualStrings("n", std.mem.span(tb.eph_tokbuf_str_ptr(buf, 99)));
141+
}
142+
143+
test "seam: repeated new/free cycles leave a clean state (no crash, fresh len)" {
144+
var c: usize = 0;
145+
while (c < 3) : (c += 1) {
146+
const buf = tb.eph_tokbuf_new(1);
147+
tb.eph_tokbuf_push(buf, 1, "x", 1, 0, 1, 1);
148+
try std.testing.expectEqual(@as(i32, 1), tb.eph_tokbuf_len(buf));
149+
tb.eph_tokbuf_free(buf);
150+
}
151+
}

idris2/ffi/zig/tokbuf.zig

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ fn copyStr(alloc: std.mem.Allocator, ptr: [*:0]const u8, len: i32) ?[*]u8 {
3838
return out.ptr;
3939
}
4040

41-
export fn eph_tokbuf_new(cap: i32) *TokBuf {
41+
pub export fn eph_tokbuf_new(cap: i32) *TokBuf {
4242
const alloc = std.heap.c_allocator;
4343
const cap_usize = if (cap <= 0) 0 else @as(usize, @intCast(cap));
4444
const items = alloc.alloc(Tok, cap_usize) catch @panic("alloc failed");
@@ -47,7 +47,7 @@ export fn eph_tokbuf_new(cap: i32) *TokBuf {
4747
return buf;
4848
}
4949

50-
export fn eph_tokbuf_free(buf: *TokBuf) void {
50+
pub export fn eph_tokbuf_free(buf: *TokBuf) void {
5151
const alloc = buf.allocator;
5252
var i: usize = 0;
5353
while (i < buf.len) : (i += 1) {
@@ -62,7 +62,7 @@ export fn eph_tokbuf_free(buf: *TokBuf) void {
6262
alloc.destroy(buf);
6363
}
6464

65-
export fn eph_tokbuf_push(buf: *TokBuf, tag: u32, str_ptr: [*:0]const u8, str_len: i32, bool_val: u8, line: i32, col: i32) void {
65+
pub export fn eph_tokbuf_push(buf: *TokBuf, tag: u32, str_ptr: [*:0]const u8, str_len: i32, bool_val: u8, line: i32, col: i32) void {
6666
ensureCap(buf, buf.len + 1);
6767
const s_ptr = copyStr(buf.allocator, str_ptr, str_len);
6868
buf.items[buf.len] = Tok{
@@ -76,16 +76,16 @@ export fn eph_tokbuf_push(buf: *TokBuf, tag: u32, str_ptr: [*:0]const u8, str_le
7676
buf.len += 1;
7777
}
7878

79-
export fn eph_tokbuf_len(buf: *TokBuf) i32 {
79+
pub export fn eph_tokbuf_len(buf: *TokBuf) i32 {
8080
return @as(i32, @intCast(buf.len));
8181
}
8282

83-
export fn eph_tokbuf_kind(buf: *TokBuf, idx: i32) u32 {
83+
pub export fn eph_tokbuf_kind(buf: *TokBuf, idx: i32) u32 {
8484
const i = @as(usize, @intCast(idx));
8585
return buf.items[i].tag;
8686
}
8787

88-
export fn eph_tokbuf_str_ptr(buf: *TokBuf, idx: i32) [*:0]const u8 {
88+
pub export fn eph_tokbuf_str_ptr(buf: *TokBuf, idx: i32) [*:0]const u8 {
8989
const i = @as(usize, @intCast(idx));
9090
const t = buf.items[i];
9191
if (t.str_ptr) |p| {
@@ -101,22 +101,22 @@ export fn eph_tokbuf_str_ptr(buf: *TokBuf, idx: i32) [*:0]const u8 {
101101
return "";
102102
}
103103

104-
export fn eph_tokbuf_str_len(buf: *TokBuf, idx: i32) i32 {
104+
pub export fn eph_tokbuf_str_len(buf: *TokBuf, idx: i32) i32 {
105105
const i = @as(usize, @intCast(idx));
106106
return @as(i32, @intCast(buf.items[i].str_len));
107107
}
108108

109-
export fn eph_tokbuf_bool(buf: *TokBuf, idx: i32) u8 {
109+
pub export fn eph_tokbuf_bool(buf: *TokBuf, idx: i32) u8 {
110110
const i = @as(usize, @intCast(idx));
111111
return buf.items[i].bool_val;
112112
}
113113

114-
export fn eph_tokbuf_line(buf: *TokBuf, idx: i32) i32 {
114+
pub export fn eph_tokbuf_line(buf: *TokBuf, idx: i32) i32 {
115115
const i = @as(usize, @intCast(idx));
116116
return buf.items[i].line;
117117
}
118118

119-
export fn eph_tokbuf_col(buf: *TokBuf, idx: i32) i32 {
119+
pub export fn eph_tokbuf_col(buf: *TokBuf, idx: i32) i32 {
120120
const i = @as(usize, @intCast(idx));
121121
return buf.items[i].col;
122122
}

0 commit comments

Comments
 (0)