refactor: split the library into fumi-core and make it embeddable

This commit is contained in:
randogoth 2026-09-28 23:21:08 +03:00
parent d3cd0afb4f
commit 8fb5661b53
28 changed files with 975 additions and 724 deletions

155
core/src/rns/ffi.rs Normal file
View file

@ -0,0 +1,155 @@
//! `extern "C"` declarations for `shim/smolmail_rns.h` and the safe wrapper.
//!
//! Every failure here is a local error — no path, a dead link, a rejected
//! resource, a timeout — and MUST NOT be reported as a status code (upstream
//! spec sec 13.5), so the wrapper turns every shim return code into an
//! `Error` message and never into a `Response`.
use std::ffi::CString;
use std::time::Duration;
use crate::crypto::KEY_LEN;
use crate::error::Error;
use crate::rns::RnsConfig;
use crate::transport::MAX_FRAME;
mod inner {
use std::os::raw::{c_char, c_int};
extern "C" {
// shim/smolmail_rns.cpp
pub fn smolmail_rns_start(
storage_dir: *const c_char,
udp_listen_host: *const c_char,
udp_listen_port: u16,
udp_forward_host: *const c_char,
udp_forward_port: u16,
) -> c_int;
pub fn smolmail_rns_connect(
destination_hash: *const u8,
timeout_ms: u32,
link_id_out: *mut u8,
) -> c_int;
pub fn smolmail_rns_request(
request: *const u8,
request_len: usize,
timeout_ms: u32,
out: *mut u8,
cap: usize,
out_len: *mut usize,
) -> c_int;
pub fn smolmail_rns_close();
}
}
/// Starts the Reticulum stack: storage path, UDP interface, loop thread.
pub fn start(config: &RnsConfig) -> Result<(), Error> {
let storage_dir =
CString::new(config.storage_dir.as_str()).expect("storage path has no interior NUL");
let listen_host =
CString::new(config.udp_listen_host.as_str()).expect("listen host has no interior NUL");
let forward_host = config
.udp_forward_host
.as_ref()
.map(|h| CString::new(h.as_str()).expect("forward host has no interior NUL"));
let rc = unsafe {
inner::smolmail_rns_start(
storage_dir.as_ptr(),
listen_host.as_ptr(),
config.udp_listen_port,
forward_host
.as_ref()
.map(|h| h.as_ptr())
.unwrap_or(std::ptr::null()),
config.udp_forward_port,
)
};
match rc {
0 => Ok(()),
_ => Err(Error::Other(format!(
"RNS shim failed to start (code {rc}); is the UDP port free?"
))),
}
}
/// Requests a path, recalls the identity, opens a link and waits for ACTIVE,
/// returning the 16-byte link id the bind values derive from.
pub fn connect(destination: &[u8; 16], timeout: Duration) -> Result<[u8; 16], Error> {
let hex = data_encoding::HEXLOWER.encode(destination);
let mut link_id = [0u8; 16];
let rc = unsafe {
inner::smolmail_rns_connect(
destination.as_ptr(),
timeout.as_millis().min(u32::MAX as u128) as u32,
link_id.as_mut_ptr(),
)
};
match rc {
0 => Ok(link_id),
-1 => Err(Error::Other(format!(
"no path to {hex}; it may be unreachable or has never announced \
(upstream spec 13.1, 13.3)"
))),
-2 => Err(Error::Other(format!(
"path known but identity not yet learned for {hex}; try again shortly"
))),
-3 => Err(Error::Other(format!(
"could not establish a link to {hex}"
))),
_ => Err(Error::Other(format!(
"RNS connect to {hex} failed (code {rc})"
))),
}
}
/// Sends one `op u8 || body` request and returns the `status u8 || payload`
/// response.
pub fn request(request: &[u8], timeout: Duration) -> Result<Vec<u8>, Error> {
// A record is returned whole even when it alone exceeds the server's
// fetch budget (upstream spec sec 6.1), and a mailbox shared with TCP
// can hold a 768 KiB envelope (sec 13.7), so the response buffer is the
// full application frame ceiling.
let cap = MAX_FRAME + 16;
let mut out = vec![0u8; cap];
let mut out_len: usize = 0;
let rc = unsafe {
inner::smolmail_rns_request(
request.as_ptr(),
request.len(),
timeout.as_millis().min(u32::MAX as u128) as u32,
out.as_mut_ptr(),
cap,
&mut out_len,
)
};
match rc {
0 => {
out.truncate(out_len);
Ok(out)
}
-1 => Err(Error::Other("no RNS link is open".into())),
-2 => Err(Error::Other("failed to send the request over the link".into())),
-3 | -4 => Err(Error::Other(
"request failed: no response (closed link, rejected transfer, or timeout \
-- not a status code, upstream spec 13.5)".into(),
)),
-5 => Err(Error::Other("malformed response from server".into())),
-6 => Err(Error::Other(
"response larger than one application frame; refusing to truncate it".into(),
)),
_ => Err(Error::Other(format!("RNS request failed (code {rc})"))),
}
}
/// Tears the link down rather than leave it on keepalives (upstream spec sec
/// 13.10).
pub fn close() {
unsafe { inner::smolmail_rns_close() };
}
/// The destination hash length, for callers checking address shapes.
pub const DEST_LEN: usize = 16;
const _: () = assert!(DEST_LEN == 16 && KEY_LEN == 32);

71
core/src/rns/mod.rs Normal file
View file

