diff --git a/.github/workflows/zig-ffi.yml b/.github/workflows/zig-ffi.yml new file mode 100644 index 0000000..6a01a2d --- /dev/null +++ b/.github/workflows/zig-ffi.yml @@ -0,0 +1,54 @@ +# SPDX-License-Identifier: PMPL-1.0-or-later +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# zig-ffi.yml — builds the verisimdb-data Zig FFI, runs its unit tests, +# and runs the OctadDimension + ProvenanceEntry round-trip consumer that +# links the library (#6, V-L3-N1). Zig is pinned by tarball sha256 (no +# third-party action) so the lane is Scorecard pinned-dependencies clean. +name: Zig FFI + +on: + push: + branches: [main, master] + paths: + - 'ffi/zig/**' + - '.github/workflows/zig-ffi.yml' + pull_request: + branches: ['**'] + paths: + - 'ffi/zig/**' + - '.github/workflows/zig-ffi.yml' + +permissions: + contents: read + +jobs: + ffi-roundtrip: + name: Build + round-trip consumer + runs-on: ubuntu-latest + timeout-minutes: 15 + + env: + ZIG_VERSION: 0.15.2 + ZIG_TARBALL: https://ziglang.org/download/0.15.2/zig-x86_64-linux-0.15.2.tar.xz + ZIG_SHA256: 02aa270f183da276e5b5920b1dac44a63f1a49e55050ebde3aecc9eb82f93239 + + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Install Zig ${{ env.ZIG_VERSION }} (sha256-pinned) + run: | + set -euo pipefail + curl -fsSL "$ZIG_TARBALL" -o /tmp/zig.tar.xz + echo "${ZIG_SHA256} /tmp/zig.tar.xz" | sha256sum -c - + mkdir -p "$HOME/zig" + tar -xJf /tmp/zig.tar.xz -C "$HOME/zig" --strip-components=1 + echo "$HOME/zig" >> "$GITHUB_PATH" + + - name: Show Zig version + run: zig version + + - name: Build, unit-test, and run the FFI round-trip consumer + working-directory: ffi/zig + run: zig build check diff --git a/.gitignore b/.gitignore index 06fe1b0..5fcb2ef 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,7 @@ deps/ .cache/ build/ dist/ + +# Zig build cache/artifacts +ffi/zig/.zig-cache/ +ffi/zig/zig-out/ diff --git a/ffi/zig/build.zig b/ffi/zig/build.zig index c2081bd..72cea54 100644 --- a/ffi/zig/build.zig +++ b/ffi/zig/build.zig @@ -1,5 +1,13 @@ -// {{PROJECT}} FFI Build Configuration +// verisimdb-data FFI build configuration // SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Zig 0.15.x. De-templated from the {{PROJECT}} skeleton (#6, V-L3-N1): +// the old build referenced a nonexistent header/bench/docs and the +// pre-0.14 addSharedLibrary API. This builds the FFI as a static +// library, runs the in-source unit tests, and builds + runs a consumer +// that links the library and exercises the OctadDimension + +// ProvenanceEntry round-trip. const std = @import("std"); @@ -7,88 +15,52 @@ pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); - // Shared library (.so, .dylib, .dll) - const lib = b.addSharedLibrary(.{ - .name = "{{project}}", - .root_source_file = b.path("src/main.zig"), - .target = target, - .optimize = optimize, + const lib = b.addLibrary(.{ + .name = "verisimdb_data", + .linkage = .static, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }), }); - - // Set version - lib.version = .{ .major = 0, .minor = 1, .patch = 0 }; - - // Static library (.a) - const lib_static = b.addStaticLibrary(.{ - .name = "{{project}}", - .root_source_file = b.path("src/main.zig"), - .target = target, - .optimize = optimize, - }); - - // Install artifacts b.installArtifact(lib); - b.installArtifact(lib_static); - // Generate header file for C compatibility - const header = b.addInstallHeader( - b.path("include/{{project}}.h"), - "{{project}}.h", - ); - b.getInstallStep().dependOn(&header.step); - - // Unit tests + // In-source unit tests (src/main.zig `test { ... }` blocks). const lib_tests = b.addTest(.{ - .root_source_file = b.path("src/main.zig"), - .target = target, - .optimize = optimize, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }), }); - const run_lib_tests = b.addRunArtifact(lib_tests); - - const test_step = b.step("test", "Run library tests"); + const test_step = b.step("test", "Run FFI unit tests"); test_step.dependOn(&run_lib_tests.step); - // Integration tests - const integration_tests = b.addTest(.{ - .root_source_file = b.path("test/integration_test.zig"), - .target = target, - .optimize = optimize, - }); - - integration_tests.linkLibrary(lib); - - const run_integration_tests = b.addRunArtifact(integration_tests); - - const integration_test_step = b.step("test-integration", "Run integration tests"); - integration_test_step.dependOn(&run_integration_tests.step); - - // Documentation - const docs = b.addTest(.{ - .root_source_file = b.path("src/main.zig"), - .target = target, - .optimize = .Debug, + // Consumer demo: a standalone executable that LINKS the FFI library + // (proving the C-ABI contract) and round-trips OctadDimension + + // ProvenanceEntry through the exported encode/decode functions. + const consumer = b.addExecutable(.{ + .name = "octad_consumer", + .root_module = b.createModule(.{ + .root_source_file = b.path("test/octad_consumer.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }), }); + consumer.linkLibrary(lib); + b.installArtifact(consumer); - const docs_step = b.step("docs", "Generate documentation"); - docs_step.dependOn(&b.addInstallDirectory(.{ - .source_dir = docs.getEmittedDocs(), - .install_dir = .prefix, - .install_subdir = "docs", - }).step); - - // Benchmark (if needed) - const bench = b.addExecutable(.{ - .name = "{{project}}-bench", - .root_source_file = b.path("bench/bench.zig"), - .target = target, - .optimize = .ReleaseFast, - }); - - bench.linkLibrary(lib); - - const run_bench = b.addRunArtifact(bench); + const run_consumer = b.addRunArtifact(consumer); + const consumer_step = b.step("consumer", "Build + run the FFI consumer round-trip demo"); + consumer_step.dependOn(&run_consumer.step); - const bench_step = b.step("bench", "Run benchmarks"); - bench_step.dependOn(&run_bench.step); + // `zig build check` = unit tests + consumer (the CI entry point). + const check_step = b.step("check", "Unit tests + consumer round-trip (CI gate)"); + check_step.dependOn(&run_lib_tests.step); + check_step.dependOn(&run_consumer.step); } diff --git a/ffi/zig/src/main.zig b/ffi/zig/src/main.zig index 1155b63..0b581fc 100644 --- a/ffi/zig/src/main.zig +++ b/ffi/zig/src/main.zig @@ -37,12 +37,13 @@ pub const Result = enum(c_int) { null_pointer = 4, }; -/// Library handle (opaque to prevent direct access) -pub const Handle = opaque { - // Internal state hidden from C +/// Library handle. A plain struct on the Zig side; C consumers only ever +/// hold it behind `?*Handle` and never dereference it, so it stays opaque +/// across the ABI. (The template declared this `opaque` *with fields*, +/// which is a compile error — fixed as part of de-stubbing for #6.) +pub const Handle = struct { allocator: std.mem.Allocator, initialized: bool, - // Add your fields here }; //============================================================================== @@ -209,7 +210,7 @@ export fn verisimdb_data_build_info() [*:0]const u8 { //============================================================================== /// Callback function type (C ABI) -pub const Callback = *const fn (u64, u32) callconv(.C) u32; +pub const Callback = *const fn (u64, u32) callconv(.c) u32; /// Register a callback export fn verisimdb_data_register_callback( @@ -248,6 +249,100 @@ export fn verisimdb_data_is_initialized(handle: ?*Handle) u32 { return if (h.initialized) 1 else 0; } +//============================================================================== +// Octad ABI (#6, V-L3-N1) +// +// The verisimdb octad has eight dimensions. `OctadDimension` carries the +// per-dimension presence byte; `ProvenanceEntry` mirrors the octad +// `provenance` block (a 64-bit content hash plus NUL-padded tool/version +// identifiers). Both are `extern struct` so the C ABI / layout is stable +// across the FFI boundary, and each has a wire encode/decode pair so a +// consumer can prove a lossless round-trip. +//============================================================================== + +/// Eight octad dimension presence bytes (0 = absent, 1 = present). +pub const OctadDimension = extern struct { + data: u8, + metadata: u8, + provenance: u8, + lineage: u8, + constraints: u8, + access_control: u8, + temporal: u8, + simulation: u8, +}; + +pub const OCTAD_WIRE_LEN: usize = 8; + +/// Serialize an `OctadDimension` (8 bytes, dimension order). Returns the +/// number of bytes written, or -1 on a null/short-buffer error. +export fn verisimdb_data_octad_encode( + in: ?*const OctadDimension, + out: ?[*]u8, + cap: usize, +) isize { + const d = in orelse return -1; + const o = out orelse return -1; + if (cap < OCTAD_WIRE_LEN) return -1; + const src = std.mem.asBytes(d); + @memcpy(o[0..OCTAD_WIRE_LEN], src[0..OCTAD_WIRE_LEN]); + return @intCast(OCTAD_WIRE_LEN); +} + +/// Deserialize an `OctadDimension` from `buf`. +export fn verisimdb_data_octad_decode( + buf: ?[*]const u8, + len: usize, + out: ?*OctadDimension, +) Result { + const b = buf orelse return .null_pointer; + const o = out orelse return .null_pointer; + if (len < OCTAD_WIRE_LEN) return .invalid_param; + const dst = std.mem.asBytes(o); + @memcpy(dst[0..OCTAD_WIRE_LEN], b[0..OCTAD_WIRE_LEN]); + return .ok; +} + +/// One provenance record: a content hash plus NUL-padded identifiers. +pub const ProvenanceEntry = extern struct { + hash: u64, + tool: [32]u8, + version: [16]u8, +}; + +/// hash (8, little-endian) + tool (32) + version (16). +pub const PROVENANCE_WIRE_LEN: usize = 8 + 32 + 16; + +/// Serialize a `ProvenanceEntry`. Returns bytes written or -1. +export fn verisimdb_data_provenance_encode( + in: ?*const ProvenanceEntry, + out: ?[*]u8, + cap: usize, +) isize { + const e = in orelse return -1; + const o = out orelse return -1; + if (cap < PROVENANCE_WIRE_LEN) return -1; + std.mem.writeInt(u64, o[0..8], e.hash, .little); + @memcpy(o[8..40], &e.tool); + @memcpy(o[40..56], &e.version); + return @intCast(PROVENANCE_WIRE_LEN); +} + +/// Deserialize a `ProvenanceEntry` from `buf`. +export fn verisimdb_data_provenance_decode( + buf: ?[*]const u8, + len: usize, + out: ?*ProvenanceEntry, +) Result { + const b = buf orelse return .null_pointer; + const e = out orelse return .null_pointer; + if (len < PROVENANCE_WIRE_LEN) return .invalid_param; + e.hash = std.mem.readInt(u64, b[0..8], .little); + @memcpy(&e.tool, b[8..40]); + @memcpy(&e.version, b[40..56]); + return .ok; +} + //============================================================================== // Tests //============================================================================== @@ -272,3 +367,58 @@ test "version" { const ver_str = std.mem.span(ver); try std.testing.expectEqualStrings(VERSION, ver_str); } + +test "octad dimension round-trip is lossless" { + const in = OctadDimension{ + .data = 1, + .metadata = 1, + .provenance = 1, + .lineage = 0, + .constraints = 1, + .access_control = 0, + .temporal = 1, + .simulation = 0, + }; + var buf: [OCTAD_WIRE_LEN]u8 = undefined; + const n = verisimdb_data_octad_encode(&in, &buf, buf.len); + try std.testing.expectEqual(@as(isize, @intCast(OCTAD_WIRE_LEN)), n); + + var out: OctadDimension = undefined; + try std.testing.expectEqual(Result.ok, verisimdb_data_octad_decode(&buf, buf.len, &out)); + try std.testing.expectEqual(in, out); +} + +test "octad encode rejects a short buffer" { + const in = std.mem.zeroes(OctadDimension); + var small: [3]u8 = undefined; + try std.testing.expectEqual(@as(isize, -1), verisimdb_data_octad_encode(&in, &small, small.len)); +} + +test "provenance entry round-trip is lossless" { + var in = std.mem.zeroes(ProvenanceEntry); + in.hash = 0xDEAD_BEEF_CAFE_F00D; + @memcpy(in.tool[0..7], "verisim"); + @memcpy(in.version[0..5], "0.1.0"); + + var buf: [PROVENANCE_WIRE_LEN]u8 = undefined; + const n = verisimdb_data_provenance_encode(&in, &buf, buf.len); + try std.testing.expectEqual(@as(isize, @intCast(PROVENANCE_WIRE_LEN)), n); + + var out: ProvenanceEntry = undefined; + try std.testing.expectEqual( + Result.ok, + verisimdb_data_provenance_decode(&buf, buf.len, &out), + ); + try std.testing.expectEqual(in.hash, out.hash); + try std.testing.expectEqualSlices(u8, &in.tool, &out.tool); + try std.testing.expectEqualSlices(u8, &in.version, &out.version); +} + +test "provenance decode rejects a short buffer" { + var out: ProvenanceEntry = undefined; + var short: [10]u8 = undefined; + try std.testing.expectEqual( + Result.invalid_param, + verisimdb_data_provenance_decode(&short, short.len, &out), + ); +} diff --git a/ffi/zig/test/integration_test.zig b/ffi/zig/test/integration_test.zig deleted file mode 100644 index d66a302..0000000 --- a/ffi/zig/test/integration_test.zig +++ /dev/null @@ -1,182 +0,0 @@ -// {{PROJECT}} Integration Tests -// SPDX-License-Identifier: PMPL-1.0-or-later -// -// These tests verify that the Zig FFI correctly implements the Idris2 ABI - -const std = @import("std"); -const testing = std.testing; - -// Import FFI functions -extern fn {{project}}_init() ?*opaque {}; -extern fn {{project}}_free(?*opaque {}) void; -extern fn {{project}}_process(?*opaque {}, u32) c_int; -extern fn {{project}}_get_string(?*opaque {}) ?[*:0]const u8; -extern fn {{project}}_free_string(?[*:0]const u8) void; -extern fn {{project}}_last_error() ?[*:0]const u8; -extern fn {{project}}_version() [*:0]const u8; -extern fn {{project}}_is_initialized(?*opaque {}) u32; - -//============================================================================== -// Lifecycle Tests -//============================================================================== - -test "create and destroy handle" { - const handle = {{project}}_init() orelse return error.InitFailed; - defer {{project}}_free(handle); - - try testing.expect(handle != null); -} - -test "handle is initialized" { - const handle = {{project}}_init() orelse return error.InitFailed; - defer {{project}}_free(handle); - - const initialized = {{project}}_is_initialized(handle); - try testing.expectEqual(@as(u32, 1), initialized); -} - -test "null handle is not initialized" { - const initialized = {{project}}_is_initialized(null); - try testing.expectEqual(@as(u32, 0), initialized); -} - -//============================================================================== -// Operation Tests -//============================================================================== - -test "process with valid handle" { - const handle = {{project}}_init() orelse return error.InitFailed; - defer {{project}}_free(handle); - - const result = {{project}}_process(handle, 42); - try testing.expectEqual(@as(c_int, 0), result); // 0 = ok -} - -test "process with null handle returns error" { - const result = {{project}}_process(null, 42); - try testing.expectEqual(@as(c_int, 4), result); // 4 = null_pointer -} - -//============================================================================== -// String Tests -//============================================================================== - -test "get string result" { - const handle = {{project}}_init() orelse return error.InitFailed; - defer {{project}}_free(handle); - - const str = {{project}}_get_string(handle); - defer if (str) |s| {{project}}_free_string(s); - - try testing.expect(str != null); -} - -test "get string with null handle" { - const str = {{project}}_get_string(null); - try testing.expect(str == null); -} - -//============================================================================== -// Error Handling Tests -//============================================================================== - -test "last error after null handle operation" { - _ = {{project}}_process(null, 0); - - const err = {{project}}_last_error(); - try testing.expect(err != null); - - if (err) |e| { - const err_str = std.mem.span(e); - try testing.expect(err_str.len > 0); - } -} - -test "no error after successful operation" { - const handle = {{project}}_init() orelse return error.InitFailed; - defer {{project}}_free(handle); - - _ = {{project}}_process(handle, 0); - - // Error should be cleared after successful operation - // (This depends on implementation) -} - -//============================================================================== -// Version Tests -//============================================================================== - -test "version string is not empty" { - const ver = {{project}}_version(); - const ver_str = std.mem.span(ver); - - try testing.expect(ver_str.len > 0); -} - -test "version string is semantic version format" { - const ver = {{project}}_version(); - const ver_str = std.mem.span(ver); - - // Should be in format X.Y.Z - try testing.expect(std.mem.count(u8, ver_str, ".") >= 1); -} - -//============================================================================== -// Memory Safety Tests -//============================================================================== - -test "multiple handles are independent" { - const h1 = {{project}}_init() orelse return error.InitFailed; - defer {{project}}_free(h1); - - const h2 = {{project}}_init() orelse return error.InitFailed; - defer {{project}}_free(h2); - - try testing.expect(h1 != h2); - - // Operations on h1 should not affect h2 - _ = {{project}}_process(h1, 1); - _ = {{project}}_process(h2, 2); -} - -test "double free is safe" { - const handle = {{project}}_init() orelse return error.InitFailed; - - {{project}}_free(handle); - {{project}}_free(handle); // Should not crash -} - -test "free null is safe" { - {{project}}_free(null); // Should not crash -} - -//============================================================================== -// Thread Safety Tests (if applicable) -//============================================================================== - -test "concurrent operations" { - const handle = {{project}}_init() orelse return error.InitFailed; - defer {{project}}_free(handle); - - const ThreadContext = struct { - h: *opaque {}, - id: u32, - }; - - const thread_fn = struct { - fn run(ctx: ThreadContext) void { - _ = {{project}}_process(ctx.h, ctx.id); - } - }.run; - - var threads: [4]std.Thread = undefined; - for (&threads, 0..) |*thread, i| { - thread.* = try std.Thread.spawn(.{}, thread_fn, .{ - ThreadContext{ .h = handle, .id = @intCast(i) }, - }); - } - - for (threads) |thread| { - thread.join(); - } -} diff --git a/ffi/zig/test/octad_consumer.zig b/ffi/zig/test/octad_consumer.zig new file mode 100644 index 0000000..b313862 --- /dev/null +++ b/ffi/zig/test/octad_consumer.zig @@ -0,0 +1,120 @@ +// verisimdb-data FFI consumer demo +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// #6 (V-L3-N1). A standalone executable that LINKS the Zig FFI static +// library (build.zig: `consumer.linkLibrary(lib)`) and drives the +// C-ABI contract from the outside — proving the OctadDimension and +// ProvenanceEntry layouts and encode/decode functions round-trip +// losslessly across the FFI boundary. Exits 0 on success, 1 on any +// mismatch; prints a one-line PASS/FAIL summary. +// +// Replaces the old test/integration_test.zig, which was an +// uncompilable `{{project}}` template stub (it could never build). + +const std = @import("std"); + +// Result codes — must match src/main.zig `Result`. +const Result = enum(c_int) { + ok = 0, + @"error" = 1, + invalid_param = 2, + out_of_memory = 3, + null_pointer = 4, +}; + +// Layouts — must match the `extern struct`s in src/main.zig. +const OctadDimension = extern struct { + data: u8, + metadata: u8, + provenance: u8, + lineage: u8, + constraints: u8, + access_control: u8, + temporal: u8, + simulation: u8, +}; + +const ProvenanceEntry = extern struct { + hash: u64, + tool: [32]u8, + version: [16]u8, +}; + +const OCTAD_WIRE_LEN: usize = 8; +const PROVENANCE_WIRE_LEN: usize = 8 + 32 + 16; + +// Symbols resolved from the linked FFI library. +extern fn verisimdb_data_octad_encode(*const OctadDimension, [*]u8, usize) isize; +extern fn verisimdb_data_octad_decode([*]const u8, usize, *OctadDimension) Result; +extern fn verisimdb_data_provenance_encode(*const ProvenanceEntry, [*]u8, usize) isize; +extern fn verisimdb_data_provenance_decode([*]const u8, usize, *ProvenanceEntry) Result; +extern fn verisimdb_data_version() [*:0]const u8; + +pub fn main() void { + + // --- OctadDimension round-trip --- + const octad_in = OctadDimension{ + .data = 1, + .metadata = 1, + .provenance = 1, + .lineage = 0, + .constraints = 1, + .access_control = 0, + .temporal = 1, + .simulation = 0, + }; + var octad_buf: [OCTAD_WIRE_LEN]u8 = undefined; + const on = verisimdb_data_octad_encode(&octad_in, &octad_buf, octad_buf.len); + if (on != @as(isize, @intCast(OCTAD_WIRE_LEN))) { + std.debug.print("FAIL: octad encode returned {d}\n", .{on}); + std.process.exit(1); + } + var octad_out: OctadDimension = undefined; + if (verisimdb_data_octad_decode(&octad_buf, octad_buf.len, &octad_out) != .ok) { + std.debug.print("FAIL: octad decode returned non-ok\n", .{}); + std.process.exit(1); + } + if (!std.meta.eql(octad_in, octad_out)) { + std.debug.print("FAIL: octad round-trip mismatch\n", .{}); + std.process.exit(1); + } + + // --- ProvenanceEntry round-trip --- + var prov_in = std.mem.zeroes(ProvenanceEntry); + prov_in.hash = 0xDEAD_BEEF_CAFE_F00D; + @memcpy(prov_in.tool[0..7], "verisim"); + @memcpy(prov_in.version[0..5], "0.1.0"); + + var prov_buf: [PROVENANCE_WIRE_LEN]u8 = undefined; + const pn = verisimdb_data_provenance_encode(&prov_in, &prov_buf, prov_buf.len); + if (pn != @as(isize, @intCast(PROVENANCE_WIRE_LEN))) { + std.debug.print("FAIL: provenance encode returned {d}\n", .{pn}); + std.process.exit(1); + } + var prov_out: ProvenanceEntry = undefined; + if (verisimdb_data_provenance_decode(&prov_buf, prov_buf.len, &prov_out) != .ok) { + std.debug.print("FAIL: provenance decode returned non-ok\n", .{}); + std.process.exit(1); + } + if (prov_in.hash != prov_out.hash or + !std.mem.eql(u8, &prov_in.tool, &prov_out.tool) or + !std.mem.eql(u8, &prov_in.version, &prov_out.version)) + { + std.debug.print("FAIL: provenance round-trip mismatch\n", .{}); + std.process.exit(1); + } + + // --- Negative path: short buffer must be rejected, not corrupt memory --- + var tiny: [3]u8 = undefined; + if (verisimdb_data_octad_encode(&octad_in, &tiny, tiny.len) != -1) { + std.debug.print("FAIL: short-buffer octad encode was not rejected\n", .{}); + std.process.exit(1); + } + + const ver = std.mem.span(verisimdb_data_version()); + std.debug.print( + "PASS: verisimdb-data FFI v{s} — OctadDimension + ProvenanceEntry round-trip OK\n", + .{ver}, + ); +}