Skip to main content

a1/
ffi.rs

1//! C ABI exports for a1.
2//!
3//! Enable with `features = ["ffi"]`.
4//!
5//! This module exposes a stable C ABI so that any language capable of calling
6//! a shared library can integrate a1 without a Rust toolchain:
7//!
8//! - **Python** — `ctypes` or `cffi`
9//! - **Go** — `cgo` (`#include "a1.h"`)
10//! - **Java / JVM** — JNA or JNI
11//! - **Node.js** — `node-ffi-napi` or a native addon
12//! - **C / C++** — link against the `.so` / `.dll` / `.dylib` directly
13//!
14//! # Generated header
15//!
16//! Run `cbindgen --config cbindgen.toml --output a1.h` from the
17//! workspace root to regenerate the C header from this source file.
18//! The header is published to `include/a1.h` on every release.
19//!
20//! # Thread safety
21//!
22//! All exported functions are thread-safe. The chain, identity, and store
23//! handles are heap-allocated Rust values wrapped in opaque pointers; the
24//! caller must not alias or free them outside the provided `_free` functions.
25//!
26//! # Error handling
27//!
28//! Every function that can fail returns a `A1Status` integer:
29//! - `0` (`A1_OK`) — success
30//! - Any other value — a `A1Error` variant (see `A1Status` enum)
31//!
32//! On failure, `dyolo_last_error()` returns a nul-terminated UTF-8 string
33//! describing the error; the string is valid until the next FFI call on
34//! the same thread.
35//!
36//! # Memory model
37//!
38//! - Objects returned as `*mut OpaqueType` are heap-allocated by Rust.
39//!   The caller MUST free them with the corresponding `_free` function.
40//! - Byte buffers (`*mut u8`) written by Rust are caller-allocated; the
41//!   length is always passed in and the function writes at most that many bytes.
42//! - String pointers returned by `dyolo_last_error()` are thread-local and
43//!   must NOT be freed by the caller.
44
45use std::cell::RefCell;
46use std::ffi::{c_char, c_int, CStr, CString};
47use std::panic;
48
49use crate::chain::{DyoloChain, SystemClock};
50use crate::error::A1Error;
51use crate::identity::DyoloIdentity;
52use crate::intent::{Intent, MerkleProof};
53use crate::registry::{MemoryNonceStore, MemoryRevocationStore};
54
55// Thread-local error string (avoids the need for a global mutex).
56thread_local! {
57    static LAST_ERROR: RefCell<Option<CString>> = const { RefCell::new(None) };
58}
59
60fn set_last_error(msg: impl Into<Vec<u8>>) {
61    LAST_ERROR.with(|e| {
62        let s =
63            CString::new(msg).unwrap_or_else(|_| CString::new("error contains nul byte").unwrap());
64        *e.borrow_mut() = Some(s);
65    });
66}
67
68fn a1_error_to_status(e: &A1Error) -> c_int {
69    match e {
70        A1Error::EmptyChain => A1Status::A1ErrEmptyChain as c_int,
71        A1Error::StorageFailure(_) => A1Status::A1ErrStorageFailure as c_int,
72        A1Error::RootMismatch => A1Status::A1ErrRootMismatch as c_int,
73        A1Error::BrokenLinkage(_) => A1Status::A1ErrBrokenLinkage as c_int,
74        A1Error::InvalidSignature(_) => A1Status::A1ErrInvalidSig as c_int,
75        A1Error::NotYetValid(..) => A1Status::A1ErrNotYetValid as c_int,
76        A1Error::Expired(..) => A1Status::A1ErrExpired as c_int,
77        A1Error::TemporalViolation(..) => A1Status::A1ErrTemporalViol as c_int,
78        A1Error::MaxDepthExceeded(..) => A1Status::A1ErrMaxDepth as c_int,
79        A1Error::InvalidSubScopeProof => A1Status::A1ErrInvalidProof as c_int,
80        A1Error::ScopeEscalation(_) => A1Status::A1ErrScopeEscal as c_int,
81        A1Error::UnauthorizedLeaf => A1Status::A1ErrUnauthorized as c_int,
82        A1Error::ScopeViolation => A1Status::A1ErrScopeViol as c_int,
83        A1Error::NonceReplay => A1Status::A1ErrNonceReplay as c_int,
84        A1Error::Revoked => A1Status::A1ErrRevoked as c_int,
85        A1Error::IntentNotFound => A1Status::A1ErrIntentNotFound as c_int,
86        A1Error::EmptyTree => A1Status::A1ErrEmptyTree as c_int,
87        A1Error::WireFormatError(_) => A1Status::A1ErrWireFormat as c_int,
88        A1Error::UnsupportedVersion { .. } => A1Status::A1ErrUnsupportedVer as c_int,
89        A1Error::PolicyViolation(_) => A1Status::A1ErrPolicyViolation as c_int,
90        A1Error::BatchItemFailed { .. } => A1Status::A1ErrBatchItemFailed as c_int,
91        A1Error::MacVerificationFailed => A1Status::A1ErrMacFailed as c_int,
92        A1Error::NamespaceMismatch { .. } => A1Status::A1ErrNamespaceMismatch as c_int,
93        A1Error::RateLimitExceeded => A1Status::A1ErrRateLimit as c_int,
94        A1Error::StorageUnhealthy(_) => A1Status::A1ErrStorageUnhealthy as c_int,
95        A1Error::PassportNarrowingViolation => A1Status::A1ErrPassportNarrowing as c_int,
96        _ => A1Status::A1ErrUnknown as c_int,
97    }
98}
99
100/// Return value of every fallible FFI function.
101///
102/// `A1_OK = 0` is the only success value; all other values are errors.
103/// Call `dyolo_last_error()` immediately after a non-zero return to read
104/// a human-readable description of the failure.
105/// Returns a `A1Status` code detailing the failure reason.
106/// Documented for `cbindgen` export.
107#[repr(C)]
108pub enum A1Status {
109    /// Operation succeeded.
110    A1Ok = 0,
111    /// The delegation chain is empty.
112    A1ErrEmptyChain = 1,
113    /// The storage backend failed.
114    A1ErrStorageFailure = 2,
115    /// Chain root does not match the expected principal.
116    A1ErrRootMismatch = 3,
117    /// Delegation link broken at hop N.
118    A1ErrBrokenLinkage = 4,
119    /// Invalid cryptographic signature at hop N.
120    A1ErrInvalidSig = 5,
121    /// Certificate is not yet valid (clock drift or future issuance).
122    A1ErrNotYetValid = 6,
123    /// Certificate has expired.
124    A1ErrExpired = 7,
125    /// Temporal violation: child outlives parent.
126    A1ErrTemporalViol = 8,
127    /// Delegation depth exceeds policy or cert maximum.
128    A1ErrMaxDepth = 9,
129    /// The sub-scope or merkle proof is invalid.
130    A1ErrInvalidProof = 10,
131    /// Scope escalation: child attempts to delegate scope it does not have.
132    A1ErrScopeEscal = 11,
133    /// The executing agent is not the terminal delegate.
134    A1ErrUnauthorized = 12,
135    /// The requested intent is not permitted by the terminal scope.
136    A1ErrScopeViol = 13,
137    /// Nonce replay detected.
138    A1ErrNonceReplay = 14,
139    /// A certificate in the chain has been revoked.
140    A1ErrRevoked = 15,
141    /// Intent not found in the scope tree.
142    A1ErrIntentNotFound = 16,
143    /// Attempted to build an empty scope tree.
144    A1ErrEmptyTree = 17,
145    /// Invalid wire format (JSON/CBOR parse error).
146    A1ErrWireFormat = 18,
147    /// Unsupported certificate version.
148    A1ErrUnsupportedVer = 19,
149    /// Delegation policy violation.
150    A1ErrPolicyViolation = 20,
151    /// Batch authorization failed at one or more indices.
152    A1ErrBatchItemFailed = 21,
153    /// MAC/HMAC verification failed.
154    A1ErrMacFailed = 22,
155    /// Chain namespace does not match the requested namespace.
156    A1ErrNamespaceMismatch = 23,
157    /// Rate limit exceeded.
158    A1ErrRateLimit = 24,
159    /// Storage health check failed.
160    A1ErrStorageUnhealthy = 25,
161    /// Passport capability narrowing violation.
162    A1ErrPassportNarrowing = 26,
163    /// A Rust panic occurred.
164    A1ErrPanic = 98,
165    /// Unknown or unmapped error.
166    A1ErrUnknown = 99,
167}
168
169/// Opaque handle to a [`DyoloIdentity`].
170pub struct OpaqueIdentity(DyoloIdentity);
171
172/// Opaque handle to a persistent [`MemoryRevocationStore`].
173pub struct OpaqueRevocationStore(MemoryRevocationStore);
174
175/// Opaque handle to a persistent [`MemoryNonceStore`].
176pub struct OpaqueNonceStore(MemoryNonceStore);
177
178/// Opaque handle to a [`DyoloChain`] plus its in-process stores.
179#[allow(dead_code)]
180pub struct OpaqueChain {
181    chain: DyoloChain,
182    rev: MemoryRevocationStore,
183    nonces: MemoryNonceStore,
184}
185
186// ── Error reporting ───────────────────────────────────────────────────────────
187
188/// Returns a nul-terminated UTF-8 string describing the last error that
189/// occurred on this thread, or a null pointer if no error has been set.
190///
191/// The returned pointer is valid until the next FFI call on this thread.
192/// Do NOT free it.
193///
194/// # Safety
195///
196/// `LAST_ERROR` is thread-local; this function is safe to call from any thread
197/// that previously called a `dyolo_*` function.
198///
199/// # Example (Python cffi)
200/// ```python
201/// err = lib.dyolo_last_error()
202/// if err:
203///     print(ffi.string(err).decode())
204/// ```
205#[unsafe(no_mangle)]
206pub unsafe extern "C" fn dyolo_last_error() -> *const c_char {
207    LAST_ERROR.with(|e| e.borrow().as_ref().map_or(std::ptr::null(), |s| s.as_ptr()))
208}
209
210// ── Identity ──────────────────────────────────────────────────────────────────
211
212/// Generate a new random `DyoloIdentity` and return an opaque handle.
213///
214/// The caller MUST free the returned handle with `dyolo_identity_free()`.
215///
216/// Returns `NULL` on allocation failure (extremely unlikely).
217#[unsafe(no_mangle)]
218pub extern "C" fn dyolo_identity_generate() -> *mut OpaqueIdentity {
219    Box::into_raw(Box::new(OpaqueIdentity(DyoloIdentity::generate())))
220}
221
222/// Restore a `DyoloIdentity` from a 32-byte signing key seed.
223///
224/// `seed` must point to exactly 32 bytes of key material. The caller retains
225/// ownership of `seed` and must zeroize it after calling this function.
226///
227/// Returns `NULL` if `seed` is null.
228///
229/// # Safety
230/// `seed` must be a valid pointer to at least 32 bytes.
231#[unsafe(no_mangle)]
232pub unsafe extern "C" fn dyolo_identity_from_seed(seed: *const u8) -> *mut OpaqueIdentity {
233    if seed.is_null() {
234        set_last_error("dyolo_identity_from_seed: seed pointer is null");
235        return std::ptr::null_mut();
236    }
237    let bytes: [u8; 32] = unsafe { std::slice::from_raw_parts(seed, 32) }
238        .try_into()
239        .expect("seed is always 32 bytes");
240    Box::into_raw(Box::new(OpaqueIdentity(DyoloIdentity::from_signing_bytes(
241        &bytes,
242    ))))
243}
244
245/// Write the 32-byte Ed25519 verifying key of `identity` into `out`.
246///
247/// `out` must point to a caller-allocated buffer of at least 32 bytes.
248///
249/// Returns `A1_OK` (0) on success.
250///
251/// # Safety
252/// `identity` and `out` must be valid, non-null pointers.
253#[unsafe(no_mangle)]
254pub unsafe extern "C" fn dyolo_identity_verifying_key(
255    identity: *const OpaqueIdentity,
256    out: *mut u8,
257) -> c_int {
258    if identity.is_null() || out.is_null() {
259        set_last_error("null pointer argument");
260        return A1Status::A1ErrUnknown as c_int;
261    }
262    let vk = unsafe { (*identity).0.verifying_key() };
263    unsafe { std::ptr::copy_nonoverlapping(vk.as_bytes().as_ptr(), out, 32) };
264    A1Status::A1Ok as c_int
265}
266
267/// Free a `DyoloIdentity` handle previously returned by `dyolo_identity_generate`
268/// or `dyolo_identity_from_seed`.
269///
270/// # Safety
271/// `identity` must be a valid, non-null pointer returned by this library.
272/// Calling this twice on the same pointer is undefined behavior.
273#[unsafe(no_mangle)]
274pub unsafe extern "C" fn dyolo_identity_free(identity: *mut OpaqueIdentity) {
275    if !identity.is_null() {
276        let _ = unsafe { Box::from_raw(identity) };
277    }
278}
279
280// ── Stores ────────────────────────────────────────────────────────────────────
281
282/// Allocate a new persistent in-memory revocation store.
283#[unsafe(no_mangle)]
284pub extern "C" fn dyolo_revocation_store_new() -> *mut OpaqueRevocationStore {
285    Box::into_raw(Box::new(
286        OpaqueRevocationStore(MemoryRevocationStore::new()),
287    ))
288}
289
290/// Free a revocation store handle.
291///
292/// # Safety
293///
294/// `store` must be a valid pointer obtained from `dyolo_revocation_store_new()`,
295/// or `NULL`. Must not be freed more than once.
296#[unsafe(no_mangle)]
297pub unsafe extern "C" fn dyolo_revocation_store_free(store: *mut OpaqueRevocationStore) {
298    if !store.is_null() {
299        let _ = unsafe { Box::from_raw(store) };
300    }
301}
302
303/// Allocate a new persistent in-memory nonce store.
304#[unsafe(no_mangle)]
305pub extern "C" fn dyolo_nonce_store_new() -> *mut OpaqueNonceStore {
306    Box::into_raw(Box::new(OpaqueNonceStore(MemoryNonceStore::new())))
307}
308
309/// Free a nonce store handle.
310///
311/// # Safety
312///
313/// `store` must be a valid pointer obtained from `dyolo_nonce_store_new()`,
314/// or `NULL`. Must not be freed more than once.
315#[unsafe(no_mangle)]
316pub unsafe extern "C" fn dyolo_nonce_store_free(store: *mut OpaqueNonceStore) {
317    if !store.is_null() {
318        let _ = unsafe { Box::from_raw(store) };
319    }
320}
321
322// ── Cert Operations ───────────────────────────────────────────────────────────
323
324/// Revoke a certificate by fingerprint in the provided revocation store.
325/// `fingerprint_hex` must be a 64-character null-terminated hex string.
326///
327/// # Safety
328///
329/// `store` must be a valid non-null pointer from `dyolo_revocation_store_new()`.
330/// `fingerprint_hex` must be a valid null-terminated C string of exactly 64 hex characters.
331#[unsafe(no_mangle)]
332pub unsafe extern "C" fn dyolo_cert_revoke(
333    store: *mut OpaqueRevocationStore,
334    fingerprint_hex: *const c_char,
335) -> c_int {
336    let result = panic::catch_unwind(|| {
337        if store.is_null() || fingerprint_hex.is_null() {
338            return Err("null pointer argument".to_string());
339        }
340        let fp_str = unsafe { CStr::from_ptr(fingerprint_hex) }
341            .to_str()
342            .map_err(|e| format!("invalid utf-8: {e}"))?;
343        let fp_bytes: [u8; 32] = hex::decode(fp_str)
344            .map_err(|e| format!("invalid hex: {e}"))?
345            .try_into()
346            .map_err(|_| "fingerprint must be 32 bytes".to_string())?;
347
348        use crate::registry::RevocationStore;
349        unsafe { &(*store).0 }
350            .revoke(&fp_bytes)
351            .map_err(|e| e.to_string())
352    });
353
354    match result {
355        Ok(Ok(())) => A1Status::A1Ok as c_int,
356        Ok(Err(msg)) => {
357            set_last_error(msg);
358            A1Status::A1ErrUnknown as c_int
359        }
360        Err(_) => {
361            set_last_error("internal panic");
362            A1Status::A1ErrPanic as c_int
363        }
364    }
365}
366
367// ── JSON authorize ────────────────────────────────────────────────────────────
368
369/// Verify a JSON-encoded `SignedChain` for a given intent and write the
370/// JSON-encoded `VerifiedToken` (HMAC-authenticated receipt) into `out_buf`.
371///
372/// Parameters:
373/// - `rev_store`       — pointer to a persistent `OpaqueRevocationStore`
374/// - `nonce_store`     — pointer to a persistent `OpaqueNonceStore`
375/// - `chain_json`      — nul-terminated UTF-8 JSON from `SignedChain::to_json()`
376/// - `agent_pk_hex`    — nul-terminated hex-encoded 32-byte Ed25519 verifying key
377/// - `intent_action`   — nul-terminated UTF-8 action name (e.g. `"trade.equity"`)
378/// - `mac_key`         — pointer to 32 bytes of MAC key shared with the executor
379/// - `out_buf`         — caller-allocated buffer for the JSON output
380/// - `out_buf_len`     — length of `out_buf` in bytes
381///
382/// Returns `A1_OK` (0) on success, a non-zero `A1Status` on failure.
383/// On failure, `dyolo_last_error()` contains the error description.
384/// On success, `out_buf` contains a nul-terminated JSON `VerifiedToken`.
385///
386/// # Safety
387/// All pointer arguments must be valid and non-null.
388#[cfg(feature = "wire")]
389#[unsafe(no_mangle)]
390pub unsafe extern "C" fn dyolo_authorize_json(
391    rev_store: *mut OpaqueRevocationStore,
392    nonce_store: *mut OpaqueNonceStore,
393    chain_json: *const c_char,
394    agent_pk_hex: *const c_char,
395    intent_action: *const c_char,
396    mac_key: *const u8,
397    out_buf: *mut c_char,
398    out_buf_len: usize,
399) -> c_int {
400    let result = panic::catch_unwind(|| -> Result<String, (c_int, String)> {
401        // Safety: all pointers are checked non-null by the guard below.
402        if chain_json.is_null()
403            || agent_pk_hex.is_null()
404            || intent_action.is_null()
405            || mac_key.is_null()
406            || out_buf.is_null()
407            || out_buf_len == 0
408        {
409            return Err((
410                A1Status::A1ErrUnknown as c_int,
411                "null or zero-length argument".to_string(),
412            ));
413        }
414
415        if rev_store.is_null() || nonce_store.is_null() {
416            return Err((
417                A1Status::A1ErrUnknown as c_int,
418                "null store pointer argument".to_string(),
419            ));
420        }
421
422        let chain_str = unsafe { CStr::from_ptr(chain_json) }
423            .to_str()
424            .map_err(|e| {
425                (
426                    A1Status::A1ErrUnknown as c_int,
427                    format!("chain_json is not valid UTF-8: {e}"),
428                )
429            })?;
430
431        let pk_hex = unsafe { CStr::from_ptr(agent_pk_hex) }
432            .to_str()
433            .map_err(|e| {
434                (
435                    A1Status::A1ErrUnknown as c_int,
436                    format!("agent_pk_hex is not valid UTF-8: {e}"),
437                )
438            })?;
439
440        let action = unsafe { CStr::from_ptr(intent_action) }
441            .to_str()
442            .map_err(|e| {
443                (
444                    A1Status::A1ErrUnknown as c_int,
445                    format!("intent_action is not valid UTF-8: {e}"),
446                )
447            })?;
448
449        let mac: [u8; 32] = unsafe { std::slice::from_raw_parts(mac_key, 32) }
450            .try_into()
451            .map_err(|_| {
452                (
453                    A1Status::A1ErrUnknown as c_int,
454                    "mac_key must be 32 bytes".to_string(),
455                )
456            })?;
457
458        // Parse agent public key
459        let pk_bytes: [u8; 32] = hex::decode(pk_hex)
460            .map_err(|e| {
461                (
462                    A1Status::A1ErrUnknown as c_int,
463                    format!("invalid agent_pk_hex: {e}"),
464                )
465            })?
466            .try_into()
467            .map_err(|_| {
468                (
469                    A1Status::A1ErrUnknown as c_int,
470                    "agent_pk must be 32 bytes".to_string(),
471                )
472            })?;
473        let agent_pk = ed25519_dalek::VerifyingKey::from_bytes(&pk_bytes).map_err(|e| {
474            (
475                A1Status::A1ErrUnknown as c_int,
476                format!("invalid agent public key: {e}"),
477            )
478        })?;
479
480        // Deserialize the chain
481        let signed: crate::wire::SignedChain = serde_json::from_str(chain_str).map_err(|e| {
482            (
483                A1Status::A1ErrWireFormat as c_int,
484                format!("chain_json parse error: {e}"),
485            )
486        })?;
487
488        #[allow(deprecated)]
489        let chain = signed.into_chain().map_err(|e| {
490            (
491                A1Status::A1ErrWireFormat as c_int,
492                format!("chain conversion error: {e}"),
493            )
494        })?;
495
496        // Full intent path - use Intent::new to build the structural hash safely
497        let intent = Intent::new(action).map_err(|e| {
498            (
499                A1Status::A1ErrUnknown as c_int,
500                format!("intent error: {e}"),
501            )
502        })?;
503        let intent_hash = intent.hash();
504
505        let action_result = chain
506            .authorize(
507                &agent_pk,
508                &intent_hash,
509                &MerkleProof::default(), // Pass-through root match only via this basic FFI endpoint
510                &SystemClock,
511                unsafe { &(*rev_store).0 },
512                unsafe { &(*nonce_store).0 },
513            )
514            .map_err(|e| (a1_error_to_status(&e), e.to_string()))?;
515
516        let token = crate::wire::VerifiedToken::sign(&action_result.receipt, &mac);
517        serde_json::to_string(&token).map_err(|e| {
518            (
519                A1Status::A1ErrUnknown as c_int,
520                format!("token serialization: {e}"),
521            )
522        })
523    });
524
525    match result {
526        Ok(Ok(json)) => {
527            let cstr = CString::new(json).unwrap_or_default();
528            let bytes = cstr.as_bytes_with_nul();
529            if bytes.len() > out_buf_len {
530                set_last_error(format!(
531                    "output buffer too small: need {}, got {out_buf_len}",
532                    bytes.len()
533                ));
534                return A1Status::A1ErrUnknown as c_int;
535            }
536            unsafe {
537                std::ptr::copy_nonoverlapping(bytes.as_ptr(), out_buf as *mut u8, bytes.len())
538            };
539            A1Status::A1Ok as c_int
540        }
541        Ok(Err((code, msg))) => {
542            set_last_error(msg);
543            code
544        }
545        Err(_panic) => {
546            set_last_error("internal panic in dyolo_authorize_json");
547            A1Status::A1ErrPanic as c_int
548        }
549    }
550}
551
552/// Authorize without a `VerifiedToken` — write a JSON `VerificationReceipt`
553/// to `out_buf`. Use this when no cross-service MAC transport is needed
554/// (e.g. the caller is logging the receipt for audit purposes only).
555///
556/// Parameters match `dyolo_authorize_json` except there is no `mac_key`.
557///
558/// Returns `A1_OK` (0) on success.
559///
560/// # Safety
561/// All pointer arguments must be valid and non-null.
562#[cfg(feature = "wire")]
563#[unsafe(no_mangle)]
564pub unsafe extern "C" fn dyolo_authorize_receipt_json(
565    rev_store: *mut OpaqueRevocationStore,
566    nonce_store: *mut OpaqueNonceStore,
567    chain_json: *const c_char,
568    agent_pk_hex: *const c_char,
569    intent_action: *const c_char,
570    out_buf: *mut c_char,
571    out_buf_len: usize,
572) -> c_int {
573    let result = panic::catch_unwind(|| -> Result<String, (c_int, String)> {
574        if chain_json.is_null()
575            || agent_pk_hex.is_null()
576            || intent_action.is_null()
577            || out_buf.is_null()
578            || out_buf_len == 0
579            || rev_store.is_null()
580            || nonce_store.is_null()
581        {
582            return Err((
583                A1Status::A1ErrUnknown as c_int,
584                "null or zero-length argument".to_string(),
585            ));
586        }
587
588        let chain_str = unsafe { CStr::from_ptr(chain_json) }
589            .to_str()
590            .map_err(|e| (A1Status::A1ErrUnknown as c_int, format!("chain_json: {e}")))?;
591        let pk_hex = unsafe { CStr::from_ptr(agent_pk_hex) }
592            .to_str()
593            .map_err(|e| {
594                (
595                    A1Status::A1ErrUnknown as c_int,
596                    format!("agent_pk_hex: {e}"),
597                )
598            })?;
599        let action = unsafe { CStr::from_ptr(intent_action) }
600            .to_str()
601            .map_err(|e| {
602                (
603                    A1Status::A1ErrUnknown as c_int,
604                    format!("intent_action: {e}"),
605                )
606            })?;
607
608        let pk_bytes: [u8; 32] = hex::decode(pk_hex)
609            .map_err(|e| {
610                (
611                    A1Status::A1ErrUnknown as c_int,
612                    format!("invalid agent_pk_hex: {e}"),
613                )
614            })?
615            .try_into()
616            .map_err(|_| {
617                (
618                    A1Status::A1ErrUnknown as c_int,
619                    "agent_pk must be 32 bytes".to_string(),
620                )
621            })?;
622        let agent_pk = ed25519_dalek::VerifyingKey::from_bytes(&pk_bytes).map_err(|e| {
623            (
624                A1Status::A1ErrUnknown as c_int,
625                format!("invalid agent public key: {e}"),
626            )
627        })?;
628
629        let signed: crate::wire::SignedChain = serde_json::from_str(chain_str).map_err(|e| {
630            (
631                A1Status::A1ErrWireFormat as c_int,
632                format!("chain_json parse error: {e}"),
633            )
634        })?;
635
636        #[allow(deprecated)]
637        let chain = signed
638            .into_chain()
639            .map_err(|e| (A1Status::A1ErrWireFormat as c_int, format!("{e}")))?;
640
641        let intent = Intent::new(action).map_err(|e| {
642            (
643                A1Status::A1ErrUnknown as c_int,
644                format!("intent error: {e}"),
645            )
646        })?;
647        let intent_hash = intent.hash();
648
649        let authorized = chain
650            .authorize(
651                &agent_pk,
652                &intent_hash,
653                &MerkleProof::default(),
654                &SystemClock,
655                unsafe { &(*rev_store).0 },
656                unsafe { &(*nonce_store).0 },
657            )
658            .map_err(|e| (a1_error_to_status(&e), e.to_string()))?;
659
660        serde_json::to_string(&authorized.receipt).map_err(|e| {
661            (
662                A1Status::A1ErrUnknown as c_int,
663                format!("receipt serialization: {e}"),
664            )
665        })
666    });
667
668    match result {
669        Ok(Ok(json)) => {
670            let cstr = CString::new(json).unwrap_or_default();
671            let bytes = cstr.as_bytes_with_nul();
672            if bytes.len() > out_buf_len {
673                set_last_error(format!(
674                    "output buffer too small: need {}, got {out_buf_len}",
675                    bytes.len()
676                ));
677                return A1Status::A1ErrUnknown as c_int;
678            }
679            unsafe {
680                std::ptr::copy_nonoverlapping(bytes.as_ptr(), out_buf as *mut u8, bytes.len())
681            };
682            A1Status::A1Ok as c_int
683        }
684        Ok(Err((code, msg))) => {
685            set_last_error(msg);
686            code
687        }
688        Err(_) => {
689            set_last_error("internal panic in dyolo_authorize_receipt_json");
690            A1Status::A1ErrPanic as c_int
691        }
692    }
693}
694
695/// Authorize with a provided Merkle Proof.
696/// `proof_json` must be a serialized `MerkleProof`.
697///
698/// # Safety
699///
700/// All pointer arguments must be valid, non-null, null-terminated C strings (except `mac_key`
701/// which must point to at least 32 bytes, and `out_buf` which must point to `out_buf_len` bytes).
702/// `rev_store` and `nonce_store` must be valid pointers from their respective `_new` functions.
703#[cfg(feature = "wire")]
704#[unsafe(no_mangle)]
705pub unsafe extern "C" fn dyolo_authorize_with_proof_json(
706    rev_store: *mut OpaqueRevocationStore,
707    nonce_store: *mut OpaqueNonceStore,
708    chain_json: *const c_char,
709    agent_pk_hex: *const c_char,
710    intent_action: *const c_char,
711    proof_json: *const c_char,
712    mac_key: *const u8,
713    out_buf: *mut c_char,
714    out_buf_len: usize,
715) -> c_int {
716    let result = panic::catch_unwind(|| {
717        if chain_json.is_null()
718            || agent_pk_hex.is_null()
719            || intent_action.is_null()
720            || mac_key.is_null()
721            || out_buf.is_null()
722            || out_buf_len == 0
723            || proof_json.is_null()
724            || rev_store.is_null()
725            || nonce_store.is_null()
726        {
727            return Err("null argument".to_string());
728        }
729
730        let proof_str = unsafe { CStr::from_ptr(proof_json) }
731            .to_str()
732            .map_err(|e| e.to_string())?;
733        let proof: MerkleProof = serde_json::from_str(proof_str).map_err(|e| e.to_string())?;
734
735        let chain_str = unsafe { CStr::from_ptr(chain_json) }
736            .to_str()
737            .map_err(|e| e.to_string())?;
738        let action = unsafe { CStr::from_ptr(intent_action) }
739            .to_str()
740            .map_err(|e| e.to_string())?;
741
742        let pk_hex = unsafe { CStr::from_ptr(agent_pk_hex) }
743            .to_str()
744            .map_err(|e| e.to_string())?;
745        let pk_bytes: [u8; 32] = hex::decode(pk_hex)
746            .map_err(|e| e.to_string())?
747            .try_into()
748            .map_err(|_| "32 bytes".to_string())?;
749        let agent_pk =
750            ed25519_dalek::VerifyingKey::from_bytes(&pk_bytes).map_err(|e| e.to_string())?;
751
752        let mac: [u8; 32] = unsafe { std::slice::from_raw_parts(mac_key, 32) }
753            .try_into()
754            .map_err(|_| "32 bytes".to_string())?;
755
756        let signed: crate::wire::SignedChain =
757            serde_json::from_str(chain_str).map_err(|e| e.to_string())?;
758
759        #[allow(deprecated)]
760        let chain = signed.into_chain().map_err(|e| e.to_string())?;
761
762        let intent = Intent::new(action).map_err(|e| e.to_string())?;
763        let intent_hash = intent.hash();
764
765        let action_result = chain
766            .authorize(
767                &agent_pk,
768                &intent_hash,
769                &proof,
770                &SystemClock,
771                unsafe { &(*rev_store).0 },
772                unsafe { &(*nonce_store).0 },
773            )
774            .map_err(|e| e.to_string())?;
775
776        let token = crate::wire::VerifiedToken::sign(&action_result.receipt, &mac);
777        serde_json::to_string(&token).map_err(|e| e.to_string())
778    });
779
780    match result {
781        Ok(Ok(json)) => {
782            let cstr = CString::new(json).unwrap_or_default();
783            let bytes = cstr.as_bytes_with_nul();
784            if bytes.len() > out_buf_len {
785                return A1Status::A1ErrUnknown as c_int;
786            }
787            unsafe {
788                std::ptr::copy_nonoverlapping(bytes.as_ptr(), out_buf as *mut u8, bytes.len())
789            };
790            A1Status::A1Ok as c_int
791        }
792        Ok(Err(msg)) => {
793            set_last_error(msg);
794            A1Status::A1ErrUnknown as c_int
795        }
796        Err(_) => A1Status::A1ErrPanic as c_int,
797    }
798}
799
800// ── Version ───────────────────────────────────────────────────────────────────
801
802/// Return the nul-terminated semantic version string of this build
803/// (e.g. `"2.0.0"`).
804///
805/// The returned pointer is valid for the lifetime of the process.
806/// Do NOT free it.
807#[unsafe(no_mangle)]
808pub extern "C" fn dyolo_version() -> *const c_char {
809    // SAFETY: This is a static string literal, always valid UTF-8 and nul-terminated.
810    concat!(env!("CARGO_PKG_VERSION"), "\0").as_ptr().cast()
811}