1use crate::backend::{json, load_config, say, tr};
10use sioul_core::compose::{Draft, DraftKind};
11use sioul_core::draftcopies;
12use sioul_core::pgp::{self, Keys, PgpView};
13use sioul_core::securitykey;
14use sioul_core::porch::Triaged;
15use sioul_core::trust::Trust;
16use sioul_sync::{SyncError, keys, secret};
17use std::path::{Path, PathBuf};
18
19fn looks_protected(raw: &[u8]) -> bool {
21 let text = String::from_utf8_lossy(&raw[..raw.len().min(512 * 1024)]);
22 text.contains("application/pgp-encrypted") || text.contains("application/pgp-signature") || text.contains("-----BEGIN PGP MESSAGE-----")
23}
24
25pub(crate) fn open(raw: &[u8]) -> Option<(Option<Vec<u8>>, PgpView)> {
31 if !looks_protected(raw) {
32 return None;
33 }
34 let mut opened = pgp::open(raw, &Keys::load(), &pgp::Unlock::new(&|fingerprint| secret::pgp_passphrase(&fingerprint.to_hex()), pgp::SessionKeys::global()));
35 if let Some((_, view)) = opened.as_mut() {
37 view.key_elsewhere = !sioul_sync::securitykey::usable_here();
38 }
39 opened
40}
41
42pub(crate) fn readable(path: &Path) -> Option<Vec<u8>> {
44 let raw = std::fs::read(path).ok()?;
45 Some(open(&raw).and_then(|(opened, _)| opened).unwrap_or(raw))
46}
47
48pub(crate) fn learn_autocrypt(raw: &[u8], triaged: &Triaged) {
52 if triaged.trust != Trust::Verified {
53 return;
54 }
55 let Some(from) = triaged.card.from_address.as_deref() else { return };
56 if let Some(header) = triaged.card.headers.first("Autocrypt") {
57 let _ = raw;
58 pgp::learn_autocrypt(header, from);
59 }
60}
61
62#[derive(serde::Serialize, Default)]
64pub(crate) struct DraftProtection {
65 pub can_sign: bool,
66 pub can_encrypt: bool,
67 pub missing: Vec<String>,
69 pub expired: String,
72 pub sign_not_here: String,
75 pub self_copy: String,
78 pub sealed_quote: String,
83 pub bcc_seen: String,
87}
88
89pub(crate) enum Answered {
92 Clear,
94 Opens(Vec<u8>),
97 AtSend,
99 Elsewhere,
102 NoKey,
106}
107
108pub(crate) fn copy_of(draft: &Draft) -> Option<Vec<u8>> {
111 draft.original_here().and_then(|path| sioul_core::filelock::read_whole(&path).ok())
112}
113
114pub(crate) fn answered(raw: Option<&[u8]>) -> Answered {
116 let Some(raw) = raw.filter(|raw| pgp::is_encrypted(raw)) else { return Answered::Clear };
117 match open(raw) {
118 Some((Some(opened), _)) => Answered::Opens(opened),
119 Some((None, view)) if view.security_key.is_some() && !view.key_elsewhere => Answered::AtSend,
120 Some((None, view)) if view.security_key.is_some() => Answered::Elsewhere,
121 _ => Answered::NoKey,
122 }
123}
124
125pub(crate) fn draft_protection(draft: &Draft) -> DraftProtection {
126 let config = load_config();
127 let Some(address) = config.account(&draft.account).and_then(|a| a.address.clone()) else { return DraftProtection::default() };
128 protection_with(&Keys::load(), &address, draft, sioul_sync::securitykey::usable_here())
129}
130
131fn protection_with(keys: &Keys, address: &str, draft: &Draft, card_here: bool) -> DraftProtection {
134 let address = address.to_string();
135 let missing: Vec<String> = draft.recipients().into_iter().filter(|r| keys.for_address(r).is_none()).collect();
136 let signs = keys.own_for(&address);
137 let can_sign = signs.is_some();
138 let expired = if can_sign { String::new() } else { crate::securitykey::expired_line(keys, &address).unwrap_or_default() };
139 let card_signs = signs.as_ref().is_some_and(|own| matches!(own.place, pgp::Place::Card { .. }));
143 let shared_serves = !can_sign && keys.card_for(&address).is_none() && keys.serving_card(&address).is_some();
144 let sign_not_here = match (card_signs || shared_serves, card_here) {
145 (false, _) => String::new(),
146 (true, false) => tr().text("seckey-sign-not-here", None),
147 (true, true) if shared_serves => tr().text(crate::securitykey::read_here_words("seckey-sign-read-here"), None),
148 (true, true) => String::new(),
149 };
150 let self_copy = if keys.self_cert_for(&address).is_none() { self_copy_line(keys, &address, card_here) } else { String::new() };
152 let sealed = copy_of(draft).is_some_and(|raw| pgp::is_encrypted(&raw));
154 let sealed_quote = match (sealed, draft.kind) {
155 (false, _) | (_, DraftKind::New) => String::new(),
156 (true, DraftKind::Forward) => tr().text("pgp-forward-needs-encryption", None),
157 (true, _) => tr().text("pgp-quote-left-out", None),
158 };
159 let bcc_seen = if draft.bcc.iter().any(|b| !b.trim().is_empty()) { tr().text("pgp-bcc-seen", None) } else { String::new() };
160 DraftProtection { can_sign: can_sign && (card_here || !card_signs), can_encrypt: !draft.recipients().is_empty() && missing.is_empty(), missing, expired, sign_not_here, self_copy, sealed_quote, bcc_seen }
161}
162
163fn self_copy_line(keys: &Keys, address: &str, computer: bool) -> String {
171 let now = jiff::Timestamp::now().as_second();
172 let card = keys.serving_card(address).map(|(_, cert)| cert);
173 let cert = card.or_else(|| keys.own.iter().find(|c| pgp::names_address(c, address)));
174 let Some(cert) = cert else { return tr().text("pgp-self-copy-none", None) };
175 if pgp::revoked_whole(cert) {
177 return tr().text("pgp-self-copy-revoked", None);
178 }
179 let mut why = securitykey::expired_parts(tr(), &securitykey::parts(cert), now);
180 if why.is_empty() {
181 why.extend(securitykey::revoked_encryption(cert).map(|fingerprint| say("seckey-part-revoked-encrypt", &[("fingerprint", securitykey::short_fingerprint(&fingerprint))])));
182 }
183 if why.is_empty() {
184 return tr().text("pgp-self-copy-none", None);
185 }
186 let renew = match (card.is_some(), computer) {
190 (true, true) if keys.card_for(address).is_none() => tr().text("pgp-self-copy-renew-read-here", None),
192 (true, true) => tr().text("pgp-self-copy-renew", None),
193 (true, false) => tr().text("pgp-self-copy-renew-phone", None),
194 (false, _) => tr().text("pgp-self-copy-new-key", None),
195 };
196 say("pgp-self-copy-why", &[("why", why.join(" ")), ("renew", renew)])
197}
198
199pub(crate) fn protect_answer(draft: &mut Draft) {
206 if copy_of(draft).is_some_and(|raw| pgp::is_encrypted(&raw)) {
207 draft.encrypt = draft_protection(draft).can_encrypt;
208 }
209}
210
211static JUDGED: std::sync::Mutex<Option<draftcopies::Judged>> = std::sync::Mutex::new(None);
214
215fn verdicts_path() -> PathBuf {
217 sioul_core::config::state_dir().join("draftcopies.json")
218}
219
220pub(crate) fn seal_clear_drafts() {
231 match pgp::keep_others_for_their_addresses() {
233 Ok(0) => {}
234 Ok(n) => eprintln!("sioul: keys: {n} kept before now say which address they serve"),
235 Err(e) => eprintln!("sioul: keys: what others' keys serve not written: {e}"),
236 }
237 let mut judged = JUDGED.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
238 let judged = judged.get_or_insert_with(|| draftcopies::Judged::load(&verdicts_path()));
239 seal_with(judged);
240 if let Err(e) = judged.save(&verdicts_path()) {
241 eprintln!("sioul: drafts: the look's verdicts not kept: {}", e.kind());
242 }
243}
244
245fn kept_versions(memory: &Path) -> Vec<PathBuf> {
248 let mut kept = sioul_sync::history::draft_copies(&sioul_sync::history::root(memory));
249 kept.extend(sioul_sync::share::draft_copies_kept_before_sharing(memory));
250 kept
251}
252
253fn seal_with(judged: &mut draftcopies::Judged) {
254 let config = load_config();
255 let roots: Vec<PathBuf> = config.accounts.iter().filter(|a| a.kind == sioul_core::config::AccountKind::Imap).map(|a| a.maildir_path()).collect();
256 let cache = sioul_core::config::cache_dir().join("mail-index.tsv");
257 let done = seal_in(&Draft::folder(), &crate::share::memory_path(), &draftcopies::Certs::lazy(Keys::load), &roots, &cache, judged);
258 if done.any() {
259 eprintln!("{}", said(&done));
260 }
261}
262
263fn seal_in(drafts: &Path, memory: &Path, certs: &draftcopies::Certs, roots: &[PathBuf], cache: &Path, judged: &mut draftcopies::Judged) -> draftcopies::Sealed {
269 let mut index: Option<sioul_core::mailindex::MailIndex> = None;
271 let mut find = |id: &str| index.get_or_insert_with(|| sioul_core::mailindex::MailIndex::build(roots, cache)).paths(id);
273 let hold = sioul_sync::share::replacements_hold(memory);
274 let beside = draftcopies::seal_beside(drafts, certs, &mut find, judged, &hold);
275 let done = beside.and(draftcopies::seal_versions(&kept_versions(memory), certs, &mut find, judged, &hold));
277 judged.set_waiting(done.waiting);
279 done
280}
281
282fn said(done: &draftcopies::Sealed) -> String {
284 format!(
285 "sioul: drafts: {} copies encrypted again, {} earlier versions, {} not written, {} waiting for a key, {} without their draft taken out",
286 done.copies, done.versions, done.failed, done.waiting, done.orphans
287 )
288}
289
290#[derive(serde::Serialize)]
291struct KeysView {
292 keys: Vec<pgp::KeySummary>,
293 without_key: Vec<String>,
295 copies_waiting: String,
299 read_here: Vec<String>,
303}
304
305fn offers(keys: &Keys, addresses: &[String], card_here: bool) -> (Vec<String>, Vec<String>) {
315 let (mut make, mut read) = (Vec::new(), Vec::new());
316 for address in addresses {
317 if keys.card_for(address).is_some() {
318 continue;
319 }
320 if keys.serving_card(address).is_some() {
321 if card_here {
322 read.push(say(crate::securitykey::read_here_words("pgp-read-card-here"), &[("address", address.clone())]));
323 }
324 continue;
325 }
326 if keys.own_for(address).is_none() {
327 make.push(address.clone());
328 }
329 }
330 (make, read)
331}
332
333pub(crate) fn keys_view() -> String {
335 let keys = Keys::load();
336 let config = load_config();
337 let addresses: Vec<String> = config.accounts.iter().filter(|a| a.syncs()).filter_map(|a| a.address.clone()).collect();
339 let (without_key, read_here) = offers(&keys, &addresses, sioul_sync::securitykey::usable_here());
340 let waiting = JUDGED.try_lock().ok().and_then(|j| j.as_ref().map(draftcopies::Judged::waiting)).unwrap_or_else(|| draftcopies::Judged::load(&verdicts_path()).waiting());
342 let copies_waiting = if waiting == 0 { String::new() } else { tr().text("pgp-copies-waiting", Some(&tr().counted(waiting))) };
343 json(&KeysView { keys: pgp::summaries(), without_key, copies_waiting, read_here })
344}
345
346pub(crate) fn generate(address: &str) -> Result<String, String> {
348 let config = load_config();
349 let name = config.accounts.iter().find(|a| a.address.as_deref() == Some(address)).and_then(|a| a.name.clone()).unwrap_or_default();
350 let passphrase = match secret::test_passphrase() {
351 Some(fixed) => fixed,
352 None => pgp::new_passphrase()?,
353 };
354 let made = pgp::generate(&name, address, &passphrase)?;
355 secret::save_pgp_passphrase(&made.fingerprint, &passphrase).map_err(|e| e.sentence(tr(), address))?;
356 Ok(say("pgp-made", &[("address", address.to_string())]))
357}
358
359pub(crate) fn import(url: &str, passphrase: &str) -> Result<String, String> {
362 let path = crate::backend::local_path(url);
363 let bytes = std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?;
364 let had: Vec<String> = pgp::summaries().into_iter().filter(|k| k.own).map(|k| k.fingerprint).collect();
366 let imported = pgp::import(&bytes)?;
367 for key in imported.iter().filter(|k| k.own) {
368 if !pgp::opens(&key.fingerprint, passphrase) {
369 if !had.contains(&key.fingerprint) {
370 let _ = pgp::remove(&key.fingerprint);
371 }
372 return Err(tr().text("pgp-wrong-passphrase", None));
373 }
374 secret::save_pgp_passphrase(&key.fingerprint, passphrase).map_err(|e| e.sentence(tr(), &key.fingerprint))?;
375 }
376 let mut args = tr().counted(imported.len());
377 args.set("n", imported.len());
378 Ok(tr().text("pgp-imported", Some(&args)))
379}
380
381pub(crate) fn export(fingerprint: &str, folder: &Path) -> Result<PathBuf, String> {
383 let armored = pgp::export_public(fingerprint).ok_or_else(|| tr().text("pgp-no-key", None))?;
384 std::fs::create_dir_all(folder).map_err(|e| e.to_string())?;
385 let short: String = fingerprint.chars().rev().take(16).collect::<Vec<_>>().into_iter().rev().collect();
386 let path = folder.join(format!("{short}.asc"));
387 std::fs::write(&path, armored).map_err(|e| e.to_string())?;
388 Ok(path)
389}
390
391pub(crate) fn lookup(draft: &Draft) -> String {
393 let missing = draft_protection(draft).missing;
394 let mut found = 0;
395 for address in &missing {
396 match keys::lookup(address) {
397 Ok(keys) if !keys.is_empty() => found += 1,
398 Ok(_) => {}
399 Err(SyncError::Network(e)) => return say("pgp-lookup-failed", &[("detail", e)]),
400 Err(e) => return e.sentence(tr(), address),
401 }
402 }
403 let still = missing.len() - found;
404 if still == 0 { tr().text("pgp-lookup-all", None) } else { say("pgp-lookup-some", &[("missing", draft_protection(draft).missing.join(", "))]) }
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410 use sioul_core::securitykey::{KnownCard, SoftCard};
411
412 fn yours(dated: Option<(std::time::SystemTime, std::time::Duration)>) -> Keys {
414 let (card, public) = SoftCard::generate_dated("0006:12345678", &["Card Holder <me@example.org>"], "123456", dated).unwrap();
415 let mine = pgp::certificates_in(&public);
416 let known = KnownCard {
417 ident: "0006:12345678".into(),
418 sign: card.info.sign.as_ref().unwrap().fingerprint.clone(),
419 decrypt: card.info.decrypt.as_ref().unwrap().fingerprint.clone(),
420 cert: mine[0].fingerprint().to_hex(),
421 addresses: vec!["me@example.org".into()],
422 ..KnownCard::default()
423 };
424 Keys { own: mine, cards: vec![known], ..Keys::default() }
425 }
426
427 #[test]
431 fn an_answered_encrypted_message_stays_encrypted_beside_the_draft() {
432 let tsk = pgp::stand_in_key("Me <me@example.org>").unwrap();
433 let keys = Keys { own: pgp::certificates_in(&tsk), ..Keys::default() };
434 let clear = b"From: Jane <jane@example.org>\r\nTo: Me <me@example.org>\r\nSubject: The lease\r\nDate: Thu, 01 Oct 2026 10:00:00 +0200\r\nMessage-ID: <lease-b@example.org>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nThe secret clause is here.\r\n";
435 let sealed = pgp::seal_message(clear, &[&keys.own[0]]).unwrap();
436 let dir = std::env::temp_dir().join(format!("sioul-answer-{}", std::process::id()));
437 std::fs::create_dir_all(&dir).unwrap();
438 let original = dir.join("lease.eml");
439 std::fs::write(&original, &sealed).unwrap();
440 let mut draft = sioul_core::compose::answer(&original, sioul_core::compose::DraftKind::Reply, "me", &["me@example.org".to_string()]).unwrap();
441 draft.keep_original().unwrap();
442 let passphrase = |_: &pgp::Fingerprint| None;
444 assert!(pgp::open(&sealed, &keys, &pgp::Unlock::new(&passphrase, &pgp::SessionKeys::new())).is_some_and(|(opened, _)| opened.is_some()));
445 protect_answer(&mut draft);
446 let kept = std::fs::read(draft.original.as_ref().unwrap()).unwrap();
447 draft.discard();
448 let _ = std::fs::remove_dir_all(&dir);
449 assert!(!String::from_utf8_lossy(&kept).contains("secret clause"), "never in clear beside the draft");
450 assert_eq!(kept, sealed, "kept as it came");
451 }
452
453 #[test]
457 fn the_writing_window_says_what_your_key_can_do_here() {
458 let mut draft = Draft::new("me");
459 draft.to = vec!["me@example.org".into()];
460 let keys = yours(None);
461 let phone = protection_with(&keys, "me@example.org", &draft, false);
462 assert!(!phone.can_sign && phone.sign_not_here == tr().text("seckey-sign-not-here", None) && phone.self_copy.is_empty() && phone.can_encrypt);
463 let computer = protection_with(&keys, "me@example.org", &draft, true);
464 assert!(computer.can_sign && computer.sign_not_here.is_empty() && computer.self_copy.is_empty());
465 let year = std::time::Duration::from_secs(365 * 24 * 3600);
466 let expired = yours(Some((std::time::SystemTime::now() - 2 * year, year)));
467 let late = protection_with(&expired, "me@example.org", &draft, true);
468 let why = securitykey::expired_parts(tr(), &securitykey::parts(&expired.own[0]), jiff::Timestamp::now().as_second()).join(" ");
470 assert!(!why.is_empty());
471 assert_eq!(late.self_copy, say("pgp-self-copy-why", &[("why", why.clone()), ("renew", tr().text("pgp-self-copy-renew", None))]));
472 assert_eq!(protection_with(&expired, "me@example.org", &draft, false).self_copy, say("pgp-self-copy-why", &[("why", why), ("renew", tr().text("pgp-self-copy-renew-phone", None))]));
473 assert!(!late.can_sign && !late.can_encrypt, "to yourself, with no key that takes it: {:?}", late.missing);
474 let mut to_jane = Draft::new("me");
476 to_jane.to = vec!["jane@example.org".into()];
477 assert_eq!(protection_with(&Keys::default(), "me@example.org", &to_jane, false).self_copy, tr().text("pgp-self-copy-none", None));
478 let shared = Keys { shared: keys.cards.clone(), cards: Vec::new(), ..keys.clone() };
481 let elsewhere = protection_with(&shared, "me@example.org", &draft, true);
482 assert!(!elsewhere.can_sign && elsewhere.sign_not_here == tr().text("seckey-sign-read-here", None) && elsewhere.self_copy.is_empty());
483 assert_eq!(protection_with(&shared, "me@example.org", &draft, false).sign_not_here, tr().text("seckey-sign-not-here", None));
484 }
485
486 #[test]
491 fn the_self_copy_line_says_what_can_renew_the_key() {
492 let mut draft = Draft::new("me");
493 draft.to = vec!["me@example.org".into()];
494 let year = std::time::Duration::from_secs(365 * 24 * 3600);
495 let card = yours(Some((std::time::SystemTime::now() - 2 * year, year)));
496 let here = Keys { cards: Vec::new(), ..card.clone() };
497 let why = securitykey::expired_parts(tr(), &securitykey::parts(&here.own[0]), jiff::Timestamp::now().as_second()).join(" ");
498 for computer in [true, false] {
499 assert_eq!(protection_with(&here, "me@example.org", &draft, computer).self_copy, say("pgp-self-copy-why", &[("why", why.clone()), ("renew", tr().text("pgp-self-copy-new-key", None))]));
500 }
501 assert!(protection_with(&card, "me@example.org", &draft, true).self_copy.ends_with(&tr().text("pgp-self-copy-renew", None)));
502 let shared = Keys { shared: card.cards.clone(), cards: Vec::new(), ..card.clone() };
504 assert!(protection_with(&shared, "me@example.org", &draft, true).self_copy.ends_with(&tr().text("pgp-self-copy-renew-read-here", None)));
505 assert!(protection_with(&shared, "me@example.org", &draft, false).self_copy.ends_with(&tr().text("pgp-self-copy-renew-phone", None)));
506 }
507
508 #[test]
513 fn a_shared_security_key_is_read_here_never_doubled_by_a_new_key() {
514 let keys = yours(None);
515 let shared = Keys { shared: keys.cards.clone(), cards: Vec::new(), ..keys.clone() };
516 let addresses = vec!["me@example.org".to_string(), "other@example.org".to_string()];
517 let other = vec!["other@example.org".to_string()];
518 assert_eq!(offers(&shared, &addresses, true), (other.clone(), vec![say("pgp-read-card-here", &[("address", "me@example.org".to_string())])]));
519 assert_eq!(offers(&shared, &addresses, false), (other.clone(), Vec::new()), "a phone cannot read it yet");
520 assert_eq!(offers(&keys, &addresses, true), (other.clone(), Vec::new()), "read here: it serves");
521 let mut both = shared.clone();
523 both.own.extend(pgp::certificates_in(&pgp::stand_in_key("Me <me@example.org>").unwrap()));
524 assert_eq!(offers(&both, &addresses, true).1.len(), 1);
525 }
526
527 const LEASE: &[u8] = b"From: Jane <jane@example.org>\r\nTo: Card Holder <me@example.org>\r\nSubject: The lease\r\nDate: Thu, 01 Oct 2026 10:00:00 +0200\r\nMessage-ID: <lease-view@example.org>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nThe secret clause is here.\r\n";
528
529 #[test]
534 fn unticking_encrypt_says_what_becomes_of_an_encrypted_quote() {
535 let keys = yours(None);
536 let dir = std::env::temp_dir().join(format!("sioul-sealed-quote-{}", std::process::id()));
537 std::fs::create_dir_all(&dir).unwrap();
538 let (sealed, clear) = (dir.join("sealed.eml"), dir.join("clear.eml"));
539 std::fs::write(&sealed, pgp::seal_message(LEASE, &[&keys.own[0]]).unwrap()).unwrap();
540 std::fs::write(&clear, LEASE).unwrap();
541 let mut draft = Draft::new("me");
542 draft.to = vec!["me@example.org".into()];
543 draft.kind = DraftKind::Reply;
544 draft.original = Some(sealed.clone());
545 assert_eq!(protection_with(&keys, "me@example.org", &draft, true).sealed_quote, tr().text("pgp-quote-left-out", None));
546 draft.kind = DraftKind::Forward;
547 assert_eq!(protection_with(&keys, "me@example.org", &draft, true).sealed_quote, tr().text("pgp-forward-needs-encryption", None));
548 draft.original = Some(clear.clone());
549 assert!(protection_with(&keys, "me@example.org", &draft, true).sealed_quote.is_empty());
550 draft.kind = DraftKind::Reply;
551 draft.original = Some(sealed);
552 draft.encrypt = true;
553 assert_eq!(crate::securitykey::quote_card(&draft, &keys).as_deref(), Some("0006:12345678"));
554 draft.encrypt = false;
555 assert_eq!(crate::securitykey::quote_card(&draft, &keys), None, "not encrypted: not quoted, no key asked");
556 draft.encrypt = true;
557 draft.original = Some(clear);
558 assert_eq!(crate::securitykey::quote_card(&draft, &keys), None, "came in clear");
559 let _ = std::fs::remove_dir_all(&dir);
560 }
561
562 #[test]
567 fn the_look_leaves_no_clear_copy_and_says_counts_only() {
568 let base = std::env::temp_dir().join(format!("sioul-seal-in-{}", std::process::id()));
569 let _ = std::fs::remove_dir_all(&base);
570 let (drafts, state, mail, cache) = (base.join("data/drafts"), base.join("state/share"), base.join("mail"), base.join("cache"));
571 let memory = state.join("memory.json");
572 let put = |path: PathBuf, bytes: &[u8]| {
573 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
574 std::fs::write(path, bytes).unwrap();
575 };
576 let keys = Keys { own: pgp::certificates_in(&pgp::stand_in_key("Card Holder <me@example.org>").unwrap()), ..Keys::default() };
577 let sealed = pgp::seal_message(LEASE, &[&keys.own[0]]).unwrap();
579 put(mail.join("INBOX/cur/1790000000.U1-1.sioul:2,S"), &sealed);
580 std::fs::create_dir_all(mail.join("INBOX/new")).unwrap();
581 let none = |_: &pgp::Fingerprint| None;
582 let decrypted = pgp::open(&sealed, &keys, &pgp::Unlock::new(&none, &pgp::SessionKeys::new())).unwrap().0.unwrap();
583 assert!(pgp::looks_opened(&decrypted));
584 put(drafts.join("b1.toml"), toml::to_string(&Draft { id: "b1".into(), ..Draft::default() }).unwrap().as_bytes());
585 put(drafts.join("b1.eml"), &decrypted);
586 put(state.join("history/drafts/data/drafts/b1.eml/2026-10-18T10-00-00.000Z"), &decrypted);
587 put(state.join("before-sharing-2026-09-21/data/drafts/b1.eml"), &decrypted);
588 let done = seal_in(&drafts, &memory, &draftcopies::Certs::of(keys.clone()), &[mail.clone()], &cache.join("mail-index.tsv"), &mut draftcopies::Judged::default());
589 assert_eq!(done, draftcopies::Sealed { copies: 1, versions: 2, ..draftcopies::Sealed::default() });
590 for folder in [&drafts, &state, &cache] {
591 for path in sioul_core::mailindex::message_files(folder).into_iter().chain(walk(folder)) {
592 assert!(!String::from_utf8_lossy(&std::fs::read(&path).unwrap_or_default()).contains("secret clause"), "{}", path.display());
593 }
594 }
595 assert!(Draft::load(&drafts.join("b1.toml")).is_some(), "the draft itself untouched");
596 let line = said(&done);
597 assert!(!line.contains("lease") && !line.contains("secret") && !line.contains("b1"), "{line}");
598 let _ = std::fs::remove_dir_all(&base);
599 }
600
601 #[test]
607 fn the_look_is_off_the_doses_path() {
608 let source = |file: &str| std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join(file)).unwrap();
609 let body = |text: &str, start: &str| text.split(start).nth(1).and_then(|rest| rest.split("\n}\n").next()).unwrap_or_else(|| panic!("{start}")).to_string();
610 let share = source("src/share.rs");
611 let alarm = body(&share, "\npub(crate) fn exchange_here(");
612 assert!(!alarm.contains("seal_clear_drafts(") && !alarm.contains("seal_clear_drafts_if_idle") && !alarm.contains("Keys::load"), "the alarm's path looks at the copies");
613 let window = body(&share, "\nfn exchange_now(");
614 let (look, busy) = (window.find("crate::crypto::seal_clear_drafts();").expect("the window's exchange looks"), window.find("BUSY.lock()").unwrap());
615 assert!(look < busy, "the look before BUSY");
616 let steps = source("src/steps.rs");
617 let step = body(&steps, "\nfn step(reason: &str)");
618 let (pull, look) = (step.find("exchange_here(fetch_first)").unwrap(), step.find("crate::crypto::seal_clear_drafts").expect("the background step looks"));
619 assert!(pull < look, "after the step's exchange");
620 for file in ["src/health.rs", "src/wake.rs", "src/timenote.rs", "src/alarms.rs"] {
621 assert!(!source(file).contains("seal_clear_drafts("), "{file}");
622 }
623 let start = source("src/lib.rs");
625 let (set, spawn) = (start.rfind("std::env::set_var(").unwrap(), start.find("std::thread::spawn(crypto::seal_clear_drafts)").expect("the start looks"));
626 assert!(set < spawn, "the look's thread after the environment is set");
627 }
628
629 fn walk(dir: &Path) -> Vec<PathBuf> {
631 let mut out = Vec::new();
632 for entry in std::fs::read_dir(dir).into_iter().flatten().filter_map(Result::ok) {
633 if entry.path().is_dir() {
634 out.extend(walk(&entry.path()));
635 } else {
636 out.push(entry.path());
637 }
638 }
639 out
640 }
641
642 #[test]
646 fn a_draft_opened_without_the_key_says_so_and_stays_encrypted() {
647 let elsewhere = pgp::certificates_in(&pgp::stand_in_key("Card Holder <me@example.org>").unwrap());
648 let sealed = pgp::seal_message(LEASE, &[&elsewhere[0]]).unwrap();
649 let mut draft = Draft::new("me");
650 draft.kind = DraftKind::Forward;
651 draft.to = vec!["bob@example.org".into()];
652 std::fs::create_dir_all(Draft::folder()).unwrap();
653 let copy = Draft::folder().join(format!("{}.eml", draft.id));
654 std::fs::write(©, &sealed).unwrap();
655 draft.original = Some(copy.clone());
656 draft.save().unwrap();
657 let view = |id: &str| serde_json::from_str::<serde_json::Value>(&crate::mail::draft(id).unwrap()).unwrap();
658 let shown = view(&draft.id);
659 let below = shown["below"].as_str().unwrap_or_default().to_string();
660 assert!(below.ends_with(&tr().text("compose-forward-sealed-no-key", None)), "no key of yours opens it: {below}");
661 assert!(shown["forwarded"].as_array().is_some_and(Vec::is_empty), "{shown}");
662 draft.kind = DraftKind::Reply;
663 draft.save().unwrap();
664 let below = view(&draft.id)["below"].as_str().unwrap_or_default().to_string();
665 assert!(below.ends_with(&tr().text("compose-below-sealed-no-key", None)), "no key of yours opens it: {below}");
666 assert_eq!(std::fs::read(©).unwrap(), sealed, "kept as it came");
667 draft.discard();
668 }
669}