383 lines
11 KiB
Rust
383 lines
11 KiB
Rust
//! C FFI for App Clip / mobile integration.
|
|
//!
|
|
//! These `extern "C"` functions can be compiled into a static library
|
|
//! (.a / .xcframework) and called directly from Swift or Kotlin.
|
|
//!
|
|
//! Build for iOS:
|
|
//! cargo build --release --target aarch64-apple-ios --lib
|
|
//! cargo build --release --target aarch64-apple-ios-sim --lib
|
|
//!
|
|
//! Build for Android:
|
|
//! cargo build --release --target aarch64-linux-android --lib
|
|
//!
|
|
//! The App Clip contains ~50 KB of this library. Combined with the QR
|
|
//! seed payload, the user experience is: Scan → Boot → Intelligence.
|
|
|
|
use crate::compress;
|
|
use crate::qr_seed::ParsedSeed;
|
|
use crate::seed_crypto;
|
|
use rvf_types::qr_seed::{SeedHeader, SEED_HEADER_SIZE};
|
|
|
|
/// Result codes for FFI functions.
|
|
pub const RVQS_OK: i32 = 0;
|
|
pub const RVQS_ERR_NULL_PTR: i32 = -1;
|
|
pub const RVQS_ERR_TOO_SHORT: i32 = -2;
|
|
pub const RVQS_ERR_BAD_MAGIC: i32 = -3;
|
|
pub const RVQS_ERR_SIGNATURE_INVALID: i32 = -4;
|
|
pub const RVQS_ERR_HASH_MISMATCH: i32 = -5;
|
|
pub const RVQS_ERR_DECOMPRESS_FAIL: i32 = -6;
|
|
pub const RVQS_ERR_BUFFER_TOO_SMALL: i32 = -7;
|
|
pub const RVQS_ERR_PARSE_FAIL: i32 = -8;
|
|
|
|
/// Opaque header struct for C interop (mirrors SeedHeader layout).
|
|
#[repr(C)]
|
|
pub struct RvqsHeaderC {
|
|
pub seed_magic: u32,
|
|
pub seed_version: u16,
|
|
pub flags: u16,
|
|
pub file_id: [u8; 8],
|
|
pub total_vector_count: u32,
|
|
pub dimension: u16,
|
|
pub base_dtype: u8,
|
|
pub profile_id: u8,
|
|
pub created_ns: u64,
|
|
pub microkernel_offset: u32,
|
|
pub microkernel_size: u32,
|
|
pub download_manifest_offset: u32,
|
|
pub download_manifest_size: u32,
|
|
pub sig_algo: u16,
|
|
pub sig_length: u16,
|
|
pub total_seed_size: u32,
|
|
pub content_hash: [u8; 8],
|
|
}
|
|
|
|
const _: () = assert!(core::mem::size_of::<RvqsHeaderC>() == SEED_HEADER_SIZE);
|
|
|
|
impl From<SeedHeader> for RvqsHeaderC {
|
|
fn from(h: SeedHeader) -> Self {
|
|
Self {
|
|
seed_magic: h.seed_magic,
|
|
seed_version: h.seed_version,
|
|
flags: h.flags,
|
|
file_id: h.file_id,
|
|
total_vector_count: h.total_vector_count,
|
|
dimension: h.dimension,
|
|
base_dtype: h.base_dtype,
|
|
profile_id: h.profile_id,
|
|
created_ns: h.created_ns,
|
|
microkernel_offset: h.microkernel_offset,
|
|
microkernel_size: h.microkernel_size,
|
|
download_manifest_offset: h.download_manifest_offset,
|
|
download_manifest_size: h.download_manifest_size,
|
|
sig_algo: h.sig_algo,
|
|
sig_length: h.sig_length,
|
|
total_seed_size: h.total_seed_size,
|
|
content_hash: h.content_hash,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Parse a QR seed payload and extract the header.
|
|
///
|
|
/// # Safety
|
|
/// `data` must point to `data_len` valid bytes. `out` must point to a valid `RvqsHeaderC`.
|
|
#[no_mangle]
|
|
pub unsafe extern "C" fn rvqs_parse_header(
|
|
data: *const u8,
|
|
data_len: usize,
|
|
out: *mut RvqsHeaderC,
|
|
) -> i32 {
|
|
if data.is_null() || out.is_null() {
|
|
return RVQS_ERR_NULL_PTR;
|
|
}
|
|
if data_len < SEED_HEADER_SIZE {
|
|
return RVQS_ERR_TOO_SHORT;
|
|
}
|
|
|
|
let slice = core::slice::from_raw_parts(data, data_len);
|
|
match SeedHeader::from_bytes(slice) {
|
|
Ok(header) => {
|
|
*out = header.into();
|
|
RVQS_OK
|
|
}
|
|
Err(_) => RVQS_ERR_BAD_MAGIC,
|
|
}
|
|
}
|
|
|
|
/// Verify the HMAC-SHA256 signature of a QR seed.
|
|
///
|
|
/// # Safety
|
|
/// All pointers must be valid for their respective lengths.
|
|
#[no_mangle]
|
|
pub unsafe extern "C" fn rvqs_verify_signature(
|
|
data: *const u8,
|
|
data_len: usize,
|
|
key: *const u8,
|
|
key_len: usize,
|
|
) -> i32 {
|
|
if data.is_null() || key.is_null() {
|
|
return RVQS_ERR_NULL_PTR;
|
|
}
|
|
if data_len < SEED_HEADER_SIZE {
|
|
return RVQS_ERR_TOO_SHORT;
|
|
}
|
|
|
|
let slice = core::slice::from_raw_parts(data, data_len);
|
|
let key_slice = core::slice::from_raw_parts(key, key_len);
|
|
|
|
let parsed = match ParsedSeed::parse(slice) {
|
|
Ok(p) => p,
|
|
Err(_) => return RVQS_ERR_PARSE_FAIL,
|
|
};
|
|
|
|
let signature = match parsed.signature {
|
|
Some(s) => s,
|
|
None => return RVQS_ERR_SIGNATURE_INVALID,
|
|
};
|
|
|
|
let signed_payload = match parsed.signed_payload(slice) {
|
|
Some(p) => p,
|
|
None => return RVQS_ERR_SIGNATURE_INVALID,
|
|
};
|
|
|
|
if seed_crypto::verify_seed(key_slice, signed_payload, signature) {
|
|
RVQS_OK
|
|
} else {
|
|
RVQS_ERR_SIGNATURE_INVALID
|
|
}
|
|
}
|
|
|
|
/// Verify the content hash of a QR seed payload.
|
|
///
|
|
/// # Safety
|
|
/// `data` must point to `data_len` valid bytes.
|
|
#[no_mangle]
|
|
pub unsafe extern "C" fn rvqs_verify_content_hash(data: *const u8, data_len: usize) -> i32 {
|
|
if data.is_null() {
|
|
return RVQS_ERR_NULL_PTR;
|
|
}
|
|
if data_len < SEED_HEADER_SIZE {
|
|
return RVQS_ERR_TOO_SHORT;
|
|
}
|
|
|
|
let slice = core::slice::from_raw_parts(data, data_len);
|
|
let parsed = match ParsedSeed::parse(slice) {
|
|
Ok(p) => p,
|
|
Err(_) => return RVQS_ERR_PARSE_FAIL,
|
|
};
|
|
|
|
let microkernel = parsed.microkernel.unwrap_or(&[]);
|
|
let manifest = parsed.manifest_bytes.unwrap_or(&[]);
|
|
|
|
let mut hash_input = Vec::with_capacity(microkernel.len() + manifest.len());
|
|
hash_input.extend_from_slice(microkernel);
|
|
hash_input.extend_from_slice(manifest);
|
|
|
|
if seed_crypto::verify_content_hash(&parsed.header.content_hash, &hash_input) {
|
|
RVQS_OK
|
|
} else {
|
|
RVQS_ERR_HASH_MISMATCH
|
|
}
|
|
}
|
|
|
|
/// Decompress the microkernel from a QR seed.
|
|
///
|
|
/// # Safety
|
|
/// `data` must point to `data_len` valid bytes. `out` must point to `out_cap` bytes.
|
|
/// `out_len` will receive the actual decompressed size.
|
|
#[no_mangle]
|
|
pub unsafe extern "C" fn rvqs_decompress_microkernel(
|
|
data: *const u8,
|
|
data_len: usize,
|
|
out: *mut u8,
|
|
out_cap: usize,
|
|
out_len: *mut usize,
|
|
) -> i32 {
|
|
if data.is_null() || out.is_null() || out_len.is_null() {
|
|
return RVQS_ERR_NULL_PTR;
|
|
}
|
|
|
|
let slice = core::slice::from_raw_parts(data, data_len);
|
|
let parsed = match ParsedSeed::parse(slice) {
|
|
Ok(p) => p,
|
|
Err(_) => return RVQS_ERR_PARSE_FAIL,
|
|
};
|
|
|
|
let compressed = match parsed.microkernel {
|
|
Some(m) => m,
|
|
None => {
|
|
*out_len = 0;
|
|
return RVQS_OK;
|
|
}
|
|
};
|
|
|
|
let decompressed = match compress::decompress(compressed) {
|
|
Ok(d) => d,
|
|
Err(_) => return RVQS_ERR_DECOMPRESS_FAIL,
|
|
};
|
|
|
|
if decompressed.len() > out_cap {
|
|
return RVQS_ERR_BUFFER_TOO_SMALL;
|
|
}
|
|
|
|
let out_slice = core::slice::from_raw_parts_mut(out, out_cap);
|
|
out_slice[..decompressed.len()].copy_from_slice(&decompressed);
|
|
*out_len = decompressed.len();
|
|
|
|
RVQS_OK
|
|
}
|
|
|
|
/// Get the download manifest URL from a parsed seed.
|
|
///
|
|
/// # Safety
|
|
/// All pointers must be valid. `url_buf` must have `url_cap` bytes available.
|
|
#[no_mangle]
|
|
pub unsafe extern "C" fn rvqs_get_primary_host_url(
|
|
data: *const u8,
|
|
data_len: usize,
|
|
url_buf: *mut u8,
|
|
url_cap: usize,
|
|
url_len: *mut usize,
|
|
) -> i32 {
|
|
if data.is_null() || url_buf.is_null() || url_len.is_null() {
|
|
return RVQS_ERR_NULL_PTR;
|
|
}
|
|
|
|
let slice = core::slice::from_raw_parts(data, data_len);
|
|
let parsed = match ParsedSeed::parse(slice) {
|
|
Ok(p) => p,
|
|
Err(_) => return RVQS_ERR_PARSE_FAIL,
|
|
};
|
|
|
|
let manifest = match parsed.parse_manifest() {
|
|
Ok(m) => m,
|
|
Err(_) => return RVQS_ERR_PARSE_FAIL,
|
|
};
|
|
|
|
let host = match manifest.hosts.first() {
|
|
Some(h) => h,
|
|
None => {
|
|
*url_len = 0;
|
|
return RVQS_OK;
|
|
}
|
|
};
|
|
|
|
let url_bytes = &host.url[..host.url_length as usize];
|
|
if url_bytes.len() > url_cap {
|
|
return RVQS_ERR_BUFFER_TOO_SMALL;
|
|
}
|
|
|
|
let out_slice = core::slice::from_raw_parts_mut(url_buf, url_cap);
|
|
out_slice[..url_bytes.len()].copy_from_slice(url_bytes);
|
|
*url_len = url_bytes.len();
|
|
|
|
RVQS_OK
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::qr_seed::{make_host_entry, SeedBuilder};
|
|
use rvf_types::qr_seed::*;
|
|
|
|
fn build_signed_seed() -> Vec<u8> {
|
|
let key = b"test-key-for-ffi-unit-testing-ok";
|
|
let mk = crate::compress::compress(&[0xCA; 2000]);
|
|
let host = make_host_entry("https://cdn.test.com/brain.rvf", 0, 1, [0xAA; 16]).unwrap();
|
|
|
|
let builder = SeedBuilder::new([0x01; 8], 128, 1000)
|
|
.with_microkernel(mk)
|
|
.add_host(host);
|
|
let (payload, _header) = builder.build_and_sign(key).unwrap();
|
|
payload
|
|
}
|
|
|
|
#[test]
|
|
fn ffi_parse_header() {
|
|
let payload = build_signed_seed();
|
|
let mut header = core::mem::MaybeUninit::<RvqsHeaderC>::uninit();
|
|
let rc = unsafe { rvqs_parse_header(payload.as_ptr(), payload.len(), header.as_mut_ptr()) };
|
|
assert_eq!(rc, RVQS_OK);
|
|
let header = unsafe { header.assume_init() };
|
|
assert_eq!(header.seed_magic, SEED_MAGIC);
|
|
assert_eq!(header.dimension, 128);
|
|
}
|
|
|
|
#[test]
|
|
fn ffi_verify_signature() {
|
|
let key = b"test-key-for-ffi-unit-testing-ok";
|
|
let payload = build_signed_seed();
|
|
let rc = unsafe {
|
|
rvqs_verify_signature(payload.as_ptr(), payload.len(), key.as_ptr(), key.len())
|
|
};
|
|
assert_eq!(rc, RVQS_OK);
|
|
}
|
|
|
|
#[test]
|
|
fn ffi_verify_signature_wrong_key() {
|
|
let payload = build_signed_seed();
|
|
let bad_key = b"wrong-key-should-fail-verificatn";
|
|
let rc = unsafe {
|
|
rvqs_verify_signature(
|
|
payload.as_ptr(),
|
|
payload.len(),
|
|
bad_key.as_ptr(),
|
|
bad_key.len(),
|
|
)
|
|
};
|
|
assert_eq!(rc, RVQS_ERR_SIGNATURE_INVALID);
|
|
}
|
|
|
|
#[test]
|
|
fn ffi_verify_content_hash() {
|
|
let payload = build_signed_seed();
|
|
let rc = unsafe { rvqs_verify_content_hash(payload.as_ptr(), payload.len()) };
|
|
assert_eq!(rc, RVQS_OK);
|
|
}
|
|
|
|
#[test]
|
|
fn ffi_decompress_microkernel() {
|
|
let payload = build_signed_seed();
|
|
let mut out = vec![0u8; 8192];
|
|
let mut out_len: usize = 0;
|
|
let rc = unsafe {
|
|
rvqs_decompress_microkernel(
|
|
payload.as_ptr(),
|
|
payload.len(),
|
|
out.as_mut_ptr(),
|
|
out.len(),
|
|
&mut out_len,
|
|
)
|
|
};
|
|
assert_eq!(rc, RVQS_OK);
|
|
assert_eq!(out_len, 2000);
|
|
assert_eq!(&out[..out_len], &[0xCA; 2000]);
|
|
}
|
|
|
|
#[test]
|
|
fn ffi_get_primary_host_url() {
|
|
let payload = build_signed_seed();
|
|
let mut url_buf = vec![0u8; 256];
|
|
let mut url_len: usize = 0;
|
|
let rc = unsafe {
|
|
rvqs_get_primary_host_url(
|
|
payload.as_ptr(),
|
|
payload.len(),
|
|
url_buf.as_mut_ptr(),
|
|
url_buf.len(),
|
|
&mut url_len,
|
|
)
|
|
};
|
|
assert_eq!(rc, RVQS_OK);
|
|
let url = core::str::from_utf8(&url_buf[..url_len]).unwrap();
|
|
assert_eq!(url, "https://cdn.test.com/brain.rvf");
|
|
}
|
|
|
|
#[test]
|
|
fn ffi_null_ptr_returns_error() {
|
|
let mut header = core::mem::MaybeUninit::<RvqsHeaderC>::uninit();
|
|
let rc = unsafe { rvqs_parse_header(core::ptr::null(), 0, header.as_mut_ptr()) };
|
|
assert_eq!(rc, RVQS_ERR_NULL_PTR);
|
|
}
|
|
}
|