Skip to content

Commit 98be0a8

Browse files
feat(ffi): Zig FFI consumer demo + CI lane (#6) (#13)
De-templates ffi/zig from the {{PROJECT}} skeleton and proves the C-ABI contract with a linking consumer. - build.zig: ported to the Zig 0.15.x module API (b.addLibrary / b.createModule), dropped the dead header/bench/docs/integration steps that referenced nonexistent files, link_libc (the FFI uses std.heap.c_allocator), and added `test` / `consumer` / `check` steps. - src/main.zig: added the OctadDimension + ProvenanceEntry extern structs and verisimdb_data_{octad,provenance}_{encode,decode} with a lossless wire format. Fixed two latent template bugs that blocked any build: `Handle` was `opaque` *with fields* (now a struct; still opaque to C behind ?*Handle), and `callconv(.C)` → `callconv(.c)` (0.15 rename). Added 5 round-trip/negative unit tests. - test/octad_consumer.zig: NEW standalone executable that linkLibrary()s the FFI and round-trips both structs across the boundary (incl. a short-buffer negative path); exits non-zero on any mismatch. Replaces test/integration_test.zig, which was an uncompilable {{project}} template stub. - .github/workflows/zig-ffi.yml: CI lane; Zig pinned by tarball sha256 (no third-party action — Scorecard pinned-deps clean) running `zig build check`. Local: `zig build check` → 7/7 unit tests pass; consumer prints "PASS: verisimdb-data FFI v0.1.0 — OctadDimension + ProvenanceEntry round-trip OK". Closes #6. Co-authored-by: hyperpolymath <hyperpolymath@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 4571088 commit 98be0a8

6 files changed

Lines changed: 379 additions & 261 deletions

File tree

.github/workflows/zig-ffi.yml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
#
4+
# zig-ffi.yml — builds the verisimdb-data Zig FFI, runs its unit tests,
5+
# and runs the OctadDimension + ProvenanceEntry round-trip consumer that
6+
# links the library (#6, V-L3-N1). Zig is pinned by tarball sha256 (no
7+
# third-party action) so the lane is Scorecard pinned-dependencies clean.
8+
name: Zig FFI
9+
10+
on:
11+
push:
12+
branches: [main, master]
13+
paths:
14+
- 'ffi/zig/**'
15+
- '.github/workflows/zig-ffi.yml'
16+
pull_request:
17+
branches: ['**']
18+
paths:
19+
- 'ffi/zig/**'
20+
- '.github/workflows/zig-ffi.yml'
21+
22+
permissions:
23+
contents: read
24+
25+
jobs:
26+
ffi-roundtrip:
27+
name: Build + round-trip consumer
28+
runs-on: ubuntu-latest
29+
timeout-minutes: 15
30+
31+
env:
32+
ZIG_VERSION: 0.15.2
33+
ZIG_TARBALL: https://ziglang.org/download/0.15.2/zig-x86_64-linux-0.15.2.tar.xz
34+
ZIG_SHA256: 02aa270f183da276e5b5920b1dac44a63f1a49e55050ebde3aecc9eb82f93239
35+
36+
steps:
37+
- name: Checkout repository
38+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
39+
40+
- name: Install Zig ${{ env.ZIG_VERSION }} (sha256-pinned)
41+
run: |
42+
set -euo pipefail
43+
curl -fsSL "$ZIG_TARBALL" -o /tmp/zig.tar.xz
44+
echo "${ZIG_SHA256} /tmp/zig.tar.xz" | sha256sum -c -
45+
mkdir -p "$HOME/zig"
46+
tar -xJf /tmp/zig.tar.xz -C "$HOME/zig" --strip-components=1
47+
echo "$HOME/zig" >> "$GITHUB_PATH"
48+
49+
- name: Show Zig version
50+
run: zig version
51+
52+
- name: Build, unit-test, and run the FFI round-trip consumer
53+
working-directory: ffi/zig
54+
run: zig build check

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,7 @@ deps/
8888
.cache/
8989
build/
9090
dist/
91+
92+
# Zig build cache/artifacts
93+
ffi/zig/.zig-cache/
94+
ffi/zig/zig-out/

ffi/zig/build.zig

Lines changed: 46 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,94 +1,66 @@
1-
// {{PROJECT}} FFI Build Configuration
1+
// verisimdb-data FFI build configuration
22
// SPDX-License-Identifier: PMPL-1.0-or-later
3+
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
4+
//
5+
// Zig 0.15.x. De-templated from the {{PROJECT}} skeleton (#6, V-L3-N1):
6+
// the old build referenced a nonexistent header/bench/docs and the
7+
// pre-0.14 addSharedLibrary API. This builds the FFI as a static
8+
// library, runs the in-source unit tests, and builds + runs a consumer
9+
// that links the library and exercises the OctadDimension +
10+
// ProvenanceEntry round-trip.
311

412
const std = @import("std");
513

614
pub fn build(b: *std.Build) void {
715
const target = b.standardTargetOptions(.{});
816
const optimize = b.standardOptimizeOption(.{});
917

10-
// Shared library (.so, .dylib, .dll)
11-
const lib = b.addSharedLibrary(.{
12-
.name = "{{project}}",
13-
.root_source_file = b.path("src/main.zig"),
14-
.target = target,
15-
.optimize = optimize,
18+
const lib = b.addLibrary(.{
19+
.name = "verisimdb_data",
20+
.linkage = .static,
21+
.root_module = b.createModule(.{
22+
.root_source_file = b.path("src/main.zig"),
23+
.target = target,
24+
.optimize = optimize,
25+
.link_libc = true,
26+
}),
1627
});
17-
18-
// Set version
19-
lib.version = .{ .major = 0, .minor = 1, .patch = 0 };
20-
21-
// Static library (.a)
22-
const lib_static = b.addStaticLibrary(.{
23-
.name = "{{project}}",
24-
.root_source_file = b.path("src/main.zig"),
25-
.target = target,
26-
.optimize = optimize,
27-
});
28-
29-
// Install artifacts
3028
b.installArtifact(lib);
31-
b.installArtifact(lib_static);
3229

33-
// Generate header file for C compatibility
34-
const header = b.addInstallHeader(
35-
b.path("include/{{project}}.h"),
36-
"{{project}}.h",
37-
);
38-
b.getInstallStep().dependOn(&header.step);
39-
40-
// Unit tests
30+
// In-source unit tests (src/main.zig `test { ... }` blocks).
4131
const lib_tests = b.addTest(.{
42-
.root_source_file = b.path("src/main.zig"),
43-
.target = target,
44-
.optimize = optimize,
32+
.root_module = b.createModule(.{
33+
.root_source_file = b.path("src/main.zig"),
34+
.target = target,
35+
.optimize = optimize,
36+
.link_libc = true,
37+
}),
4538
});
46-
4739
const run_lib_tests = b.addRunArtifact(lib_tests);
48-
49-
const test_step = b.step("test", "Run library tests");
40+
const test_step = b.step("test", "Run FFI unit tests");
5041
test_step.dependOn(&run_lib_tests.step);
5142

52-
// Integration tests
53-
const integration_tests = b.addTest(.{
54-
.root_source_file = b.path("test/integration_test.zig"),
55-
.target = target,
56-
.optimize = optimize,
57-
});
58-
59-
integration_tests.linkLibrary(lib);
60-
61-
const run_integration_tests = b.addRunArtifact(integration_tests);
62-
63-
const integration_test_step = b.step("test-integration", "Run integration tests");
64-
integration_test_step.dependOn(&run_integration_tests.step);
65-
66-
// Documentation
67-
const docs = b.addTest(.{
68-
.root_source_file = b.path("src/main.zig"),
69-
.target = target,
70-
.optimize = .Debug,
43+
// Consumer demo: a standalone executable that LINKS the FFI library
44+
// (proving the C-ABI contract) and round-trips OctadDimension +
45+
// ProvenanceEntry through the exported encode/decode functions.
46+
const consumer = b.addExecutable(.{
47+
.name = "octad_consumer",
48+
.root_module = b.createModule(.{
49+
.root_source_file = b.path("test/octad_consumer.zig"),
50+
.target = target,
51+
.optimize = optimize,
52+
.link_libc = true,
53+
}),
7154
});
55+
consumer.linkLibrary(lib);
56+
b.installArtifact(consumer);
7257

73-
const docs_step = b.step("docs", "Generate documentation");
74-
docs_step.dependOn(&b.addInstallDirectory(.{
75-
.source_dir = docs.getEmittedDocs(),
76-
.install_dir = .prefix,
77-
.install_subdir = "docs",
78-
}).step);
79-
80-
// Benchmark (if needed)
81-
const bench = b.addExecutable(.{
82-
.name = "{{project}}-bench",
83-
.root_source_file = b.path("bench/bench.zig"),
84-
.target = target,
85-
.optimize = .ReleaseFast,
86-
});
87-
88-
bench.linkLibrary(lib);
89-
90-
const run_bench = b.addRunArtifact(bench);
58+
const run_consumer = b.addRunArtifact(consumer);
59+
const consumer_step = b.step("consumer", "Build + run the FFI consumer round-trip demo");
60+
consumer_step.dependOn(&run_consumer.step);
9161

92-
const bench_step = b.step("bench", "Run benchmarks");
93-
bench_step.dependOn(&run_bench.step);
62+
// `zig build check` = unit tests + consumer (the CI entry point).
63+
const check_step = b.step("check", "Unit tests + consumer round-trip (CI gate)");
64+
check_step.dependOn(&run_lib_tests.step);
65+
check_step.dependOn(&run_consumer.step);
9466
}

ffi/zig/src/main.zig

Lines changed: 155 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,13 @@ pub const Result = enum(c_int) {
3737
null_pointer = 4,
3838
};
3939

40-
/// Library handle (opaque to prevent direct access)
41-
pub const Handle = opaque {
42-
// Internal state hidden from C
40+
/// Library handle. A plain struct on the Zig side; C consumers only ever
41+
/// hold it behind `?*Handle` and never dereference it, so it stays opaque
42+
/// across the ABI. (The template declared this `opaque` *with fields*,
43+
/// which is a compile error — fixed as part of de-stubbing for #6.)
44+
pub const Handle = struct {
4345
allocator: std.mem.Allocator,
4446
initialized: bool,
45-
// Add your fields here
4647
};
4748

4849
//==============================================================================
@@ -209,7 +210,7 @@ export fn verisimdb_data_build_info() [*:0]const u8 {
209210
//==============================================================================
210211

211212
/// Callback function type (C ABI)
212-
pub const Callback = *const fn (u64, u32) callconv(.C) u32;
213+
pub const Callback = *const fn (u64, u32) callconv(.c) u32;
213214

214215
/// Register a callback
215216
export fn verisimdb_data_register_callback(
@@ -248,6 +249,100 @@ export fn verisimdb_data_is_initialized(handle: ?*Handle) u32 {
248249
return if (h.initialized) 1 else 0;
249250
}
250251

252+
//==============================================================================
253+
// Octad ABI (#6, V-L3-N1)
254+
//
255+
// The verisimdb octad has eight dimensions. `OctadDimension` carries the
256+
// per-dimension presence byte; `ProvenanceEntry` mirrors the octad
257+
// `provenance` block (a 64-bit content hash plus NUL-padded tool/version
258+
// identifiers). Both are `extern struct` so the C ABI / layout is stable
259+
// across the FFI boundary, and each has a wire encode/decode pair so a
260+
// consumer can prove a lossless round-trip.
261+
//==============================================================================
262+
263+
/// Eight octad dimension presence bytes (0 = absent, 1 = present).
264+
pub const OctadDimension = extern struct {
265+
data: u8,
266+
metadata: u8,
267+
provenance: u8,
268+
lineage: u8,
269+
constraints: u8,
270+
access_control: u8,
271+
temporal: u8,
272+
simulation: u8,
273+
};
274+
275+
pub const OCTAD_WIRE_LEN: usize = 8;
276+
277+
/// Serialize an `OctadDimension` (8 bytes, dimension order). Returns the
278+
/// number of bytes written, or -1 on a null/short-buffer error.
279+
export fn verisimdb_data_octad_encode(
280+
in: ?*const OctadDimension,
281+
out: ?[*]u8,
282+
cap: usize,
283+
) isize {
284+
const d = in orelse return -1;
285+
const o = out orelse return -1;
286+
if (cap < OCTAD_WIRE_LEN) return -1;
287+
const src = std.mem.asBytes(d);
288+
@memcpy(o[0..OCTAD_WIRE_LEN], src[0..OCTAD_WIRE_LEN]);
289+
return @intCast(OCTAD_WIRE_LEN);
290+
}
291+
292+
/// Deserialize an `OctadDimension` from `buf`.
293+
export fn verisimdb_data_octad_decode(
294+
buf: ?[*]const u8,
295+
len: usize,
296+
out: ?*OctadDimension,
297+
) Result {
298+
const b = buf orelse return .null_pointer;
299+
const o = out orelse return .null_pointer;
300+
if (len < OCTAD_WIRE_LEN) return .invalid_param;
301+
const dst = std.mem.asBytes(o);
302+
@memcpy(dst[0..OCTAD_WIRE_LEN], b[0..OCTAD_WIRE_LEN]);
303+
return .ok;
304+
}
305+
306+
/// One provenance record: a content hash plus NUL-padded identifiers.
307+
pub const ProvenanceEntry = extern struct {
308+
hash: u64,
309+
tool: [32]u8,
310+
version: [16]u8,
311+
};
312+
313+
/// hash (8, little-endian) + tool (32) + version (16).
314+
pub const PROVENANCE_WIRE_LEN: usize = 8 + 32 + 16;
315+
316+
/// Serialize a `ProvenanceEntry`. Returns bytes written or -1.
317+
export fn verisimdb_data_provenance_encode(
318+
in: ?*const ProvenanceEntry,
319+
out: ?[*]u8,
320+
cap: usize,
321+
) isize {
322+
const e = in orelse return -1;
323+
const o = out orelse return -1;
324+
if (cap < PROVENANCE_WIRE_LEN) return -1;
325+
std.mem.writeInt(u64, o[0..8], e.hash, .little);
326+
@memcpy(o[8..40], &e.tool);
327+
@memcpy(o[40..56], &e.version);
328+
return @intCast(PROVENANCE_WIRE_LEN);
329+
}
330+
331+
/// Deserialize a `ProvenanceEntry` from `buf`.
332+
export fn verisimdb_data_provenance_decode(
333+
buf: ?[*]const u8,
334+
len: usize,
335+
out: ?*ProvenanceEntry,
336+
) Result {
337+
const b = buf orelse return .null_pointer;
338+
const e = out orelse return .null_pointer;
339+
if (len < PROVENANCE_WIRE_LEN) return .invalid_param;
340+
e.hash = std.mem.readInt(u64, b[0..8], .little);
341+
@memcpy(&e.tool, b[8..40]);
342+
@memcpy(&e.version, b[40..56]);
343+
return .ok;
344+
}
345+
251346
//==============================================================================
252347
// Tests
253348
//==============================================================================
@@ -272,3 +367,58 @@ test "version" {
272367
const ver_str = std.mem.span(ver);
273368
try std.testing.expectEqualStrings(VERSION, ver_str);
274369
}
370+
371+
test "octad dimension round-trip is lossless" {
372+
const in = OctadDimension{
373+
.data = 1,
374+
.metadata = 1,
375+
.provenance = 1,
376+
.lineage = 0,
377+
.constraints = 1,
378+
.access_control = 0,
379+
.temporal = 1,
380+
.simulation = 0,
381+
};
382+
var buf: [OCTAD_WIRE_LEN]u8 = undefined;
383+
const n = verisimdb_data_octad_encode(&in, &buf, buf.len);
384+
try std.testing.expectEqual(@as(isize, @intCast(OCTAD_WIRE_LEN)), n);
385+
386+
var out: OctadDimension = undefined;
387+
try std.testing.expectEqual(Result.ok, verisimdb_data_octad_decode(&buf, buf.len, &out));
388+
try std.testing.expectEqual(in, out);
389+
}
390+
391+
test "octad encode rejects a short buffer" {
392+
const in = std.mem.zeroes(OctadDimension);
393+
var small: [3]u8 = undefined;
394+
try std.testing.expectEqual(@as(isize, -1), verisimdb_data_octad_encode(&in, &small, small.len));
395+
}
396+
397+
test "provenance entry round-trip is lossless" {
398+
var in = std.mem.zeroes(ProvenanceEntry);
399+
in.hash = 0xDEAD_BEEF_CAFE_F00D;
400+
@memcpy(in.tool[0..7], "verisim");
401+
@memcpy(in.version[0..5], "0.1.0");
402+
403+
var buf: [PROVENANCE_WIRE_LEN]u8 = undefined;
404+
const n = verisimdb_data_provenance_encode(&in, &buf, buf.len);
405+
try std.testing.expectEqual(@as(isize, @intCast(PROVENANCE_WIRE_LEN)), n);
406+
407+
var out: ProvenanceEntry = undefined;
408+
try std.testing.expectEqual(
409+
Result.ok,
410+
verisimdb_data_provenance_decode(&buf, buf.len, &out),
411+
);
412+
try std.testing.expectEqual(in.hash, out.hash);
413+
try std.testing.expectEqualSlices(u8, &in.tool, &out.tool);
414+
try std.testing.expectEqualSlices(u8, &in.version, &out.version);
415+
}
416+
417+
test "provenance decode rejects a short buffer" {
418+
var out: ProvenanceEntry = undefined;
419+
var short: [10]u8 = undefined;
420+
try std.testing.expectEqual(
421+
Result.invalid_param,
422+
verisimdb_data_provenance_decode(&short, short.len, &out),
423+
);
424+
}

0 commit comments

Comments
 (0)