1use std::{
11 collections::{BTreeMap, BTreeSet, hash_map::RandomState},
12 fs,
13 hash::{BuildHasher, Hasher},
14 io,
15 path::{Path, PathBuf},
16 process,
17 string::ToString,
18 thread,
19 vec::Vec,
20};
21
22use log::trace;
23use thiserror::Error;
24
25use crate::{
26 checksum::validate_checksum,
27 coroutine::*,
28 entry::{
29 M2dirEntry, M2dirEntryParseError, M2dirFullEntry, delete::*, get::*, list::*, store::*,
30 },
31 flag::{M2dirFlags, add::*, remove::*, set::*},
32 m2dir::{DOT_M2DIR, M2dir, M2dirLoadError, create::*, delete::*, list::*},
33 path::M2dirPath,
34 store::{DOT_M2STORE, M2dirStore, M2dirStoreError},
35};
36
37#[derive(Debug, Error)]
39pub enum M2dirClientError {
40 #[error(transparent)]
42 Store(#[from] M2dirStoreError),
43 #[error(transparent)]
45 LoadM2dir(#[from] M2dirLoadError),
46 #[error(transparent)]
48 CreateM2dir(#[from] M2dirCreateError),
49 #[error(transparent)]
51 DeleteM2dir(#[from] M2dirDeleteError),
52 #[error(transparent)]
54 ListM2dirs(#[from] M2dirListError),
55 #[error(transparent)]
57 ListEntries(#[from] M2dirEntryListError),
58 #[error(transparent)]
60 GetEntry(#[from] M2dirEntryGetError),
61 #[error(transparent)]
63 StoreEntry(#[from] M2dirEntryStoreError),
64 #[error(transparent)]
66 DeleteEntry(#[from] M2dirEntryDeleteError),
67 #[error(transparent)]
69 AddFlags(#[from] M2dirFlagAddError),
70 #[error(transparent)]
72 RemoveFlags(#[from] M2dirFlagRemoveError),
73 #[error(transparent)]
75 SetFlags(#[from] M2dirFlagSetError),
76 #[error(transparent)]
78 Parse(#[from] M2dirEntryParseError),
79 #[error(transparent)]
81 Io(#[from] io::Error),
82}
83
84#[derive(Debug)]
90pub struct M2dirClient {
91 root: M2dirPath,
92}
93
94impl M2dirClient {
96 pub fn new(root: impl Into<M2dirPath>) -> Self {
99 Self { root: root.into() }
100 }
101
102 pub fn root(&self) -> &M2dirPath {
104 &self.root
105 }
106
107 pub fn run<C, T, E>(&self, mut coroutine: C) -> Result<T, M2dirClientError>
111 where
112 C: M2dirCoroutine<Yield = M2dirYield, Return = Result<T, E>>,
113 M2dirClientError: From<E>,
114 {
115 let mut arg: Option<M2dirArg> = None;
116
117 loop {
118 match coroutine.resume(arg.take()) {
119 M2dirCoroutineState::Complete(Ok(out)) => return Ok(out),
120 M2dirCoroutineState::Complete(Err(err)) => return Err(err.into()),
121 M2dirCoroutineState::Yielded(M2dirYield::WantsPid) => {
122 arg = Some(M2dirArg::Pid(process::id()));
123 }
124 M2dirCoroutineState::Yielded(M2dirYield::WantsRandom { len }) => {
125 arg = Some(M2dirArg::Random(random_bytes(len)));
126 }
127 M2dirCoroutineState::Yielded(M2dirYield::WantsFileExists(paths)) => {
128 arg = Some(M2dirArg::FileExists(file_exists(paths)));
129 }
130 M2dirCoroutineState::Yielded(M2dirYield::WantsDirRead(paths)) => {
131 arg = Some(M2dirArg::DirRead(read_dirs(paths)?));
132 }
133 M2dirCoroutineState::Yielded(M2dirYield::WantsDirCreate(paths)) => {
134 create_dirs(paths)?;
135 arg = Some(M2dirArg::DirCreate);
136 }
137 M2dirCoroutineState::Yielded(M2dirYield::WantsDirRemove(paths)) => {
138 remove_dirs(paths)?;
139 arg = Some(M2dirArg::DirRemove);
140 }
141 M2dirCoroutineState::Yielded(M2dirYield::WantsFileRead(paths)) => {
142 arg = Some(M2dirArg::FileRead(read_files_tolerant(paths)?));
143 }
144 M2dirCoroutineState::Yielded(M2dirYield::WantsFileCreate(files)) => {
145 write_files(files)?;
146 arg = Some(M2dirArg::FileCreate);
147 }
148 M2dirCoroutineState::Yielded(M2dirYield::WantsFileRemove(paths)) => {
149 remove_files_tolerant(paths)?;
150 arg = Some(M2dirArg::FileRemove);
151 }
152 M2dirCoroutineState::Yielded(M2dirYield::WantsRename(pairs)) => {
153 rename_paths(pairs)?;
154 arg = Some(M2dirArg::Rename);
155 }
156 }
157 }
158 }
159
160 pub fn open_store(&self) -> Result<M2dirStore, M2dirClientError> {
163 load_store(self.root.clone()).map_err(Into::into)
164 }
165
166 pub fn init_store(&self) -> Result<M2dirStore, M2dirClientError> {
169 trace!("init m2store at {}", self.root);
170
171 fs::create_dir_all(self.root.as_str())?;
172 let marker = self.root.join(DOT_M2STORE);
173 if !Path::new(marker.as_str()).exists() {
174 fs::write(marker.as_str(), b"")?;
175 }
176
177 Ok(M2dirStore::from_path(self.root.clone()))
178 }
179
180 pub fn open_m2dir(&self, path: impl Into<M2dirPath>) -> Result<M2dir, M2dirClientError> {
183 load_m2dir(path.into()).map_err(Into::into)
184 }
185
186 pub fn create_m2dir(&self, name: &str) -> Result<M2dir, M2dirClientError> {
189 let store = self.open_store()?;
190 let coroutine = M2dirCreate::new(&store, name, M2dirCreateOptions::default())?;
191 self.run(coroutine)
192 }
193
194 pub fn delete_m2dir(&self, path: impl Into<M2dirPath>) -> Result<(), M2dirClientError> {
196 self.run(M2dirDelete::new(path, M2dirDeleteOptions::default()))
197 }
198
199 pub fn list_m2dirs(&self) -> Result<BTreeSet<M2dir>, M2dirClientError> {
201 let store = self.open_store()?;
202 self.run(M2dirList::new(&store, M2dirListOptions::default()))
203 }
204}
205
206impl M2dirClient {
208 pub fn list_entries(&self, m2dir: M2dir) -> Result<Vec<M2dirEntry>, M2dirClientError> {
210 self.run(M2dirEntryList::new(m2dir, M2dirEntryListOptions::default()))
211 }
212
213 pub fn read_entry(&self, entry: &M2dirEntry) -> Result<Vec<u8>, M2dirClientError> {
218 let path = entry.path();
219 trace!("read entry at {path}");
220
221 let bytes = fs::read(path.as_str())?;
222 let checksum = entry.checksum();
223
224 if !validate_checksum(checksum, &bytes) {
225 return Err(M2dirEntryParseError::InvalidChecksum {
226 path: path.clone(),
227 expected: checksum.to_string(),
228 got: entry.id().to_string(),
229 }
230 .into());
231 }
232
233 Ok(bytes)
234 }
235
236 pub fn read_entries(
242 &self,
243 m2dir: &M2dir,
244 entries: &[M2dirEntry],
245 ) -> Result<BTreeSet<M2dirFullEntry>, M2dirClientError> {
246 entries
247 .iter()
248 .map(|entry| self.read_full_entry(m2dir, entry))
249 .collect()
250 }
251
252 pub fn read_entries_par(
256 &self,
257 m2dir: &M2dir,
258 entries: &[M2dirEntry],
259 ) -> Result<BTreeSet<M2dirFullEntry>, M2dirClientError> {
260 if entries.len() <= 1 {
261 return entries
262 .iter()
263 .map(|entry| self.read_full_entry(m2dir, entry))
264 .collect();
265 }
266
267 let n_threads = thread::available_parallelism()
268 .map(|n| n.get())
269 .unwrap_or(8)
270 .min(entries.len());
271
272 let chunk_size = entries.len().div_ceil(n_threads);
273
274 thread::scope(|s| -> Result<BTreeSet<M2dirFullEntry>, M2dirClientError> {
275 let mut handles = Vec::with_capacity(n_threads);
276
277 for chunk in entries.chunks(chunk_size) {
278 let this = self;
279
280 handles.push(s.spawn(move || {
281 chunk
282 .iter()
283 .map(|entry| this.read_full_entry(m2dir, entry))
284 .collect::<Result<Vec<_>, _>>()
285 }));
286 }
287
288 let mut out = BTreeSet::new();
289
290 for handle in handles {
291 for full in handle.join().expect("m2dir worker thread panicked")? {
292 out.insert(full);
293 }
294 }
295
296 Ok(out)
297 })
298 }
299
300 fn read_full_entry(
301 &self,
302 m2dir: &M2dir,
303 entry: &M2dirEntry,
304 ) -> Result<M2dirFullEntry, M2dirClientError> {
305 let contents = self.read_entry(entry)?;
306 let flags = self.read_flags(m2dir, entry.id())?;
307
308 Ok(M2dirFullEntry::from_parts(entry.clone(), contents, flags))
309 }
310
311 pub fn get(
314 &self,
315 m2dir: M2dir,
316 id: impl ToString,
317 ) -> Result<(M2dirEntry, Vec<u8>), M2dirClientError> {
318 let M2dirEntryGetOutput { entry, contents } = self.run(M2dirEntryGet::new(
319 m2dir,
320 id,
321 M2dirEntryGetOptions::default(),
322 ))?;
323 Ok((entry, contents))
324 }
325
326 pub fn store(&self, m2dir: M2dir, bytes: Vec<u8>) -> Result<M2dirEntry, M2dirClientError> {
329 self.run(M2dirEntryStore::new(
330 m2dir,
331 bytes,
332 M2dirEntryStoreOptions::default(),
333 ))
334 }
335
336 pub fn delete_entry(&self, m2dir: M2dir, id: impl ToString) -> Result<(), M2dirClientError> {
338 self.run(M2dirEntryDelete::new(
339 m2dir,
340 id,
341 M2dirEntryDeleteOptions::default(),
342 ))
343 }
344}
345
346impl M2dirClient {
348 pub fn read_flags(
351 &self,
352 m2dir: &M2dir,
353 id: impl AsRef<str>,
354 ) -> Result<M2dirFlags, M2dirClientError> {
355 let path = m2dir.flags_path(id.as_ref());
356 trace!("read flags at {path}");
357
358 match fs::read_to_string(path.as_str()) {
359 Ok(text) => Ok(M2dirFlags::from_meta(&text)),
360 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(M2dirFlags::default()),
361 Err(err) => Err(err.into()),
362 }
363 }
364
365 pub fn add_flags(
367 &self,
368 m2dir: &M2dir,
369 id: impl AsRef<str>,
370 flags: M2dirFlags,
371 ) -> Result<(), M2dirClientError> {
372 self.run(M2dirFlagAdd::new(
373 m2dir,
374 id,
375 flags,
376 M2dirFlagAddOptions::default(),
377 ))
378 }
379
380 pub fn remove_flags(
383 &self,
384 m2dir: &M2dir,
385 id: impl AsRef<str>,
386 flags: M2dirFlags,
387 ) -> Result<(), M2dirClientError> {
388 self.run(M2dirFlagRemove::new(
389 m2dir,
390 id,
391 flags,
392 M2dirFlagRemoveOptions::default(),
393 ))
394 }
395
396 pub fn set_flags(
399 &self,
400 m2dir: &M2dir,
401 id: impl AsRef<str>,
402 flags: M2dirFlags,
403 ) -> Result<(), M2dirClientError> {
404 self.run(M2dirFlagSet::new(
405 m2dir,
406 id,
407 flags,
408 M2dirFlagSetOptions::default(),
409 ))
410 }
411}
412
413fn load_store(path: M2dirPath) -> Result<M2dirStore, M2dirStoreError> {
416 if !Path::new(path.as_str()).is_dir() {
417 return Err(M2dirStoreError::NotDir(path));
418 }
419
420 let marker = path.join(DOT_M2STORE);
421 if !Path::new(marker.as_str()).exists() {
422 return Err(M2dirStoreError::NoDotM2store(path));
423 }
424
425 Ok(M2dirStore::from_path(path))
426}
427
428fn load_m2dir(path: M2dirPath) -> Result<M2dir, M2dirLoadError> {
431 if !Path::new(path.as_str()).is_dir() {
432 return Err(M2dirLoadError::NotDir(path));
433 }
434
435 let marker = path.join(DOT_M2DIR);
436 if !Path::new(marker.as_str()).exists() {
437 return Err(M2dirLoadError::NoDotM2dir(path));
438 }
439
440 Ok(M2dir::from_path(path))
441}
442
443fn normalize_path(path: PathBuf) -> M2dirPath {
446 let s = path.to_string_lossy().into_owned();
447 #[cfg(windows)]
448 let s = s.replace('\\', "/");
449 M2dirPath::new(s)
450}
451
452fn create_dirs(paths: BTreeSet<M2dirPath>) -> Result<(), io::Error> {
453 for path in paths {
454 trace!("create_dir_all {path}");
455 fs::create_dir_all(path.as_str())?;
456 }
457 Ok(())
458}
459
460fn remove_dirs(paths: BTreeSet<M2dirPath>) -> Result<(), io::Error> {
461 for path in paths {
462 trace!("remove_dir_all {path}");
463 fs::remove_dir_all(path.as_str())?;
464 }
465 Ok(())
466}
467
468fn write_files(files: BTreeMap<M2dirPath, Vec<u8>>) -> Result<(), io::Error> {
469 for (path, contents) in files {
470 trace!("write {path} ({} bytes)", contents.len());
471
472 if let Some(parent) = Path::new(path.as_str()).parent() {
473 fs::create_dir_all(parent)?;
474 }
475 fs::write(path.as_str(), &contents)?;
476 }
477 Ok(())
478}
479
480fn remove_files_tolerant(paths: BTreeSet<M2dirPath>) -> Result<(), io::Error> {
481 for path in paths {
482 trace!("remove_file (tolerant) {path}");
483 match fs::remove_file(path.as_str()) {
484 Ok(()) => {}
485 Err(err) if err.kind() == io::ErrorKind::NotFound => {}
486 Err(err) => return Err(err),
487 }
488 }
489 Ok(())
490}
491
492fn read_dirs(
493 paths: BTreeSet<M2dirPath>,
494) -> Result<BTreeMap<M2dirPath, BTreeSet<M2dirPath>>, io::Error> {
495 let mut entries = BTreeMap::new();
496
497 for path in paths {
498 trace!("read_dir {path}");
499
500 let mut names = BTreeSet::new();
501 match fs::read_dir(path.as_str()) {
502 Ok(iter) => {
503 for entry in iter {
504 let entry = entry?;
505 names.insert(normalize_path(entry.path()));
506 }
507 }
508 Err(err) if err.kind() == io::ErrorKind::NotFound => {}
509 Err(err) if err.kind() == io::ErrorKind::NotADirectory => {}
510 Err(err) => return Err(err),
511 }
512
513 entries.insert(path, names);
514 }
515
516 Ok(entries)
517}
518
519fn read_files_tolerant(
520 paths: BTreeSet<M2dirPath>,
521) -> Result<BTreeMap<M2dirPath, Vec<u8>>, io::Error> {
522 let mut contents = BTreeMap::new();
523
524 for path in paths {
525 trace!("read_file (tolerant) {path}");
526 match fs::read(path.as_str()) {
527 Ok(bytes) => {
528 contents.insert(path, bytes);
529 }
530 Err(err) if err.kind() == io::ErrorKind::NotFound => {
531 contents.insert(path, Vec::new());
532 }
533 Err(err) => return Err(err),
534 }
535 }
536
537 Ok(contents)
538}
539
540fn rename_paths(pairs: Vec<(M2dirPath, M2dirPath)>) -> Result<(), io::Error> {
541 for (from, to) in pairs {
542 trace!("rename {from} -> {to}");
543 fs::rename(from.as_str(), to.as_str())?;
544 }
545 Ok(())
546}
547
548fn file_exists(paths: BTreeSet<M2dirPath>) -> BTreeMap<M2dirPath, bool> {
549 let mut out = BTreeMap::new();
550 for path in paths {
551 let exists = fs::metadata(path.as_str())
552 .map(|m| m.is_file())
553 .unwrap_or(false);
554 trace!("file_exists {path}: {exists}");
555 out.insert(path, exists);
556 }
557 out
558}
559
560fn random_bytes(len: usize) -> Vec<u8> {
563 let mut state = RandomState::new().build_hasher().finish();
564 if state == 0 {
565 state = 0xdeadbeef;
566 }
567
568 let mut out = Vec::with_capacity(len);
569 let mut buf = 0u64;
570 let mut i = 8;
571
572 while out.len() < len {
573 if i == 8 {
574 state ^= state << 13;
575 state ^= state >> 7;
576 state ^= state << 17;
577 buf = state;
578 i = 0;
579 }
580 out.push(buf as u8);
581 buf >>= 8;
582 i += 1;
583 }
584
585 out
586}
587
588#[cfg(test)]
589mod tests {
590 use std::path::Path;
591
592 use tempfile::tempdir;
593
594 use crate::{client::*, flag::M2dirFlags};
595
596 fn client() -> (tempfile::TempDir, M2dirClient) {
597 let dir = tempdir().unwrap();
598 let root = dir.path().to_string_lossy().into_owned();
599 let client = M2dirClient::new(root);
600 client.init_store().unwrap();
601 (dir, client)
602 }
603
604 #[test]
605 fn init_store_writes_marker() {
606 let (dir, _client) = client();
607 assert!(dir.path().join(".m2store").exists());
608 }
609
610 #[test]
611 fn create_m2dir_writes_marker() {
612 let (_dir, client) = client();
613
614 let inbox = client.create_m2dir("inbox").unwrap();
615 assert!(Path::new(inbox.path().as_str()).is_dir());
616 assert!(Path::new(inbox.marker_path().as_str()).exists());
617 assert!(Path::new(inbox.meta_dir().as_str()).is_dir());
618 }
619
620 #[test]
621 fn list_m2dirs_finds_created_folder() {
622 let (_dir, client) = client();
623
624 client.create_m2dir("inbox").unwrap();
625 client.create_m2dir("sent").unwrap();
626
627 let m2dirs = client.list_m2dirs().unwrap();
628 assert_eq!(m2dirs.len(), 2);
629 }
630
631 #[test]
632 fn store_and_list_entries_round_trip() {
633 let (_dir, client) = client();
634
635 let inbox = client.create_m2dir("inbox").unwrap();
636 let msg = b"From: alice@example.org\r\nDate: Tue, 15 Apr 1994 08:12:31 GMT\r\nSubject: hi\r\n\r\nbody\r\n";
637
638 let entry = client.store(inbox.clone(), msg.to_vec()).unwrap();
639 assert!(Path::new(entry.path().as_str()).is_file());
640
641 let listed = client.list_entries(inbox.clone()).unwrap();
642 assert_eq!(listed.len(), 1);
643 assert_eq!(listed[0].id(), entry.id());
644
645 let (fetched, contents) = client.get(inbox, entry.id()).unwrap();
646 assert_eq!(fetched.id(), entry.id());
647 assert_eq!(contents, msg);
648 }
649
650 #[test]
651 fn flags_round_trip_via_meta() {
652 let (_dir, client) = client();
653
654 let inbox = client.create_m2dir("inbox").unwrap();
655 let msg = b"From: a\r\n\r\nbody\r\n";
656 let entry = client.store(inbox.clone(), msg.to_vec()).unwrap();
657
658 let initial = client.read_flags(&inbox, entry.id()).unwrap();
659 assert_eq!(initial.len(), 0);
660
661 let mut to_add = M2dirFlags::default();
662 to_add.insert("$seen");
663 to_add.insert("$forwarded");
664 client.add_flags(&inbox, entry.id(), to_add).unwrap();
665
666 let after_add = client.read_flags(&inbox, entry.id()).unwrap();
667 assert_eq!(after_add.len(), 2);
668 assert!(after_add.contains("$seen"));
669 assert!(after_add.contains("$forwarded"));
670
671 let mut to_remove = M2dirFlags::default();
672 to_remove.insert("$seen");
673 client.remove_flags(&inbox, entry.id(), to_remove).unwrap();
674
675 let after_remove = client.read_flags(&inbox, entry.id()).unwrap();
676 assert_eq!(after_remove.len(), 1);
677 assert!(after_remove.contains("$forwarded"));
678
679 let mut replacement = M2dirFlags::default();
680 replacement.insert("custom");
681 replacement.insert("$junk");
682 client.set_flags(&inbox, entry.id(), replacement).unwrap();
683
684 let after_set = client.read_flags(&inbox, entry.id()).unwrap();
685 assert_eq!(after_set.len(), 2);
686 assert!(after_set.contains("custom"));
687 assert!(after_set.contains("$junk"));
688
689 client
690 .set_flags(&inbox, entry.id(), M2dirFlags::default())
691 .unwrap();
692 let after_clear = client.read_flags(&inbox, entry.id()).unwrap();
693 assert!(after_clear.is_empty());
694 assert!(!Path::new(inbox.flags_path(entry.id()).as_str()).exists());
695 }
696
697 #[test]
698 fn delete_entry_removes_file_and_flags_meta() {
699 let (_dir, client) = client();
700
701 let inbox = client.create_m2dir("inbox").unwrap();
702 let entry = client.store(inbox.clone(), b"hello".to_vec()).unwrap();
703
704 let mut flags = M2dirFlags::default();
705 flags.insert("$seen");
706 client.add_flags(&inbox, entry.id(), flags).unwrap();
707 assert!(Path::new(inbox.flags_path(entry.id()).as_str()).exists());
708
709 client.delete_entry(inbox.clone(), entry.id()).unwrap();
710 assert!(!Path::new(entry.path().as_str()).exists());
711 assert!(!Path::new(inbox.flags_path(entry.id()).as_str()).exists());
712
713 let listed = client.list_entries(inbox).unwrap();
714 assert!(listed.is_empty());
715 }
716
717 #[test]
718 fn delete_m2dir_removes_tree() {
719 let (_dir, client) = client();
720
721 let inbox = client.create_m2dir("inbox").unwrap();
722 let path = inbox.path().clone();
723 assert!(Path::new(path.as_str()).is_dir());
724
725 client.delete_m2dir(path.clone()).unwrap();
726 assert!(!Path::new(path.as_str()).exists());
727 }
728}