@ -0,0 +1,71 @@
//! The RNS carrier (Smol Mail 1.2, upstream RNS.md): microReticulum behind
//! the C shim, dispatched through the same operations as TCP.
//!
//! The carrier starts lazily, on the first `smol+rns://` dial, so local-only
//! commands stay usable offline and start instantly. A client creates no
//! Reticulum identity (upstream spec sec 13.8): the shim never calls
//! `Link::identify`, and the flags below configure only the storage path and
//! the UDP interface.
pub mod ffi;
pub mod transport;
use std::path::Path;
use std::sync::{Mutex, OnceLock};
use crate::error::Error;
/// Carrier configuration, set once by the CLI and consumed on first use.
#[derive(Clone, Debug)]
pub struct RnsConfig {
/// microReticulum storage directory; created before the carrier starts.
pub storage_dir: String,
pub udp_listen_host: String,
pub udp_listen_port: u16,
/// Effectively required for a client (upstream spec sec 13.2 and the
/// plan): a client speaks first, so with no forward target the interface
/// has nowhere to send its path request.
pub udp_forward_host: Option<String>,
pub udp_forward_port: u16,
}
impl Default for RnsConfig {
fn default() -> Self {
RnsConfig {
storage_dir: "fumi.rns".to_string(),
// An ephemeral listen port avoids colliding with a local rnsd on
// 4242; replies come back to the source address.
udp_listen_host: "127.0.0.1".to_string(),
udp_listen_port: 0,
udp_forward_host: None,
udp_forward_port: 4242,
}
}
}
static CONFIG: OnceLock<RnsConfig> = OnceLock::new();
static STARTED: OnceLock<()> = OnceLock::new();
static START_MUTEX: Mutex<()> = Mutex::new(());
/// Records the carrier configuration; the first dial wins, as in a CLI the
/// flags are constant for the run.
pub fn configure(config: RnsConfig) {
let _ = CONFIG.set(config);
}
/// Starts the Reticulum stack at most once, on the first RNS dial.
pub fn ensure_started() -> Result<(), Error> {
if STARTED.get().is_some() {
return Ok(());
}
let _guard = START_MUTEX.lock().unwrap();
if STARTED.get().is_some() {
return Ok(());
}
let config = CONFIG.get().cloned().unwrap_or_default();
std::fs::create_dir_all(Path::new(&config.storage_dir))
.map_err(|e| Error::Other(format!("cannot create {}: {e}", config.storage_dir)))?;
ffi::start(&config)?;
let _ = STARTED.set(());
Ok(())
}

83
core/src/rns/transport.rs Normal file
View file

@ -0,0 +1,83 @@
//! `impl Transport` for the RNS carrier: one Reticulum link to a
//! `smolmail.server` destination, one request in flight at a time.
//!
//! There is nothing to pin here (upstream spec sec 13.4): a destination hash
//! is a 128-bit truncated hash over the server identity's public keys, and
//! Reticulum proves the link against exactly that identity. The address is
//! the pin, so `pinned()` is always true and RESOLVE results are as good as
//! the channel the address arrived on.
use std::time::Duration;
use crate::error::Error;
use crate::rns::{ensure_started, ffi};
use crate::transport::{Response, Transport, TransportBindValues};
pub struct RnsTransport {
/// The 16-byte destination hash the address carried.
destination: [u8; 16],
/// What AUTH and REGISTER signatures bind to (upstream spec sec 13.6):
/// derived from destination || link_id, replacing the Noise handshake
/// hash and the server's static key.
bind: TransportBindValues,
/// The per-request timeout a client MUST set itself (upstream spec sec
/// 13.5): Reticulum's default is derived from the round trip time and
/// covers a packet, not a FETCH page.
timeout: Duration,
}
impl RnsTransport {
/// Starts the carrier if needed, requests a path, and opens a fresh
/// link — never reused across authentications, since link_id is what
/// stops an AUTH replaying on another link (upstream spec sec 13.6).
pub fn connect(destination: &[u8; 16], timeout: Duration) -> Result<RnsTransport, Error> {
ensure_started()?;
let link_id = ffi::connect(destination, timeout)?;
Ok(RnsTransport {
destination: *destination,
bind: TransportBindValues::rns(destination, &link_id),
timeout,
})
}
/// The destination hash, as the `smol+rns://` address host part.
pub fn destination_hex(&self) -> String {
data_encoding::HEXLOWER.encode(&self.destination)
}
}
impl Transport for RnsTransport {
/// Sends `op u8 || body` and returns the `status u8 || payload` response
/// (upstream spec sec 13.5): every operation body and response payload
/// is reused byte for byte from SPEC.md sec 6.1, with only the `length
/// u32` gone, because Reticulum delimits messages itself.
fn request(&mut self, op: u8, body: &[u8]) -> Result<Response, Error> {
let mut wire = Vec::with_capacity(1 + body.len());
wire.push(op);
wire.extend_from_slice(body);
let response = ffi::request(&wire, self.timeout)?;
// A server MUST answer every registered request with a status, so an
// empty response is a peer misbehaving, not a local error.
let status = response
.first()
.copied()
.ok_or_else(|| Error::Other("empty response from server".into()))?;
Ok(Response {
status,
body: response[1..].to_vec(),
})
}
fn bind(&self) -> &TransportBindValues {
&self.bind
}
fn pinned(&self) -> bool {
// The destination hash is the pin (upstream spec sec 13.4).
true
}
fn close(&mut self) {
ffi::close();
}
}