refactor: split the library into fumi-core and make it embeddable
This commit is contained in:
parent
d3cd0afb4f
commit
8fb5661b53
28 changed files with 975 additions and 724 deletions
155
core/src/rns/ffi.rs
Normal file
155
core/src/rns/ffi.rs
Normal 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
71
core/src/rns/mod.rs
Normal 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
83
core/src/rns/transport.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue