From c823805befc272337899a99dfcf9f0762932b227 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 22:53:04 +0000 Subject: [PATCH 1/3] fix(ffi-zig): implement jk_* C ABI to satisfy integration tests Rewrite ffi/zig/src/main.zig from the old `januskey_*` scaffold to the authoritative `jk_*` C ABI declared in ffi/zig/include/januskey.h (generated from src/abi/{Foreign,Types,Layout}.idr), so that ffi/zig/test/integration_test.zig passes. - extern-struct value types ContentHash (32B), KeyId (16B) and OblitProof (112B, 8-aligned: content_hash 32 + nonce 32 + commitment 32 + overwrite_passes u64 + passes_valid u8), with comptime size/align/offset assertions pinning them to Layout.idr. - Error enum(c_int) with the 12 JK_OK / JK_ERR_* wire values. - Sized, heap-allocated Handle struct (not opaque-with-fields) tracking init state + the single in-flight transaction; pointer round-trips through ?*anyopaque at the C boundary. - jk_init/jk_open/jk_close, jk_execute/jk_undo/jk_obliterate, jk_generate_key/jk_rotate_key/jk_revoke_key, jk_tx_begin/commit/rollback (one-active-tx invariant -> TX_CONFLICT; foreign/null token -> TX_NOT_ACTIVE), jk_version. - jk_execute takes op as c_int (the C enum wire type the test passes) and jk_version returns [*c]const u8 (C nullable const char*) so the conformance suite's literal/null-check usages typecheck. Crypto/CNO internals are honest scaffolds marked TODO(product:); the lifecycle, transaction-state and null-guard semantics the suite asserts are real. Also gitignore Zig build artefacts (.zig-cache/, zig-out/). https://claude.ai/code/session_01GJatEm2TVFSTBEkKXmserJ --- .gitignore | 4 + ffi/zig/src/main.zig | 574 +++++++++++++++++++++++++++---------------- 2 files changed, 367 insertions(+), 211 deletions(-) diff --git a/.gitignore b/.gitignore index 4de6b1b..e773be4 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,10 @@ Thumbs.db # Cargo.lock # Keep for binaries **/target/ +# Zig +.zig-cache/ +zig-out/ + # Elixir /cover/ /doc/ diff --git a/ffi/zig/src/main.zig b/ffi/zig/src/main.zig index cf09ff0..0adf65f 100644 --- a/ffi/zig/src/main.zig +++ b/ffi/zig/src/main.zig @@ -1,277 +1,429 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) Jonathan D.A. Jewell -// JANUSKEY FFI Implementation +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell // -// This module implements the C-compatible FFI declared in src/abi/Foreign.idr -// All types and layouts must match the Idris2 ABI definitions. +// JanusKey C FFI implementation (Zig). // +// This module is the concrete implementation of the C ABI declared in +// `ffi/zig/include/januskey.h`, which is itself generated from the Idris2 +// specification in `src/abi/{Foreign,Types,Layout}.idr`. The names, the +// struct byte-layouts and the integer error codes are all fixed by that +// header; the compile-time assertions below pin them so the build fails +// loudly if the layout ever drifts from the spec. +// +// Behaviour is an honest scaffold: the lifecycle, transaction-state and +// null-guard semantics that the conformance suite +// (`ffi/zig/test/integration_test.zig`) exercises are implemented for real, +// while the cryptographic / CNO product internals are stubbed and marked +// `TODO(product):`. Every public function returns a documented `JK_OK` / +// `JK_ERR_*` code from `Error` below. const std = @import("std"); +const builtin = @import("builtin"); + +// ============================================================ +// Version +// ============================================================ -// Version information (keep in sync with project) const VERSION = "0.1.0"; -const BUILD_INFO = "JANUSKEY built with Zig " ++ @import("builtin").zig_version_string; -/// Thread-local error storage -threadlocal var last_error: ?[]const u8 = null; +// ============================================================ +// Error codes (must match januskey.h / Foreign.idr CError) +// +// Backed by c_int so `@intFromEnum` yields the exact wire values the C +// header `#define`s (JK_OK = 0, JK_ERR_* = 1..11). The integration suite +// asserts each discriminant. +// ============================================================ -/// Set the last error message -fn setError(msg: []const u8) void { - last_error = msg; -} +pub const Error = enum(c_int) { + ok = 0, + not_initialized = 1, + invalid_path = 2, + io_error = 3, + crypto_error = 4, + tx_not_active = 5, + tx_conflict = 6, + key_not_found = 7, + key_revoked = 8, + obliteration_error = 9, + attestation_error = 10, + buffer_too_small = 11, +}; -/// Clear the last error -fn clearError() void { - last_error = null; +/// Convenience: return the `c_int` wire value of an `Error`. +inline fn code(e: Error) c_int { + return @intFromEnum(e); } -//============================================================================== -// Core Types (must match src/abi/Types.idr) -//============================================================================== - -/// Result codes (must match Idris2 Result type) -pub const Result = enum(c_int) { - ok = 0, - @"error" = 1, - invalid_param = 2, - out_of_memory = 3, - null_pointer = 4, +// ============================================================ +// Operation / algorithm enums (must match januskey.h) +// ============================================================ + +pub const OpKind = enum(c_int) { + copy = 0, + move = 1, + delete = 2, + modify = 3, + obliterate = 4, + key_gen = 5, + key_rotate = 6, + key_revoke = 7, }; -/// Library handle. Opaque on the C side (the header only forward-declares -/// it); on the Zig side it must be a sized struct — `opaque` types cannot -/// have fields, and `allocator.create(Handle)` below needs a known size. -pub const Handle = struct { - // Internal state hidden from C - allocator: std.mem.Allocator, - initialized: bool, - // Add your fields here +pub const Algorithm = enum(c_int) { + aes256gcm = 0, + chacha20 = 1, + ed25519 = 2, + x25519 = 3, + argon2id = 4, }; -//============================================================================== -// Library Lifecycle -//============================================================================== - -/// Initialize the library -/// Returns a handle, or null on failure -export fn januskey_init() ?*Handle { - const allocator = std.heap.c_allocator; - - const handle = allocator.create(Handle) catch { - setError("Failed to allocate handle"); - return null; - }; - - // Initialize handle - handle.* = .{ - .allocator = allocator, - .initialized = true, - }; +// ============================================================ +// C-compatible value types (must match januskey.h / Layout.idr) +// +// `extern struct` guarantees C layout. The comptime asserts below lock the +// sizes/alignments the header documents and the test re-checks. +// ============================================================ - clearError(); - return handle; -} +/// SHA-256 content digest — exactly 32 bytes (Layout.idr: contentHashSize). +pub const ContentHash = extern struct { + bytes: [32]u8 = [_]u8{0} ** 32, +}; -/// Free the library handle -export fn januskey_free(handle: ?*Handle) void { - const h = handle orelse return; - const allocator = h.allocator; +/// Key identifier (UUID) — exactly 16 bytes (Layout.idr: keyIdSize). +pub const KeyId = extern struct { + bytes: [16]u8 = [_]u8{0} ** 16, +}; - // Clean up resources - h.initialized = false; +/// Obliteration proof — 112 bytes, 8-byte aligned (Layout.idr: oblitProofSize). +/// +/// Field order matches januskey.h exactly: +/// content_hash 32 + nonce 32 + commitment 32 + overwrite_passes 8 (u64) +/// + passes_valid 1 (u8). Raw payload is 105 bytes; the u64 forces 8-byte +/// alignment so the struct tail-pads to 112. +pub const OblitProof = extern struct { + content_hash: ContentHash = .{}, + nonce: [32]u8 = [_]u8{0} ** 32, + commitment: ContentHash = .{}, + overwrite_passes: u64 = 0, + passes_valid: u8 = 0, +}; - allocator.destroy(h); - clearError(); +comptime { + // Layout invariants — these mirror src/abi/Layout.idr and the comments + // in januskey.h. If any fires, the C ABI and the Idris spec disagree. + std.debug.assert(@sizeOf(ContentHash) == 32); + std.debug.assert(@sizeOf(KeyId) == 16); + std.debug.assert(@sizeOf(OblitProof) == 112); + std.debug.assert(@alignOf(OblitProof) >= 8); + std.debug.assert(@offsetOf(OblitProof, "content_hash") == 0); + std.debug.assert(@offsetOf(OblitProof, "nonce") == 32); + std.debug.assert(@offsetOf(OblitProof, "commitment") == 64); + std.debug.assert(@offsetOf(OblitProof, "overwrite_passes") == 96); + std.debug.assert(@offsetOf(OblitProof, "passes_valid") == 104); + // Error code wire values must match the C header #defines. + std.debug.assert(code(Error.ok) == 0); + std.debug.assert(code(Error.buffer_too_small) == 11); } -//============================================================================== -// Core Operations -//============================================================================== +// ============================================================ +// Internal handle +// +// `jk_handle_t` is `void*` on the C side. On the Zig side it is a sized, +// heap-allocated struct (NOT an `opaque` type — those cannot carry fields) +// so we can track repository state and the in-flight transaction. The +// pointer is round-tripped through `?*anyopaque` at the C boundary. +// +// Default number of secure-overwrite passes; matches OVERWRITE_PASSES in +// crates/januskey-cli/src/obliteration.rs and Types.idr (>= 3). +// ============================================================ -/// Process data (example operation) -export fn januskey_process(handle: ?*Handle, input: u32) Result { - const h = handle orelse { - setError("Null handle"); - return .null_pointer; - }; +const OVERWRITE_PASSES: u64 = 3; - if (!h.initialized) { - setError("Handle not initialized"); - return .@"error"; - } +const Handle = struct { + /// Owning allocator (libc malloc/free via std.heap.c_allocator). + allocator: std.mem.Allocator, + /// Repository initialised flag. + initialized: bool, + /// Exactly one transaction may be active at a time. We store its + /// heap-allocated token pointer so the test's `tx` round-trips. + active_tx: ?*Transaction, +}; - // Example processing logic - _ = input; +const Transaction = struct { + /// Monotonic id (scaffold — a real impl threads this into the log). + id: u64, +}; - clearError(); - return .ok; +/// Reinterpret the C `?*anyopaque` handle as a `*Handle`, or null. +inline fn asHandle(h: ?*anyopaque) ?*Handle { + return @ptrCast(@alignCast(h)); } -//============================================================================== -// String Operations -//============================================================================== +inline fn asTx(t: ?*anyopaque) ?*Transaction { + return @ptrCast(@alignCast(t)); +} -/// Get a string result (example) -/// Caller must free the returned string -export fn januskey_get_string(handle: ?*Handle) ?[*:0]const u8 { - const h = handle orelse { - setError("Null handle"); - return null; +// ============================================================ +// Repository lifecycle +// ============================================================ + +/// Initialise (create) a repository at `path` and return a fresh handle in +/// `out_handle`. Returns JK_ERR_INVALID_PATH if `path`/`out_handle` is null, +/// JK_ERR_IO on allocation failure, JK_OK otherwise. +pub export fn jk_init(path: ?[*:0]const u8, out_handle: ?*?*anyopaque) c_int { + const out = out_handle orelse return code(.invalid_path); + const p = path orelse { + out.* = null; + return code(.invalid_path); }; - - if (!h.initialized) { - setError("Handle not initialized"); - return null; + // An empty path is not a valid repository location. + if (std.mem.len(p) == 0) { + out.* = null; + return code(.invalid_path); } - // Example: allocate and return a string - const result = h.allocator.dupeZ(u8, "Example result") catch { - setError("Failed to allocate string"); - return null; + // TODO(product): create the on-disk `.januskey/` layout, content store, + // metadata log and transaction directory for `path` (mirrors + // crates/reversible-core JanusKey::init). The scaffold only allocates an + // in-memory handle so the FFI lifecycle is exercisable end-to-end. + const allocator = std.heap.c_allocator; + const handle = allocator.create(Handle) catch { + out.* = null; + return code(.io_error); }; - - clearError(); - return result.ptr; + handle.* = .{ + .allocator = allocator, + .initialized = true, + .active_tx = null, + }; + out.* = handle; + return code(.ok); } -/// Free a string allocated by the library -export fn januskey_free_string(str: ?[*:0]const u8) void { - const s = str orelse return; - const allocator = std.heap.c_allocator; - - const slice = std.mem.span(s); - allocator.free(slice); +/// Open an existing repository. Scaffold shares `jk_init`'s behaviour. +pub export fn jk_open(path: ?[*:0]const u8, out_handle: ?*?*anyopaque) c_int { + // TODO(product): require the repository to already exist and load its + // state, rather than creating it. + return jk_init(path, out_handle); } -//============================================================================== -// Array/Buffer Operations -//============================================================================== - -/// Process an array of data -export fn januskey_process_array( - handle: ?*Handle, - buffer: ?[*]const u8, - len: u32, -) Result { - const h = handle orelse { - setError("Null handle"); - return .null_pointer; - }; - - const buf = buffer orelse { - setError("Null buffer"); - return .null_pointer; - }; - - if (!h.initialized) { - setError("Handle not initialized"); - return .@"error"; +/// Close a handle. Null is a safe no-op (the test relies on this). Any +/// dangling active transaction token is freed first. +pub export fn jk_close(handle: ?*anyopaque) void { + const h = asHandle(handle) orelse return; + if (h.active_tx) |tx| { + h.allocator.destroy(tx); + h.active_tx = null; } - - // Access the buffer - const data = buf[0..len]; - _ = data; - - // Process data here - - clearError(); - return .ok; + h.initialized = false; + h.allocator.destroy(h); } -//============================================================================== -// Error Handling -//============================================================================== +// ============================================================ +// File operations +// ============================================================ + +/// Execute a file operation. With a null handle the repository is not +/// initialised, so JK_ERR_NOT_INITIALIZED is returned (asserted by the test). +/// +/// `op` is typed `c_int` (the wire type of the C `jk_op_kind_t` enum) rather +/// than the Zig `OpKind` enum: the conformance suite passes a bare integer +/// literal (`jk_execute(null, 0, …)`), which is exactly how a C caller passes +/// an enum value. We map it back to `OpKind` internally. +pub export fn jk_execute( + handle: ?*anyopaque, + op: c_int, + src: ?[*:0]const u8, + dst: ?[*:0]const u8, +) c_int { + const h = asHandle(handle) orelse return code(.not_initialized); + if (!h.initialized) return code(.not_initialized); + const kind: OpKind = std.meta.intToEnum(OpKind, op) catch return code(.invalid_path); + _ = kind; + _ = src; + _ = dst; + // TODO(product): dispatch on `op` and execute the reversible operation + // (copy/move/delete/modify/obliterate/key-*) recording inverse metadata, + // delegating to the reversible-core executor. + return code(.ok); +} -/// Get the last error message -/// Returns null if no error -export fn januskey_last_error() ?[*:0]const u8 { - const err = last_error orelse return null; +/// Undo the most recent operation. Null handle => not initialised. +pub export fn jk_undo(handle: ?*anyopaque) c_int { + const h = asHandle(handle) orelse return code(.not_initialized); + if (!h.initialized) return code(.not_initialized); + // TODO(product): pop the last operation from the log and apply its + // stored inverse (Theorem 3.4: Sequential Reversibility). + return code(.ok); +} - // Return C string (static storage, no need to free) - const allocator = std.heap.c_allocator; - const c_str = allocator.dupeZ(u8, err) catch return null; - return c_str.ptr; +/// Obliterate the content at `path`, optionally emitting a proof. Null handle +/// => not initialised. When `out_proof` is non-null it is filled with a +/// well-formed (scaffold) proof carrying the standard overwrite-pass count. +pub export fn jk_obliterate( + handle: ?*anyopaque, + path: ?[*:0]const u8, + out_proof: ?*OblitProof, +) c_int { + const h = asHandle(handle) orelse return code(.not_initialized); + if (!h.initialized) return code(.not_initialized); + if (path == null) return code(.invalid_path); + // TODO(product): perform the DoD 5220.22-M secure-overwrite passes and + // compute the real commitment H(content_hash || nonce || timestamp) + // (mirrors crates/januskey-cli/src/obliteration.rs). The scaffold returns + // a structurally-valid proof so consumers can exercise the ABI. + if (out_proof) |proof| { + proof.* = .{}; + proof.overwrite_passes = OVERWRITE_PASSES; + proof.passes_valid = 1; + } + return code(.ok); } -//============================================================================== -// Version Information -//============================================================================== +// ============================================================ +// Key management +// ============================================================ + +/// Generate a key. Scaffold returns a zeroed id and JK_OK for a valid handle. +pub export fn jk_generate_key( + handle: ?*anyopaque, + algo: Algorithm, + passphrase: ?[*:0]const u8, + out_id: ?*KeyId, +) c_int { + const h = asHandle(handle) orelse return code(.not_initialized); + if (!h.initialized) return code(.not_initialized); + _ = algo; + _ = passphrase; + // TODO(product): derive key material (Argon2id KDF + AEAD), persist the + // wrapped key, and return its UUID. + if (out_id) |id| id.* = .{}; + return code(.ok); +} -/// Get the library version -export fn januskey_version() [*:0]const u8 { - return VERSION.ptr; +/// Rotate a key. Scaffold returns a zeroed new id and JK_OK. +pub export fn jk_rotate_key( + handle: ?*anyopaque, + old_id: ?*const KeyId, + new_passphrase: ?[*:0]const u8, + out_new_id: ?*KeyId, +) c_int { + const h = asHandle(handle) orelse return code(.not_initialized); + if (!h.initialized) return code(.not_initialized); + if (old_id == null) return code(.key_not_found); + _ = new_passphrase; + // TODO(product): re-wrap content under the new key and retire the old one. + if (out_new_id) |id| id.* = .{}; + return code(.ok); } -/// Get build information -export fn januskey_build_info() [*:0]const u8 { - return BUILD_INFO.ptr; +/// Revoke a key. Scaffold returns JK_OK for a valid handle + id. +pub export fn jk_revoke_key(handle: ?*anyopaque, key_id: ?*const KeyId) c_int { + const h = asHandle(handle) orelse return code(.not_initialized); + if (!h.initialized) return code(.not_initialized); + if (key_id == null) return code(.key_not_found); + // TODO(product): mark the key revoked in the key store / audit log. + return code(.ok); } -//============================================================================== -// Callback Support -//============================================================================== - -/// Callback function type (C ABI) -pub const Callback = *const fn (u64, u32) callconv(.C) u32; - -/// Register a callback -export fn januskey_register_callback( - handle: ?*Handle, - callback: ?Callback, -) Result { - const h = handle orelse { - setError("Null handle"); - return .null_pointer; +// ============================================================ +// Transactions +// +// Invariant exercised by the suite: at most one active transaction per +// handle. A second `jk_tx_begin` while one is active => JK_ERR_TX_CONFLICT; +// commit/rollback of a null/foreign token => JK_ERR_TX_NOT_ACTIVE. +// ============================================================ + +pub export fn jk_tx_begin(handle: ?*anyopaque, out_tx: ?*?*anyopaque) c_int { + const h = asHandle(handle) orelse return code(.not_initialized); + if (!h.initialized) return code(.not_initialized); + const out = out_tx orelse return code(.invalid_path); + if (h.active_tx != null) { + out.* = null; + return code(.tx_conflict); + } + const tx = h.allocator.create(Transaction) catch { + out.* = null; + return code(.io_error); }; + tx.* = .{ .id = 1 }; + h.active_tx = tx; + out.* = tx; + return code(.ok); +} - const cb = callback orelse { - setError("Null callback"); - return .null_pointer; - }; +pub export fn jk_tx_commit(handle: ?*anyopaque, tx: ?*anyopaque) c_int { + const h = asHandle(handle) orelse return code(.not_initialized); + if (!h.initialized) return code(.not_initialized); + const active = h.active_tx orelse return code(.tx_not_active); + const given = asTx(tx) orelse return code(.tx_not_active); + if (given != active) return code(.tx_not_active); + // TODO(product): durably commit the staged operations. + h.allocator.destroy(active); + h.active_tx = null; + return code(.ok); +} - if (!h.initialized) { - setError("Handle not initialized"); - return .@"error"; - } +pub export fn jk_tx_rollback(handle: ?*anyopaque, tx: ?*anyopaque) c_int { + const h = asHandle(handle) orelse return code(.not_initialized); + if (!h.initialized) return code(.not_initialized); + const active = h.active_tx orelse return code(.tx_not_active); + const given = asTx(tx) orelse return code(.tx_not_active); + if (given != active) return code(.tx_not_active); + // TODO(product): undo the staged operations in reverse order. + h.allocator.destroy(active); + h.active_tx = null; + return code(.ok); +} - // Store callback for later use - _ = cb; +// ============================================================ +// Version +// ============================================================ - clearError(); - return .ok; +/// Return a static, null-terminated version string. Typed as a C pointer +/// (`[*c]const u8`, i.e. C's nullable `const char*`) so the conformance suite +/// can both null-check it and `std.mem.span` it; the scaffold always returns +/// the non-null static `VERSION`. +pub export fn jk_version() [*c]const u8 { + return VERSION.ptr; } -//============================================================================== -// Utility Functions -//============================================================================== +// ============================================================ +// In-module unit tests (kept; the conformance suite lives in +// test/integration_test.zig and imports this module as "januskey"). +// ============================================================ -/// Check if handle is initialized -export fn januskey_is_initialized(handle: ?*Handle) u32 { - const h = handle orelse return 0; - return if (h.initialized) 1 else 0; +test "layout sizes match the C ABI" { + try std.testing.expectEqual(@as(usize, 32), @sizeOf(ContentHash)); + try std.testing.expectEqual(@as(usize, 16), @sizeOf(KeyId)); + try std.testing.expectEqual(@as(usize, 112), @sizeOf(OblitProof)); + try std.testing.expect(@alignOf(OblitProof) >= 8); } -//============================================================================== -// Tests -//============================================================================== - -test "lifecycle" { - const handle = januskey_init() orelse return error.InitFailed; - defer januskey_free(handle); +test "error codes match header" { + try std.testing.expectEqual(@as(c_int, 0), code(Error.ok)); + try std.testing.expectEqual(@as(c_int, 2), code(Error.invalid_path)); + try std.testing.expectEqual(@as(c_int, 11), code(Error.buffer_too_small)); +} - try std.testing.expect(januskey_is_initialized(handle) == 1); +test "init then close round-trips a handle" { + var handle: ?*anyopaque = null; + try std.testing.expectEqual(@as(c_int, 0), jk_init("/tmp/jk-unit", &handle)); + try std.testing.expect(handle != null); + jk_close(handle); } -test "error handling" { - const result = januskey_process(null, 0); - try std.testing.expectEqual(Result.null_pointer, result); +test "transaction conflict on double begin" { + var handle: ?*anyopaque = null; + _ = jk_init("/tmp/jk-unit-tx", &handle); + defer jk_close(handle); - const err = januskey_last_error(); - try std.testing.expect(err != null); -} + var tx: ?*anyopaque = null; + try std.testing.expectEqual(@as(c_int, 0), jk_tx_begin(handle, &tx)); + + var tx2: ?*anyopaque = null; + try std.testing.expectEqual(@as(c_int, 6), jk_tx_begin(handle, &tx2)); -test "version" { - const ver = januskey_version(); - const ver_str = std.mem.span(ver); - try std.testing.expectEqualStrings(VERSION, ver_str); + try std.testing.expectEqual(@as(c_int, 0), jk_tx_commit(handle, tx)); } From a88bb0029e68c09d40d8aee1a69672547844eecb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 23:00:28 +0000 Subject: [PATCH 2/3] feat(cli): support `jk init `, `-r/--repo`, and `obliterate` Make the `jk` CLI satisfy the init->execute->undo->obliterate lifecycle the E2E test drives. - `init` now takes an optional positional path (`jk init `), initialising that directory (created if absent) and overriding --dir/--repo when given. - Add a global `-r/--repo ` flag (alias of --dir, takes precedence) so `jk -r ` selects the repository. - Add an `obliterate ` subcommand implementing GDPR Art. 17 erasure of concrete files via a new public `obliteration::obliterate_file`: hash, DoD-style multi-pass secure overwrite, unlink, and return a proof. Irreversible, so it refuses to run without --yes when stdin is not a TTY (never auto-confirms destructive erasure unattended) and prompts otherwise. Scaffold-level where noted with TODO(product:) (repo-side scrub of content-store copies and op-log pruning on obliterate). https://claude.ai/code/session_01GJatEm2TVFSTBEkKXmserJ --- crates/januskey-cli/src/main.rs | 112 ++++++++++++++++++++++-- crates/januskey-cli/src/obliteration.rs | 31 +++++++ 2 files changed, 138 insertions(+), 5 deletions(-) diff --git a/crates/januskey-cli/src/main.rs b/crates/januskey-cli/src/main.rs index 6c860d9..281d5d7 100644 --- a/crates/januskey-cli/src/main.rs +++ b/crates/januskey-cli/src/main.rs @@ -36,6 +36,10 @@ struct Cli { #[arg(short = 'C', long, global = true)] dir: Option, + /// Repository directory to operate on (alias of --dir; takes precedence) + #[arg(short = 'r', long, global = true)] + repo: Option, + /// Dry run mode (don't actually make changes) #[arg(long, global = true)] dry_run: bool, @@ -47,8 +51,12 @@ struct Cli { #[derive(Subcommand)] enum Commands { - /// Initialize JanusKey in the current directory - Init, + /// Initialize JanusKey in a directory (defaults to the working directory) + Init { + /// Directory to initialize (created if it does not exist). + /// Overrides --dir / --repo when given. + path: Option, + }, /// Delete files (reversible) #[command(alias = "rm")] @@ -101,6 +109,14 @@ enum Commands { new_name: PathBuf, }, + /// Obliterate a file: securely overwrite then remove it (NOT reversible). + /// Implements GDPR Article 17 "right to erasure". + Obliterate { + /// File(s) to obliterate + #[arg(required = true)] + paths: Vec, + }, + /// Undo the last operation(s) Undo { /// Number of operations to undo @@ -156,14 +172,17 @@ enum Commands { fn main() -> Result<()> { let cli = Cli::parse(); - // Determine working directory - let working_dir = match cli.dir { + // Determine working directory. --repo takes precedence over --dir; both + // fall back to the current directory. + let working_dir = match cli.repo.or(cli.dir) { Some(dir) => dir, None => std::env::current_dir().context("Failed to get current directory")?, }; match cli.command { - Commands::Init => cmd_init(&working_dir), + // `jk init ` targets the positional path when given; otherwise + // it initialises the working directory. + Commands::Init { path } => cmd_init(&path.unwrap_or(working_dir)), Commands::Delete { paths, recursive } => { cmd_delete(&working_dir, &paths, recursive, cli.dry_run, cli.yes) } @@ -179,6 +198,9 @@ fn main() -> Result<()> { Commands::Rename { old_name, new_name } => { cmd_move(&working_dir, &old_name.to_string_lossy(), &new_name, cli.dry_run) } + Commands::Obliterate { paths } => { + cmd_obliterate(&working_dir, &paths, cli.dry_run, cli.yes) + } Commands::Undo { count, id } => cmd_undo(&working_dir, count, id), Commands::Begin { name } => cmd_begin(&working_dir, name), Commands::Commit => cmd_commit(&working_dir), @@ -562,6 +584,86 @@ fn cmd_copy(dir: &PathBuf, source: &PathBuf, destination: &PathBuf, dry_run: boo Ok(()) } +fn cmd_obliterate( + dir: &PathBuf, + paths: &[PathBuf], + dry_run: bool, + auto_yes: bool, +) -> Result<()> { + use januskey::obliteration::obliterate_file; + + // Resolve each path against the working directory if it is relative. + let targets: Vec = paths + .iter() + .map(|p| if p.is_absolute() { p.clone() } else { dir.join(p) }) + .collect(); + + if dry_run { + println!("{} Dry run - would obliterate:", "[DRY RUN]".cyan()); + for t in &targets { + println!(" - {}", t.display()); + } + return Ok(()); + } + + // Obliteration is irreversible — confirm unless --yes was given. Refuse + // outright (rather than silently auto-confirming) when stdin is not a + // terminal and no --yes was supplied, so destructive erasure never runs + // unattended without explicit consent. + if !auto_yes { + use std::io::IsTerminal; + if !std::io::stdin().is_terminal() { + anyhow::bail!( + "refusing to obliterate without confirmation in non-interactive mode; \ + pass --yes/-y to confirm" + ); + } + println!( + "{} Obliteration is {} — content will be unrecoverable:", + "⚠".yellow(), + "irreversible".red() + ); + for t in &targets { + println!(" - {}", t.display()); + } + if !Confirm::new() + .with_prompt("Continue?") + .default(false) + .interact()? + { + println!("{}", "Cancelled".red()); + return Ok(()); + } + } + + let mut obliterated = 0; + for t in &targets { + match obliterate_file(t) { + Ok(proof) => { + obliterated += 1; + println!( + "{} Obliterated {} ({} passes, proof {})", + "✓".green(), + t.display(), + proof.overwrite_passes, + &proof.id[..8] + ); + } + Err(e) => { + eprintln!("{} Failed to obliterate {}: {}", "✗".red(), t.display(), e); + } + } + } + + println!( + "{} Obliterated {} file(s) — erasure is permanent", + "✓".green(), + obliterated + ); + + Ok(()) +} + fn cmd_undo(dir: &PathBuf, count: usize, id: Option) -> Result<()> { let mut jk = JanusKey::open(dir).context("Failed to open JanusKey directory")?; diff --git a/crates/januskey-cli/src/obliteration.rs b/crates/januskey-cli/src/obliteration.rs index ee469b6..2232e32 100644 --- a/crates/januskey-cli/src/obliteration.rs +++ b/crates/januskey-cli/src/obliteration.rs @@ -318,6 +318,37 @@ fn secure_overwrite(path: &Path) -> Result { Ok(OVERWRITE_PASSES) } +/// Obliterate an arbitrary file on disk (not necessarily in the content +/// store): hash its current content, securely overwrite it with +/// [`OVERWRITE_PASSES`] passes, remove it, and return a proof of erasure. +/// +/// This is the GDPR Article 17 "right to erasure" primitive applied to a +/// concrete filesystem path, used by the `jk obliterate ` command. +/// Unlike [`ObliterationManager::obliterate`] it does not consult the content +/// store, so it works on files the repository never ingested. +/// +/// TODO(product): also scrub any content-store copies and prune the +/// associated operation-log entries so no recoverable trace remains, and +/// thread the resulting proof into the obliteration audit log. +pub fn obliterate_file(path: &Path) -> Result { + if !path.exists() { + return Err(JanusError::FileNotFound(format!( + "{} not found", + path.display() + ))); + } + + // Record what we are about to destroy (content hash, for the proof). + let content = fs::read(path)?; + let content_hash = ContentHash::from_bytes(&content); + + // DoD 5220.22-M style multi-pass overwrite, then unlink. + let passes = secure_overwrite(path)?; + fs::remove_file(path)?; + + Ok(ObliterationProof::generate(&content_hash, passes)) +} + /// Verify that content no longer exists at a path pub fn verify_obliteration(path: &Path, original_hash: &ContentHash) -> Result { if !path.exists() { From 6de4b55e6c99986b69309b3fb860e5b0e5b72fe2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 23:00:28 +0000 Subject: [PATCH 3/3] fix(tests): POSIX arithmetic in e2e; prune Zig build dirs in aspect - tests/e2e/lifecycle_e2e.sh: replace ((PASS++))/((FAIL++))/((SKIP++)) with PASS=$((PASS+1)) etc. Under `set -euo pipefail` a post-increment of a 0-valued var returns exit 1 and aborted the script on the first check (same fix already present in the aspect test). - tests/aspect/cross_cutting_test.sh: prune .zig-cache/ and zig-out/ from the Zig SPDX `find` so a generated, header-less dependencies.zig left by `zig build` cannot skew the count when the test runs in an already-built tree. https://claude.ai/code/session_01GJatEm2TVFSTBEkKXmserJ --- tests/aspect/cross_cutting_test.sh | 7 +++++-- tests/e2e/lifecycle_e2e.sh | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/aspect/cross_cutting_test.sh b/tests/aspect/cross_cutting_test.sh index 28f943c..4f57c53 100755 --- a/tests/aspect/cross_cutting_test.sh +++ b/tests/aspect/cross_cutting_test.sh @@ -29,8 +29,11 @@ idr_total=$(find "${JK_DIR}/src/abi" -name '*.idr' 2>/dev/null | wc -l) idr_spdx=$(grep -rl 'SPDX-License-Identifier' "${JK_DIR}/src/abi" --include='*.idr' 2>/dev/null | wc -l) check "Idris2 SPDX headers (${idr_spdx}/${idr_total})" "[ '${idr_spdx}' -eq '${idr_total}' ]" -zig_total=$(find "${JK_DIR}/ffi/zig" -name '*.zig' 2>/dev/null | wc -l) -zig_spdx=$(grep -rl 'SPDX-License-Identifier' "${JK_DIR}/ffi/zig" --include='*.zig' 2>/dev/null | wc -l) +# NB: prune Zig build artefacts (.zig-cache/, zig-out/) — `zig build` drops a +# generated dependencies.zig in the cache with no SPDX header, which would +# otherwise skew the count when this runs in a tree that has been built. +zig_total=$(find "${JK_DIR}/ffi/zig" -type d \( -name '.zig-cache' -o -name 'zig-out' \) -prune -o -name '*.zig' -print 2>/dev/null | wc -l) +zig_spdx=$(find "${JK_DIR}/ffi/zig" -type d \( -name '.zig-cache' -o -name 'zig-out' \) -prune -o -name '*.zig' -print 2>/dev/null | xargs -r grep -l 'SPDX-License-Identifier' 2>/dev/null | wc -l) check "Zig SPDX headers (${zig_spdx}/${zig_total})" "[ '${zig_spdx}' -eq '${zig_total}' ]" # --- Forbidden Patterns --- diff --git a/tests/e2e/lifecycle_e2e.sh b/tests/e2e/lifecycle_e2e.sh index a64806a..31ea48d 100755 --- a/tests/e2e/lifecycle_e2e.sh +++ b/tests/e2e/lifecycle_e2e.sh @@ -13,8 +13,11 @@ SKIP=0 TMPDIR="$(mktemp -d)" trap 'rm -rf "$TMPDIR"' EXIT -check() { if eval "$2"; then echo "[PASS] $1"; ((PASS++)); else echo "[FAIL] $1"; ((FAIL++)); fi; } -skip() { echo "[SKIP] $1"; ((SKIP++)); } +# NB: use POSIX arithmetic assignment, not ((PASS++)) — under `set -e` a +# post-increment whose old value is 0 returns exit status 1 and kills the +# script after the very first check (matches tests/aspect/cross_cutting_test.sh). +check() { if eval "$2"; then echo "[PASS] $1"; PASS=$((PASS+1)); else echo "[FAIL] $1"; FAIL=$((FAIL+1)); fi; } +skip() { echo "[SKIP] $1"; SKIP=$((SKIP+1)); } echo "=== JanusKey E2E Lifecycle Test ==="