feat: gate first contact on a pinned server, confirm key changes

This commit is contained in:
randogoth 2026-10-08 21:36:02 +03:00
parent 743c388cfb
commit 4c5665a6ff
5 changed files with 981 additions and 44 deletions

View file

@ -10,8 +10,9 @@ A minimalist desktop client for [Smol Mail](../smolmail): one compact window —
- Composes closed by any sidebar trip are stashed as drafts — kept beside the store, reopened from a folder that appears only once one exists; Cancel is the only way to throw one away.
- Custom `Key: value` frontmatter rows when writing: one line per header under the subject, validated against the frontmatter rules — the client's own fields (subject, reply-to, in-reply-to, accept) can't be shadowed, and rows survive a stash as part of the draft. Received mail shows the sender's own fields in the message detail, between the meta line and the body; replying to such a mail asks whether to carry copies of the fields into the reply, where they stay editable.
- Accept or block a requester when their mail carried a self-certifying reply address; accept tokens attach to accepted contacts automatically.
- Contacts with verified/tofu marks and smol:// import; your own identity opens in the same detail view, its address in the header bar with a QR share beside it, key, fingerprint and URI below.
- Key rotation with a confirm-first step, server pin management, a network timeout, gsmol backup export/import that never contains the seed, and a factory reset that erases the local files and returns to onboarding.
- Contacts with verified/tofu marks and smol:// import; each contact's detail names the server its address lives on and whether that server's key is pinned, with a pin action when it is not. Your own identity opens in the same detail view, its address in the header bar with a QR share beside it, key, fingerprint and URI below.
- Optional strict first contact: writing to someone new whose address carries no key means their key has to be looked up on their own server, and an unpinned server can answer with one of its own. With the setting on, that send stops and the compose offers to fetch and pin the server's key first, so it can be checked against what the operator published. A smol:// address whose key differs from the one already on file asks before replacing it, either way.
- Key rotation with a confirm-first step, a network timeout, gsmol backup export/import that never contains the seed, and a log-out that erases the local files and returns to onboarding. Preferences live in a plaintext sidecar beside the store, so they survive a restart.
## Layout
@ -24,7 +25,7 @@ src/ops.rs fumi-core calls, key/read-mark files, and the offline tests
## State
Everything lives under `$XDG_CONFIG_HOME/carrier` (default `~/.config/carrier`): `carrier.key` is the master seed, written 0600; `carrier.db` is fumi-core's sealed store; `read.txt` is carrier's own read marks, one hex id per line.
Everything lives under `$XDG_CONFIG_HOME/carrier` (default `~/.config/carrier`): `carrier.key` is the master seed, written 0600; `carrier.db` is fumi-core's sealed store; `read.txt` is carrier's own read marks, one hex id per line; `settings.txt` holds the preferences as one `key value` line each.
## Run and verify

View file

