// C ABI over fumi-core for the Dart FFI binding; the app links the cdylib. // One smoke function for the spike; the real surface lives in `ffi`. pub mod ffi; #[cfg(test)] mod tests { use fumi_core::{account::Identity, message}; use rand_core::{OsRng, RngCore}; use std::time::Instant; #[test] fn store_round_trip() { assert_eq!(crate::ffi::smol_smoke(), 0); } /// Spike benchmark: seal/unseal throughput with a 2 KiB body, to compare /// against the Dart core's numbers (test/perf_smoke_test.dart). Print /// only; the assertions pin correctness, not speed. #[test] fn envelope_perf() { let mut seed = [0u8; 32]; OsRng.fill_bytes(&mut seed); let sender = Identity::from_seed(seed); OsRng.fill_bytes(&mut seed); let recipient = Identity::from_seed(seed); let body = vec![b'x'; 2048]; const N: usize = 100; let t0 = Instant::now(); let envelopes: Vec> = (0..N) .map(|i| message::seal(&sender, &recipient.pk(), &body, i as i64, true).unwrap()) .collect(); let seal_dt = t0.elapsed(); let t1 = Instant::now(); for (i, envelope) in envelopes.iter().enumerate() { let opened = message::unseal(&[recipient.clone()], envelope, 0).unwrap(); assert_eq!(opened.body.len(), 2048); assert_eq!(opened.sender, sender.pk()); assert_eq!(opened.time, i as i64); } let open_dt = t1.elapsed(); println!( "rust: seal {N} in {seal_dt:?} ({:.0} us/msg), unseal {N} in {open_dt:?} ({:.0} us/msg)", seal_dt.as_micros() as f64 / N as f64, open_dt.as_micros() as f64 / N as f64, ); } /// Spike round-trip against a live bunshin on 127.0.0.1:19619; ignored /// because it needs the server (bunshin serve --key ... --port 19619). /// Covers the embeddable path end to end: pin, REGISTER, SEND, FETCH. #[test] #[ignore] fn bunshin_round_trip() { use fumi_core::account::Account; use fumi_core::address::Address; use fumi_core::client::{fetch, register, send, SendDraft}; use fumi_core::crypto::unb32; use fumi_core::store::Store; use fumi_core::transport::KEY_LEN; use rand_core::{OsRng, RngCore}; let server: [u8; KEY_LEN] = unb32( "wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga", ) .unwrap() .try_into() .unwrap(); let mut master = [0u8; 32]; let mut identity = || { OsRng.fill_bytes(&mut master); (Store::open_in_memory().unwrap(), Account::new(master, 0).unwrap()) }; let (alice_store, alice) = identity(); let (bob_store, bob) = identity(); // Random usernames: the server persists registrations, so fixed ones // would collide on the second run of this test. let mut suffix = [0u8; 2]; OsRng.fill_bytes(&mut suffix); let run = fumi_core::crypto::b32(&suffix); let alice_addr = Address::parse(&format!("a{run}@127.0.0.1:19619")).unwrap(); let bob_addr = Address::parse(&format!("b{run}@127.0.0.1:19619")).unwrap(); alice_store.pin_server("127.0.0.1", &server).unwrap(); bob_store.pin_server("127.0.0.1", &server).unwrap(); register(&alice_store, &alice_addr, &alice, None, 5).unwrap(); register(&bob_store, &bob_addr, &bob, None, 5).unwrap(); let draft = SendDraft { address: &bob_addr, text: "spike: hello over fumi".into(), subject: Some("spike"), reply_to: None, headers: &[], anonymous: false, no_pad: false, }; let sent = send(&alice_store, &alice, &draft, 5).unwrap(); assert_eq!(sent.warning, None); let fetched = fetch(&bob_store, &bob, false, false, 5).unwrap(); assert_eq!(fetched.stored, 1); // First contact without an accept token lands in the requests tier // (sec 5.8), not the inbox. let stored = &bob_store.mail("requests").unwrap()[0]; let opened = fumi_core::client::describe(&bob_store, &bob, stored).unwrap(); assert_eq!(opened.text, "spike: hello over fumi"); assert_eq!(opened.subject, "spike"); assert_eq!(opened.sender, alice.keys()[0].pk()); assert_eq!(opened.from, alice_addr.short()); // sec 5.6, the cross-recipient case self-sends mask: the sent copy // is sealed to the sender's own key, so alice can read back what she // sent to bob even though bob's envelope used a discarded ephemeral. let sent = &alice_store.mail("sent").unwrap()[0]; let reread = fumi_core::client::describe(&alice_store, &alice, sent).unwrap(); assert_eq!(reread.text, "spike: hello over fumi"); assert_eq!(reread.subject, "spike"); } /// Spike: cancellation and resume, against the same live bunshin. Four /// ~400 KiB envelopes exceed the server's 512 KiB fetch budget, so each /// page carries one message and the between-page cancel check is /// reachable. Phase A is deterministic (flag pre-set); phase B cancels /// from a watcher thread the moment the first message commits — which is /// also the concurrent-reader check, since the store is read while the /// fetch holds it. #[test] #[ignore] fn bunshin_cancel_and_resume() { use fumi_core::account::Account; use fumi_core::address::Address; use fumi_core::client::{fetch, fetch_with, register, send, FetchOptions, SendDraft}; use fumi_core::crypto::unb32; use fumi_core::store::Store; use fumi_core::transport::KEY_LEN; use rand_core::{OsRng, RngCore}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; let server: [u8; KEY_LEN] = unb32( "wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga", ) .unwrap() .try_into() .unwrap(); let mut master = [0u8; 32]; let mut identity = || { OsRng.fill_bytes(&mut master); (Store::open_in_memory().unwrap(), Account::new(master, 0).unwrap()) }; let (alice_store, alice) = identity(); let bob_store = Arc::new(Store::open_in_memory().unwrap()); let mut bob_master = [0u8; 32]; OsRng.fill_bytes(&mut bob_master); let bob = Account::new(bob_master, 0).unwrap(); let mut suffix = [0u8; 2]; OsRng.fill_bytes(&mut suffix); let run = fumi_core::crypto::b32(&suffix); let alice_addr = Address::parse(&format!("a{run}@127.0.0.1:19619")).unwrap(); let bob_addr = Address::parse(&format!("b{run}@127.0.0.1:19619")).unwrap(); alice_store.pin_server("127.0.0.1", &server).unwrap(); bob_store.pin_server("127.0.0.1", &server).unwrap(); register(&alice_store, &alice_addr, &alice, None, 5).unwrap(); register(&bob_store, &bob_addr, &bob, None, 5).unwrap(); let big = "x".repeat(400 * 1024); for i in 0..4 { let draft = SendDraft { address: &bob_addr, text: format!("{i}\n{big}"), subject: None, reply_to: None, headers: &[], anonymous: false, no_pad: false, }; send(&alice_store, &alice, &draft, 5).unwrap(); } // Phase A: the flag is checked before the first page, so nothing is // fetched and nothing is acknowledged. let cancel = AtomicBool::new(true); let a = fetch_with( &bob_store, &bob, FetchOptions { keep: false, reset: false, dial: None, timeout: 5, cancel: Some(&cancel), }, ) .unwrap(); assert!(a.cancelled); assert_eq!(a.stored, 0); // Phase B: a reader thread watches the store fill and raises the // flag after the first commit; the fetch stops between pages. cancel.store(false, Ordering::Relaxed); let flag = Arc::new(AtomicBool::new(false)); let thread_flag = Arc::clone(&flag); let watcher_store = Arc::clone(&bob_store); let watcher = std::thread::spawn(move || { while watcher_store.mail("all").unwrap().len() < 1 { std::thread::sleep(std::time::Duration::from_millis(1)); } thread_flag.store(true, Ordering::Relaxed); }); let b = fetch_with( &bob_store, &bob, FetchOptions { keep: false, reset: false, dial: None, timeout: 5, cancel: Some(&flag), }, ) .unwrap(); assert!(b.cancelled, "watcher should have raised the flag mid-fetch"); assert!(b.stored >= 1 && b.stored < 4); watcher.join().unwrap(); // Resume: an uncancelled fetch delivers the rest, and the seen-id // set keeps the acknowledged pages from coming back as duplicates. let c = fetch(&bob_store, &bob, false, false, 5).unwrap(); assert_eq!(b.stored + c.stored, 4); assert_eq!(bob_store.mail("all").unwrap().len(), 4); } /// The Android regression: a hostname the native resolver cannot /// resolve (the device's getaddrinfo is dead for app processes), pinned /// by name, dialed by the IP the app resolved itself. Registration must /// succeed and the account must keep the hostname identity. #[test] #[ignore] fn bunshin_dial_hint_routes_by_ip() { use fumi_core::client::register; use fumi_core::crypto::unb32; use fumi_core::store::Store; use fumi_core::transport::KEY_LEN; use rand_core::{OsRng, RngCore}; let server: [u8; KEY_LEN] = unb32( "wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga", ) .unwrap() .try_into() .unwrap(); let mut master = [0u8; 32]; OsRng.fill_bytes(&mut master); let store = Store::open_in_memory().unwrap(); let account = fumi_core::account::Account::new(master, 0).unwrap(); let mut suffix = [0u8; 2]; OsRng.fill_bytes(&mut suffix); let run = fumi_core::crypto::b32(&suffix); let host = format!("unresolvable-{run}.invalid"); let addr = fumi_core::address::Address::parse(&format!("u{run}@{host}:19619")) .unwrap() .with_dial("127.0.0.1"); store.pin_server(&host, &server).unwrap(); register(&store, &addr, &account, None, 5).unwrap(); assert_eq!(store.account().unwrap().unwrap().short(), format!("u{run}@{host}:19619")); } /// Spike: the error variants the onboarding routes on. No pin and a /// wrong pin are distinct and matchable — the two branches that decide /// whether the register step frames itself as "pin the key" (§4) or /// aborts. Needs the live bunshin but changes no server state. #[test] #[ignore] fn bunshin_pin_errors() { use fumi_core::address::Address; use fumi_core::client::connect; use fumi_core::error::Error; use fumi_core::store::Store; use fumi_core::transport::KEY_LEN; use rand_core::{OsRng, RngCore}; let addr = Address::parse("nobody@127.0.0.1:19619").unwrap(); let unpinned = Store::open_in_memory().unwrap(); match connect(&unpinned, &addr, true, 5) { Err(Error::NotPinned { host }) => assert_eq!(host, "127.0.0.1"), Err(err) => panic!("expected NotPinned, got {err}"), Ok(_) => panic!("expected NotPinned, connected"), } let mut wrong = [0u8; KEY_LEN]; OsRng.fill_bytes(&mut wrong); let mismatched = Store::open_in_memory().unwrap(); mismatched.pin_server("127.0.0.1", &wrong).unwrap(); match connect(&mismatched, &addr, true, 5) { Err(Error::PinMismatch { .. }) => {} Err(err) => panic!("expected PinMismatch, got {err}"), Ok(_) => panic!("expected PinMismatch, connected"), } } /// Spike, bright path: a real rotation validates through the chain and /// surfaces as `TrustChange::Rotated` — a warning, not an error. Carol /// registers, Alice learns her key by sending, Carol rotates, and /// Alice's next resolve walks the chain the server returns. #[test] #[ignore] fn bunshin_rotation_bright() { use fumi_core::account::Account; use fumi_core::address::Address; use fumi_core::client::{connect, register, rotate, send, trust_key, SendDraft}; use fumi_core::crypto::unb32; use fumi_core::store::Store; use fumi_core::transport::KEY_LEN; use rand_core::{OsRng, RngCore}; let server: [u8; KEY_LEN] = unb32( "wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga", ) .unwrap() .try_into() .unwrap(); let mut carol_master = [0u8; 32]; OsRng.fill_bytes(&mut carol_master); let mut alice_master = [0u8; 32]; OsRng.fill_bytes(&mut alice_master); let carol_store = Store::open_in_memory().unwrap(); let alice_store = Store::open_in_memory().unwrap(); let carol = Account::new(carol_master, 0).unwrap(); let alice = Account::new(alice_master, 0).unwrap(); let mut suffix = [0u8; 2]; OsRng.fill_bytes(&mut suffix); let run = fumi_core::crypto::b32(&suffix); let carol_addr = Address::parse(&format!("c{run}@127.0.0.1:19619")).unwrap(); let alice_addr = Address::parse(&format!("d{run}@127.0.0.1:19619")).unwrap(); carol_store.pin_server("127.0.0.1", &server).unwrap(); alice_store.pin_server("127.0.0.1", &server).unwrap(); register(&carol_store, &carol_addr, &carol, None, 5).unwrap(); register(&alice_store, &alice_addr, &alice, None, 5).unwrap(); let draft = SendDraft { address: &carol_addr, text: "before the rotation".into(), subject: None, reply_to: None, headers: &[], anonymous: false, no_pad: false, }; let sent = send(&alice_store, &alice, &draft, 5).unwrap(); assert!(matches!( sent.change, fumi_core::client::TrustChange::New { unverified: false } )); rotate(&carol_store, &carol, None, 5).unwrap(); let carol_next = Account::new(carol_master, 1).unwrap(); let mut session = connect(&alice_store, &carol_addr, true, 5).unwrap(); let (pk, change) = { let transport = session.transport(); trust_key(&alice_store, transport, &carol_addr).unwrap() }; session.close(); assert!(matches!(change, fumi_core::client::TrustChange::Rotated)); assert_eq!(pk, carol_next.me().pk()); } /// Spike, dark twin: RESOLVE returns a key with no chain to the one we /// hold — the state a hijacked or re-registered username produces, and /// the branch the onboarding must treat as terminal until the user /// verifies out of band. A mock transport stands in for the server, so /// this needs no live bunshin. #[test] fn keychanged_dark_twin() { use fumi_core::address::Address; use fumi_core::client::trust_key; use fumi_core::error::Error; use fumi_core::store::Store; use fumi_core::transport::{ Response, Transport, TransportBindValues, KEY_LEN, OP_RESOLVE, }; use rand_core::{OsRng, RngCore}; struct MockResolve { offered: [u8; KEY_LEN], bind: TransportBindValues, } impl Transport for MockResolve { fn request(&mut self, op: u8, _body: &[u8]) -> Result { assert_eq!(op, OP_RESOLVE); let mut body = Vec::with_capacity(KEY_LEN + 1); body.extend_from_slice(&self.offered); body.push(0); // no chain at all Ok(Response { status: 0, body }) } fn bind(&self) -> &TransportBindValues { &self.bind } fn pinned(&self) -> bool { true } fn close(&mut self) {} } let addr = Address::parse("dark@127.0.0.1:19619").unwrap(); let mut known = [0u8; KEY_LEN]; OsRng.fill_bytes(&mut known); let mut offered = [0u8; KEY_LEN]; OsRng.fill_bytes(&mut offered); let store = Store::open_in_memory().unwrap(); store.save_contact(&addr.short(), &known, false).unwrap(); let mut mock = MockResolve { offered, bind: TransportBindValues { h: [0u8; 32], server_static: [0u8; 32], }, }; match trust_key(&store, &mut mock, &addr) { Err(Error::KeyChanged { address, known: k, offered: o, }) => { assert_eq!(address, addr.short()); assert_eq!(k, known); assert_eq!(o, offered); } Err(err) => panic!("expected KeyChanged, got {err}"), Ok(_) => panic!("expected KeyChanged, trusted the new key"), } } }