1use crate::compose::Draft;
47use crate::filelock::{FileStamp, Prepared};
48use crate::pgp::{self, Keys};
49use sequoia_openpgp::Cert;
50use std::collections::{HashSet, VecDeque};
51use std::path::{Path, PathBuf};
52use std::time::{Duration, Instant};
53
54pub type Hold<'a> = &'a dyn Fn() -> Option<Box<dyn Held>>;
59
60pub trait Held {
62 fn wanted(&self) -> bool {
65 false
66 }
67}
68
69impl Held for () {}
70
71pub fn unshared() -> Option<Box<dyn Held>> {
73 Some(Box::new(()))
74}
75
76const HOLD_AT_MOST: Duration = Duration::from_millis(200);
81const LET_GO: Duration = Duration::from_millis(20);
85const BATCH_FILES: usize = 32;
87const BATCH_BYTES: usize = 16 << 20;
88
89#[derive(Debug, Default, Clone, PartialEq, Eq)]
91pub struct Sealed {
92 pub copies: usize,
94 pub versions: usize,
96 pub failed: usize,
99 pub waiting: usize,
102 pub orphans: usize,
106}
107
108impl Sealed {
109 pub fn any(&self) -> bool {
111 self.copies + self.versions + self.failed + self.waiting + self.orphans > 0
112 }
113
114 pub fn and(self, other: Sealed) -> Sealed {
116 Sealed {
117 copies: self.copies + other.copies,
118 versions: self.versions + other.versions,
119 failed: self.failed + other.failed,
120 waiting: self.waiting + other.waiting,
121 orphans: self.orphans + other.orphans,
122 }
123 }
124}
125
126#[derive(Debug, Default)]
134pub struct Judged {
135 files: std::collections::BTreeMap<PathBuf, FileStamp>,
136 waiting: usize,
138 met: HashSet<PathBuf>,
140 changed: bool,
141}
142
143#[derive(serde::Serialize, serde::Deserialize)]
145struct JudgedFile {
146 format: u32,
147 files: std::collections::BTreeMap<PathBuf, FileStamp>,
148 #[serde(default)]
149 waiting: usize,
150}
151
152const JUDGED_FORMAT: u32 = 1;
154
155impl Judged {
156 pub fn load(path: &Path) -> Judged {
158 let (files, waiting) = std::fs::read(path).ok().and_then(|bytes| serde_json::from_slice::<JudgedFile>(&bytes).ok()).filter(|f| f.format == JUDGED_FORMAT).map(|f| (f.files, f.waiting)).unwrap_or_default();
159 Judged { files, waiting, met: HashSet::new(), changed: false }
160 }
161
162 pub fn waiting(&self) -> usize {
164 self.waiting
165 }
166
167 pub fn set_waiting(&mut self, waiting: usize) {
169 if self.waiting != waiting {
170 self.waiting = waiting;
171 self.changed = true;
172 }
173 }
174
175 pub fn save(&mut self, path: &Path) -> std::io::Result<()> {
179 let before = self.files.len();
180 let met = std::mem::take(&mut self.met);
181 self.files.retain(|file, _| met.contains(file));
182 if !self.changed && self.files.len() == before {
183 return Ok(());
184 }
185 if let Some(parent) = path.parent() {
186 std::fs::create_dir_all(parent)?;
187 }
188 let text = serde_json::to_vec(&JudgedFile { format: JUDGED_FORMAT, files: self.files.clone(), waiting: self.waiting }).map_err(std::io::Error::other)?;
189 crate::filelock::replace(path, &text)?;
190 self.changed = false;
191 Ok(())
192 }
193
194 fn unseen(&mut self, path: &Path) -> Option<FileStamp> {
197 let now = FileStamp::now(path)?;
198 self.met.insert(path.to_path_buf());
199 (self.files.get(path) != Some(&now)).then_some(now)
200 }
201
202 fn mark(&mut self, path: &Path, stamp: FileStamp) {
205 self.met.insert(path.to_path_buf());
206 if self.files.insert(path.to_path_buf(), stamp) != Some(stamp) {
207 self.changed = true;
208 }
209 }
210}
211
212pub struct Certs<'a> {
216 load: Box<dyn Fn() -> Keys + 'a>,
217 keys: std::cell::OnceCell<Keys>,
218}
219
220impl<'a> Certs<'a> {
221 pub fn lazy(load: impl Fn() -> Keys + 'a) -> Certs<'a> {
223 Certs { load: Box::new(load), keys: std::cell::OnceCell::new() }
224 }
225
226 pub fn of(keys: Keys) -> Certs<'a> {
228 let cell = std::cell::OnceCell::new();
229 let _ = cell.set(keys);
230 Certs { load: Box::new(Keys::default), keys: cell }
231 }
232
233 fn get(&self) -> Vec<&Cert> {
234 self.keys.get_or_init(|| (self.load)()).own_certs()
235 }
236
237 pub fn read(&self) -> bool {
239 self.keys.get().is_some()
240 }
241}
242
243#[cfg(test)]
244thread_local! {
245 static READ: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
247}
248
249fn read_copy(path: &Path) -> std::io::Result<(Vec<u8>, std::fs::Metadata)> {
251 #[cfg(test)]
252 READ.with(|n| n.set(n.get() + 1));
253 crate::filelock::read_whole_with(path)
254}
255
256fn copy_id(path: &Path) -> Option<String> {
259 let name = path.file_name()?.to_str()?;
260 let id = name.strip_suffix(".eml")?;
261 (!id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric())).then(|| id.to_string())
262}
263
264pub fn message_id(raw: &[u8]) -> Option<String> {
266 let message = mail_parser::MessageParser::default().parse_headers(raw)?;
267 Some(crate::mailindex::bare_id(message.message_id()?)).filter(|id| !id.is_empty())
268}
269
270fn may_hold_decrypted(raw: &[u8], answers: Option<&str>, find: &mut dyn FnMut(&str) -> Vec<PathBuf>) -> bool {
275 let mut ids: Vec<String> = message_id(raw).into_iter().collect();
276 if let Some(answers) = answers.map(crate::mailindex::bare_id).filter(|id| !id.is_empty() && !ids.contains(id)) {
277 ids.push(answers);
278 }
279 if ids.is_empty() {
280 return true;
281 }
282 let found: Vec<Vec<u8>> = ids.iter().flat_map(|id| find(id)).filter_map(|path| crate::filelock::read_whole(&path).ok()).collect();
283 let every_clear = !found.is_empty() && found.iter().all(|message| !pgp::is_encrypted(message));
284 !(every_clear && found.iter().any(|message| message.as_slice() == raw))
285}
286
287pub fn seal_beside(drafts: &Path, certs: &Certs, find: &mut dyn FnMut(&str) -> Vec<PathBuf>, judged: &mut Judged, hold: Hold) -> Sealed {
292 let mut out = Sealed::default();
293 let Ok(entries) = std::fs::read_dir(drafts) else { return out };
294 let mut copies: Vec<(PathBuf, String)> = entries.filter_map(Result::ok).filter(|e| e.file_type().is_ok_and(|t| t.is_file())).filter_map(|e| Some((e.path(), copy_id(&e.path())?))).collect();
295 copies.sort();
296 let mut batch = Batch { waiting: VecDeque::new(), bytes: 0, hold };
297 let mut orphans = Vec::new();
298 for (path, id) in copies {
299 let draft = Draft::load(&drafts.join(format!("{id}.toml")));
300 if draft.is_none() && orphaned(&path) {
302 orphans.push(path);
303 continue;
304 }
305 let Some(stamp) = judged.unseen(&path) else { continue };
306 if draft.as_ref().is_some_and(|d| d.copy_as_received) {
308 judged.mark(&path, stamp);
309 continue;
310 }
311 let Ok((raw, meta)) = read_copy(&path) else { continue };
312 let read = FileStamp::of(&meta);
314 let answers = draft.as_ref().and_then(|d| d.in_reply_to.clone());
315 if pgp::is_encrypted(&raw) || !may_hold_decrypted(&raw, answers.as_deref(), find) {
316 judged.mark(&path, read);
317 continue;
318 }
319 match prepare(&path, &raw, &certs.get()) {
320 Some(Ok((prepared, size))) => batch.push(Waiting { prepared, was: read, path, version: false }, size, judged, &mut out),
321 Some(Err(_)) => out.failed += 1,
322 None => out.waiting += 1,
323 }
324 }
325 batch.flush(judged, &mut out);
326 let mut orphans: VecDeque<PathBuf> = orphans.into();
327 while !orphans.is_empty() {
329 let Some(held) = hold() else { break };
330 let started = Instant::now();
331 while let Some(path) = orphans.pop_front() {
332 if !drafts.join(format!("{}.toml", copy_id(&path).unwrap_or_default())).exists() && std::fs::remove_file(&path).is_ok() {
334 out.orphans += 1;
335 }
336 if started.elapsed() >= HOLD_AT_MOST || held.wanted() {
337 break;
338 }
339 }
340 drop(held);
341 if !orphans.is_empty() {
342 std::thread::sleep(LET_GO);
343 }
344 }
345 out
346}
347
348fn orphaned(path: &Path) -> bool {
350 let old = std::fs::metadata(path).ok().and_then(|m| m.modified().ok()).and_then(|t| t.elapsed().ok()).is_some_and(|age| age.as_secs() > 24 * 3600);
351 old && crate::filelock::read_whole(path).is_ok_and(|raw| pgp::is_encrypted(&raw))
352}
353
354pub fn seal_versions(paths: &[PathBuf], certs: &Certs, find: &mut dyn FnMut(&str) -> Vec<PathBuf>, judged: &mut Judged, hold: Hold) -> Sealed {
359 let mut out = Sealed::default();
360 let mut batch = Batch { waiting: VecDeque::new(), bytes: 0, hold };
361 for path in paths {
362 if judged.unseen(path).is_none() {
363 continue;
364 }
365 let Ok((raw, meta)) = read_copy(path) else { continue };
366 let read = FileStamp::of(&meta);
367 if pgp::is_encrypted(&raw) || !may_hold_decrypted(&raw, None, find) {
368 judged.mark(path, read);
369 continue;
370 }
371 match prepare(path, &raw, &certs.get()) {
372 Some(Ok((prepared, size))) => batch.push(Waiting { prepared, was: read, path: path.clone(), version: true }, size, judged, &mut out),
373 Some(Err(_)) => out.failed += 1,
374 None => out.waiting += 1,
375 }
376 }
377 batch.flush(judged, &mut out);
378 out
379}
380
381fn prepare(path: &Path, clear: &[u8], to: &[&Cert]) -> Option<Result<(Prepared, usize), String>> {
385 if to.is_empty() {
386 return None;
387 }
388 Some(pgp::seal_message(clear, to).and_then(|sealed| Prepared::new(path, &sealed).map(|prepared| (prepared, sealed.len())).map_err(|e| format!("{}: {e}", path.display()))))
389}
390
391struct Waiting {
394 prepared: Prepared,
395 was: FileStamp,
396 path: PathBuf,
397 version: bool,
398}
399
400struct Batch<'a> {
402 waiting: VecDeque<Waiting>,
403 bytes: usize,
404 hold: Hold<'a>,
405}
406
407impl Batch<'_> {
408 fn push(&mut self, waiting: Waiting, size: usize, judged: &mut Judged, out: &mut Sealed) {
409 self.bytes += size;
410 self.waiting.push_back(waiting);
411 if self.waiting.len() >= BATCH_FILES || self.bytes >= BATCH_BYTES {
412 self.flush(judged, out);
413 }
414 }
415
416 fn flush(&mut self, judged: &mut Judged, out: &mut Sealed) {
421 self.bytes = 0;
422 while !self.waiting.is_empty() {
423 let Some(held) = (self.hold)() else {
424 out.failed += self.waiting.len();
425 self.waiting.clear();
426 return;
427 };
428 let started = Instant::now();
429 let mut placed = Vec::new();
430 while let Some(waiting) = self.waiting.pop_front() {
431 match waiting.prepared.place(Some(waiting.was)) {
432 Ok(Some(done)) => placed.push((done, waiting.path, waiting.version)),
433 Ok(None) => {}
434 Err(_) => out.failed += 1,
435 }
436 if started.elapsed() >= HOLD_AT_MOST || held.wanted() {
437 break;
438 }
439 }
440 drop(held);
441 for (done, path, version) in placed {
442 let stamp = done.stamp();
444 done.finish();
445 if version {
446 out.versions += 1;
447 } else {
448 out.copies += 1;
449 }
450 if let Some(stamp) = stamp {
451 judged.mark(&path, stamp);
452 }
453 }
454 if !self.waiting.is_empty() {
455 std::thread::sleep(LET_GO);
456 }
457 }
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use crate::pgp::{SessionKeys, Unlock};
465
466 fn scratch(name: &str) -> PathBuf {
467 let base = std::env::temp_dir().join(format!("sioul-draftcopies-{name}-{}", std::process::id()));
468 let _ = std::fs::remove_dir_all(&base);
469 std::fs::create_dir_all(&base).unwrap();
470 base
471 }
472
473 fn keys_of(key: &[u8]) -> Keys {
474 let cert = Cert::from_bytes(key).unwrap();
475 Keys { own: vec![cert], ..Keys::default() }
476 }
477
478 use sequoia_openpgp::parse::Parse;
479
480 const CLEAR: &str = "From: Jane <jane@example.org>\r\nTo: Noa <noa@example.org>\r\nSubject: The lease\r\nMessage-ID: <lease@example.org>\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nThe secret clause is on page four.\r\n";
481
482 fn draft_with(folder: &Path, id: &str, encrypt: bool) {
483 let draft = Draft { id: id.to_string(), encrypt, ..Draft::default() };
484 std::fs::write(folder.join(format!("{id}.toml")), toml::to_string(&draft).unwrap()).unwrap();
485 }
486
487 fn opens(raw: &[u8], keys: &Keys) -> Vec<u8> {
488 let sessions = SessionKeys::new();
489 let none = |_: &pgp::Fingerprint| None;
490 pgp::open(raw, keys, &Unlock::new(&none, &sessions)).and_then(|(opened, _)| opened).expect("opens with your key")
491 }
492
493 #[test]
494 fn a_copy_of_an_encrypted_message_is_sealed_again_and_a_clear_one_left() {
495 let base = scratch("beside");
496 let (drafts, mail) = (base.join("drafts"), base.join("mail"));
497 std::fs::create_dir_all(&drafts).unwrap();
498 std::fs::create_dir_all(&mail).unwrap();
499 let key = pgp::stand_in_key("Noa <noa@example.org>").unwrap();
500 let keys = keys_of(&key);
501 let cert = Cert::from_bytes(&key).unwrap();
502 std::fs::write(mail.join("lease"), pgp::seal_message(CLEAR.as_bytes(), &[&cert]).unwrap()).unwrap();
504 let invoice = CLEAR.replace("lease@example.org", "invoice@example.org").replace("The secret clause is on page four.", "Your invoice.");
505 std::fs::write(mail.join("invoice"), &invoice).unwrap();
506 std::fs::write(drafts.join("a1.eml"), CLEAR).unwrap();
508 std::fs::write(drafts.join("a2.eml"), &invoice).unwrap();
509 let gone = CLEAR.replace("lease@example.org", "gone@example.org");
511 std::fs::write(drafts.join("a3.eml"), &gone).unwrap();
512 std::fs::write(drafts.join("a4.eml"), gone.replace("gone@", "gone2@")).unwrap();
513 for (id, encrypt) in [("a1", false), ("a2", false), ("a3", false), ("a4", true)] {
514 draft_with(&drafts, id, encrypt);
515 }
516 let mut find = |id: &str| match id {
517 "lease@example.org" => vec![mail.join("lease")],
518 "invoice@example.org" => vec![mail.join("invoice")],
519 _ => Vec::new(),
520 };
521 let mut judged = Judged::default();
522 let sealed = seal_beside(&drafts, &Certs::of(keys.clone()), &mut find, &mut judged, &unshared);
523 assert_eq!(sealed, Sealed { copies: 3, ..Sealed::default() });
525 let a1 = std::fs::read(drafts.join("a1.eml")).unwrap();
527 assert!(pgp::is_encrypted(&a1));
528 assert!(!String::from_utf8_lossy(&a1).contains("secret clause"));
529 assert!(String::from_utf8_lossy(&opens(&a1, &keys)).contains("The secret clause is on page four."));
530 assert!(pgp::is_encrypted(&std::fs::read(drafts.join("a4.eml")).unwrap()));
531 assert_eq!(std::fs::read_to_string(drafts.join("a2.eml")).unwrap(), invoice);
533 assert!(pgp::is_encrypted(&std::fs::read(drafts.join("a3.eml")).unwrap()), "found nowhere: sealed, in doubt");
534 for id in ["a1", "a2", "a3", "a4"] {
536 assert!(Draft::load(&drafts.join(format!("{id}.toml"))).is_some());
537 }
538 let names: Vec<String> = std::fs::read_dir(&drafts).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect();
539 assert_eq!(names.len(), 8, "{names:?}");
540 let again = seal_beside(&drafts, &Certs::of(keys.clone()), &mut |_| panic!("not asked again"), &mut judged, &unshared);
542 assert_eq!(again, Sealed::default());
543 let fresh = seal_beside(&drafts, &Certs::of(keys.clone()), &mut find, &mut Judged::default(), &unshared);
545 assert_eq!(fresh, Sealed::default());
546 let _ = std::fs::remove_dir_all(&base);
547 }
548
549 #[test]
556 fn a_forged_duplicate_or_no_message_id_leaves_no_copy_in_clear() {
557 let base = scratch("forged");
558 let (drafts, mail) = (base.join("drafts"), base.join("mail"));
559 std::fs::create_dir_all(&drafts).unwrap();
560 std::fs::create_dir_all(&mail).unwrap();
561 let key = pgp::stand_in_key("Noa <noa@example.org>").unwrap();
562 let keys = keys_of(&key);
563 std::fs::write(mail.join("archive"), pgp::seal_message(CLEAR.as_bytes(), &keys.own_certs()).unwrap()).unwrap();
564 std::fs::write(mail.join("inbox"), CLEAR.replace("The secret clause is on page four.", "Nothing to see.")).unwrap();
565 std::fs::write(drafts.join("a1.eml"), CLEAR).unwrap();
566 std::fs::write(drafts.join("a2.eml"), CLEAR.replace("Message-ID: <lease@example.org>\r\n", "")).unwrap();
567 for id in ["a1", "a2"] {
568 draft_with(&drafts, id, false);
569 }
570 let mut find = |id: &str| if id == "lease@example.org" { vec![mail.join("inbox"), mail.join("archive")] } else { Vec::new() };
572 assert_eq!(seal_beside(&drafts, &Certs::of(keys.clone()), &mut find, &mut Judged::default(), &unshared), Sealed { copies: 2, ..Sealed::default() });
573 for id in ["a1", "a2"] {
574 assert!(pgp::is_encrypted(&std::fs::read(drafts.join(format!("{id}.eml"))).unwrap()), "{id}");
575 }
576 let _ = std::fs::remove_dir_all(&base);
577 }
578
579 #[test]
584 fn a_copy_quoting_an_armored_block_is_still_sealed() {
585 let base = scratch("armored");
586 let (drafts, mail) = (base.join("drafts"), base.join("mail"));
587 std::fs::create_dir_all(&drafts).unwrap();
588 std::fs::create_dir_all(&mail).unwrap();
589 let keys = keys_of(&pgp::stand_in_key("Noa <noa@example.org>").unwrap());
590 let quoting = format!("{CLEAR}> -----BEGIN PGP MESSAGE-----\r\n> hQEMA0aY\r\n> -----END PGP MESSAGE-----\r\n");
591 assert!(!pgp::is_encrypted(quoting.as_bytes()));
592 std::fs::write(mail.join("lease"), pgp::seal_message(quoting.as_bytes(), &keys.own_certs()).unwrap()).unwrap();
593 std::fs::write(drafts.join("a1.eml"), "ing).unwrap();
594 draft_with(&drafts, "a1", false);
595 let mut find = |_: &str| vec![mail.join("lease")];
596 assert_eq!(seal_beside(&drafts, &Certs::of(keys.clone()), &mut find, &mut Judged::default(), &unshared).copies, 1);
597 let inline = "From: Jane <jane@example.org>\r\nSubject: The lease\r\n\r\n-----BEGIN PGP MESSAGE-----\r\n\r\nhQEMA0aY\r\n-----END PGP MESSAGE-----\r\n";
598 assert!(pgp::is_encrypted(inline.as_bytes()));
599 let _ = std::fs::remove_dir_all(&base);
600 }
601
602 #[test]
610 fn copies_kept_as_they_came_are_left_and_the_others_judged_by_their_message() {
611 let base = scratch("as-came");
612 let mail = scratch("as-came-mail");
613 let keys = keys_of(&pgp::stand_in_key("Noa <noa@example.org>").unwrap());
614 std::fs::write(base.join("a1.eml"), CLEAR).unwrap();
615 let draft = Draft { id: "a1".into(), copy_as_received: true, ..Draft::default() };
616 std::fs::write(base.join("a1.toml"), toml::to_string(&draft).unwrap()).unwrap();
617 let apple = "Content-Type: text/plain; charset=us-ascii\r\nMime-Version: 1.0 (Mac OS X Mail 16.0)\r\nFrom: Jane <jane@example.org>\r\nSubject: The lease\r\nMessage-ID: <a@example.org>\r\n\r\nThe clause.\r\n";
618 let outlook = "From: Jane <jane@example.org>\r\nSubject: The lease\r\nContent-Type: text/plain; charset=utf-8\r\nMIME-Version: 1.0\r\nMessage-ID: <o@example.org>\r\n\r\nThe clause.\r\n";
619 let plain = "From: Jane <jane@example.org>\r\nSubject: The lease\r\n\r\nThe clause, no Message-ID.\r\n";
620 for (id, text) in [("a2", apple), ("a3", outlook), ("a4", plain)] {
621 std::fs::write(base.join(format!("{id}.eml")), text).unwrap();
622 std::fs::write(mail.join(id), text).unwrap();
623 }
624 draft_with(&base, "a2", false);
625 draft_with(&base, "a3", false);
626 let answering = Draft { id: "a4".into(), in_reply_to: Some("<p@example.org>".into()), ..Draft::default() };
628 std::fs::write(base.join("a4.toml"), toml::to_string(&answering).unwrap()).unwrap();
629 let mut find = |id: &str| match id {
630 "a@example.org" => vec![mail.join("a2")],
631 "o@example.org" => vec![mail.join("a3")],
632 "p@example.org" => vec![mail.join("a4")],
633 _ => Vec::new(),
634 };
635 let left = seal_beside(&base, &Certs::lazy(|| -> Keys { panic!("nothing to seal") }), &mut find, &mut Judged::default(), &unshared);
636 assert_eq!(left, Sealed::default(), "kept as it came, or byte for byte a clear message found here");
637 let sealed = seal_beside(&base, &Certs::of(keys.clone()), &mut |_| Vec::new(), &mut Judged::default(), &unshared);
639 assert_eq!(sealed, Sealed { copies: 3, ..Sealed::default() });
640 assert_eq!(std::fs::read_to_string(base.join("a1.eml")).unwrap(), CLEAR, "kept as it came: untouched");
641 let _ = std::fs::remove_dir_all(&base);
642 let _ = std::fs::remove_dir_all(&mail);
643 }
644
645 #[test]
652 fn a_decrypted_copy_without_headers_of_its_own_is_sealed() {
653 let base = scratch("headless");
654 let mail = scratch("headless-mail");
655 let key = pgp::stand_in_key("Noa <noa@example.org>").unwrap();
656 let keys = keys_of(&key);
657 let cert = Cert::from_bytes(&key).unwrap();
658 let none = |_: &pgp::Fingerprint| None;
659 let sessions = SessionKeys::new();
660 let unlock = Unlock::new(&none, &sessions);
661 for (n, entity) in ["\r\nSECRET-2 the clause\r\n", "SECRET-3 the clause\r\nline two\r\n"].iter().enumerate() {
662 let armored = pgp::encrypt(entity.as_bytes(), &[&cert], None).unwrap();
663 let received = format!(
664 "From: Jane <jane@example.org>\nTo: Noa <noa@example.org>\nSubject: ...\nMessage-ID: <s{n}@example.org>\nMIME-Version: 1.0\nContent-Type: multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"o\"\n\n--o\nContent-Type: application/pgp-encrypted\n\nVersion: 1\n\n--o\nContent-Type: application/octet-stream\n\n{armored}\n--o--\n"
665 );
666 std::fs::write(mail.join(format!("m{n}")), &received).unwrap();
667 let (opened, _) = pgp::open(received.as_bytes(), &keys, &unlock).unwrap();
668 let copy = opened.unwrap();
669 assert!(!pgp::looks_opened(©), "its shape says nothing");
670 std::fs::write(base.join(format!("b{n}.eml")), ©).unwrap();
671 draft_with(&base, &format!("b{n}"), false);
672 }
673 let mut find = |id: &str| if id == "s0@example.org" { vec![mail.join("m0")] } else { Vec::new() };
674 let done = seal_beside(&base, &Certs::of(keys.clone()), &mut find, &mut Judged::default(), &unshared);
675 assert_eq!(done, Sealed { copies: 2, ..Sealed::default() });
676 for n in 0..2 {
677 let copy = std::fs::read(base.join(format!("b{n}.eml"))).unwrap();
678 assert!(pgp::is_encrypted(©) && !String::from_utf8_lossy(©).contains("SECRET-"));
679 }
680 let _ = std::fs::remove_dir_all(&base);
681 let _ = std::fs::remove_dir_all(&mail);
682 }
683
684 #[test]
691 fn a_copy_replaced_while_the_look_reads_the_mail_is_looked_at_again() {
692 let base = scratch("race-a");
693 let mail = scratch("race-a-mail");
694 let keys = keys_of(&pgp::stand_in_key("Noa <noa@example.org>").unwrap());
695 let thunderbird = "Message-ID: <lease@example.org>\r\nMIME-Version: 1.0\r\nSubject: The lease\r\nFrom: Jane <jane@example.org>\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\nNothing secret here.\r\n";
696 std::fs::write(mail.join("m1"), thunderbird).unwrap();
697 let path = base.join("a1.eml");
698 std::fs::write(&path, thunderbird).unwrap();
699 draft_with(&base, "a1", false);
700 let incoming = CLEAR.to_string();
701 let mut find = |id: &str| {
702 let beside = base.join(".incoming");
703 std::fs::write(&beside, &incoming).unwrap();
704 std::fs::rename(&beside, &path).unwrap();
705 if id == "lease@example.org" { vec![mail.join("m1")] } else { Vec::new() }
706 };
707 let verdicts = base.join("state").join("draftcopies.json");
708 let mut judged = Judged::load(&verdicts);
709 assert_eq!(seal_beside(&base, &Certs::of(keys.clone()), &mut find, &mut judged, &unshared), Sealed::default(), "the copy read was its clear message");
710 judged.save(&verdicts).unwrap();
711 let mut later = Judged::load(&verdicts);
712 assert_eq!(seal_beside(&base, &Certs::of(keys.clone()), &mut |_| Vec::new(), &mut later, &unshared).copies, 1, "the version put in its place meanwhile, looked at");
713 assert!(pgp::is_encrypted(&std::fs::read(&path).unwrap()));
714 let _ = std::fs::remove_dir_all(&base);
715 let _ = std::fs::remove_dir_all(&mail);
716 }
717
718 #[test]
724 fn a_copy_replaced_as_soon_as_the_look_lets_go_is_looked_at_again() {
725 struct Swap {
726 path: PathBuf,
727 bytes: Vec<u8>,
728 armed: std::rc::Rc<std::cell::Cell<bool>>,
729 }
730 impl Held for Swap {}
731 impl Drop for Swap {
732 fn drop(&mut self) {
733 if self.armed.replace(false) {
734 let beside = self.path.with_extension("incoming");
735 std::fs::write(&beside, &self.bytes).unwrap();
736 std::fs::rename(&beside, &self.path).unwrap();
737 }
738 }
739 }
740 let base = scratch("race-b");
741 let keys = keys_of(&pgp::stand_in_key("Noa <noa@example.org>").unwrap());
742 let path = base.join("a1.eml");
743 std::fs::write(&path, CLEAR).unwrap();
744 draft_with(&base, "a1", true);
745 let second = CLEAR.replace("page four", "page five");
746 let armed = std::rc::Rc::new(std::cell::Cell::new(true));
747 let hold = || -> Option<Box<dyn Held>> { Some(Box::new(Swap { path: path.clone(), bytes: second.clone().into_bytes(), armed: armed.clone() })) };
748 let mut judged = Judged::default();
749 assert_eq!(seal_beside(&base, &Certs::of(keys.clone()), &mut |_| Vec::new(), &mut judged, &hold).copies, 1);
750 assert!(String::from_utf8_lossy(&std::fs::read(&path).unwrap()).contains("page five"), "the other version took its place");
751 assert_eq!(seal_beside(&base, &Certs::of(keys.clone()), &mut |_| Vec::new(), &mut judged, &unshared).copies, 1, "and is looked at");
752 assert!(pgp::is_encrypted(&std::fs::read(&path).unwrap()));
753 let _ = std::fs::remove_dir_all(&base);
754 }
755
756 #[test]
757 fn without_a_certificate_of_yours_nothing_is_written() {
758 let base = scratch("unkeyed");
759 std::fs::write(base.join("a1.eml"), CLEAR).unwrap();
760 draft_with(&base, "a1", true);
761 let mut judged = Judged::default();
762 let none = Keys::default();
763 assert_eq!(seal_beside(&base, &Certs::of(none.clone()), &mut |_| Vec::new(), &mut judged, &unshared), Sealed { waiting: 1, ..Sealed::default() });
764 assert_eq!(std::fs::read_to_string(base.join("a1.eml")).unwrap(), CLEAR);
765 let keys = keys_of(&pgp::stand_in_key("Noa <noa@example.org>").unwrap());
767 assert_eq!(seal_beside(&base, &Certs::of(keys.clone()), &mut |_| Vec::new(), &mut judged, &unshared).copies, 1);
768 let _ = std::fs::remove_dir_all(&base);
769 }
770
771 #[test]
780 fn an_encrypted_copy_whose_draft_went_is_taken_out_a_day_later() {
781 let base = scratch("orphans");
782 let key = pgp::stand_in_key("Noa <noa@example.org>").unwrap();
783 let keys = keys_of(&key);
784 let sealed = pgp::seal_message(CLEAR.as_bytes(), &[&Cert::from_bytes(&key).unwrap()]).unwrap();
785 for id in ["gone", "fresh", "kept"] {
786 std::fs::write(base.join(format!("{id}.eml")), &sealed).unwrap();
787 }
788 let day_old = |id: &str| std::fs::File::options().write(true).open(base.join(format!("{id}.eml"))).unwrap().set_modified(std::time::SystemTime::now() - Duration::from_secs(25 * 3600)).unwrap();
789 day_old("gone");
790 day_old("kept");
791 draft_with(&base, "kept", true);
792 let refused = || -> Option<Box<dyn Held>> { None };
793 assert_eq!(seal_beside(&base, &Certs::of(keys.clone()), &mut |_| Vec::new(), &mut Judged::default(), &refused), Sealed::default());
794 assert!(base.join("gone.eml").exists(), "without the lock: left");
795 assert_eq!(seal_beside(&base, &Certs::of(keys.clone()), &mut |_| Vec::new(), &mut Judged::default(), &unshared), Sealed { orphans: 1, ..Sealed::default() });
796 assert!(!base.join("gone.eml").exists(), "a day old, its draft gone: taken out");
797 assert!(base.join("fresh.eml").exists() && base.join("kept.eml").exists() && base.join("kept.toml").exists());
798 let _ = std::fs::remove_dir_all(&base);
799 }
800
801 #[test]
802 fn nothing_takes_its_place_without_the_lock() {
803 let base = scratch("unlocked");
804 std::fs::write(base.join("a1.eml"), CLEAR).unwrap();
805 draft_with(&base, "a1", true);
806 let keys = keys_of(&pgp::stand_in_key("Noa <noa@example.org>").unwrap());
807 let mut judged = Judged::default();
808 let refused = || -> Option<Box<dyn Held>> { None };
809 assert_eq!(seal_beside(&base, &Certs::of(keys.clone()), &mut |_| Vec::new(), &mut judged, &refused), Sealed { failed: 1, ..Sealed::default() });
810 assert_eq!(std::fs::read_to_string(base.join("a1.eml")).unwrap(), CLEAR);
811 assert_eq!(std::fs::read_dir(&base).unwrap().count(), 2, "the copy and its draft alone");
812 let holding = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
814 struct Guard(std::sync::Arc<std::sync::atomic::AtomicUsize>);
815 impl Held for Guard {}
816 impl Drop for Guard {
817 fn drop(&mut self) {
818 self.0.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
819 }
820 }
821 let counted = {
822 let holding = holding.clone();
823 move || -> Option<Box<dyn Held>> {
824 assert_eq!(holding.fetch_add(1, std::sync::atomic::Ordering::SeqCst), 0, "one hold at a time");
825 Some(Box::new(Guard(holding.clone())))
826 }
827 };
828 assert_eq!(seal_beside(&base, &Certs::of(keys.clone()), &mut |_| Vec::new(), &mut judged, &counted).copies, 1);
829 assert_eq!(holding.load(std::sync::atomic::Ordering::SeqCst), 0, "given back");
830 assert!(pgp::is_encrypted(&std::fs::read(base.join("a1.eml")).unwrap()));
831 let _ = std::fs::remove_dir_all(&base);
832 }
833
834 #[test]
837 fn a_copy_changed_since_it_was_read_keeps_its_new_version() {
838 let base = scratch("changed");
839 let path = base.join("a1.eml");
840 std::fs::write(&path, CLEAR).unwrap();
841 let (_, meta) = crate::filelock::read_whole_with(&path).unwrap();
842 let prepared = Prepared::new(&path, b"sealed").unwrap();
843 std::fs::write(base.join(".a1.eml.incoming"), "another version").unwrap();
845 std::fs::rename(base.join(".a1.eml.incoming"), &path).unwrap();
846 assert!(prepared.place(Some(FileStamp::of(&meta))).unwrap().is_none());
847 assert_eq!(std::fs::read_to_string(&path).unwrap(), "another version");
848 assert_eq!(std::fs::read_dir(&base).unwrap().count(), 1, "nothing left beside it");
849 let (_, meta) = crate::filelock::read_whole_with(&path).unwrap();
851 let prepared = Prepared::new(&path, b"sealed").unwrap();
852 std::fs::remove_file(&path).unwrap();
853 assert!(prepared.place(Some(FileStamp::of(&meta))).unwrap().is_none());
854 assert!(!path.exists());
855 assert_eq!(std::fs::read_dir(&base).unwrap().count(), 0);
856 let _ = std::fs::remove_dir_all(&base);
857 }
858
859 #[test]
865 fn a_later_process_reads_nothing_met_before_and_no_key() {
866 let base = scratch("verdicts");
867 let keys = keys_of(&pgp::stand_in_key("Noa <noa@example.org>").unwrap());
868 let verdicts = base.join("state").join("draftcopies.json");
869 let large = format!("{CLEAR}{}\r\n", "The lease runs three years. ".repeat(3_700));
870 let versions: Vec<PathBuf> = (0..40)
871 .map(|n| {
872 let path = base.join("history").join(format!("2026-10-18T10-00-{n:02}.000Z"));
873 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
874 std::fs::write(&path, &large).unwrap();
875 path
876 })
877 .collect();
878 let mut judged = Judged::load(&verdicts);
879 assert_eq!(seal_versions(&versions, &Certs::of(keys.clone()), &mut |_| Vec::new(), &mut judged, &unshared).versions, 40);
880 judged.save(&verdicts).unwrap();
881 READ.with(|n| n.set(0));
883 let mut later = Judged::load(&verdicts);
884 let certs = Certs::lazy(|| -> Keys { panic!("no key read for a look with nothing to seal") });
885 assert_eq!(seal_versions(&versions, &certs, &mut |_| Vec::new(), &mut later, &unshared), Sealed::default());
886 assert_eq!(READ.with(|n| n.get()), 0, "nothing read again");
887 assert!(!certs.read());
888 std::fs::write(&versions[7], CLEAR).unwrap();
890 READ.with(|n| n.set(0));
891 assert_eq!(seal_versions(&versions, &Certs::of(keys.clone()), &mut |_| Vec::new(), &mut later, &unshared).versions, 1);
892 assert_eq!(READ.with(|n| n.get()), 1);
893 std::fs::remove_file(&versions[3]).unwrap();
895 later.save(&verdicts).unwrap();
896 let mut after = Judged::load(&verdicts);
897 assert_eq!(seal_versions(&versions, &Certs::of(keys.clone()), &mut |_| Vec::new(), &mut after, &unshared), Sealed::default());
898 after.save(&verdicts).unwrap();
899 let kept: JudgedFile = serde_json::from_slice(&std::fs::read(&verdicts).unwrap()).unwrap();
900 assert_eq!(kept.files.len(), 39);
901 std::fs::write(&verdicts, "not json").unwrap();
902 assert!(Judged::load(&verdicts).files.is_empty());
903 let _ = std::fs::remove_dir_all(&base);
904 }
905
906 #[test]
907 fn earlier_versions_are_sealed_too() {
908 let base = scratch("versions");
909 let keys = keys_of(&pgp::stand_in_key("Noa <noa@example.org>").unwrap());
910 let (clear, sealed) = (base.join("2026-10-18T10-00-00.000Z"), base.join("2026-10-19T10-00-00.000Z"));
911 std::fs::write(&clear, CLEAR).unwrap();
912 std::fs::write(&sealed, pgp::seal_message(CLEAR.as_bytes(), &keys.own_certs()).unwrap()).unwrap();
913 let before = std::fs::read(&sealed).unwrap();
914 let done = seal_versions(&[clear.clone(), sealed.clone()], &Certs::of(keys.clone()), &mut |_| Vec::new(), &mut Judged::default(), &unshared);
915 assert_eq!(done, Sealed { versions: 1, ..Sealed::default() });
916 assert!(!String::from_utf8_lossy(&std::fs::read(&clear).unwrap()).contains("secret clause"));
917 assert_eq!(std::fs::read(&sealed).unwrap(), before, "one sealed already is left as it is");
918 let _ = std::fs::remove_dir_all(&base);
919 }
920
921 #[cfg(unix)]
924 #[test]
925 fn the_old_bytes_are_overwritten_and_the_path_never_missing() {
926 use std::io::{Read, Seek};
927 let base = scratch("wipe");
928 let path = base.join("a1.eml");
929 std::fs::write(&path, CLEAR).unwrap();
930 let mut before = std::fs::File::open(&path).unwrap();
931 crate::filelock::replace_wiping(&path, b"sealed").unwrap();
932 assert_eq!(std::fs::read(&path).unwrap(), b"sealed");
933 let mut old = Vec::new();
934 before.seek(std::io::SeekFrom::Start(0)).unwrap();
935 before.read_to_end(&mut old).unwrap();
936 assert_eq!(old.len(), CLEAR.len());
937 assert!(old.iter().all(|&b| b == 0), "the old file still says {:?}", String::from_utf8_lossy(&old));
938 assert_eq!(std::os::unix::fs::PermissionsExt::mode(&std::fs::metadata(&path).unwrap().permissions()) & 0o777, 0o600);
940 assert_eq!(std::fs::read_dir(&base).unwrap().count(), 1);
941 let other = base.join("other");
943 std::fs::hard_link(&path, &other).unwrap();
944 crate::filelock::replace_wiping(&path, b"sealed again").unwrap();
945 assert_eq!(std::fs::read(&other).unwrap(), b"sealed");
946 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
951 let reader = {
952 let (path, stop) = (path.clone(), stop.clone());
953 std::thread::spawn(move || {
954 let (mut read, mut again) = (0usize, 0usize);
955 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
956 match crate::filelock::read_whole(&path) {
957 Ok(bytes) => {
958 assert!(bytes == b"sealed again" || bytes == b"third", "{bytes:?}");
959 read += 1;
960 }
961 Err(e) if e.kind() == std::io::ErrorKind::Interrupted => again += 1,
963 Err(e) => panic!("never missing: {e}"),
964 }
965 }
966 (read, again)
967 })
968 };
969 for _ in 0..500 {
970 crate::filelock::replace_wiping(&path, b"third").unwrap();
971 crate::filelock::replace_wiping(&path, b"sealed again").unwrap();
972 }
973 stop.store(true, std::sync::atomic::Ordering::Relaxed);
974 let (read, again) = reader.join().unwrap();
975 assert!(read > 0 && again <= read / 10 + 1, "{read} reads, {again} given up");
976 let _ = std::fs::remove_dir_all(&base);
977 }
978}