@ -10,8 +10,8 @@ use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use fumi_core::address::Address;
use fumi_core::crypto::{unb32, KEY_LEN};
use fumi_core::address::{trust_label, Address, Scheme};
use fumi_core::crypto::{fingerprint, unb32, KEY_LEN};
use fumi_core::store::Store;
use gtk::prelude::*;
@ -111,6 +111,20 @@ pub struct Compose {
pub headers: Vec<String>,
}
/// A correspondent's server that has to be pinned before carrier will
/// take their key from it, and the key fetched for it so far. The compose
/// and the contact detail both prompt through this one slot: they are
/// never on screen together, since selecting a contact stashes the
/// compose. Signup keeps its own `pin_offer` — there the address arrives
/// late from a live entry, and "already pinned" is a third state this
/// struct has no room for.
#[derive(Debug, Clone)]
pub struct PinPrompt {
pub addr: Address,
/// None until the fetch lands, Some once the key is on screen.
pub offered: Option<[u8; KEY_LEN]>,
}
#[derive(Debug, Clone)]
pub enum Dialog {
/// Delete locally, and on the server too when `everywhere` is chosen.
@ -131,6 +145,49 @@ pub enum Dialog {
id: String,
rows: Vec<String>,
},
/// A `smol://` address whose key is not the one on file for that
/// contact. `save_contact` is an upsert and both ways in use it with
/// `verified = true`, so a replacement would silently displace a
/// known-good key and skip fumi's own rotation-chain check. It is
/// confirmed here instead.
KeyChange {
address: String,
offered: [u8; KEY_LEN],
known_fingerprint: String,
offered_fingerprint: String,
resume: KeyChangeResume,
},
}
/// What confirming a key change goes on to do.
#[derive(Debug, Clone)]
pub enum KeyChangeResume {
Send,
Import { uri: String },
}
/// Which question a dialog is asking. The alert's response demux keys off
/// this instead of one boolean per variant, so a new dialog adds arms
/// rather than widening a tuple.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DialogKind {
Delete,
Rotate,
Reset,
Reply,
KeyChange,
}
impl Dialog {
pub fn kind(&self) -> DialogKind {
match self {
Dialog::Delete { .. } => DialogKind::Delete,
Dialog::Rotate => DialogKind::Rotate,
Dialog::Reset => DialogKind::Reset,
Dialog::Reply { .. } => DialogKind::Reply,
Dialog::KeyChange { .. } => DialogKind::KeyChange,
}
}
}
/// UI inputs. Background results arrive as [`OpResult`] instead, so the
@ -177,6 +234,10 @@ pub enum Message {
ComposeHeaderRemoved(usize),
ComposeClosed,
SendPressed,
/// The pin card's one button, on whichever surface shows it: fetches
/// the server's key, then — once it is on screen — pins it, and in a
/// compose sends in the same press.
PinServerAction,
ReplyPressed,
ReplyCancelled,
ReplyConfirmed(bool),
@ -203,6 +264,7 @@ pub enum Message {
SecretRevealToggled,
CopySecret,
KeepMailChanged(bool),
StrictPinningChanged(bool),
TimeoutChanged(String),
RotatePressed,
RotateConfirmed,
@ -212,6 +274,8 @@ pub enum Message {
ResetPressed,
ResetConfirmed,
ResetCancelled,
KeyChangeConfirmed,
KeyChangeCancelled,
}
/// What a background operation reports back.
@ -293,6 +357,10 @@ pub struct Carrier {
/// Off by default: a fetch acknowledges what it takes, so mail is
/// removed from the server once it's stored here.
pub keep_mail: bool,
/// Refuse a send that would take a recipient's key from a server with
/// no pin: `client::resolve` verifies nothing, so such a key is
/// whatever answered the connection.
pub strict_pinning: bool,
pub timeout: u64,
pub timeout_text: String,
pub onboard: Onboard,
@ -310,7 +378,10 @@ pub struct Carrier {
/// the drafts sidecar; the folder only shows once it holds one.
pub drafts: Vec<Draft>,
pub snapshot: Option<Snapshot>,
/// Signup's offer only; correspondents' servers prompt through
/// `pin_prompt`.
pub pin_offer: Option<(Address, [u8; KEY_LEN])>,
pub pin_prompt: Option<PinPrompt>,
/// The Identity tab's recovery secret is masked until this is toggled.
pub secret_revealed: bool,
pub contact_import: String,
@ -343,6 +414,7 @@ impl Carrier {
(true, true) => Page::Mail,
};
let drafts = ops::load_drafts(&dir.join("drafts.txt"));
let settings = ops::load_settings(&dir.join("settings.txt"));
let app = Carrier {
marks: ReadMarks::load(dir.join("read.txt")),
dir,
@ -353,9 +425,10 @@ impl Carrier {
status_error: false,
busy: None,
cancel: Arc::new(AtomicBool::new(false)),
keep_mail: false,
timeout: 30,
timeout_text: "30".to_string(),
keep_mail: settings.keep_mail,
strict_pinning: settings.strict_pinning,
timeout: settings.timeout,
timeout_text: settings.timeout.to_string(),
onboard: Onboard {
mode: OnboardMode::Start,
seed: String::new(),
@ -378,6 +451,7 @@ impl Carrier {
drafts,
snapshot: None,
pin_offer: None,
pin_prompt: None,
secret_revealed: false,
contact_import: String::new(),
dialog: None,
@ -402,6 +476,19 @@ impl Carrier {
self.dir.join("drafts.txt")
}
/// Rewritten on every change: three values in one short file, the
/// same whole-file rewrite the drafts sidecar does.
fn save_settings(&self) {
ops::save_settings(
&self.dir.join("settings.txt"),
&ops::Settings {
keep_mail: self.keep_mail,
timeout: self.timeout,
strict_pinning: self.strict_pinning,
},
);
}
/// Every sidebar trip closes the compose this way: a compose with
/// anything in it becomes a draft, an empty one is thrown away, and
/// Cancel stays the only way to discard work outright.
@ -575,6 +662,49 @@ impl Carrier {
Address::parse(&format!("{user}@{host}")).ok()
}
/// The recipient's address when sending to it would make carrier take
/// their key from a server it has never pinned: no key in the address,
/// no contact on file, no pin for the host. A pin is enough to make
/// the lookup trustworthy — `connect` rejects a mismatch whatever
/// `require_pin` says. RNS has nothing to pin: there the destination
/// hash in the address is itself the pin.
fn unpinned_first_contact(&self, to: &str) -> Option<Address> {
let addr = Address::parse(to).ok()?;
if addr.scheme != Scheme::Tcp || addr.identity.is_some() {
return None;
}
// A store error reads as "not known, not pinned": this gate errs
// towards asking.
let known = self.store.contact(&addr.short()).ok().flatten().is_some();
let pinned = self
.store
.server_pin(&addr.host, addr.port)
.ok()
.flatten()
.is_some();
(!known && !pinned).then_some(addr)
}
/// A self-certifying address whose key differs from the contact on
/// file. Both keys are public and about to be shown side by side, so
/// a plain comparison is all this needs.
fn key_change(&self, text: &str) -> Option<(String, [u8; KEY_LEN], [u8; KEY_LEN])> {
let addr = Address::parse(text).ok()?;
let offered = addr.identity?;
let (known, _) = self.store.contact(&addr.short()).ok().flatten()?;
(known != offered).then_some((addr.short(), known, offered))
}
/// The key the pin card fetched, if it was fetched for this very
/// recipient; taken, so editing the address cannot reuse it.
fn take_offered_pin(&mut self, to: &str) -> Option<(Address, [u8; KEY_LEN])> {
let wanted = Address::parse(to).ok()?.short();
let prompt = self.pin_prompt.as_ref()?;
let key = prompt.offered.filter(|_| prompt.addr.short() == wanted)?;
let prompt = self.pin_prompt.take()?;
Some((prompt.addr, key))
}
/// The host as typed on the signup page's server step, before a
/// username exists — `learn_key`/`connect` only read host/port/scheme,
/// so a throwaway username satisfies `Address::parse`'s grammar.
@ -688,6 +818,11 @@ impl Carrier {
}
Message::KeepMailChanged(keep) => {
self.keep_mail = keep;
self.save_settings();
}
Message::StrictPinningChanged(strict) => {
self.strict_pinning = strict;
self.save_settings();
}
Message::SecretRevealToggled => {
self.secret_revealed = !self.secret_revealed;
@ -695,6 +830,7 @@ impl Carrier {
Message::TimeoutChanged(text) => {
if let Ok(timeout) = text.trim().parse::<u64>() {
self.timeout = timeout;
self.save_settings();
}
self.timeout_text = text;
}
@ -939,16 +1075,103 @@ impl Carrier {
self.fail(reason);
return Vec::new();
}
let to = compose.to.trim().to_string();
// Asked before anything else: a pasted address carrying a
// different key is a question about who the recipient is,
// which has to settle before how to reach them.
if let Some((address, known, offered)) = self.key_change(&to) {
self.dialog = Some(Dialog::KeyChange {
address,
offered,
known_fingerprint: fingerprint(&known),
offered_fingerprint: fingerprint(&offered),
resume: KeyChangeResume::Send,
});
return Vec::new();
}
let op = PendingOp::Send {
to: compose.to.trim().to_string(),
to: to.clone(),
subject: compose.subject.trim().to_string(),
body: compose.body.clone(),
reply_to: compose.reply_to.clone(),
headers,
};
// The card's second press: the key on screen was fetched
// for this recipient's server, so pin it here — a local
// write — and the send below opens a pinned session.
// Writing the pin first is also what stops the re-entry
// in PinServerAction from looping back into the gate.
if let Some((addr, key)) = self.take_offered_pin(&to) {
if let Err(e) = ops::pin(&self.store, &addr, &key) {
self.fail(e);
return Vec::new();
}
}
if self.strict_pinning {
if let Some(addr) = self.unpinned_first_contact(&to) {
self.fail(format!(
"{} is not a contact yet and {} is not pinned — pin their server's key to send.",
addr.short(),
trust_label(&addr.host, addr.port),
));
self.pin_prompt = Some(PinPrompt {
addr,
offered: None,
});
// The compose stays open: its text is the message
// the user is still trying to send.
return Vec::new();
}
}
self.pin_prompt = None;
self.compose = None;
return vec![op];
}
Message::PinServerAction => {
// The surface is the address: a compose's recipient, or
// the contact whose detail is open. Never both at once.
let Some(to) = self
.compose
.as_ref()
.map(|compose| compose.to.trim().to_string())
.or_else(|| self.selected_contact.clone())
else {
return Vec::new();
};
let Ok(addr) = Address::parse(&to) else {
self.fail("that address does not parse".into());
return Vec::new();
};
let fetched = self
.pin_prompt
.as_ref()
.is_some_and(|p| p.offered.is_some() && p.addr.short() == addr.short());
if !fetched {
self.pin_prompt = Some(PinPrompt {
addr: addr.clone(),
offered: None,
});
return vec![PendingOp::LearnKey { addr }];
}
// In a compose the pin is only ever the first half of the
// send the user already asked for, and SendPressed writes
// it before re-checking the gate — so this does not loop.
if self.compose.is_some() {
return self.handle(Message::SendPressed);
}
let Some(prompt) = self.pin_prompt.take() else {
return Vec::new();
};
return prompt
.offered
.map(|key| {
vec![PendingOp::Pin {
addr: prompt.addr,
key,
}]
})
.unwrap_or_default();
}
Message::DeletePressed => {
let Some((folder, msg)) = &self.open else {
return Vec::new();
@ -1019,8 +1242,45 @@ impl Carrier {
if uri.is_empty() {
return Vec::new();
}
if let Some((address, known, offered)) = self.key_change(&uri) {
self.dialog = Some(Dialog::KeyChange {
address,
offered,
known_fingerprint: fingerprint(&known),
offered_fingerprint: fingerprint(&offered),
resume: KeyChangeResume::Import { uri },
});
return Vec::new();
}
return vec![PendingOp::ImportContact { uri }];
}
Message::KeyChangeCancelled => {
self.dialog = None;
}
Message::KeyChangeConfirmed => {
let Some(Dialog::KeyChange {
address,
offered,
resume,
..
}) = self.dialog.take()
else {
return Vec::new();
};
match resume {
// Recording the accepted key is also what keeps the
// re-dispatch below from asking the same question.
KeyChangeResume::Send => {
match ops::save_contact(&self.store, &address, &offered) {
Ok(()) => return self.handle(Message::SendPressed),
Err(e) => self.fail(e),
}
}
KeyChangeResume::Import { uri } => {
return vec![PendingOp::ImportContact { uri }]
}
}
}
Message::ContactWrite(address) => {
let key = self
.snapshot
@ -1143,7 +1403,17 @@ impl Carrier {
}
OpResult::PinOffered(Ok(Some((addr, key)))) => {
self.done();
self.pin_offer = Some((addr, key));
// The prompt is armed synchronously before the fetch is
// ever spawned, so an offer that matches it belongs to a
// correspondent's server; anything else is signup's.
match self
.pin_prompt
.as_mut()
.filter(|prompt| prompt.addr.short() == addr.short())
{
Some(prompt) => prompt.offered = Some(key),
None => self.pin_offer = Some((addr, key)),
}
self.note(
"The server presented this key. Pin it only if the fingerprint \
matches what the operator published."
@ -1153,7 +1423,11 @@ impl Carrier {
}
OpResult::PinOffered(Ok(None)) => {
self.done();
self.signup.key_trusted = true;
// A pin appeared in the meantime: a correspondent prompt
// has nothing left to ask, and signup can advance.
if self.pin_prompt.take().is_none() {
self.signup.key_trusted = true;
}
self.note("This server's key is already pinned.".into());
None
}
@ -1165,6 +1439,12 @@ impl Carrier {
OpResult::Pinned(Ok(())) => {
self.done();
self.note("server key pinned".into());
// The contact detail shows the pin state, so that pane
// needs the fresh row; signup's own pin changes nothing
// that is on screen.
if self.page == Page::Mail {
return self.refresh();
}
None
}
OpResult::Pinned(Err(e)) => {
@ -1230,6 +1510,261 @@ mod tests {
assert!(!app.keep_mail);
}
fn composing(to: &str) -> Compose {
Compose {
to: to.into(),
subject: "hello".into(),
body: "a message".into(),
reply_to: None,
headers: Vec::new(),
}
}
/// `client::resolve` verifies nothing, so a first contact over a
/// server with no pin seals the message to whatever answered. Strict
/// mode stops exactly that case, and keeps the compose so the text
/// the user is trying to send survives the refusal.
#[test]
fn strict_pinning_blocks_first_contact_over_an_unpinned_server() {
let mut app = app();
app.master = Some([0u8; KEY_LEN]);
app.strict_pinning = true;
app.compose = Some(composing("bob@example.org"));
assert!(app.handle(Message::SendPressed).is_empty());
assert!(app.compose.is_some(), "the draft must survive the refusal");
assert!(app.status_error);
let prompt = app.pin_prompt.as_ref().expect("the card must be armed");
assert_eq!(prompt.addr.short(), "bob@example.org");
assert!(prompt.offered.is_none());
// A pin is all the gate wants: `connect` rejects a mismatch
// whatever it was asked for, so the lookup is now trustworthy.
app.store
.pin_server("example.org", 1961, &[7u8; KEY_LEN])
.unwrap();
let ops = app.handle(Message::SendPressed);
assert!(matches!(ops.as_slice(), [PendingOp::Send { .. }]));
assert!(app.compose.is_none());
}
/// The gate only bites where a key would have to be learned: an
/// address that carries one, a contact already on file, and RNS —
/// where the destination hash is itself the pin — all go straight out.
#[test]
fn strict_pinning_lets_carried_keys_contacts_and_rns_through() {
let mut app = app();
app.master = Some([0u8; KEY_LEN]);
app.strict_pinning = true;
let key = [3u8; KEY_LEN];
let carried = fumi_core::crypto::b32(&key);
app.compose = Some(composing(&format!("smol://bob@example.org/{carried}")));
assert!(matches!(
app.handle(Message::SendPressed).as_slice(),
[PendingOp::Send { .. }]
));
app.store
.save_contact("carol@example.org", &key, false)
.unwrap();
app.compose = Some(composing("carol@example.org"));
assert!(matches!(
app.handle(Message::SendPressed).as_slice(),
[PendingOp::Send { .. }]
));
let hash = "8f2c1d0a7b6e5f4c3d2a1b0e9f8c7d6a";
app.compose = Some(composing(&format!("smol+rns://dave@{hash}")));
assert!(
matches!(
app.handle(Message::SendPressed).as_slice(),
[PendingOp::Send { .. }]
),
"rns has nothing to pin, so the gate must not strand it"
);
}
#[test]
fn strict_pinning_off_sends_over_an_unpinned_server() {
let mut app = app();
app.master = Some([0u8; KEY_LEN]);
app.handle(Message::StrictPinningChanged(false));
app.compose = Some(composing("bob@example.org"));
assert!(matches!(
app.handle(Message::SendPressed).as_slice(),
[PendingOp::Send { .. }]
));
}
/// The card's two presses: fetch, then pin and send in one go. The
/// offer must land in `pin_prompt` and leave signup's own slot alone,
/// and the pin must be written before the send is dispatched — that
/// ordering is what keeps the re-entry from looping.
#[test]
fn the_pin_card_fetches_then_pins_and_sends() {
let mut app = app();
app.master = Some([0u8; KEY_LEN]);
app.strict_pinning = true;
app.compose = Some(composing("bob@example.org"));
app.handle(Message::SendPressed);
let ops = app.handle(Message::PinServerAction);
assert!(matches!(ops.as_slice(), [PendingOp::LearnKey { .. }]));
let addr = Address::parse("bob@example.org").unwrap();
let key = [5u8; KEY_LEN];
app.handle_result(OpResult::PinOffered(Ok(Some((addr, key)))));
assert!(app.pin_offer.is_none(), "signup's slot must stay empty");
assert_eq!(app.pin_prompt.as_ref().unwrap().offered, Some(key));
let ops = app.handle(Message::PinServerAction);
assert!(matches!(ops.as_slice(), [PendingOp::Send { .. }]));
assert_eq!(
app.store.server_pin("example.org", 1961).unwrap(),
Some(key)
);
assert!(app.pin_prompt.is_none());
assert!(app.compose.is_none());
}
/// A pin that appeared in the meantime retires the prompt without
/// touching signup, which shares the result variant.
#[test]
fn an_already_pinned_server_retires_the_prompt() {
let mut app = app();
app.pin_prompt = Some(PinPrompt {
addr: Address::parse("bob@example.org").unwrap(),
offered: None,
});
app.handle_result(OpResult::PinOffered(Ok(None)));
assert!(app.pin_prompt.is_none());
assert!(!app.signup.key_trusted);
app.handle_result(OpResult::PinOffered(Ok(None)));
assert!(app.signup.key_trusted, "an unarmed offer is signup's");
}
/// `save_contact` is an upsert, so a pasted address carrying a
/// different key would quietly displace a known-good one and skip
/// fumi's own rotation-chain check. It has to be confirmed.
#[test]
fn a_changed_smol_key_asks_before_replacing_a_contact() {
let mut app = app();
app.master = Some([0u8; KEY_LEN]);
let known = [1u8; KEY_LEN];
let swapped = [2u8; KEY_LEN];
app.store
.save_contact("bob@example.org", &known, true)
.unwrap();
let uri = format!(
"smol://bob@example.org/{}",
fumi_core::crypto::b32(&swapped)
);
app.compose = Some(composing(&uri));
assert!(app.handle(Message::SendPressed).is_empty());
assert!(matches!(
app.dialog,
Some(Dialog::KeyChange {
resume: KeyChangeResume::Send,
..
})
));
// Cancelling leaves both the draft and the stored key alone, and
// pressing Send again asks the same question.
app.handle(Message::KeyChangeCancelled);
assert!(app.dialog.is_none());
assert!(app.compose.is_some());
assert_eq!(
app.store.contact("bob@example.org").unwrap().unwrap().0,
known
);
assert!(app.handle(Message::SendPressed).is_empty());
let ops = app.handle(Message::KeyChangeConfirmed);
assert!(matches!(ops.as_slice(), [PendingOp::Send { .. }]));
assert_eq!(
app.store.contact("bob@example.org").unwrap().unwrap().0,
swapped
);
assert!(app.compose.is_none());
// The same address carrying the key already on file is not a
// change, so it goes straight out.
app.compose = Some(composing(&uri));
assert!(matches!(
app.handle(Message::SendPressed).as_slice(),
[PendingOp::Send { .. }]
));
}
#[test]
fn a_changed_smol_key_asks_before_importing_it() {
let mut app = app();
let known = [1u8; KEY_LEN];
let swapped = [2u8; KEY_LEN];
app.store
.save_contact("bob@example.org", &known, true)
.unwrap();
app.contact_import = format!(
"smol://bob@example.org/{}",
fumi_core::crypto::b32(&swapped)
);
assert!(app.handle(Message::ContactImportPressed).is_empty());
assert!(matches!(
app.dialog,
Some(Dialog::KeyChange {
resume: KeyChangeResume::Import { .. },
..
})
));
assert!(matches!(
app.handle(Message::KeyChangeConfirmed).as_slice(),
[PendingOp::ImportContact { .. }]
));
// An address with no contact on file is not a change either.
app.contact_import = format!("smol://new@example.org/{}", fumi_core::crypto::b32(&known));
assert!(matches!(
app.handle(Message::ContactImportPressed).as_slice(),
[PendingOp::ImportContact { .. }]
));
}
/// Off by default — this one is opt-in — and a plain toggle, not
/// busy-guarded like a network action.
#[test]
fn strict_pinning_defaults_off_and_toggles() {
let mut app = app();
assert!(!app.strict_pinning);
app.busy = Some("fetching".into());
app.handle(Message::StrictPinningChanged(true));
assert!(app.strict_pinning);
app.handle(Message::StrictPinningChanged(false));
assert!(!app.strict_pinning);
}
/// Settings are the one piece of state a restart used to forget,
/// which would have let a security toggle lapse on every launch.
#[test]
fn settings_survive_a_reboot() {
let dir = tempfile::tempdir().unwrap().keep();
let (mut app, _) = Carrier::boot(dir.clone());
app.handle(Message::StrictPinningChanged(true));
app.handle(Message::KeepMailChanged(true));
app.handle(Message::TimeoutChanged("90".into()));
assert!(dir.join("settings.txt").exists());
let (rebooted, _) = Carrier::boot(dir);
assert!(rebooted.strict_pinning);
assert!(rebooted.keep_mail);
assert_eq!(rebooted.timeout, 90);
assert_eq!(rebooted.timeout_text, "90");
}
#[test]
fn secret_reveal_toggles() {
let mut app = app();
@ -1271,6 +1806,7 @@ mod tests {
std::fs::write(dir.join("drafts.txt"), "draft2 id 1 -\n\n\n\n").unwrap();
app.signup.host = "typed-before-the-reset.example".into();
app.signup.user = "alice".into();
app.handle(Message::StrictPinningChanged(true));
// The button is busy-guarded like every other action.
app.busy = Some("fetching".into());
@ -1294,9 +1830,12 @@ mod tests {
assert!(app.signup.host.is_empty());
assert!(app.signup.user.is_empty());
assert!(app.form_fresh.get());
for name in ["carrier.key", "read.txt", "drafts.txt"] {
for name in ["carrier.key", "read.txt", "drafts.txt", "settings.txt"] {
assert!(!dir.join(name).exists(), "{name} survived the reset");
}
// Preferences go with everything else, so the rebooted model is
// back to its defaults rather than the logged-out user's.
assert!(!app.strict_pinning);
// The store is reopened by boot, so a fresh empty db is the one
// file a reset legitimately leaves behind.
assert!(dir.join("carrier.db").exists());

View file

@ -39,6 +39,11 @@ fn plant(dir: &Path) -> Result<(), String> {
let me = Account::new(master, 0).map_err(|e| e.to_string())?;
let addr = Address::parse("demo@carrier.local").map_err(|e| e.to_string())?;
store.set_account(&addr).map_err(|e| e.to_string())?;
// Our own server is pinned, the contacts' server is not: the two
// states the trust lines and the pin prompts distinguish.
store
.pin_server(&addr.host, addr.port, &identity(42).pk())
.map_err(|e| e.to_string())?;
let pk = me.me().pk();
let mut read_ids: Vec<String> = Vec::new();

View file

@ -7,7 +7,7 @@ use std::path::Path;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use fumi_core::address::Address;
use fumi_core::address::{trust_label, Address, Scheme};
use fumi_core::client::{FetchOptions, TrustChange};
use fumi_core::crypto::{b32, fingerprint, unb32, KEY_LEN};
use fumi_core::error::Error;
@ -52,12 +52,21 @@ pub struct Contact {
pub verified: bool,
/// Some(true) accepted, Some(false) blocked, None never decided.
pub accepted: Option<bool>,
/// The server this address lives on, as the label pins are keyed by.
pub server: String,
/// Whether that server's key is pinned. A key learned over a session
/// with no pin is only as good as whoever answered, which `accepted`
/// stops saying anything about once it is Some.
pub pinned: bool,
}
#[derive(Debug, Clone)]
pub struct Who {
pub address: String,
pub host: String,
/// Paired with `host` to build the label pins are keyed by, so the
/// account's own pin can be told apart from a correspondent's.
pub port: u16,
pub key: String,
pub fingerprint: String,
pub uri: String,
@ -235,6 +244,74 @@ pub fn load_drafts(path: &Path) -> Vec<Draft> {
drafts
}
/// Carrier's own preferences, beside the store as one `key value` line
/// each: nothing here is secret, and plaintext keeps a hand edit honest.
/// Unknown keys and unreadable values are skipped, so a file from another
/// build — or a torn write — costs only the lines it broke.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Settings {
pub keep_mail: bool,
pub timeout: u64,
/// Refuse to learn a recipient's key from a server with no pin.
pub strict_pinning: bool,
}
impl Default for Settings {
fn default() -> Settings {
Settings {
keep_mail: false,
timeout: 30,
strict_pinning: false,
}
}
}
fn flag(value: &str) -> Option<bool> {
match value.trim() {
"1" => Some(true),
"0" => Some(false),
_ => None,
}
}
pub fn load_settings(path: &Path) -> Settings {
let mut settings = Settings::default();
let text = std::fs::read_to_string(path).unwrap_or_default();
for row in text.lines() {
let Some((key, value)) = row.split_once(' ') else {
continue;
};
match key.trim() {
"keep_mail" => settings.keep_mail = flag(value).unwrap_or(settings.keep_mail),
"strict_pinning" => {
settings.strict_pinning = flag(value).unwrap_or(settings.strict_pinning)
}
// Out of the spin row's range is as good as unreadable: the
// widget would clamp it and write the clamp straight back.
"timeout" => {
settings.timeout = value
.trim()
.parse()
.ok()
.filter(|seconds| (1..=600).contains(seconds))
.unwrap_or(settings.timeout)
}
_ => {}
}
}
settings
}
pub fn save_settings(path: &Path, settings: &Settings) {
let text = format!(
"keep_mail {}\nstrict_pinning {}\ntimeout {}\n",
u8::from(settings.keep_mail),
u8::from(settings.strict_pinning),
settings.timeout,
);
let _ = std::fs::write(path, text);
}
fn explain(e: Error) -> String {
e.to_string()
}
@ -310,7 +387,13 @@ pub fn delete_key(path: &Path) -> Result<(), String> {
/// The caller must close the store first: Windows refuses to remove a
/// file a live connection holds.
pub fn factory_reset(dir: &Path) -> Result<(), String> {
for name in ["carrier.db", "carrier.key", "read.txt", "drafts.txt"] {
for name in [
"carrier.db",
"carrier.key",
"read.txt",
"drafts.txt",
"settings.txt",
] {
let path = dir.join(name);
if let Err(e) = std::fs::remove_file(&path) {
if e.kind() != std::io::ErrorKind::NotFound {
@ -574,6 +657,14 @@ pub fn block_contact(
}
/// Adds a contact from a self-certifying `smol://` address.
/// Saves a key the user has confirmed, verified because it came from a
/// self-certifying address they chose to accept.
pub fn save_contact(store: &Store, address: &str, key: &[u8; KEY_LEN]) -> Result<(), String> {
store.save_contact(address, key, true).map_err(explain)
}
/// A plain upsert: a key that would displace a known-good one is caught
/// at the app layer, which asks before this runs.
pub fn import_contact(store: &Store, uri: &str) -> Result<(String, String), String> {
let addr = parse_address(uri)?;
let key = addr
@ -590,12 +681,31 @@ pub fn contacts(store: &Store) -> Result<Vec<Contact>, String> {
.contact_rows()
.map_err(explain)?
.into_iter()
.map(|(address, identity, verified, accepted)| Contact {
address,
key: b32(&identity),
fingerprint: fingerprint(&identity),
verified,
accepted: accepted.map(|state| state == 1),
.map(|(address, identity, verified, accepted)| {
let (server, pinned) = match Address::parse(&address) {
// On RNS the destination hash in the address is itself
// the pin: there is nothing to pin and nothing to warn
// about.
Ok(addr) if addr.scheme == Scheme::Rns => (addr.host.clone(), true),
Ok(addr) => (
trust_label(&addr.host, addr.port),
store
.server_pin(&addr.host, addr.port)
.ok()
.flatten()
.is_some(),
),
Err(_) => (String::new(), false),
};
Contact {
address,
key: b32(&identity),
fingerprint: fingerprint(&identity),
verified,
accepted: accepted.map(|state| state == 1),
server,
pinned,
}
})
.collect())
}
@ -727,6 +837,7 @@ pub fn whoami(store: &Store, master: &[u8; KEY_LEN]) -> Result<Who, String> {
.as_ref()
.map(|addr| addr.host.clone())
.unwrap_or_default();
let port = account.as_ref().map(|addr| addr.port).unwrap_or_default();
let uri = account
.as_ref()
.map(|addr| addr.uri(&pk))
@ -734,6 +845,7 @@ pub fn whoami(store: &Store, master: &[u8; KEY_LEN]) -> Result<Who, String> {
Ok(Who {
address,
host,
port,
key: b32(&pk),
fingerprint: fingerprint(&pk),
uri,
@ -778,6 +890,40 @@ mod tests {
use fumi_core::message::{build_frontmatter, message_id, seal};
use fumi_core::store::now;
/// A settings file from another build, or one a hand edit broke, has
/// to degrade a line at a time rather than resetting the lot.
#[test]
fn settings_round_trip_and_degrade_per_line() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.txt");
assert_eq!(load_settings(&path), Settings::default());
assert!(!Settings::default().strict_pinning);
let settings = Settings {
keep_mail: true,
timeout: 90,
strict_pinning: true,
};
save_settings(&path, &settings);
assert_eq!(load_settings(&path), settings);
// Each unreadable value keeps the default; the readable one still
// lands, and an unknown key is simply skipped.
std::fs::write(
&path,
"keep_mail yes\ntimeout 0\nstrict_pinning 1\nwhat even 3\n",
)
.unwrap();
let degraded = load_settings(&path);
assert!(!degraded.keep_mail);
assert_eq!(degraded.timeout, 30);
assert!(degraded.strict_pinning);
// Out of the spin row's range counts as unreadable.
std::fs::write(&path, "timeout 9999\n").unwrap();
assert_eq!(load_settings(&path).timeout, 30);
}
#[test]
fn drafts_round_trip_through_the_sidecar() {
let dir = tempfile::tempdir().unwrap();
@ -1029,6 +1175,41 @@ mod tests {
assert_eq!(folders[0][0].subject, "kept");
}
/// The detail pane says whether a contact's key could have come from
/// an unpinned server, so each row carries its server's pin state —
/// keyed by the same label pins use, non-default ports included.
#[test]
fn contacts_report_their_server_and_pin_state() {
let store = setup();
let key = master();
store.save_contact("bob@example.org", &key, false).unwrap();
store
.save_contact("eve@example.org:1962", &key, false)
.unwrap();
let by_address = |all: Vec<Contact>, address: &str| {
all.into_iter().find(|c| c.address == address).unwrap()
};
let all = contacts(&store).unwrap();
assert_eq!(
by_address(all.clone(), "bob@example.org").server,
"example.org"
);
assert!(!by_address(all.clone(), "bob@example.org").pinned);
assert_eq!(
by_address(all, "eve@example.org:1962").server,
"example.org:1962"
);
store.pin_server("example.org", 1961, &key).unwrap();
let all = contacts(&store).unwrap();
assert!(by_address(all.clone(), "bob@example.org").pinned);
assert!(
!by_address(all, "eve@example.org:1962").pinned,
"a default-port pin must not cover another port"
);
}
#[test]
fn pin_lists_under_the_host_port_label() {
let store = setup();

View file

@ -11,6 +11,7 @@
use std::sync::atomic::Ordering;
use std::time::Duration;
use fumi_core::address::trust_label;
use fumi_core::crypto::{b32, fingerprint, KEY_LEN};
use gtk::prelude::*;
use gtk::{Align, Orientation, WrapMode};
@ -20,7 +21,8 @@ use relm4::ComponentSender;
use relm4::{Component, ComponentParts};
use crate::app::{
Carrier, Dialog, Message, OnboardMode, OpResult, Page, PendingOp, SignupMode, SignupStep,
Carrier, Dialog, DialogKind, Message, OnboardMode, OpResult, Page, PendingOp, SignupMode,
SignupStep,
};
use crate::ops::{self, Contact, Folder, Row};
use relm4::RelmApp;
@ -210,6 +212,13 @@ pub struct CarrierWidgets {
compose_subject: adw::EntryRow,
compose_body: gtk::TextBuffer,
compose_send: adw::ButtonContent,
compose_pin_card: gtk::Box,
compose_pin_title: gtk::Label,
compose_pin_body: gtk::Label,
compose_pin_key: gtk::Label,
compose_pin_fingerprint: gtk::Label,
compose_pin_hint: gtk::Label,
compose_pin_button: gtk::Button,
// sidebar bottom: own identity and the contact list
sidebar_own: gtk::Label,
@ -228,6 +237,7 @@ pub struct CarrierWidgets {
public_key_row: adw::ActionRow,
rotations_value: gtk::Label,
keep_mail_check: gtk::CheckButton,
strict_check: gtk::CheckButton,
timeout_row: adw::SpinRow,
// onboarding
@ -569,6 +579,56 @@ impl CarrierWidgets {
compose_fields.append(&compose_to);
compose_fields.append(&compose_subject);
compose.append(&compose_fields);
// Shown when a send was refused because the recipient's key would
// have come from a server with no pin: the refusal and its fix in
// one place, since nowhere else in the app pins a correspondent's
// server.
let compose_pin_title = label("", "heading");
compose_pin_title.set_wrap(true);
let compose_pin_body = label("", "dim");
compose_pin_body.set_wrap(true);
let compose_pin_key = label("", "mono");
compose_pin_key.set_selectable(true);
compose_pin_key.set_wrap(true);
compose_pin_key.set_wrap_mode(gtk::pango::WrapMode::Char);
let compose_pin_fingerprint = label("", "mono dim");
compose_pin_fingerprint.set_selectable(true);
compose_pin_fingerprint.set_wrap(true);
let compose_pin_hint = label(
"If the server operator published a key or fingerprint, make \
sure it matches before trusting it.",
"dim caption",
);
compose_pin_hint.set_wrap(true);
let compose_pin_button = button("", "accent");
compose_pin_button.set_halign(Align::Start);
compose_pin_button.set_margin_top(6);
let compose_pin_card = gtk::Box::new(Orientation::Vertical, 6);
compose_pin_card.add_css_class("card");
compose_pin_card.set_visible(false);
for child in [&compose_pin_title, &compose_pin_body] {
child.set_margin_start(12);
child.set_margin_end(12);
}
compose_pin_title.set_margin_top(12);
for child in [
&compose_pin_key,
&compose_pin_fingerprint,
&compose_pin_hint,
] {
child.set_margin_start(12);
child.set_margin_end(12);
}
compose_pin_button.set_margin_start(12);
compose_pin_button.set_margin_bottom(12);
compose_pin_card.append(&compose_pin_title);
compose_pin_card.append(&compose_pin_body);
compose_pin_card.append(&compose_pin_key);
compose_pin_card.append(&compose_pin_fingerprint);
compose_pin_card.append(&compose_pin_hint);
compose_pin_card.append(&compose_pin_button);
compose.append(&compose_pin_card);
// Frontmatter rows: one `Key: value` entry each, plus a flat
// add button below. Rows are rebuilt in render on structural
// changes only, so the entries never lose focus while typing.
@ -770,6 +830,32 @@ impl CarrierWidgets {
rotations_group.add(&rotate_button);
advanced_page.add(&rotations_group);
let trust_group = adw::PreferencesGroup::builder()
.title("First Contact")
.description(
"A recipient who is not a contact yet, and whose address carries \
no key, has to be looked up on their own server. That answer is \
only as good as the server: one with no pinned key can return a \
key of its own and read what you send.",
)
.build();
let strict_row = adw::ActionRow::builder()
.title("Require a pinned server")
.subtitle(
"Stop such a send and offer to pin the server's key first, so it \
can be checked against what the operator published. With this \
off, the key is taken on trust and the send is only reported as \
unverified afterwards.",
)
.subtitle_lines(0)
.activatable(true)
.build();
let strict_check = gtk::CheckButton::builder().valign(Align::Center).build();
strict_row.add_suffix(&strict_check);
strict_row.set_activatable_widget(Some(&strict_check));
trust_group.add(&strict_row);
advanced_page.add(&trust_group);
// The log-out button is wide and destructive, so it sits in the
// group's body under the warning rather than squeezed beside the
// header where its label would crowd the title.
@ -1086,6 +1172,7 @@ impl CarrierWidgets {
add_header.connect_clicked(input(Message::ComposeHeaderAdded));
compose_send_button.connect_clicked(input(Message::SendPressed));
compose_pin_button.connect_clicked(input(Message::PinServerAction));
compose_cancel.connect_clicked(input(Message::ComposeClosed));
let to_sender = sender.clone();
compose_to.connect_changed(move |entry| {
@ -1123,6 +1210,10 @@ impl CarrierWidgets {
keep_mail_check.connect_toggled(move |check| {
keep_mail_sender.input(Message::KeepMailChanged(check.is_active()))
});
let strict_sender = sender.clone();
strict_check.connect_toggled(move |check| {
strict_sender.input(Message::StrictPinningChanged(check.is_active()))
});
// Export/Import go straight to a native file dialog rather than a
// Message — the chosen path only matters for this one call, and
// round-tripping it through the model would gain nothing.
@ -1238,6 +1329,13 @@ impl CarrierWidgets {
compose_subject,
compose_body,
compose_send,
compose_pin_card,
compose_pin_title,
compose_pin_body,
compose_pin_key,
compose_pin_fingerprint,
compose_pin_hint,
compose_pin_button,
sidebar_own,
sidebar_contacts: contacts_list,
detail_share,
@ -1250,6 +1348,7 @@ impl CarrierWidgets {
public_key_row,
rotations_value,
keep_mail_check,
strict_check,
timeout_row,
onboard_seed_label,
onboard_seed_entry,
@ -1354,8 +1453,8 @@ fn spawn_op(model: &Carrier, op: PendingOp, sender: &ComponentSender<Carrier>) {
let _ = out.send(OpResult::Generic(res));
}),
PendingOp::LearnKey { addr } => sender.spawn_command(move |out| {
let offered =
ops::learn_key(&store, &addr, 30).map(|key| key.map(|key| (addr.clone(), key)));
let offered = ops::learn_key(&store, &addr, timeout)
.map(|key| key.map(|key| (addr.clone(), key)));
let _ = out.send(OpResult::PinOffered(offered));
}),
PendingOp::Pin { addr, key } => sender.spawn_command(move |out| {
@ -1496,6 +1595,46 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
}
}
// The pin card follows the recipient: a prompt armed for a different
// address simply isn't shown, so editing To retires it with no
// explicit clearing.
let compose_prompt = model.compose.as_ref().and_then(|compose| {
let typed = ops::parse_address(compose.to.trim()).ok()?.short();
model
.pin_prompt
.as_ref()
.filter(|prompt| prompt.addr.short() == typed)
});
w.compose_pin_card.set_visible(compose_prompt.is_some());
if let Some(prompt) = compose_prompt {
w.compose_pin_title.set_text(&format!(
"{} is not pinned",
trust_label(&prompt.addr.host, prompt.addr.port)
));
w.compose_pin_body.set_text(&format!(
"{} is not a contact yet, so carrier would have to ask this \
server for their key — and a server with no pinned key can \
answer with one of its own and read the message.",
prompt.addr.short()
));
match prompt.offered {
Some(key) => {
w.compose_pin_key.set_text(&b32(&key));
w.compose_pin_fingerprint.set_text(&fingerprint(&key));
w.compose_pin_button.set_label("Pin and send");
}
None => w.compose_pin_button.set_label("Fetch their server's key"),
}
let fetched = prompt.offered.is_some();
for line in [
&w.compose_pin_key,
&w.compose_pin_fingerprint,
&w.compose_pin_hint,
] {
line.set_visible(fetched);
}
}
// Header and sidebar identity.
let address = model
.snapshot
@ -1642,12 +1781,37 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
w.article_stack.set_visible_child_name("detail");
w.article_view.add_css_class("monospace");
w.article_subject.set_text(&contact.address);
w.article_meta.set_text(contact_state(contact));
w.article_body.set_text(&detail_body(
&contact.address,
&contact.key,
&contact.fingerprint,
// `contact_state` collapses to accepted/blocked once a token
// exists, which says nothing about where the key came from —
// so the server's pin state is spelled out beside it.
w.article_meta.set_text(&format!(
"{} · server {}",
contact_state(contact),
if contact.pinned {
"pinned"
} else {
"not pinned"
}
));
let mut body = detail_body(&contact.address, &contact.key, &contact.fingerprint);
if !contact.server.is_empty() {
body.push_str(&format!("\nserver {}", contact.server));
}
// A key fetched for this contact's server renders here rather
// than in a second card: the pane is already a key column.
if let Some(key) = model
.pin_prompt
.as_ref()
.filter(|prompt| prompt.addr.short() == contact.address)
.and_then(|prompt| prompt.offered)
{
body.push_str(&format!(
"\n\nthis server presents\nkey {}\nfingerprint {}\n\nIf the server operator published a key or fingerprint, make sure it matches before trusting it.",
b32(&key),
fingerprint(&key)
));
}
w.article_body.set_text(&body);
set_qr(
&w.detail_qr,
&format!("smol://{}/{}", contact.address, contact.key),
@ -1664,6 +1828,22 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
let spacer = gtk::Label::new(None);
spacer.set_hexpand(true);
w.actions.append(&spacer);
if !contact.pinned && !contact.server.is_empty() {
let fetched = model.pin_prompt.as_ref().is_some_and(|prompt| {
prompt.offered.is_some() && prompt.addr.short() == contact.address
});
let pin = icon_label_button(
"channel-secure-symbolic",
if fetched {
"Pin this key"
} else {
"Pin server"
},
if fetched { "accent" } else { "" },
);
pin.connect_clicked(input(Message::PinServerAction));
w.actions.append(&pin);
}
match contact.accepted {
Some(true) => {
let block = icon_label_button("action-unavailable-symbolic", "Block", "danger");
@ -1821,9 +2001,14 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
row.set_subtitle(&text);
}
w.host_row.set_subtitle(&snap.who.host);
// Pins are ordered by host and hold correspondents' servers too,
// so the account's own is found by its label rather than taken
// off the front of the list.
let own = trust_label(&snap.who.host, snap.who.port);
let public_key = snap
.pins
.first()
.iter()
.find(|(label, _)| *label == own)
.map(|(_, key)| b32(key))
.unwrap_or_else(|| "not pinned yet".to_string());
w.public_key_row.set_subtitle(&public_key);
@ -1847,6 +2032,9 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
if w.keep_mail_check.is_active() != model.keep_mail {
w.keep_mail_check.set_active(model.keep_mail);
}
if w.strict_check.is_active() != model.strict_pinning {
w.strict_check.set_active(model.strict_pinning);
}
if w.timeout_row.value() as u64 != model.timeout {
w.timeout_row.set_value(model.timeout as f64);
}
@ -1911,9 +2099,7 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
// again would stack a second copy over the first.
match (&model.dialog, w.dialog.is_some()) {
(Some(dialog), false) => {
let rotating = matches!(dialog, Dialog::Rotate);
let replying = matches!(dialog, Dialog::Reply { .. });
let resetting = matches!(dialog, Dialog::Reset);
let kind = dialog.kind();
let alert = match dialog {
Dialog::Delete { kept, .. } => {
let alert = adw::AlertDialog::new(
@ -1974,25 +2160,50 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
alert.set_response_appearance("copy", adw::ResponseAppearance::Suggested);
alert
}
Dialog::KeyChange {
address,
known_fingerprint,
offered_fingerprint,
..
} => {
let alert = adw::AlertDialog::new(
Some("Replace this contact's key?"),
Some(&format!(
"{address} is already on file with a different key.\n\nknown {known_fingerprint}\nnew {offered_fingerprint}\n\nThe address you were given carries the new key and would replace the stored one. If this contact did not hand it to you themselves, stop: a key swapped in on the way reads everything you send them."
)),
);
alert.add_response("cancel", "Cancel");
alert.add_response("replace", "Replace Key");
alert.set_response_appearance("replace", adw::ResponseAppearance::Destructive);
alert
}
};
// The reply dialog carries its own default: Enter answers the
// question instead of cancelling the reply the user asked for.
let close_default = if replying { "without" } else { "cancel" };
let close_default = if kind == DialogKind::Reply {
"without"
} else {
"cancel"
};
alert.set_close_response("cancel");
alert.set_default_response(Some(close_default));
let response_sender = sender.clone();
alert.connect_response(None, move |_, response| {
response_sender.input(match (rotating, replying, resetting, response) {
(true, _, _, "rotate") => Message::RotateConfirmed,
(true, _, _, _) => Message::RotateCancelled,
(_, true, _, "copy") => Message::ReplyConfirmed(true),
(_, true, _, "without") => Message::ReplyConfirmed(false),
(_, true, _, _) => Message::ReplyCancelled,
(_, _, true, "reset") => Message::ResetConfirmed,
(_, _, true, _) => Message::ResetCancelled,
(false, _, _, "here") => Message::DeleteConfirmed(false),
(false, _, _, "everywhere") => Message::DeleteConfirmed(true),
(false, _, _, _) => Message::DeleteCancelled,
// Anything that is not an explicit confirmation — a
// Cancel, Escape, or the close response — cancels.
response_sender.input(match (kind, response) {
(DialogKind::Delete, "here") => Message::DeleteConfirmed(false),
(DialogKind::Delete, "everywhere") => Message::DeleteConfirmed(true),
(DialogKind::Delete, _) => Message::DeleteCancelled,
(DialogKind::Rotate, "rotate") => Message::RotateConfirmed,
(DialogKind::Rotate, _) => Message::RotateCancelled,
(DialogKind::Reset, "reset") => Message::ResetConfirmed,
(DialogKind::Reset, _) => Message::ResetCancelled,
(DialogKind::Reply, "copy") => Message::ReplyConfirmed(true),
(DialogKind::Reply, "without") => Message::ReplyConfirmed(false),
(DialogKind::Reply, _) => Message::ReplyCancelled,
(DialogKind::KeyChange, "replace") => Message::KeyChangeConfirmed,
(DialogKind::KeyChange, _) => Message::KeyChangeCancelled,
});
});
alert.present(Some(&w.window));