carrier/src/ops.rs

807 lines
25 KiB
Rust

//! 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<String>,
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<bool>,
}
#[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<Vec<Row>>,
pub contacts: Vec<Contact>,
pub pins: Vec<(String, [u8; KEY_LEN])>,
pub who: Who,
}
pub fn snapshot(
store: &Store,
master: &[u8; KEY_LEN],
read: &HashSet<String>,
) -> Result<Snapshot, String> {
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<String>,
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<Draft> {
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::<i64>() 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<account::Account, String> {
let index = store.rotations().map_err(explain)?;
account::Account::new(*master, index).map_err(explain)
}
pub fn parse_address(text: &str) -> Result<Address, String> {
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<u32, String> {
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<Option<[u8; KEY_LEN]>, 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<Vec<(String, [u8; KEY_LEN])>, 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<AtomicBool>,
timeout: u64,
) -> Result<String, String> {
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<String, String> {
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<bool, String> {
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<Vec<Contact>, 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<String, String> {
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::<Result<Vec<_>, _>>()
.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<String>) -> Vec<Row> {
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<String>,
) -> Result<Vec<Vec<Row>>, 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<OpenedMsg, String> {
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<Who, String> {
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::<Vec<_>>().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");
}
}