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
22
cli/Cargo.toml
Normal file
22
cli/Cargo.toml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
[package]
|
||||
name = "fumi"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Smol Mail client"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[[bin]]
|
||||
name = "fumi"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
# Passes the core's carrier feature through; addresses select the carrier by
|
||||
# scheme, so enabling it only affects smol+rns:// dials.
|
||||
rns = ["fumi-core/rns"]
|
||||
|
||||
[dependencies]
|
||||
fumi-core = { path = "../core" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
anyhow = "1"
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
data-encoding = "2"
|
||||
863
cli/src/main.rs
Normal file
863
cli/src/main.rs
Normal file
|
|
@ -0,0 +1,863 @@
|
|||
//! The command line: one master per store, selected by `--key` and `--db`.
|
||||
//! A second identity is a second pair of files.
|
||||
|
||||
use std::io::{IsTerminal, Read, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
use fumi_core as fumi;
|
||||
|
||||
use fumi::account::{identity_seed, Account};
|
||||
use fumi::address::Address;
|
||||
use fumi::client::{self, Pushed, SendDraft, TrustChange};
|
||||
use fumi::crypto::{b32, fingerprint, unb32, KEY_LEN};
|
||||
|
||||
use fumi::store::Store;
|
||||
use fumi::transport::ID_LEN;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(about = "Smol Mail client")]
|
||||
struct Cli {
|
||||
/// Master secret file
|
||||
#[arg(long, global = true, default_value = "identity.key")]
|
||||
key: PathBuf,
|
||||
/// Local state and mail
|
||||
#[arg(long, global = true, default_value = "fumi.db")]
|
||||
db: PathBuf,
|
||||
/// Network timeout in seconds
|
||||
#[arg(long, global = true, default_value_t = 30)]
|
||||
timeout: u64,
|
||||
#[cfg(feature = "rns")]
|
||||
#[command(flatten)]
|
||||
rns: RnsArgs,
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
/// RNS carrier flags; addresses select the carrier by scheme, so these apply
|
||||
/// only to `smol+rns://` dials.
|
||||
#[cfg(feature = "rns")]
|
||||
#[derive(clap::Args)]
|
||||
struct RnsArgs {
|
||||
/// UDP interface to listen on, host[:port]
|
||||
#[arg(long = "rns-udp", global = true, default_value = "127.0.0.1:0")]
|
||||
udp: String,
|
||||
/// UDP forward target, host[:port]; a client speaks first, so this should
|
||||
/// point at an rnsd or at the server's UDP interface
|
||||
#[arg(long = "rns-udp-forward", global = true, value_name = "HOST:PORT")]
|
||||
udp_forward: Option<String>,
|
||||
/// Reticulum storage directory
|
||||
#[arg(long = "rns-storage", global = true, default_value = "fumi.rns")]
|
||||
storage: String,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Create a master secret
|
||||
Keygen {
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
/// Show this identity
|
||||
Whoami,
|
||||
/// Recover local state from the master alone
|
||||
Restore { address: String },
|
||||
/// Advance to the next identity, signing the change
|
||||
Rotate,
|
||||
/// Pin a server's Noise static key (TCP only)
|
||||
Trust {
|
||||
host: String,
|
||||
key_b32: String,
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
/// Bind this identity to a username
|
||||
Register {
|
||||
address: String,
|
||||
/// Invite token, if the server requires one
|
||||
#[arg(long)]
|
||||
invite: Option<String>,
|
||||
},
|
||||
/// Look up a contact's key, walk the chain, pin it
|
||||
Resolve { address: String },
|
||||
/// Add a contact from a self-certifying address
|
||||
Import { uri: String },
|
||||
/// List known keys and how each was learned
|
||||
Contacts,
|
||||
/// Issue an accept token, admit to the main tier, sync the set
|
||||
Accept { address: String },
|
||||
/// Withdraw a contact's accept token, sync the set
|
||||
Block { address: String },
|
||||
/// Seal and deliver a message
|
||||
Send {
|
||||
address: String,
|
||||
#[arg(long)]
|
||||
subject: Option<String>,
|
||||
/// Message text; stdin is read when neither --body nor --file is given
|
||||
#[arg(long)]
|
||||
body: Option<String>,
|
||||
/// Read the message text from this file ("-" for stdin)
|
||||
#[arg(long)]
|
||||
file: Option<String>,
|
||||
/// Extra `Key: value` frontmatter field; repeatable
|
||||
#[arg(long = "header", value_name = "KEY:VALUE")]
|
||||
headers: Vec<String>,
|
||||
/// The id this message replies to, 64 hex characters
|
||||
#[arg(long = "reply-to", value_name = "ID")]
|
||||
reply_to: Option<String>,
|
||||
/// Omit the Reply-To field carrying this address
|
||||
#[arg(long)]
|
||||
anonymous: bool,
|
||||
/// Do not pad the payload to 1 KiB
|
||||
#[arg(long = "no-pad")]
|
||||
no_pad: bool,
|
||||
},
|
||||
/// Retrieve, verify, store and acknowledge mail
|
||||
Fetch {
|
||||
/// Do not delete from the server; remember the cursor instead
|
||||
#[arg(long)]
|
||||
keep: bool,
|
||||
/// Forget the cursor and page again
|
||||
#[arg(long)]
|
||||
reset: bool,
|
||||
},
|
||||
/// Remove messages from the server explicitly
|
||||
Delete {
|
||||
/// Message ids, or unambiguous prefixes
|
||||
ids: Vec<String>,
|
||||
},
|
||||
/// List stored mail
|
||||
List {
|
||||
#[arg(long)]
|
||||
sent: bool,
|
||||
/// Mail that arrived without an accept token
|
||||
#[arg(long)]
|
||||
requests: bool,
|
||||
},
|
||||
/// Show one message
|
||||
Read {
|
||||
id: String,
|
||||
#[arg(long)]
|
||||
sent: bool,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
if let Err(e) = run(cli) {
|
||||
eprintln!("error: {e}");
|
||||
hint(e.downcast_ref::<fumi::error::Error>());
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// The remedy for the errors whose remedy is a command to run. The library
|
||||
/// reports the distinction; only the CLI knows the command.
|
||||
fn hint(error: Option<&fumi::error::Error>) {
|
||||
match error {
|
||||
Some(fumi::error::Error::NotPinned { host }) => eprintln!(
|
||||
"obtain the key from the operator through a trusted channel, then:\n fumi trust {host} <key>"
|
||||
),
|
||||
Some(fumi::error::Error::KeyChanged { address, offered, .. }) => {
|
||||
if let Ok(addr) = Address::parse(address) {
|
||||
eprintln!("verify out of band, then:\n fumi import {}", addr.uri(offered));
|
||||
}
|
||||
}
|
||||
Some(fumi::error::Error::NotRegistered) => {
|
||||
eprintln!("run: fumi register <user@host>")
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn run(cli: Cli) -> Result<()> {
|
||||
let store = Store::open(&cli.db)?;
|
||||
#[cfg(feature = "rns")]
|
||||
fumi::rns::configure(rns_config(&cli));
|
||||
match &cli.command {
|
||||
Command::Keygen { force } => keygen(&cli.key, *force),
|
||||
Command::Whoami => whoami(&cli.key, &store),
|
||||
Command::Restore { address } => restore(&cli.key, &store, address, cli.timeout),
|
||||
Command::Rotate => rotate(&cli.key, &store, cli.timeout),
|
||||
Command::Trust {
|
||||
host,
|
||||
key_b32,
|
||||
force,
|
||||
} => trust(&store, host, key_b32, *force),
|
||||
Command::Register { address, invite } => {
|
||||
register(&cli.key, &store, address, invite.as_deref(), cli.timeout)
|
||||
}
|
||||
Command::Resolve { address } => resolve(&store, address, cli.timeout),
|
||||
Command::Import { uri } => import(&store, uri),
|
||||
Command::Contacts => contacts(&store),
|
||||
Command::Accept { address } => accept(&cli.key, &store, address, cli.timeout),
|
||||
Command::Block { address } => block(&cli.key, &store, address, cli.timeout),
|
||||
Command::Send { .. } => send(&cli, &store),
|
||||
Command::Fetch { keep, reset } => fetch(&cli.key, &store, *keep, *reset, cli.timeout),
|
||||
Command::Delete { ids } => delete(&cli.key, &store, ids, cli.timeout),
|
||||
Command::List { sent, requests } => list(&cli.key, &store, *sent, *requests),
|
||||
Command::Read { id, sent } => read(&cli.key, &store, id, *sent),
|
||||
}
|
||||
}
|
||||
|
||||
fn warn(message: &str) {
|
||||
eprintln!("warning: {message}");
|
||||
}
|
||||
|
||||
fn load_master(path: &PathBuf) -> Result<[u8; KEY_LEN]> {
|
||||
let bytes = std::fs::read(path).map_err(|_| {
|
||||
anyhow!(format!(
|
||||
"no identity at {}; run: fumi keygen",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
bytes.try_into().map_err(|v: Vec<u8>| {
|
||||
anyhow!(format!(
|
||||
"master secret at {} is {} bytes, expected {KEY_LEN}",
|
||||
path.display(),
|
||||
v.len()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// The master from disk plus the rotation index from local state.
|
||||
fn load_account(path: &PathBuf, store: &Store) -> Result<Account> {
|
||||
Ok(Account::new(load_master(path)?, store.rotations()?)?)
|
||||
}
|
||||
|
||||
/// Writes a secret with mode 0600 before any bytes land, so it is never
|
||||
/// briefly world-readable.
|
||||
fn write_secret(path: &PathBuf, secret: &[u8]) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
let file = {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(path);
|
||||
let mut file = file.with_context(|| format!("cannot write {}", path.display()))?;
|
||||
file.write_all(secret)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn keygen(path: &PathBuf, force: bool) -> Result<()> {
|
||||
if path.exists() && !force {
|
||||
return Err(anyhow!(format!(
|
||||
"{} exists; refusing to overwrite (use --force)",
|
||||
path.display()
|
||||
))
|
||||
.into());
|
||||
}
|
||||
let mut master = [0u8; KEY_LEN];
|
||||
use rand_core::RngCore;
|
||||
rand_core::OsRng.fill_bytes(&mut master);
|
||||
write_secret(path, &master)?;
|
||||
let account = Account::new(master, 0)?;
|
||||
println!(
|
||||
"master: {} (back this up; it is the only secret)",
|
||||
path.display()
|
||||
);
|
||||
println!("public key: {}", b32(&account.me().pk()));
|
||||
println!("fingerprint: {}", fingerprint(&account.me().pk()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn whoami(key: &PathBuf, store: &Store) -> Result<()> {
|
||||
let account = load_account(key, store)?;
|
||||
println!("public key: {}", b32(&account.me().pk()));
|
||||
println!("fingerprint: {}", fingerprint(&account.me().pk()));
|
||||
match store.account()? {
|
||||
Some(addr) => {
|
||||
println!("address: {}", addr.short());
|
||||
println!("uri: {}", addr.uri(&account.me().pk()));
|
||||
}
|
||||
None => println!("address: (not registered)"),
|
||||
}
|
||||
if account.index() > 0 {
|
||||
println!(
|
||||
"rotations: {} (earlier keys derived on demand)",
|
||||
account.index()
|
||||
);
|
||||
}
|
||||
let live = store
|
||||
.contact_rows()?
|
||||
.iter()
|
||||
.filter(|(_, _, _, active)| *active == Some(1))
|
||||
.count();
|
||||
let sync = store.sync_ok()?;
|
||||
println!(
|
||||
"accepted: {live} correspondent(s){}",
|
||||
if sync {
|
||||
String::new()
|
||||
} else {
|
||||
", not pushed to the server until rebuilt".to_string()
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restore(key: &PathBuf, store: &Store, address: &str, timeout: u64) -> Result<()> {
|
||||
let master = load_master(key)?;
|
||||
let addr = Address::parse(address)?;
|
||||
let index = client::restore(store, &master, &addr, timeout)?;
|
||||
let identity = fumi::account::Identity::from_seed(identity_seed(&master, index));
|
||||
println!("restored {} at rotation {index}", addr.short());
|
||||
println!("public key: {}", b32(&identity.pk()));
|
||||
println!(
|
||||
"Your accepted correspondents are still live on the server; `accept` them again only \
|
||||
when you are ready to replace that set."
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rotate(key: &PathBuf, store: &Store, timeout: u64) -> Result<()> {
|
||||
let account = load_account(key, store)?;
|
||||
let rotated = client::rotate(store, &account, timeout)?;
|
||||
let addr = store.account()?.expect("rotate checked for an account");
|
||||
println!("rotated {}", addr.short());
|
||||
println!("new key: {}", b32(&rotated.new_pk));
|
||||
println!("fingerprint: {}", fingerprint(&rotated.new_pk));
|
||||
println!("share: {}", addr.uri(&rotated.new_pk));
|
||||
println!();
|
||||
println!("Tell your contacts; they will accept the change from the signed chain.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn trust(store: &Store, host: &str, key_b32: &str, force: bool) -> Result<()> {
|
||||
let key: [u8; KEY_LEN] = unb32(key_b32)?.try_into().map_err(|v: Vec<u8>| {
|
||||
anyhow!(format!(
|
||||
"server key is {} bytes, expected {KEY_LEN}",
|
||||
v.len()
|
||||
))
|
||||
})?;
|
||||
// A pin is per server, and the reference client strips any :port the
|
||||
// same way its address grammar does: at the first colon.
|
||||
let host = host.split(':').next().unwrap_or(host);
|
||||
match store.server_pin(host)? {
|
||||
Some(old) if old != key && !force => {
|
||||
return Err(anyhow!(format!(
|
||||
"{host} is already pinned to {}; use --force to replace",
|
||||
b32(&old)
|
||||
))
|
||||
.into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
store.pin_server(host, &key)?;
|
||||
println!("pinned {host} {}", b32(&key));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn register(
|
||||
key: &PathBuf,
|
||||
store: &Store,
|
||||
address: &str,
|
||||
invite: Option<&str>,
|
||||
timeout: u64,
|
||||
) -> Result<()> {
|
||||
let account = load_account(key, store)?;
|
||||
let addr = Address::parse(address)?;
|
||||
client::register(store, &addr, &account, invite, timeout)?;
|
||||
println!("registered {}", addr.short());
|
||||
println!("share: {}", addr.uri(&account.me().pk()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve(store: &Store, address: &str, timeout: u64) -> Result<()> {
|
||||
let addr = Address::parse(address)?;
|
||||
if addr.identity.is_some() {
|
||||
return Err(
|
||||
anyhow!("that address already carries a key; use `import` instead").into(),
|
||||
);
|
||||
}
|
||||
let mut session = client::connect(store, &addr, false, timeout)?;
|
||||
if let Some(key) = session.unpinned_static {
|
||||
warn(&format!(
|
||||
"{} is not pinned; its key is {}",
|
||||
addr.host,
|
||||
b32(&key)
|
||||
));
|
||||
}
|
||||
let transport = session.transport();
|
||||
let (identity, change) = client::trust_key(store, transport, &addr)?;
|
||||
session.close();
|
||||
match change {
|
||||
TrustChange::New { unverified } => println!(
|
||||
"{} {}\n pinned (trust on first use{})",
|
||||
addr.short(),
|
||||
b32(&identity),
|
||||
if unverified {
|
||||
", UNVERIFIED server"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
),
|
||||
TrustChange::Rotated => {
|
||||
warn(&format!(
|
||||
"{} rotated its key; a signed chain confirms it",
|
||||
addr.short()
|
||||
));
|
||||
println!(" now {}", b32(&identity));
|
||||
}
|
||||
TrustChange::None => println!("{} {}", addr.short(), b32(&identity)),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn import(store: &Store, uri: &str) -> Result<()> {
|
||||
let addr = Address::parse(uri)?;
|
||||
let key = addr
|
||||
.identity
|
||||
.ok_or_else(|| anyhow!("import needs a self-certifying address carrying a key"))?;
|
||||
store.save_contact(&addr.short(), &key, true)?;
|
||||
println!("imported {} {} (verified)", addr.short(), b32(&key));
|
||||
println!("fingerprint: {}", fingerprint(&key));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn contacts(store: &Store) -> Result<()> {
|
||||
let rows = store.contact_rows()?;
|
||||
if rows.is_empty() {
|
||||
println!("no contacts");
|
||||
return Ok(());
|
||||
}
|
||||
let width = rows.iter().map(|(a, _, _, _)| a.len()).max().unwrap_or(0);
|
||||
for (address, identity, verified, active) in rows {
|
||||
let state = match active {
|
||||
Some(1) => "accepted",
|
||||
Some(_) => "blocked",
|
||||
None => "",
|
||||
};
|
||||
println!(
|
||||
"{address:<width$} {} {:<8} {state}",
|
||||
b32(&identity),
|
||||
if verified { "verified" } else { "tofu" }
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An address plus the key we hold for it, for commands naming a contact.
|
||||
fn target_contact(store: &Store, text: &str) -> Result<(String, [u8; KEY_LEN])> {
|
||||
let addr = Address::parse(text)?;
|
||||
if let Some(key) = addr.identity {
|
||||
return Ok((addr.short(), key));
|
||||
}
|
||||
match store.contact(&addr.short())? {
|
||||
Some((key, _)) => Ok((addr.short(), key)),
|
||||
None => Err(anyhow!(format!(
|
||||
"no key for {}; run `resolve` or `import` first",
|
||||
addr.short()
|
||||
))
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn accept(key: &PathBuf, store: &Store, address: &str, timeout: u64) -> Result<()> {
|
||||
let account = load_account(key, store)?;
|
||||
let (address, identity) = target_contact(store, address)?;
|
||||
if !store.sync_ok()? {
|
||||
warn(
|
||||
"this client's accepted set was not restored; from now on it replaces the server's, \
|
||||
so re-accept everyone you still correspond with",
|
||||
);
|
||||
}
|
||||
store.accept(&address, &identity)?;
|
||||
println!("accepted {address}; its token travels in your next message to them");
|
||||
match client::push_tokens(store, &account, timeout)? {
|
||||
Pushed::NotRegistered => {
|
||||
warn("not registered; the set will be pushed with your first fetch")
|
||||
}
|
||||
Pushed::Held(held) => println!("server now holds {held} accept token(s)"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn block(key: &PathBuf, store: &Store, address: &str, timeout: u64) -> Result<()> {
|
||||
let account = load_account(key, store)?;
|
||||
let (address, _) = target_contact(store, address)?;
|
||||
if !store.block(&address)? {
|
||||
return Err(anyhow!(format!("{address} was never accepted")));
|
||||
}
|
||||
println!("blocked {address}; their mail lands in requests from their next message on");
|
||||
match client::push_tokens(store, &account, timeout)? {
|
||||
Pushed::NotRegistered => {
|
||||
warn("not registered; the set will be pushed with your first fetch")
|
||||
}
|
||||
Pushed::Held(held) => println!("server now holds {held} accept token(s)"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send(cli: &Cli, store: &Store) -> Result<()> {
|
||||
let Command::Send {
|
||||
address,
|
||||
subject,
|
||||
body,
|
||||
file,
|
||||
headers,
|
||||
reply_to,
|
||||
anonymous,
|
||||
no_pad,
|
||||
} = &cli.command
|
||||
else {
|
||||
unreachable!("send dispatches only from the Send command")
|
||||
};
|
||||
let account = load_account(&cli.key, store)?;
|
||||
let addr = Address::parse(address)?;
|
||||
let text = match (body, file) {
|
||||
(Some(text), _) => text.clone(),
|
||||
(None, Some(path)) if path == "-" => read_stdin()?,
|
||||
(None, Some(path)) => {
|
||||
std::fs::read_to_string(path).with_context(|| format!("cannot read {path}"))?
|
||||
}
|
||||
(None, None) if !std::io::stdin().is_terminal() => read_stdin()?,
|
||||
(None, None) => {
|
||||
return Err(anyhow!("no message body; pass --body or pipe it on stdin").into())
|
||||
}
|
||||
};
|
||||
if let Some(reply) = reply_to {
|
||||
if reply.len() != 64 || !reply.bytes().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(
|
||||
anyhow!("--reply-to must be a message id: 64 hex characters"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Extra headers must be valid `Key: value` fields (sec 5.5).
|
||||
let mut extra: Vec<(String, String)> = Vec::new();
|
||||
for raw in headers {
|
||||
let Some((k, v)) = raw.split_once(':') else {
|
||||
return Err(
|
||||
anyhow!(format!("{raw:?} is not a valid `Key: value` header")),
|
||||
);
|
||||
};
|
||||
if !valid_frontmatter_key(k.trim()) {
|
||||
return Err(
|
||||
anyhow!(format!("{raw:?} is not a valid `Key: value` header")),
|
||||
);
|
||||
}
|
||||
extra.push((k.trim().to_string(), v.trim().to_string()));
|
||||
}
|
||||
let borrowed: Vec<(&str, &str)> = extra
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||
.collect();
|
||||
|
||||
let draft = SendDraft {
|
||||
address: &addr,
|
||||
text,
|
||||
subject: subject.as_deref(),
|
||||
reply_to: reply_to.as_deref(),
|
||||
headers: &borrowed,
|
||||
anonymous: *anonymous,
|
||||
no_pad: *no_pad,
|
||||
};
|
||||
let sent = client::send(store, &account, &draft, cli.timeout)?;
|
||||
if let Some(warning) = &sent.warning {
|
||||
warn(warning);
|
||||
}
|
||||
match sent.change {
|
||||
TrustChange::New { unverified } => warn(&format!(
|
||||
"{} is new; its key was learned by trust on first use{}",
|
||||
addr.short(),
|
||||
if unverified {
|
||||
" over an UNVERIFIED server"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
)),
|
||||
TrustChange::Rotated => warn(&format!(
|
||||
"{} rotated its key; a signed chain confirms it",
|
||||
addr.short()
|
||||
)),
|
||||
TrustChange::None => {}
|
||||
}
|
||||
println!(
|
||||
"sent {} to {} ({} bytes{})",
|
||||
&hex(&sent.id)[..16],
|
||||
addr.short(),
|
||||
sent.bytes,
|
||||
if sent.token_used { ", accepted" } else { "" }
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_stdin() -> Result<String> {
|
||||
let mut text = String::new();
|
||||
std::io::stdin()
|
||||
.read_to_string(&mut text)
|
||||
.context("cannot read stdin")?;
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
fn valid_frontmatter_key(key: &str) -> bool {
|
||||
!key.is_empty()
|
||||
&& key.len() <= 64
|
||||
&& key.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-')
|
||||
}
|
||||
|
||||
fn fetch(key: &PathBuf, store: &Store, keep: bool, reset: bool, timeout: u64) -> Result<()> {
|
||||
let account = load_account(key, store)?;
|
||||
let summary = client::fetch(store, &account, keep, reset, timeout)?;
|
||||
for rejected in &summary.rejected {
|
||||
warn(&format!(
|
||||
"{}: {}; left on server",
|
||||
&hex(&rejected.id)[..16],
|
||||
rejected.reason
|
||||
));
|
||||
}
|
||||
let mut line = format!(
|
||||
"{} message(s): {} new, {} rejected",
|
||||
summary.total,
|
||||
summary.stored,
|
||||
summary.rejected.len()
|
||||
);
|
||||
if summary.cancelled {
|
||||
line.push_str("; cancelled, fetching again continues");
|
||||
} else if keep && summary.total > 0 {
|
||||
line.push_str(" (left on the server)");
|
||||
}
|
||||
println!("{line}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolves an unambiguous id prefix against stored mail and returns the full
|
||||
/// ids, ready for the wire.
|
||||
fn resolve_id_prefixes(store: &Store, ids: &[String]) -> Result<Vec<[u8; ID_LEN]>> {
|
||||
if ids.is_empty() {
|
||||
return Err(anyhow!("no message ids given"));
|
||||
}
|
||||
let stored = store
|
||||
.mail("all")?
|
||||
.into_iter()
|
||||
.chain(store.mail("sent")?)
|
||||
.map(|m| m.id)
|
||||
.collect::<Vec<_>>();
|
||||
let mut full = Vec::with_capacity(ids.len());
|
||||
for id in ids {
|
||||
let prefix = id.to_ascii_lowercase();
|
||||
let matches: Vec<[u8; ID_LEN]> = stored
|
||||
.iter()
|
||||
.filter(|m| hex(m).starts_with(&prefix))
|
||||
.copied()
|
||||
.collect();
|
||||
match matches.len() {
|
||||
0 => return Err(anyhow!(format!("no message matching {id:?}"))),
|
||||
1 => full.push(matches[0]),
|
||||
_ => {
|
||||
return Err(anyhow!(format!(
|
||||
"{id:?} matches {} messages; be more specific",
|
||||
matches.len()
|
||||
))
|
||||
.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(full)
|
||||
}
|
||||
|
||||
fn delete(key: &PathBuf, store: &Store, ids: &[String], timeout: u64) -> Result<()> {
|
||||
let account = load_account(key, store)?;
|
||||
let full = resolve_id_prefixes(store, ids)?;
|
||||
let removed = client::delete(store, &account, &full, timeout)?;
|
||||
println!("removed {removed} message(s) from the server");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn list(key: &PathBuf, store: &Store, sent: bool, requests: bool) -> Result<()> {
|
||||
let account = load_account(key, store)?;
|
||||
let folder = if sent {
|
||||
"sent"
|
||||
} else if requests {
|
||||
"requests"
|
||||
} else {
|
||||
"inbox"
|
||||
};
|
||||
let rows = store.mail(folder)?;
|
||||
if rows.is_empty() {
|
||||
println!("{folder} is empty");
|
||||
return Ok(());
|
||||
}
|
||||
for row in rows {
|
||||
let who = row.recipient.clone();
|
||||
match client::describe(store, &account, &row) {
|
||||
Ok(described) => {
|
||||
let who = who.unwrap_or(described.from);
|
||||
let subject = if described.subject.is_empty() {
|
||||
"(no subject)".to_string()
|
||||
} else {
|
||||
described.subject
|
||||
};
|
||||
println!(
|
||||
"{} {} {:<28.28} {subject}",
|
||||
&hex(&row.id)[..8],
|
||||
format_utc(row.at),
|
||||
who
|
||||
);
|
||||
}
|
||||
Err(e) => println!(
|
||||
"{} {} {:<28.28} <unreadable: {e}>",
|
||||
&hex(&row.id)[..8],
|
||||
format_utc(row.at),
|
||||
"?"
|
||||
),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read(key: &PathBuf, store: &Store, id: &str, sent: bool) -> Result<()> {
|
||||
let account = load_account(key, store)?;
|
||||
let folder = if sent { "sent" } else { "all" };
|
||||
let prefix = id.to_ascii_lowercase();
|
||||
let matches: Vec<_> = store
|
||||
.mail(folder)?
|
||||
.into_iter()
|
||||
.filter(|m| hex(&m.id).starts_with(&prefix))
|
||||
.collect();
|
||||
match matches.len() {
|
||||
0 => Err(anyhow!(format!(
|
||||
"no {}message matching {id:?}",
|
||||
if sent { "sent " } else { "" }
|
||||
))),
|
||||
1 => {
|
||||
let row = &matches[0];
|
||||
let described = client::describe(store, &account, row)?;
|
||||
fn field(key: &str, value: &str) {
|
||||
println!("{:<12} {value}", format!("{key}:"));
|
||||
}
|
||||
field("id", &hex(&row.id));
|
||||
if let Some(recipient) = &row.recipient {
|
||||
field("to", recipient);
|
||||
}
|
||||
field("from", &described.from);
|
||||
field("key", &b32(&described.sender));
|
||||
field("date", &format_utc(described.time));
|
||||
for (k, v) in &described.fields {
|
||||
if k != "accept" {
|
||||
field(k, v);
|
||||
}
|
||||
}
|
||||
println!("signature verified");
|
||||
println!();
|
||||
print!("{}", described.text);
|
||||
if !described.text.ends_with('\n') {
|
||||
println!();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(anyhow!(format!(
|
||||
"{id:?} matches {} messages; be more specific",
|
||||
matches.len()
|
||||
))
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses host[:port]; `default_port` applies when the port is omitted.
|
||||
#[cfg(feature = "rns")]
|
||||
fn split_host_port(text: &str, default_port: u16, what: &str) -> Result<(String, u16)> {
|
||||
match text.rsplit_once(':') {
|
||||
Some((host, port)) => {
|
||||
let port: u16 = port
|
||||
.parse()
|
||||
.with_context(|| format!("{what} port {port:?} is not a number"))?;
|
||||
Ok((host.to_string(), port))
|
||||
}
|
||||
None => Ok((text.to_string(), default_port)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Carrier configuration from the global flags.
|
||||
#[cfg(feature = "rns")]
|
||||
fn rns_config(cli: &Cli) -> fumi::rns::RnsConfig {
|
||||
let listen =
|
||||
split_host_port(&cli.rns.udp, 0, "--rns-udp").expect("--rns-udp defaults are valid");
|
||||
let forward = cli
|
||||
.rns
|
||||
.udp_forward
|
||||
.as_deref()
|
||||
.map(|target| split_host_port(target, 4242, "--rns-udp-forward"))
|
||||
.transpose()
|
||||
.expect("clap-validated forward target");
|
||||
let (forward_host, forward_port) = match forward {
|
||||
Some((host, port)) => (Some(host), port),
|
||||
None => (None, 4242),
|
||||
};
|
||||
fumi::rns::RnsConfig {
|
||||
storage_dir: cli.rns.storage.clone(),
|
||||
udp_listen_host: listen.0,
|
||||
udp_listen_port: listen.1,
|
||||
udp_forward_host: forward_host,
|
||||
udp_forward_port: forward_port,
|
||||
}
|
||||
}
|
||||
|
||||
fn hex(id: &[u8; ID_LEN]) -> String {
|
||||
data_encoding::HEXLOWER.encode(id)
|
||||
}
|
||||
|
||||
/// UTC date and time, so no time-zone dependency is needed for a mail listing.
|
||||
fn format_utc(secs: i64) -> String {
|
||||
let days = secs.div_euclid(86_400);
|
||||
let time_of_day = secs.rem_euclid(86_400);
|
||||
let (year, month, day) = civil_from_days(days);
|
||||
format!(
|
||||
"{year:04}-{month:02}-{day:02} {:02}:{:02}",
|
||||
time_of_day / 3600,
|
||||
time_of_day % 3600 / 60
|
||||
)
|
||||
}
|
||||
|
||||
/// Days since the Unix epoch to a civil date (Howard Hinnant's algorithm).
|
||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
(if m <= 2 { y + 1 } else { y }, m as u32, d as u32)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(feature = "rns")]
|
||||
#[test]
|
||||
fn rns_flag_parsing() {
|
||||
assert_eq!(
|
||||
split_host_port("127.0.0.1:4242", 0, "--rns-udp").unwrap(),
|
||||
("127.0.0.1".to_string(), 4242)
|
||||
);
|
||||
assert_eq!(
|
||||
split_host_port("127.0.0.1", 4242, "--rns-udp-forward").unwrap(),
|
||||
("127.0.0.1".to_string(), 4242)
|
||||
);
|
||||
assert!(split_host_port("host:notaport", 0, "--rns-udp").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utc_formatting_matches_known_dates() {
|
||||
assert_eq!(format_utc(0), "1970-01-01 00:00");
|
||||
assert_eq!(format_utc(1_700_000_000), "2023-11-14 22:13");
|
||||
assert_eq!(format_utc(951_782_400), "2000-02-29 00:00"); // leap day
|
||||
assert_eq!(format_utc(-1), "1969-12-31 23:59");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue