807 lines
25 KiB
Rust
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");
|
|
}
|
|
}
|