//! Blocking fumi-core operations and the local files around them. Everything //! here runs on a worker thread; the UI state only ever sees the plain //! values these functions return. use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::atomic::AtomicBool; use std::sync::Arc; use fumi_core::address::{Address, DEFAULT_PORT}; use fumi_core::client::{FetchOptions, TrustChange}; use fumi_core::crypto::{b32, fingerprint, unb32, KEY_LEN}; use fumi_core::error::Error; use fumi_core::store::Store; use fumi_core::{account, client, export, store}; /// One message in a folder list, already opened for its metadata. #[derive(Debug, Clone)] pub struct Row { pub id: String, pub from: String, pub subject: String, pub at: i64, pub unread: bool, } /// A fully opened message. #[derive(Debug, Clone)] pub struct OpenedMsg { pub id: String, pub from: String, pub subject: String, pub time: i64, pub body: String, /// The sender's public key, base32 — the identity `accept` freezes. pub sender: String, /// The sender's self-certifying address, when the payload carried one. pub reply_to: Option, pub kept: bool, } #[derive(Debug, Clone)] pub struct Contact { pub address: String, pub key: String, pub verified: bool, /// Some(true) accepted, Some(false) blocked, None never decided. pub accepted: Option, } #[derive(Debug, Clone)] pub struct Who { pub address: String, pub key: String, pub fingerprint: String, pub rotations: u32, } /// Everything the main pages display, refreshed in one blocking pass. #[derive(Debug, Clone)] pub struct Snapshot { pub lists: Vec>, pub contacts: Vec, pub pins: Vec<(String, [u8; KEY_LEN])>, pub who: Who, } pub fn snapshot( store: &Store, master: &[u8; KEY_LEN], read: &HashSet, ) -> Result { Ok(Snapshot { lists: lists(store, master, read)?, contacts: contacts(store)?, pins: pins(store)?, who: whoami(store, master)?, }) } /// The sidebar's folders, in its order. Drafts is carrier's own folder: /// it has no table in the sealed store, its rows come from the drafts /// sidecar, and it only appears in the sidebar once it holds one. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Folder { Inbox, Requests, Sent, Drafts, } impl Folder { pub const ALL: [Folder; 3] = [Folder::Inbox, Folder::Requests, Folder::Sent]; fn table(self) -> &'static str { match self { Folder::Inbox => "inbox", Folder::Requests => "requests", Folder::Sent => "sent", // Never reached: `handle` routes draft selection away from // the store before any table lookup happens. Folder::Drafts => "drafts", } } } /// A compose set aside instead of sent, kept beside the store like the read /// marks — it is plaintext by nature until it is sealed on the way out. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Draft { pub id: String, pub to: String, pub subject: String, pub body: String, /// The message this draft replies to, as 64 hex characters. pub reply_to: Option, pub at: i64, } /// A fresh draft id: 8 random bytes as hex, unique within the sidecar. pub fn draft_id() -> String { use rand_core::RngCore; let mut bytes = [0u8; 8]; rand_core::OsRng.fill_bytes(&mut bytes); bytes.iter().map(|b| format!("{b:02x}")).collect() } /// One line per field, with backslash escapes so no field can break the /// line format: `\` becomes `\\`, a newline `\n`, a carriage return `\r`. fn line(text: &str) -> String { text.replace('\\', "\\\\") .replace('\n', "\\n") .replace('\r', "\\r") } fn unline(text: &str) -> String { let mut out = String::with_capacity(text.len()); let mut chars = text.chars(); while let Some(c) = chars.next() { if c == '\\' { match chars.next() { Some('n') => out.push('\n'), Some('r') => out.push('\r'), Some(other) => out.push(other), None => out.push('\\'), } } else { out.push(c); } } out } /// Four lines per draft: header, to, subject, body. The whole file is /// rewritten on every change, which a handful of drafts makes honest. pub fn save_drafts(path: &Path, drafts: &[Draft]) { let mut text = String::new(); for draft in drafts { let reply_to = draft.reply_to.as_deref().unwrap_or("-"); use std::fmt::Write; let _ = writeln!(text, "draft {} {} {}", draft.id, draft.at, reply_to); let _ = writeln!(text, "{}", line(&draft.to)); let _ = writeln!(text, "{}", line(&draft.subject)); let _ = writeln!(text, "{}", line(&draft.body)); } let _ = std::fs::write(path, text); } /// Reads the drafts sidecar; a missing or torn tail only costs the /// incomplete record at the end. pub fn load_drafts(path: &Path) -> Vec { let text = std::fs::read_to_string(path).unwrap_or_default(); let lines: Vec<&str> = text.lines().collect(); let mut drafts = Vec::new(); for record in lines.chunks(4) { let [header, to, subject, body] = record else { break; }; let mut parts = header.split_whitespace(); let (Some("draft"), Some(id), Some(at), reply_to) = (parts.next(), parts.next(), parts.next(), parts.next()) else { break; }; let Ok(at) = at.parse::() else { break; }; let id = id.to_string(); drafts.push(Draft { id, to: unline(to), subject: unline(subject), body: unline(body), reply_to: match reply_to { None | Some("-") => None, Some(other) => Some(other.to_string()), }, at, }); } drafts } fn explain(e: Error) -> String { e.to_string() } pub fn new_master() -> [u8; KEY_LEN] { use rand_core::RngCore; let mut master = [0u8; KEY_LEN]; rand_core::OsRng.fill_bytes(&mut master); master } pub fn seed_text(master: &[u8; KEY_LEN]) -> String { b32(master) } /// A seed as base32 or 64 hex characters, the two forms a paste can carry. pub fn parse_seed(text: &str) -> Result<[u8; KEY_LEN], String> { let text = text.trim(); let bytes = if text.len() == 64 && text.chars().all(|c| c.is_ascii_hexdigit()) { let mut raw = Vec::with_capacity(32); for pair in text.as_bytes().chunks(2) { let hi = (pair[0] as char).to_digit(16).ok_or("bad hex")?; let lo = (pair[1] as char).to_digit(16).ok_or("bad hex")?; raw.push((hi * 16 + lo) as u8); } raw } else { unb32(text).map_err(|e| explain(e))? }; bytes .try_into() .map_err(|_| "a seed is 32 bytes".to_string()) } pub fn load_key(path: &Path) -> Result<[u8; KEY_LEN], String> { let raw = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; raw.try_into() .map_err(|_| format!("{}: not a 32-byte master", path.display())) } pub fn save_key(path: &Path, master: &[u8; KEY_LEN]) -> Result<(), String> { #[cfg(unix)] let err = { use std::io::Write; use std::os::unix::fs::OpenOptionsExt; let mut file = std::fs::OpenOptions::new() .write(true) .create(true) .truncate(true) .mode(0o600) .open(path) .map_err(|e| e.to_string())?; file.write_all(master).map_err(|e| e.to_string()) }; #[cfg(not(unix))] let err = std::fs::write(path, master).map_err(|e| e.to_string()); err } pub fn account(store: &Store, master: &[u8; KEY_LEN]) -> Result { let index = store.rotations().map_err(explain)?; account::Account::new(*master, index).map_err(explain) } pub fn parse_address(text: &str) -> Result { Address::parse(text).map_err(explain) } /// A base32 public key, the form the operator publishes and pins are shown in. pub fn parse_key(text: &str) -> Result<[u8; KEY_LEN], String> { unb32(text.trim()) .map_err(explain)? .try_into() .map_err(|_| "keys are 32 bytes of base32".to_string()) } pub fn register( store: &Store, master: &[u8; KEY_LEN], addr: &Address, invite: Option<&str>, timeout: u64, ) -> Result<(), String> { let me = account(store, master)?; client::register(store, addr, &me, invite, timeout).map_err(explain) } pub fn restore( store: &Store, master: &[u8; KEY_LEN], addr: &Address, timeout: u64, ) -> Result { client::restore(store, master, addr, timeout).map_err(explain) } /// Connects without requiring a pin and reports the static key the server /// presented, so the user can decide to pin it (sec 4 trust on first use). /// None when a pin already covers this host. pub fn learn_key( store: &Store, addr: &Address, timeout: u64, ) -> Result, String> { if store .server_pin(&addr.host, addr.port) .map_err(explain)? .is_some() { return Ok(None); } let mut session = client::connect(store, addr, false, timeout).map_err(explain)?; let offered = session.unpinned_static; session.close(); Ok(offered) } pub fn pin(store: &Store, addr: &Address, key: &[u8; KEY_LEN]) -> Result<(), String> { store .pin_server(&addr.host, addr.port, key) .map_err(explain) } /// Splits a stored pin label (`host` or `host:port`) back into its parts. fn label_parts(label: &str) -> (String, u16) { match label.rsplit_once(':') { Some((host, port)) => (host.to_string(), port.parse().unwrap_or(DEFAULT_PORT)), None => (label.to_string(), DEFAULT_PORT), } } pub fn unpin(store: &Store, label: &str) -> Result<(), String> { let (host, port) = label_parts(label); store.unpin_server(&host, port).map_err(explain) } pub fn pins(store: &Store) -> Result, String> { store.pins().map_err(explain) } /// Fetches with the default cursor policy, returning a one-line summary. pub fn fetch( store: &Store, master: &[u8; KEY_LEN], cancel: Arc, timeout: u64, ) -> Result { let me = account(store, master)?; let opts = FetchOptions { keep: false, reset: false, timeout, dial: None, cancel: Some(&cancel), }; let fetched = client::fetch_with(store, &me, opts).map_err(explain)?; let mut note = format!("{} fetched, {} stored", fetched.total, fetched.stored); if !fetched.rejected.is_empty() { note.push_str(&format!(", {} rejected", fetched.rejected.len())); } if fetched.cancelled { note.push_str(" (cancelled)"); } Ok(note) } pub fn send( store: &Store, master: &[u8; KEY_LEN], to: &str, subject: Option<&str>, body: &str, reply_to: Option<&str>, timeout: u64, ) -> Result { let addr = parse_address(to)?; let me = account(store, master)?; let draft = client::SendDraft { address: &addr, text: body.to_string(), subject, reply_to, headers: &[], anonymous: false, no_pad: false, }; let sent = client::send(store, &me, &draft, timeout).map_err(explain)?; let change = match sent.change { TrustChange::New { unverified: true } => { "; new contact learned from an unpinned server (unverified)" } TrustChange::New { unverified: false } => "; new contact learned (trust on first use)", TrustChange::Rotated => "; contact rotated to a new key (chain verified)", TrustChange::None => "", }; let mut note = format!("sent to {} ({} bytes)", addr.short(), sent.bytes); if sent.token_used { note.push_str(", accept token attached"); } note.push_str(change); if let Some(warning) = sent.warning { note.push_str(&format!("; {warning}")); } Ok(note) } /// Removes one message locally, and on the server when `everywhere` is set. pub fn delete( store: &Store, master: &[u8; KEY_LEN], id: &str, folder: Folder, everywhere: bool, timeout: u64, ) -> Result<(), String> { let raw = hex32(id)?; store.delete_local(folder.table(), &raw).map_err(explain)?; if everywhere { let me = account(store, master)?; client::delete(store, &me, &[raw], None, timeout).map_err(explain)?; } Ok(()) } /// Admits a correspondent to the main tier and pushes the changed token set. pub fn accept_contact( store: &Store, master: &[u8; KEY_LEN], address: &str, identity: &[u8; KEY_LEN], timeout: u64, ) -> Result<(), String> { store.accept(address, identity).map_err(explain)?; let me = account(store, master)?; client::push_tokens(store, &me, None, timeout).map_err(explain)?; Ok(()) } /// Blocks a correspondent and pushes the changed token set. pub fn block_contact( store: &Store, master: &[u8; KEY_LEN], address: &str, timeout: u64, ) -> Result { let blocked = store.block(address).map_err(explain)?; let me = account(store, master)?; client::push_tokens(store, &me, None, timeout).map_err(explain)?; Ok(blocked) } /// Adds a contact from a self-certifying `smol://` address. pub fn import_contact(store: &Store, uri: &str) -> Result<(String, String), String> { let addr = parse_address(uri)?; let key = addr .identity .ok_or("import needs a self-certifying smol:// address carrying a key")?; store .save_contact(&addr.short(), &key, true) .map_err(explain)?; Ok((addr.short(), b32(&key))) } pub fn contacts(store: &Store) -> Result, String> { Ok(store .contact_rows() .map_err(explain)? .into_iter() .map(|(address, identity, verified, accepted)| Contact { address, key: b32(&identity), verified, accepted: accepted.map(|state| state == 1), }) .collect()) } pub fn rotate( store: &Store, master: &[u8; KEY_LEN], timeout: u64, ) -> Result<(u32, String), String> { let me = account(store, master)?; let rotated = client::rotate(store, &me, None, timeout).map_err(explain)?; Ok((store.rotations().map_err(explain)?, b32(&rotated.new_pk))) } /// Writes the gsmol backup beside the store and returns it for the clipboard. pub fn export_backup( store: &Store, master: &[u8; KEY_LEN], dir: &Path, ) -> Result<(PathBuf, String), String> { let text = export::export(store, master).map_err(explain)?; let path = dir.join(format!("carrier-backup-{}.gsmol", store::now())); std::fs::write(&path, &text).map_err(|e| format!("{}: {e}", path.display()))?; Ok((path, text)) } pub fn import_backup(store: &Store, master: &[u8; KEY_LEN], text: &str) -> Result { let summary = export::import(store, master, text).map_err(explain)?; Ok(summary.to_string()) } fn hex32(text: &str) -> Result<[u8; KEY_LEN], String> { let raw = if text.len() == 64 && text.chars().all(|c| c.is_ascii_hexdigit()) { (0..32) .map(|i| u8::from_str_radix(&text[i * 2..i * 2 + 2], 16)) .collect::, _>>() .map_err(|e| e.to_string())? } else { return Err("message ids are 64 hex characters".to_string()); }; raw.try_into() .map_err(|_| "message ids are 32 bytes".to_string()) } /// Lists one folder as rows, marking unread against the local read set. fn rows(store: &Store, me: &account::Account, folder: Folder, read: &HashSet) -> Vec { store .mail(folder.table()) .unwrap_or_default() .into_iter() .filter_map(|stored| { let id = b32_hex(&stored.id); let described = client::describe(store, me, &stored).ok()?; let from = match folder { Folder::Sent => stored.recipient.clone().unwrap_or_default(), _ => described.from, }; let unread = !read.contains(&id); Some(Row { id, from, subject: described.subject, at: described.time, unread, }) }) .collect() } /// All three folders at once, so the sidebar counts never go stale. pub fn lists( store: &Store, master: &[u8; KEY_LEN], read: &HashSet, ) -> Result>, String> { let me = account(store, master)?; Ok(Folder::ALL .iter() .map(|folder| rows(store, &me, *folder, read)) .collect()) } pub fn open( store: &Store, master: &[u8; KEY_LEN], id: &str, folder: Folder, ) -> Result { let raw = hex32(id)?; let me = account(store, master)?; let stored = store .mail(folder.table()) .map_err(explain)? .into_iter() .find(|m| m.id == raw) .ok_or_else(|| "message not found".to_string())?; let described = client::describe(store, &me, &stored).map_err(explain)?; Ok(OpenedMsg { id: id.to_string(), from: match folder { Folder::Sent => stored.recipient.clone().unwrap_or_default(), _ => described.from, }, subject: described.subject, time: described.time, body: described.text, sender: b32(&described.sender), reply_to: described.fields.get("reply-to").cloned(), kept: stored.kept, }) } pub fn whoami(store: &Store, master: &[u8; KEY_LEN]) -> Result { let me = account(store, master)?; let address = store .account() .map_err(explain)? .map(|addr| addr.short()) .unwrap_or_default(); Ok(Who { address, key: b32(&me.me().pk()), fingerprint: fingerprint(&me.me().pk()), rotations: me.index(), }) } fn b32_hex(raw: &[u8; KEY_LEN]) -> String { raw.iter().map(|b| format!("{b:02x}")).collect() } /// UTC stamp for an epoch-second timestamp; mail is not minute-critical and /// the writer's zone is unknown, so UTC is the honest display. pub fn when(at: i64) -> String { let days = at.div_euclid(86400); let secs = at.rem_euclid(86400); let (year, month, day) = civil_from_days(days); format!( "{year:04}-{month:02}-{day:02} {:02}:{:02}", secs / 3600, (secs % 3600) / 60 ) } /// Days since 1970-01-01 to a civil date (Howard Hinnant's algorithm). fn civil_from_days(z: i64) -> (i64, u32, u32) { let z = z + 719468; let era = if z >= 0 { z } else { z - 146096 } / 146097; let doe = z - era * 146097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let year = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let day = (doy - (153 * mp + 2) / 5 + 1) as u32; let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; (if month <= 2 { year + 1 } else { year }, month, day) } #[cfg(test)] mod tests { use super::*; use fumi_core::message::{build_frontmatter, message_id, seal}; use fumi_core::store::now; #[test] fn drafts_round_trip_through_the_sidecar() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("drafts.txt"); let drafts = vec![ Draft { id: draft_id(), to: "smol://alice@example.org/KEY".into(), subject: "line breaks\nand \\backslashes\r".into(), body: "a draft\n\nwith paragraphs\nand a \\ literal".into(), reply_to: Some("ab".repeat(32)), at: now(), }, Draft { id: draft_id(), to: "bob@example.org".into(), subject: "no reply".into(), body: String::new(), reply_to: None, at: now(), }, ]; save_drafts(&path, &drafts); assert_eq!(load_drafts(&path), drafts); // A torn tail costs only its own incomplete record. let text = std::fs::read_to_string(&path).unwrap(); let torn = text.lines().take(6).collect::>().join("\n") + "\n"; std::fs::write(&path, torn).unwrap(); assert_eq!(load_drafts(&path).len(), 1); } fn master() -> [u8; KEY_LEN] { let mut seed = [0u8; KEY_LEN]; for (i, b) in seed.iter_mut().enumerate() { *b = i as u8; } seed } fn setup() -> Store { Store::open_in_memory().expect("in-memory store") } #[test] fn seed_round_trips_through_base32_and_hex() { let m = master(); assert_eq!(parse_seed(&seed_text(&m)).unwrap(), m); let hex: String = m.iter().map(|b| format!("{b:02x}")).collect(); assert_eq!(parse_seed(&hex).unwrap(), m); assert!(parse_seed("not a seed").is_err()); assert!(parse_seed(&hex[..63]).is_err()); } #[test] fn key_parsing_round_trips() { let m = master(); assert_eq!(parse_key(&b32(&m)).unwrap(), m); assert!(parse_key("short").is_err()); } /// A sealed self-copy in the store surfaces as a row, opens with its /// frontmatter split, and flips to read against the marks set. #[test] fn list_and_open_round_trip() { let store = setup(); let m = master(); let me = account(&store, &m).unwrap(); let addr = Address::parse("me@localhost").unwrap(); store.set_account(&addr).unwrap(); let body = build_frontmatter( &[("Subject", "hello"), ("Reply-To", &addr.uri(&me.me().pk()))], "the body text", ); let envelope = seal(me.me(), &me.me().pk(), body.as_bytes(), now(), true).unwrap(); let id = message_id(&envelope); store .store_inbox(&id, &envelope, now(), false, false) .unwrap(); let id_hex: String = id.iter().map(|b| format!("{b:02x}")).collect(); let empty = HashSet::new(); let folders = lists(&store, &m, &empty).unwrap(); let inbox = &folders[0]; assert_eq!(inbox.len(), 1); assert_eq!(inbox[0].subject, "hello"); assert_eq!(inbox[0].from, "me@localhost"); assert!(inbox[0].unread); let opened = open(&store, &m, &id_hex, Folder::Inbox).unwrap(); assert_eq!(opened.subject, "hello"); assert_eq!(opened.body, "the body text"); assert!(opened.reply_to.is_some()); let mut read = HashSet::new(); read.insert(id_hex); let folders = lists(&store, &m, &read).unwrap(); assert!(!folders[0][0].unread); } #[test] fn import_contact_saves_verified() { let store = setup(); let me = account(&store, &master()).unwrap(); let addr = Address::parse("me@localhost").unwrap(); let uri = addr.uri(&me.me().pk()); import_contact(&store, &uri).unwrap(); let rows = contacts(&store).unwrap(); assert_eq!(rows.len(), 1); assert_eq!(rows[0].address, "me@localhost"); assert!(rows[0].verified); // A short form carries no key, so importing it is an error. assert!(import_contact(&store, "me@localhost").is_err()); } #[test] fn backup_round_trips() { let dir = tempfile::tempdir().unwrap(); let store = setup(); let m = master(); let me = account(&store, &m).unwrap(); let addr = Address::parse("me@localhost").unwrap(); store.set_account(&addr).unwrap(); let body = build_frontmatter(&[("Subject", "kept")], "sealed body"); let envelope = seal(me.me(), &me.me().pk(), body.as_bytes(), now(), true).unwrap(); let id = message_id(&envelope); store .store_inbox(&id, &envelope, now(), false, false) .unwrap(); let (path, text) = export_backup(&store, &m, dir.path()).unwrap(); assert!(path.exists()); assert!(!text.is_empty()); let fresh = setup(); let summary = import_backup(&fresh, &m, &text).unwrap(); assert!(summary.contains("1 messages")); let empty = HashSet::new(); let folders = lists(&fresh, &m, &empty).unwrap(); assert_eq!(folders[0].len(), 1); assert_eq!(folders[0][0].subject, "kept"); } #[test] fn unpin_splits_the_label() { let store = setup(); let addr = Address::parse("me@localhost:1962").unwrap(); let key = master(); store.pin_server(&addr.host, addr.port, &key).unwrap(); assert_eq!(pin(&store, &addr, &key), Ok(())); let all = pins(&store).unwrap(); assert_eq!(all.len(), 1); assert_eq!(all[0].0, "localhost:1962"); unpin(&store, "localhost:1962").unwrap(); assert!(pins(&store).unwrap().is_empty()); } #[test] fn stamps_are_utc_civil_dates() { assert_eq!(when(0), "1970-01-01 00:00"); assert_eq!(when(1_759_279_361), "2025-10-01 00:42"); // Negative stays well-defined (pre-1970). assert_eq!(when(-1), "1969-12-31 23:59"); } }