1use base64::Engine;
40use base64::engine::general_purpose::STANDARD as B64;
41use chacha20poly1305::aead::rand_core::RngCore;
42use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng, Payload};
43use chacha20poly1305::{XChaCha20Poly1305, XNonce};
44use serde::{Deserialize, Serialize};
45use sioul_core::config::Config;
46use std::collections::{BTreeMap, BTreeSet};
47use std::io::Write;
48use std::path::{Path, PathBuf};
49
50pub const KEY_NAME: &str = "Sharing key";
52
53const SEP: char = '\u{1f}';
55const MARK: char = '\u{1e}';
57const LARGEST: u64 = 16 << 20;
59const ROUND_SIZE: u64 = 1 << 20;
62const SEGMENT: u64 = 32 << 10;
67const FRUGAL_ROUND_SIZE: u64 = 4 << 20;
71
72static FRUGAL: std::sync::Mutex<BTreeSet<PathBuf>> = std::sync::Mutex::new(BTreeSet::new());
75
76pub fn set_frugal(memory: &Path, on: bool) {
80 if let Ok(mut frugal) = FRUGAL.lock() {
81 if on {
82 frugal.insert(memory.to_path_buf());
83 } else {
84 frugal.remove(memory);
85 }
86 }
87}
88
89fn frugal(memory: &Path) -> bool {
90 FRUGAL.lock().is_ok_and(|f| f.contains(memory))
91}
92
93const IMPORT_ROUND_SIZE: u64 = 64 << 20;
98
99static IMPORTING: std::sync::Mutex<BTreeSet<PathBuf>> = std::sync::Mutex::new(BTreeSet::new());
101
102pub fn set_importing(memory: &Path, on: bool) {
108 if let Ok(mut importing) = IMPORTING.lock() {
109 if on {
110 importing.insert(memory.to_path_buf());
111 } else {
112 importing.remove(memory);
113 }
114 }
115}
116
117fn importing(memory: &Path) -> bool {
118 IMPORTING.lock().is_ok_and(|i| i.contains(memory))
119}
120const TWIN_WITHIN: i64 = 6 * 60 * 60_000;
123const TOMBSTONE_DAYS: i64 = 90;
125const SILENT_DAYS: i64 = 180;
127const EMPTIED_WAIT: i64 = 10 * 60_000;
131
132const AWAITED: i64 = 60 * 60_000;
138
139const AWAIT_STEP: i64 = 5 * 60_000;
146
147const OWED_METERED: i64 = 6 * 60 * 60_000;
153const LONGEST_LINE: usize = 32 << 20;
156const READ_CHUNK: usize = 8 << 20;
158const MASS: usize = 10;
161const MINUTE: i64 = 60_000;
162const HOUR: i64 = 60 * MINUTE;
163const DAY: i64 = 24 * HOUR;
164
165const FILES: &str = "files/";
167
168#[derive(Debug, Clone, Copy)]
170pub enum Shape {
171 Whole,
173 Lines,
175 Toml(&'static Rules),
177 Files,
181}
182
183#[derive(Debug)]
185pub struct Rules {
186 pub keyed: &'static [Keyed],
188 pub whole: &'static [&'static str],
190 pub local: &'static [&'static str],
192 pub next: Option<&'static Rules>,
198}
199
200impl Rules {
201 pub fn at(&'static self, format: u32) -> &'static Rules {
203 match self.next {
204 Some(next) if format >= 2 => next,
205 _ => self,
206 }
207 }
208}
209
210#[derive(Debug)]
213pub struct Keyed {
214 pub list: &'static str,
217 pub by: &'static [&'static str],
218 pub local: &'static [&'static str],
219 pub also: &'static [&'static str],
223 pub ids: bool,
229}
230
231const fn keyed(list: &'static str, by: &'static [&'static str]) -> Keyed {
233 Keyed { list, by, local: &[], also: &[], ids: false }
234}
235
236const fn with_ids(list: &'static str, by: &'static [&'static str]) -> Keyed {
238 Keyed { list, by, local: &[], also: &[], ids: true }
239}
240
241const CONFIG_LOCAL: &[&str] = &["case_store", "known_senders", "blocked_senders", "reading", "history_weeks", "letters.inbox", "dnd.background"];
243static CONFIG_RULES: Rules = Rules { keyed: &[Keyed { list: "account", by: &["id"], local: &["maildir", "history_weeks"], also: &[], ids: false }], whole: &[], local: CONFIG_LOCAL, next: Some(&CONFIG_RULES_2) };
244static CONFIG_RULES_2: Rules = Rules {
250 keyed: &[
251 Keyed { list: "account", by: &["id"], local: &["maildir", "history_weeks"], also: &[], ids: false },
252 with_ids("site", &["id"]),
253 with_ids("routine", &["id"]),
254 with_ids("tasks.kind", &["id"]),
255 with_ids("time_off", &[]),
256 keyed("words.*.*.add", &[]),
257 keyed("words.*.*.remove", &[]),
258 keyed("words.*.*.add.*", &[]),
260 ],
261 whole: &[],
262 local: CONFIG_LOCAL,
263 next: None,
264};
265static HEALTH_RULES: Rules = Rules {
266 keyed: &[keyed("prescription", &["id"]), keyed("medicine", &["id"])],
267 whole: &[],
268 local: &["watch_folder"],
271 next: None,
272};
273static LINKS_RULES: Rules = Rules { keyed: &[keyed("link", &[])], whole: &[], local: &[], next: None };
274static PORCH_RULES: Rules = Rules { keyed: &[], whole: &["done.*"], local: &[], next: None };
275static MONEY_RULES: Rules = Rules { keyed: &[keyed("ignored", &[])], whole: &[], local: &[], next: None };
276static TODAY_RULES: Rules = Rules { keyed: &[keyed("aside", &[])], whole: &[], local: &[], next: None };
277static TIME_RULES: Rules = Rules { keyed: &[keyed("session", &["start", "task", "project"])], whole: &[], local: &[], next: Some(&TIME_RULES_2) };
278static TIME_RULES_2: Rules = Rules { keyed: &[with_ids("session", &["start", "task", "project"])], whole: &[], local: &[], next: None };
281static QUIET_RULES: Rules = Rules { keyed: &[], whole: &[], local: &[], next: Some(&QUIET_RULES_2) };
284static QUIET_RULES_2: Rules = Rules { keyed: &[keyed("lighter", &[])], whole: &[], local: &[], next: None };
285static REVIEWS_RULES: Rules = Rules { keyed: &[], whole: &["*.work", "*.night"], local: &[], next: None };
289static PLAIN_RULES: Rules = Rules { keyed: &[], whole: &[], local: &[], next: None };
290static DND_RULES: Rules = Rules { keyed: &[], whole: &["device.*"], local: &[], next: None };
293static DND_PEOPLE_RULES: Rules = Rules { keyed: &[keyed("person", &["id"])], whole: &[], local: &[], next: None };
295static DOSES_RULES: Rules = Rules { keyed: &[], whole: &["dose.*.answer.*"], local: &[], next: None };
299static PROJECTS_RULES: Rules = Rules { keyed: &[Keyed { list: "case", by: &["id"], local: &[], also: &["project"], ids: false }], whole: &[], local: &[], next: None };
304static BANK_RULES: Rules = Rules { keyed: &[keyed("account", &["id"]), keyed("movement", &["account", "id"])], whole: &[], local: &[], next: None };
306static LEDGER_RULES: Rules = Rules {
307 keyed: &[
308 keyed("budget", &["id"]),
309 keyed("preset", &["id"]),
310 keyed("reserve", &["id"]),
311 keyed("bank_account", &["id"]),
312 keyed("assign", &["account", "movement"]),
313 keyed("line", &[]),
315 keyed("cover", &[]),
316 keyed("mail_rule", &[]),
317 keyed("split", &[]),
318 ],
319 whole: &[],
320 local: &[],
321 next: Some(&LEDGER_RULES_2),
322};
323static LEDGER_RULES_2: Rules = Rules {
328 keyed: &[
329 keyed("budget", &["id"]),
330 with_ids("preset", &["id"]),
331 keyed("reserve", &["id"]),
332 keyed("bank_account", &["id"]),
333 keyed("assign", &["account", "movement"]),
334 with_ids("line", &[]),
335 with_ids("cover", &[]),
336 with_ids("mail_rule", &[]),
337 with_ids("split", &[]),
338 ],
339 whole: &[],
340 local: &[],
341 next: None,
342};
343static CONTRACTS_RULES: Rules = Rules { keyed: &[keyed("contract", &["id"])], whole: &[], local: &[], next: None };
344static PAPERS_RULES: Rules = Rules { keyed: &[keyed("paper", &["id"])], whole: &[], local: &[], next: None };
345const NOT_NOTES: &[&str] = &[
348 sioul_core::projects::FILE,
349 sioul_core::projects::OLD_FILE,
350 sioul_core::projects::BEFORE_RENAME,
351 sioul_core::budget::LEDGER,
352 sioul_core::bank::MANIFEST,
353 sioul_core::contracts::MANIFEST,
354 sioul_core::papers::MANIFEST,
355 sioul_core::papers::FOLDER,
356];
357
358pub const PARTS: [&str; 13] = ["settings", "senders", "calls", sioul_core::phonemsgs::PART, sioul_core::texts::PART, "spam", "health", "time", "drafts", "projects", "lists", "notes", "papers"];
365
366const RETIRED: &[&str] = &["data/watch/", "state/watch-offers.json", "state/texts/"];
374
375fn retired(key: &str) -> bool {
377 let file = file_of(key);
378 RETIRED.iter().any(|store| if store.ends_with('/') { file.starts_with(store) } else { file == *store })
379}
380
381pub const CALLS_LOG: &str = "state/calls/log/";
386pub const CALLS_SEEN: &str = "state/calls/seen/";
389const CALLS: &str = "state/calls/";
394
395pub const PHONE_MESSAGES_LOG: &str = "state/phone-messages/log/";
401pub const PHONE_MESSAGES_SEEN: &str = "state/phone-messages/seen/";
404const PHONE_MESSAGES: &str = "state/phone-messages/";
408
409pub const TEXTS_LOG: &str = "data/texts/log/";
415pub const TEXTS_SEND: &str = "data/texts/send/";
417pub const TEXTS_OUTCOME: &str = "data/texts/outcome/";
419const TEXTS: &str = "data/texts/";
422
423fn calls_store(name: &str) -> bool {
427 name.starts_with(CALLS) || name.starts_with(PHONE_MESSAGES) || name.starts_with(TEXTS)
429}
430
431pub const SPAM_TABLE: &str = "files/spam/table.bin";
439
440pub const SPAM_LABELS: &str = "state/spam/labels/";
446pub const SPAM_MOVED: &str = "state/spam/moved/";
450pub const SPAM_FLAGGED: &str = "state/spam/flagged/";
453
454pub fn shared_by_default(part: &str, config: &Config) -> bool {
460 match part {
461 "projects" => config.share_projects,
462 "notes" | "papers" => false,
463 sioul_core::phonemsgs::PART => false,
465 sioul_core::texts::PART => false,
467 _ => true,
468 }
469}
470
471#[derive(Debug, Clone)]
473pub struct Store {
474 pub name: String,
476 pub path: PathBuf,
477 pub folder: bool,
479 pub shape: Shape,
480 pub skip: &'static [&'static str],
482 pub part: &'static str,
484}
485
486pub struct Roots {
488 pub config: PathBuf,
489 pub data: PathBuf,
490 pub state: PathBuf,
491}
492
493impl Roots {
494 pub fn here() -> Roots {
495 Roots { config: sioul_core::config::config_dir(), data: sioul_core::config::data_dir(), state: sioul_core::config::state_dir() }
496 }
497}
498
499pub fn stores(config: &Config, roots: &Roots) -> Vec<Store> {
501 stores_of(config, roots, &|part| shared_by_default(part, config))
502}
503
504pub const FORMAT: u32 = 2;
514
515pub fn part_format(part: &str) -> u32 {
519 match part {
520 "settings" | "time" | "projects" => 2,
521 _ => 1,
522 }
523}
524
525pub fn stores_of(config: &Config, roots: &Roots, shares: &dyn Fn(&str) -> bool) -> Vec<Store> {
530 let (c, d, s) = (&roots.config, &roots.data, &roots.state);
531 let file = |part: &'static str, name: &str, path: PathBuf, shape: Shape| Store { name: name.into(), path, folder: false, shape, skip: &[], part };
532 let folder = |part: &'static str, name: &str, path: PathBuf, shape: Shape, skip: &'static [&'static str]| Store { name: name.into(), path, folder: true, shape, skip, part };
533 let senders = |chosen: &Option<String>, name: &str| chosen.as_deref().map_or_else(|| c.join(name), sioul_core::config::expand_home);
534 let local = sioul_core::vdir::LOCAL;
535 let mut stores = vec![
536 file("settings", "config/config.toml", c.join("config.toml"), Shape::Toml(&CONFIG_RULES)),
537 file("senders", "config/known-senders.txt", senders(&config.known_senders, "known-senders.txt"), Shape::Lines),
538 file("senders", "config/blocked-senders.txt", senders(&config.blocked_senders, "blocked-senders.txt"), Shape::Lines),
539 file("senders", "config/safe-senders.txt", c.join("safe-senders.txt"), Shape::Lines),
540 file("senders", "config/neutral-senders.txt", c.join("neutral-senders.txt"), Shape::Lines),
541 file("senders", "config/restricted-senders.txt", c.join("restricted-senders.txt"), Shape::Lines),
542 file("senders", "config/dnd-people.toml", c.join(sioul_core::everywhere::PEOPLE_FILE), Shape::Toml(&DND_PEOPLE_RULES)),
544 file("settings", "data/links.toml", d.join("links.toml"), Shape::Toml(&LINKS_RULES)),
545 file("health", "data/health.toml", d.join("health.toml"), Shape::Toml(&HEALTH_RULES)),
546 file("health", "data/health-days.toml", d.join("health-days.toml"), Shape::Toml(&PLAIN_RULES)),
548 file("time", "data/time/running.toml", d.join("time").join("running.toml"), Shape::Whole),
549 folder("time", "data/time/", d.join("time"), Shape::Toml(&TIME_RULES), &["running.toml"]),
550 folder("time", "data/reviews/", d.join("reviews"), Shape::Toml(&REVIEWS_RULES), &[]),
553 folder("drafts", "data/drafts/", d.join("drafts"), Shape::Whole, &[]),
554 folder("drafts", "data/invoices/", d.join("invoices"), Shape::Whole, &[]),
555 folder("senders", "data/pgp/others/", d.join("pgp").join("others"), Shape::Whole, &[]),
556 folder("lists", "data/calendars/local/", d.join("calendars").join(local), Shape::Whole, &[]),
557 folder("lists", "data/contacts/local/", d.join("contacts").join(local), Shape::Whole, &[]),
558 file("settings", "state/porch.toml", s.join("porch.toml"), Shape::Toml(&PORCH_RULES)),
559 file("settings", "state/money.toml", s.join("money.toml"), Shape::Toml(&MONEY_RULES)),
560 file("settings", "state/do-not-disturb.toml", s.join(sioul_core::everywhere::SWITCH_FILE), Shape::Toml(&DND_RULES)),
562 file("time", "state/today.toml", s.join("today.toml"), Shape::Toml(&TODAY_RULES)),
563 file("time", "state/quiet.toml", s.join("quiet.toml"), Shape::Toml(&QUIET_RULES)),
564 file("time", "state/stopped.toml", s.join("stopped.toml"), Shape::Toml(&PLAIN_RULES)),
565 file("health", "state/health-state.toml", s.join("health-state.toml"), Shape::Toml(&PLAIN_RULES)),
566 file("health", "state/health-doses.toml", s.join(sioul_core::doses::FILE), Shape::Toml(&DOSES_RULES)),
569 folder("senders", "state/shield/", s.join("shield"), Shape::Whole, &[]),
570 folder("lists", "state/dav/local/", s.join("dav").join(local), Shape::Whole, &[]),
571 file("spam", SPAM_TABLE, d.join("spam").join("table.bin"), Shape::Files),
573 folder("spam", SPAM_LABELS, s.join("spam").join(sioul_core::spam::labels::FOLDER), Shape::Lines, &[]),
577 folder("spam", SPAM_MOVED, s.join("spam").join(sioul_core::spam::labels::MOVED), Shape::Lines, &[]),
578 folder("spam", SPAM_FLAGGED, s.join("spam").join(sioul_core::spam::labels::FLAGGED), Shape::Lines, &[]),
579 folder("calls", CALLS_LOG, s.join(sioul_core::calls::FOLDER).join(sioul_core::calls::LOG), Shape::Lines, &[]),
582 folder("calls", CALLS_SEEN, s.join(sioul_core::calls::FOLDER).join(sioul_core::calls::SEEN_LOG), Shape::Lines, &[]),
583 folder(sioul_core::phonemsgs::PART, PHONE_MESSAGES_LOG, s.join(sioul_core::phonemsgs::FOLDER).join(sioul_core::phonemsgs::LOG), Shape::Lines, &[]),
586 folder(sioul_core::phonemsgs::PART, PHONE_MESSAGES_SEEN, s.join(sioul_core::phonemsgs::FOLDER).join(sioul_core::phonemsgs::SEEN_LOG), Shape::Lines, &[]),
587 folder(sioul_core::texts::PART, TEXTS_LOG, d.join(sioul_core::texts::FOLDER).join(sioul_core::texts::LOG), Shape::Lines, &[]),
589 folder(sioul_core::texts::PART, TEXTS_SEND, d.join(sioul_core::texts::FOLDER).join(sioul_core::texts::SEND), Shape::Lines, &[]),
590 folder(sioul_core::texts::PART, TEXTS_OUTCOME, d.join(sioul_core::texts::FOLDER).join(sioul_core::texts::OUTCOME), Shape::Lines, &[]),
591 ];
592 if let Some(notes) = config.notes_root_path() {
596 stores.push(file("projects", "notes/sioul-cases.toml", sioul_core::projects::file_in(¬es), Shape::Toml(&PROJECTS_RULES)));
597 stores.push(file("projects", "notes/sioul-budgets.toml", notes.join(sioul_core::budget::LEDGER), Shape::Toml(&LEDGER_RULES)));
598 stores.push(file("projects", "notes/sioul-bank.toml", notes.join(sioul_core::bank::MANIFEST), Shape::Toml(&BANK_RULES)));
599 stores.push(file("projects", "notes/sioul-contracts.toml", notes.join(sioul_core::contracts::MANIFEST), Shape::Toml(&CONTRACTS_RULES)));
600 stores.push(file("papers", "notes/sioul-papers.toml", notes.join(sioul_core::papers::MANIFEST), Shape::Toml(&PAPERS_RULES)));
601 stores.push(folder("papers", "files/papers/", notes.join(sioul_core::papers::FOLDER), Shape::Files, &[]));
602 stores.push(folder("notes", "files/notes/", notes, Shape::Files, NOT_NOTES));
603 }
604 stores.retain(|store| shares(store.part));
605 stores
606}
607
608pub fn shaped(stores: &[Store], format: u32) -> Vec<Store> {
611 stores
612 .iter()
613 .map(|store| Store {
614 shape: match store.shape {
615 Shape::Toml(rules) => Shape::Toml(rules.at(format)),
616 other => other,
617 },
618 ..store.clone()
619 })
620 .collect()
621}
622
623pub fn estimate(stores: &[Store]) -> (usize, u64, usize) {
627 let (mut found, mut files, known) = (Found::default(), Vec::new(), BTreeMap::new());
628 for store in stores.iter().filter(|s| matches!(s.shape, Shape::Files) && s.path.is_dir()) {
629 Listing { store: &store.name, root: &store.path, skip: store.skip, known: &known, confirmed: false, files: &mut files, found: &mut found }.list(&store.path);
630 }
631 files.iter().filter_map(|(_, path)| std::fs::metadata(path).ok()).fold((0, 0, 0), |(count, bytes, big), meta| if meta.len() > crate::blobs::LARGEST { (count, bytes, big + 1) } else { (count + 1, bytes + meta.len(), big) })
632}
633
634fn known_part(file: &str) -> Option<&'static str> {
638 static KNOWN: std::sync::OnceLock<Vec<(String, bool, &'static str)>> = std::sync::OnceLock::new();
639 let known = KNOWN.get_or_init(|| {
640 let roots = Roots { config: PathBuf::from("config"), data: PathBuf::from("data"), state: PathBuf::from("state") };
641 let config = Config { notes_root: Some("notes".into()), ..Config::default() };
642 stores_of(&config, &roots, &|_| true).into_iter().map(|store| (store.name, store.folder, store.part)).collect()
643 });
644 #[cfg(test)]
645 if tests::OLDER.with(|older| older.borrow().iter().any(|store| file.starts_with(store.as_str()))) {
646 return None;
647 }
648 known.iter().find(|(name, folder, _)| !folder && name == file).or_else(|| known.iter().filter(|(name, folder, _)| *folder && file.starts_with(name.as_str())).max_by_key(|(name, _, _)| name.len())).map(|(_, _, part)| *part)
649}
650
651pub fn shown(file: &str) -> &str {
654 ["files/notes/", "files/", "config/", "data/", "state/", "notes/"].iter().find_map(|root| file.strip_prefix(root)).unwrap_or(file)
655}
656
657#[derive(Debug, Default)]
661struct Found {
662 hashes: BTreeMap<String, String>,
664 values: BTreeMap<String, String>,
666 changed: BTreeMap<String, i64>,
668 unknown: BTreeSet<String>,
671 files: BTreeMap<String, Stat>,
673 too_big: Vec<String>,
675 emptied: Vec<(String, usize)>,
678 said: Vec<String>,
680}
681
682#[derive(Debug, Clone, Default, Serialize, Deserialize)]
685struct Stat {
686 size: u64,
687 modified: u64,
688 entries: Vec<(String, String)>,
689 #[serde(default, skip_serializing_if = "is_zero")]
692 seen: i64,
693}
694
695fn is_zero(n: &i64) -> bool {
696 *n == 0
697}
698
699impl Found {
700 fn is_unknown(&self, key: &str) -> bool {
701 let file = file_of(key);
702 self.unknown.iter().any(|u| file == u || (u.ends_with('/') && file.starts_with(u.as_str())))
703 }
704}
705
706const WHOLE_FOLDERS: &[&str] = &["data/drafts/", "data/invoices/", "data/pgp/others/", "data/calendars/local/", "data/contacts/local/", "state/shield/", "state/dav/local/", FILES];
710
711const DRAFTS: &str = "data/drafts/";
721
722fn sent_meanwhile(met: Option<&BTreeSet<String>>, here: Option<(bool, &str, &str)>, removal: bool, base: &str) -> Option<bool> {
727 let (gone, version, replaced) = here?;
728 let known = |v: &str| !v.is_empty() && (v == version || v == replaced || met.is_some_and(|met| met.contains(v)));
729 match (removal, gone) {
730 (false, true) if known(base) => Some(false),
732 (true, false) if known(base) => Some(true),
734 _ => None,
735 }
736}
737
738fn file_of(key: &str) -> &str {
740 if WHOLE_FOLDERS.iter().any(|folder| key.starts_with(folder))
741 && let Some(file) = key.strip_suffix('#')
742 {
743 return file;
744 }
745 key.split_once('#').map_or(key, |(file, _)| file)
746}
747
748fn entry_of(key: &str) -> &str {
750 key.get(file_of(key).len() + 1..).unwrap_or("")
751}
752
753pub(crate) fn modified_ns(meta: &std::fs::Metadata) -> u64 {
754 meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_nanos() as u64)
755}
756
757struct Look<'a> {
760 reads: &'a dyn Fn(&Store) -> bool,
762 hurry: &'a dyn Fn() -> bool,
764 confirmed: &'a BTreeSet<String>,
765 clock: i64,
766}
767
768fn clock_ms() -> i64 {
770 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_millis() as i64)
771}
772
773#[cfg(test)]
774thread_local! {
775 static SETTLE: std::cell::Cell<i64> = const { std::cell::Cell::new(0) };
777}
778
779fn settling_ms() -> i64 {
782 #[cfg(test)]
783 return SETTLE.with(std::cell::Cell::get);
784 #[cfg(not(test))]
785 2_000
786}
787
788fn gather(stores: &[Store], known: &BTreeMap<String, Stat>, look: &Look) -> Found {
792 let mut found = Found::default();
793 for store in stores {
794 if !(look.reads)(store) {
795 found.unknown.insert(store.name.clone());
797 continue;
798 }
799 if store.folder {
800 if !store.path.is_dir() {
801 let held = known.range(store.name.clone()..).take_while(|(file, _)| file.starts_with(&store.name)).count();
805 if matches!(store.shape, Shape::Files) && held == 0 {
806 continue;
807 }
808 if matches!(store.shape, Shape::Files) && !look.confirmed.contains(&store.name) {
809 found.emptied.push((store.name.clone(), held));
810 }
811 found.unknown.insert(store.name.clone());
812 continue;
813 }
814 let mut files = Vec::new();
815 if matches!(store.shape, Shape::Files) {
816 let mut listing = Listing { store: &store.name, root: &store.path, skip: store.skip, known, confirmed: look.confirmed.contains(&store.name), files: &mut files, found: &mut found };
817 listing.list(&store.path);
818 } else {
819 list_files(&store.path, &store.path, store.skip, &mut files);
820 }
821 for (relative, path) in files {
822 read_file(store, &format!("{}{relative}", store.name), &path, known, look, &mut found);
823 }
824 } else if store.path.is_file() {
825 read_file(store, &store.name, &store.path, known, look, &mut found);
826 } else if !matches!(store.shape, Shape::Whole | Shape::Files) {
827 found.unknown.insert(store.name.clone());
830 }
831 }
832 let carried: Vec<(String, Stat)> = known.iter().filter(|(file, _)| !found.files.contains_key(*file) && found.is_unknown(file)).map(|(file, stat)| (file.clone(), stat.clone())).collect();
834 found.files.extend(carried);
835 if let Ok(mut all) = LINES.lock() {
837 all.retain(|path, _| path.exists() || !stores.iter().any(|s| matches!(s.shape, Shape::Lines) && path.starts_with(&s.path)));
838 }
839 found
840}
841
842fn list_files(root: &Path, dir: &Path, skip: &[&str], out: &mut Vec<(String, PathBuf)>) {
844 let Ok(entries) = std::fs::read_dir(dir) else { return };
845 for entry in entries.filter_map(Result::ok) {
846 let name = entry.file_name().to_string_lossy().to_string();
847 if name.starts_with('.') || name.ends_with(".new") || name.ends_with(".tmp") || name.ends_with('~') || skip.contains(&name.as_str()) {
848 continue;
849 }
850 let path = entry.path();
851 if path.is_dir() {
852 list_files(root, &path, skip, out);
853 } else if let Ok(relative) = path.strip_prefix(root) {
854 out.push((relative.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/"), path));
855 }
856 }
857}
858
859struct Listing<'a> {
861 store: &'a str,
862 root: &'a Path,
863 skip: &'a [&'a str],
864 known: &'a BTreeMap<String, Stat>,
865 confirmed: bool,
867 files: &'a mut Vec<(String, PathBuf)>,
868 found: &'a mut Found,
869}
870
871impl Listing<'_> {
872 fn list(&mut self, dir: &Path) -> usize {
881 let relative = dir.strip_prefix(self.root).map(|r| r.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/")).unwrap_or_default();
882 let within = if relative.is_empty() { String::new() } else { format!("{relative}/") };
883 let prefix = format!("{}{within}", self.store);
884 let mut whole = true;
885 let mut count = 0;
886 match std::fs::read_dir(dir) {
887 Err(_) => whole = false,
888 Ok(entries) => {
889 for entry in entries {
890 let Ok(entry) = entry else {
891 whole = false;
892 continue;
893 };
894 let Ok(kind) = entry.file_type() else {
895 whole = false;
896 continue;
897 };
898 let name = entry.file_name();
899 let Some(name) = name.to_str() else {
900 self.found.said.push(format!("share-not-text:{prefix}{}", name.to_string_lossy()));
901 continue;
902 };
903 let path = entry.path();
904 if is_leftover(name) {
905 remove_if_old(&path);
906 continue;
907 }
908 if made_by_tools(name) || kind.is_symlink() || (dir == self.root && self.skip.iter().any(|skip| skip.eq_ignore_ascii_case(name))) {
909 continue;
910 }
911 if kind.is_dir() {
912 if !matches!(name, "node_modules" | "target") && !path.join("seal.toml").is_file() {
913 count += self.list(&path);
914 }
915 } else if kind.is_file() {
916 self.files.push((format!("{within}{name}"), path));
917 count += 1;
918 }
919 }
920 }
921 }
922 let held = self.known.range(prefix.clone()..).take_while(|(file, _)| file.starts_with(&prefix)).count();
923 if !whole || (count == 0 && held > 0 && !self.confirmed) {
924 if whole {
925 self.found.emptied.push((prefix.clone(), held));
926 }
927 self.found.unknown.insert(prefix);
928 }
929 count
930 }
931}
932
933fn is_leftover(name: &str) -> bool {
935 name.starts_with('.') && [TEMPORARY, ".shared.tmp", ".conflict.tmp"].iter().any(|end| name.ends_with(end))
936}
937
938fn remove_if_old(path: &Path) {
940 if std::fs::metadata(path).ok().and_then(|m| m.modified().ok()).and_then(|t| t.elapsed().ok()).is_some_and(|age| age.as_secs() > 3600) {
941 let _ = std::fs::remove_file(path);
942 }
943}
944
945pub(crate) fn clean_leftovers(dir: &Path) {
947 for entry in std::fs::read_dir(dir).into_iter().flatten().filter_map(Result::ok) {
948 if entry.file_name().to_str().is_some_and(is_leftover) {
949 remove_if_old(&entry.path());
950 }
951 }
952}
953
954fn made_by_tools(name: &str) -> bool {
958 let lower = name.to_lowercase();
959 name.starts_with('.')
960 || name.starts_with('~')
961 || name.ends_with('~')
962 || (name.starts_with('#') && name.ends_with('#'))
963 || [".tmp", ".temp", ".part", ".partial", ".crdownload", ".download", ".swp", ".swx", ".new"].iter().any(|end| lower.ends_with(end))
964 || ["conflicted copy", ".sync-conflict-", "_conflict-", "sfconflict"].iter().any(|word| lower.contains(word))
965 || matches!(lower.as_str(), "thumbs.db" | "desktop.ini" | "4913")
966}
967
968#[derive(Debug, Serialize, Deserialize)]
970struct Reference {
971 h: String,
972 s: u64,
973 t: i64,
974}
975
976fn reference(h: &str, size: u64, modified_ns: u64) -> String {
977 serde_json::to_string(&Reference { h: h.to_string(), s: size, t: (modified_ns / 1_000_000) as i64 }).unwrap_or_default()
978}
979
980fn value_hash(key: &str, value: &str) -> String {
982 if key.starts_with(FILES) { serde_json::from_str::<Reference>(value).map(|r| r.h).unwrap_or_default() } else { hash(value) }
983}
984
985fn read_file(store: &Store, file: &str, path: &Path, known: &BTreeMap<String, Stat>, look: &Look, found: &mut Found) {
986 let Ok(meta) = std::fs::metadata(path) else {
987 found.unknown.insert(file.to_string());
988 return;
989 };
990 let sealed_apart = matches!(store.shape, Shape::Files);
991 if meta.len() > if sealed_apart { crate::blobs::LARGEST } else { LARGEST } {
992 found.unknown.insert(file.to_string());
993 if sealed_apart {
994 found.too_big.push(file.to_string());
995 }
996 return;
997 }
998 let modified = modified_ns(&meta);
999 found.changed.insert(file.to_string(), (modified / 1_000_000) as i64);
1000 let lines = matches!(store.shape, Shape::Lines);
1004 if let Some(stat) = known.get(file).filter(|s| !lines && s.size == meta.len() && s.modified == modified && (s.seen == 0 || (s.modified / 1_000_000) as i64 + settling_ms() < s.seen)) {
1005 for (entry, h) in &stat.entries {
1006 found.hashes.insert(format!("{file}#{entry}"), h.clone());
1007 if sealed_apart {
1009 found.values.insert(format!("{file}#{entry}"), reference(h, stat.size, stat.modified));
1010 }
1011 }
1012 found.files.insert(file.to_string(), stat.clone());
1013 return;
1014 }
1015 if sealed_apart {
1016 let fresh = look.clock - ((modified / 1_000_000) as i64) < settling_ms();
1020 let read = (!fresh && !(look.hurry)()).then(|| crate::blobs::hash_file(path).ok()).flatten().filter(|(_, size)| *size == meta.len());
1021 let Some((h, _)) = read else {
1022 found.unknown.insert(file.to_string());
1023 return;
1024 };
1025 let key = format!("{file}#");
1026 found.hashes.insert(key.clone(), h.clone());
1027 found.values.insert(key, reference(&h, meta.len(), modified));
1028 found.files.insert(file.to_string(), Stat { size: meta.len(), modified, entries: vec![(String::new(), h)], seen: look.clock });
1029 return;
1030 }
1031 if lines {
1032 let Some(lines) = lines_of(path, &meta, look.clock) else {
1035 found.unknown.insert(file.to_string());
1036 return;
1037 };
1038 let h = hash("");
1039 for line in lines.iter() {
1040 let key = format!("{file}#{line}");
1041 found.hashes.insert(key.clone(), h.clone());
1042 found.values.insert(key, String::new());
1043 }
1044 found.files.insert(file.to_string(), Stat { size: meta.len(), modified, entries: Vec::new(), seen: 0 });
1046 return;
1047 }
1048 let Some(entries) = std::fs::read(path).ok().and_then(|bytes| entries_of(store, &bytes)) else {
1049 found.unknown.insert(file.to_string());
1050 return;
1051 };
1052 let mut stat = Stat { size: meta.len(), modified, entries: Vec::with_capacity(entries.len()), seen: look.clock };
1053 for (entry, value) in entries {
1054 let key = format!("{file}#{entry}");
1055 let h = hash(&value);
1056 stat.entries.push((entry, h.clone()));
1057 found.hashes.insert(key.clone(), h);
1058 found.values.insert(key, value);
1059 }
1060 found.files.insert(file.to_string(), stat);
1061}
1062
1063type Lines = BTreeMap<PathBuf, (FileStamp, i64, std::sync::Arc<Vec<String>>)>;
1070static LINES: std::sync::Mutex<Lines> = std::sync::Mutex::new(BTreeMap::new());
1071
1072fn lines_of(path: &Path, meta: &std::fs::Metadata, clock: i64) -> Option<std::sync::Arc<Vec<String>>> {
1075 let stamp = file_stamp(meta);
1076 let settled = |read: i64| (stamp.modified / 1_000_000) as i64 + settling_ms() < read;
1077 if let Some((_, _, lines)) = keeping().then(|| LINES.lock().ok().and_then(|all| all.get(path).filter(|(was, read, _)| *was == stamp && settled(*read)).cloned())).flatten() {
1078 return Some(lines);
1079 }
1080 let bytes = std::fs::read(path).ok()?;
1081 #[cfg(test)]
1082 LINES_READ.with(|n| n.set(n.get() + 1));
1083 let lines: std::sync::Arc<Vec<String>> = std::sync::Arc::new(std::str::from_utf8(&bytes).ok()?.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).map(str::to_string).collect());
1085 if keeping()
1087 && bytes.len() as u64 == stamp.size
1088 && let Ok(mut all) = LINES.lock()
1089 {
1090 all.insert(path.to_path_buf(), (stamp, clock, std::sync::Arc::clone(&lines)));
1091 }
1092 Some(lines)
1093}
1094
1095fn entries_of(store: &Store, bytes: &[u8]) -> Option<Vec<(String, String)>> {
1102 match store.shape {
1103 Shape::Whole => Some(vec![(String::new(), B64.encode(bytes))]),
1104 Shape::Lines => Some(std::str::from_utf8(bytes).ok()?.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).map(|l| (l.to_string(), String::new())).collect()),
1105 Shape::Toml(rules) => toml_entries(std::str::from_utf8(bytes).ok()?, rules).ok(),
1106 Shape::Files => None,
1107 }
1108}
1109
1110fn text_to_write(path: &Path) -> Result<String, String> {
1114 match std::fs::read(path) {
1115 Ok(bytes) => String::from_utf8(bytes).map_err(|_| format!("share-unreadable:{}", path.display())),
1116 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
1117 Err(e) => Err(format!("share-unreadable:{}: {e}", path.display())),
1118 }
1119}
1120
1121fn value_of(stores: &[Store], key: &str) -> Option<String> {
1123 let (file, entry) = (file_of(key), entry_of(key));
1124 let (store, path) = locate(stores, file)?;
1125 if matches!(store.shape, Shape::Files) {
1126 let meta = std::fs::metadata(&path).ok()?;
1127 let (h, size) = crate::blobs::hash_file(&path).ok()?;
1128 return entry.is_empty().then(|| reference(&h, size, modified_ns(&meta)));
1129 }
1130 let bytes = std::fs::read(path).ok()?;
1131 entries_of(store, &bytes)?.into_iter().find(|(e, _)| e == entry).map(|(_, value)| value)
1132}
1133
1134fn leaf_text(value: &toml::Value) -> String {
1136 let mut table = toml::Table::new();
1137 table.insert("v".into(), value.clone());
1138 toml::to_string(&table).unwrap_or_default()
1139}
1140
1141fn leaf_value(text: &str) -> Result<toml::Value, String> {
1142 let mut table: toml::Table = text.parse().map_err(|e: toml::de::Error| e.to_string())?;
1143 table.remove("v").ok_or_else(|| "no value".to_string())
1144}
1145
1146fn matches_rule(path: &[String], rule: &str) -> bool {
1147 let parts: Vec<&str> = rule.split('.').collect();
1148 parts.len() == path.len() && parts.iter().zip(path).all(|(r, p)| *r == "*" || r == p)
1149}
1150
1151fn identity(element: &toml::Value, by: &[&str]) -> String {
1153 if by.is_empty() {
1154 return leaf_text(element);
1155 }
1156 by.iter()
1157 .map(|field| match element.get(field) {
1158 Some(toml::Value::String(s)) => s.clone(),
1159 Some(other) => other.to_string(),
1160 None => String::new(),
1161 })
1162 .collect::<Vec<_>>()
1163 .join("\u{1d}")
1164}
1165
1166fn strip_local(element: &mut toml::Value, local: &[&str]) {
1167 if let toml::Value::Table(table) = element {
1168 for field in local {
1169 table.remove(*field);
1170 }
1171 }
1172}
1173
1174fn keyed_at<'r>(rules: &'r Rules, path: &[String]) -> Option<&'r Keyed> {
1177 rules.keyed.iter().find(|k| match path {
1178 [name] => k.list == name || k.also.contains(&name.as_str()),
1179 _ => k.list.contains('.') && matches_rule(path, k.list),
1180 })
1181}
1182
1183fn keyed_named<'r>(rules: &'r Rules, list: &[&str]) -> Option<&'r Keyed> {
1185 let path: Vec<String> = list.iter().map(|part| (*part).to_string()).collect();
1186 rules.keyed.iter().find(|k| match list {
1187 [name] => k.list == *name,
1188 _ => k.list.contains('.') && matches_rule(&path, k.list),
1189 })
1190}
1191
1192fn own_id(element: &toml::Value) -> Option<&str> {
1194 element.get("id").and_then(toml::Value::as_str).filter(|id| !id.is_empty())
1195}
1196
1197pub(crate) fn derived(list: &str, what: &str) -> String {
1204 use sha2::Digest;
1205 let mut hasher = sha2::Sha256::new();
1206 hasher.update(b"sioul-id\x1f");
1207 hasher.update(list.as_bytes());
1208 hasher.update(b"\x1f");
1209 hasher.update(what.as_bytes());
1210 let digest = hasher.finalize();
1211 let mut bytes = [0u8; 16];
1212 bytes.copy_from_slice(&digest[..16]);
1213 bytes[6] = (bytes[6] & 0x0f) | 0x80;
1214 bytes[8] = (bytes[8] & 0x3f) | 0x80;
1215 uuid::Uuid::from_bytes(bytes).to_string()
1216}
1217
1218fn names(elements: &[toml::Value], keyed: &Keyed) -> Vec<String> {
1227 if !keyed.ids {
1228 return elements.iter().map(|e| identity(e, keyed.by)).collect();
1229 }
1230 let mut ids: BTreeMap<&str, usize> = BTreeMap::new();
1231 for id in elements.iter().filter_map(own_id) {
1232 *ids.entry(id).or_default() += 1;
1233 }
1234 elements
1235 .iter()
1236 .map(|element| match own_id(element) {
1237 Some(id) if ids.get(id) == Some(&1) => id.to_string(),
1238 _ => derived(keyed.list, &identity(element, keyed.by)),
1239 })
1240 .collect()
1241}
1242
1243fn toml_entries(text: &str, rules: &Rules) -> Result<Vec<(String, String)>, String> {
1246 let table: toml::Table = text.parse().map_err(|e: toml::de::Error| e.to_string())?;
1247 let mut out = Vec::new();
1248 for (name, value) in &table {
1249 flatten(vec![name.clone()], value, rules, &mut out);
1250 }
1251 Ok(out)
1252}
1253
1254fn list_name(keyed: &Keyed, path: &[String]) -> String {
1256 if path.len() == 1 { keyed.list.to_string() } else { path.join(&SEP.to_string()) }
1257}
1258
1259fn flatten(path: Vec<String>, value: &toml::Value, rules: &Rules, out: &mut Vec<(String, String)>) {
1260 if rules.local.iter().any(|rule| matches_rule(&path, rule)) {
1261 return;
1262 }
1263 if let toml::Value::Array(elements) = value
1264 && let Some(keyed) = keyed_at(rules, &path)
1265 {
1266 let list = list_name(keyed, &path);
1267 let elements: Vec<toml::Value> = elements
1268 .iter()
1269 .map(|element| {
1270 let mut element = element.clone();
1271 strip_local(&mut element, keyed.local);
1272 element
1273 })
1274 .collect();
1275 for (element, name) in elements.iter().zip(names(&elements, keyed)) {
1276 out.push((format!("{list}{SEP}{MARK}{name}"), leaf_text(element)));
1277 }
1278 return;
1279 }
1280 match value {
1281 toml::Value::Table(table) if !rules.whole.iter().any(|rule| matches_rule(&path, rule)) => {
1282 for (name, value) in table {
1283 let mut deeper = path.clone();
1284 deeper.push(name.clone());
1285 flatten(deeper, value, rules, out);
1286 }
1287 }
1288 _ => out.push((path.join(&SEP.to_string()), leaf_text(value))),
1289 }
1290}
1291
1292fn write_entries(store: &Store, path: &Path, changes: &[(&str, Option<&str>)], keep: impl FnOnce() -> Result<(), String>) -> Result<BTreeMap<String, String>, String> {
1299 sioul_core::filelock::with_lock(path, || {
1300 let new = rewritten(store, path, changes)?;
1301 let written: BTreeMap<String, String> = new.as_deref().and_then(|bytes| entries_of(store, bytes)).unwrap_or_default().into_iter().map(|(entry, value)| (entry, hash(&value))).collect();
1303 let old = std::fs::read(path).ok();
1304 if old == new {
1305 return Ok(written);
1306 }
1307 if old.is_none() && of_the_doses(store) && written.is_empty() {
1311 return Ok(written);
1312 }
1313 if old.is_some() {
1314 keep()?;
1315 }
1316 match new {
1317 Some(bytes) if of_the_doses(store) => sioul_core::health::write_witnessed(path, &bytes)?,
1323 Some(bytes) => write_atomically(path, &bytes)?,
1324 None => match std::fs::remove_file(path) {
1325 Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(format!("{}: {e}", path.display())),
1326 _ => {}
1327 },
1328 }
1329 Ok(written)
1330 })
1331}
1332
1333fn of_the_doses(store: &Store) -> bool {
1336 matches!(store.name.as_str(), "state/health-state.toml" | "state/health-doses.toml")
1337}
1338
1339fn rewritten(store: &Store, path: &Path, changes: &[(&str, Option<&str>)]) -> Result<Option<Vec<u8>>, String> {
1341 match store.shape {
1342 Shape::Whole => {
1343 let (_, value) = changes.last().ok_or("nothing")?;
1344 value.map(|value| B64.decode(value).map_err(|e| e.to_string())).transpose()
1345 }
1346 Shape::Files => Err(format!("{}: sealed apart", path.display())),
1347 Shape::Lines => {
1348 let text = text_to_write(path)?;
1349 let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
1350 for (entry, value) in changes {
1351 let at = lines.iter().position(|l| l.trim() == *entry);
1352 match (value, at) {
1353 (Some(_), None) => lines.push(entry.to_string()),
1354 (None, Some(at)) => {
1355 lines.remove(at);
1356 }
1357 _ => {}
1358 }
1359 }
1360 let mut text = lines.join("\n");
1361 if !text.is_empty() {
1362 text.push('\n');
1363 }
1364 Ok(Some(text.into_bytes()))
1365 }
1366 Shape::Toml(rules) => {
1367 let text = text_to_write(path)?;
1368 let renamed = path.file_name() != Path::new(&store.name).file_name();
1371 Ok(Some(toml_write(&text, rules, changes, renamed)?.into_bytes()))
1372 }
1373 }
1374}
1375
1376pub fn write_atomically(path: &Path, bytes: &[u8]) -> Result<(), String> {
1380 write_then_rename(path, bytes, false)
1381}
1382
1383pub(crate) fn write_synced(path: &Path, bytes: &[u8]) -> Result<(), String> {
1386 write_then_rename(path, bytes, true)
1387}
1388
1389fn write_then_rename(path: &Path, bytes: &[u8], synced: bool) -> Result<(), String> {
1390 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
1391 if let Some(parent) = path.parent() {
1392 private_dirs(parent).map_err(fail)?;
1393 }
1394 let temporary = temporary(path);
1398 let written = (|| {
1399 let mut file = new_private(&temporary)?;
1400 file.write_all(bytes)?;
1401 if synced {
1402 file.sync_all()?;
1403 }
1404 std::fs::rename(&temporary, path)
1405 })();
1406 if written.is_err() {
1407 let _ = std::fs::remove_file(&temporary);
1408 }
1409 written.map_err(fail)
1410}
1411
1412pub(crate) fn read_small(path: &Path) -> Option<String> {
1415 use std::io::Read;
1416 let mut text = String::new();
1417 std::fs::File::open(path).ok()?.take(SMALL_FILE + 1).read_to_string(&mut text).ok()?;
1418 (text.len() as u64 <= SMALL_FILE).then_some(text)
1419}
1420
1421pub(crate) const SMALL_FILE: u64 = 64 << 10;
1423
1424pub(crate) fn temporary(path: &Path) -> PathBuf {
1427 static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1428 let name = path.file_name().map_or_else(|| "file".into(), |n| n.to_string_lossy().to_string());
1429 let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1430 path.with_file_name(format!(".{name}.{}-{n}{TEMPORARY}", std::process::id()))
1431}
1432
1433pub(crate) const TEMPORARY: &str = ".sioul.tmp";
1436
1437pub fn private_dirs(dir: &Path) -> std::io::Result<()> {
1450 let mut builder = std::fs::DirBuilder::new();
1451 builder.recursive(true);
1452 #[cfg(unix)]
1453 std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
1454 builder.create(dir)
1455}
1456
1457pub(crate) fn new_private(path: &Path) -> std::io::Result<std::fs::File> {
1459 let mut options = std::fs::OpenOptions::new();
1460 options.write(true).create_new(true);
1461 #[cfg(unix)]
1462 std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
1463 options.open(path)
1464}
1465
1466pub(crate) fn make_private(path: &Path) {
1469 #[cfg(unix)]
1470 {
1471 use std::os::unix::fs::PermissionsExt;
1472 if let Ok(metadata) = std::fs::symlink_metadata(path)
1473 && metadata.is_file()
1474 && metadata.permissions().mode() & 0o077 != 0
1475 {
1476 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
1477 }
1478 }
1479 #[cfg(not(unix))]
1480 let _ = path;
1481}
1482
1483pub(crate) fn copy_private(from: &Path, to: &Path) -> std::io::Result<()> {
1485 std::fs::copy(from, to)?;
1486 make_private(to);
1487 Ok(())
1488}
1489
1490const PAD_LIMIT: u64 = 4096;
1492
1493pub(crate) fn pad_for(path: &Path, size: impl Fn(usize) -> u64) -> usize {
1505 let base = size(0);
1506 let Ok(before) = std::fs::metadata(path).map(|m| m.len()) else { return 0 };
1507 if before < base + PAD_LIMIT {
1508 let mut pad = usize::try_from(before.saturating_sub(base) * 3 / 4).unwrap_or(0);
1510 while size(pad) <= before {
1511 pad += 1;
1512 }
1513 if size(pad) <= base + PAD_LIMIT {
1514 return pad;
1515 }
1516 }
1517 (0..).find(|&pad| size(pad) != before).unwrap_or(0)
1519}
1520
1521fn edit_item(text: &str) -> Result<toml_edit::Item, String> {
1523 let doc: toml_edit::DocumentMut = text.parse().map_err(|e: toml_edit::TomlError| e.to_string())?;
1524 let mut item = doc.as_table().get("v").cloned().ok_or("no value")?;
1525 match &mut item {
1526 toml_edit::Item::Value(value) => value.decor_mut().clear(),
1527 toml_edit::Item::Table(table) => table.decor_mut().clear(),
1528 _ => {}
1529 }
1530 Ok(item)
1531}
1532
1533fn toml_write(text: &str, rules: &Rules, changes: &[(&str, Option<&str>)], renamed: bool) -> Result<String, String> {
1536 let mut doc: toml_edit::DocumentMut = text.parse().map_err(|e: toml_edit::TomlError| e.to_string())?;
1537 let mut lists: Vec<(Vec<&str>, Vec<(&str, Option<&str>)>)> = Vec::new();
1539 for (entry, value) in changes {
1540 let path: Vec<&str> = entry.split(SEP).collect();
1541 if let Some((element, list)) = path.split_last()
1542 && let Some(id) = element.strip_prefix(MARK)
1543 && !list.is_empty()
1544 {
1545 match lists.iter_mut().find(|(known, _)| known.as_slice() == list) {
1546 Some((_, elements)) => elements.push((id, *value)),
1547 None => lists.push((list.to_vec(), vec![(id, *value)])),
1548 }
1549 } else {
1550 let item = value.map(edit_item).transpose()?;
1551 set_path(doc.as_table_mut(), &path, item.as_ref())?;
1552 }
1553 }
1554 for (list, elements) in lists {
1555 let keyed = keyed_named(rules, &list).ok_or_else(|| format!("{}: not a list", list.join(".")))?;
1556 let Some((name, within)) = list.split_last() else { continue };
1557 let Some(table) = table_at(doc.as_table_mut(), within, elements.iter().any(|(_, value)| value.is_some()))? else { continue };
1558 write_elements(table, keyed, name, &elements, renamed && within.is_empty())?;
1559 }
1560 Ok(doc.to_string())
1561}
1562
1563fn table_at<'t>(table: &'t mut toml_edit::Table, path: &[&str], make: bool) -> Result<Option<&'t mut toml_edit::Table>, String> {
1567 let Some((first, rest)) = path.split_first() else { return Ok(Some(table)) };
1568 if !make && !table.contains_key(first) {
1569 return Ok(None);
1570 }
1571 let item = table.entry(first).or_insert_with(|| {
1572 let mut new = toml_edit::Table::new();
1573 new.set_implicit(true);
1574 toml_edit::Item::Table(new)
1575 });
1576 if let Some(inline) = item.as_inline_table().cloned() {
1577 *item = toml_edit::Item::Table(inline.into_table());
1578 }
1579 let inner = item.as_table_mut().ok_or_else(|| format!("{first}: not a table"))?;
1580 table_at(inner, rest, make)
1581}
1582
1583fn names_read(elements: &[Option<toml::Value>], keyed: &Keyed) -> Vec<Option<String>> {
1585 let read: Vec<toml::Value> = elements.iter().flatten().cloned().collect();
1586 let mut named = names(&read, keyed).into_iter();
1587 elements.iter().map(|element| element.as_ref().and_then(|_| named.next())).collect()
1588}
1589
1590fn set_path(table: &mut toml_edit::Table, path: &[&str], value: Option<&toml_edit::Item>) -> Result<(), String> {
1591 match path {
1592 [] => Ok(()),
1593 [last] => {
1594 match value {
1595 Some(toml_edit::Item::Value(new)) if table.get(last).is_some_and(toml_edit::Item::is_value) => {
1597 if let Some(old) = table.get_mut(last).and_then(toml_edit::Item::as_value_mut) {
1598 let decor = old.decor().clone();
1599 *old = new.clone();
1600 *old.decor_mut() = decor;
1601 }
1602 }
1603 Some(item) => {
1604 table.insert(last, item.clone());
1605 }
1606 None => {
1607 table.remove(last);
1608 }
1609 }
1610 Ok(())
1611 }
1612 [first, rest @ ..] => {
1613 if value.is_none() && !table.contains_key(first) {
1614 return Ok(());
1615 }
1616 let item = table.entry(first).or_insert_with(|| {
1617 let mut new = toml_edit::Table::new();
1618 new.set_implicit(true);
1619 toml_edit::Item::Table(new)
1620 });
1621 if let Some(inline) = item.as_inline_table().cloned() {
1622 *item = toml_edit::Item::Table(inline.into_table());
1623 }
1624 let inner = item.as_table_mut().ok_or_else(|| format!("{first}: not a table"))?;
1625 set_path(inner, rest, value)
1626 }
1627 }
1628}
1629
1630fn place_of(store: &Store) -> String {
1635 let renamed = !store.folder && store.path.file_name().is_some_and(|name| name == sioul_core::projects::FILE);
1636 let path = if renamed { store.path.with_file_name(sioul_core::projects::OLD_FILE) } else { store.path.clone() };
1637 path.display().to_string()
1638}
1639
1640fn placed(mut table: toml_edit::Table, at: Option<isize>) -> toml_edit::Table {
1647 fn clear(table: &mut toml_edit::Table) {
1648 table.set_position(None);
1649 for (_, item) in table.iter_mut() {
1650 match item {
1651 toml_edit::Item::Table(inner) => clear(inner),
1652 toml_edit::Item::ArrayOfTables(list) => list.iter_mut().for_each(clear),
1653 _ => {}
1654 }
1655 }
1656 }
1657 clear(&mut table);
1658 table.set_position(at);
1659 table
1660}
1661
1662fn write_elements(table: &mut toml_edit::Table, keyed: &Keyed, list: &str, changes: &[(&str, Option<&str>)], renamed: bool) -> Result<(), String> {
1669 let normal = |text: String| -> Option<toml::Value> {
1670 let mut element = leaf_value(&text).ok()?;
1671 strip_local(&mut element, keyed.local);
1672 Some(element)
1673 };
1674 let news: Vec<(&str, Option<toml_edit::Item>)> = changes.iter().map(|(id, value)| Ok((*id, value.map(edit_item).transpose()?))).collect::<Result<_, String>>()?;
1675 let others: &[&str] = if keyed.list == list { keyed.also } else { &[] };
1676 let name = std::iter::once(list).chain(others.iter().copied()).find(|n| table.contains_key(n)).unwrap_or(match others.first() {
1677 Some(other) if renamed => other,
1678 _ => list,
1679 });
1680 if !table.contains_key(name) {
1681 match news.iter().find_map(|(_, new)| new.as_ref()) {
1682 Some(toml_edit::Item::Table(_)) => {
1683 table.insert(name, toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
1684 }
1685 Some(_) => {
1686 table.insert(name, toml_edit::value(toml_edit::Array::new()));
1687 }
1688 None => return Ok(()),
1689 }
1690 }
1691 let mut gone: Vec<usize> = Vec::new();
1692 match table.get_mut(name) {
1693 Some(toml_edit::Item::ArrayOfTables(elements)) => {
1694 let named = names_read(&elements.iter().map(|t| normal(format!("[v]\n{t}"))).collect::<Vec<_>>(), keyed);
1695 let mut added: Vec<toml_edit::Table> = Vec::new();
1696 for (id, new) in news {
1697 let at = named.iter().position(|n| n.as_deref() == Some(id));
1698 match (new, at) {
1699 (Some(toml_edit::Item::Table(mut element)), Some(at)) => {
1700 if let Some(old) = elements.get(at) {
1701 for field in keyed.local {
1702 if let Some(kept) = old.get(field) {
1703 element.insert(field, kept.clone());
1704 }
1705 }
1706 if keyed.ids
1710 && !element.contains_key("id")
1711 && let Some(id) = old.get("id")
1712 {
1713 element.insert("id", id.clone());
1714 }
1715 }
1716 if let Some(place) = elements.get_mut(at) {
1717 let position = place.position();
1718 *place = placed(element, position);
1719 }
1720 }
1721 (Some(toml_edit::Item::Table(element)), None) => added.push(element),
1722 (None, Some(at)) => gone.push(at),
1723 _ => {}
1724 }
1725 }
1726 for element in added {
1727 elements.push(placed(element, None));
1728 }
1729 gone.sort_unstable();
1730 for at in gone.into_iter().rev() {
1731 elements.remove(at);
1732 }
1733 }
1734 Some(toml_edit::Item::Value(toml_edit::Value::Array(elements))) => {
1735 let named = names_read(&elements.iter().map(|v| normal(format!("v = {v}"))).collect::<Vec<_>>(), keyed);
1736 let mut added: Vec<toml_edit::Value> = Vec::new();
1737 for (id, new) in news {
1738 let at = named.iter().position(|n| n.as_deref() == Some(id));
1739 match (new, at) {
1740 (Some(toml_edit::Item::Value(element)), Some(at)) => {
1741 elements.replace(at, element);
1742 }
1743 (Some(toml_edit::Item::Value(element)), None) => added.push(element),
1744 (None, Some(at)) => gone.push(at),
1745 _ => {}
1746 }
1747 }
1748 for element in added {
1749 elements.push(element);
1750 }
1751 gone.sort_unstable();
1752 for at in gone.into_iter().rev() {
1753 elements.remove(at);
1754 }
1755 elements.fmt();
1756 }
1757 _ => return Err(format!("{name}: not a list")),
1758 }
1759 Ok(())
1760}
1761
1762#[derive(Debug, Serialize, Deserialize)]
1767struct SealFile {
1768 version: u32,
1769 salt: String,
1770 memory_kib: u32,
1771 passes: u32,
1772 check: String,
1773}
1774
1775const CHECK: &[u8] = b"sioul: the passphrase is right";
1776
1777#[derive(Debug, PartialEq, Eq)]
1779pub enum Refused {
1780 WrongPassphrase,
1782 Other(String),
1783}
1784
1785fn derive(passphrase: &str, salt: &[u8], memory_kib: u32, passes: u32) -> Result<[u8; 32], String> {
1786 let params = argon2::Params::new(memory_kib, passes, 1, Some(32)).map_err(|e| e.to_string())?;
1787 let mut key = [0u8; 32];
1788 argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params).hash_password_into(passphrase.as_bytes(), salt, &mut key).map_err(|e| e.to_string())?;
1789 Ok(key)
1790}
1791
1792pub fn key_for(folder: &Path, passphrase: &str) -> Result<[u8; 32], Refused> {
1795 key_with(folder, passphrase, 64 * 1024, 3)
1796}
1797
1798fn key_with(folder: &Path, passphrase: &str, memory_kib: u32, passes: u32) -> Result<[u8; 32], Refused> {
1799 let path = folder.join("seal.toml");
1800 if path.exists() {
1801 let text = read_small(&path).ok_or_else(|| Refused::Other(format!("{}: not a seal", path.display())))?;
1803 let seal: SealFile = toml::from_str(&text).map_err(|e| Refused::Other(format!("{}: {e}", path.display())))?;
1804 if seal.memory_kib > 1 << 20 || seal.passes > 16 {
1807 return Err(Refused::Other(format!("{}: {} KiB, {} passes", path.display(), seal.memory_kib, seal.passes)));
1808 }
1809 let salt = B64.decode(&seal.salt).map_err(|e| Refused::Other(e.to_string()))?;
1810 let key = derive(passphrase, &salt, seal.memory_kib, seal.passes).map_err(Refused::Other)?;
1811 return match open(&key, "check", &seal.check) {
1812 Some(plain) if plain == CHECK => Ok(key),
1813 _ => Err(Refused::WrongPassphrase),
1814 };
1815 }
1816 let mut salt = [0u8; 16];
1817 OsRng.fill_bytes(&mut salt);
1818 let key = derive(passphrase, &salt, memory_kib, passes).map_err(Refused::Other)?;
1819 let seal = SealFile { version: 1, salt: B64.encode(salt), memory_kib, passes, check: seal(&key, "check", CHECK) };
1820 let text = format!("# Sioul: how your devices' shared records are sealed (docs/database.md).\n{}", toml::to_string(&seal).map_err(|e| Refused::Other(e.to_string()))?);
1821 private_dirs(folder).map_err(|e| Refused::Other(format!("{}: {e}", folder.display())))?;
1822 write_atomically(&path, text.as_bytes()).map_err(Refused::Other)?;
1823 Ok(key)
1824}
1825
1826fn sealed_for(folder: &Path, key: &[u8; 32]) -> Result<Option<(bool, String)>, ()> {
1839 if !folder.is_dir() {
1840 return Err(());
1841 }
1842 let Some(seal) = read_small(&folder.join("seal.toml")).and_then(|text| toml::from_str::<SealFile>(&text).ok()) else { return Ok(None) };
1843 Ok(Some((open(key, "check", &seal.check).is_some_and(|plain| plain == CHECK), seal.salt)))
1844}
1845
1846pub(crate) fn seal(key: &[u8; 32], bound: &str, plain: &[u8]) -> String {
1847 let cipher = XChaCha20Poly1305::new(key.into());
1848 let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
1849 let sealed = cipher.encrypt(&nonce, Payload { msg: plain, aad: bound.as_bytes() }).unwrap_or_default();
1850 B64.encode([nonce.as_slice(), &sealed].concat())
1851}
1852
1853pub(crate) fn open(key: &[u8; 32], bound: &str, text: &str) -> Option<Vec<u8>> {
1854 let bytes = B64.decode(text).ok()?;
1855 if bytes.len() < 24 {
1856 return None;
1857 }
1858 let (nonce, sealed) = bytes.split_at(24);
1859 XChaCha20Poly1305::new(key.into()).decrypt(XNonce::from_slice(nonce), Payload { msg: sealed, aad: bound.as_bytes() }).ok()
1860}
1861
1862#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1868pub struct Here {
1869 #[serde(default)]
1870 pub id: String,
1871 #[serde(default)]
1872 pub machine: String,
1873 #[serde(default, skip_serializing_if = "Option::is_none")]
1874 pub folder: Option<String>,
1875 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1878 pub parts: BTreeMap<String, bool>,
1879 #[serde(default, skip_serializing_if = "Option::is_none")]
1883 pub notes_carried: Option<bool>,
1884 #[serde(default, skip_serializing_if = "Option::is_none")]
1890 pub notes_carried_for: Option<String>,
1891}
1892
1893impl Here {
1894 pub fn shares(&self, part: &str, config: &Config) -> bool {
1896 self.parts.get(part).copied().unwrap_or_else(|| shared_by_default(part, config))
1897 }
1898
1899 pub fn path(state: &Path) -> PathBuf {
1900 state.join("share").join("here.toml")
1901 }
1902
1903 pub fn load(state: &Path) -> Here {
1905 let path = Here::path(state);
1906 let mut here: Here = std::fs::read_to_string(&path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default();
1907 let machine = machine();
1908 if here.id.is_empty() || (!machine.is_empty() && here.machine != machine) {
1909 here.id = uuid::Uuid::new_v4().to_string();
1910 here.machine = machine;
1911 let _ = here.save(state);
1912 }
1913 here
1914 }
1915
1916 pub fn save(&self, state: &Path) -> Result<(), String> {
1918 write_synced(&Here::path(state), toml::to_string(self).map_err(|e| e.to_string())?.as_bytes())
1919 }
1920
1921 pub fn folder_path(&self) -> Option<PathBuf> {
1923 self.folder.as_deref().filter(|f| !f.is_empty()).map(sioul_core::config::expand_home)
1924 }
1925
1926 pub fn notes_said(&self, notes: &Path) -> Option<bool> {
1929 self.notes_carried.filter(|_| self.notes_carried_for.as_deref().is_none_or(|said| Path::new(said) == notes))
1930 }
1931}
1932
1933fn machine() -> String {
1935 let id = ["/etc/machine-id", "/var/lib/dbus/machine-id"].iter().find_map(|p| std::fs::read_to_string(p).ok()).or_else(|| std::env::var("COMPUTERNAME").ok()).or_else(|| std::fs::read_to_string("/etc/hostname").ok()).unwrap_or_default();
1936 let id = id.trim();
1937 if id.is_empty() { String::new() } else { format!("{:016x}", fnv(id.as_bytes())) }
1938}
1939
1940fn fnv(bytes: &[u8]) -> u64 {
1941 bytes.iter().fold(0xcbf2_9ce4_8422_2325u64, |hash, b| (hash ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3))
1942}
1943
1944pub(crate) fn hash(value: &str) -> String {
1945 format!("{:016x}", fnv(value.as_bytes()))
1946}
1947
1948#[derive(Debug, Default, Serialize, Deserialize)]
1955struct Memory {
1956 #[serde(default)]
1957 computer: String,
1958 #[serde(default)]
1960 clock: u64,
1961 #[serde(default)]
1963 round: u32,
1964 #[serde(default)]
1965 seq: u64,
1966 #[serde(default)]
1969 own_size: u64,
1970 #[serde(default)]
1974 round_base: u64,
1975 #[serde(default)]
1979 full_round: u32,
1980 #[serde(default)]
1982 grown: u64,
1983 #[serde(default, skip_serializing_if = "is_zero_u64")]
1987 continues: u64,
1988 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1994 owed: bool,
1995 #[serde(default, skip_serializing_if = "is_zero_i64")]
1999 owed_waited: i64,
2000 #[serde(default, skip_serializing_if = "is_zero_i64")]
2001 owed_last: i64,
2002 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2007 turns: bool,
2008 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2011 seal_seen: bool,
2012 #[serde(default, skip_serializing_if = "String::is_empty")]
2015 seal_salt: String,
2016 #[serde(default, skip_serializing_if = "is_zero")]
2020 ahead_at: i64,
2021 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2024 drafts: BTreeMap<String, BTreeSet<String>>,
2025 #[serde(default)]
2028 checked: i64,
2029 #[serde(default)]
2030 tidied: i64,
2031 #[serde(default, skip_serializing_if = "is_zero")]
2035 cleaned: i64,
2036 #[serde(default)]
2038 joined: bool,
2039 #[serde(default)]
2041 read: BTreeMap<String, (u32, u64)>,
2042 #[serde(default)]
2046 read_n: BTreeMap<String, (u32, u64)>,
2047 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2054 met: BTreeMap<String, (u32, u64)>,
2055 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2060 waiting_for: BTreeMap<String, Awaited>,
2061 #[serde(default)]
2063 broken: BTreeMap<String, i64>,
2064 #[serde(default)]
2067 rebuild: BTreeSet<String>,
2068 #[serde(default)]
2071 pending: BTreeMap<String, (Option<String>, u64, String)>,
2072 #[serde(default)]
2074 entries: BTreeMap<String, Known>,
2075 #[serde(default)]
2078 places: BTreeMap<String, String>,
2079 #[serde(default)]
2082 emptied: BTreeMap<String, i64>,
2083 #[serde(default)]
2085 files: BTreeMap<String, Stat>,
2086 #[serde(default)]
2088 traffic: BTreeMap<String, (i64, i64)>,
2089 #[serde(default)]
2093 format: u32,
2094 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2097 switch: bool,
2098 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2102 floors: BTreeMap<String, Floor>,
2103 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2107 waiting: BTreeMap<String, (Option<String>, u64, String, u32)>,
2108 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2112 newer: BTreeMap<String, Newer>,
2113 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2116 released: BTreeMap<String, u64>,
2117 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2122 held: BTreeMap<String, (u64, String)>,
2123 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2127 heard_at: BTreeMap<String, i64>,
2128 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2131 forgotten: BTreeMap<String, i64>,
2132 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2135 seen: BTreeMap<String, crate::devices::Entry>,
2136 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2143 unclocked: BTreeMap<String, i64>,
2144 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2151 lists: BTreeMap<String, WholeList>,
2152 #[serde(skip)]
2154 sealed: Sealed,
2155 #[serde(skip)]
2158 saved: Vec<u8>,
2159}
2160
2161#[derive(Debug, Default, Serialize, Deserialize)]
2165struct Sealed {
2166 #[serde(default)]
2167 computer: String,
2168 #[serde(default)]
2169 entries: BTreeMap<String, Known>,
2170 #[serde(default)]
2171 files: BTreeMap<String, Stat>,
2172 #[serde(default)]
2173 pending: BTreeMap<String, (Option<String>, u64, String)>,
2174 #[serde(default)]
2176 bases: BTreeMap<String, String>,
2177 #[serde(default)]
2180 named: BTreeMap<String, BTreeSet<String>>,
2181 #[serde(default)]
2184 mine: BTreeMap<String, (i64, u64)>,
2185 #[serde(default)]
2188 named_at: BTreeMap<String, i64>,
2189 #[serde(default)]
2192 damaged: BTreeMap<String, (u64, u64, u64)>,
2193 #[serde(default)]
2195 missing: BTreeMap<String, i64>,
2196 #[serde(default)]
2199 failing: BTreeMap<String, (String, i64, u32)>,
2200 #[serde(default)]
2203 gone: BTreeMap<String, i64>,
2204 #[serde(default)]
2206 confirmed: BTreeSet<String>,
2207 #[serde(default, skip_serializing)]
2213 checked: i64,
2214 #[serde(default, skip_serializing)]
2215 tidied: i64,
2216 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2222 tables: Vec<String>,
2223}
2224
2225const KEPT_TABLES: usize = 2;
2232
2233fn prune_tables(folder: &Path, sealed: &mut Sealed, used: &BTreeSet<String>) {
2241 sealed.tables.retain(|name| sealed.mine.contains_key(name));
2242 while sealed.tables.len() > KEPT_TABLES {
2243 let old = sealed.tables.remove(0);
2244 if used.contains(&old) {
2245 continue;
2246 }
2247 let _ = std::fs::remove_file(folder.join("blobs").join(&old));
2248 sealed.mine.remove(&old);
2249 }
2250}
2251
2252#[derive(Debug, Clone, Serialize, Deserialize)]
2253struct Known {
2254 c: u64,
2255 w: String,
2256 h: String,
2257 #[serde(default, skip_serializing_if = "String::is_empty")]
2259 b: String,
2260}
2261
2262impl Memory {
2263 fn load(path: &Path, computer: &str) -> Memory {
2265 let memory: Memory = std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
2266 if memory.computer == computer { memory } else { Memory { computer: computer.to_string(), round: 1, ..Memory::default() } }
2267 }
2268
2269 fn load_all(path: &Path, computer: &str) -> Memory {
2274 if let Some(kept) = Memory::kept(path, computer) {
2275 return kept;
2276 }
2277 let mut memory = Memory::load(path, computer);
2278 match std::fs::read_to_string(sealed_path(path)).ok().and_then(|t| serde_json::from_str::<Sealed>(&t).ok()).filter(|s| s.computer == computer) {
2279 Some(sealed) => {
2280 memory.checked = memory.checked.max(sealed.checked);
2282 memory.tidied = memory.tidied.max(sealed.tidied);
2283 memory.sealed = sealed;
2284 }
2285 None => memory.places.retain(|store, _| !store.starts_with(FILES) && !calls_store(store)),
2289 }
2290 memory.entries.append(&mut memory.sealed.entries);
2291 memory.files.append(&mut memory.sealed.files);
2292 memory.pending.append(&mut memory.sealed.pending);
2293 memory
2294 }
2295
2296 fn save(&mut self, path: &Path) -> Result<(), String> {
2302 let apart = |key: &String| key.starts_with(FILES) || calls_store(key);
2303 let (sealed, kept): (BTreeMap<_, _>, BTreeMap<_, _>) = std::mem::take(&mut self.entries).into_iter().partition(|(key, _)| apart(key));
2304 (self.sealed.entries, self.entries) = (sealed, kept);
2305 let (sealed, kept): (BTreeMap<_, _>, BTreeMap<_, _>) = std::mem::take(&mut self.files).into_iter().partition(|(file, _)| apart(file));
2306 (self.sealed.files, self.files) = (sealed, kept);
2307 let (sealed, kept): (BTreeMap<_, _>, BTreeMap<_, _>) = std::mem::take(&mut self.pending).into_iter().partition(|(key, _)| apart(key));
2308 (self.sealed.pending, self.pending) = (sealed, kept);
2309 self.sealed.computer = self.computer.clone();
2310 let write = |path: &Path, text: String| {
2311 if std::fs::read(path).is_ok_and(|old| old == text.as_bytes()) {
2312 return Ok(());
2313 }
2314 write_synced(path, text.as_bytes())
2315 };
2316 write(&sealed_path(path), serde_json::to_string(&self.sealed).map_err(|e| e.to_string())?)?;
2317 let text = serde_json::to_string(self).map_err(|e| e.to_string())?;
2318 write(path, text.clone())?;
2319 self.saved = text.into_bytes();
2320 Ok(())
2321 }
2322
2323 fn keep(mut self, path: &Path) {
2326 let text = std::mem::take(&mut self.saved);
2327 if text.is_empty() {
2328 return;
2329 }
2330 let sealed = stamp_at(&sealed_path(path));
2331 if let Ok(mut mine) = MINE.lock() {
2332 mine.insert(path.to_path_buf(), (sealed, self.computer.clone(), self.sealed.mine.iter().map(|(name, (_, size))| (name.clone(), *size)).collect()));
2333 }
2334 if !keeping() {
2335 return;
2336 }
2337 self.entries.append(&mut self.sealed.entries);
2338 self.files.append(&mut self.sealed.files);
2339 self.pending.append(&mut self.sealed.pending);
2340 let kept = Kept { own: stamp_at(path), sealed, text, memory: self };
2341 if let Ok(mut all) = KEPT.lock() {
2342 all.insert(path.to_path_buf(), kept);
2343 }
2344 }
2345
2346 fn kept(path: &Path, computer: &str) -> Option<Memory> {
2350 if !keeping() {
2351 return None;
2352 }
2353 let kept = KEPT.lock().ok()?.remove(path)?;
2354 let same = kept.memory.computer == computer && kept.own.is_some() && stamp_at(path) == kept.own && stamp_at(&sealed_path(path)) == kept.sealed && std::fs::read(path).is_ok_and(|text| text == kept.text);
2355 same.then_some(kept.memory)
2356 }
2357
2358 fn tick(&mut self, physical: i64) -> u64 {
2360 self.clock = (self.clock + 1).max((physical.max(0) as u64) << 16);
2361 self.clock
2362 }
2363}
2364
2365fn sealed_path(memory: &Path) -> PathBuf {
2367 memory.with_file_name("files.json")
2368}
2369
2370#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2373struct FileStamp {
2374 ino: u64,
2375 size: u64,
2376 modified: u64,
2377}
2378
2379fn file_stamp(meta: &std::fs::Metadata) -> FileStamp {
2380 #[cfg(unix)]
2381 let ino = std::os::unix::fs::MetadataExt::ino(meta);
2382 #[cfg(not(unix))]
2383 let ino = 0;
2384 FileStamp { ino, size: meta.len(), modified: modified_ns(meta) }
2385}
2386
2387fn stamp_at(path: &Path) -> Option<FileStamp> {
2388 std::fs::metadata(path).ok().map(|meta| file_stamp(&meta))
2389}
2390
2391struct Kept {
2398 memory: Memory,
2399 text: Vec<u8>,
2400 own: Option<FileStamp>,
2401 sealed: Option<FileStamp>,
2402}
2403
2404static KEPT: std::sync::Mutex<BTreeMap<PathBuf, Kept>> = std::sync::Mutex::new(BTreeMap::new());
2406
2407static KEEPING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(cfg!(test));
2410
2411pub fn set_keeping(on: bool) {
2418 KEEPING.store(on, std::sync::atomic::Ordering::Relaxed);
2419 if !on {
2420 KEPT.lock().map(|mut all| all.clear()).ok();
2421 LINES.lock().map(|mut all| all.clear()).ok();
2422 }
2423}
2424
2425fn keeping() -> bool {
2426 KEEPING.load(std::sync::atomic::Ordering::Relaxed)
2427}
2428
2429type Mine = BTreeMap<PathBuf, (Option<FileStamp>, String, BTreeMap<String, u64>)>;
2433static MINE: std::sync::Mutex<Mine> = std::sync::Mutex::new(BTreeMap::new());
2434
2435#[derive(Debug, Serialize, Deserialize)]
2437struct Line {
2438 n: u64,
2439 c: u64,
2440 s: String,
2441}
2442
2443#[derive(Debug, Serialize, Deserialize)]
2445struct Change {
2446 k: String,
2447 v: Option<String>,
2448 #[serde(default, skip_serializing_if = "String::is_empty")]
2452 b: String,
2453 #[serde(default, skip_serializing_if = "String::is_empty")]
2459 build: String,
2460 #[serde(default, skip_serializing_if = "first_format")]
2466 f: u32,
2467}
2468
2469fn first_format(f: &u32) -> bool {
2470 *f <= 1
2471}
2472
2473#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2483struct Turn {
2484 #[serde(default, skip_serializing_if = "is_zero_u64")]
2486 p: u64,
2487 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2489 full: bool,
2490}
2491
2492fn is_zero_i64(n: &i64) -> bool {
2493 *n == 0
2494}
2495
2496fn is_zero_u64(n: &u64) -> bool {
2497 *n == 0
2498}
2499
2500#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2502struct Awaited {
2503 round: u32,
2505 waited: i64,
2507 last: i64,
2509}
2510
2511const UNKNOWN_TURN: u64 = u64::MAX;
2518
2519#[derive(Deserialize)]
2521struct Opened {
2522 k: String,
2523 v: Option<String>,
2524 #[serde(default)]
2525 b: String,
2526 #[serde(default)]
2527 build: String,
2528 #[serde(default)]
2529 f: u32,
2530 #[serde(default)]
2531 p: u64,
2532 #[serde(default)]
2533 full: bool,
2534}
2535
2536impl Opened {
2537 fn parts(self) -> (Change, Turn) {
2538 (Change { k: self.k, v: self.v, b: self.b, build: self.build, f: self.f }, Turn { p: self.p, full: self.full })
2539 }
2540}
2541
2542#[derive(Serialize)]
2545struct Sealing<'a> {
2546 #[serde(flatten)]
2547 change: &'a Change,
2548 #[serde(flatten)]
2549 turn: Turn,
2550}
2551
2552#[derive(Debug, Default, Serialize, Deserialize)]
2554struct Seen {
2555 #[serde(default)]
2556 at: i64,
2557 #[serde(default)]
2558 round: u32,
2559 #[serde(default)]
2560 read: BTreeMap<String, u32>,
2561 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2567 full: BTreeMap<String, u32>,
2568 #[serde(default, skip_serializing_if = "String::is_empty")]
2570 pad: String,
2571}
2572
2573fn bound(computer: &str, round: u32, n: u64, clock: u64) -> String {
2574 format!("{computer}:{round}:{n}:{clock}")
2575}
2576
2577fn round_file(folder: &Path, computer: &str, round: u32) -> PathBuf {
2578 folder.join(format!("{computer}-{round}.jsonl"))
2579}
2580
2581fn computers(folder: &Path) -> BTreeSet<String> {
2585 let mut out: BTreeSet<String> = rounds_with(folder).into_keys().collect();
2586 for dir in std::iter::once(folder.to_path_buf()).chain(crate::remote::overlay(folder)) {
2587 for entry in std::fs::read_dir(&dir).into_iter().flatten().filter_map(Result::ok) {
2588 let name = entry.file_name().to_string_lossy().to_string();
2589 if let Some(id) = name.strip_suffix(".toml").filter(|id| id.len() == 36 && uuid::Uuid::parse_str(id).is_ok()) {
2590 out.insert(id.to_string());
2591 }
2592 }
2593 }
2594 out
2595}
2596
2597fn rounds_with(folder: &Path) -> BTreeMap<String, Vec<u32>> {
2600 let mut all = rounds(folder);
2601 if let Some(fetched) = crate::remote::overlay(folder) {
2602 for (computer, theirs) in rounds(&fetched) {
2603 let list = all.entry(computer).or_default();
2604 list.extend(theirs);
2605 list.sort_unstable();
2606 list.dedup();
2607 }
2608 }
2609 all
2610}
2611
2612fn open_round(folder: &Path, computer: &str, round: u32) -> std::io::Result<std::fs::File> {
2617 let here = round_file(folder, computer, round);
2618 let Some(fetched) = crate::remote::overlay(folder).map(|f| round_file(&f, computer, round)) else { return std::fs::File::open(here) };
2619 let size = |path: &Path| std::fs::metadata(path).map(|m| m.len()).ok();
2620 match (size(&here), size(&fetched)) {
2621 (Some(a), Some(b)) if b > a => std::fs::File::open(fetched),
2622 (None, Some(_)) => std::fs::File::open(fetched),
2623 _ => std::fs::File::open(here),
2624 }
2625}
2626
2627fn rounds(folder: &Path) -> BTreeMap<String, Vec<u32>> {
2629 let mut out: BTreeMap<String, Vec<u32>> = BTreeMap::new();
2630 for entry in std::fs::read_dir(folder).into_iter().flatten().filter_map(Result::ok) {
2631 let name = entry.file_name().to_string_lossy().to_string();
2632 let Some(stem) = name.strip_suffix(".jsonl") else { continue };
2633 let Some((computer, round)) = stem.rsplit_once('-') else { continue };
2635 if computer.len() != 36 || uuid::Uuid::parse_str(computer).is_err() {
2636 continue;
2637 }
2638 if let Ok(round) = round.parse::<u32>() {
2639 out.entry(computer.to_string()).or_default().push(round);
2640 }
2641 }
2642 for list in out.values_mut() {
2643 list.sort_unstable();
2644 }
2645 out
2646}
2647
2648fn wait(waiting: &mut BTreeMap<String, (Option<String>, u64, String, u32)>, key: String, change: (Option<String>, u64, String, u32)) {
2652 if waiting.get(&key).is_none_or(|(_, c, w, _)| (change.1, change.2.as_str()) > (*c, w.as_str())) {
2653 waiting.insert(key, change);
2654 }
2655}
2656
2657#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2659struct Newer {
2660 format: u32,
2662 #[serde(default)]
2664 build: String,
2665 #[serde(default)]
2669 writers: BTreeSet<String>,
2670}
2671
2672#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2678struct Floor {
2679 c: u64,
2680 w: String,
2681 #[serde(default, skip_serializing_if = "Option::is_none")]
2685 held: Option<BTreeSet<String>>,
2686 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2690 removed: BTreeMap<String, u64>,
2691}
2692
2693#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2700struct WholeList {
2701 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2702 held_at: BTreeMap<String, u64>,
2703 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2704 lacked_at: BTreeMap<String, u64>,
2705 #[serde(default)]
2707 top: u64,
2708}
2709
2710impl WholeList {
2711 fn saw(&mut self, c: u64, names: &BTreeSet<String>) {
2714 for name in self.held_at.keys().filter(|name| !names.contains(*name)) {
2715 let lacked = self.lacked_at.entry(name.clone()).or_default();
2716 *lacked = (*lacked).max(c);
2717 }
2718 for name in names {
2719 if !self.held_at.contains_key(name) && self.top > 0 {
2721 self.lacked_at.insert(name.clone(), self.top);
2722 }
2723 let held = self.held_at.entry(name.clone()).or_default();
2724 *held = (*held).max(c);
2725 }
2726 self.top = self.top.max(c);
2727 }
2728
2729 fn removed_at(&self, name: &str) -> Option<u64> {
2732 let held = self.held_at.get(name)?;
2733 self.lacked_at.get(name).copied().filter(|lacked| lacked > held)
2734 }
2735
2736 fn removed(&self) -> BTreeMap<String, u64> {
2737 self.held_at.keys().filter_map(|name| self.removed_at(name).map(|at| (name.clone(), at))).collect()
2738 }
2739}
2740
2741fn note_whole_record(stores: &[Store], memory: &mut Memory, key: &str, value: Option<&str>, c: u64) {
2745 let (file, entry) = (file_of(key), entry_of(key));
2746 let Some((store, _)) = locate(stores, file) else { return };
2747 let Shape::Toml(rules) = store.shape else { return };
2748 if rules.next.is_none() {
2749 return;
2750 }
2751 let Renamed::Whole(keyed) = renamed(rules, rules.at(2), entry) else { return };
2752 let elements: Vec<toml::Value> = value
2753 .and_then(|v| leaf_value(v).ok())
2754 .and_then(|v| v.as_array().cloned())
2755 .unwrap_or_default()
2756 .into_iter()
2757 .map(|mut element| {
2758 strip_local(&mut element, keyed.local);
2759 element
2760 })
2761 .collect();
2762 let held: BTreeSet<String> = names(&elements, keyed).into_iter().collect();
2763 memory.lists.entry(key.to_string()).or_default().saw(c, &held);
2764}
2765
2766fn note_whole_lists(stores: &[Store], memory: &mut Memory) {
2773 for (file, path, store) in divided(stores) {
2774 let Shape::Toml(rules) = store.shape else { continue };
2775 let next = rules.at(2);
2776 let Ok(text) = std::fs::read_to_string(&path) else { continue };
2777 if text.parse::<toml::Table>().is_err() {
2778 continue;
2779 }
2780 let mut now: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
2781 for (before, after, _) in aligned(&text, rules, next) {
2782 if matches!(renamed(rules, next, &before), Renamed::Whole(_)) && !after.is_empty() {
2783 now.entry(format!("{file}#{before}")).or_default().insert(element_name(&after).to_string());
2784 }
2785 }
2786 let keys: BTreeSet<String> = memory.lists.keys().filter(|key| file_of(key) == file).cloned().chain(now.keys().cloned()).collect();
2787 for key in keys {
2788 let held = now.remove(&key).unwrap_or_default();
2789 let clock = memory.entries.get(&key).map_or(memory.clock, |known| known.c);
2790 memory.lists.entry(key).or_default().saw(clock, &held);
2791 }
2792 }
2793}
2794
2795fn floor_key(key: &str) -> String {
2797 derived("floor", key)
2798}
2799
2800fn below_whole_floor(memory: &Memory, key: &str, c: u64) -> bool {
2810 let (file, entry) = (file_of(key), entry_of(key));
2811 let Some((list, element)) = entry.rsplit_once(SEP) else { return false };
2812 let Some(name) = element.strip_prefix(MARK) else { return false };
2813 let whole = format!("{file}#{list}");
2814 let by_floor = memory.floors.get(&floor_key(&whole)).is_some_and(|floor| floor.held.as_ref().is_some_and(|held| !held.contains(name)) && floor.removed.get(name).is_some_and(|at| c <= *at));
2815 by_floor || memory.lists.get(&whole).and_then(|list| list.removed_at(name)).is_some_and(|at| c <= at)
2817}
2818
2819#[derive(Debug, Clone, PartialEq, Eq)]
2821pub enum Holds {
2822 Older,
2825 Unread,
2827 Unheard,
2829}
2830
2831fn entry_stamp(entry: &crate::devices::Entry) -> i64 {
2834 entry.started.max(entry.imported).max(entry.closed).max(entry.exported)
2835}
2836
2837fn note_entries(folder: &Path, key: &[u8; 32], memory: &mut Memory) {
2841 for entry in crate::devices::all(folder, key).0 {
2842 if memory.seen.get(&entry.id).is_none_or(|seen| entry_stamp(&entry) >= entry_stamp(seen)) {
2843 memory.seen.insert(entry.id.clone(), entry);
2844 }
2845 }
2846}
2847
2848fn fill_heard_at(folder: &Path, key: &[u8; 32], computer: &str, memory: &mut Memory, now_ms: i64) {
2858 for (other, their_rounds) in rounds_with(folder) {
2859 if other == computer || memory.heard_at.contains_key(&other) {
2860 continue;
2861 }
2862 let Some(&round) = their_rounds.last() else { continue };
2863 if let Some(clock) = last_record_clock(folder, key, &other, round) {
2864 memory.heard_at.insert(other, (clock >> 16) as i64);
2865 }
2866 }
2867 let read: Vec<String> = memory.read_n.keys().filter(|id| *id != computer && !memory.heard_at.contains_key(*id)).cloned().collect();
2868 for other in read {
2869 let held = memory.entries.values().filter(|k| k.w == other).map(|k| k.c).chain(memory.floors.values().filter(|f| f.w == other).map(|f| f.c)).max();
2870 match held {
2871 Some(clock) => {
2872 memory.heard_at.insert(other.clone(), (clock >> 16) as i64);
2873 memory.unclocked.remove(&other);
2874 }
2875 None => {
2876 memory.unclocked.entry(other).or_insert(now_ms);
2877 }
2878 }
2879 }
2880 let heard: Vec<String> = memory.unclocked.keys().filter(|id| memory.heard_at.contains_key(*id)).cloned().collect();
2881 for id in heard {
2882 memory.unclocked.remove(&id);
2883 }
2884}
2885
2886fn last_record_clock(folder: &Path, key: &[u8; 32], computer: &str, round: u32) -> Option<u64> {
2888 use std::io::{Read, Seek};
2889 let mut file = open_round(folder, computer, round).ok()?;
2890 let size = file.metadata().ok()?.len();
2891 let from = size.saturating_sub(256 * 1024);
2892 let mut tail = Vec::new();
2893 file.seek(std::io::SeekFrom::Start(from)).ok()?;
2894 file.read_to_end(&mut tail).ok()?;
2895 tail.split(|b| *b == b'\n').rev().find_map(|line| {
2896 let record = std::str::from_utf8(line).ok().and_then(|t| serde_json::from_str::<Line>(t).ok())?;
2897 open(key, &bound(computer, round, record.n, record.c), &record.s).map(|_| record.c)
2898 })
2899}
2900
2901fn last_heard(entry: Option<&crate::devices::Entry>, memory: &Memory, id: &str) -> i64 {
2906 let entry = entry.map_or(0, |e| entry_stamp(e) * 1000);
2907 entry.max(memory.heard_at.get(id).copied().unwrap_or(0))
2908}
2909
2910fn present(folder: &Path, key: &[u8; 32], computer: &str, memory: &Memory, now_ms: i64) -> Vec<(String, Option<crate::devices::Entry>, bool)> {
2922 let (entries, unread) = crate::devices::all(folder, key);
2923 let recorded: BTreeSet<String> = rounds_with(folder).into_keys().chain(memory.read_n.keys().cloned()).chain(memory.heard_at.keys().cloned()).collect();
2924 let mut ids = computers(folder);
2925 ids.extend(entries.iter().map(|e| e.id.clone()));
2926 ids.extend(unread.iter().cloned());
2927 ids.extend(recorded.iter().cloned());
2928 ids.into_iter()
2929 .filter(|id| id != computer)
2930 .filter_map(|id| {
2931 let here = entries.iter().find(|e| e.id == id).cloned();
2932 let entry = match (here, memory.seen.get(&id)) {
2933 (Some(here), Some(seen)) if entry_stamp(seen) > entry_stamp(&here) => Some(seen.clone()),
2934 (None, Some(seen)) => Some(seen.clone()),
2935 (here, _) => here,
2936 };
2937 if entry.is_none() && !recorded.contains(&id) {
2938 return None;
2939 }
2940 if entry.as_ref().is_some_and(|e| e.left) {
2941 return None;
2942 }
2943 let last = last_heard(entry.as_ref(), memory, &id);
2944 if memory.forgotten.get(&id).is_some_and(|mark| last <= *mark) {
2945 return None;
2946 }
2947 let aged = if last > 0 { last } else { memory.unclocked.get(&id).copied().unwrap_or(0) };
2949 if aged > 0 && now_ms - aged > SILENT_DAYS * DAY {
2950 return None;
2951 }
2952 let unread = unread.contains(&id) && entries.iter().all(|e| e.id != id);
2953 Some((id, entry, unread))
2954 })
2955 .collect()
2956}
2957
2958fn format_holders(folder: &Path, key: &[u8; 32], computer: &str, memory: &Memory, now_ms: i64) -> Vec<(String, Holds)> {
2967 let heard = Heard { read: memory.read_n.clone(), broken: BTreeMap::new() };
2968 present(folder, key, computer, memory, now_ms)
2969 .into_iter()
2970 .filter_map(|(id, entry, unread)| match entry {
2971 Some(entry) if entry.format >= 2 => entry.wrote.is_some_and(|wrote| !heard.complete(&id, wrote)).then_some((id, Holds::Unheard)),
2972 _ if unread => Some((id, Holds::Unread)),
2973 _ => Some((id, Holds::Older)),
2974 })
2975 .collect()
2976}
2977
2978fn format_open(sharing: &Sharing, memory: &Memory, now_ms: i64) -> bool {
2984 !present(sharing.folder, sharing.key, sharing.computer, memory, now_ms).is_empty() && format_holders(sharing.folder, sharing.key, sharing.computer, memory, now_ms).iter().all(|(_, holds)| !memory.joined && *holds == Holds::Unheard)
2985}
2986
2987pub fn forget_device(folder: &Path, key: &[u8; 32], memory: &Path, computer: &str, device: &str) -> Result<(), String> {
2992 let _running = exchange_lock(memory, true)?;
2993 let mut state = Memory::load_all(memory, computer);
2994 let (entries, _) = crate::devices::all(folder, key);
2995 let mark = last_heard(entries.iter().find(|e| e.id == device), &state, device);
2996 state.forgotten.insert(device.to_string(), mark.max(1));
2997 state.save(memory)?;
2998 state.keep(memory);
2999 Ok(())
3000}
3001
3002pub fn count_device_again(memory: &Path, computer: &str, device: &str) -> Result<(), String> {
3004 let _running = exchange_lock(memory, true)?;
3005 let mut state = Memory::load_all(memory, computer);
3006 if state.forgotten.remove(device).is_some() {
3007 state.save(memory)?;
3008 state.keep(memory);
3009 }
3010 Ok(())
3011}
3012
3013#[derive(Debug, Default, Clone)]
3019pub struct Formats {
3020 pub writes: u32,
3021 pub newer: Vec<(String, u32, String)>,
3022 pub holders: Vec<(String, Holds)>,
3023 pub older: Vec<String>,
3026 pub heard: BTreeMap<String, i64>,
3031}
3032
3033pub fn formats(folder: &Path, key: &[u8; 32], memory: &Path, computer: &str, now_ms: i64) -> Formats {
3034 let state = Memory::load(memory, computer);
3035 let holders = if state.format < 2 && state.joined { format_holders(folder, key, computer, &state, now_ms) } else { Vec::new() };
3036 let counting = present(folder, key, computer, &state, now_ms);
3037 let older = if state.format >= 2 { counting.iter().filter(|(_, entry, _)| entry.as_ref().is_some_and(|e| e.format < FORMAT)).map(|(id, _, _)| id.clone()).collect() } else { Vec::new() };
3038 let heard = counting.iter().filter(|(id, _, _)| holders.iter().any(|(holder, _)| holder == id) || older.contains(id)).map(|(id, entry, _)| (id.clone(), last_heard(entry.as_ref(), &state, id))).collect();
3039 Formats { writes: state.format.max(1), newer: state.newer.into_iter().map(|(part, newer)| (part, newer.format, newer.build)).collect(), holders, older, heard }
3040}
3041
3042pub fn holds(memory: &Path, part: &str) -> bool {
3047 #[derive(Deserialize)]
3049 struct Held {
3050 #[serde(default)]
3051 newer: BTreeMap<String, serde::de::IgnoredAny>,
3052 }
3053 std::fs::read_to_string(memory).ok().and_then(|t| serde_json::from_str::<Held>(&t).ok()).is_some_and(|held| held.newer.contains_key(part))
3054}
3055
3056pub fn writes(memory: &Path) -> u32 {
3058 format_of(memory).max(1)
3059}
3060
3061fn format_of(memory: &Path) -> u32 {
3063 std::fs::read_to_string(memory).ok().and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok()).and_then(|v| v.get("format").and_then(serde_json::Value::as_u64)).map_or(0, |f| f as u32)
3064}
3065
3066fn release_newer(sharing: &Sharing, memory: &mut Memory, now_ms: i64) {
3070 if memory.newer.is_empty() {
3071 return;
3072 }
3073 let counting: BTreeSet<String> = present(sharing.folder, sharing.key, sharing.computer, memory, now_ms).into_iter().map(|(id, _, _)| id).collect();
3077 let gone: Vec<String> = memory.newer.iter().filter(|(_, newer)| newer.writers.iter().all(|w| !counting.contains(w))).map(|(part, _)| part.clone()).collect();
3078 for part in gone {
3079 memory.newer.remove(&part);
3080 let dropped: Vec<String> = memory.waiting.iter().filter(|(key, (_, _, _, f))| known_part(file_of(key)) == Some(part.as_str()) && *f > part_format(&part)).map(|(key, _)| key.clone()).collect();
3081 for key in dropped {
3082 if let Some((_, c, _, _)) = memory.waiting.remove(&key) {
3083 let released = memory.released.entry(part.clone()).or_default();
3084 *released = (*released).max(c);
3085 }
3086 }
3087 }
3088}
3089
3090enum Renamed<'r> {
3092 Same,
3094 Element(&'r Keyed),
3096 Whole(&'r Keyed),
3098}
3099
3100fn renamed<'r>(rules: &Rules, next: &'r Rules, entry: &str) -> Renamed<'r> {
3101 let path: Vec<&str> = entry.split(SEP).collect();
3102 match path.split_last() {
3103 Some((element, list)) if element.starts_with(MARK) && !list.is_empty() => match (keyed_named(rules, list), keyed_named(next, list)) {
3104 (Some(before), Some(after)) if before.ids == after.ids && before.by == after.by => Renamed::Same,
3105 (_, Some(after)) => Renamed::Element(after),
3106 _ => Renamed::Same,
3107 },
3108 _ => match (keyed_named(rules, &path), keyed_named(next, &path)) {
3109 (None, Some(after)) => Renamed::Whole(after),
3110 _ => Renamed::Same,
3111 },
3112 }
3113}
3114
3115fn aligned(text: &str, rules: &Rules, next: &Rules) -> Vec<(String, String, String)> {
3119 let Ok(table) = text.parse::<toml::Table>() else { return Vec::new() };
3120 let mut out = Vec::new();
3121 for (name, value) in &table {
3122 align(vec![name.clone()], value, rules, next, &mut out);
3123 }
3124 out
3125}
3126
3127fn align(path: Vec<String>, value: &toml::Value, rules: &Rules, next: &Rules, out: &mut Vec<(String, String, String)>) {
3128 if next.local.iter().any(|rule| matches_rule(&path, rule)) {
3129 return;
3130 }
3131 if let toml::Value::Array(elements) = value
3132 && let Some(keyed) = keyed_at(next, &path)
3133 {
3134 let (before, after) = list_names(elements, &path, rules, keyed);
3135 let list = list_name(keyed, &path);
3136 for ((entry, name), element) in before.into_iter().zip(names(&after, keyed)).zip(&after) {
3137 out.push((entry, format!("{list}{SEP}{MARK}{name}"), leaf_text(element)));
3138 }
3139 return;
3140 }
3141 if let toml::Value::Table(table) = value
3142 && !next.whole.iter().any(|rule| matches_rule(&path, rule))
3143 {
3144 for (name, value) in table {
3145 let mut deeper = path.clone();
3146 deeper.push(name.clone());
3147 align(deeper, value, rules, next, out);
3148 }
3149 }
3150}
3151
3152fn list_names(elements: &[toml::Value], path: &[String], rules: &Rules, keyed: &Keyed) -> (Vec<String>, Vec<toml::Value>) {
3156 let strip = |local: &[&str]| -> Vec<toml::Value> {
3157 elements
3158 .iter()
3159 .map(|element| {
3160 let mut element = element.clone();
3161 strip_local(&mut element, local);
3162 element
3163 })
3164 .collect()
3165 };
3166 let before: Vec<String> = match keyed_at(rules, path) {
3167 Some(before) => {
3168 let list = list_name(before, path);
3169 names(&strip(before.local), before).into_iter().map(|name| format!("{list}{SEP}{MARK}{name}")).collect()
3170 }
3171 None => vec![path.join(&SEP.to_string()); elements.len()],
3172 };
3173 (before, strip(keyed.local))
3174}
3175
3176fn stateless(keyed: &Keyed, ident: &str) -> String {
3182 if keyed.by.is_empty()
3183 && let Some(id) = leaf_value(ident).ok().as_ref().and_then(own_id).map(str::to_string)
3184 {
3185 return id;
3186 }
3187 if keyed.by == ["id"] && !ident.is_empty() {
3188 return ident.to_string();
3189 }
3190 derived(keyed.list, ident)
3191}
3192
3193fn element_name(entry: &str) -> &str {
3195 entry.rsplit_once(MARK).map_or("", |(_, name)| name)
3196}
3197
3198fn without_id(text: &str) -> String {
3200 match leaf_value(text) {
3201 Ok(toml::Value::Table(mut table)) => {
3202 table.remove("id");
3203 leaf_text(&toml::Value::Table(table))
3204 }
3205 Ok(other) => leaf_text(&other),
3206 Err(_) => text.to_string(),
3207 }
3208}
3209
3210fn translate(stores: &[Store], older: &BTreeMap<String, (Option<String>, u64, String)>, memory: &mut Memory, winners: &BTreeMap<String, (Option<String>, u64, String)>, modern: &BTreeSet<String>) -> (Vec<(String, Option<String>, u64, String)>, Vec<(String, String)>) {
3234 let mut versions: BTreeMap<String, Vec<(Option<String>, u64, String)>> = BTreeMap::new();
3235 let mut out = Vec::new();
3236 let mut again = Vec::new();
3237 let mut here: BTreeMap<String, Vec<(String, String, String)>> = BTreeMap::new();
3238 for (key, (value, c, w)) in older {
3239 let (file, entry) = (file_of(key), entry_of(key));
3240 let Some((store, path)) = locate(stores, file) else { continue };
3241 let Shape::Toml(rules) = store.shape else { continue };
3242 let next = rules.at(2);
3243 let renaming = renamed(rules, next, entry);
3244 if matches!(renaming, Renamed::Same) {
3245 out.push((key.clone(), value.clone(), *c, w.clone()));
3246 continue;
3247 }
3248 let floor = floor_key(key);
3249 let previous = memory.floors.get(&floor).cloned();
3250 if previous.as_ref().is_some_and(|f| (*c, w.as_str()) <= (f.c, f.w.as_str())) {
3251 continue;
3252 }
3253 let elements = here.entry(file.to_string()).or_insert_with(|| aligned(&std::fs::read_to_string(&path).unwrap_or_default(), rules, next));
3254 match renaming {
3255 Renamed::Same => {}
3256 Renamed::Element(keyed) => {
3257 let (list, ident) = entry.rsplit_once(SEP).map_or(("", ""), |(list, element)| (list, element.trim_start_matches(MARK)));
3258 let given = value.as_deref().and_then(|v| leaf_value(v).ok()).map(|mut element| {
3259 strip_local(&mut element, keyed.local);
3260 element
3261 });
3262 let fallback = stateless(keyed, ident);
3263 let derived_here = elements.iter().any(|(_, after, _)| element_name(after) == fallback);
3268 let mine = (!keyed.by.is_empty() && !derived_here).then(|| elements.iter().find(|(before, _, _)| before == entry)).flatten();
3269 let mut value = value.clone();
3270 let mut kept = false;
3272 let name = match (&given, given.as_ref().and_then(own_id), mine) {
3273 (Some(_), Some(id), _) => id.to_string(),
3274 (Some(_), None, Some((_, after, text))) => {
3275 kept = leaf_value(text).ok().as_ref().and_then(own_id).is_some();
3276 element_name(after).to_string()
3277 }
3278 (None, _, Some((_, after, _))) => element_name(after).to_string(),
3279 _ => fallback.clone(),
3280 };
3281 if let Some(element) = given.as_ref().filter(|e| own_id(e).is_none() && e.is_table()) {
3284 let mut element = element.clone();
3285 if let toml::Value::Table(table) = &mut element {
3286 table.insert("id".into(), toml::Value::String(name.clone()));
3287 }
3288 value = Some(leaf_text(&element));
3289 }
3290 if given.is_none() && keyed.by.is_empty() && !modern.contains(w) {
3296 let key2 = format!("{file}#{list}{SEP}{MARK}{name}");
3297 let current = winners.get(&key2).map(|(v, _, _)| v.clone()).unwrap_or_else(|| elements.iter().find(|(_, after, _)| *after == format!("{list}{SEP}{MARK}{name}")).map(|(_, _, text)| text.clone()));
3298 if current.is_some_and(|text| without_id(&text) != without_id(ident)) {
3299 memory.floors.insert(floor, Floor { c: *c, w: w.clone(), held: None, removed: BTreeMap::new() });
3300 continue;
3301 }
3302 }
3303 memory.floors.insert(floor, Floor { c: *c, w: w.clone(), held: None, removed: BTreeMap::new() });
3304 if kept && name != fallback {
3305 again.push((format!("{file}#{list}{SEP}{MARK}{name}"), format!("{file}#{list}{SEP}{MARK}{fallback}")));
3306 }
3307 versions.entry(format!("{file}#{list}{SEP}{MARK}{name}")).or_default().push((value, *c, w.clone()));
3308 }
3309 Renamed::Whole(keyed) => {
3310 let path: Vec<String> = entry.split(SEP).map(str::to_string).collect();
3311 let prefix = format!("{file}#{}{SEP}{MARK}", list_name(keyed, &path));
3312 let elements: Vec<toml::Value> = value
3313 .as_deref()
3314 .and_then(|v| leaf_value(v).ok())
3315 .and_then(|v| v.as_array().cloned())
3316 .unwrap_or_default()
3317 .into_iter()
3318 .map(|mut element| {
3319 strip_local(&mut element, keyed.local);
3320 element
3321 })
3322 .collect();
3323 let listed: BTreeSet<String> = names(&elements, keyed).into_iter().collect();
3324 for (element, name) in elements.iter().zip(names(&elements, keyed)) {
3325 versions.entry(format!("{prefix}{name}")).or_default().push((Some(leaf_text(element)), *c, w.clone()));
3326 }
3327 let mut removed = previous.as_ref().map(|f| f.removed.clone()).unwrap_or_default();
3329 for gone in previous.and_then(|f| f.held).unwrap_or_default().difference(&listed) {
3330 versions.entry(format!("{prefix}{gone}")).or_default().push((None, *c, w.clone()));
3331 removed.insert(gone.clone(), *c);
3332 }
3333 removed.retain(|name, _| !listed.contains(name));
3334 memory.floors.insert(floor, Floor { c: *c, w: w.clone(), held: Some(listed), removed });
3335 }
3336 }
3337 }
3338 for (key, list) in versions {
3339 let Some(last) = list.iter().max_by(|a, b| (a.1, &a.2).cmp(&(b.1, &b.2))) else { continue };
3340 let held = list.iter().filter(|v| v.0.is_some()).max_by(|a, b| (a.1, &a.2).cmp(&(b.1, &b.2))).and_then(|v| v.0.clone());
3341 out.push((key, held, last.1, last.2.clone()));
3342 }
3343 (out, again)
3344}
3345
3346fn divided<'s>(stores: &'s [Store]) -> Vec<(String, PathBuf, &'s Store)> {
3349 let mut out = Vec::new();
3350 for store in stores.iter().filter(|s| matches!(s.shape, Shape::Toml(rules) if rules.next.is_some())) {
3351 if store.folder {
3352 let mut files = Vec::new();
3353 list_files(&store.path, &store.path, store.skip, &mut files);
3354 out.extend(files.into_iter().map(|(relative, path)| (format!("{}{relative}", store.name), path, store)));
3355 } else if store.path.is_file() {
3356 out.push((store.name.clone(), store.path.clone(), store));
3357 }
3358 }
3359 out
3360}
3361
3362fn switch_format(sharing: &Sharing, memory: &mut Memory, stores: &[Store], now_ms: i64, problems: &mut Vec<String>) -> Result<bool, String> {
3382 let files = divided(stores);
3383 for (name, path, _) in &files {
3384 let text = std::fs::read_to_string(path);
3385 let held = memory.entries.iter().any(|(key, known)| file_of(key) == name && !known.h.is_empty());
3386 let reads = match &text {
3387 Ok(text) if text.trim().is_empty() => !held,
3388 Ok(text) => text.parse::<toml::Table>().is_ok(),
3389 Err(_) => false,
3390 };
3391 if !reads {
3392 problems.push(format!("share-format-wait:{name}"));
3393 return Ok(false);
3394 }
3395 }
3396 if memory.joined {
3397 flush(sharing, memory, stores, now_ms)?;
3398 }
3399 note_whole_lists(stores, memory);
3401 let history = crate::history::root(sharing.memory);
3406 let mut done = Vec::new();
3407 for (name, path, store) in &files {
3408 match give_ids(store, name, path, memory, &history, now_ms) {
3409 Ok(mut placed) => {
3410 placed.extend(whole_lists(store, name, path, memory));
3411 done.push((name, *store, placed));
3412 }
3413 Err(e) => {
3414 problems.push(e);
3415 problems.push(format!("share-format-wait:{name}"));
3416 return Ok(false);
3417 }
3418 }
3419 }
3420 for (name, store, placed) in done {
3421 rename_memory(memory, store, name, &placed);
3422 }
3423 let belongs = |file: &str| locate(stores, file).is_some_and(|(store, _)| matches!(store.shape, Shape::Toml(rules) if rules.next.is_some()));
3424 memory.files.retain(|file, _| !belongs(file));
3425 let pending: Vec<String> = memory.pending.keys().filter(|key| belongs(file_of(key))).cloned().collect();
3427 for key in pending {
3428 if let Some((value, c, w)) = memory.pending.remove(&key) {
3429 wait(&mut memory.waiting, key, (value, c, w, 1));
3430 }
3431 }
3432 memory.format = 2;
3433 memory.switch = false;
3434 Ok(true)
3435}
3436
3437fn flush(sharing: &Sharing, memory: &mut Memory, stores: &[Store], now_ms: i64) -> Result<(), String> {
3441 let joining = |store: &Store| !memory.places.is_empty() && memory.places.get(&store.name) != Some(&place_of(store));
3445 let subset: Vec<Store> = stores.iter().filter(|s| matches!(s.shape, Shape::Toml(rules) if rules.next.is_some()) && !joining(s) && !memory.newer.contains_key(s.part)).cloned().collect();
3446 let confirmed = BTreeSet::new();
3447 let found = gather(&subset, &memory.files, &Look { reads: &|_| true, hurry: &|| false, confirmed: &confirmed, clock: clock_ms() });
3448 let mut out: Vec<(String, Option<String>, u64, String)> = Vec::new();
3449 for (key, h) in &found.hashes {
3450 if memory.entries.get(key).is_some_and(|k| k.h == *h) {
3451 continue;
3452 }
3453 let Some(value) = found.values.get(key).cloned().or_else(|| value_of(&subset, key)) else { continue };
3454 let clock = memory.tick(found.changed.get(file_of(key)).copied().unwrap_or(now_ms).min(now_ms));
3455 memory.entries.insert(key.clone(), Known { c: clock, w: sharing.computer.to_string(), h: h.clone(), b: String::new() });
3456 out.push((key.clone(), Some(value), clock, String::new()));
3457 }
3458 let gone: Vec<String> = memory.entries.iter().filter(|(key, known)| !known.h.is_empty() && !found.hashes.contains_key(*key) && !found.is_unknown(key) && locate(&subset, file_of(key)).is_some()).map(|(key, _)| key.clone()).collect();
3459 for key in gone {
3460 let clock = memory.tick(now_ms);
3461 memory.entries.insert(key.clone(), Known { c: clock, w: sharing.computer.to_string(), h: String::new(), b: String::new() });
3462 out.push((key, None, clock, String::new()));
3463 }
3464 if memory.round == 0 {
3465 memory.round = 1;
3466 }
3467 append(sharing, memory, &out)?;
3468 for (file, stat) in found.files {
3469 memory.files.insert(file, stat);
3470 }
3471 Ok(())
3472}
3473
3474struct Placed {
3479 before: String,
3480 after: String,
3481 text: String,
3482 stands: bool,
3483 exact: bool,
3487}
3488
3489fn give_ids(store: &Store, file: &str, path: &Path, memory: &Memory, history: &Path, now_ms: i64) -> Result<Vec<Placed>, String> {
3502 let Shape::Toml(rules) = store.shape else { return Ok(Vec::new()) };
3503 let next = rules.at(2);
3504 sioul_core::filelock::with_lock(path, || {
3505 let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
3506 let mut doc: toml_edit::DocumentMut = text.parse().map_err(|e: toml_edit::TomlError| format!("{}: {e}", path.display()))?;
3507 let mut before: Vec<(Vec<String>, Vec<String>, Vec<(bool, bool)>)> = Vec::new();
3508 let mut presets: BTreeMap<String, String> = BTreeMap::new();
3509 let mut changed = false;
3510 each_id_list(doc.as_table_mut(), Vec::new(), next, &mut |path, keyed, list| {
3511 let normal = |t: &toml_edit::Table| leaf_value(&format!("[v]\n{t}")).ok();
3512 let read: Vec<toml::Value> = list.iter().map(|t| normal(t).unwrap_or_else(|| toml::Value::Table(toml::Table::new()))).collect();
3513 let (entries, _) = list_names(&read, path, rules, keyed);
3514 let whole = keyed_at(rules, path).is_none();
3515 let whole_stands = whole && memory.entries.get(&format!("{file}#{}", entries.first().cloned().unwrap_or_default())).is_some_and(|k| !k.h.is_empty() && toml_entries(&text, rules).is_ok_and(|all| all.iter().any(|(e, v)| entries.first() == Some(e) && hash(v) == k.h)));
3517 let texts: Vec<String> = read
3518 .iter()
3519 .map(|element| {
3520 let mut element = element.clone();
3521 strip_local(&mut element, keyed_at(rules, path).map_or(keyed.local, |b| b.local));
3522 leaf_text(&element)
3523 })
3524 .collect();
3525 let mut ids: BTreeMap<String, Vec<usize>> = BTreeMap::new();
3526 for (i, element) in read.iter().enumerate() {
3527 if let Some(id) = own_id(element) {
3528 ids.entry(id.to_string()).or_default().push(i);
3529 }
3530 }
3531 let mut alike: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
3537 for (i, entry) in entries.iter().enumerate() {
3538 alike.entry(entry.as_str()).or_default().push(i);
3539 }
3540 let standing = |i: usize| -> (bool, bool) {
3541 if whole {
3542 return (whole_stands, true);
3543 }
3544 let Some(known) = memory.entries.get(&format!("{file}#{}", entries[i])).filter(|k| !k.h.is_empty()) else { return (false, false) };
3545 let at = &alike[entries[i].as_str()];
3546 let chosen = if at.len() == 1 { at[0] } else if known.w == memory.computer { at[at.len() - 1] } else { at[0] };
3547 (chosen == i, known.h == hash(&texts[i]))
3548 };
3549 let mut claimed: BTreeSet<String> = BTreeSet::new();
3550 let mut stands = vec![(false, false); read.len()];
3551 for (i, element) in read.iter().enumerate() {
3552 let entry = &entries[i];
3553 let (shared, exact) = standing(i);
3554 let ident = element_name(entry).to_string();
3555 let new_id = match own_id(element) {
3556 Some(id) if ids.get(id).is_some_and(|at| at.len() == 1) => {
3557 stands[i] = (shared, exact);
3558 None
3559 }
3560 Some(id) => {
3561 let at = &ids[id];
3562 let keeper = at.iter().copied().max_by(|a, b| texts[*a].cmp(&texts[*b]).then(b.cmp(a))).unwrap_or(i);
3563 stands[i] = (shared, exact);
3564 (keeper != i).then(|| derived(keyed.list, if whole { &texts[i] } else { &ident }))
3565 }
3566 None => {
3567 let what = if whole { texts[i].clone() } else { ident.clone() };
3568 if (shared || !memory.joined) && claimed.insert(what.clone()) {
3569 stands[i] = (shared, exact);
3570 Some(derived(keyed.list, &what))
3571 } else {
3572 Some(sioul_core::ids::new())
3573 }
3574 }
3575 };
3576 if let Some(id) = new_id {
3577 if keyed.list == "preset" && path.len() == 1 {
3578 presets.insert(format!("#{i}"), id.clone());
3579 }
3580 if let Some(table) = list.get_mut(i) {
3581 table.insert("id", toml_edit::value(id));
3582 changed = true;
3583 }
3584 }
3585 }
3586 before.push((path.to_vec(), entries, stands));
3587 });
3588 let mut repointed: BTreeSet<(String, usize)> = BTreeSet::new();
3593 if !presets.is_empty() {
3594 for list in ["split", "line", "mail_rule"] {
3595 if let Some(tables) = doc.get_mut(list).and_then(toml_edit::Item::as_array_of_tables_mut) {
3596 for (at, table) in tables.iter_mut().enumerate() {
3597 if let Some(id) = table.get("preset").and_then(toml_edit::Item::as_str).and_then(|place| presets.get(place)).cloned() {
3598 table.insert("preset", toml_edit::value(id));
3599 repointed.insert((list.to_string(), at));
3600 }
3601 }
3602 }
3603 }
3604 }
3605 let written = doc.to_string();
3606 if changed {
3607 crate::history::keep(history, store.part, file, path, now_ms)?;
3608 write_atomically(path, written.as_bytes())?;
3609 }
3610 let now: toml::Table = written.parse().map_err(|e: toml::de::Error| format!("{}: {e}", path.display()))?;
3612 let mut placed = Vec::new();
3613 for (list_path, entries, stands) in before {
3614 let Some(keyed) = keyed_at(next, &list_path) else { continue };
3615 let Some(elements) = array_at(&now, &list_path) else { continue };
3616 let (_, after) = list_names(elements, &list_path, rules, keyed);
3617 let list = list_name(keyed, &list_path);
3618 for (at, (((entry, (stands, exact)), name), element)) in entries.into_iter().zip(stands).zip(names(&after, keyed)).zip(&after).enumerate() {
3619 let exact = exact && !(list_path.len() == 1 && repointed.contains(&(list_path[0].clone(), at)));
3620 placed.push(Placed { before: entry, after: format!("{list}{SEP}{MARK}{name}"), text: leaf_text(element), stands, exact });
3621 }
3622 }
3623 Ok(placed)
3624 })
3625}
3626
3627fn array_at<'t>(table: &'t toml::Table, path: &[String]) -> Option<&'t Vec<toml::Value>> {
3629 let (last, within) = path.split_last()?;
3630 let mut at = table;
3631 for part in within {
3632 at = at.get(part)?.as_table()?;
3633 }
3634 at.get(last)?.as_array()
3635}
3636
3637fn whole_lists(store: &Store, file: &str, path: &Path, memory: &Memory) -> Vec<Placed> {
3641 let Shape::Toml(rules) = store.shape else { return Vec::new() };
3642 let next = rules.at(2);
3643 let Ok(text) = std::fs::read_to_string(path) else { return Vec::new() };
3644 let sent: BTreeMap<String, String> = toml_entries(&text, rules).unwrap_or_default().into_iter().map(|(entry, value)| (entry, hash(&value))).collect();
3645 aligned(&text, rules, next)
3646 .into_iter()
3647 .filter(|(before, after, _)| matches!(renamed(rules, next, before), Renamed::Whole(keyed) if !keyed.ids) && !after.is_empty())
3648 .map(|(before, after, text)| {
3649 let stands = memory.entries.get(&format!("{file}#{before}")).is_some_and(|known| !known.h.is_empty() && sent.get(&before) == Some(&known.h));
3650 Placed { before, after, text, stands, exact: true }
3651 })
3652 .collect()
3653}
3654
3655fn each_id_list(table: &mut toml_edit::Table, path: Vec<String>, next: &Rules, f: &mut dyn FnMut(&[String], &Keyed, &mut toml_edit::ArrayOfTables)) {
3657 let keys: Vec<String> = table.iter().map(|(key, _)| key.to_string()).collect();
3658 for key in keys {
3659 let mut deeper = path.clone();
3660 deeper.push(key.clone());
3661 if next.local.iter().any(|rule| matches_rule(&deeper, rule)) {
3662 continue;
3663 }
3664 let Some(item) = table.get_mut(&key) else { continue };
3665 match item {
3666 toml_edit::Item::ArrayOfTables(list) => {
3667 if let Some(keyed) = keyed_at(next, &deeper).filter(|k| k.ids) {
3668 f(&deeper, keyed, list);
3669 }
3670 }
3671 toml_edit::Item::Table(inner) if !next.whole.iter().any(|rule| matches_rule(&deeper, rule)) => each_id_list(inner, deeper, next, f),
3672 _ => {}
3673 }
3674 }
3675}
3676
3677fn rename_memory(memory: &mut Memory, store: &Store, file: &str, placed: &[Placed]) {
3686 let Shape::Toml(rules) = store.shape else { return };
3687 let next = rules.at(2);
3688 let mut grouped: BTreeMap<String, Vec<Known>> = BTreeMap::new();
3689 let mut wholes: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
3690 for element in placed {
3691 let before = format!("{file}#{}", element.before);
3692 if let Renamed::Whole(_) = renamed(rules, next, &element.before) {
3693 wholes.entry(before.clone()).or_default().insert(element_name(&element.after).to_string());
3694 }
3695 if !element.stands {
3696 continue;
3697 }
3698 if let Some(known) = memory.entries.get(&before) {
3699 let h = if element.exact { hash(&element.text) } else { "?".to_string() };
3701 grouped.entry(format!("{file}#{}", element.after)).or_default().push(Known { c: known.c, w: known.w.clone(), h, b: String::new() });
3702 }
3703 }
3704 let keys: Vec<String> = memory.entries.keys().filter(|key| file_of(key) == file).cloned().collect();
3705 for key in keys {
3706 let entry = entry_of(&key).to_string();
3707 let renaming = renamed(rules, next, &entry);
3708 if matches!(renaming, Renamed::Same) {
3709 continue;
3710 }
3711 let Some(known) = memory.entries.remove(&key) else { continue };
3712 match renaming {
3713 Renamed::Same => {}
3714 Renamed::Whole(_) => {
3715 let held = wholes.get(&key).cloned().filter(|_| !known.h.is_empty());
3716 let removed = memory.lists.get(&key).map(WholeList::removed).unwrap_or_default();
3717 memory.floors.insert(floor_key(&key), Floor { c: known.c, w: known.w.clone(), held: Some(held.unwrap_or_default()), removed });
3718 }
3719 Renamed::Element(keyed) => {
3720 memory.floors.insert(floor_key(&key), Floor { c: known.c, w: known.w.clone(), held: None, removed: BTreeMap::new() });
3721 if !placed.iter().any(|p| p.stands && format!("{file}#{}", p.before) == key) {
3722 let (list, ident) = entry.rsplit_once(SEP).map_or(("", ""), |(list, element)| (list, element.trim_start_matches(MARK)));
3723 grouped.entry(format!("{file}#{list}{SEP}{MARK}{}", stateless(keyed, ident))).or_default().push(Known { h: String::new(), ..known });
3724 }
3725 }
3726 }
3727 }
3728 for (key, versions) in grouped {
3729 let Some(last) = versions.iter().max_by(|a, b| (a.c, &a.w).cmp(&(b.c, &b.w))).cloned() else { continue };
3730 let held = versions.iter().filter(|v| !v.h.is_empty()).max_by(|a, b| (a.c, &a.w).cmp(&(b.c, &b.w))).map(|v| v.h.clone()).unwrap_or_default();
3731 memory.entries.insert(key, Known { c: last.c, w: last.w, h: held, b: String::new() });
3732 }
3733}
3734
3735pub struct Sharing<'a> {
3739 pub folder: &'a Path,
3740 pub computer: &'a str,
3741 pub key: &'a [u8; 32],
3742 pub memory: &'a Path,
3744 pub files: bool,
3749 pub hurry: Option<&'a std::sync::atomic::AtomicBool>,
3752}
3753
3754#[derive(Debug, Default)]
3756pub struct Outcome {
3757 pub written: BTreeSet<String>,
3759 pub waits: BTreeMap<String, usize>,
3767 pub sent: usize,
3769 pub received: usize,
3771 pub problems: Vec<String>,
3773 pub pending: usize,
3775 pub looked: i64,
3779 pub wrote: Option<(u32, u64)>,
3782 pub quiet: bool,
3785}
3786
3787fn keep_copies(stores: &[Store], memory: &Path, today: &str) {
3796 let Some(base) = memory.parent() else { return };
3797 let aside = base.join(format!("before-sharing-{today}"));
3798 let keep = |from: &Path, to: &Path| {
3799 if to.exists() {
3800 return;
3801 }
3802 let _ = to.parent().map(private_dirs);
3803 let _ = copy_private(from, to);
3804 };
3805 for store in stores.iter().filter(|s| !matches!(s.shape, Shape::Files) && !calls_store(&s.name)) {
3806 let target = aside.join(store.name.trim_end_matches('/'));
3807 if store.folder {
3808 let mut files = Vec::new();
3809 list_files(&store.path, &store.path, store.skip, &mut files);
3810 for (relative, path) in files {
3811 keep(&path, &target.join(relative));
3812 }
3813 } else if store.path.is_file() {
3814 keep(&store.path, &target);
3815 }
3816 }
3817}
3818
3819pub fn exchange(sharing: &Sharing, stores: &[Store], now_ms: i64) -> Result<Outcome, String> {
3823 let (sealed, salt) = match sealed_for(sharing.folder, sharing.key) {
3826 Ok(Some((opens, salt))) => (Some(opens), salt),
3827 Ok(None) => (None, String::new()),
3828 Err(()) => return Ok(Outcome { problems: vec!["share-no-seal".into()], ..Outcome::default() }),
3829 };
3830 let Some(_running) = exchange_lock(sharing.memory, !sharing.files)? else { return Ok(Outcome { problems: vec!["share-busy".into()], ..Outcome::default() }) };
3831 crate::remote::attach(sharing.folder, sharing.memory);
3835 let mut outcome = Outcome::default();
3836 let hurried = || sharing.hurry.is_some_and(|hurry| hurry.load(std::sync::atomic::Ordering::Relaxed));
3840 let reads = |store: &Store| reads_now(sharing, store);
3843 let standing = (!sharing.files).then(|| {
3849 let taken = clock_ms();
3850 (Standing::now(sharing, stores, &reads), taken)
3851 });
3852 if let Some(outcome) = standing.as_ref().and_then(|(standing, taken)| quiet(sharing, standing, *taken, now_ms)) {
3853 return Ok(outcome);
3854 }
3855 let mut memory = Memory::load_all(sharing.memory, sharing.computer);
3856 if sealed == Some(true) && !memory.seal_salt.is_empty() && memory.seal_salt != salt {
3863 let round = last_own(sharing.folder, sharing.computer).map_or(1, |(round, _, _)| round + 1);
3867 memory = Memory { computer: sharing.computer.to_string(), round, full_round: round, ..Memory::default() };
3868 }
3869 if sealed == Some(true) {
3870 memory.seal_salt = salt;
3871 }
3872 match (sealed, memory.seal_seen) {
3878 (None, true) => return Ok(Outcome { problems: vec!["share-no-seal".into()], ..Outcome::default() }),
3879 (Some(false), true) => return Ok(Outcome { problems: vec!["share-sealed-otherwise".into()], ..Outcome::default() }),
3880 (Some(true), false) => memory.seal_seen = true,
3881 _ => {}
3882 }
3883 let mut restate = false;
3890 if let Some((round, n, size)) = last_own(sharing.folder, sharing.computer)
3891 && (round, n) > (memory.round, memory.seq)
3892 {
3893 outcome.problems.push("share-own-ahead".into());
3894 if appended(sharing.memory, sharing.computer).is_none_or(|end| (round, n) > end) {
3904 if memory.ahead_at > 0 && now_ms - memory.ahead_at < TWIN_WITHIN {
3905 outcome.problems.push("share-twin".into());
3906 }
3907 memory.ahead_at = now_ms;
3908 }
3909 memory.round = round;
3910 memory.seq = n;
3911 memory.own_size = size;
3912 memory.round_base = 0;
3913 memory.continues = 0;
3918 if n == 0 && round > 1 {
3919 match last_line(sharing.folder, sharing.computer, round - 1) {
3920 Some(last) if last > 0 => memory.continues = last,
3921 _ => restate = true,
3922 }
3923 }
3924 memory.full_round = 1;
3927 memory.read.clear();
3928 memory.rebuild.insert("*".into());
3929 }
3930 memory.entries.retain(|key, _| !retired(key));
3937 if !memory.turns {
3938 if memory.joined {
3939 memory.read.clear();
3940 }
3941 memory.turns = true;
3942 }
3943 if !memory.joined {
3944 let today = jiff::Timestamp::from_millisecond(now_ms).map(|t| t.strftime("%Y-%m-%d").to_string()).unwrap_or_default();
3945 keep_copies(stores, sharing.memory, &today);
3946 }
3947 memory.newer.retain(|part, newer| newer.format > part_format(part));
3956 note_entries(sharing.folder, sharing.key, &mut memory);
3958 fill_heard_at(sharing.folder, sharing.key, sharing.computer, &mut memory, now_ms);
3959 release_newer(sharing, &mut memory, now_ms);
3960 if memory.format < 2 && (memory.switch || format_open(sharing, &memory, now_ms)) {
3961 switch_format(sharing, &mut memory, stores, now_ms, &mut outcome.problems)?;
3962 }
3963 let originals = stores;
3964 let shaped_stores = shaped(stores, memory.format);
3965 let stores = shaped_stores.as_slice();
3966 let held: BTreeSet<&'static str> = memory.newer.keys().filter_map(|part| PARTS.iter().find(|p| **p == part.as_str()).copied()).collect();
3968 let holds = |key: &str| known_part(file_of(key)).is_some_and(|part| held.contains(part));
3969 let clock = clock_ms();
3970 let confirmed = memory.sealed.confirmed.clone();
3971 let mut found = gather(stores, &memory.files, &Look { reads: &reads, hurry: &hurried, confirmed: &confirmed, clock });
3972 outcome.problems.extend(found.too_big.iter().map(|file| format!("share-too-big:{file}")));
3973 outcome.problems.append(&mut found.said);
3974 outcome.problems.extend(found.emptied.iter().map(|(folder, held)| format!("share-vanished:{folder}:{held}")));
3975 let mut out: Vec<(String, Option<String>, u64, String)> = Vec::new();
3976
3977 let place = place_of;
3985 if memory.joined && memory.places.is_empty() {
3986 memory.places = stores.iter().map(|s| (s.name.clone(), place(s))).collect();
3987 }
3988 let joining: BTreeSet<String> = if memory.joined { stores.iter().filter(|s| reads(s) && memory.places.get(&s.name) != Some(&place(s))).map(|s| s.name.clone()).collect() } else { BTreeSet::new() };
3989 let joins = |key: &str| locate(stores, file_of(key)).is_some_and(|(store, _)| joining.contains(&store.name));
3990 if !joining.is_empty() {
3991 memory.entries.retain(|key, _| !joins(key));
3992 memory.read.clear();
3993 memory.rebuild.extend(joining.iter().cloned());
3994 }
3995
3996 let mut held_empty: BTreeSet<String> = BTreeSet::new();
3999 let emptied_now: Vec<String> = found.files.iter().filter(|(file, stat)| file.starts_with(FILES) && stat.size == 0 && memory.files.get(*file).is_some_and(|before| before.size > 0)).map(|(file, _)| file.clone()).collect();
4000 for file in emptied_now {
4001 let since = *memory.emptied.entry(file.clone()).or_insert(now_ms);
4002 if now_ms - since < EMPTIED_WAIT {
4003 found.hashes.remove(&format!("{file}#"));
4004 found.unknown.insert(file.clone());
4005 if let Some(before) = memory.files.get(&file) {
4006 found.files.insert(file.clone(), before.clone());
4007 }
4008 held_empty.insert(file);
4009 }
4010 }
4011 let unread = |file: &str| locate(stores, file).is_some_and(|(store, _)| !reads(store));
4013 memory.emptied.retain(|file, _| !file.starts_with(FILES) || held_empty.contains(file) || unread(file));
4014
4015 let mut held_gone: BTreeSet<String> = BTreeSet::new();
4021 let mut gone_since: BTreeMap<String, i64> = memory.sealed.gone.iter().filter(|(file, _)| unread(file)).map(|(file, since)| (file.clone(), *since)).collect();
4022 if memory.joined {
4023 let mut by_store: BTreeMap<String, (usize, Vec<String>)> = BTreeMap::new();
4024 for key in memory.entries.iter().filter(|(key, known)| key.starts_with(FILES) && !known.h.is_empty()).map(|(key, _)| key) {
4025 let Some((store, _)) = locate(stores, file_of(key)) else { continue };
4026 if !reads(store) || joining.contains(&store.name) {
4027 continue;
4028 }
4029 let counted = by_store.entry(store.name.clone()).or_default();
4030 counted.0 += 1;
4031 if !found.hashes.contains_key(key) && !found.is_unknown(key) {
4032 counted.1.push(file_of(key).to_string());
4033 }
4034 }
4035 for (store, (held, gone)) in by_store.into_iter().filter(|(_, (_, gone))| !gone.is_empty()) {
4036 let confirmed = memory.sealed.confirmed.contains(&store);
4037 let mass = gone.len() > MASS || (gone.len() >= 3 && gone.len() * 5 > held);
4038 if mass && !confirmed {
4039 outcome.problems.push(format!("share-vanished:{store}:{}", gone.len()));
4040 }
4041 for file in gone {
4042 let since = memory.sealed.gone.get(&file).copied().unwrap_or(now_ms);
4043 if mass && !confirmed {
4044 gone_since.insert(file.clone(), since);
4045 found.unknown.insert(file);
4046 } else if !confirmed && now_ms - since < EMPTIED_WAIT {
4047 gone_since.insert(file.clone(), since);
4048 held_gone.insert(file);
4049 }
4050 }
4051 }
4052 }
4053 memory.sealed.gone = gone_since;
4054 let value = |key: &str| found.values.get(key).cloned().or_else(|| value_of(stores, key));
4055
4056 let mut older: Vec<String> = Vec::new();
4059 if memory.joined {
4060 for (key, h) in &found.hashes {
4061 if memory.entries.get(key).is_some_and(|k| k.h == *h) || joins(key) {
4062 memory.held.remove(key);
4063 continue;
4064 }
4065 if holds(key) {
4068 if memory.held.get(key).is_none_or(|(_, held)| held != h) {
4069 let at = memory.tick(found.changed.get(file_of(key)).copied().unwrap_or(now_ms).min(now_ms));
4070 memory.held.insert(key.clone(), (at, h.clone()));
4071 }
4072 continue;
4073 }
4074 if key.starts_with(FILES) {
4075 let file = file_of(key);
4079 if memory.entries.get(key).is_some_and(|k| !k.h.is_empty()) && found.files.get(file).zip(memory.files.get(file)).is_some_and(|(now, before)| now.modified < before.modified) {
4080 older.push(key.clone());
4081 continue;
4082 }
4083 if hurried() {
4084 continue;
4085 }
4086 }
4087 let Some(value) = value(key) else { continue };
4088 if !seal_apart(sharing, stores, key, &value, &mut memory.sealed, &mut outcome.problems, now_ms) {
4090 continue;
4091 }
4092 let base = base_of(&memory, key);
4093 let clock = match memory.held.remove(key) {
4094 Some((at, held)) if held == *h => at,
4095 _ => memory.tick(found.changed.get(file_of(key)).copied().unwrap_or(now_ms).min(now_ms)),
4096 };
4097 memory.entries.insert(key.clone(), Known { c: clock, w: sharing.computer.to_string(), h: h.clone(), b: base.clone() });
4098 met_draft(&mut memory, key, [&base, h]);
4099 out.push((key.clone(), Some(value), clock, base));
4100 }
4101 let settings = |file: &str| stores.iter().find(|s| !s.folder && s.name == file && matches!(s.shape, Shape::Toml(_)));
4110 let held: BTreeSet<String> = memory.entries.iter().filter(|(_, k)| !k.h.is_empty()).map(|(key, _)| file_of(key).to_string()).filter(|file| settings(file).is_some()).collect();
4111 let mut waiting: BTreeSet<String> = BTreeSet::new();
4112 for file in held {
4113 let nothing = settings(&file).is_some_and(|s| std::fs::metadata(&s.path).map_or(true, |m| m.len() == 0));
4114 if !nothing || found.hashes.keys().any(|k| file_of(k) == file) {
4115 memory.emptied.remove(&file);
4116 } else if now_ms - *memory.emptied.entry(file.clone()).or_insert(now_ms) < EMPTIED_WAIT {
4117 outcome.problems.push(format!("share-emptied:{file}"));
4118 waiting.insert(file);
4119 }
4120 }
4121 let gone: Vec<String> = memory
4122 .entries
4123 .iter()
4124 .filter(|(key, known)| !known.h.is_empty() && !found.hashes.contains_key(*key) && !found.is_unknown(key) && !held_gone.contains(file_of(key)))
4125 .filter(|(key, _)| locate(stores, file_of(key)).is_some_and(|(store, _)| reads(store)) && !waiting.contains(file_of(key)))
4126 .map(|(key, _)| key.clone())
4127 .collect();
4128 for key in gone {
4129 if holds(&key) {
4130 if memory.held.get(&key).is_none_or(|(_, held)| !held.is_empty()) {
4131 let at = memory.tick(now_ms);
4132 memory.held.insert(key.clone(), (at, String::new()));
4133 }
4134 continue;
4135 }
4136 let base = base_of(&memory, &key);
4137 let clock = match memory.held.remove(&key) {
4138 Some((at, held)) if held.is_empty() => at,
4139 _ => memory.tick(now_ms),
4140 };
4141 memory.entries.insert(key.clone(), Known { c: clock, w: sharing.computer.to_string(), h: String::new(), b: base.clone() });
4142 met_draft(&mut memory, &key, [&base, ""]);
4143 out.push((key, None, clock, base));
4144 }
4145 }
4146
4147 let newer = |c: u64, w: &str, than: Option<(u64, &str)>| than.is_none_or(|(tc, tw)| (c, w) > (tc, tw));
4151 let off = |key: &str| retired(key) || (locate(stores, file_of(key)).is_none() && known_part(file_of(key)).is_some());
4153 let mut winners: BTreeMap<String, (Option<String>, u64, String)> = std::mem::take(&mut memory.pending)
4154 .into_iter()
4155 .filter(|(key, (_, c, w))| !off(key) && newer(*c, w, memory.entries.get(key).map(|k| (k.c, k.w.as_str()))))
4156 .collect();
4157 let mut older_format: BTreeMap<String, (Option<String>, u64, String)> = BTreeMap::new();
4161 let divided_otherwise = |key: &str| locate(originals, file_of(key)).is_some_and(|(store, _)| matches!(store.shape, Shape::Toml(rules) if rules.next.is_some()));
4162 let later = |older_format: &mut BTreeMap<String, (Option<String>, u64, String)>, key: String, value: Option<String>, c: u64, w: &str| {
4163 if older_format.get(&key).is_none_or(|(_, tc, tw)| (c, w) > (*tc, tw.as_str())) {
4164 older_format.insert(key, (value, c, w.to_string()));
4165 }
4166 };
4167 let ready: Vec<String> = memory.waiting.iter().filter(|(key, (_, _, _, f))| memory.format >= 2 && known_part(file_of(key)).is_some_and(|part| *f <= part_format(part))).map(|(key, _)| key.clone()).collect();
4170 for key in ready {
4171 let Some((value, c, w, f)) = memory.waiting.remove(&key) else { continue };
4172 if off(&key) {
4173 continue;
4174 }
4175 if f < 2 && divided_otherwise(&key) {
4176 later(&mut older_format, key, value, c, &w);
4177 } else if !below_whole_floor(&memory, &key, c) && newer(c, &w, memory.entries.get(&key).map(|k| (k.c, k.w.as_str()))) && newer(c, &w, winners.get(&key).map(|(_, c, w)| (*c, w.as_str()))) {
4178 winners.insert(key, (value, c, w));
4179 }
4180 }
4181 let mut builds: BTreeMap<String, String> = BTreeMap::new();
4183 let mut bases: BTreeMap<String, String> = std::mem::take(&mut memory.sealed.bases).into_iter().filter(|(key, _)| winners.contains_key(key)).collect();
4188 let mut named: BTreeMap<String, BTreeSet<String>> = std::mem::take(&mut memory.sealed.named).into_iter().filter(|(key, _)| winners.contains_key(key)).collect();
4189 for (key, (value, _, _)) in winners.iter().filter(|(key, _)| key.starts_with(FILES)) {
4190 named.entry(key.clone()).or_default().extend(value.as_deref().map(|v| value_hash(key, v)).into_iter().chain(bases.get(key).cloned()).filter(|h| !h.is_empty()));
4191 }
4192 let all = rounds_with(sharing.folder);
4193 let rebuilding = std::mem::take(&mut memory.rebuild);
4195 let rebuilt = |file: &str| rebuilding.contains("*") || rebuilding.contains(file) || rebuilding.iter().any(|r| r.ends_with('/') && file.starts_with(r.as_str()));
4196 for (computer, their_rounds) in all.iter().filter(|(c, _)| *c != sharing.computer || !rebuilding.is_empty()) {
4197 let own = computer == sharing.computer;
4198 let (mut start, mut skip) = if own { (0, 0) } else { memory.read.get(computer).copied().unwrap_or((0, 0)) };
4199 if !their_rounds.contains(&start) {
4200 (start, skip) = (their_rounds[0], 0);
4202 }
4203 let mut from_round = start;
4206 'rounds: for &round in their_rounds.iter().filter(|r| **r >= start) {
4207 if round < from_round {
4208 continue;
4209 }
4210 let mut offset = if round == start { skip } else { 0 };
4211 let opened = if own { std::fs::File::open(round_file(sharing.folder, computer, round)) } else { open_round(sharing.folder, computer, round) };
4212 let Ok(mut file) = opened else {
4213 if !own {
4214 memory.read.insert(computer.clone(), (round, offset));
4215 }
4216 continue;
4217 };
4218 if !own && file.metadata().is_ok_and(|m| m.len() < offset) {
4222 outcome.problems.push(format!("share-other-cut:{computer}"));
4223 say_broken(&mut memory, computer, now_ms / 1000);
4224 offset = 0;
4225 }
4226 {
4227 use std::io::Seek;
4228 let _ = file.seek(std::io::SeekFrom::Start(offset));
4229 }
4230 let record_of = |line: &[u8]| std::str::from_utf8(line).ok().and_then(|text| serde_json::from_str::<Line>(text).ok());
4234 let opens = |record: &Line| {
4235 let plain = open(sharing.key, &bound(computer, round, record.n, record.c), &record.s)?;
4236 serde_json::from_slice::<Opened>(&plain).ok().map(Opened::parts)
4239 };
4240 let mut good: Option<(u64, Option<u64>)> = memory.read_n.get(computer).filter(|(r, _)| *r == round && offset > 0).map(|(_, n)| (*n, None));
4243 let mut lines = LineReader::new(file);
4244 let mut next = lines.next();
4245 while let Some((line, length)) = next.take() {
4246 next = lines.next();
4247 let Some(record) = record_of(&line) else {
4248 offset += length;
4250 if !own {
4251 if let Some(at) = lost_line(memory.read_n.get(computer).copied(), round, good, next.as_ref().map(|(n, _)| n.as_slice()), now_ms) {
4252 outcome.problems.push(format!("share-other-line:{computer}"));
4253 say_broken(&mut memory, computer, at);
4254 }
4255 lost_here(&mut memory, computer, round, None);
4256 }
4257 continue;
4258 };
4259 let Some((change, turn)) = opens(&record) else {
4260 outcome.problems.push(format!("share-other-seal:{computer}"));
4261 if !own && next.as_ref().and_then(|(next, _)| record_of(next)).is_some_and(|next| opens(&next).is_some()) {
4265 offset += length;
4266 if let Some(at) = lost_line(memory.read_n.get(computer).copied(), round, good, next.as_ref().map(|(n, _)| n.as_slice()), now_ms) {
4267 say_broken(&mut memory, computer, at);
4268 }
4269 lost_here(&mut memory, computer, round, Some(record.n));
4270 continue;
4271 }
4272 if !own {
4273 if let Some(full) = later_full(sharing, computer, their_rounds, round) {
4277 from_round = full;
4278 continue 'rounds;
4279 }
4280 memory.read.insert(computer.clone(), (round, offset));
4281 }
4282 break 'rounds;
4283 };
4284 if !own
4301 && record.n == 1
4302 && turn.p > 0
4303 && let Some(before) = round.checked_sub(1)
4304 && memory.met.get(computer).or_else(|| memory.read_n.get(computer)).is_some_and(|met| *met < (before, turn.p))
4305 {
4306 if let Some(full) = later_full(sharing, computer, their_rounds, round) {
4307 memory.waiting_for.remove(computer);
4308 from_round = full;
4309 continue 'rounds;
4310 }
4311 if their_rounds.iter().any(|r| *r <= before) {
4312 let waiting = memory.waiting_for.entry(computer.clone()).or_default();
4313 if waiting.round == round {
4314 waiting.waited += (now_ms - waiting.last).clamp(0, AWAIT_STEP);
4315 } else {
4316 *waiting = Awaited { round, waited: 0, last: now_ms };
4317 }
4318 waiting.last = now_ms;
4319 if waiting.waited < AWAITED {
4320 break 'rounds;
4321 }
4322 }
4323 }
4324 if !own && record.n == 1 {
4325 memory.waiting_for.remove(computer);
4326 }
4327 offset += length;
4328 good = Some((record.n, Some(record.c)));
4329 if !change.build.is_empty() {
4330 builds.insert(computer.clone(), change.build.clone());
4331 }
4332 if change.k.starts_with(FILES)
4334 && let Some(value) = change.v.as_deref()
4335 {
4336 memory.sealed.named_at.insert(crate::blobs::name(sharing.key, &value_hash(&change.k, value)), now_ms);
4337 }
4338 if own {
4339 if rebuilt(file_of(&change.k)) && !off(&change.k) && memory.format < 2 && change.f >= 2 {
4341 memory.switch = true;
4346 wait(&mut memory.waiting, change.k, (change.v, record.c, computer.clone(), change.f));
4347 } else if rebuilt(file_of(&change.k)) && !off(&change.k) && memory.format >= 2 && change.f < 2 && divided_otherwise(&change.k) {
4348 later(&mut older_format, change.k, change.v, record.c, computer);
4349 } else if rebuilt(file_of(&change.k)) && !off(&change.k) && !below_whole_floor(&memory, &change.k, record.c) && newer(record.c, computer, winners.get(&change.k).map(|(_, c, w)| (*c, w.as_str()))) && memory.entries.get(&change.k).is_none_or(|k| (record.c, computer.as_str()) > (k.c, k.w.as_str())) {
4350 bases.insert(change.k.clone(), change.b);
4351 winners.insert(change.k, (change.v, record.c, computer.clone()));
4352 }
4353 continue;
4354 }
4355 if memory.met.get(computer).is_none_or(|met| (round, record.n) > *met) {
4364 memory.met.insert(computer.clone(), (round, record.n));
4365 }
4366 let last = memory.read_n.get(computer).copied();
4367 if last.is_none_or(|last| (round, record.n) > last) {
4368 let follows = match last {
4369 Some((r, n)) if r == round => record.n == n + 1,
4370 _ if record.n != 1 => false,
4371 _ if turn.full => true,
4372 _ if turn.p > 0 => last.is_some() && last == round.checked_sub(1).map(|before| (before, turn.p)),
4373 _ => true,
4374 };
4375 if follows {
4376 memory.read_n.insert(computer.clone(), (round, record.n));
4377 } else if last.is_some() {
4378 if !outcome.problems.iter().any(|p| *p == format!("share-other-gap:{computer}")) {
4379 outcome.problems.push(format!("share-other-gap:{computer}"));
4380 }
4381 say_broken(&mut memory, computer, ((record.c >> 16) as i64 / 1000).min(now_ms / 1000));
4383 }
4384 }
4385 memory.clock = memory.clock.max(record.c);
4386 let heard_at = (record.c >> 16) as i64;
4388 if memory.heard_at.get(computer).is_none_or(|at| *at < heard_at) {
4389 memory.heard_at.insert(computer.clone(), heard_at);
4390 }
4391 if off(&change.k) {
4392 continue;
4393 }
4394 let format = change.f.max(1);
4398 if let Some(part) = known_part(file_of(&change.k))
4399 && format > part_format(part)
4400 {
4401 if memory.released.get(part).is_some_and(|c| record.c <= *c) {
4403 continue;
4404 }
4405 if !memory.newer.contains_key(part) {
4406 outcome.problems.push(format!("share-newer:{part}"));
4407 }
4408 let newer = memory.newer.entry(part.to_string()).or_default();
4409 newer.format = newer.format.max(format);
4410 if let Some(build) = builds.get(computer) {
4411 newer.build = build.clone();
4412 }
4413 newer.writers.insert(computer.clone());
4414 wait(&mut memory.waiting, change.k, (change.v, record.c, computer.clone(), format));
4415 continue;
4416 }
4417 if format < 2 {
4420 note_whole_record(originals, &mut memory, &change.k, change.v.as_deref(), record.c);
4421 }
4422 if format >= 2 && memory.format < 2 {
4426 memory.switch = true;
4427 wait(&mut memory.waiting, change.k, (change.v, record.c, computer.clone(), format));
4428 continue;
4429 }
4430 if format < 2 && memory.format >= 2 && divided_otherwise(&change.k) {
4432 later(&mut older_format, change.k, change.v, record.c, computer);
4433 continue;
4434 }
4435 if change.k.starts_with(FILES) {
4436 named.entry(change.k.clone()).or_default().extend(change.v.as_deref().map(|v| value_hash(&change.k, v)).into_iter().chain([change.b.clone()]).filter(|h| !h.is_empty()));
4437 }
4438 let sent = change.k.starts_with(DRAFTS).then(|| {
4440 let here = match winners.get(&change.k) {
4441 Some((value, _, _)) => Some((value.is_none(), value.as_deref().map(|v| value_hash(&change.k, v)).unwrap_or_default(), bases.get(&change.k).cloned().unwrap_or_default())),
4442 None => memory.entries.get(&change.k).map(|k| (k.h.is_empty(), k.h.clone(), k.b.clone())),
4443 };
4444 let decided = sent_meanwhile(memory.drafts.get(&change.k), here.as_ref().map(|(gone, h, b)| (*gone, h.as_str(), b.as_str())), change.v.is_none(), &change.b);
4445 let version = change.v.as_deref().map(|v| value_hash(&change.k, v)).unwrap_or_default();
4446 met_draft(&mut memory, &change.k, [&change.b, &version]);
4447 decided
4448 });
4449 let known = memory.entries.get(&change.k).map(|k| (k.c, k.w.as_str()));
4450 let pending = winners.get(&change.k).map(|(_, c, w)| (*c, w.as_str()));
4451 let wins = match sent.flatten() {
4453 Some(wins) => wins,
4454 None => newer(record.c, computer, known) && newer(record.c, computer, pending) && !below_whole_floor(&memory, &change.k, record.c),
4455 };
4456 if wins {
4457 bases.insert(change.k.clone(), change.b);
4458 winners.insert(change.k, (change.v, record.c, computer.clone()));
4459 }
4460 }
4461 if !own {
4462 memory.read.insert(computer.clone(), (round, offset));
4463 }
4464 }
4465 }
4466
4467 let mut again: Vec<(String, String)> = Vec::new();
4469 if !older_format.is_empty() {
4470 let modern: BTreeSet<String> = memory.seen.values().filter(|e| e.format >= 2).map(|e| e.id.clone()).collect();
4472 let (translated, named_here) = translate(originals, &older_format, &mut memory, &winners, &modern);
4473 again = named_here;
4474 for (key, value, c, w) in translated {
4475 if newer(c, &w, memory.entries.get(&key).map(|k| (k.c, k.w.as_str()))) && newer(c, &w, winners.get(&key).map(|(_, c, w)| (*c, w.as_str()))) {
4476 bases.remove(&key);
4477 winners.insert(key, (value, c, w));
4478 }
4479 }
4480 }
4481
4482 let mut by_file: BTreeMap<String, Vec<(String, Option<String>)>> = BTreeMap::new();
4485 for (key, (value, _, _)) in &winners {
4486 by_file.entry(file_of(key).to_string()).or_default().push((key.clone(), value.clone()));
4487 }
4488 let mut order: Vec<&String> = by_file.keys().collect();
4489 order.sort_by_key(|file| !(file.starts_with(FILES) && by_file[*file].first().is_some_and(|(_, value)| value.is_none())));
4490 let mut written_keys: BTreeSet<String> = BTreeSet::new();
4491 let mut written_hashes: BTreeMap<String, String> = BTreeMap::new();
4493 let mut written_files: BTreeMap<String, Option<Stat>> = BTreeMap::new();
4495 let mut settled: BTreeSet<String> = BTreeSet::new();
4497 let history = crate::history::root(sharing.memory);
4498 let mut kept = false;
4499 let mut names: Option<Names> = None;
4500 let receiving = Receiving { sharing, history: &history, now_ms, clock };
4501 let mut wanted: BTreeMap<String, (String, u64)> = BTreeMap::new();
4503 let mut there: BTreeSet<String> = BTreeSet::new();
4504 for file in order {
4505 let changes = &by_file[file];
4506 let Some((store, path)) = locate(stores, file) else {
4507 if file.starts_with(FILES) {
4508 *outcome.waits.entry("no-store".into()).or_default() += 1;
4509 }
4510 continue;
4511 };
4512 if matches!(store.shape, Shape::Files) {
4513 let Some((key, value)) = changes.first() else { continue };
4516 let why = if !reads(store) { "not-read" } else if hurried() { "hurried" } else if found.is_unknown(file) { "unknown" } else { "" };
4517 if !why.is_empty() {
4518 *outcome.waits.entry(why.into()).or_default() += 1;
4519 continue;
4520 }
4521 let reference = value.as_deref().and_then(|v| serde_json::from_str::<Reference>(v).ok());
4522 let blob = reference.as_ref().map(|r| crate::blobs::name(sharing.key, &r.h)).unwrap_or_default();
4523 if memory.sealed.failing.get(key).is_some_and(|(failed, again, _)| *failed == blob && now_ms < *again) {
4525 *outcome.waits.entry("backoff".into()).or_default() += 1;
4526 continue;
4527 }
4528 if !writable(store, &path, sharing) {
4529 outcome.problems.push(format!("share-refused:{file}"));
4530 continue;
4531 }
4532 let names = names.get_or_insert_with(|| Names::new(&memory.entries, &found));
4533 if value.is_some() && names.clash(file) {
4534 outcome.problems.push(format!("share-name-clash:{file}"));
4535 continue;
4536 }
4537 let base = bases.get(key).map_or("", String::as_str);
4538 let seen = |h: &str| h == base || named.get(key).is_some_and(|n| n.contains(h));
4539 let back_off = |failing: &mut BTreeMap<String, (String, i64, u32)>| {
4540 let tried = failing.get(key).map_or(0, |(_, _, n)| *n);
4541 let wait = [MINUTE, 10 * MINUTE, HOUR, 6 * HOUR][(tried as usize).min(3)];
4542 failing.insert(key.clone(), (blob.clone(), now_ms + wait, tried + 1));
4543 };
4544 let came = receive(&receiving, store, file, &path, value.as_deref(), found.hashes.get(key).map(String::as_str), &seen, memory.entries.get(key), found.files.get(file), &mut memory.sealed.damaged);
4545 match (&came, &reference) {
4547 (Err(crate::blobs::Fault::Missing), Some(reference)) => {
4548 wanted.insert(blob.clone(), (reference.h.clone(), reference.s));
4549 }
4550 (_, Some(_)) => {
4551 there.insert(blob.clone());
4552 }
4553 _ => {}
4554 }
4555 match came {
4556 Ok(Received::AlreadySo) => {
4557 settled.insert(key.clone());
4558 memory.sealed.missing.remove(key);
4559 memory.sealed.failing.remove(key);
4560 }
4561 Ok(Received::Later) => *outcome.waits.entry("later".into()).or_default() += 1,
4562 Ok(Received::Written { stat, copy, kept: into_history }) => {
4563 kept |= into_history;
4564 outcome.written.insert(store.name.clone());
4565 outcome.received += 1;
4566 written_keys.insert(key.clone());
4567 written_hashes.insert(key.clone(), stat.as_ref().and_then(|s| s.entries.first()).map(|(_, h)| h.clone()).unwrap_or_default());
4568 names.written(file, stat.is_some());
4569 written_files.insert(file.clone(), stat);
4570 memory.sealed.missing.remove(key);
4571 memory.sealed.failing.remove(key);
4572 if let Some(copy) = copy {
4573 outcome.problems.push(format!("{}:{copy}", if value.is_some() { "share-conflict" } else { "share-conflict-gone" }));
4574 }
4575 }
4576 Err(crate::blobs::Fault::Missing) => {
4579 *outcome.waits.entry("no-blob".into()).or_default() += 1;
4580 if now_ms - *memory.sealed.missing.entry(key.clone()).or_insert(now_ms) > DAY {
4581 outcome.problems.push(format!("share-missing:{file}"));
4582 }
4583 }
4584 Err(crate::blobs::Fault::Broken) => outcome.problems.push(format!("share-damaged:{file}")),
4585 Err(crate::blobs::Fault::Room) => {
4586 outcome.problems.push(format!("share-no-room:{file}"));
4587 back_off(&mut memory.sealed.failing);
4588 }
4589 Err(crate::blobs::Fault::Local(e)) => {
4590 outcome.problems.push(e);
4591 back_off(&mut memory.sealed.failing);
4592 }
4593 }
4594 continue;
4595 }
4596 if found.unknown.contains(file) && !matches!(store.shape, Shape::Whole) && path.exists() {
4597 outcome.problems.push(format!("share-unreadable:{}", path.display()));
4599 continue;
4600 }
4601 if store.folder && !writable(store, &path, sharing) {
4602 outcome.problems.push(format!("share-refused:{file}"));
4603 continue;
4604 }
4605 let entries: Vec<(&str, Option<&str>)> = changes.iter().map(|(key, value)| (entry_of(key), value.as_deref())).collect();
4606 let keep = || {
4609 if calls_store(file) {
4610 return Ok(());
4611 }
4612 kept = true;
4613 crate::history::keep(&history, store.part, file, &path, now_ms)
4614 };
4615 match write_entries(store, &path, &entries, keep) {
4616 Ok(hashes) => {
4617 outcome.written.insert(store.name.clone());
4618 outcome.received += changes.len();
4619 for (key, _) in changes {
4620 written_keys.insert(key.clone());
4621 written_hashes.insert(key.clone(), hashes.get(entry_of(key)).cloned().unwrap_or_default());
4622 }
4623 written_files.insert(file.clone(), None);
4624 }
4625 Err(e) => outcome.problems.push(e),
4626 }
4627 }
4628 for (key, (value, clock, computer)) in winners {
4629 if written_keys.contains(&key) || settled.contains(&key) {
4630 let h = written_hashes.get(&key).cloned().unwrap_or_else(|| value.as_deref().map(|v| value_hash(&key, v)).unwrap_or_default());
4631 memory.entries.insert(key, Known { c: clock, w: computer, h, b: String::new() });
4632 } else {
4633 if let Some(base) = bases.remove(&key).filter(|b| !b.is_empty()) {
4634 memory.sealed.bases.insert(key.clone(), base);
4635 }
4636 if let Some(contents) = named.remove(&key) {
4637 memory.sealed.named.insert(key.clone(), contents);
4638 }
4639 memory.pending.insert(key, (value, clock, computer));
4640 }
4641 }
4642 for (name, fallback) in again.into_iter().filter(|(name, _)| written_keys.contains(name)) {
4647 let value = value_of(stores, &name);
4648 let clock = memory.tick(now_ms);
4649 memory.entries.insert(name.clone(), Known { c: clock, w: sharing.computer.to_string(), h: value.as_deref().map(hash).unwrap_or_default(), b: String::new() });
4650 out.push((name.clone(), value, clock, String::new()));
4651 if fallback != name {
4652 let clock = memory.tick(now_ms);
4653 memory.entries.insert(fallback.clone(), Known { c: clock, w: sharing.computer.to_string(), h: String::new(), b: String::new() });
4654 out.push((fallback, None, clock, String::new()));
4655 }
4656 }
4657 let mut files = found.files.clone();
4662 for (file, stat) in written_files {
4663 match stat {
4664 Some(stat) => files.insert(file, stat),
4665 None => files.remove(&file),
4666 };
4667 }
4668
4669 if !memory.joined || !joining.is_empty() {
4671 for (key, h) in &found.hashes {
4672 if memory.entries.contains_key(key) || memory.pending.contains_key(key) || (memory.joined && !joins(key)) || (key.starts_with(FILES) && hurried()) || holds(key) {
4673 continue;
4674 }
4675 let Some(value) = value(key) else { continue };
4676 if !seal_apart(sharing, stores, key, &value, &mut memory.sealed, &mut outcome.problems, now_ms) {
4677 continue;
4678 }
4679 let clock = memory.tick(found.changed.get(file_of(key)).copied().unwrap_or(now_ms).min(now_ms));
4680 memory.entries.insert(key.clone(), Known { c: clock, w: sharing.computer.to_string(), h: h.clone(), b: String::new() });
4681 out.push((key.clone(), Some(value), clock, String::new()));
4682 }
4683 memory.joined = true;
4684 }
4685
4686 for key in older.into_iter().filter(|key| !written_keys.contains(key)) {
4690 let file = file_of(&key).to_string();
4691 let (Some((store, path)), Some(known), Some(before)) = (locate(stores, &file), memory.entries.get(&key), memory.files.get(&file).cloned()) else { continue };
4692 let back = serde_json::to_string(&Reference { h: known.h.clone(), s: before.size, t: (before.modified / 1_000_000) as i64 }).unwrap_or_default();
4693 let came = if hurried() || !writable(store, &path, sharing) { Ok(Received::Later) } else { receive(&receiving, store, &file, &path, Some(&back), found.hashes.get(&key).map(String::as_str), &|_| false, Some(known), found.files.get(&file), &mut memory.sealed.damaged) };
4694 match came {
4695 Ok(Received::Written { stat, copy, kept: into_history }) => {
4696 kept |= into_history;
4697 outcome.problems.push(format!("share-older-copy:{}", copy.unwrap_or_else(|| shown(&file).to_string())));
4698 if let Some(stat) = stat {
4699 files.insert(file, stat);
4700 }
4701 }
4702 _ => {
4703 outcome.problems.push(format!("share-older-copy:{}", shown(&file)));
4704 files.insert(file, before);
4705 }
4706 }
4707 }
4708
4709 outcome.sent = out.len();
4710 outcome.pending = memory.pending.len();
4711 if memory.round == 0 {
4712 memory.round = 1;
4713 }
4714 let own = |memory: &Memory| std::fs::metadata(round_file(sharing.folder, sharing.computer, memory.round)).map_or(0, |m| m.len());
4719 if memory.own_size > own(&memory) || restate {
4720 if !restate {
4721 outcome.problems.push("share-own-cut".into());
4722 }
4723 new_round(sharing, &mut memory, stores, &found, &hurried, now_ms, true)?;
4724 memory.round_base = own(&memory);
4725 }
4726 if !out.is_empty() && own(&memory) >= SEGMENT {
4729 if memory.full_round == 0 {
4730 memory.full_round = memory.round;
4731 }
4732 memory.continues = memory.seq;
4734 memory.round += 1;
4735 memory.seq = 0;
4736 memory.round_base = 0;
4737 }
4738 let before = own(&memory);
4739 append(sharing, &mut memory, &out)?;
4740 memory.grown += own(&memory).saturating_sub(before);
4741 if memory.full_round == 0 && memory.round == 1 {
4743 memory.full_round = 1;
4744 memory.grown = 0;
4745 }
4746 let part_of = |key: &str| locate(stores, file_of(key)).map(|(store, _)| store.part);
4748 for part in out.iter().filter_map(|(key, ..)| part_of(key)) {
4749 memory.traffic.entry(part.to_string()).or_default().0 = now_ms / 1000;
4750 }
4751 for part in written_keys.iter().filter_map(|key| part_of(key)) {
4752 memory.traffic.entry(part.to_string()).or_default().1 = now_ms / 1000;
4753 }
4754 if sharing.files && !hurried() && now_ms - memory.checked >= 10 * MINUTE {
4756 look_after_sealed(sharing, stores, &mut memory, &found, &hurried, &mut outcome.problems, now_ms);
4757 memory.checked = now_ms;
4758 }
4759 note_wanted(sharing, &memory, &wanted, &there, now_ms);
4760 if !memory.sealed.damaged.is_empty() {
4762 let waited: BTreeSet<String> = memory.pending.iter().filter(|(key, _)| key.starts_with(FILES)).filter_map(|(key, (value, _, _))| value.as_deref().map(|v| crate::blobs::name(sharing.key, &value_hash(key, v)))).collect();
4763 memory.sealed.damaged.retain(|name, _| waited.contains(name));
4764 }
4765 memory.sealed.named_at.retain(|_, at| now_ms - *at < crate::blobs::UNUSED_DAYS * DAY);
4768 if !memory.sealed.mine.is_empty() {
4769 let used: BTreeSet<String> = memory
4770 .entries
4771 .iter()
4772 .filter(|(key, known)| key.starts_with(FILES) && !known.h.is_empty())
4773 .map(|(_, known)| known.h.clone())
4774 .chain(memory.pending.iter().filter(|(key, _)| key.starts_with(FILES)).filter_map(|(key, (value, _, _))| value.as_deref().map(|v| value_hash(key, v))))
4775 .map(|h| crate::blobs::name(sharing.key, &h))
4776 .chain(memory.sealed.named_at.keys().cloned())
4777 .collect();
4778 prune_tables(sharing.folder, &mut memory.sealed, &used);
4779 let mut last: BTreeMap<String, i64> = memory.sealed.mine.iter().map(|(name, (at, _))| (name.clone(), *at)).collect();
4780 crate::blobs::sweep(sharing.folder, &mut last, &used, now_ms);
4781 memory.sealed.mine.retain(|name, (at, _)| last.get(name).map(|kept| *at = *kept).is_some());
4782 }
4783 if kept || now_ms - memory.tidied >= HOUR {
4785 crate::history::prune(&history, now_ms, crate::history::cap(&history), &|_, file| locate(stores, file).is_some_and(|(_, path)| path.exists()));
4786 memory.tidied = now_ms;
4787 }
4788 if now_ms - memory.cleaned >= DAY {
4790 clean_leftovers(&sharing.folder.join("blobs"));
4791 memory.cleaned = now_ms;
4792 }
4793 let waits = if importing(sharing.memory) { memory.grown <= IMPORT_ROUND_SIZE } else { frugal(sharing.memory) && memory.grown <= FRUGAL_ROUND_SIZE };
4797 if !memory.owed && asked_for_full(sharing, &memory, now_ms) {
4800 memory.owed = true;
4801 }
4802 let metered = frugal(sharing.memory) && !importing(sharing.memory);
4813 if memory.owed && metered {
4814 if memory.owed_last > 0 {
4815 memory.owed_waited += (now_ms - memory.owed_last).clamp(0, AWAIT_STEP);
4816 }
4817 memory.owed_last = now_ms;
4818 }
4819 let thrifty = importing(sharing.memory) || (metered && memory.owed_waited < OWED_METERED);
4820 let owed = memory.owed && !thrifty && restatement(sharing, &memory, stores, &found).1 == 0;
4821 if (memory.grown > ROUND_SIZE && !waits) || owed {
4822 new_round(sharing, &mut memory, stores, &found, &hurried, now_ms, false)?;
4823 memory.round_base = own(&memory);
4824 }
4825 memory.own_size = own(&memory);
4826 memory.files = files;
4827 let places: BTreeMap<String, String> = stores.iter().filter_map(|s| if reads(s) { Some((s.name.clone(), place(s))) } else { memory.places.get(&s.name).map(|p| (s.name.clone(), p.clone())) }).collect();
4829 memory.places = places;
4830 memory.sealed.confirmed.retain(|store| !stores.iter().any(|s| s.name == *store && reads(s)));
4832 memory.sealed.missing.retain(|key, _| memory.pending.contains_key(key));
4833 memory.sealed.failing.retain(|key, _| memory.pending.contains_key(key));
4834 let seen_due = write_seen(sharing, &memory, &all, now_ms);
4835 outcome.looked = if memory.newer.contains_key("health") { 0 } else { clock };
4840 outcome.wrote = memory.joined.then_some((memory.round.max(1), memory.seq));
4841 remove_old_rounds(sharing, &memory, now_ms);
4842 if memory.format < 2 {
4844 note_whole_lists(originals, &mut memory);
4845 }
4846 let settled = outcome.problems.is_empty()
4851 && memory.joined
4852 && !memory.switch
4853 && memory.newer.is_empty()
4854 && memory.waiting.is_empty()
4855 && memory.held.is_empty()
4856 && memory.emptied.is_empty()
4857 && memory.sealed.gone.is_empty()
4858 && memory.sealed.failing.is_empty()
4859 && memory.sealed.missing.is_empty()
4860 && memory.waiting_for.is_empty()
4861 && !memory.owed
4862 && memory.pending.keys().all(|key| locate(stores, file_of(key)).is_some_and(|(store, _)| !reads(store)));
4863 let until = (now_ms + HOUR).min(memory.tidied + HOUR).min(memory.cleaned + DAY).min(seen_due);
4864 let (round, own_size) = (memory.round, memory.own_size);
4865 let drafts: BTreeSet<String> = memory.drafts.keys().filter(|key| !memory.entries.contains_key(*key) && !memory.pending.contains_key(*key)).cloned().collect();
4867 for key in drafts {
4868 memory.drafts.remove(&key);
4869 }
4870 memory.save(sharing.memory)?;
4871 if let Some((standing, _)) = standing.filter(|(standing, taken)| settled && (standing.newest / 1_000_000) as i64 + settling_ms() < *taken) {
4872 let quiet = Quiet { standing, at: now_ms, until, own: (stamp_at(sharing.memory), stamp_at(&sealed_path(sharing.memory))), round, own_size, wrote: outcome.wrote, pending: outcome.pending };
4873 if let Ok(mut all) = QUIET.lock() {
4874 all.insert(sharing.memory.to_path_buf(), quiet);
4875 }
4876 }
4877 memory.keep(sharing.memory);
4878 Ok(outcome)
4879}
4880
4881#[derive(Debug, Clone, Default, PartialEq, Eq)]
4891struct Standing {
4892 sharing: (PathBuf, String, String),
4893 stores: Vec<(String, PathBuf)>,
4894 files: BTreeMap<PathBuf, FileStamp>,
4895 folder: BTreeMap<PathBuf, Option<FileStamp>>,
4896 entries: BTreeMap<String, String>,
4897 overlay: Option<PathBuf>,
4898 waits: (bool, bool),
4901 in_use: bool,
4902 newest: u64,
4904}
4905
4906impl Standing {
4907 fn now(sharing: &Sharing, stores: &[Store], reads: &dyn Fn(&Store) -> bool) -> Standing {
4908 let mut standing = Standing { sharing: (sharing.folder.to_path_buf(), sharing.computer.to_string(), hash(&B64.encode(sharing.key))), ..Standing::default() };
4909 for store in stores.iter().filter(|s| reads(s)) {
4910 standing.stores.push((store.name.clone(), store.path.clone()));
4911 stamp_below(&store.path, &mut standing.files);
4912 }
4913 let overlay = crate::remote::overlay(sharing.folder);
4914 for dir in std::iter::once(sharing.folder.to_path_buf()).chain(overlay.clone()) {
4915 for entry in std::fs::read_dir(&dir).into_iter().flatten().filter_map(Result::ok) {
4916 let name = entry.file_name().to_string_lossy().to_string();
4917 if name.starts_with('.') || entry.file_type().is_ok_and(|t| t.is_dir()) {
4918 continue;
4919 }
4920 let stamped = !name.starts_with(sharing.computer) && (name.ends_with(".jsonl") || name == "seal.toml");
4922 standing.folder.insert(entry.path(), if stamped { entry.metadata().ok().map(|m| file_stamp(&m)) } else { None });
4923 }
4924 }
4925 let (entries, unread) = crate::devices::all(sharing.folder, sharing.key);
4926 standing.entries = entries.iter().filter(|e| e.id != sharing.computer).map(|e| (e.id.clone(), crate::devices::steady(e))).chain(unread.into_iter().map(|id| (id, String::new()))).collect();
4927 standing.overlay = overlay;
4928 standing.waits = (frugal(sharing.memory), importing(sharing.memory));
4929 standing.in_use = crate::devices::in_use(sharing.memory);
4930 standing.newest = standing.files.values().chain(standing.folder.values().flatten()).map(|s| s.modified).max().unwrap_or(0);
4931 standing
4932 }
4933}
4934
4935fn stamp_below(path: &Path, out: &mut BTreeMap<PathBuf, FileStamp>) {
4937 let Ok(meta) = std::fs::metadata(path) else { return };
4938 if meta.is_file() {
4939 out.insert(path.to_path_buf(), file_stamp(&meta));
4940 return;
4941 }
4942 for entry in std::fs::read_dir(path).into_iter().flatten().filter_map(Result::ok) {
4943 if entry.file_name().to_string_lossy().starts_with('.') {
4944 continue;
4945 }
4946 stamp_below(&entry.path(), out);
4947 }
4948}
4949
4950struct Quiet {
4955 standing: Standing,
4956 at: i64,
4957 until: i64,
4958 own: (Option<FileStamp>, Option<FileStamp>),
4959 round: u32,
4960 own_size: u64,
4961 wrote: Option<(u32, u64)>,
4962 pending: usize,
4963}
4964
4965static QUIET: std::sync::Mutex<BTreeMap<PathBuf, Quiet>> = std::sync::Mutex::new(BTreeMap::new());
4967
4968fn quiet(sharing: &Sharing, standing: &Standing, taken: i64, now_ms: i64) -> Option<Outcome> {
4977 let mut all = QUIET.lock().ok()?;
4978 let quiet = all.get(sharing.memory)?;
4979 let own_size = std::fs::metadata(round_file(sharing.folder, sharing.computer, quiet.round)).map_or(0, |m| m.len());
4980 let still = quiet.standing == *standing && (quiet.at..quiet.until).contains(&now_ms) && (stamp_at(sharing.memory), stamp_at(&sealed_path(sharing.memory))) == quiet.own && own_size == quiet.own_size;
4981 if !still {
4982 all.remove(sharing.memory);
4983 return None;
4984 }
4985 #[cfg(test)]
4986 QUIETED.with(|n| n.set(n.get() + 1));
4987 Some(Outcome { looked: taken, wrote: quiet.wrote, pending: quiet.pending, quiet: true, ..Outcome::default() })
4988}
4989
4990#[cfg(test)]
4991thread_local! {
4992 pub(super) static QUIETED: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
4995 pub(super) static LINES_READ: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
4996}
4997
4998struct LineReader {
5004 file: std::fs::File,
5005 held: Vec<u8>,
5006 start: usize,
5007 skipping: Option<u64>,
5009 end: bool,
5010}
5011
5012impl LineReader {
5013 fn new(file: std::fs::File) -> LineReader {
5014 LineReader { file, held: Vec::new(), start: 0, skipping: None, end: false }
5015 }
5016
5017 fn next(&mut self) -> Option<(Vec<u8>, u64)> {
5019 use std::io::Read;
5020 loop {
5021 if let Some(at) = self.held[self.start..].iter().position(|b| *b == b'\n') {
5022 let line = &self.held[self.start..=self.start + at];
5023 self.start += at + 1;
5024 return Some(match self.skipping.take() {
5025 Some(skipped) => (Vec::new(), skipped + line.len() as u64),
5026 None => (line.to_vec(), line.len() as u64),
5027 });
5028 }
5029 self.held.drain(..self.start);
5030 self.start = 0;
5031 if self.skipping.is_some() || self.held.len() > LONGEST_LINE {
5032 *self.skipping.get_or_insert(0) += self.held.len() as u64;
5033 self.held.clear();
5034 }
5035 if self.end {
5036 return None;
5037 }
5038 let mut piece = vec![0u8; READ_CHUNK];
5039 match self.file.read(&mut piece) {
5040 Ok(0) | Err(_) => self.end = true,
5041 Ok(n) => self.held.extend_from_slice(&piece[..n]),
5042 }
5043 }
5044 }
5045}
5046
5047fn lost_here(memory: &mut Memory, computer: &str, round: u32, n: Option<u64>) {
5058 let met = memory.met.get(computer).or_else(|| memory.read_n.get(computer)).copied();
5059 let n = n.unwrap_or(match met {
5060 Some((r, m)) if r == round => m + 1,
5061 _ => 1,
5062 });
5063 if met.is_none_or(|met| (round, n) > met) {
5064 memory.met.insert(computer.to_string(), (round, n));
5065 }
5066}
5067
5068fn say_broken(memory: &mut Memory, computer: &str, at: i64) {
5072 let kept = memory.broken.entry(computer.to_string()).or_insert(at);
5073 *kept = (*kept).max(at);
5074}
5075
5076fn later_full(sharing: &Sharing, computer: &str, rounds: &[u32], round: u32) -> Option<u32> {
5081 rounds.iter().copied().filter(|r| *r > round).find(|r| {
5082 let at = (sharing.folder.to_path_buf(), computer.to_string(), *r);
5084 if let Some(full) = FIRST_LINES.lock().ok().and_then(|seen| seen.get(&at).copied()) {
5085 return full;
5086 }
5087 let Ok(file) = open_round(sharing.folder, computer, *r) else { return false };
5088 let Some((line, _)) = LineReader::new(file).next() else { return false };
5089 let Some(record) = std::str::from_utf8(&line).ok().and_then(|text| serde_json::from_str::<Line>(text).ok()) else { return false };
5090 let Some(turn) = open(sharing.key, &bound(computer, *r, record.n, record.c), &record.s).and_then(|plain| serde_json::from_slice::<Turn>(&plain).ok()) else { return false };
5091 let full = record.n == 1 && turn.full;
5092 if let Ok(mut seen) = FIRST_LINES.lock() {
5093 seen.insert(at, full);
5094 }
5095 full
5096 })
5097}
5098
5099static FIRST_LINES: std::sync::Mutex<BTreeMap<(PathBuf, String, u32), bool>> = std::sync::Mutex::new(BTreeMap::new());
5103
5104fn lost_line(read_n: Option<(u32, u64)>, round: u32, good: Option<(u64, Option<u64>)>, next: Option<&[u8]>, now_ms: i64) -> Option<i64> {
5114 let before = good.map_or(0, |(n, _)| n);
5115 if read_n.is_some_and(|read| read >= (round, before + 1)) {
5116 return None;
5117 }
5118 let after = next.and_then(|line| std::str::from_utf8(line).ok()).and_then(|text| serde_json::from_str::<Line>(text).ok()).map(|line| line.c);
5119 Some(after.map_or(now_ms / 1000, |c| ((c >> 16) as i64 / 1000).min(now_ms / 1000)))
5120}
5121
5122fn look_after_sealed(sharing: &Sharing, stores: &[Store], memory: &mut Memory, found: &Found, hurried: &dyn Fn() -> bool, problems: &mut Vec<String>, now_ms: i64) {
5128 let ours: Vec<(String, String)> = memory.entries.iter().filter(|(key, known)| key.starts_with(FILES) && known.w == sharing.computer && !known.h.is_empty() && found.hashes.get(*key) == Some(&known.h)).map(|(key, known)| (key.clone(), known.h.clone())).collect();
5129 for (key, h) in ours {
5130 if hurried() {
5131 return;
5132 }
5133 let name = crate::blobs::name(sharing.key, &h);
5134 let there = std::fs::metadata(sharing.folder.join("blobs").join(&name)).map_or(0, |m| m.len());
5135 if there > 0 && memory.sealed.mine.get(&name).is_none_or(|(_, size)| *size == there) {
5136 continue;
5137 }
5138 let Some((_, path)) = locate(stores, file_of(&key)) else { continue };
5139 match crate::blobs::put_again(sharing.folder, sharing.key, &path, &h) {
5140 Ok(size) => {
5141 memory.sealed.mine.insert(name, (now_ms, size));
5142 }
5143 Err(e) => problems.push(e),
5144 }
5145 }
5146}
5147
5148struct Names {
5152 by_folded: BTreeMap<String, String>,
5153}
5154
5155impl Names {
5156 fn new(entries: &BTreeMap<String, Known>, found: &Found) -> Names {
5157 let known = entries.iter().filter(|(key, known)| key.starts_with(FILES) && !known.h.is_empty()).map(|(key, _)| key);
5158 let here = found.hashes.keys().filter(|key| key.starts_with(FILES));
5159 Names { by_folded: known.chain(here).map(|key| (folded(file_of(key)), file_of(key).to_string())).collect() }
5160 }
5161
5162 fn clash(&self, file: &str) -> bool {
5164 self.by_folded.get(&folded(file)).is_some_and(|other| other != file)
5165 }
5166
5167 fn written(&mut self, file: &str, there: bool) {
5168 let name = folded(file);
5169 if there {
5170 self.by_folded.insert(name, file.to_string());
5171 } else if self.by_folded.get(&name).is_some_and(|other| other == file) {
5172 self.by_folded.remove(&name);
5173 }
5174 }
5175}
5176
5177fn folded(name: &str) -> String {
5179 icu_normalizer::ComposingNormalizerBorrowed::new_nfc().normalize(name).to_lowercase()
5180}
5181
5182fn note_wanted(sharing: &Sharing, memory: &Memory, missing: &BTreeMap<String, (String, u64)>, there: &BTreeSet<String>, now_ms: i64) {
5190 if missing.is_empty() && !crate::blobs::Wanted::path(sharing.memory).exists() {
5191 return;
5192 }
5193 let waited: BTreeSet<String> = memory.pending.iter().filter(|(key, _)| key.starts_with(FILES)).filter_map(|(key, (value, _, _))| value.as_deref().map(|v| crate::blobs::name(sharing.key, &value_hash(key, v)))).collect();
5194 let _ = crate::blobs::Wanted::change(sharing.memory, |wanted| {
5195 wanted.blobs.retain(|name, _| waited.contains(name) && !there.contains(name));
5196 for (name, (hash, size)) in missing.iter().filter(|(name, _)| waited.contains(*name)) {
5197 wanted.blobs.entry(name.clone()).or_insert_with(|| crate::blobs::Want { hash: hash.clone(), size: *size, since: now_ms / 1000, ..Default::default() });
5198 }
5199 });
5200}
5201
5202enum Received {
5204 AlreadySo,
5206 Written { stat: Option<Stat>, copy: Option<String>, kept: bool },
5210 Later,
5213}
5214
5215struct Receiving<'a> {
5217 sharing: &'a Sharing<'a>,
5218 history: &'a Path,
5219 now_ms: i64,
5220 clock: i64,
5222}
5223
5224#[cfg(test)]
5225thread_local! {
5226 static BEFORE_WRITING: std::cell::RefCell<Option<Box<dyn Fn(&Path)>>> = std::cell::RefCell::new(None);
5228}
5229
5230#[allow(clippy::too_many_arguments)]
5240fn receive(how: &Receiving, store: &Store, file: &str, path: &Path, value: Option<&str>, current: Option<&str>, seen: &dyn Fn(&str) -> bool, known: Option<&Known>, gathered: Option<&Stat>, damaged: &mut BTreeMap<String, (u64, u64, u64)>) -> Result<Received, crate::blobs::Fault> {
5241 use crate::blobs::Fault;
5242 let sharing = how.sharing;
5243 let wanted = value.map(serde_json::from_str::<Reference>).transpose().map_err(|_| Fault::Broken)?;
5244 let current = match current {
5246 Some(here) => Some(here.to_string()),
5247 None if path.is_file() => crate::blobs::hash_file(path).ok().map(|(h, _)| h),
5248 None => None,
5249 };
5250 match (&wanted, ¤t) {
5251 (Some(wanted), Some(here)) if wanted.h == *here => return Ok(Received::AlreadySo),
5252 (None, None) => return Ok(Received::AlreadySo),
5253 _ => {}
5254 }
5255 let local = |e: std::io::Error| Fault::Local(format!("{}: {e}", path.display()));
5256 let size_here = std::fs::metadata(path).map_or(0, |m| m.len());
5257 let diverged = current.as_deref().is_some_and(|here| !seen(here));
5258 let by_reference = current.as_deref().is_some_and(|here| known.is_some_and(|k| k.h == here) || seen(here));
5260 let room = wanted.as_ref().map_or(0, |w| w.s) + if by_reference { 0 } else { size_here } + if diverged { size_here } else { 0 };
5261 if wanted.as_ref().is_some_and(|w| w.s > crate::blobs::LARGEST) {
5262 return Err(Fault::Broken);
5263 }
5264 if !crate::disk::fits(path, room) {
5265 return Err(Fault::Room);
5266 }
5267 let temporary = temporary(path);
5268 if let Some(wanted) = &wanted {
5269 let blob = crate::blobs::name(sharing.key, &wanted.h);
5272 let copy = std::fs::metadata(crate::blobs::path(sharing.folder, sharing.key, &wanted.h)).ok().map(|m| crate::blobs::fingerprint(&m));
5273 let known_damaged = copy.is_some() && damaged.get(&blob) == copy.as_ref();
5274 if let Some(parent) = path.parent() {
5275 private_dirs(parent).map_err(local)?;
5276 }
5277 match crate::blobs::get_unless(sharing.folder, sharing.key, &wanted.h, &temporary, known_damaged) {
5278 Err(Fault::Broken) => {
5279 damaged.extend(copy.map(|copy| (blob, copy)));
5280 return Err(Fault::Broken);
5281 }
5282 Err(fault) => return Err(fault),
5283 Ok(()) => {
5284 damaged.remove(&blob);
5285 }
5286 }
5287 }
5288 let placed = (|| {
5289 #[cfg(test)]
5290 BEFORE_WRITING.with(|hook| {
5291 if let Some(hook) = hook.borrow().as_ref() {
5292 hook(path);
5293 }
5294 });
5295 let same = match (std::fs::symlink_metadata(path).ok(), gathered) {
5298 (None, None) => true,
5299 (Some(now), Some(then)) => now.len() == then.size && modified_ns(&now) == then.modified,
5300 _ => false,
5301 };
5302 if !same {
5303 return Ok(None);
5304 }
5305 let copy = match ¤t {
5306 Some(here) if diverged => Some(conflict_copy(path, here, known, how.now_ms).map_err(Fault::Local)?),
5307 _ => None,
5308 };
5309 let mut kept = false;
5310 if let Some(here) = current.as_deref()
5311 && path.exists()
5312 {
5313 let kept_now = if by_reference { crate::history::keep_sealed(how.history, store.part, file, here, size_here, how.now_ms) } else { crate::history::keep(how.history, store.part, file, path, how.now_ms) };
5314 kept_now.map_err(|e| if e.starts_with("share-no-room") { Fault::Room } else { Fault::Local(e) })?;
5315 kept = true;
5316 }
5317 let stat = match &wanted {
5318 Some(wanted) => {
5319 if store.name == SPAM_TABLE {
5321 sioul_core::spam::table::keep_previous(path);
5322 }
5323 std::fs::rename(&temporary, path).map_err(local)?;
5324 if let Ok(written) = std::fs::File::options().write(true).open(path) {
5326 let _ = written.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_millis(wanted.t.min(how.clock).max(0) as u64));
5327 }
5328 let now = std::fs::metadata(path).map_err(local)?;
5329 Some(Stat { size: now.len(), modified: modified_ns(&now), entries: vec![(String::new(), wanted.h.clone())], seen: how.clock })
5330 }
5331 None => {
5332 match std::fs::remove_file(path) {
5333 Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(local(e)),
5334 _ => {}
5335 }
5336 let mut dir = path.parent();
5338 while let Some(folder) = dir.filter(|d| *d != store.path && d.starts_with(&store.path)) {
5339 if std::fs::remove_dir(folder).is_err() {
5340 break;
5341 }
5342 dir = folder.parent();
5343 }
5344 None
5345 }
5346 };
5347 Ok(Some((stat, copy, kept)))
5348 })();
5349 if !matches!(placed, Ok(Some(_))) {
5350 let _ = std::fs::remove_file(&temporary);
5351 }
5352 let in_store = |copy: PathBuf| copy.strip_prefix(&store.path).map_or_else(|_| copy.display().to_string(), |r| r.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/"));
5353 Ok(match placed? {
5354 Some((stat, copy, kept)) => Received::Written { stat, copy: copy.map(in_store), kept },
5355 None => Received::Later,
5356 })
5357}
5358
5359pub fn conflict_name(stem: &str, at_ms: i64) -> String {
5363 let when = jiff::Timestamp::from_millisecond(at_ms).map(|t| t.to_zoned(jiff::tz::TimeZone::system()).strftime("%Y-%m-%d %H.%M").to_string()).unwrap_or_default();
5364 format!("{stem} (conflict {when})")
5365}
5366
5367fn conflict_copy(path: &Path, here: &str, known: Option<&Known>, now_ms: i64) -> Result<PathBuf, String> {
5372 let at = known.filter(|k| k.h == here).map(|k| (k.c >> 16) as i64).or_else(|| std::fs::metadata(path).ok().map(|m| (modified_ns(&m) / 1_000_000) as i64)).unwrap_or(now_ms);
5373 let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
5374 let (stem, extension) = match name.rsplit_once('.') {
5375 Some((stem, extension)) if !stem.is_empty() => (stem.to_string(), format!(".{extension}")),
5376 _ => (name.clone(), String::new()),
5377 };
5378 let named = conflict_name(&stem, at);
5379 let mut n = 1;
5380 loop {
5381 let copy = path.with_file_name(if n == 1 { format!("{named}{extension}") } else { format!("{named} {n}{extension}") });
5382 if !copy.exists() {
5383 let temporary = temporary(©);
5384 return copy_private(path, &temporary).and_then(|()| std::fs::rename(&temporary, ©)).map(|()| copy.clone()).map_err(|e| {
5385 let _ = std::fs::remove_file(&temporary);
5386 format!("{}: {e}", copy.display())
5387 });
5388 }
5389 if crate::blobs::hash_file(©).is_ok_and(|(h, _)| h == here) {
5391 return Ok(copy);
5392 }
5393 n += 1;
5394 }
5395}
5396
5397fn writable(store: &Store, path: &Path, sharing: &Sharing) -> bool {
5404 writable_in(store, path, Some(sharing.folder), sharing.memory)
5405}
5406
5407fn writable_in(store: &Store, path: &Path, folder: Option<&Path>, memory: &Path) -> bool {
5408 let Ok(relative) = path.strip_prefix(&store.path) else { return false };
5409 let mut at = store.path.clone();
5410 for part in relative.components() {
5411 at.push(part);
5412 match std::fs::symlink_metadata(&at) {
5413 Ok(meta) if meta.file_type().is_symlink() => return false,
5414 Ok(_) => {}
5415 Err(_) => break,
5416 }
5417 }
5418 let Some(there) = path.ancestors().skip(1).find(|p| p.exists()).and_then(|p| p.canonicalize().ok()) else { return true };
5420 let inside = |root: &Path| root.canonicalize().is_ok_and(|root| there.starts_with(root));
5421 let state = memory.parent().and_then(Path::parent);
5422 let own_state = store.name.starts_with("state/") && state.is_some_and(|state| store.path.starts_with(state));
5425 !folder.is_some_and(inside) && (own_state || !state.is_some_and(inside))
5426}
5427
5428fn kept_by_windows(name: &str) -> bool {
5431 let stem = name.split('.').next().unwrap_or(name).to_ascii_uppercase();
5432 let numbered = (stem.starts_with("COM") || stem.starts_with("LPT")) && stem.len() == 4 && matches!(stem.as_bytes()[3], b'1'..=b'9');
5433 matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") || numbered || name.ends_with('.') || name.ends_with(' ')
5434}
5435
5436fn locate<'a>(stores: &'a [Store], file: &str) -> Option<(&'a Store, PathBuf)> {
5438 if let Some(store) = stores.iter().find(|s| !s.folder && s.name == file) {
5439 return Some((store, store.path.clone()));
5440 }
5441 let store = stores.iter().filter(|s| s.folder && file.starts_with(s.name.as_str())).max_by_key(|s| s.name.len())?;
5442 let relative = &file[store.name.len()..];
5443 if relative.is_empty() || relative.split('/').any(|part| part.is_empty() || part == ".." || part == "." || part.contains(['\\', ':'])) {
5446 return None;
5447 }
5448 if matches!(store.shape, Shape::Files)
5451 && (relative.split('/').any(|part| made_by_tools(part) || matches!(part, "node_modules" | "target") || (cfg!(windows) && kept_by_windows(part))) || relative.split('/').next().is_some_and(|top| store.skip.iter().any(|skip| skip.eq_ignore_ascii_case(top))))
5452 {
5453 return None;
5454 }
5455 Some((store, relative.split('/').fold(store.path.clone(), |path, part| path.join(part))))
5456}
5457
5458#[derive(Debug, Default, PartialEq, Eq)]
5460pub struct Back {
5461 pub whole: bool,
5463 pub changed: usize,
5465 pub returning: usize,
5467 pub kept: usize,
5469}
5470
5471pub fn put_back_preview(memory: &Path, stores: &[Store], part: &str, file: &str, stamp: &str) -> Result<Back, String> {
5473 let shaped_stores = shaped(stores, format_of(memory));
5475 let stores = shaped_stores.as_slice();
5476 let (store, path) = locate(stores, file).filter(|(store, _)| store.part == part).ok_or_else(|| format!("{file}: not shared"))?;
5477 let kept = crate::history::version(&crate::history::root(memory), part, file, stamp).ok_or_else(|| format!("{file}: {stamp}: not kept"))?;
5478 let (crate::history::Kept::Copy(version), Shape::Toml(_) | Shape::Lines) = (kept, store.shape) else { return Ok(Back { whole: true, ..Back::default() }) };
5479 let (old, now) = entries_then_and_now(store, &version, &path)?;
5480 Ok(Back {
5481 whole: false,
5482 changed: old.iter().filter(|(entry, value)| now.get(*entry).is_some_and(|v| v != *value)).count(),
5483 returning: old.keys().filter(|entry| !now.contains_key(*entry)).count(),
5484 kept: now.keys().filter(|entry| !old.contains_key(*entry)).count(),
5485 })
5486}
5487
5488fn entries_then_and_now(store: &Store, version: &Path, path: &Path) -> Result<(BTreeMap<String, String>, BTreeMap<String, String>), String> {
5490 let old = std::fs::read(version).ok().and_then(|bytes| entries_of(store, &bytes)).ok_or_else(|| format!("{}: not readable", version.display()))?;
5491 let now = std::fs::read(path).ok().and_then(|bytes| entries_of(store, &bytes)).unwrap_or_default();
5492 Ok((old.into_iter().collect(), now.into_iter().collect()))
5493}
5494
5495pub fn put_back(memory: &Path, vault: Option<(&Path, &[u8; 32])>, stores: &[Store], part: &str, file: &str, stamp: &str, now_ms: i64) -> Result<(), String> {
5505 let shaped_stores = shaped(stores, format_of(memory));
5506 let stores = shaped_stores.as_slice();
5507 let (store, path) = locate(stores, file).filter(|(store, _)| store.part == part).ok_or_else(|| format!("{file}: not shared"))?;
5508 let root = crate::history::root(memory);
5509 let kept = crate::history::version(&root, part, file, stamp).ok_or_else(|| format!("{file}: {stamp}: not kept"))?;
5510 let _running = exchange_lock(memory, true)?;
5511 if store.folder && !writable_in(store, &path, vault.map(|(folder, _)| folder), memory) {
5512 return Err(format!("{}: not written there", path.display()));
5513 }
5514 if let (crate::history::Kept::Copy(version), Shape::Toml(_) | Shape::Lines) = (&kept, store.shape) {
5515 let (old, now) = entries_then_and_now(store, version, &path)?;
5516 let back: Vec<(&str, Option<&str>)> = old.iter().filter(|(entry, value)| now.get(*entry) != Some(*value)).map(|(entry, value)| (entry.as_str(), Some(value.as_str()))).collect();
5517 if back.is_empty() {
5518 return Ok(());
5519 }
5520 return write_entries(store, &path, &back, || crate::history::keep(&root, part, file, &path, now_ms)).map(|_| ());
5521 }
5522 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
5523 if let Some(parent) = path.parent() {
5524 private_dirs(parent).map_err(fail)?;
5525 }
5526 let temporary = temporary(&path);
5527 let brought = match kept {
5528 crate::history::Kept::Copy(version) => copy_private(&version, &temporary).map_err(fail),
5529 crate::history::Kept::Sealed { hash, .. } => {
5530 let (folder, key) = vault.ok_or_else(|| format!("{file}: kept in the sharing folder, which is not set here"))?;
5531 crate::blobs::get(folder, key, &hash, &temporary).map_err(|fault| match fault {
5532 crate::blobs::Fault::Missing => format!("share-missing:{file}"),
5533 crate::blobs::Fault::Broken => format!("share-damaged:{file}"),
5534 crate::blobs::Fault::Room => format!("share-no-room:{file}"),
5535 crate::blobs::Fault::Local(e) => e,
5536 })
5537 }
5538 };
5539 let put = || {
5540 brought?;
5541 if path.exists() {
5542 crate::history::keep(&root, part, file, &path, now_ms)?;
5543 }
5544 let _ = std::fs::File::options().write(true).open(&temporary).and_then(|f| f.set_modified(std::time::SystemTime::now()));
5546 std::fs::rename(&temporary, &path).map_err(fail)
5547 };
5548 let done = if matches!(store.shape, Shape::Files) && !sioul_core::filelock::has_lock(&path) { put() } else { sioul_core::filelock::with_lock(&path, put) };
5550 if done.is_err() {
5551 let _ = std::fs::remove_file(&temporary);
5552 }
5553 done
5554}
5555
5556fn base_of(memory: &Memory, key: &str) -> String {
5558 if key.starts_with(FILES) || key.starts_with(DRAFTS) { memory.entries.get(key).map(|k| k.h.clone()).unwrap_or_default() } else { String::new() }
5559}
5560
5561fn met_draft(memory: &mut Memory, key: &str, versions: [&str; 2]) {
5563 if key.starts_with(DRAFTS) {
5564 memory.drafts.entry(key.to_string()).or_default().extend(versions.iter().filter(|v| !v.is_empty()).map(|v| (*v).to_string()));
5565 }
5566}
5567
5568fn seal_apart(sharing: &Sharing, stores: &[Store], key: &str, value: &str, sealed: &mut Sealed, problems: &mut Vec<String>, now_ms: i64) -> bool {
5572 if !key.starts_with(FILES) {
5573 return true;
5574 }
5575 let (Some((_, path)), Ok(reference)) = (locate(stores, file_of(key)), serde_json::from_str::<Reference>(value)) else { return false };
5576 match crate::blobs::put(sharing.folder, sharing.key, &path, &reference.h) {
5577 Ok(written) => {
5578 let name = crate::blobs::name(sharing.key, &reference.h);
5579 if let Some(size) = written {
5580 sealed.mine.insert(name.clone(), (now_ms, size));
5581 }
5582 if file_of(key) == SPAM_TABLE && sealed.mine.contains_key(&name) {
5584 sealed.tables.retain(|t| *t != name);
5585 sealed.tables.push(name);
5586 }
5587 true
5588 }
5589 Err(e) => {
5590 problems.push(e);
5591 false
5592 }
5593 }
5594}
5595
5596fn append(sharing: &Sharing, memory: &mut Memory, changes: &[(String, Option<String>, u64, String)]) -> Result<(), String> {
5597 if changes.is_empty() {
5598 return Ok(());
5599 }
5600 let path = round_file(sharing.folder, sharing.computer, memory.round);
5601 let mut text = String::new();
5602 for (at, (key, value, clock, base)) in changes.iter().enumerate() {
5603 let turn = if at == 0 && memory.seq == 0 {
5607 if memory.continues > 0 {
5608 Turn { p: memory.continues, full: false }
5609 } else if memory.full_round == memory.round || memory.round <= 1 {
5610 Turn { p: 0, full: true }
5611 } else {
5612 Turn::default()
5613 }
5614 } else {
5615 Turn::default()
5616 };
5617 memory.seq += 1;
5618 let build = if at == 0 { sioul_core::build::DESCRIBED.to_string() } else { String::new() };
5619 let f = if memory.format >= 2 { known_part(file_of(key)).map_or(1, part_format) } else { 1 };
5621 let change = Change { k: key.clone(), v: value.clone(), b: base.clone(), build, f };
5622 let plain = serde_json::to_vec(&Sealing { change: &change, turn }).map_err(|e| e.to_string())?;
5623 let line = Line { n: memory.seq, c: *clock, s: seal(sharing.key, &bound(sharing.computer, memory.round, memory.seq, *clock), &plain) };
5624 text.push_str(&serde_json::to_string(&line).map_err(|e| e.to_string())?);
5625 text.push('\n');
5626 }
5627 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
5628 write_synced(&sharing.memory.with_file_name("appended.toml"), format!("computer = \"{}\"\nround = {}\nn = {}\n", sharing.computer, memory.round, memory.seq).as_bytes())?;
5633 if let Some(whole) = unfinished(&path) {
5636 let file = std::fs::OpenOptions::new().write(true).open(&path).map_err(fail)?;
5637 file.set_len(whole).map_err(fail)?;
5638 }
5639 let mut options = std::fs::OpenOptions::new();
5640 options.create(true).append(true);
5641 #[cfg(unix)]
5642 std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
5643 let mut file = options.open(&path).map_err(fail)?;
5644 make_private(&path);
5646 file.write_all(text.as_bytes()).map_err(fail)?;
5647 file.sync_all().map_err(fail)
5648}
5649
5650fn appended(memory: &Path, computer: &str) -> Option<(u32, u64)> {
5656 #[derive(Deserialize)]
5657 struct Appended {
5658 computer: String,
5659 round: u32,
5660 n: u64,
5661 }
5662 let text = read_small(&memory.with_file_name("appended.toml"))?;
5663 toml::from_str::<Appended>(&text).ok().filter(|a| a.computer == computer).map(|a| (a.round, a.n))
5664}
5665
5666fn unfinished(path: &Path) -> Option<u64> {
5670 use std::io::{Read, Seek, SeekFrom};
5671 let mut file = std::fs::File::open(path).ok()?;
5672 let size = file.metadata().ok()?.len();
5673 let mut last = [0u8];
5674 file.seek(SeekFrom::Start(size.checked_sub(1)?)).ok()?;
5675 file.read_exact(&mut last).ok()?;
5676 if last[0] == b'\n' {
5677 return None;
5678 }
5679 let mut end = size;
5680 while end > 0 {
5681 let from = end.saturating_sub(64 << 10);
5682 let mut piece = vec![0u8; (end - from) as usize];
5683 file.seek(SeekFrom::Start(from)).ok()?;
5684 file.read_exact(&mut piece).ok()?;
5685 if let Some(at) = piece.iter().rposition(|b| *b == b'\n') {
5686 return Some(from + at as u64 + 1);
5687 }
5688 end = from;
5689 }
5690 Some(0)
5691}
5692
5693fn last_own(folder: &Path, computer: &str) -> Option<(u32, u64, u64)> {
5695 let round = *rounds(folder).get(computer)?.last()?;
5696 let (n, size) = last_of(&round_file(folder, computer, round))?;
5697 Some((round, n, size))
5698}
5699
5700fn last_line(folder: &Path, computer: &str, round: u32) -> Option<u64> {
5702 last_of(&round_file(folder, computer, round)).map(|(n, _)| n)
5703}
5704
5705fn last_of(path: &Path) -> Option<(u64, u64)> {
5707 use std::io::{Read, Seek};
5708 let mut file = std::fs::File::open(path).ok()?;
5709 let size = file.metadata().ok()?.len();
5710 let from = size.saturating_sub(64 * 1024);
5712 let mut tail = Vec::new();
5713 file.seek(std::io::SeekFrom::Start(from)).ok()?;
5714 file.read_to_end(&mut tail).ok()?;
5715 let n = tail.split(|b| *b == b'\n').rev().find_map(|line| std::str::from_utf8(line).ok().and_then(|t| serde_json::from_str::<Line>(t).ok())).map_or(0, |l| l.n);
5716 Some((n, size))
5717}
5718
5719fn restatement(sharing: &Sharing, memory: &Memory, stores: &[Store], found: &Found) -> (Vec<(String, Option<String>, u64, String)>, usize) {
5728 let mut omitted = 0usize;
5729 let ours = memory
5730 .entries
5731 .iter()
5732 .filter(|(_, known)| known.w == sharing.computer)
5733 .filter_map(|(key, known)| {
5734 if known.h.is_empty() {
5735 return (!found.hashes.contains_key(key)).then(|| (key.clone(), None, known.c, known.b.clone()));
5736 }
5737 let (store, _) = locate(stores, file_of(key))?;
5738 let value = match found.values.get(key) {
5740 Some(value) => Some(value.clone()),
5741 None if key.starts_with(FILES) => found.files.get(file_of(key)).and_then(|stat| stat.entries.first().map(|(_, h)| reference(h, stat.size, stat.modified))),
5742 None => value_of(stores, key),
5743 };
5744 match value {
5745 Some(value) if value_hash(key, &value) == known.h => Some((key.clone(), Some(value), known.c, known.b.clone())),
5746 _ => {
5747 if reads_now(sharing, store) {
5748 omitted += 1;
5749 }
5750 None
5751 }
5752 }
5753 })
5754 .collect();
5755 (ours, omitted)
5756}
5757
5758fn reads_now(sharing: &Sharing, store: &Store) -> bool {
5761 sharing.files || !matches!(store.shape, Shape::Files) || !store.folder
5762}
5763
5764fn new_round(sharing: &Sharing, memory: &mut Memory, stores: &[Store], found: &Found, hurried: &dyn Fn() -> bool, now_ms: i64, cut: bool) -> Result<(), String> {
5771 let oldest = ((now_ms - TOMBSTONE_DAYS * 86_400_000).max(0) as u64) << 16;
5772 memory.entries.retain(|_, known| !known.h.is_empty() || known.c >= oldest);
5773 let (ours, omitted) = restatement(sharing, memory, stores, found);
5774 let mut problems = Vec::new();
5775 for (key, value, _, _) in ours.iter().filter(|_| sharing.files && !hurried()) {
5776 if let Some(value) = value {
5777 seal_apart(sharing, stores, key, value, &mut memory.sealed, &mut problems, now_ms);
5778 }
5779 }
5780 let previous = memory.seq;
5787 memory.round += 1;
5788 memory.seq = 0;
5789 memory.grown = 0;
5790 if omitted == 0 {
5791 memory.full_round = memory.round;
5792 memory.continues = 0;
5793 memory.owed = false;
5794 (memory.owed_waited, memory.owed_last) = (0, 0);
5795 } else {
5796 memory.continues = if previous > 0 { previous } else { UNKNOWN_TURN };
5797 memory.owed |= cut;
5798 }
5799 append(sharing, memory, &ours)
5800}
5801
5802pub fn own_sealed(memory: &Path, computer: &str) -> BTreeMap<String, u64> {
5810 let path = sealed_path(memory);
5811 let stamp = stamp_at(&path);
5812 if let Some((whose, mine)) = MINE.lock().ok().and_then(|all| all.get(memory).filter(|(was, _, _)| stamp.is_some() && *was == stamp).map(|(_, whose, mine)| (whose.clone(), mine.clone()))) {
5813 return if whose == computer { mine } else { BTreeMap::new() };
5814 }
5815 #[derive(Deserialize)]
5816 struct Part {
5817 #[serde(default)]
5818 computer: String,
5819 #[serde(default)]
5820 mine: BTreeMap<String, (i64, u64)>,
5821 }
5822 let Some(read) = std::fs::read(&path).ok().and_then(|bytes| serde_json::from_slice::<Part>(&bytes).ok()) else { return BTreeMap::new() };
5823 let mine: BTreeMap<String, u64> = read.mine.into_iter().map(|(name, (_, size))| (name, size)).collect();
5824 if let Ok(mut all) = MINE.lock() {
5825 all.insert(memory.to_path_buf(), (stamp, read.computer.clone(), mine.clone()));
5826 }
5827 if read.computer == computer { mine } else { BTreeMap::new() }
5828}
5829
5830pub fn written(memory: &Path, computer: &str) -> Option<(u32, u64)> {
5833 let memory = Memory::load(memory, computer);
5834 memory.joined.then_some((memory.round.max(1), memory.seq))
5835}
5836
5837#[derive(Debug, Default, Clone)]
5839pub struct Heard {
5840 pub read: BTreeMap<String, (u32, u64)>,
5842 pub broken: BTreeMap<String, i64>,
5844}
5845
5846impl Heard {
5847 pub fn complete(&self, computer: &str, wrote: (u32, u64)) -> bool {
5851 wrote.1 == 0 || self.read.get(computer).is_some_and(|read| *read >= wrote)
5852 }
5853}
5854
5855pub fn traffic(memory: &Path, computer: &str) -> BTreeMap<String, (i64, i64)> {
5857 Memory::load(memory, computer).traffic
5858}
5859
5860pub fn heard(memory: &Path, computer: &str) -> Heard {
5861 let memory = Memory::load(memory, computer);
5862 let mut read = memory.read_n;
5863 for (key, (_, _, writer, f)) in &memory.waiting {
5866 if known_part(file_of(key)) == Some("health") && *f > part_format("health") {
5867 read.remove(writer);
5868 }
5869 }
5870 Heard { read, broken: memory.broken }
5871}
5872
5873pub fn rebuild(memory: &Path, computer: &str, files: &[&str]) -> Result<(), String> {
5878 let _running = exchange_lock(memory, true)?;
5879 let mut state = Memory::load_all(memory, computer);
5880 state.entries.retain(|key, _| !files.contains(&file_of(key)));
5881 state.pending.retain(|key, _| !files.contains(&file_of(key)));
5882 for file in files {
5883 state.files.remove(*file);
5884 state.rebuild.insert((*file).to_string());
5885 }
5886 state.read.clear();
5888 state.save(memory)?;
5889 state.keep(memory);
5890 Ok(())
5891}
5892
5893pub fn confirm_gone(memory: &Path, computer: &str, store: &str) -> Result<(), String> {
5897 let _running = exchange_lock(memory, true)?;
5898 let mut state = Memory::load_all(memory, computer);
5899 state.sealed.confirmed.insert(store.to_string());
5900 state.save(memory)?;
5901 state.keep(memory);
5902 Ok(())
5903}
5904
5905pub fn exchange_lock(memory: &Path, wait: bool) -> Result<Option<std::fs::File>, String> {
5910 let path = memory.with_file_name("exchange.lock");
5911 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
5912 if let Some(parent) = path.parent() {
5913 private_dirs(parent).map_err(fail)?;
5914 }
5915 let file = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(&path).map_err(fail)?;
5916 if wait {
5917 file.lock().map_err(fail)?;
5918 return Ok(Some(file));
5919 }
5920 for _ in 0..50 {
5924 match file.try_lock() {
5925 Ok(()) => return Ok(Some(file)),
5926 Err(std::fs::TryLockError::WouldBlock) => std::thread::sleep(std::time::Duration::from_millis(20)),
5927 Err(std::fs::TryLockError::Error(e)) => return Err(fail(e)),
5928 }
5929 }
5930 Ok(None)
5931}
5932
5933fn seen_path(folder: &Path, computer: &str) -> PathBuf {
5934 folder.join(format!("{computer}.toml"))
5935}
5936
5937fn write_seen(sharing: &Sharing, memory: &Memory, all: &BTreeMap<String, Vec<u32>>, now_ms: i64) -> i64 {
5945 let again = if crate::devices::in_use(sharing.memory) { 15 * 60 } else { 24 * 3600 };
5946 let mut seen = Seen {
5947 at: now_ms / 1000,
5948 round: memory.round,
5949 read: memory.read.iter().filter(|(c, _)| all.contains_key(*c)).map(|(c, (round, _))| (c.clone(), *round)).collect(),
5950 full: full_asked(memory, all),
5951 pad: String::new(),
5952 };
5953 if let Some(old) = read_seen_in(sharing.folder, sharing.computer).filter(|old| old.round == seen.round && old.read == seen.read && old.full == seen.full && seen.at - old.at < again) {
5954 return (old.at + again) * 1000;
5955 }
5956 let path = seen_path(sharing.folder, sharing.computer);
5957 let text = |seen: &Seen, pad: usize| toml::to_string(&Seen { pad: ".".repeat(pad), read: seen.read.clone(), full: seen.full.clone(), ..*seen }).unwrap_or_default();
5958 seen.pad = ".".repeat(pad_for(&path, |pad| text(&seen, pad).len() as u64));
5959 if let Ok(text) = toml::to_string(&seen) {
5960 let _ = write_atomically(&path, text.as_bytes());
5961 }
5962 (seen.at + again) * 1000
5963}
5964
5965fn full_asked(memory: &Memory, all: &BTreeMap<String, Vec<u32>>) -> BTreeMap<String, u32> {
5971 memory.met.iter().filter(|(computer, met)| all.contains_key(*computer) && memory.read_n.get(*computer) != Some(*met)).map(|(computer, (round, _))| (computer.clone(), *round)).collect()
5972}
5973
5974fn asked_for_full(sharing: &Sharing, memory: &Memory, now_ms: i64) -> bool {
5978 let last_full = memory.full_round.max(1);
5979 computers(sharing.folder)
5980 .iter()
5981 .filter(|c| *c != sharing.computer)
5982 .filter_map(|c| read_seen(sharing.folder, c))
5983 .filter(|s| now_ms / 1000 - s.at < SILENT_DAYS * 86_400)
5984 .any(|s| s.full.get(sharing.computer).is_some_and(|round| (last_full..=memory.round).contains(round)))
5985}
5986
5987fn read_seen_in(folder: &Path, computer: &str) -> Option<Seen> {
5988 read_small(&seen_path(folder, computer)).and_then(|t| toml::from_str(&t).ok())
5989}
5990
5991fn read_seen(folder: &Path, computer: &str) -> Option<Seen> {
5994 let here = read_seen_in(folder, computer);
5995 match (here, crate::remote::overlay(folder).and_then(|fetched| read_seen_in(&fetched, computer))) {
5996 (Some(here), Some(fetched)) => Some(if fetched.at > here.at { fetched } else { here }),
5997 (here, fetched) => here.or(fetched),
5998 }
5999}
6000
6001fn remove_old_rounds(sharing: &Sharing, memory: &Memory, now_ms: i64) {
6003 let all = rounds(sharing.folder);
6004 let others: Vec<Seen> = computers(sharing.folder).iter().filter(|c| *c != sharing.computer).filter_map(|c| read_seen(sharing.folder, c)).filter(|s| now_ms / 1000 - s.at < SILENT_DAYS * 86_400).collect();
6005 let read_up_to = others.iter().map(|s| s.read.get(sharing.computer).copied().unwrap_or(0)).min().unwrap_or(memory.round);
6006 let full = if memory.full_round == 0 { memory.round } else { memory.full_round };
6009 for round in all.get(sharing.computer).into_iter().flatten().filter(|r| **r < full && **r < read_up_to) {
6010 let _ = std::fs::remove_file(round_file(sharing.folder, sharing.computer, *round));
6011 }
6012}
6013
6014#[derive(Debug, Clone, PartialEq, Eq)]
6016pub struct Other {
6017 pub id: String,
6018 pub heard: i64,
6019}
6020
6021pub fn others(folder: &Path, computer: &str) -> Vec<Other> {
6023 computers(folder).into_iter().filter(|c| c != computer).map(|c| Other { heard: read_seen(folder, &c).map_or(0, |s| s.at), id: c }).collect()
6024}
6025
6026pub fn others_heard(folder: &Path, key: &[u8; 32], computer: &str) -> Vec<Other> {
6034 let (entries, _) = crate::devices::all(folder, key);
6035 let all = rounds_with(folder);
6036 let mut out = others(folder, computer);
6037 for other in &mut out {
6038 if let Some(entry) = entries.iter().find(|e| e.id == other.id) {
6039 other.heard = other.heard.max(entry_stamp(entry));
6040 }
6041 if let Some(clock) = all.get(&other.id).and_then(|rounds| rounds.last()).and_then(|round| last_record_clock(folder, key, &other.id, *round)) {
6042 other.heard = other.heard.max((clock >> 16) as i64 / 1000);
6043 }
6044 }
6045 out
6046}
6047
6048pub fn sealed(folder: &Path) -> bool {
6050 folder.join("seal.toml").is_file()
6051}
6052
6053#[cfg(test)]
6054mod tests {
6055 use super::*;
6056
6057 thread_local! {
6058 pub(super) static OLDER: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
6061 }
6062
6063 pub(super) struct Computer {
6065 pub(super) roots: Roots,
6066 pub(super) id: String,
6067 pub(super) memory: PathBuf,
6068 }
6069
6070 impl Computer {
6071 pub(super) fn new(base: &Path, name: &str) -> Computer {
6072 let root = base.join(name);
6073 let roots = Roots { config: root.join("config"), data: root.join("data"), state: root.join("state") };
6074 for dir in [&roots.config, &roots.data, &roots.state] {
6075 std::fs::create_dir_all(dir).unwrap();
6076 }
6077 let memory = roots.state.join("share").join("memory.json");
6078 Computer { roots, id: uuid::Uuid::new_v4().to_string(), memory }
6079 }
6080
6081 pub(super) fn exchange(&self, folder: &Path, key: &[u8; 32], now: i64) -> Outcome {
6082 let stores = stores(&Config::default(), &self.roots);
6083 exchange(&Sharing { folder, computer: &self.id, key, memory: &self.memory, files: true, hurry: None }, &stores, now).unwrap()
6084 }
6085
6086 pub(super) fn exchange_notes(&self, folder: &Path, key: &[u8; 32], now: i64, notes: &Path) -> Outcome {
6088 let config = Config { share_projects: true, notes_root: Some(notes.display().to_string()), ..Config::default() };
6089 let stores = stores(&config, &self.roots);
6090 exchange(&Sharing { folder, computer: &self.id, key, memory: &self.memory, files: true, hurry: None }, &stores, now).unwrap()
6091 }
6092
6093 fn exchange_with(&self, folder: &Path, key: &[u8; 32], now: i64, notes: &Path, on: &[&str]) -> Outcome {
6095 let stores = self.stores_with(notes, &|part| on.contains(&part));
6096 exchange(&Sharing { folder, computer: &self.id, key, memory: &self.memory, files: true, hurry: None }, &stores, now).unwrap()
6097 }
6098
6099 fn stores_with(&self, notes: &Path, on: &dyn Fn(&str) -> bool) -> Vec<Store> {
6100 let config = Config { notes_root: Some(notes.display().to_string()), ..Config::default() };
6101 stores_of(&config, &self.roots, &|part| on(part) || shared_by_default(part, &config))
6102 }
6103
6104 pub(super) fn write(&self, relative: &str, text: &str) {
6105 let path = self.path(relative);
6106 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
6107 std::fs::write(path, text).unwrap();
6108 }
6109
6110 pub(super) fn read(&self, relative: &str) -> String {
6111 std::fs::read_to_string(self.path(relative)).unwrap_or_default()
6112 }
6113
6114 pub(super) fn path(&self, relative: &str) -> PathBuf {
6115 let (root, rest) = relative.split_once('/').unwrap();
6116 match root {
6117 "config" => self.roots.config.join(rest),
6118 "data" => self.roots.data.join(rest),
6119 _ => self.roots.state.join(rest),
6120 }
6121 }
6122 }
6123
6124 pub(super) fn scratch(name: &str) -> PathBuf {
6125 let dir = std::env::temp_dir().join(format!("sioul-share-{name}-{}", std::process::id()));
6126 let _ = std::fs::remove_dir_all(&dir);
6127 std::fs::create_dir_all(&dir).unwrap();
6128 dir
6129 }
6130
6131 pub(super) fn quick_key(folder: &Path, passphrase: &str) -> Result<[u8; 32], Refused> {
6132 key_with(folder, passphrase, 64, 1)
6133 }
6134
6135 fn put(dir: &Path, relative: &str, bytes: &[u8]) {
6136 let path = dir.join(relative);
6137 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
6138 std::fs::write(path, bytes).unwrap();
6139 }
6140
6141 fn noise(length: usize, seed: u32) -> Vec<u8> {
6143 let mut x = seed.wrapping_mul(2_654_435_761).wrapping_add(1);
6144 (0..length)
6145 .map(|_| {
6146 x ^= x << 13;
6147 x ^= x >> 17;
6148 x ^= x << 5;
6149 x as u8
6150 })
6151 .collect()
6152 }
6153
6154 fn files_in(dir: &Path) -> BTreeMap<String, Vec<u8>> {
6156 let mut found = Vec::new();
6157 list_files(dir, dir, &[], &mut found);
6158 found.into_iter().map(|(name, path)| (name, std::fs::read(path).unwrap())).collect()
6159 }
6160
6161 fn version_text(history: &Path, part: &str, file: &str, stamp: &str, folder: &Path, key: &[u8; 32]) -> String {
6163 match crate::history::version(history, part, file, stamp) {
6164 Some(crate::history::Kept::Copy(path)) => std::fs::read_to_string(path).unwrap(),
6165 Some(crate::history::Kept::Sealed { hash, .. }) => {
6166 let target = temporary(&history.join("opened"));
6167 crate::blobs::get(folder, key, &hash, &target).unwrap();
6168 let text = std::fs::read_to_string(&target).unwrap();
6169 std::fs::remove_file(target).unwrap();
6170 text
6171 }
6172 None => panic!("{file}: {stamp}: not kept"),
6173 }
6174 }
6175
6176 fn copies_of(dir: &Path, stem: &str) -> Vec<Vec<u8>> {
6178 files_in(dir).into_iter().filter(|(name, _)| name.starts_with(&format!("{stem} (")) && name.ends_with(").md") && !made_by_tools(name)).map(|(_, bytes)| bytes).collect()
6179 }
6180
6181 pub(super) const MINUTE: i64 = 60_000;
6182 pub(super) const NOW: i64 = 1_790_000_000_000;
6183
6184 #[derive(Default)]
6191 struct Edrive {
6192 known: BTreeMap<String, (String, String)>,
6194 }
6195
6196 impl Edrive {
6197 fn files(root: &Path) -> BTreeMap<String, Vec<u8>> {
6198 let mut found = Vec::new();
6199 list_files(root, root, &[], &mut found);
6200 found.into_iter().filter(|(name, _)| !name.split('/').any(|p| p.starts_with('.'))).filter_map(|(name, path)| std::fs::read(path).ok().map(|b| (name, b))).collect()
6201 }
6202
6203 fn carry(&mut self, server: &Path, phone: &Path) {
6204 let digest = |bytes: &[u8]| hash(&String::from_utf8_lossy(bytes));
6205 let (up, down) = (Self::files(phone), Self::files(server));
6206 for (name, bytes) in &up {
6207 let changed_here = self.known.get(name).is_none_or(|(_, local)| *local != digest(bytes));
6208 if changed_here {
6209 let to = server.join(name);
6210 std::fs::create_dir_all(to.parent().unwrap()).unwrap();
6211 std::fs::write(&to, bytes).unwrap();
6212 self.known.insert(name.clone(), (digest(bytes), digest(bytes)));
6213 }
6214 }
6215 for (name, bytes) in &down {
6216 let local = phone.join(name);
6217 match (self.known.get(name), std::fs::read(&local)) {
6218 (Some((remote, _)), _) if *remote == digest(bytes) => {}
6219 (Some(_), Ok(here)) if here.len() == bytes.len() => {
6221 self.known.insert(name.clone(), (digest(bytes), digest(&here)));
6222 }
6223 _ => {
6224 std::fs::create_dir_all(local.parent().unwrap()).unwrap();
6225 std::fs::write(&local, bytes).unwrap();
6226 self.known.insert(name.clone(), (digest(bytes), digest(bytes)));
6227 }
6228 }
6229 }
6230 }
6231 }
6232
6233 #[derive(Clone, Copy, Debug, PartialEq)]
6237 enum Carrying {
6238 Exact,
6240 SizeOnly,
6242 Late,
6244 StaleCopies,
6246 Placeholders,
6248 }
6249
6250 struct Carrier {
6251 how: Carrying,
6252 turn: u64,
6253 known: BTreeMap<String, (String, String)>,
6255 history: BTreeMap<String, Vec<Vec<u8>>>,
6257 }
6258
6259 impl Carrier {
6260 fn new(how: Carrying) -> Carrier {
6261 Carrier { how, turn: 0, known: BTreeMap::new(), history: BTreeMap::new() }
6262 }
6263
6264 fn files(root: &Path) -> BTreeMap<String, Vec<u8>> {
6265 let mut found = Vec::new();
6266 list_files(root, root, &[], &mut found);
6267 found.into_iter().filter(|(name, _)| !name.split('/').any(|p| p.starts_with('.'))).filter_map(|(name, path)| std::fs::read(path).ok().map(|b| (name, b))).collect()
6268 }
6269
6270 fn write(path: &Path, bytes: &[u8]) {
6271 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
6272 std::fs::write(path, bytes).unwrap();
6273 }
6274
6275 fn carry(&mut self, server: &Path, phone: &Path) {
6276 self.turn += 1;
6277 let digest = |bytes: &[u8]| hash(&String::from_utf8_lossy(bytes));
6278 let (up, down) = (Self::files(phone), Self::files(server));
6279 for (name, bytes) in &up {
6280 let changed_here = self.known.get(name).is_none_or(|(_, local)| *local != digest(bytes));
6281 if changed_here && !(self.how == Carrying::Placeholders && bytes.is_empty() && down.contains_key(name)) {
6283 Self::write(&server.join(name), bytes);
6284 self.known.insert(name.clone(), (digest(bytes), digest(bytes)));
6285 }
6286 }
6287 for (i, (name, bytes)) in down.iter().enumerate() {
6288 self.history.entry(name.clone()).or_default().push(bytes.clone());
6289 if self.how == Carrying::Late && (i as u64 + self.turn) % 2 == 0 {
6290 continue;
6291 }
6292 let local = phone.join(name);
6293 let here = std::fs::read(&local).ok();
6294 match (self.known.get(name), here) {
6295 (Some((remote, _)), _) if *remote == digest(bytes) => {}
6296 (Some(_), Some(here)) if self.how == Carrying::SizeOnly && here.len() == bytes.len() => {
6297 self.known.insert(name.clone(), (digest(bytes), digest(&here)));
6298 }
6299 (None, None) if self.how == Carrying::Placeholders => {
6300 Self::write(&local, b"");
6301 self.known.insert(name.clone(), (String::new(), digest(b"")));
6302 }
6303 _ => {
6304 Self::write(&local, bytes);
6305 self.known.insert(name.clone(), (digest(bytes), digest(bytes)));
6306 }
6307 }
6308 }
6309 if self.how == Carrying::Exact {
6310 for name in up.keys().filter(|n| !down.contains_key(*n) && self.known.contains_key(*n)) {
6311 let _ = std::fs::remove_file(phone.join(name));
6312 }
6313 }
6314 if self.how == Carrying::StaleCopies && self.turn % 7 == 0 {
6316 for (name, versions) in self.history.iter().filter(|(n, _)| n.ends_with(".jsonl")) {
6317 if versions.len() > 2 {
6318 let current = std::fs::read(server.join(name)).unwrap_or_default();
6319 Self::write(&server.join(format!("{name} (conflicted copy)")), ¤t);
6320 Self::write(&server.join(name), &versions[versions.len() / 2]);
6321 }
6322 }
6323 }
6324 }
6325 }
6326
6327 #[test]
6328 fn doses_are_never_known_wrongly_whatever_carries_them() {
6329 for how in [Carrying::Exact, Carrying::SizeOnly, Carrying::Late, Carrying::StaleCopies, Carrying::Placeholders] {
6330 let base = scratch(&format!("carrier-{how:?}").to_lowercase());
6331 let (server, phone_folder) = (base.join("server").join("Sioul"), base.join("phone").join("Sioul"));
6332 let key = quick_key(&server, "four words make a passphrase").unwrap();
6333 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
6334 let mut carrier = Carrier::new(how);
6335 let mut marked: Vec<String> = Vec::new();
6336 for minute in 0..120 {
6337 let now = NOW + minute * MINUTE;
6338 if minute % 10 == 0 {
6340 marked.push(format!("dose@{minute}"));
6341 let record: String = marked.iter().map(|d| format!("\"{d}\" = {minute}\n")).collect();
6342 desk.write("state/health-state.toml", &format!("[taken]\n{record}"));
6343 }
6344 desk.exchange(&server, &key, now);
6345 let wrote = written(&desk.memory, &desk.id);
6346 crate::lease::renew(&server, &key, "health", &desk.id, now / 1000, now / 1000, false, crate::lease::Rule::FollowsYou, wrote).unwrap();
6347 carrier.carry(&server, &phone_folder);
6348 let import: Vec<sioul_core::texts::Text> = (0..20).map(|i| sioul_core::texts::Text { id: format!("sms-{minute}-{i}"), at: NOW - 86_400_000 + minute * MINUTE + i, direction: "in".into(), with: vec!["+33199001234".into()], body: format!("Message {minute}-{i}"), ..sioul_core::texts::Text::default() }).collect();
6350 sioul_core::texts::append(&sioul_core::texts::own_file(&texts_of(&phone), sioul_core::texts::LOG, &phone.id), &import, &crate::textseal::TextSeal::new(&key), None).unwrap();
6351 if minute % 10 == 0 {
6352 let picture = base.join(format!("picture-{minute}.bin"));
6353 std::fs::write(&picture, vec![minute as u8; 256 * 1024]).unwrap();
6354 let (hash, _) = crate::blobs::hash_file(&picture).unwrap();
6355 crate::blobs::put(&phone_folder, &key, &picture, &hash).unwrap();
6356 }
6357 phone.exchange_with(&phone_folder, &key, now + 30_000, &base.join("phone-notes"), &[sioul_core::texts::PART]);
6358 if let Some(claim) = crate::lease::claims(&phone_folder, &key, "health").into_iter().find(|c| c.computer == desk.id)
6360 && let Some(wrote) = claim.wrote
6361 && heard(&phone.memory, &phone.id).complete(&desk.id, wrote)
6362 {
6363 let record = phone.read("state/health-state.toml");
6364 let claimed = (claim.renewed - NOW / 1000) / 60;
6365 for dose in marked.iter().filter(|d| d[5..].parse::<i64>().unwrap() < claimed) {
6366 assert!(record.contains(dose.as_str()), "{how:?}, minute {minute}: the phone takes {dose} as known, its record lacks it:\n{record}");
6367 }
6368 }
6369 }
6370 for minute in 120..140 {
6372 let now = NOW + minute * MINUTE;
6373 desk.exchange(&server, &key, now);
6374 carrier.carry(&server, &phone_folder);
6375 phone.exchange(&phone_folder, &key, now + 30_000);
6376 }
6377 let record = phone.read("state/health-state.toml");
6378 for dose in &marked {
6379 assert!(record.contains(dose.as_str()), "{how:?}: {dose} never came:\n{record}");
6380 }
6381 let _ = std::fs::remove_dir_all(&base);
6382 }
6383 }
6384
6385 #[test]
6386 fn edrive_brings_every_rewrite() {
6387 let base = scratch("edrive");
6388 let (server, phone_folder) = (base.join("server").join("Sioul"), base.join("phone").join("Sioul"));
6389 let key = quick_key(&server, "four words make a passphrase").unwrap();
6390 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
6391 let mut edrive = Edrive::default();
6392 let health = |computer: &Computer, folder: &Path, now: i64, closed: bool| {
6393 let wrote = written(&computer.memory, &computer.id);
6394 if closed {
6395 crate::lease::close(folder, &key, "health", &computer.id, now / 1000, wrote).unwrap();
6396 } else {
6397 crate::lease::renew(folder, &key, "health", &computer.id, now / 1000, now / 1000, false, crate::lease::Rule::FollowsYou, wrote).unwrap();
6398 }
6399 };
6400
6401 desk.write("state/health-state.toml", "[taken]\n\"levothyroxine@2026-10-05T07:30\" = 1790000000\n");
6403 desk.exchange(&server, &key, NOW);
6404 health(&desk, &server, NOW, false);
6405 edrive.carry(&server, &phone_folder);
6406 phone.exchange(&phone_folder, &key, NOW + MINUTE);
6407 assert!(phone.read("state/health-state.toml").contains("levothyroxine@2026-10-05T07:30"));
6408
6409 for minute in 2..200 {
6411 let now = NOW + minute * MINUTE;
6412 if minute == 120 {
6413 desk.write("state/health-state.toml", &format!("{}\"levothyroxine@2026-10-06T07:30\" = 1790090000\n", desk.read("state/health-state.toml")));
6414 }
6415 desk.exchange(&server, &key, now);
6416 health(&desk, &server, now, minute == 199);
6417 edrive.carry(&server, &phone_folder);
6418 for name in [format!("{}.toml", desk.id), format!("leases/health/{}.lease", desk.id)] {
6419 assert_eq!(std::fs::read(phone_folder.join(&name)).ok(), std::fs::read(server.join(&name)).ok(), "minute {minute}: {name} stale on the phone");
6420 }
6421 }
6422 phone.exchange(&phone_folder, &key, NOW + 200 * MINUTE);
6424 assert!(phone.read("state/health-state.toml").contains("levothyroxine@2026-10-06T07:30"));
6425 let claim = crate::lease::claims(&phone_folder, &key, "health").into_iter().find(|c| c.computer == desk.id).unwrap();
6426 assert!(claim.closed && claim.wrote == written(&desk.memory, &desk.id), "{claim:?}");
6427 let phone_record = format!("{}-1.jsonl", phone.id);
6429 assert_eq!(std::fs::read(server.join(&phone_record)).ok(), std::fs::read(phone_folder.join(&phone_record)).ok());
6430 let _ = std::fs::remove_dir_all(&base);
6431 }
6432
6433 #[test]
6437 fn each_batch_of_records_says_its_build() {
6438 let base = scratch("build");
6439 let folder = base.join("Sioul");
6440 let key = quick_key(&folder, "four words make a passphrase").unwrap();
6441 let desk = Computer::new(&base, "desk");
6442 desk.write("config/safe-senders.txt", "a@example.org\nb@example.org\n");
6443 desk.exchange(&folder, &key, NOW);
6444 let own = round_file(&folder, &desk.id, 1);
6445 let first = std::fs::read_to_string(&own).unwrap().lines().count();
6446 desk.write("config/safe-senders.txt", "a@example.org\nb@example.org\nc@example.org\n");
6447 desk.exchange(&folder, &key, NOW + MINUTE);
6448 let text = std::fs::read_to_string(&own).unwrap();
6449 let builds: Vec<String> = text
6450 .lines()
6451 .map(|l| {
6452 let line: Line = serde_json::from_str(l).unwrap();
6453 serde_json::from_slice::<Change>(&open(&key, &bound(&desk.id, 1, line.n, line.c), &line.s).unwrap()).unwrap().build
6454 })
6455 .collect();
6456 assert!(first >= 2 && builds.len() > first, "{builds:?}");
6457 for (n, build) in builds.iter().enumerate() {
6458 assert_eq!(build.as_str(), if n == 0 || n == first { sioul_core::build::DESCRIBED } else { "" }, "line {n}");
6459 }
6460 assert!(!text.contains(sioul_core::build::DESCRIBED), "sealed: the server never learns it");
6461 std::fs::write(&own, text.lines().next().unwrap().to_string() + "\n").unwrap();
6463 desk.exchange(&folder, &key, NOW + 2 * MINUTE);
6464 let line: Line = serde_json::from_str(std::fs::read_to_string(round_file(&folder, &desk.id, 2)).unwrap().lines().next().unwrap()).unwrap();
6465 let change: Change = serde_json::from_slice(&open(&key, &bound(&desk.id, 2, line.n, line.c), &line.s).unwrap()).unwrap();
6466 assert_eq!(change.build, sioul_core::build::DESCRIBED);
6467 let laptop = Computer::new(&base, "laptop");
6469 let stranger = uuid::Uuid::new_v4().to_string();
6470 append_to(&round_file(&folder, &stranger, 1), &record_of(&key, &stranger, 1, ((NOW + MINUTE) as u64) << 16, &Change { k: "config/safe-senders.txt#d@example.org".into(), v: Some(String::new()), b: String::new(), build: String::new(), f: 0 }));
6471 laptop.exchange(&folder, &key, NOW + 3 * MINUTE);
6472 let safe = laptop.read("config/safe-senders.txt");
6473 assert!(["a@", "b@", "c@", "d@"].iter().all(|a| safe.contains(a)), "{safe}");
6474 let _ = std::fs::remove_dir_all(&base);
6475 }
6476
6477 #[test]
6478 fn a_record_put_back_shorter_starts_again() {
6479 let base = scratch("cut");
6480 let folder = base.join("Sioul");
6481 let key = quick_key(&folder, "four words make a passphrase").unwrap();
6482 let desk = Computer::new(&base, "desk");
6483 desk.write("config/safe-senders.txt", "a@example.org\n");
6484 desk.exchange(&folder, &key, NOW);
6485 desk.write("config/safe-senders.txt", "a@example.org\nb@example.org\n");
6486 desk.exchange(&folder, &key, NOW + MINUTE);
6487 let own = round_file(&folder, &desk.id, 1);
6489 let text = std::fs::read_to_string(&own).unwrap();
6490 std::fs::write(&own, text.lines().next().unwrap().to_string() + "\n").unwrap();
6491 desk.write("config/safe-senders.txt", "a@example.org\nb@example.org\nc@example.org\n");
6492 let outcome = desk.exchange(&folder, &key, NOW + 2 * MINUTE);
6493 assert!(outcome.problems.iter().any(|p| p == "share-own-cut"), "{outcome:?}");
6494 assert!(round_file(&folder, &desk.id, 2).exists(), "a new file, opening on all the desk holds");
6495 let laptop = Computer::new(&base, "laptop");
6497 laptop.exchange(&folder, &key, NOW + 3 * MINUTE);
6498 let safe = laptop.read("config/safe-senders.txt");
6499 assert!(["a@", "b@", "c@"].iter().all(|a| safe.contains(a)), "{safe}");
6500 let _ = std::fs::remove_dir_all(&base);
6501 }
6502
6503 #[test]
6504 fn a_memory_restored_or_lost_goes_on_numbering() {
6505 let base = scratch("restored");
6506 let folder = base.join("Sioul");
6507 let key = quick_key(&folder, "four words make a passphrase").unwrap();
6508 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
6509 desk.write("state/health-state.toml", "[taken]\n\"a@100\" = 100\n");
6510 desk.exchange(&folder, &key, NOW);
6511 phone.exchange(&folder, &key, NOW + MINUTE);
6512 let backup = (std::fs::read(&desk.memory).unwrap(), desk.read("state/health-state.toml"));
6514 desk.write("state/health-state.toml", "[taken]\n\"a@100\" = 100\n\"b@800\" = 800\n");
6515 desk.exchange(&folder, &key, NOW + 2 * MINUTE);
6516 phone.exchange(&folder, &key, NOW + 3 * MINUTE);
6517 assert!(phone.read("state/health-state.toml").contains("b@800"));
6518 let before = written(&desk.memory, &desk.id).unwrap();
6519 std::fs::write(&desk.memory, &backup.0).unwrap();
6521 desk.write("state/health-state.toml", &backup.1);
6522 let outcome = desk.exchange(&folder, &key, NOW + 4 * MINUTE);
6523 assert!(outcome.problems.iter().any(|p| p == "share-own-ahead"), "{outcome:?}");
6524 assert!(desk.read("state/health-state.toml").contains("b@800"), "{}", desk.read("state/health-state.toml"));
6525 desk.write("state/health-state.toml", &format!("{}\"c@900\" = 900\n", desk.read("state/health-state.toml")));
6526 desk.exchange(&folder, &key, NOW + 5 * MINUTE);
6527 let after = written(&desk.memory, &desk.id).unwrap();
6528 assert!(after > before && after.0 == before.0, "numbering goes on in the same round: {before:?} → {after:?}");
6529 let outcome = phone.exchange(&folder, &key, NOW + 6 * MINUTE);
6531 assert!(!outcome.problems.iter().any(|p| p.starts_with("share-other-gap")), "{outcome:?}");
6532 assert!(phone.read("state/health-state.toml").contains("c@900"));
6533 assert!(heard(&phone.memory, &phone.id).complete(&desk.id, after));
6534 std::fs::remove_file(&desk.memory).unwrap();
6536 desk.write("state/health-state.toml", &format!("{}\"d@950\" = 950\n", desk.read("state/health-state.toml")));
6537 desk.exchange(&folder, &key, NOW + 7 * MINUTE);
6538 assert!(written(&desk.memory, &desk.id).unwrap() > after);
6539 phone.exchange(&folder, &key, NOW + 8 * MINUTE);
6540 assert!(phone.read("state/health-state.toml").contains("d@950"));
6541 assert!(heard(&phone.memory, &phone.id).complete(&desk.id, written(&desk.memory, &desk.id).unwrap()));
6542 let _ = std::fs::remove_dir_all(&base);
6543 }
6544
6545 #[test]
6546 fn a_gap_is_a_doubt_and_a_damaged_line_is_one_line() {
6547 let base = scratch("gap");
6548 let folder = base.join("Sioul");
6549 let key = quick_key(&folder, "four words make a passphrase").unwrap();
6550 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
6551 desk.write("config/safe-senders.txt", "a@example.org\n");
6552 desk.exchange(&folder, &key, NOW);
6553 phone.exchange(&folder, &key, NOW + MINUTE);
6554 let own = round_file(&folder, &desk.id, 1);
6556 let mut bytes = std::fs::read(&own).unwrap();
6557 bytes.extend_from_slice(b"\xff\xfe broken\n");
6558 std::fs::write(&own, &bytes).unwrap();
6559 desk.write("config/safe-senders.txt", "a@example.org\nb@example.org\n");
6560 desk.exchange(&folder, &key, NOW + 2 * MINUTE);
6561 let outcome = phone.exchange(&folder, &key, NOW + 3 * MINUTE);
6562 assert!(outcome.problems.iter().any(|p| p.starts_with("share-other-line")), "{outcome:?}");
6563 assert!(phone.read("config/safe-senders.txt").contains("b@example.org"));
6564 let line = |n: u64, value: &str| {
6566 let clock = (NOW as u64) << 16 | n;
6567 let plain = serde_json::to_vec(&Change { k: "config/safe-senders.txt#".to_string() + value, v: Some(String::new()), b: String::new(), build: String::new(), f: 0 }).unwrap();
6568 serde_json::to_string(&Line { n, c: clock, s: seal(&key, &bound(&desk.id, 1, n, clock), &plain) }).unwrap() + "\n"
6569 };
6570 let last = written(&desk.memory, &desk.id).unwrap().1;
6571 let mut bytes = std::fs::read(&own).unwrap();
6572 bytes.extend_from_slice(line(last + 3, "c@example.org").as_bytes());
6573 std::fs::write(&own, bytes).unwrap();
6574 let outcome = phone.exchange(&folder, &key, NOW + 4 * MINUTE);
6575 assert!(outcome.problems.iter().any(|p| p.starts_with("share-other-gap")), "{outcome:?}");
6576 assert!(!heard(&phone.memory, &phone.id).complete(&desk.id, (1, last + 3)), "past a gap, nothing is known");
6577 let _ = std::fs::remove_dir_all(&base);
6578 }
6579
6580 #[test]
6581 fn stores_that_leave_arrive_or_move_take_nothing_out() {
6582 let base = scratch("stores");
6583 let folder = base.join("Sioul");
6584 let key = quick_key(&folder, "four words make a passphrase").unwrap();
6585 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
6586 let (desk_notes, phone_notes, moved) = (base.join("desk-notes"), base.join("phone-notes"), base.join("moved-notes"));
6587 let ledger = |notes: &Path| std::fs::read_to_string(notes.join("sioul-budgets.toml")).unwrap_or_default();
6588 let line = |label: &str, amount: f64| format!("\n[[line]]\nbudget = \"home\"\ndate = \"2026-10-01\"\namount = {amount}\nlabel = \"{label}\"\n");
6589 std::fs::create_dir_all(&desk_notes).unwrap();
6590 std::fs::write(desk_notes.join("sioul-budgets.toml"), format!("[[budget]]\nid = \"home\"\ntitle = \"Home\"\n{}", line("Rent", -620.0))).unwrap();
6591 std::fs::create_dir_all(&phone_notes).unwrap();
6593 std::fs::write(phone_notes.join("sioul-budgets.toml"), format!("[[budget]]\nid = \"home\"\ntitle = \"Old home\"\n{}", line("Old", -1.0))).unwrap();
6594 let old = std::fs::File::options().write(true).open(phone_notes.join("sioul-budgets.toml")).unwrap();
6595 old.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000)).unwrap();
6596 desk.exchange_notes(&folder, &key, NOW, &desk_notes);
6597 phone.exchange(&folder, &key, NOW + MINUTE);
6598 phone.exchange_notes(&folder, &key, NOW + 2 * MINUTE, &phone_notes);
6600 desk.exchange_notes(&folder, &key, NOW + 3 * MINUTE, &desk_notes);
6601 assert!(ledger(&phone_notes).contains("title = \"Home\"") && ledger(&phone_notes).contains("Rent"), "{}", ledger(&phone_notes));
6602 assert!(ledger(&desk_notes).contains("title = \"Home\""), "{}", ledger(&desk_notes));
6603 phone.exchange_notes(&folder, &key, NOW + 4 * MINUTE, &moved);
6605 desk.exchange_notes(&folder, &key, NOW + 5 * MINUTE, &desk_notes);
6606 assert!(ledger(&moved).contains("Rent"), "{}", ledger(&moved));
6607 assert!(ledger(&desk_notes).contains("Rent"), "{}", ledger(&desk_notes));
6608 phone.exchange(&folder, &key, NOW + 6 * MINUTE);
6610 desk.exchange_notes(&folder, &key, NOW + 7 * MINUTE, &desk_notes);
6611 assert!(ledger(&desk_notes).contains("Rent"), "{}", ledger(&desk_notes));
6612 let _ = std::fs::remove_dir_all(&base);
6613 }
6614
6615 #[test]
6616 fn a_file_emptied_takes_nothing_out_at_once() {
6617 let base = scratch("emptied");
6618 let folder = base.join("Sioul");
6619 let key = quick_key(&folder, "four words make a passphrase").unwrap();
6620 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
6621 let settings = "language = \"fr\"\n\n[tasks]\nkind = \"plan\"\n";
6622 desk.write("config/config.toml", settings);
6623 desk.exchange(&folder, &key, NOW);
6624 laptop.exchange(&folder, &key, NOW + MINUTE);
6625 desk.write("config/config.toml", "");
6627 let outcome = desk.exchange(&folder, &key, NOW + 2 * MINUTE);
6628 assert!(outcome.problems.iter().any(|p| p.starts_with("share-emptied")), "{outcome:?}");
6629 laptop.exchange(&folder, &key, NOW + 3 * MINUTE);
6630 assert!(laptop.read("config/config.toml").contains("language = \"fr\""));
6631 desk.write("config/config.toml", settings);
6633 assert_eq!(desk.exchange(&folder, &key, NOW + 4 * MINUTE).sent, 0);
6634 desk.write("config/safe-senders.txt", "a@example.org\n");
6636 desk.exchange(&folder, &key, NOW + 5 * MINUTE);
6637 desk.write("config/safe-senders.txt", "");
6638 desk.exchange(&folder, &key, NOW + 6 * MINUTE);
6639 laptop.exchange(&folder, &key, NOW + 7 * MINUTE);
6640 assert!(!laptop.read("config/safe-senders.txt").contains("a@example.org"));
6641 let _ = std::fs::remove_dir_all(&base);
6642 }
6643
6644 #[test]
6650 fn a_change_sends_a_small_file_and_a_late_joiner_reads_everything() {
6651 let base = scratch("segments");
6652 let folder = base.join("Sioul");
6653 let key = quick_key(&folder, "four words make a passphrase").unwrap();
6654 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
6655 let many: String = (0..3000).map(|n| format!("sender{n}@example.org\n")).collect();
6657 desk.write("config/known-senders.txt", &many);
6658 desk.exchange(&folder, &key, NOW);
6659 laptop.exchange(&folder, &key, NOW + 30_000);
6660 let current = |c: &Computer| {
6661 let all = rounds(&folder);
6662 let last = *all.get(&c.id).unwrap().last().unwrap();
6663 std::fs::metadata(round_file(&folder, &c.id, last)).unwrap().len()
6664 };
6665 assert!(current(&desk) > SEGMENT, "the first file holds it all");
6666 let mut list = many.clone();
6668 for minute in 1..=60 {
6669 list.push_str(&format!("new{minute}@example.org\n"));
6670 desk.write("config/known-senders.txt", &list);
6671 desk.exchange(&folder, &key, NOW + minute * MINUTE);
6672 assert!(current(&desk) <= SEGMENT + 4096, "minute {minute}: the file sent is {} bytes", current(&desk));
6673 laptop.exchange(&folder, &key, NOW + minute * MINUTE + 30_000);
6674 }
6675 assert!(laptop.read("config/known-senders.txt").contains("new60@example.org"));
6676 let more: String = (0..8000).map(|n| format!("later{n}@example.org\n")).collect();
6678 list.push_str(&more);
6679 desk.write("config/known-senders.txt", &list);
6680 desk.exchange(&folder, &key, NOW + 61 * MINUTE);
6681 let full = Memory::load(&desk.memory, &desk.id).full_round;
6682 assert!(full > 1, "a full round opened");
6683 for minute in 62..66 {
6684 laptop.exchange(&folder, &key, NOW + minute * MINUTE + 30_000);
6685 list.push_str(&format!("after{minute}@example.org\n"));
6686 desk.write("config/known-senders.txt", &list);
6687 desk.exchange(&folder, &key, NOW + minute * MINUTE);
6688 }
6689 let kept = rounds(&folder).get(&desk.id).cloned().unwrap();
6690 assert!(kept[0] >= full && kept.len() > 1, "only the last full round and those continuing it: {kept:?} (full {full})");
6691 let late = Computer::new(&base, "late");
6693 late.exchange(&folder, &key, NOW + 70 * MINUTE);
6694 let known = late.read("config/known-senders.txt");
6695 for address in ["sender0@", "sender2999@", "new1@", "new60@", "later7999@", "after65@"] {
6696 assert!(known.contains(address), "{address} missing for the late joiner");
6697 }
6698 let _ = std::fs::remove_dir_all(&base);
6699 }
6700
6701 #[test]
6704 fn a_full_round_waits_while_the_connection_is_metered() {
6705 let base = scratch("frugal");
6706 let folder = base.join("Sioul");
6707 let key = quick_key(&folder, "four words make a passphrase").unwrap();
6708 let desk = Computer::new(&base, "desk");
6709 let memory_full = || Memory::load(&desk.memory, &desk.id).full_round;
6710 let lines = |from: usize, count: usize| -> String { (from..from + count).map(|n| format!("s{n}@example.org\n")).collect() };
6711 set_frugal(&desk.memory, true);
6712 let mut list = lines(0, 6000);
6713 desk.write("config/known-senders.txt", &list);
6714 desk.exchange(&folder, &key, NOW);
6715 list.push_str(&lines(6000, 7000));
6716 desk.write("config/known-senders.txt", &list);
6717 desk.exchange(&folder, &key, NOW + MINUTE);
6718 let waited = memory_full();
6719 assert!(Memory::load(&desk.memory, &desk.id).grown > ROUND_SIZE && waited <= 1, "past a megabyte, metered: no full round yet ({waited})");
6720 set_frugal(&desk.memory, false);
6721 list.push_str(&lines(13000, 10));
6722 desk.write("config/known-senders.txt", &list);
6723 desk.exchange(&folder, &key, NOW + 2 * MINUTE);
6724 assert!(memory_full() > 1, "not metered any more: the full round opens");
6725 let _ = std::fs::remove_dir_all(&base);
6726 }
6727
6728 #[test]
6729 fn a_big_round_does_not_start_another_each_minute() {
6730 let base = scratch("big");
6731 let folder = base.join("Sioul");
6732 let key = quick_key(&folder, "four words make a passphrase").unwrap();
6733 let desk = Computer::new(&base, "desk");
6734 desk.write("data/drafts/big.toml", &format!("body = \"{}\"\n", "x".repeat(ROUND_SIZE as usize + 1000)));
6735 desk.exchange(&folder, &key, NOW);
6736 desk.write("config/safe-senders.txt", "a@example.org\n");
6737 desk.exchange(&folder, &key, NOW + MINUTE);
6738 let rounds_then = rounds(&folder).get(&desk.id).map_or(0, |r| *r.last().unwrap());
6739 for minute in 2..6 {
6740 desk.write("config/safe-senders.txt", &format!("a@example.org\nn{minute}@example.org\n"));
6741 desk.exchange(&folder, &key, NOW + minute * MINUTE);
6742 }
6743 assert_eq!(rounds(&folder).get(&desk.id).map_or(0, |r| *r.last().unwrap()), rounds_then, "small changes after a big round stay in it");
6744 let _ = std::fs::remove_dir_all(&base);
6745 }
6746
6747 #[test]
6748 fn a_half_written_line_is_cut_before_the_next() {
6749 let base = scratch("half");
6750 let folder = base.join("Sioul");
6751 let key = quick_key(&folder, "four words make a passphrase").unwrap();
6752 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
6753 desk.write("config/safe-senders.txt", "a@example.org\n");
6754 desk.exchange(&folder, &key, NOW);
6755 let own = round_file(&folder, &desk.id, 1);
6756 let mut text = std::fs::read_to_string(&own).unwrap();
6757 text.push_str("{\"n\":99,\"c\":1,\"s\":\"half");
6758 std::fs::write(&own, text).unwrap();
6759 desk.write("config/safe-senders.txt", "a@example.org\nb@example.org\n");
6760 desk.exchange(&folder, &key, NOW + MINUTE);
6761 assert!(std::fs::read_to_string(&own).unwrap().lines().all(|l| l.ends_with('}')), "whole lines only");
6762 laptop.exchange(&folder, &key, NOW + 2 * MINUTE);
6763 assert!(laptop.read("config/safe-senders.txt").contains("b@example.org"));
6764 let _ = std::fs::remove_dir_all(&base);
6765 }
6766
6767 #[test]
6768 fn the_days_reviews_travel_whole() {
6769 use sioul_core::reviews::{Felt, Mix, Reviews};
6770 let base = scratch("reviews");
6771 let folder = base.join("Nextcloud").join("sioul-shared");
6772 let key = quick_key(&folder, "four words make a passphrase").unwrap();
6773 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
6774 let at = |ms: i64| std::time::UNIX_EPOCH + std::time::Duration::from_millis(ms as u64);
6775 let month = "data/reviews/2026-10.toml";
6776 desk.write(month, "[2026-10-06]\nweather = \"haze\"\n\n[2026-10-06.work]\nat = \"2026-10-06T17:04:00+02:00\"\nfelt = \"heavy\"\nmix = \"too_much\"\n");
6778 dated(&desk.path(month), at(NOW - 10 * MINUTE));
6779 desk.exchange(&folder, &key, NOW);
6780 phone.exchange(&folder, &key, NOW + MINUTE);
6781 assert!(phone.read(month).contains("felt = \"heavy\""));
6782 desk.write(month, &desk.read(month).replace("felt = \"heavy\"", "felt = \"usual\""));
6785 dated(&desk.path(month), at(NOW + 90_000));
6786 phone.write(month, &format!("{}\n[2026-10-06.night]\nat = \"2026-10-06T22:10:00+02:00\"\nmix = \"about_right\"\nmemo = \"\"\"\nA walk.\n\"\"\"\n", phone.read(month).replace("mix = \"too_much\"", "mix = \"about_right\"")));
6787 dated(&phone.path(month), at(NOW + 100_000));
6788 phone.exchange(&folder, &key, NOW + 2 * MINUTE);
6789 desk.exchange(&folder, &key, NOW + 3 * MINUTE);
6790 phone.exchange(&folder, &key, NOW + 4 * MINUTE);
6791 for computer in [&desk, &phone] {
6792 let text = computer.read(month);
6793 let reviews = Reviews::load(&computer.path("data/reviews"));
6794 let day = reviews.day("2026-10-06".parse().unwrap()).unwrap();
6795 assert_eq!(day.weather, Some(sioul_core::today::Weather::Haze), "{text}");
6796 assert_eq!(day.work.as_ref().map(|r| (r.felt, r.mix)), Some((Some(Felt::Heavy), Some(Mix::AboutRight))), "{text}");
6798 assert_eq!(day.night.as_ref().map(|r| (r.mix, r.memo.trim().to_string())), Some((Some(Mix::AboutRight), "A walk.".to_string())), "{text}");
6799 }
6800 let _ = std::fs::remove_dir_all(&base);
6801 }
6802
6803 #[test]
6804 fn two_computers_agree() {
6805 let base = scratch("agree");
6806 let folder = base.join("Nextcloud").join("sioul-shared");
6807 let key = quick_key(&folder, "four words make a passphrase").unwrap();
6808 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
6809
6810 desk.write("config/config.toml", "language = \"fr\"\ncase_store = \"~/Notes\"\n\n[tasks]\nkind = \"plan\"\n\n[[account]]\nid = \"perso\"\nkind = \"imap\"\nmaildir = \"/desk/mail\"\n");
6812 desk.write("config/blocked-senders.txt", "spam@example.org\n");
6813 desk.write("data/drafts/a1.toml", "subject = \"Hello\"\n");
6814 desk.write("data/time/2026-10.toml", "[[session]]\ntask = \"t1\"\nstart = 100\nminutes = 25\n");
6815 assert_eq!(desk.exchange(&folder, &key, NOW).received, 0);
6816
6817 laptop.write("config/config.toml", "language = \"en\"\ncase_store = \"~/Nextcloud/Notes\"\n");
6819 laptop.write("config/blocked-senders.txt", "*@pushy.example.com\n");
6820 let outcome = laptop.exchange(&folder, &key, NOW + MINUTE);
6821 assert!(outcome.received > 0 && outcome.sent > 0, "{outcome:?}");
6822 let config = laptop.read("config/config.toml");
6823 assert!(config.contains("language = \"fr\""), "the shared settings win when joining: {config}");
6824 assert!(config.contains("case_store = \"~/Nextcloud/Notes\""), "where things are stays here: {config}");
6825 assert!(config.contains("id = \"perso\"") && !config.contains("/desk/mail"), "accounts come, without this computer's paths: {config}");
6826 assert_eq!(laptop.read("data/drafts/a1.toml"), "subject = \"Hello\"\n");
6827 assert!(laptop.read("data/time/2026-10.toml").contains("task = \"t1\""));
6828 let blocked = laptop.read("config/blocked-senders.txt");
6829 assert!(blocked.contains("spam@example.org") && blocked.contains("*@pushy.example.com"), "{blocked}");
6830
6831 desk.exchange(&folder, &key, NOW + 2 * MINUTE);
6833 assert!(desk.read("config/blocked-senders.txt").contains("*@pushy.example.com"));
6834
6835 desk.write("data/time/2026-10.toml", "[[session]]\ntask = \"t1\"\nstart = 100\nminutes = 25\n\n[[session]]\ntask = \"t2\"\nstart = 200\nminutes = 50\n");
6837 desk.write("config/config.toml", &desk.read("config/config.toml").replace("kind = \"plan\"", "kind = \"list\""));
6838 laptop.write("data/time/2026-10.toml", &format!("{}\n[[session]]\nproject = \"p\"\nstart = 300\nminutes = 10\n", laptop.read("data/time/2026-10.toml")));
6839 let config = laptop.read("config/config.toml").replace("language = \"fr\"", "language = \"de\"");
6840 laptop.write("config/config.toml", &config);
6841 std::fs::remove_file(laptop.path("data/drafts/a1.toml")).unwrap();
6842 desk.exchange(&folder, &key, NOW + 3 * MINUTE);
6843 laptop.exchange(&folder, &key, NOW + 4 * MINUTE);
6844 desk.exchange(&folder, &key, NOW + 5 * MINUTE);
6845 for computer in [&desk, &laptop] {
6846 let time = computer.read("data/time/2026-10.toml");
6847 assert!(time.contains("\"t1\"") && time.contains("\"t2\"") && time.contains("project = \"p\""), "{time}");
6848 let config = computer.read("config/config.toml");
6849 assert!(config.contains("language = \"de\"") && config.contains("kind = \"list\""), "{config}");
6850 assert!(!computer.path("data/drafts/a1.toml").exists(), "a draft sent on one is gone on both");
6851 }
6852 assert!(desk.read("config/config.toml").contains("maildir = \"/desk/mail\""), "the desk keeps its own path");
6853
6854 assert_eq!(desk.exchange(&folder, &key, NOW + 6 * MINUTE).sent, 0);
6856 assert_eq!(laptop.exchange(&folder, &key, NOW + 7 * MINUTE).sent, 0);
6857 let _ = std::fs::remove_dir_all(&base);
6858 }
6859
6860 #[test]
6861 fn projects_and_budgets_travel_when_asked() {
6862 let base = scratch("projects");
6863 let folder = base.join("Nextcloud").join("Documents").join("Sioul");
6864 let key = quick_key(&folder, "four words make a passphrase").unwrap();
6865 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
6866 let (desk_notes, phone_notes) = (base.join("desk-notes"), base.join("phone-notes"));
6867 let projects = |notes: &Path| std::fs::read_to_string(sioul_core::projects::file_in(notes)).unwrap_or_default();
6868 let ledger = |notes: &Path| std::fs::read_to_string(notes.join("sioul-budgets.toml")).unwrap_or_default();
6869 let bank = |notes: &Path| std::fs::read_to_string(notes.join("sioul-bank.toml")).unwrap_or_default();
6870 let movement = |id: &str, label: &str| format!("\n[[movement]]\naccount = \"main\"\ndate = 2026-10-01\namount = -20.0\nlabel = \"{label}\"\nid = \"{id}\"\n");
6871 std::fs::create_dir_all(&desk_notes).unwrap();
6872 std::fs::write(desk_notes.join("sioul-cases.toml"), "[[case]]\nid = \"acme\"\ntitle = \"Acme site\"\nstatus = \"open\"\n\n[[case.route]]\nfrom_domains = [\"acme.example\"]\n").unwrap();
6873 std::fs::write(desk_notes.join("sioul-budgets.toml"), "[[budget]]\nid = \"home\"\ntitle = \"Home\"\n\n[[line]]\nbudget = \"home\"\ndate = \"2026-10-01\"\namount = -620.0\nlabel = \"Rent\"\n").unwrap();
6874 std::fs::write(desk_notes.join("sioul-bank.toml"), format!("[[account]]\nid = \"main\"\ntitle = \"Main\"\nbalance = 1200.0\nas_of = 2026-10-01\n{}", movement("fitid-1", "Rent"))).unwrap();
6875 desk.exchange_notes(&folder, &key, NOW, &desk_notes);
6876
6877 phone.exchange_notes(&folder, &key, NOW + MINUTE, &phone_notes);
6879 assert!(projects(&phone_notes).contains("id = \"acme\"") && projects(&phone_notes).contains("acme.example"), "{}", projects(&phone_notes));
6880 assert!(ledger(&phone_notes).contains("Rent"), "{}", ledger(&phone_notes));
6881 assert!(bank(&phone_notes).contains("fitid-1") && bank(&phone_notes).contains("1200"), "{}", bank(&phone_notes));
6882 assert!(phone_notes.join(sioul_core::projects::FILE).exists() && projects(&phone_notes).contains("[[project]]") && !projects(&phone_notes).contains("[[case"), "{}", projects(&phone_notes));
6884 assert!(projects(&desk_notes).contains("[[case]]") && !desk_notes.join(sioul_core::projects::FILE).exists());
6885
6886 std::fs::write(desk_notes.join("sioul-budgets.toml"), format!("{}\n[[line]]\nbudget = \"home\"\ndate = \"2026-10-02\"\namount = -12.5\nlabel = \"Bread\"\n", ledger(&desk_notes))).unwrap();
6888 std::fs::write(phone_notes.join("sioul-budgets.toml"), format!("{}\n[[line]]\nbudget = \"home\"\ndate = \"2026-10-02\"\namount = -3.2\nlabel = \"Coffee\"\n", ledger(&phone_notes))).unwrap();
6889 std::fs::write(desk_notes.join("sioul-bank.toml"), format!("{}{}", bank(&desk_notes), movement("fitid-2", "Bread"))).unwrap();
6891 std::fs::write(phone_notes.join("sioul-bank.toml"), format!("{}{}", bank(&phone_notes), movement("fitid-3", "Coffee"))).unwrap();
6892 desk.exchange_notes(&folder, &key, NOW + 2 * MINUTE, &desk_notes);
6893 phone.exchange_notes(&folder, &key, NOW + 3 * MINUTE, &phone_notes);
6894 desk.exchange_notes(&folder, &key, NOW + 4 * MINUTE, &desk_notes);
6895 for notes in [&desk_notes, &phone_notes] {
6896 let text = ledger(notes);
6897 assert!(text.contains("Rent") && text.contains("Bread") && text.contains("Coffee"), "{text}");
6898 let mut ids: Vec<String> = sioul_core::bank::Bank::load(notes).unwrap().movements.into_iter().map(|m| m.id).collect();
6899 ids.sort();
6900 assert_eq!(ids, ["fitid-1", "fitid-2", "fitid-3"], "{}", bank(notes));
6901 }
6902
6903 use sioul_core::projects::{ProjectEdit, ProjectStore, file_in, rename_file, save_project, set_ai};
6907 save_project(&file_in(&phone_notes), "", &ProjectEdit { title: "Garden".into(), ..ProjectEdit::default() }).unwrap();
6908 phone.exchange_notes(&folder, &key, NOW + 5 * MINUTE, &phone_notes);
6909 desk.exchange_notes(&folder, &key, NOW + 6 * MINUTE, &desk_notes);
6910 assert!(projects(&desk_notes).matches("[[case]]").count() == 2 && !projects(&desk_notes).contains("[[project"), "{}", projects(&desk_notes));
6911 let desk_store = ProjectStore::load(&desk_notes).unwrap();
6913 assert!(desk_store.get("acme").unwrap().routes.len() == 1 && desk_store.get("garden").unwrap().routes.is_empty(), "{}", projects(&desk_notes));
6914 rename_file(&desk_notes).unwrap();
6915 set_ai(&file_in(&desk_notes), "acme", true).unwrap();
6916 desk.exchange_notes(&folder, &key, NOW + 7 * MINUTE, &desk_notes);
6917 phone.exchange_notes(&folder, &key, NOW + 8 * MINUTE, &phone_notes);
6918 desk.exchange_notes(&folder, &key, NOW + 9 * MINUTE, &desk_notes);
6919 for notes in [&desk_notes, &phone_notes] {
6920 let store = ProjectStore::load(notes).unwrap();
6921 let mut ids: Vec<&str> = store.projects.iter().map(|p| p.id.as_str()).collect();
6922 ids.sort();
6923 assert_eq!(ids, ["acme", "garden"], "{}", projects(notes));
6924 assert!(store.get("acme").unwrap().ai && store.get("acme").unwrap().routes.len() == 1 && store.get("garden").unwrap().routes.is_empty(), "{}", projects(notes));
6925 assert!(!projects(notes).contains("[[case"), "{}", projects(notes));
6926 }
6927 assert!(desk_notes.join(sioul_core::projects::BEFORE_RENAME).exists());
6928
6929 let laptop = Computer::new(&base, "laptop");
6931 laptop.write("config/config.toml", &format!("case_store = '{}'\n", base.join("laptop-notes").display()));
6933 let config: Config = toml::from_str(&laptop.read("config/config.toml")).unwrap();
6934 assert!(stores(&config, &laptop.roots).iter().all(|s| !s.name.starts_with("notes/")));
6935 let _ = std::fs::remove_dir_all(&base);
6936 }
6937
6938 #[test]
6939 fn what_was_read_is_known_and_a_lost_record_comes_back() {
6940 let base = scratch("doses");
6941 let folder = base.join("shared");
6942 let key = quick_key(&folder, "four words make a passphrase").unwrap();
6943 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
6944 desk.write("state/health-state.toml", "[taken]\n\"d@100\" = 110\n");
6945 desk.exchange(&folder, &key, NOW);
6946 phone.exchange(&folder, &key, NOW + MINUTE);
6947 assert!(phone.read("state/health-state.toml").contains("d@100"));
6948 let wrote = written(&desk.memory, &desk.id).unwrap();
6950 assert!(wrote.1 > 0);
6951 let read = heard(&phone.memory, &phone.id);
6952 assert!(read.complete(&desk.id, wrote));
6953 assert!(!read.complete(&desk.id, (wrote.0, wrote.1 + 1)), "a record not read yet is not known");
6954 assert!(read.complete("someone", (1, 0)), "nothing written, nothing to read");
6955
6956 desk.write("state/health-state.toml", "[taken]\n\"d@100\" = 110\n\"d@200\" = 210\n");
6958 desk.exchange(&folder, &key, NOW + 2 * MINUTE);
6959 let round = round_file(&folder, &desk.id, wrote.0);
6960 let mut text = std::fs::read_to_string(&round).unwrap();
6961 let last = text.trim_end().rfind('\n').map_or(0, |i| i + 1);
6962 text.insert_str(last, "{broken\n");
6963 std::fs::write(&round, text).unwrap();
6964 let outcome = phone.exchange(&folder, &key, NOW + 3 * MINUTE);
6965 assert!(outcome.problems.iter().any(|p| p == &format!("share-other-line:{}", desk.id)), "{:?}", outcome.problems);
6966 assert!(heard(&phone.memory, &phone.id).broken.contains_key(&desk.id));
6967 assert!(phone.read("state/health-state.toml").contains("d@200"), "the next line came");
6968
6969 phone.write("state/health-state.toml", &format!("{}\"d@300\" = 310\n", phone.read("state/health-state.toml")));
6971 phone.exchange(&folder, &key, NOW + 4 * MINUTE);
6972 assert!(desk.exchange(&folder, &key, NOW + 5 * MINUTE).received > 0);
6973 assert!(desk.read("state/health-state.toml").contains("d@300"));
6974 std::fs::remove_file(phone.path("state/health-state.toml")).unwrap();
6975 rebuild(&phone.memory, &phone.id, &["state/health-state.toml"]).unwrap();
6976 phone.write("state/health-state.toml", "[taken]\n\"d@400\" = 410\n");
6978 phone.exchange(&folder, &key, NOW + 6 * MINUTE);
6979 let back = phone.read("state/health-state.toml");
6980 assert!(back.contains("d@100") && back.contains("d@200") && back.contains("d@300") && back.contains("d@400"), "{back}");
6981 desk.exchange(&folder, &key, NOW + 7 * MINUTE);
6983 let there = desk.read("state/health-state.toml");
6984 assert!(there.contains("d@100") && there.contains("d@200") && there.contains("d@300") && there.contains("d@400"), "{there}");
6985 let _ = std::fs::remove_dir_all(&base);
6986 }
6987
6988 #[test]
6989 fn the_later_word_wins() {
6990 let base = scratch("later");
6991 let folder = base.join("shared");
6992 let key = quick_key(&folder, "a passphrase of some length").unwrap();
6993 let (a, b) = (Computer::new(&base, "a"), Computer::new(&base, "b"));
6994 a.write("config/safe-senders.txt", "friend@example.org\n");
6995 a.exchange(&folder, &key, NOW);
6996 b.exchange(&folder, &key, NOW + MINUTE);
6997 assert!(b.read("config/safe-senders.txt").contains("friend@example.org"));
6998
6999 b.write("config/safe-senders.txt", "");
7001 b.write("config/blocked-senders.txt", "friend@example.org\n");
7002 b.exchange(&folder, &key, NOW + 2 * MINUTE);
7003 a.exchange(&folder, &key, NOW + 3 * MINUTE);
7004 assert!(!a.read("config/safe-senders.txt").contains("friend@example.org"));
7005 assert!(a.read("config/blocked-senders.txt").contains("friend@example.org"));
7006
7007 a.write("state/quiet.toml", "work_until = 5\n");
7009 let sent = a.exchange(&folder, &key, NOW + 4 * MINUTE);
7010 let received = b.exchange(&folder, &key, NOW + 5 * MINUTE);
7011 assert!(b.read("state/quiet.toml").contains("work_until = 5"), "{sent:?} {received:?}");
7012 b.write("state/quiet.toml", "work_until = [ broken");
7013 b.exchange(&folder, &key, NOW + 6 * MINUTE);
7014 a.exchange(&folder, &key, NOW + 7 * MINUTE);
7015 assert!(a.read("state/quiet.toml").contains("work_until = 5"));
7016 let _ = std::fs::remove_dir_all(&base);
7017 }
7018
7019 #[test]
7020 fn sealed_with_one_passphrase() {
7021 let base = scratch("seal");
7022 let folder = base.join("shared");
7023 let key = quick_key(&folder, "the right passphrase").unwrap();
7024 assert_eq!(quick_key(&folder, "the right passphrase"), Ok(key));
7025 assert_eq!(quick_key(&folder, "another passphrase"), Err(Refused::WrongPassphrase));
7026
7027 let a = Computer::new(&base, "a");
7029 a.write("config/safe-senders.txt", "friend@example.org\n");
7030 a.exchange(&folder, &key, NOW);
7031 let file = std::fs::read_to_string(round_file(&folder, &a.id, 1)).unwrap();
7032 assert!(!file.contains("friend") && !file.contains("safe-senders"), "{file}");
7033 assert_eq!(others(&folder, "someone else").len(), 1);
7034 let text = std::fs::read_to_string(folder.join("seal.toml")).unwrap();
7036 std::fs::write(folder.join("seal.toml"), text.replace("memory_kib = 64", "memory_kib = 4294967295")).unwrap();
7037 assert!(matches!(quick_key(&folder, "the right passphrase"), Err(Refused::Other(_))));
7038 let _ = std::fs::remove_dir_all(&base);
7039 }
7040
7041 #[test]
7042 fn records_stay_in_their_folders() {
7043 let roots = Roots { config: PathBuf::from("config"), data: PathBuf::from("data"), state: PathBuf::from("state") };
7044 let stores = stores(&Config::default(), &roots);
7045 assert!(locate(&stores, "data/drafts/a1.toml").is_some());
7046 for file in ["data/drafts/../x", "data/drafts/", "data/drafts//x", "data/drafts/a\\..\\..\\x", "data/drafts/C:x"] {
7047 assert!(locate(&stores, file).is_none(), "{file}");
7048 }
7049 }
7050
7051 #[test]
7052 fn rounds_start_over_and_old_ones_go() {
7053 let base = scratch("rounds");
7054 let folder = base.join("shared");
7055 let key = quick_key(&folder, "a passphrase of some length").unwrap();
7056 let (a, b) = (Computer::new(&base, "a"), Computer::new(&base, "b"));
7057 a.write("config/safe-senders.txt", "friend@example.org\n");
7058 a.exchange(&folder, &key, NOW);
7059 b.exchange(&folder, &key, NOW + MINUTE);
7060 let mut now = NOW + MINUTE;
7062 while Memory::load(&a.memory, &a.id).full_round <= 1 {
7063 now += MINUTE;
7064 a.write("data/drafts/2026-10-01.json", &format!("{{\"steps\": {now}, \"pad\": \"{}\"}}", "x".repeat(100_000)));
7065 a.exchange(&folder, &key, now);
7066 }
7067 assert!(round_file(&folder, &a.id, 1).exists(), "kept until b has read past it");
7068 b.exchange(&folder, &key, now + MINUTE);
7070 assert!(b.read("data/drafts/2026-10-01.json").contains(&format!("\"steps\": {now}")));
7071 a.exchange(&folder, &key, now + 2 * MINUTE);
7072 assert!(!round_file(&folder, &a.id, 1).exists(), "read by everyone: removed");
7073 let c = Computer::new(&base, "c");
7075 c.exchange(&folder, &key, now + 3 * MINUTE);
7076 assert!(c.read("data/drafts/2026-10-01.json").contains(&format!("\"steps\": {now}")));
7077 let _ = std::fs::remove_dir_all(&base);
7078 }
7079
7080 #[test]
7081 fn notes_travel_file_by_file_whatever_carries_them() {
7082 for how in [Carrying::Exact, Carrying::SizeOnly, Carrying::Late, Carrying::StaleCopies, Carrying::Placeholders] {
7083 let base = scratch(&format!("notes-{how:?}").to_lowercase());
7084 let (server, phone_folder) = (base.join("server").join("Sioul"), base.join("phone").join("Sioul"));
7085 let key = quick_key(&server, "four words make a passphrase").unwrap();
7086 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
7087 let (desk_notes, phone_notes) = (base.join("desk-notes"), base.join("phone-notes"));
7088 put(&desk_notes, "lease.md", b"# Lease\nSigned on 2 October.\n");
7089 put(&desk_notes, "admin/taxes.md", b"# Taxes\nNotice received.\n");
7090 put(&desk_notes, "admin/scan.pdf", &noise(1_500_000, 1));
7091 for name in ["lease.md~", ".lease.md.swp", "lease (conflicted copy 2026-10-05 140533).md", "taxes.sync-conflict-20261005-140533-ABCDEFG.md", "~$letter.docx", "sioul-cases.toml", "sioul-projects.toml", "sioul-cases.toml.before-rename", "papers/passport.pdf"] {
7093 put(&desk_notes, name, b"stays home");
7094 }
7095 let mut carrier = Carrier::new(how);
7096 let run = |carrier: &mut Carrier, minutes: std::ops::Range<i64>| {
7097 for minute in minutes {
7098 let now = NOW + minute * MINUTE;
7099 desk.exchange_with(&server, &key, now, &desk_notes, &["notes"]);
7100 carrier.carry(&server, &phone_folder);
7101 phone.exchange_with(&phone_folder, &key, now + 30_000, &phone_notes, &["notes"]);
7102 }
7103 };
7104 run(&mut carrier, 0..20);
7105 let there = files_in(&phone_notes);
7106 assert_eq!(there.keys().map(String::as_str).collect::<Vec<_>>(), ["admin/scan.pdf", "admin/taxes.md", "lease.md"], "{how:?}");
7107 assert!(there["admin/scan.pdf"] == noise(1_500_000, 1), "{how:?}: the scan came whole");
7108 put(&phone_notes, "lease.md", b"# Lease\nSigned on 2 October.\nKeys: two.\n");
7110 put(&phone_notes, "new.md", b"# New\n");
7111 std::fs::remove_file(desk_notes.join("admin/taxes.md")).unwrap();
7112 run(&mut carrier, 20..40);
7113 for notes in [&desk_notes, &phone_notes] {
7114 let files = files_in(notes);
7115 assert!(!files.contains_key("admin/taxes.md"), "{how:?}: {}", notes.display());
7116 assert_eq!(files.get("lease.md").map(Vec::as_slice), Some(&b"# Lease\nSigned on 2 October.\nKeys: two.\n"[..]), "{how:?}: {}", notes.display());
7117 assert_eq!(files.get("new.md").map(Vec::as_slice), Some(&b"# New\n"[..]), "{how:?}: {}", notes.display());
7118 assert!(copies_of(notes, "lease").is_empty(), "{how:?}: no conflict where there was none");
7119 }
7120 let sealed = std::fs::read_dir(server.join("blobs")).unwrap().filter_map(Result::ok).filter(|e| !e.file_name().to_string_lossy().starts_with('.')).count();
7122 assert_eq!(sealed, 5, "{how:?}: the lease twice, the taxes, the scan, the new note");
7123 let mut everything = Vec::new();
7124 list_files(&server, &server, &[], &mut everything);
7125 for (_, path) in everything {
7126 let bytes = std::fs::read(&path).unwrap();
7127 for secret in [&b"Signed on"[..], b"Taxes", b"Keys: two", b"stays home", b"lease.md"] {
7128 assert!(!bytes.windows(secret.len()).any(|w| w == secret), "{how:?}: {} shows {}", path.display(), String::from_utf8_lossy(secret));
7129 }
7130 }
7131 let _ = std::fs::remove_dir_all(&base);
7132 }
7133 }
7134
7135 #[test]
7136 fn a_note_changed_on_both_keeps_both_whatever_carries_them() {
7137 for how in [Carrying::Exact, Carrying::SizeOnly, Carrying::Late, Carrying::StaleCopies, Carrying::Placeholders] {
7138 let base = scratch(&format!("notes-both-{how:?}").to_lowercase());
7139 let (server, phone_folder) = (base.join("server").join("Sioul"), base.join("phone").join("Sioul"));
7140 let key = quick_key(&server, "four words make a passphrase").unwrap();
7141 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
7142 let (desk_notes, phone_notes) = (base.join("desk-notes"), base.join("phone-notes"));
7143 put(&desk_notes, "lease.md", b"Rent: 620\n");
7144 put(&desk_notes, "taxes.md", b"Notice received.\n");
7145 let mut carrier = Carrier::new(how);
7146 let run = |carrier: &mut Carrier, minutes: std::ops::Range<i64>| {
7147 for minute in minutes {
7148 let now = NOW + minute * MINUTE;
7149 desk.exchange_with(&server, &key, now, &desk_notes, &["notes"]);
7150 carrier.carry(&server, &phone_folder);
7151 phone.exchange_with(&phone_folder, &key, now + 30_000, &phone_notes, &["notes"]);
7152 }
7153 };
7154 run(&mut carrier, 0..10);
7155 assert_eq!(files_in(&phone_notes).len(), 2, "{how:?}");
7156 put(&desk_notes, "lease.md", b"Rent: 640\n");
7158 put(&phone_notes, "lease.md", b"Rent: 620\nKeys: two\n");
7159 std::fs::remove_file(desk_notes.join("taxes.md")).unwrap();
7160 put(&phone_notes, "taxes.md", b"Notice received.\nPaid on 3 October.\n");
7161 run(&mut carrier, 10..40);
7162 for notes in [&desk_notes, &phone_notes] {
7163 let files = files_in(notes);
7164 assert_eq!(files.get("lease.md").map(Vec::as_slice), Some(&b"Rent: 620\nKeys: two\n"[..]), "{how:?}: {}", notes.display());
7165 assert_eq!(copies_of(notes, "lease"), [b"Rent: 640\n".to_vec()], "{how:?}: {}: {:?}", notes.display(), files.keys().collect::<Vec<_>>());
7166 assert_eq!(files.get("taxes.md").map(Vec::as_slice), Some(&b"Notice received.\nPaid on 3 October.\n"[..]), "{how:?}: {}", notes.display());
7167 assert_eq!(files.len(), 3, "{how:?}: {}: {:?}", notes.display(), files.keys().collect::<Vec<_>>());
7168 }
7169 let _ = std::fs::remove_dir_all(&base);
7170 }
7171 }
7172
7173 #[test]
7174 fn a_big_folder_sends_only_what_changed() {
7175 let base = scratch("big-folder");
7176 let notes = base.join("notes");
7177 let folder = notes.join("sioul-shared");
7179 let key = quick_key(&folder, "four words make a passphrase").unwrap();
7180 let desk = Computer::new(&base, "desk");
7181 for n in 0..300 {
7182 put(¬es, &format!("days/{n:03}.md"), format!("# Day {n}\n").as_bytes());
7183 }
7184 for n in 0..3 {
7185 put(¬es, &format!("scans/{n}.pdf"), &noise(1_500_000, n));
7186 }
7187 std::thread::sleep(std::time::Duration::from_millis(5));
7189 let blobs = || std::fs::read_dir(folder.join("blobs")).unwrap().count();
7190 assert_eq!(desk.exchange_with(&folder, &key, NOW, ¬es, &["notes"]).sent, 303);
7191 assert_eq!(blobs(), 303);
7192 crate::blobs::HASHED.with(|n| n.set(0));
7194 assert_eq!(desk.exchange_with(&folder, &key, NOW + MINUTE, ¬es, &["notes"]).sent, 0);
7195 assert_eq!(crate::blobs::HASHED.with(std::cell::Cell::get), 0, "a file of the same size and time is not read");
7196 std::fs::File::options().write(true).open(notes.join("days/007.md")).unwrap().set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(60)).unwrap();
7198 put(¬es, "scans/1.pdf", &noise(1_500_000, 9));
7199 let outcome = desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, ¬es, &["notes"]);
7200 assert_eq!(outcome.sent, 1, "{outcome:?}");
7201 assert_eq!(crate::blobs::HASHED.with(std::cell::Cell::get), 2);
7202 assert_eq!(blobs(), 304);
7203 std::fs::File::create(notes.join("scans/film.mp4")).unwrap().set_len(crate::blobs::LARGEST + 1).unwrap();
7205 let outcome = desk.exchange_with(&folder, &key, NOW + 3 * MINUTE, ¬es, &["notes"]);
7206 assert!(outcome.problems.iter().any(|p| p == "share-too-big:files/notes/scans/film.mp4"), "{outcome:?}");
7207 assert_eq!(outcome.sent, 0);
7208 let _ = std::fs::remove_dir_all(&base);
7209 }
7210
7211 #[test]
7212 fn a_note_changed_on_both_keeps_both() {
7213 for desk_later in [false, true] {
7214 let base = scratch(&format!("both-{desk_later}"));
7215 let folder = base.join("Sioul");
7216 let key = quick_key(&folder, "four words make a passphrase").unwrap();
7217 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
7218 let (desk_notes, laptop_notes) = (base.join("desk-notes"), base.join("laptop-notes"));
7219 put(&desk_notes, "lease.md", b"Rent: 620\n");
7220 desk.exchange_with(&folder, &key, NOW, &desk_notes, &["notes"]);
7221 laptop.exchange_with(&folder, &key, NOW + MINUTE, &laptop_notes, &["notes"]);
7222 assert_eq!(std::fs::read(laptop_notes.join("lease.md")).unwrap(), b"Rent: 620\n");
7223 put(&desk_notes, "lease.md", b"Rent: 640\n");
7225 put(&laptop_notes, "lease.md", b"Rent: 620\nKeys: two\n");
7226 let (first, second): (&Computer, &Computer) = if desk_later { (&laptop, &desk) } else { (&desk, &laptop) };
7227 let notes = |c: &Computer| if std::ptr::eq(c, &desk) { desk_notes.clone() } else { laptop_notes.clone() };
7228 let mut said = Vec::new();
7229 for minute in 2..10 {
7230 let (who, at) = if minute % 2 == 0 { (first, minute) } else { (second, minute) };
7231 said.extend(who.exchange_with(&folder, &key, NOW + at * MINUTE, ¬es(who), &["notes"]).problems);
7232 }
7233 let (later, earlier): (&[u8], &[u8]) = if desk_later { (b"Rent: 640\n", b"Rent: 620\nKeys: two\n") } else { (b"Rent: 620\nKeys: two\n", b"Rent: 640\n") };
7234 for notes in [&desk_notes, &laptop_notes] {
7235 assert_eq!(std::fs::read(notes.join("lease.md")).unwrap(), later, "desk later: {desk_later}");
7236 assert_eq!(copies_of(notes, "lease"), [earlier.to_vec()], "desk later: {desk_later}: {:?}", files_in(notes).keys().collect::<Vec<_>>());
7237 }
7238 assert!(said.iter().any(|p| p.starts_with("share-conflict:lease (")), "{said:?}");
7239 assert_eq!(desk.exchange_with(&folder, &key, NOW + 10 * MINUTE, &desk_notes, &["notes"]).sent, 0);
7241 assert_eq!(laptop.exchange_with(&folder, &key, NOW + 11 * MINUTE, &laptop_notes, &["notes"]).sent, 0);
7242 let _ = std::fs::remove_dir_all(&base);
7243 }
7244 }
7245
7246 #[test]
7247 fn a_part_switched_off_takes_nothing_out_and_sends_nothing() {
7248 let base = scratch("part-off");
7249 let folder = base.join("Sioul");
7250 let key = quick_key(&folder, "four words make a passphrase").unwrap();
7251 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
7252 let (desk_notes, phone_notes) = (base.join("desk-notes"), base.join("phone-notes"));
7253 for name in ["a", "b", "c", "d", "e", "f"] {
7254 put(&desk_notes, &format!("{name}.md"), format!("# {name}\n").as_bytes());
7255 }
7256 desk.exchange_with(&folder, &key, NOW, &desk_notes, &["notes"]);
7257 phone.exchange_with(&folder, &key, NOW + MINUTE, &phone_notes, &["notes"]);
7258 assert_eq!(files_in(&phone_notes).len(), 6);
7259 phone.exchange_with(&folder, &key, NOW + 2 * MINUTE, &phone_notes, &[]);
7261 std::fs::remove_file(phone_notes.join("a.md")).unwrap();
7262 put(&phone_notes, "b.md", b"# b, changed on the phone while off\n");
7263 put(&phone_notes, "new.md", b"# new\n");
7264 put(&desk_notes, "c.md", b"# c, changed on the desk\n");
7265 desk.exchange_with(&folder, &key, NOW + 3 * MINUTE, &desk_notes, &["notes"]);
7266 assert_eq!(phone.exchange_with(&folder, &key, NOW + 4 * MINUTE, &phone_notes, &[]).sent, 0, "a part switched off sends nothing");
7267 desk.exchange_with(&folder, &key, NOW + 5 * MINUTE, &desk_notes, &["notes"]);
7268 let desk_files = files_in(&desk_notes);
7269 assert!(desk_files.contains_key("a.md") && desk_files["b.md"] == b"# b\n" && !desk_files.contains_key("new.md"), "nothing taken out, nothing came: {desk_files:?}");
7270 assert_eq!(std::fs::read(phone_notes.join("c.md")).unwrap(), b"# c\n", "nothing comes into a part switched off");
7271 phone.exchange_with(&folder, &key, NOW + 6 * MINUTE, &phone_notes, &["notes"]);
7274 desk.exchange_with(&folder, &key, NOW + 7 * MINUTE, &desk_notes, &["notes"]);
7275 phone.exchange_with(&folder, &key, NOW + 8 * MINUTE, &phone_notes, &["notes"]);
7276 desk.exchange_with(&folder, &key, NOW + 9 * MINUTE, &desk_notes, &["notes"]);
7277 for notes in [&desk_notes, &phone_notes] {
7278 let files = files_in(notes);
7279 assert_eq!(files.get("a.md").map(Vec::as_slice), Some(&b"# a\n"[..]), "{}", notes.display());
7280 assert_eq!(files.get("b.md").map(Vec::as_slice), Some(&b"# b\n"[..]), "{}", notes.display());
7281 assert_eq!(files.get("c.md").map(Vec::as_slice), Some(&b"# c, changed on the desk\n"[..]), "{}", notes.display());
7282 assert_eq!(files.get("new.md").map(Vec::as_slice), Some(&b"# new\n"[..]), "{}", notes.display());
7283 assert_eq!(copies_of(notes, "b"), [b"# b, changed on the phone while off\n".to_vec()], "{}", notes.display());
7284 }
7285 let _ = std::fs::remove_dir_all(&base);
7286 }
7287
7288 #[cfg(unix)]
7295 #[test]
7296 fn what_the_sharing_writes_is_yours_alone() {
7297 use std::os::unix::fs::PermissionsExt;
7298 let mode = |path: &Path| std::fs::symlink_metadata(path).unwrap().permissions().mode() & 0o777;
7299 let set = |path: &Path, mode: u32| std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
7300 let base = scratch("yours-alone");
7301 let folder = base.join("Sioul");
7302 let key = quick_key(&folder, "four words make a passphrase").unwrap();
7303 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
7304 let (desk_notes, laptop_notes) = (base.join("desk-notes"), base.join("laptop-notes"));
7305 put(&desk_notes, "admin/lease.md", b"Rent: 620\n");
7306 desk.write("config/config.toml", "language = \"fr\"\n");
7307 put(&laptop_notes, "admin/lease.md", b"Rent: 600\n");
7311 put(&laptop_notes, "mine.md", b"Mine\n");
7312 laptop.write("config/config.toml", "theme = \"dark\"\n");
7313 for path in [laptop_notes.join("admin/lease.md"), laptop_notes.join("mine.md"), laptop.path("config/config.toml")] {
7314 set(&path, 0o644);
7315 }
7316 set(&laptop_notes.join("admin"), 0o755);
7317 std::fs::File::options().write(true).open(laptop_notes.join("admin/lease.md")).unwrap().set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_millis((NOW - 24 * 60 * MINUTE) as u64)).unwrap();
7319 let mut now = NOW;
7320 for _ in 0..3 {
7321 now += MINUTE;
7322 desk.exchange_with(&folder, &key, now, &desk_notes, &["notes"]);
7323 now += MINUTE;
7324 laptop.exchange_with(&folder, &key, now, &laptop_notes, &["notes"]);
7325 }
7326 assert_eq!(std::fs::read(laptop_notes.join("admin/lease.md")).unwrap(), b"Rent: 620\n");
7327 assert!(laptop.read("config/config.toml").contains("language = \"fr\""), "{}", laptop.read("config/config.toml"));
7328 assert_eq!(copies_of(&laptop_notes.join("admin"), "lease").len(), 1, "the laptop's version kept beside");
7329 assert_eq!(mode(&laptop_notes.join("admin/lease.md")), 0o600);
7331 assert_eq!(mode(&laptop.path("config/config.toml")), 0o600);
7332 for (name, _) in files_in(&laptop_notes.join("admin")).into_iter().filter(|(n, _)| n.starts_with("lease (")) {
7333 assert_eq!(mode(&laptop_notes.join("admin").join(&name)), 0o600, "{name}");
7334 }
7335 assert_eq!(mode(&desk_notes.join("mine.md")), 0o600, "brought to the desk");
7336 assert_eq!(mode(&laptop_notes.join("mine.md")), 0o644);
7338 assert_eq!(mode(&desk_notes.join("admin/lease.md")), 0o644);
7339 assert_eq!(mode(&laptop_notes.join("admin")), 0o755, "a folder there already keeps its mode");
7340 let history = crate::history::root(&laptop.memory);
7342 assert!(!crate::history::files(&history, "settings").is_empty(), "a version kept");
7343 for root in [folder.clone(), history.clone()] {
7344 let mut stack = vec![root];
7345 while let Some(dir) = stack.pop() {
7346 assert_eq!(mode(&dir), 0o700, "{}", dir.display());
7347 for entry in std::fs::read_dir(&dir).unwrap().map(Result::unwrap) {
7348 let path = entry.path();
7349 if path.is_dir() {
7350 stack.push(path);
7351 } else {
7352 assert_eq!(mode(&path), 0o600, "{}", path.display());
7353 }
7354 }
7355 }
7356 }
7357 let rounds: Vec<PathBuf> = std::fs::read_dir(&folder).unwrap().map(|e| e.unwrap().path()).filter(|p| p.extension().is_some_and(|e| e == "jsonl") && p.to_string_lossy().contains(&desk.id)).collect();
7359 assert!(!rounds.is_empty(), "the desk's records");
7360 for round in &rounds {
7361 set(round, 0o644);
7362 }
7363 desk.write("config/config.toml", "language = \"en\"\n");
7364 desk.exchange_with(&folder, &key, now + MINUTE, &desk_notes, &["notes"]);
7365 let written: Vec<&PathBuf> = rounds.iter().filter(|r| mode(r) == 0o600).collect();
7366 assert_eq!(written.len(), 1, "the round written to, and it alone: {rounds:?}");
7367 let _ = std::fs::remove_dir_all(&base);
7368 }
7369
7370 #[test]
7371 fn earlier_versions_are_kept_and_put_back() {
7372 let base = scratch("history");
7373 let folder = base.join("Sioul");
7374 let key = quick_key(&folder, "four words make a passphrase").unwrap();
7375 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
7376 let (desk_notes, laptop_notes) = (base.join("desk-notes"), base.join("laptop-notes"));
7377 let history = crate::history::root(&laptop.memory);
7378 let mut now = NOW;
7379 let both = |now: &mut i64| {
7380 *now += MINUTE;
7381 desk.exchange_with(&folder, &key, *now, &desk_notes, &["notes"]);
7382 *now += MINUTE;
7383 laptop.exchange_with(&folder, &key, *now, &laptop_notes, &["notes"])
7384 };
7385 desk.write("config/config.toml", "language = \"fr\"\n");
7388 for n in 1..=3 {
7389 put(&desk_notes, "lease.md", format!("version {n}\n").as_bytes());
7390 let at = std::time::UNIX_EPOCH + std::time::Duration::from_nanos(NOW as u64 * 1_000_000 + n * 100);
7391 std::fs::File::options().write(true).open(desk_notes.join("lease.md")).unwrap().set_modified(at).unwrap();
7392 both(&mut now);
7393 }
7394 desk.write("config/config.toml", "language = \"en\"\n");
7395 both(&mut now);
7396 assert_eq!(std::fs::read(laptop_notes.join("lease.md")).unwrap(), b"version 3\n");
7397 let kept = crate::history::versions(&history, "notes", "files/notes/lease.md");
7398 let texts: Vec<String> = kept.iter().map(|v| version_text(&history, "notes", "files/notes/lease.md", &v.stamp, &folder, &key)).collect();
7399 assert_eq!(texts, ["version 2\n", "version 1\n"], "the newest first");
7400 assert_eq!(crate::history::files(&history, "settings").iter().map(|(f, _)| f.as_str()).collect::<Vec<_>>(), ["config/config.toml"]);
7401 let every = laptop.stores_with(&laptop_notes, &|_| true);
7403 put_back(&laptop.memory, Some((&folder, &key)), &every, "notes", "files/notes/lease.md", &kept[1].stamp, now).unwrap();
7404 assert_eq!(std::fs::read(laptop_notes.join("lease.md")).unwrap(), b"version 1\n");
7405 assert_eq!(crate::history::versions(&history, "notes", "files/notes/lease.md").len(), 3);
7406 let setting = crate::history::versions(&history, "settings", "config/config.toml");
7408 put_back(&laptop.memory, Some((&folder, &key)), &every, "settings", "config/config.toml", &setting[0].stamp, now).unwrap();
7409 assert!(laptop.read("config/config.toml").contains("\"fr\""));
7410 assert!(put_back(&laptop.memory, Some((&folder, &key)), &every, "settings", "files/notes/lease.md", &kept[1].stamp, now).is_err());
7412 assert!(put_back(&laptop.memory, Some((&folder, &key)), &every, "notes", "files/notes/lease.md", "2020-01-01T00-00-00.000Z", now).is_err());
7413 now += MINUTE;
7414 assert!(laptop.exchange_with(&folder, &key, now, &laptop_notes, &["notes"]).sent >= 2);
7415 now += MINUTE;
7416 desk.exchange_with(&folder, &key, now, &desk_notes, &["notes"]);
7417 assert_eq!(std::fs::read(desk_notes.join("lease.md")).unwrap(), b"version 1\n", "put back on one, put back on all");
7418 assert!(copies_of(&desk_notes, "lease").is_empty(), "{:?}", files_in(&desk_notes).into_iter().map(|(n, b)| (n, String::from_utf8_lossy(&b).to_string())).collect::<Vec<_>>());
7419 assert!(desk.read("config/config.toml").contains("\"fr\""));
7420 put(&desk_notes, "other.md", b"other\n");
7423 both(&mut now);
7424 std::fs::remove_file(desk_notes.join("lease.md")).unwrap();
7425 both(&mut now);
7426 assert!(laptop_notes.join("lease.md").exists(), "not before ten minutes");
7427 for _ in 0..6 {
7428 both(&mut now);
7429 }
7430 assert!(!laptop_notes.join("lease.md").exists());
7431 let kept = crate::history::versions(&history, "notes", "files/notes/lease.md");
7432 put_back(&laptop.memory, Some((&folder, &key)), &every, "notes", "files/notes/lease.md", &kept[0].stamp, now).unwrap();
7433 both(&mut now);
7434 both(&mut now);
7435 assert_eq!(std::fs::read(desk_notes.join("lease.md")).unwrap(), b"version 1\n");
7436 let _ = std::fs::remove_dir_all(&base);
7437 }
7438
7439 #[test]
7440 fn a_damaged_blob_or_record_never_erases_the_file_here() {
7441 let base = scratch("damaged");
7442 let folder = base.join("Sioul");
7443 let key = quick_key(&folder, "four words make a passphrase").unwrap();
7444 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
7445 let (desk_notes, laptop_notes) = (base.join("desk-notes"), base.join("laptop-notes"));
7446 put(&desk_notes, "lease.md", b"version 1\n");
7447 desk.exchange_with(&folder, &key, NOW, &desk_notes, &["notes"]);
7448 laptop.exchange_with(&folder, &key, NOW + MINUTE, &laptop_notes, &["notes"]);
7449 put(&desk_notes, "lease.md", b"version 2, longer\n");
7451 desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &desk_notes, &["notes"]);
7452 let sealed = crate::blobs::path(&folder, &key, &crate::blobs::hash_file(&desk_notes.join("lease.md")).unwrap().0);
7453 let mut bytes = std::fs::read(&sealed).unwrap();
7454 let middle = bytes.len() / 2;
7455 bytes[middle] ^= 0x20;
7456 std::fs::write(&sealed, &bytes).unwrap();
7457 for minute in [3, 4] {
7458 let outcome = laptop.exchange_with(&folder, &key, NOW + minute * MINUTE, &laptop_notes, &["notes"]);
7459 assert!(outcome.problems.iter().any(|p| p == "share-damaged:files/notes/lease.md") && outcome.pending == 1, "{outcome:?}");
7460 assert_eq!(std::fs::read(laptop_notes.join("lease.md")).unwrap(), b"version 1\n");
7461 }
7462 put(&desk_notes, "lease.md", b"version 3\n");
7464 desk.exchange_with(&folder, &key, NOW + 5 * MINUTE, &desk_notes, &["notes"]);
7465 let next = crate::blobs::path(&folder, &key, &crate::blobs::hash_file(&desk_notes.join("lease.md")).unwrap().0);
7466 let whole = std::fs::read(&next).unwrap();
7467 std::fs::write(&next, b"").unwrap();
7468 let outcome = laptop.exchange_with(&folder, &key, NOW + 6 * MINUTE, &laptop_notes, &["notes"]);
7469 assert!(outcome.problems.is_empty() && outcome.pending == 1, "{outcome:?}");
7470 assert_eq!(std::fs::read(laptop_notes.join("lease.md")).unwrap(), b"version 1\n");
7471 let last = written(&desk.memory, &desk.id).unwrap();
7473 let clock = ((NOW + 7 * MINUTE) as u64) << 16;
7474 let plain = serde_json::to_vec(&Change { k: "files/notes/lease.md#".into(), v: Some("not a reference".into()), b: String::new(), build: String::new(), f: 0 }).unwrap();
7475 let mut text = std::fs::read_to_string(round_file(&folder, &desk.id, last.0)).unwrap();
7476 text.push_str("{\"n\": 99, \"c\": 1, \"s\": \"broken\"}\n");
7477 text.push_str(&(serde_json::to_string(&Line { n: last.1 + 1, c: clock, s: seal(&key, &bound(&desk.id, last.0, last.1 + 1, clock), &plain) }).unwrap() + "\n"));
7478 std::fs::write(round_file(&folder, &desk.id, last.0), text).unwrap();
7479 let outcome = laptop.exchange_with(&folder, &key, NOW + 8 * MINUTE, &laptop_notes, &["notes"]);
7480 assert!(outcome.problems.iter().any(|p| p.starts_with("share-other-seal")) && outcome.problems.iter().any(|p| p == "share-damaged:files/notes/lease.md"), "{outcome:?}");
7481 assert_eq!(std::fs::read(laptop_notes.join("lease.md")).unwrap(), b"version 1\n");
7482 std::fs::write(&next, &whole).unwrap();
7484 put(&desk_notes, "lease.md", b"version 4\n");
7485 desk.exchange_with(&folder, &key, NOW + 9 * MINUTE, &desk_notes, &["notes"]);
7486 laptop.exchange_with(&folder, &key, NOW + 10 * MINUTE, &laptop_notes, &["notes"]);
7487 assert_eq!(std::fs::read(laptop_notes.join("lease.md")).unwrap(), b"version 4\n");
7488 assert!(copies_of(&laptop_notes, "lease").is_empty());
7489 let _ = std::fs::remove_dir_all(&base);
7490 }
7491
7492 #[test]
7493 fn a_notes_folder_found_empty_takes_nothing_out() {
7494 let base = scratch("notes-emptied");
7495 let folder = base.join("Sioul");
7496 let key = quick_key(&folder, "four words make a passphrase").unwrap();
7497 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
7498 let (desk_notes, laptop_notes) = (base.join("desk-notes"), base.join("laptop-notes"));
7499 for n in 0..6 {
7500 put(&desk_notes, &format!("note-{n}.md"), format!("# {n}\n").as_bytes());
7501 }
7502 desk.exchange_with(&folder, &key, NOW, &desk_notes, &["notes"]);
7503 laptop.exchange_with(&folder, &key, NOW + MINUTE, &laptop_notes, &["notes"]);
7504 for n in 0..6 {
7506 std::fs::remove_file(desk_notes.join(format!("note-{n}.md"))).unwrap();
7507 }
7508 let outcome = desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &desk_notes, &["notes"]);
7509 assert!(outcome.problems.iter().any(|p| p == "share-vanished:files/notes/:6") && outcome.sent == 0, "{outcome:?}");
7510 std::fs::remove_dir_all(&desk_notes).unwrap();
7512 assert_eq!(desk.exchange_with(&folder, &key, NOW + 3 * MINUTE, &desk_notes, &["notes"]).sent, 0);
7513 laptop.exchange_with(&folder, &key, NOW + 4 * MINUTE, &laptop_notes, &["notes"]);
7514 assert_eq!(files_in(&laptop_notes).len(), 6);
7515 std::fs::remove_file(laptop_notes.join("note-0.md")).unwrap();
7517 put(&laptop_notes, "note-1.md", b"# 1, changed\n");
7518 laptop.exchange_with(&folder, &key, NOW + 5 * MINUTE, &laptop_notes, &["notes"]);
7519 laptop.exchange_with(&folder, &key, NOW + 16 * MINUTE, &laptop_notes, &["notes"]);
7520 assert!(desk.exchange_with(&folder, &key, NOW + 17 * MINUTE, &desk_notes, &["notes"]).pending > 0);
7521 assert!(!desk_notes.exists());
7522 desk.exchange_with(&folder, &key, NOW + 18 * MINUTE, &desk_notes, &[]);
7524 desk.exchange_with(&folder, &key, NOW + 19 * MINUTE, &desk_notes, &["notes"]);
7525 let files = files_in(&desk_notes);
7526 assert_eq!(files.keys().map(String::as_str).collect::<Vec<_>>(), ["note-1.md", "note-2.md", "note-3.md", "note-4.md", "note-5.md"]);
7527 assert_eq!(files["note-1.md"], b"# 1, changed\n");
7528 let _ = std::fs::remove_dir_all(&base);
7529 }
7530
7531 pub(super) fn record_of(key: &[u8; 32], computer: &str, n: u64, clock: u64, change: &Change) -> String {
7533 let plain = serde_json::to_vec(change).unwrap();
7534 serde_json::to_string(&Line { n, c: clock, s: seal(key, &bound(computer, 1, n, clock), &plain) }).unwrap() + "\n"
7535 }
7536
7537 pub(super) fn append_to(path: &Path, text: &str) {
7538 let mut file = std::fs::OpenOptions::new().create(true).append(true).open(path).unwrap();
7539 file.write_all(text.as_bytes()).unwrap();
7540 }
7541
7542 pub(super) fn copy_dir(from: &Path, to: &Path) {
7543 for entry in std::fs::read_dir(from).unwrap().filter_map(Result::ok) {
7544 let target = to.join(entry.file_name());
7545 if entry.path().is_dir() {
7546 std::fs::create_dir_all(&target).unwrap();
7547 copy_dir(&entry.path(), &target);
7548 } else {
7549 std::fs::create_dir_all(to).unwrap();
7550 std::fs::copy(entry.path(), target).unwrap();
7551 }
7552 }
7553 }
7554
7555 fn dated(path: &Path, at: std::time::SystemTime) {
7556 std::fs::File::options().write(true).open(path).unwrap().set_modified(at).unwrap();
7557 }
7558
7559 #[test]
7561 fn a_put_back_takes_out_nothing_added_since() {
7562 let base = scratch("put-back-entries");
7563 let folder = base.join("Sioul");
7564 let key = quick_key(&folder, "four words make a passphrase").unwrap();
7565 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
7566 let mut now = NOW;
7567 let both = |now: &mut i64| {
7568 *now += MINUTE;
7569 desk.exchange(&folder, &key, *now);
7570 *now += MINUTE;
7571 phone.exchange(&folder, &key, *now);
7572 };
7573 desk.write("state/health-state.toml", "[taken]\nd1 = 1\n");
7574 desk.write("config/config.toml", "language = \"fr\"\n");
7575 both(&mut now);
7576 phone.write("state/health-state.toml", "[taken]\nd1 = 1\nd2 = 2\n");
7578 both(&mut now);
7579 phone.write("state/health-state.toml", "[taken]\nd1 = 1\nd2 = 2\nd3 = 3\n");
7580 phone.write("config/config.toml", "language = \"en\"\n\n[[account]]\nid = \"new\"\nkind = \"imap\"\n");
7581 both(&mut now);
7582 both(&mut now);
7583 let history = crate::history::root(&desk.memory);
7584 let doses = crate::history::versions(&history, "health", "state/health-state.toml");
7585 assert_eq!(doses.len(), 2, "{doses:?}");
7586 let every = stores_of(&Config::default(), &desk.roots, &|_| true);
7587 let back = put_back_preview(&desk.memory, &every, "health", "state/health-state.toml", &doses[0].stamp).unwrap();
7589 assert_eq!(back, Back { whole: false, changed: 0, returning: 0, kept: 1 });
7590 put_back(&desk.memory, Some((&folder, &key)), &every, "health", "state/health-state.toml", &doses[0].stamp, now).unwrap();
7591 let settings = crate::history::versions(&history, "settings", "config/config.toml");
7593 let back = put_back_preview(&desk.memory, &every, "settings", "config/config.toml", &settings[0].stamp).unwrap();
7594 assert_eq!(back, Back { whole: false, changed: 1, returning: 0, kept: 1 });
7595 put_back(&desk.memory, Some((&folder, &key)), &every, "settings", "config/config.toml", &settings[0].stamp, now).unwrap();
7596 both(&mut now);
7597 both(&mut now);
7598 for computer in [&desk, &phone] {
7599 let doses = computer.read("state/health-state.toml");
7600 assert!(["d1", "d2", "d3"].iter().all(|d| doses.contains(d)), "a dose marked after that version was taken out: {doses}");
7601 let config = computer.read("config/config.toml");
7602 assert!(config.contains("language = \"fr\"") && config.contains("id = \"new\""), "{config}");
7603 }
7604 let _ = std::fs::remove_dir_all(&base);
7605 }
7606
7607 #[test]
7609 fn a_quick_exchange_leaves_notes_for_later() {
7610 let base = scratch("quick");
7611 let folder = base.join("Sioul");
7612 let key = quick_key(&folder, "four words make a passphrase").unwrap();
7613 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
7614 let (dn, pn) = (base.join("desk-notes"), base.join("phone-notes"));
7615 put(&dn, "a.md", b"# a\n");
7616 desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
7617 phone.exchange_with(&folder, &key, NOW + MINUTE, &pn, &["notes"]);
7618 desk.write("state/health-state.toml", "[taken]\nd1 = 1\n");
7620 put(&dn, "a.md", b"# a, changed\n");
7621 desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &dn, &["notes"]);
7622 let stores = phone.stores_with(&pn, &|part| part == "notes");
7623 let quick = exchange(&Sharing { folder: &folder, computer: &phone.id, key: &key, memory: &phone.memory, files: false, hurry: None }, &stores, NOW + 3 * MINUTE).unwrap();
7624 assert!(phone.read("state/health-state.toml").contains("d1"));
7625 assert_eq!(std::fs::read(pn.join("a.md")).unwrap(), b"# a\n");
7626 assert_eq!(quick.pending, 1, "{quick:?}");
7627 let whole = phone.exchange_with(&folder, &key, NOW + 4 * MINUTE, &pn, &["notes"]);
7629 assert_eq!(std::fs::read(pn.join("a.md")).unwrap(), b"# a, changed\n");
7630 assert_eq!(whole.sent, 0, "{whole:?}");
7631 put(&dn, "a.md", b"# a, again\n");
7633 desk.write("state/health-state.toml", "[taken]\nd1 = 1\nd2 = 2\n");
7634 desk.exchange_with(&folder, &key, NOW + 5 * MINUTE, &dn, &["notes"]);
7635 let hurry = std::sync::atomic::AtomicBool::new(true);
7636 exchange(&Sharing { folder: &folder, computer: &phone.id, key: &key, memory: &phone.memory, files: true, hurry: Some(&hurry) }, &stores, NOW + 6 * MINUTE).unwrap();
7637 assert!(phone.read("state/health-state.toml").contains("d2"));
7638 assert_eq!(std::fs::read(pn.join("a.md")).unwrap(), b"# a, changed\n");
7639 hurry.store(false, std::sync::atomic::Ordering::Relaxed);
7640 exchange(&Sharing { folder: &folder, computer: &phone.id, key: &key, memory: &phone.memory, files: true, hurry: Some(&hurry) }, &stores, NOW + 7 * MINUTE).unwrap();
7641 assert_eq!(std::fs::read(pn.join("a.md")).unwrap(), b"# a, again\n");
7642 let _ = std::fs::remove_dir_all(&base);
7643 }
7644
7645 #[test]
7647 fn notes_gone_at_once_wait_for_a_word() {
7648 let base = scratch("vanished");
7649 let folder = base.join("Sioul");
7650 let key = quick_key(&folder, "four words make a passphrase").unwrap();
7651 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
7652 let (dn, ln) = (base.join("desk-notes"), base.join("laptop-notes"));
7653 put(&dn, "today.md", b"# Today\n");
7654 for n in 0..20 {
7655 put(&dn, &format!("archive/{n}.md"), format!("# {n}\n").as_bytes());
7656 }
7657 desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
7658 laptop.exchange_with(&folder, &key, NOW + MINUTE, &ln, &["notes"]);
7659 std::fs::remove_dir_all(dn.join("archive")).unwrap();
7661 std::fs::create_dir(dn.join("archive")).unwrap();
7662 let outcome = desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &dn, &["notes"]);
7663 assert!(outcome.problems.iter().any(|p| p == "share-vanished:files/notes/archive/:20") && outcome.sent == 0, "{outcome:?}");
7664 laptop.exchange_with(&folder, &key, NOW + 3 * MINUTE, &ln, &["notes"]);
7665 assert_eq!(files_in(&ln).len(), 21);
7666 std::fs::remove_dir(dn.join("archive")).unwrap();
7668 for minute in [4, 20] {
7669 let outcome = desk.exchange_with(&folder, &key, NOW + minute * MINUTE, &dn, &["notes"]);
7670 assert!(outcome.problems.iter().any(|p| p == "share-vanished:files/notes/:20") && outcome.sent == 0, "{outcome:?}");
7671 }
7672 laptop.exchange_with(&folder, &key, NOW + 21 * MINUTE, &ln, &["notes"]);
7673 assert_eq!(files_in(&ln).len(), 21);
7674 #[cfg(unix)]
7676 {
7677 use std::os::unix::fs::PermissionsExt;
7678 put(&dn, "locked/x.md", b"# x\n");
7679 desk.exchange_with(&folder, &key, NOW + 22 * MINUTE, &dn, &["notes"]);
7680 std::fs::set_permissions(dn.join("locked"), std::fs::Permissions::from_mode(0o000)).unwrap();
7681 if std::fs::read_dir(dn.join("locked")).is_err() {
7682 for minute in [23, 40] {
7683 desk.exchange_with(&folder, &key, NOW + minute * MINUTE, &dn, &["notes"]);
7684 }
7685 laptop.exchange_with(&folder, &key, NOW + 41 * MINUTE, &ln, &["notes"]);
7686 assert!(ln.join("locked/x.md").exists());
7687 }
7688 std::fs::set_permissions(dn.join("locked"), std::fs::Permissions::from_mode(0o755)).unwrap();
7689 }
7690 confirm_gone(&desk.memory, &desk.id, "files/notes/").unwrap();
7692 let outcome = desk.exchange_with(&folder, &key, NOW + 42 * MINUTE, &dn, &["notes"]);
7693 assert!(outcome.sent >= 20, "{outcome:?}");
7694 laptop.exchange_with(&folder, &key, NOW + 43 * MINUTE, &ln, &["notes"]);
7695 let left: Vec<String> = files_in(&ln).into_keys().collect();
7696 assert!(left.iter().all(|f| !f.starts_with("archive/")) && left.contains(&"today.md".to_string()), "{left:?}");
7697 assert!(!ln.join("archive").exists(), "its folder, left empty, goes too");
7698 let _ = std::fs::remove_dir_all(&base);
7699 }
7700
7701 #[test]
7703 fn no_room_holds_a_file_back_and_tries_again_later() {
7704 let base = scratch("no-room");
7705 let folder = base.join("Sioul");
7706 let key = quick_key(&folder, "four words make a passphrase").unwrap();
7707 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
7708 let (dn, ln) = (base.join("desk-notes"), base.join("laptop-notes"));
7709 put(&dn, "scan.pdf", &noise(100_000, 1));
7710 put(&dn, "other.md", b"other\n");
7711 desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
7712 laptop.exchange_with(&folder, &key, NOW + MINUTE, &ln, &["notes"]);
7713 put(&dn, "scan.pdf", &noise(100_000, 2));
7714 desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &dn, &["notes"]);
7715 crate::disk::ROOM.with(|room| room.set(Some(1000)));
7717 let outcome = laptop.exchange_with(&folder, &key, NOW + 3 * MINUTE, &ln, &["notes"]);
7718 assert!(outcome.problems.iter().any(|p| p == "share-no-room:files/notes/scan.pdf") && outcome.pending == 1, "{outcome:?}");
7719 let outcome = laptop.exchange_with(&folder, &key, NOW + 3 * MINUTE + 30_000, &ln, &["notes"]);
7720 assert!(outcome.problems.is_empty(), "not opened again within the minute: {outcome:?}");
7721 assert_eq!(std::fs::read(ln.join("scan.pdf")).unwrap(), noise(100_000, 1));
7722 crate::disk::ROOM.with(|room| room.set(Some(u64::MAX)));
7723 laptop.exchange_with(&folder, &key, NOW + 5 * MINUTE, &ln, &["notes"]);
7724 assert_eq!(std::fs::read(ln.join("scan.pdf")).unwrap(), noise(100_000, 2));
7725 let kept = crate::history::versions(&crate::history::root(&laptop.memory), "notes", "files/notes/scan.pdf");
7727 assert!(kept.len() == 1 && kept[0].sealed.is_some(), "{kept:?}");
7728 assert_eq!(version_text_bytes(&crate::history::root(&laptop.memory), "notes", "files/notes/scan.pdf", &kept[0].stamp, &folder, &key), noise(100_000, 1));
7729 let _ = std::fs::remove_dir_all(&base);
7730 }
7731
7732 fn version_text_bytes(history: &Path, part: &str, file: &str, stamp: &str, folder: &Path, key: &[u8; 32]) -> Vec<u8> {
7733 let Some(crate::history::Kept::Sealed { hash, .. }) = crate::history::version(history, part, file, stamp) else { panic!("{file}: not by reference") };
7734 let target = temporary(&history.join("opened"));
7735 crate::blobs::get(folder, key, &hash, &target).unwrap();
7736 let bytes = std::fs::read(&target).unwrap();
7737 std::fs::remove_file(target).unwrap();
7738 bytes
7739 }
7740
7741 #[test]
7743 fn huge_or_endless_files_in_the_folder_are_never_read_whole() {
7744 let base = scratch("huge");
7745 let folder = base.join("Sioul");
7746 let key = quick_key(&folder, "four words make a passphrase").unwrap();
7747 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
7748 desk.write("config/safe-senders.txt", "a@example.org\n");
7749 desk.exchange(&folder, &key, NOW);
7750 let stranger = uuid::Uuid::new_v4().to_string();
7752 {
7753 let mut file = std::fs::File::create(round_file(&folder, &stranger, 1)).unwrap();
7754 let piece = vec![b'x'; 1 << 20];
7755 for _ in 0..40 {
7756 file.write_all(&piece).unwrap();
7757 }
7758 file.write_all(b"\n").unwrap();
7759 }
7760 let clock = ((NOW + MINUTE) as u64) << 16;
7761 append_to(&round_file(&folder, &stranger, 1), &record_of(&key, &stranger, 1, clock, &Change { k: "config/safe-senders.txt#b@example.org".into(), v: Some(String::new()), b: String::new(), build: String::new(), f: 0 }));
7762 std::fs::write(seen_path(&folder, &stranger), "x".repeat(100_000)).unwrap();
7763 let outcome = laptop.exchange(&folder, &key, NOW + 2 * MINUTE);
7764 assert!(outcome.problems.iter().any(|p| *p == format!("share-other-line:{stranger}")), "{outcome:?}");
7765 let safe = laptop.read("config/safe-senders.txt");
7766 assert!(safe.contains("a@example.org") && safe.contains("b@example.org"), "{safe}");
7767 assert!(others(&folder, &laptop.id).iter().any(|o| o.id == stranger && o.heard == 0), "its notes, too long, not read");
7768 let own = round_file(&folder, &desk.id, 1);
7770 let whole = std::fs::metadata(&own).unwrap().len();
7771 append_to(&own, "{\"n\": 99, \"c\": 1, \"s\": \"half");
7772 assert_eq!(unfinished(&own), Some(whole));
7773 desk.write("config/safe-senders.txt", "a@example.org\nc@example.org\n");
7774 desk.exchange(&folder, &key, NOW + 3 * MINUTE);
7775 assert!(std::fs::read_to_string(&own).unwrap().lines().all(|l| l.ends_with('}')));
7776 let _ = std::fs::remove_dir_all(&base);
7777 }
7778
7779 #[test]
7781 fn an_edit_made_while_receiving_is_never_lost() {
7782 let base = scratch("while-receiving");
7783 let folder = base.join("Sioul");
7784 let key = quick_key(&folder, "four words make a passphrase").unwrap();
7785 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
7786 let (dn, ln) = (base.join("desk-notes"), base.join("laptop-notes"));
7787 for name in ["a.md", "b.md", "lease.md"] {
7788 put(&dn, name, format!("{name}: one\n").as_bytes());
7789 }
7790 desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
7791 laptop.exchange_with(&folder, &key, NOW + MINUTE, &ln, &["notes"]);
7792 for name in ["a.md", "b.md", "lease.md"] {
7793 put(&dn, name, format!("{name}: two, from the desk\n").as_bytes());
7794 }
7795 desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &dn, &["notes"]);
7796 let (lease, first) = (ln.join("lease.md"), ln.join("a.md"));
7799 BEFORE_WRITING.with(|hook| {
7800 *hook.borrow_mut() = Some(Box::new(move |path: &Path| {
7801 if path.ends_with("lease.md") {
7802 std::fs::write(&lease, "lease.md: edited on the laptop\n").unwrap();
7803 }
7804 if path.ends_with("b.md") {
7805 std::fs::write(&first, "a.md: edited on the laptop\n").unwrap();
7806 }
7807 }))
7808 });
7809 laptop.exchange_with(&folder, &key, NOW + 3 * MINUTE, &ln, &["notes"]);
7810 BEFORE_WRITING.with(|hook| *hook.borrow_mut() = None);
7811 assert_eq!(std::fs::read_to_string(ln.join("lease.md")).unwrap(), "lease.md: edited on the laptop\n", "not written over");
7812 for minute in 4..10 {
7813 if minute % 2 == 0 {
7814 laptop.exchange_with(&folder, &key, NOW + minute * MINUTE, &ln, &["notes"]);
7815 } else {
7816 desk.exchange_with(&folder, &key, NOW + minute * MINUTE, &dn, &["notes"]);
7817 }
7818 }
7819 for notes in [&dn, &ln] {
7822 let all: Vec<String> = files_in(notes).into_values().map(|b| String::from_utf8(b).unwrap()).collect();
7823 for text in ["lease.md: edited on the laptop\n", "lease.md: two, from the desk\n", "a.md: edited on the laptop\n"] {
7824 assert!(all.iter().any(|t| t == text), "{}: {text:?} lost: {all:?}", notes.display());
7825 }
7826 assert_eq!(std::fs::read_to_string(notes.join("a.md")).unwrap(), "a.md: edited on the laptop\n");
7827 }
7828 let _ = std::fs::remove_dir_all(&base);
7829 }
7830
7831 #[test]
7833 fn a_part_switched_off_keeps_nothing_waiting() {
7834 let base = scratch("off-pending");
7835 let folder = base.join("Sioul");
7836 let key = quick_key(&folder, "four words make a passphrase").unwrap();
7837 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
7838 let off = stores_of(&Config::default(), &phone.roots, &|part| part != "drafts");
7839 for n in 0..20 {
7840 desk.write(&format!("data/drafts/{n}.eml"), &"x".repeat(200_000));
7841 desk.exchange(&folder, &key, NOW + 2 * n * MINUTE);
7842 exchange(&Sharing { folder: &folder, computer: &phone.id, key: &key, memory: &phone.memory, files: true, hurry: None }, &off, NOW + (2 * n + 1) * MINUTE).unwrap();
7843 }
7844 assert!(std::fs::metadata(&phone.memory).unwrap().len() < 200_000, "memory.json holds the drafts of a part switched off");
7845 phone.exchange(&folder, &key, NOW + 41 * MINUTE);
7847 assert_eq!(phone.read("data/drafts/19.eml").len(), 200_000);
7848 let _ = std::fs::remove_dir_all(&base);
7849 }
7850
7851 #[test]
7856 fn an_older_sioul_s_watch_is_left_aside() {
7857 let base = scratch("watch-older");
7858 let folder = base.join("Sioul");
7859 let key = quick_key(&folder, "four words make a passphrase").unwrap();
7860 let (older, desk) = (Computer::new(&base, "older"), Computer::new(&base, "desk"));
7861 let mut with_watch = stores(&Config::default(), &older.roots);
7863 with_watch.push(Store { name: "data/watch/".into(), path: older.roots.data.join("watch"), folder: true, shape: Shape::Whole, skip: &["imported.json"], part: "watch" });
7864 with_watch.push(Store { name: "state/watch-offers.json".into(), path: older.roots.state.join("watch-offers.json"), folder: false, shape: Shape::Whole, skip: &[], part: "watch" });
7865 let older_exchange = |now: i64| exchange(&Sharing { folder: &folder, computer: &older.id, key: &key, memory: &older.memory, files: true, hurry: None }, &with_watch, now).unwrap();
7866 older.write("data/watch/2026-10-07.json", "{\"steps\": 4200}");
7867 older.write("state/watch-offers.json", "{\"declined\": {}}");
7868 older.write("config/safe-senders.txt", "friend@example.org\n");
7869 let sent = older_exchange(NOW);
7870 assert!(sent.problems.is_empty() && sent.sent >= 3, "{sent:?}");
7871 for n in 1..=2 {
7872 let came = desk.exchange(&folder, &key, NOW + n * MINUTE);
7873 assert!(came.problems.is_empty() && came.pending == 0, "{came:?}");
7874 }
7875 assert_eq!(desk.read("config/safe-senders.txt"), "friend@example.org\n", "the rest came in");
7876 assert!(!desk.path("data/watch").exists() && !desk.path("state/watch-offers.json").exists(), "nothing of the watch written");
7877 let memory = std::fs::read_to_string(&desk.memory).unwrap();
7879 assert!(!memory.contains("data/watch/") && !memory.contains("watch-offers"), "nothing of the watch kept waiting");
7880 older.write("data/watch/2026-10-08.json", "{\"steps\": 900}");
7882 older_exchange(NOW + 3 * MINUTE);
7883 desk.write("config/safe-senders.txt", "friend@example.org\nneighbour@example.org\n");
7884 let came = desk.exchange(&folder, &key, NOW + 4 * MINUTE);
7885 assert!(came.problems.is_empty() && came.pending == 0 && came.sent >= 1, "{came:?}");
7886 let back = older_exchange(NOW + 5 * MINUTE);
7887 assert!(back.problems.is_empty() && back.received >= 1, "{back:?}");
7888 assert_eq!(older.read("config/safe-senders.txt"), "friend@example.org\nneighbour@example.org\n");
7889 assert_eq!(older.read("data/watch/2026-10-07.json"), "{\"steps\": 4200}", "its own files as they were");
7890 assert!(!desk.path("data/watch").exists());
7891 let _ = std::fs::remove_dir_all(&base);
7892 }
7893
7894 #[cfg(unix)]
7896 #[test]
7897 fn nothing_is_written_through_a_link_nor_into_the_sharing_folder() {
7898 let base = scratch("through-link");
7899 let notes = base.join("desk-notes");
7900 let folder = notes.join("sioul-shared");
7902 let key = quick_key(&folder, "four words make a passphrase").unwrap();
7903 let desk = Computer::new(&base, "desk");
7904 let outside = base.join("outside");
7905 put(¬es, "lease.md", b"x\n");
7906 std::fs::create_dir_all(&outside).unwrap();
7907 std::os::unix::fs::symlink(&outside, notes.join("link")).unwrap();
7908 desk.exchange_with(&folder, &key, NOW, ¬es, &["notes"]);
7909 let source = base.join("evil");
7911 std::fs::write(&source, b"[Desktop Entry]\nExec=sh -c id\n").unwrap();
7912 let (h, s) = crate::blobs::hash_file(&source).unwrap();
7913 crate::blobs::put(&folder, &key, &source, &h).unwrap();
7914 let other = uuid::Uuid::new_v4().to_string();
7915 let value = reference(&h, s, 0);
7916 let mut text = String::new();
7917 for (n, file) in [(1, "files/notes/link/evil.desktop#"), (2, "files/notes/sioul-shared/plain.md#")] {
7918 text += &record_of(&key, &other, n, ((NOW + MINUTE) as u64) << 16 | n, &Change { k: file.into(), v: Some(value.clone()), b: String::new(), build: String::new(), f: 0 });
7919 }
7920 std::fs::write(round_file(&folder, &other, 1), text).unwrap();
7921 let outcome = desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, ¬es, &["notes"]);
7922 assert!(!outside.join("evil.desktop").exists(), "written through the link");
7923 assert!(!folder.join("plain.md").exists(), "written in plain into the sharing folder");
7924 assert!(outcome.problems.iter().filter(|p| p.starts_with("share-refused:")).count() == 2, "{outcome:?}");
7925 let _ = std::fs::remove_dir_all(&base);
7926 }
7927
7928 #[test]
7930 fn a_content_used_again_is_never_lost_to_a_sweep() {
7931 let base = scratch("sweep-race");
7932 let (server, phone_folder) = (base.join("server").join("Sioul"), base.join("phone").join("Sioul"));
7933 let key = quick_key(&server, "four words make a passphrase").unwrap();
7934 let (desk, phone, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"), Computer::new(&base, "laptop"));
7935 let (dn, pn, ln) = (base.join("desk-notes"), base.join("phone-notes"), base.join("laptop-notes"));
7936 let day = 86_400_000;
7937 put(&dn, "a.md", b"template\n");
7938 put(&dn, "keep.md", b"keep\n");
7939 desk.exchange_with(&server, &key, NOW, &dn, &["notes"]);
7940 put(&dn, "a.md", b"changed\n");
7941 desk.exchange_with(&server, &key, NOW + MINUTE, &dn, &["notes"]);
7942 std::fs::write(base.join("template"), b"template\n").unwrap();
7944 let sealed = crate::blobs::path(&server, &key, &crate::blobs::hash_file(&base.join("template")).unwrap().0);
7945 dated(&sealed, std::time::SystemTime::now() - std::time::Duration::from_secs(100 * 86_400));
7946 copy_dir(&server, &phone_folder);
7947 put(&pn, "b.md", b"template\n");
7949 phone.exchange_with(&phone_folder, &key, NOW + 91 * day, &pn, &["notes"]);
7950 desk.exchange_with(&server, &key, NOW + 91 * day + MINUTE, &dn, &["notes"]);
7952 assert!(!sealed.exists(), "swept, as unused for 90 days by what the desk knew");
7953 for entry in std::fs::read_dir(&phone_folder).unwrap().filter_map(Result::ok) {
7955 if entry.file_name().to_string_lossy().ends_with(".jsonl") {
7956 std::fs::copy(entry.path(), server.join(entry.file_name())).unwrap();
7957 }
7958 }
7959 let _ = std::fs::remove_file(phone_folder.join("blobs").join(sealed.file_name().unwrap()));
7960 laptop.exchange_with(&server, &key, NOW + 91 * day + 2 * MINUTE, &ln, &["notes"]);
7961 assert!(!ln.join("b.md").exists());
7962 let outcome = laptop.exchange_with(&server, &key, NOW + 92 * day + 3 * MINUTE, &ln, &["notes"]);
7964 assert!(outcome.problems.iter().any(|p| p == "share-missing:files/notes/b.md"), "{outcome:?}");
7965 phone.exchange_with(&phone_folder, &key, NOW + 92 * day + 4 * MINUTE, &pn, &["notes"]);
7967 assert!(phone_folder.join("blobs").join(sealed.file_name().unwrap()).exists());
7968 copy_dir(&phone_folder.join("blobs"), &server.join("blobs"));
7969 laptop.exchange_with(&server, &key, NOW + 92 * day + 5 * MINUTE, &ln, &["notes"]);
7970 assert_eq!(std::fs::read(ln.join("b.md")).ok().as_deref(), Some(&b"template\n"[..]));
7971 let _ = std::fs::remove_dir_all(&base);
7972 }
7973
7974 #[test]
7976 fn one_exchange_at_a_time() {
7977 let base = scratch("one-at-a-time");
7978 let folder = base.join("Sioul");
7979 let key = quick_key(&folder, "four words make a passphrase").unwrap();
7980 let desk = Computer::new(&base, "desk");
7981 desk.write("config/safe-senders.txt", "a@example.org\n");
7982 let running = exchange_lock(&desk.memory, true).unwrap();
7984 let outcome = desk.exchange(&folder, &key, NOW);
7985 assert_eq!((outcome.problems.as_slice(), outcome.sent), (&["share-busy".to_string()][..], 0));
7986 drop(running);
7987 assert_eq!(desk.exchange(&folder, &key, NOW + MINUTE).sent, 1);
7988 let memory = desk.memory.clone();
7991 let (held, release) = std::sync::mpsc::channel();
7992 let holder = std::thread::spawn(move || {
7993 let running = exchange_lock(&memory, true).unwrap();
7994 held.send(()).unwrap();
7995 std::thread::sleep(std::time::Duration::from_millis(150));
7996 drop(running);
7997 });
7998 release.recv().unwrap();
7999 desk.write("config/safe-senders.txt", "a@example.org\nb@example.org\n");
8000 let outcome = desk.exchange(&folder, &key, NOW + 2 * MINUTE);
8001 assert_eq!((outcome.problems.len(), outcome.sent), (0, 1), "{outcome:?}");
8002 holder.join().unwrap();
8003 let path = folder.join("x.toml");
8005 let (one, two) = (temporary(&path), temporary(&path));
8006 assert!(one != two && one.file_name().unwrap().to_string_lossy().starts_with('.'));
8007 let _ = std::fs::remove_dir_all(&base);
8008 }
8009
8010 #[test]
8012 fn names_one_storage_takes_for_one_are_never_both_written() {
8013 let known = |file: &str| (format!("{file}#"), Known { c: 1, w: "desk".into(), h: "h".into(), b: String::new() });
8016 let names = Names::new(&[known("files/notes/Lease.md"), known("files/notes/\u{e9}t\u{e9}.md")].into(), &Found::default());
8017 assert!(names.clash("files/notes/lease.md") && names.clash("files/notes/LEASE.md") && names.clash("files/notes/e\u{301}te\u{301}.md"));
8018 assert!(!names.clash("files/notes/Lease.md") && !names.clash("files/notes/\u{e9}t\u{e9}.md") && !names.clash("files/notes/other.md"));
8019 let base = scratch("case");
8023 let folder = base.join("Sioul");
8024 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8025 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
8026 let (dn, ln) = (base.join("desk-notes"), base.join("laptop-notes"));
8027 let apart = |one: &str, other: &str| {
8028 let probe = base.join("probe");
8029 std::fs::create_dir_all(&probe).unwrap();
8030 std::fs::write(probe.join(one), b"").unwrap();
8031 let apart = !probe.join(other).exists();
8032 std::fs::remove_dir_all(&probe).unwrap();
8033 apart
8034 };
8035 put(&dn, "other.md", b"other\n");
8036 let mut pairs = 0;
8037 for (one, other) in [("Lease.md", "lease.md"), ("\u{e9}t\u{e9}.md", "e\u{301}te\u{301}.md")] {
8038 if apart(one, other) {
8039 put(&dn, one, b"one\n");
8040 put(&dn, other, b"other way\n");
8041 pairs += 1;
8042 }
8043 }
8044 desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
8045 let outcome = laptop.exchange_with(&folder, &key, NOW + MINUTE, &ln, &["notes"]);
8046 assert_eq!(outcome.problems.iter().filter(|p| p.starts_with("share-name-clash:")).count(), pairs, "{outcome:?}");
8047 assert_eq!(files_in(&ln).len(), 1 + pairs);
8048 let config = Config { notes_root: Some("/notes".into()), ..Config::default() };
8050 let every = stores_of(&config, &laptop.roots, &|_| true);
8051 assert!(locate(&every, "files/notes/Sioul-Cases.toml").is_none() && locate(&every, "files/notes/Sioul-Projects.toml").is_none() && locate(&every, "files/notes/Papers/id.pdf").is_none());
8052 assert_eq!(kept_by_windows("aux.md"), true);
8053 assert_eq!(kept_by_windows("auxiliary.md"), false);
8054 let _ = std::fs::remove_dir_all(&base);
8055 }
8056
8057 #[test]
8059 fn a_name_with_a_hash_travels() {
8060 let base = scratch("hash-name");
8061 let folder = base.join("Sioul");
8062 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8063 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8064 let (dn, pn) = (base.join("desk-notes"), base.join("phone-notes"));
8065 put(&dn, "Issue #12.md", b"# Issue 12\n");
8066 put(&dn, "#ideas.md", b"# Ideas\n");
8067 put(&dn, "papers/Facture #123.pdf", &noise(10_000, 3));
8068 desk.write("data/drafts/a#1.toml", "subject = \"Hash\"\n");
8069 for m in 0..3 {
8070 desk.exchange_with(&folder, &key, NOW + 2 * m * MINUTE, &dn, &["notes", "papers"]);
8071 phone.exchange_with(&folder, &key, NOW + (2 * m + 1) * MINUTE, &pn, &["notes", "papers"]);
8072 }
8073 let files = files_in(&pn);
8074 assert_eq!(files.get("Issue #12.md").map(Vec::as_slice), Some(&b"# Issue 12\n"[..]), "{:?}", files.keys().collect::<Vec<_>>());
8075 assert!(files.contains_key("#ideas.md") && files.get("papers/Facture #123.pdf") == Some(&noise(10_000, 3)));
8076 assert_eq!(phone.read("data/drafts/a#1.toml"), "subject = \"Hash\"\n");
8077 assert_eq!(file_of("files/papers/Facture #123.pdf#"), "files/papers/Facture #123.pdf");
8078 assert_eq!((file_of("config/safe-senders.txt#a#b@example.org"), entry_of("config/safe-senders.txt#a#b@example.org")), ("config/safe-senders.txt", "a#b@example.org"));
8079 let _ = std::fs::remove_dir_all(&base);
8080 }
8081
8082 #[test]
8084 fn an_old_copy_does_not_roll_the_others_back() {
8085 let base = scratch("old-copy");
8086 let folder = base.join("Sioul");
8087 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8088 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
8089 let (dn, ln) = (base.join("desk-notes"), base.join("laptop-notes"));
8090 let old = std::time::UNIX_EPOCH + std::time::Duration::from_millis(NOW as u64);
8091 put(&dn, "lease.md", b"Rent: 620\n");
8092 put(&dn, "other.md", b"other\n");
8093 dated(&dn.join("lease.md"), old);
8094 desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
8095 laptop.exchange_with(&folder, &key, NOW + MINUTE, &ln, &["notes"]);
8096 put(&ln, "lease.md", b"Rent: 640\n");
8097 laptop.exchange_with(&folder, &key, NOW + 86_400_000, &ln, &["notes"]);
8098 desk.exchange_with(&folder, &key, NOW + 86_400_000 + MINUTE, &dn, &["notes"]);
8099 put(&dn, "lease.md", b"Rent: 620\n");
8101 dated(&dn.join("lease.md"), old);
8102 let outcome = desk.exchange_with(&folder, &key, NOW + 86_400_000 + 2 * MINUTE, &dn, &["notes"]);
8103 assert!(outcome.problems.iter().any(|p| p.starts_with("share-older-copy:lease (conflict ")), "{outcome:?}");
8104 assert_eq!(std::fs::read(dn.join("lease.md")).unwrap(), b"Rent: 640\n", "the version last seen came back");
8105 desk.exchange_with(&folder, &key, NOW + 86_400_000 + 3 * MINUTE, &dn, &["notes"]);
8106 laptop.exchange_with(&folder, &key, NOW + 86_400_000 + 4 * MINUTE, &ln, &["notes"]);
8107 assert_eq!(std::fs::read(ln.join("lease.md")).unwrap(), b"Rent: 640\n");
8108 assert_eq!(copies_of(&ln, "lease"), [b"Rent: 620\n".to_vec()], "the older copy kept beside, everywhere");
8109 let _ = std::fs::remove_dir_all(&base);
8110 }
8111
8112 #[test]
8114 fn a_file_being_written_waits() {
8115 let base = scratch("settling");
8116 let folder = base.join("Sioul");
8117 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8118 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
8119 let (dn, ln) = (base.join("desk-notes"), base.join("laptop-notes"));
8120 put(&dn, "keep.md", b"keep\n");
8121 dated(&dn.join("keep.md"), std::time::SystemTime::now() - std::time::Duration::from_secs(120));
8122 SETTLE.with(|settle| settle.set(60_000));
8124 put(&dn, "a.md", b"# a\n");
8125 assert_eq!(desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]).sent, 1);
8126 dated(&dn.join("a.md"), std::time::SystemTime::now() - std::time::Duration::from_secs(120));
8127 assert_eq!(desk.exchange_with(&folder, &key, NOW + MINUTE, &dn, &["notes"]).sent, 1);
8128 SETTLE.with(|settle| settle.set(0));
8129 laptop.exchange_with(&folder, &key, NOW + 2 * MINUTE, &ln, &["notes"]);
8130 std::fs::write(dn.join("a.md"), b"").unwrap();
8132 assert_eq!(desk.exchange_with(&folder, &key, NOW + 3 * MINUTE, &dn, &["notes"]).sent, 0);
8133 laptop.exchange_with(&folder, &key, NOW + 4 * MINUTE, &ln, &["notes"]);
8134 assert_eq!(std::fs::read(ln.join("a.md")).unwrap(), b"# a\n");
8135 put(&dn, "a.md", b"# a, written\n");
8136 assert_eq!(desk.exchange_with(&folder, &key, NOW + 5 * MINUTE, &dn, &["notes"]).sent, 1);
8137 let _ = std::fs::remove_dir_all(&base);
8138 }
8139
8140 #[test]
8142 fn the_doses_never_read_the_notes_memory() {
8143 let base = scratch("memory-apart");
8144 let folder = base.join("Sioul");
8145 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8146 let desk = Computer::new(&base, "desk");
8147 let dn = base.join("desk-notes");
8148 for n in 0..50 {
8149 put(&dn, &format!("note-{n}.md"), format!("# {n}\n").as_bytes());
8150 }
8151 desk.write("state/health-state.toml", "[taken]\nd1 = 1\n");
8152 desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
8153 let memory = std::fs::read_to_string(&desk.memory).unwrap();
8154 assert!(!memory.contains("files/notes/note-") && memory.contains("health-state"), "{memory}");
8155 assert!(std::fs::read_to_string(sealed_path(&desk.memory)).unwrap().contains("files/notes/note-7.md"));
8156 assert!(written(&desk.memory, &desk.id).is_some_and(|(_, n)| n > 50));
8157 std::fs::remove_file(sealed_path(&desk.memory)).unwrap();
8159 let outcome = desk.exchange_with(&folder, &key, NOW + MINUTE, &dn, &["notes"]);
8160 assert_eq!(outcome.sent, 0, "{outcome:?}");
8161 assert_eq!(files_in(&dn).len(), 50);
8162 let _ = std::fs::remove_dir_all(&base);
8163 }
8164
8165 #[test]
8167 fn what_is_said_and_left_behind() {
8168 let at = NOW;
8170 assert_eq!(conflict_name("lease", at), conflict_name("lease", at));
8171 assert!(conflict_name("lease", at).starts_with("lease (conflict 20"));
8172 let base = scratch("left-behind");
8173 let folder = base.join("Sioul");
8174 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8175 let desk = Computer::new(&base, "desk");
8176 let dn = base.join("desk-notes");
8177 put(&dn, "lease.md", b"lease\n");
8178 put(&dn, ".lease.md.1-1.sioul.tmp", b"half");
8180 dated(&dn.join(".lease.md.1-1.sioul.tmp"), std::time::SystemTime::now() - std::time::Duration::from_secs(7200));
8181 put(&dn, ".lease.md.1-2.sioul.tmp", b"being written");
8182 #[cfg(unix)]
8184 let latin = {
8185 use std::os::unix::ffi::OsStrExt;
8186 std::fs::write(dn.join(std::ffi::OsStr::from_bytes(b"caf\xe9.md")), b"latin-1").is_ok()
8187 };
8188 #[cfg(not(unix))]
8189 let latin = false;
8190 let outcome = desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
8191 assert!(!dn.join(".lease.md.1-1.sioul.tmp").exists() && dn.join(".lease.md.1-2.sioul.tmp").exists());
8192 assert_eq!(outcome.problems.iter().any(|p| p.starts_with("share-not-text:files/notes/caf")), latin, "{outcome:?}");
8193 assert_eq!(outcome.sent, 1, "{outcome:?}");
8194 let _ = std::fs::remove_dir_all(&base);
8195 }
8196
8197 #[test]
8198 fn each_device_chooses_its_parts() {
8199 let roots = Roots { config: PathBuf::from("config"), data: PathBuf::from("data"), state: PathBuf::from("state") };
8200 let config = Config { notes_root: Some("/notes".into()), ..Config::default() };
8201 let parts = |stores: &[Store]| stores.iter().map(|s| s.part).collect::<BTreeSet<_>>();
8202 assert_eq!(parts(&stores(&config, &roots)), ["calls", "drafts", "health", "lists", "senders", "settings", "spam", "time"].into());
8204 let old = Config { share_projects: true, ..config.clone() };
8205 assert!(parts(&stores(&old, &roots)).contains("projects"), "projects as the old setting said");
8206 let mut here = Here::default();
8208 here.parts.insert("notes".into(), true);
8209 here.parts.insert("health".into(), false);
8210 here.parts.insert("projects".into(), false);
8211 let chosen = stores_of(&old, &roots, &|part| here.shares(part, &old));
8212 assert!(chosen.iter().any(|s| s.name == "files/notes/") && !chosen.iter().any(|s| s.part == "health" || s.part == "projects"));
8213 let notes = chosen.iter().find(|s| s.name == "files/notes/").unwrap();
8214 assert!(["sioul-projects.toml", "sioul-cases.toml", "sioul-cases.toml.before-rename", "sioul-papers.toml", "papers"].iter().all(|n| notes.skip.contains(n)), "what other parts carry stays out of the notes");
8215 let back: Here = toml::from_str(&toml::to_string(&here).unwrap()).unwrap();
8216 assert_eq!(back.parts, here.parts);
8217 assert!(stores_of(&config, &roots, &|_| true).iter().all(|s| s.name != "state/share/here.toml"));
8218 let every = stores_of(&config, &roots, &|_| true);
8220 assert!(locate(&every, "files/notes/admin/lease.md").is_some());
8221 for file in ["files/notes/.obsidian/app.json", "files/notes/lease.md~", "files/notes/sioul-cases.toml", "files/notes/sioul-projects.toml", "files/notes/papers/id.pdf", "files/notes/a (conflicted copy).md"] {
8222 assert!(locate(&every, file).is_none(), "{file}");
8223 }
8224 assert_eq!(locate(&every, "files/papers/id.pdf").map(|(s, _)| s.part), Some("papers"));
8225 assert_eq!(shown("files/notes/admin/lease.md"), "admin/lease.md");
8226 assert_eq!(shown("files/papers/id.pdf"), "papers/id.pdf");
8227 assert_eq!(shown("config/config.toml"), "config.toml");
8228 }
8229
8230 #[test]
8235 fn the_spam_table_travels_alone_in_every_exchange() {
8236 use sioul_core::spam::features::{FEATURES, N};
8237 use sioul_core::spam::table::{self, Table};
8238 let base = scratch("spam-table");
8239 let folder = base.join("folder");
8240 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8241 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8242 let made = |bias: f32| {
8243 let mut made = Table {
8244 tokenizer: sioul_core::spam::tokenize::TOKENIZER,
8245 features: FEATURES,
8246 dim: 2,
8247 minn: 3,
8248 maxn: 6,
8249 bucket: 4,
8250 words: vec![(table::word_hash("lotteri"), 2.0)],
8251 buckets: vec![0.0; 4],
8252 weights: vec![0.0; N],
8253 means: vec![0.0; N],
8254 bias,
8255 text_mean: 0.0,
8256 platt_a: -1.0,
8257 platt_b: 0.0,
8258 meta: table::Meta { device: "desk".into(), ..table::Meta::default() },
8259 };
8260 made.sort();
8261 made
8262 };
8263 let trained = |bias: f32, second: u64| {
8265 let path = desk.path("data/spam/table.bin");
8266 made(bias).write(&path).unwrap();
8267 std::fs::File::options().write(true).open(&path).unwrap().set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(second)).unwrap();
8268 };
8269 trained(0.5, 1_789_990_000);
8271 desk.write("data/spam/language.bin", "the vocabulary in plain text: Jane, the lease, the lottery");
8272 desk.write("data/spam/corpus/home/INBOX.jsonl.gz", "Subject: the lease");
8273 let quick = |c: &Computer, now: i64| exchange(&Sharing { folder: &folder, computer: &c.id, key: &key, memory: &c.memory, files: false, hurry: None }, &stores(&Config::default(), &c.roots), now).unwrap();
8275 quick(&desk, NOW);
8276 let came = quick(&phone, NOW + MINUTE);
8277 assert!(came.written.contains(SPAM_TABLE), "{came:?}");
8278 assert_eq!(std::fs::read(phone.path("data/spam/table.bin")).unwrap(), std::fs::read(desk.path("data/spam/table.bin")).unwrap());
8279 assert!(!phone.path("data/spam/language.bin").exists() && !phone.path("data/spam/corpus").exists());
8280 assert!(!table::previous(&phone.path("data/spam/table.bin")).exists(), "nothing before the first");
8281 let mut files = Vec::new();
8283 list_files(&folder, &folder, &[], &mut files);
8284 for (name, path) in &files {
8285 let bytes = std::fs::read(path).unwrap();
8286 for plain in [&b"SIOULSPM"[..], b"vocabulary", b"lottery"] {
8287 assert!(!bytes.windows(plain.len()).any(|w| w == plain), "{name}");
8288 }
8289 }
8290 trained(1.5, 1_789_990_600);
8292 quick(&desk, NOW + 2 * MINUTE);
8293 assert!(quick(&phone, NOW + 3 * MINUTE).written.contains(SPAM_TABLE));
8294 let path = phone.path("data/spam/table.bin");
8295 assert_eq!((Table::read(&path).unwrap().bias, Table::read(&table::previous(&path)).unwrap().bias), (1.5, 0.5));
8296 let every = stores_of(&Config::default(), &desk.roots, &|_| true);
8298 for file in ["files/spam/language.bin", "files/spam/corpus/home/INBOX.jsonl.gz", "files/spam/", "data/spam/language.bin"] {
8299 assert!(locate(&every, file).is_none(), "{file}");
8300 }
8301 assert_eq!(locate(&every, SPAM_TABLE).map(|(s, p)| (s.part, p)), Some(("spam", desk.path("data/spam/table.bin"))));
8302 assert_eq!(shown(SPAM_TABLE), "spam/table.bin");
8303 let _ = std::fs::remove_dir_all(&base);
8304 }
8305
8306 #[test]
8313 fn only_the_two_newest_spam_tables_stay_in_the_folder() {
8314 let base = scratch("spam-tables");
8315 let folder = base.join("folder");
8316 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8317 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8318 let quick = |c: &Computer, now: i64| exchange(&Sharing { folder: &folder, computer: &c.id, key: &key, memory: &c.memory, files: false, hurry: None }, &stores(&Config::default(), &c.roots), now).unwrap();
8319 let stranger = folder.join("blobs").join("0f".repeat(32));
8321 put(&folder, &format!("blobs/{}", "0f".repeat(32)), b"another device's");
8322 let tables = || std::fs::read_dir(folder.join("blobs")).unwrap().filter_map(Result::ok).filter(|e| !e.file_name().to_string_lossy().starts_with('.') && e.path() != stranger).count();
8323 let trained = |n: u64| {
8325 let path = desk.path("data/spam/table.bin");
8326 put(path.parent().unwrap(), "table.bin", &noise(4096, n as u32));
8327 std::fs::File::options().write(true).open(&path).unwrap().set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_789_990_000 + n * 600)).unwrap();
8328 };
8329 let mut now = NOW;
8330 for n in 1..=4 {
8331 trained(n);
8332 now += MINUTE;
8333 quick(&desk, now);
8334 now += MINUTE;
8335 let came = quick(&phone, now);
8336 assert!(came.problems.is_empty(), "{came:?}");
8337 assert_eq!(std::fs::read(phone.path("data/spam/table.bin")).unwrap(), noise(4096, n as u32));
8338 assert_eq!(tables(), n.min(2) as usize, "after training {n}");
8339 }
8340 assert_eq!(Memory::load_all(&desk.memory, &desk.id).sealed.tables.len(), 2);
8341 for n in 5..=7 {
8343 trained(n);
8344 now += MINUTE;
8345 quick(&desk, now);
8346 }
8347 assert_eq!(tables(), 2);
8348 now += MINUTE;
8349 let came = quick(&phone, now);
8350 assert!(came.problems.is_empty() && came.pending == 0, "{came:?}");
8351 assert_eq!(std::fs::read(phone.path("data/spam/table.bin")).unwrap(), noise(4096, 7));
8352 let came = quick(&phone, now + 2 * DAY);
8353 assert!(came.problems.is_empty() && came.pending == 0, "nothing missing, a day on: {came:?}");
8354 assert!(stranger.exists(), "another device's file stays");
8355 let _ = std::fs::remove_dir_all(&base);
8356 }
8357
8358 #[test]
8363 fn a_label_on_one_device_reaches_the_others() {
8364 use sioul_core::spam::labels::{self, Entry, Label, Moved, Source};
8365 let base = scratch("spam-labels");
8366 let folder = base.join("folder");
8367 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8368 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8369 let quick = |c: &Computer, now: i64| exchange(&Sharing { folder: &folder, computer: &c.id, key: &key, memory: &c.memory, files: false, hurry: None }, &stores(&Config::default(), &c.roots), now).unwrap();
8370 let spam_state = |c: &Computer| c.roots.state.join("spam");
8371 let mut card = sioul_core::card::Card::from_bytes(b"From: Prize <win@lottery.test>\r\nMessage-ID: <win-1@lottery.test>\r\nSubject: You won\r\n\r\nClaim it.\r\n").unwrap();
8373 card.account = Some("home".into());
8374 card.origin = Some(sioul_core::card::ImapOrigin { validity: 7, uid: 42 });
8375 assert!(!labels::said_ham_in(&labels::read_all_in(&spam_state(&desk)), &card));
8376 let said = Entry { at: 1_791_360_000, account: "home".into(), folder: "INBOX".into(), uidvalidity: 7, uid: 42, message_id: Some("win-1@lottery.test".into()), label: Label::Ham, source: Source::NotSpam };
8378 labels::append_to(&labels::own_log(&spam_state(&phone), &phone.id), &said).unwrap();
8379 let moved = Moved { at: 1_791_360_100, account: "home".into(), folder: "INBOX".into(), uidvalidity: 7, uid: 43, message_id: Some("deal-2@shop.test".into()), class: sioul_core::spam::Class::Spam };
8380 labels::append_to(&labels::own_moved_log(&spam_state(&phone), &phone.id), &moved).unwrap();
8381 let flagged = Moved { at: 1_791_360_200, uid: 44, message_id: Some("flag-3@shop.test".into()), class: sioul_core::spam::Class::Unsure, ..moved.clone() };
8383 labels::append_to(&labels::own_flagged_log(&spam_state(&phone), &phone.id), &flagged).unwrap();
8384 quick(&phone, NOW);
8385 let came = quick(&desk, NOW + MINUTE);
8386 let theirs = format!("{SPAM_LABELS}{}.jsonl", phone.id);
8387 assert!(came.written.contains(SPAM_LABELS) && came.written.contains(SPAM_MOVED) && came.written.contains(SPAM_FLAGGED) && came.received == 3, "{came:?}");
8388 assert!(desk.roots.state.join("spam/labels").join(format!("{}.jsonl", phone.id)).exists(), "the phone's own file, beside the desk's");
8389 assert!(labels::said_ham_in(&labels::read_all_in(&spam_state(&desk)), &card));
8391 assert_eq!(labels::read_moved_in(&spam_state(&desk)), vec![moved]);
8392 assert_eq!(labels::read_flagged_in(&spam_state(&desk)), vec![flagged]);
8393 let later = Entry { at: 1_791_360_500, label: Label::Spam, source: Source::Junk, ..said.clone() };
8395 labels::append_to(&labels::own_log(&spam_state(&desk), &desk.id), &later).unwrap();
8396 quick(&desk, NOW + 2 * MINUTE);
8397 quick(&phone, NOW + 3 * MINUTE);
8398 for device in [&desk, &phone] {
8399 let all = labels::read_all_in(&spam_state(device));
8400 assert_eq!(all.len(), 2);
8401 assert!(!labels::said_ham_in(&all, &card));
8402 }
8403 assert_eq!(labels::read_from::<Entry>(&labels::own_log(&spam_state(&phone), &phone.id)), vec![said]);
8405 let mut files = Vec::new();
8407 list_files(&folder, &folder, &[], &mut files);
8408 for (name, path) in &files {
8409 let bytes = std::fs::read(path).unwrap();
8410 for plain in [&b"win-1@lottery.test"[..], b"not-spam", b"deal-2", b"flag-3"] {
8411 assert!(!bytes.windows(plain.len()).any(|w| w == plain), "{name}");
8412 }
8413 }
8414 let every = stores_of(&Config::default(), &desk.roots, &|_| true);
8415 assert_eq!(locate(&every, &theirs).map(|(s, _)| s.part), Some("spam"));
8416 let _ = std::fs::remove_dir_all(&base);
8417 }
8418
8419 fn declined(at: i64, key: &str) -> sioul_core::calls::Held {
8421 sioul_core::calls::Held { at, key: key.into(), number: key.into(), who: "stranger".into(), name: "Cabinet du Dr Martin".into(), column: "sleep".into(), why: "matrix".into(), ..sioul_core::calls::Held::default() }
8422 }
8423
8424 fn calls_of(c: &Computer) -> PathBuf {
8426 c.roots.state.join(sioul_core::calls::FOLDER)
8427 }
8428
8429 #[test]
8434 fn a_phone_s_calls_reach_the_desk_and_seen_comes_back() {
8435 use sioul_core::calls;
8436 let base = scratch("calls");
8437 let folder = base.join("folder");
8438 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8439 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8440 let own = calls::own_file(&calls_of(&phone), calls::LOG, &phone.id);
8441 assert_eq!(calls::carry_into(&own, &[declined(NOW - 3_600_000, "+33199001234")], None, NOW).unwrap(), 1);
8442 phone.exchange(&folder, &key, NOW);
8443 let came = desk.exchange(&folder, &key, NOW + MINUTE);
8444 assert!(came.written.contains(CALLS_LOG), "{came:?}");
8445 let on_desk = calls::read_logs(&calls_of(&desk), NOW + MINUTE);
8446 assert_eq!(on_desk.iter().map(|h| (h.device.as_str(), h.key.as_str(), h.rang, h.name.as_str())).collect::<Vec<_>>(), [(phone.id.as_str(), "+33199001234", false, "Cabinet du Dr Martin")]);
8447 calls::mark_seen(&calls_of(&desk), &desk.id, &[on_desk[0].id()], NOW + 2 * MINUTE).unwrap();
8449 desk.exchange(&folder, &key, NOW + 3 * MINUTE);
8450 let came = phone.exchange(&folder, &key, NOW + 4 * MINUTE);
8451 assert!(came.written.contains(CALLS_SEEN), "{came:?}");
8452 assert!(calls::read_seen(&calls_of(&phone)).seen.contains(&on_desk[0].id()));
8453 assert!(!calls::own_file(&calls_of(&phone), calls::SEEN_LOG, &phone.id).exists());
8455 assert!(!calls::own_file(&calls_of(&desk), calls::LOG, &desk.id).exists());
8456 let mut files = Vec::new();
8458 list_files(&folder, &folder, &[], &mut files);
8459 for (name, path) in &files {
8460 let bytes = std::fs::read(path).unwrap();
8461 for plain in [&b"199001234"[..], b"Cabinet", b"stranger"] {
8462 assert!(!bytes.windows(plain.len()).any(|w| w == plain), "{name}");
8463 }
8464 }
8465 let every = stores_of(&Config::default(), &desk.roots, &|_| true);
8466 assert_eq!(locate(&every, &format!("{CALLS_LOG}{}.jsonl", phone.id)).map(|(s, _)| s.part), Some("calls"));
8467 for c in [&desk, &phone] {
8468 let small = Memory::load(&c.memory, &c.id);
8470 assert!(small.entries.keys().chain(small.files.keys()).chain(small.pending.keys()).all(|k| !calls_store(k)), "memory.json stays small");
8471 assert!(Memory::load_all(&c.memory, &c.id).entries.keys().any(|k| calls_store(k)));
8472 let share = c.memory.parent().unwrap();
8474 let mut kept = Vec::new();
8475 list_files(share, share, &[], &mut kept);
8476 assert!(kept.iter().all(|(name, _)| !name.contains("calls")), "{kept:?}");
8477 }
8478 std::fs::remove_file(sealed_path(&desk.memory)).unwrap();
8480 let again = desk.exchange(&folder, &key, NOW + 5 * MINUTE);
8481 assert_eq!(again.sent, 0, "{again:?}");
8482 assert_eq!(calls::read_logs(&calls_of(&desk), NOW + 5 * MINUTE).len(), 1);
8483 let _ = std::fs::remove_dir_all(&base);
8484 }
8485
8486 #[test]
8488 fn several_phones_each_keep_their_own_log() {
8489 use sioul_core::calls;
8490 let base = scratch("calls-phones");
8491 let folder = base.join("folder");
8492 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8493 let (desk, a, b) = (Computer::new(&base, "desk"), Computer::new(&base, "phone-a"), Computer::new(&base, "phone-b"));
8494 calls::carry_into(&calls::own_file(&calls_of(&a), calls::LOG, &a.id), &[declined(NOW - 7_200_000, "+33199001234")], None, NOW).unwrap();
8495 calls::carry_into(&calls::own_file(&calls_of(&b), calls::LOG, &b.id), &[declined(NOW - 3_600_000, "+33465710042")], None, NOW).unwrap();
8496 for (n, c) in [&a, &b, &desk, &a, &b].iter().enumerate() {
8497 c.exchange(&folder, &key, NOW + n as i64 * MINUTE);
8498 }
8499 for c in [&desk, &a, &b] {
8500 let heard = calls::read_logs(&calls_of(c), NOW + 10 * MINUTE);
8501 assert_eq!(heard.iter().map(|h| (h.device.clone(), h.key.clone())).collect::<Vec<_>>(), [(a.id.clone(), "+33199001234".to_string()), (b.id.clone(), "+33465710042".to_string())]);
8502 }
8503 let _ = std::fs::remove_dir_all(&base);
8504 }
8505
8506 #[test]
8509 fn an_older_sioul_keeps_the_calls_waiting() {
8510 use sioul_core::calls;
8511 let base = scratch("calls-older");
8512 let folder = base.join("folder");
8513 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8514 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8515 let own = calls::own_file(&calls_of(&phone), calls::LOG, &phone.id);
8516 calls::carry_into(&own, &[declined(NOW - 3_600_000, "+33199001234")], None, NOW).unwrap();
8517 phone.exchange(&folder, &key, NOW);
8518 let older_stores = stores(&Config::default(), &desk.roots).into_iter().filter(|s| !calls_store(&s.name)).collect::<Vec<_>>();
8520 OLDER.with(|older| *older.borrow_mut() = vec![CALLS_LOG.to_string(), CALLS_SEEN.to_string()]);
8521 for n in 1..=3 {
8522 let outcome = exchange(&Sharing { folder: &folder, computer: &desk.id, key: &key, memory: &desk.memory, files: true, hurry: None }, &older_stores, NOW + n * MINUTE).unwrap();
8523 assert_eq!(outcome.pending, 1, "kept waiting: {outcome:?}");
8524 }
8525 assert!(!calls_of(&desk).exists(), "nothing written");
8526 phone.exchange(&folder, &key, NOW + 4 * MINUTE);
8527 assert_eq!(calls::read_held(&own).len(), 1, "nothing taken out");
8528 OLDER.with(|older| older.borrow_mut().clear());
8530 let came = desk.exchange(&folder, &key, NOW + 5 * MINUTE);
8531 assert!(came.written.contains(CALLS_LOG) && came.pending == 0, "{came:?}");
8532 assert_eq!(calls::read_logs(&calls_of(&desk), NOW + 5 * MINUTE).len(), 1);
8533 let _ = std::fs::remove_dir_all(&base);
8534 }
8535
8536 #[test]
8538 fn the_calls_part_switched_off_receives_nothing_and_keeps_nothing_waiting() {
8539 use sioul_core::calls;
8540 let base = scratch("calls-off");
8541 let folder = base.join("folder");
8542 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8543 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8544 calls::carry_into(&calls::own_file(&calls_of(&phone), calls::LOG, &phone.id), &[declined(NOW - 3_600_000, "+33199001234")], None, NOW).unwrap();
8545 phone.exchange(&folder, &key, NOW);
8546 let off = stores_of(&Config::default(), &desk.roots, &|part| part != "calls" && shared_by_default(part, &Config::default()));
8547 let outcome = exchange(&Sharing { folder: &folder, computer: &desk.id, key: &key, memory: &desk.memory, files: true, hurry: None }, &off, NOW + MINUTE).unwrap();
8548 assert_eq!(outcome.pending, 0, "{outcome:?}");
8549 assert!(!calls_of(&desk).exists());
8550 let came = desk.exchange(&folder, &key, NOW + 2 * MINUTE);
8552 assert!(came.written.contains(CALLS_LOG), "{came:?}");
8553 let _ = std::fs::remove_dir_all(&base);
8554 }
8555
8556 #[test]
8558 fn a_trimmed_line_leaves_every_device() {
8559 use sioul_core::calls;
8560 let base = scratch("calls-trim");
8561 let folder = base.join("folder");
8562 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8563 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8564 let own = calls::own_file(&calls_of(&phone), calls::LOG, &phone.id);
8565 calls::carry_into(&own, &[declined(NOW - 3_600_000, "+33199001234")], None, NOW).unwrap();
8566 phone.exchange(&folder, &key, NOW);
8567 desk.exchange(&folder, &key, NOW + MINUTE);
8568 assert_eq!(calls::read_held(&calls::own_file(&calls_of(&desk), calls::LOG, &phone.id)).len(), 1);
8569 let later = NOW + (calls::KEPT_DAYS + 1) * 86_400_000;
8570 assert_eq!(calls::trim_own(&calls_of(&phone), &phone.id, later).unwrap(), 1);
8571 phone.exchange(&folder, &key, later);
8572 desk.exchange(&folder, &key, later + MINUTE);
8573 assert!(calls::read_held(&calls::own_file(&calls_of(&desk), calls::LOG, &phone.id)).is_empty(), "gone from the desk's copy too");
8574 let _ = std::fs::remove_dir_all(&base);
8575 }
8576
8577 fn texted(at: i64) -> sioul_core::phonemsgs::Line {
8579 sioul_core::phonemsgs::Line {
8580 at,
8581 id: format!("{at}-0123456789abcdef"),
8582 app: "foundation.e.message".into(),
8583 label: "Message".into(),
8584 kind: "text".into(),
8585 name: "Cabinet du Dr Martin".into(),
8586 key: "+33199001234".into(),
8587 who: "neutral".into(),
8588 text: "Votre rendez-vous est déplacé à jeudi 10 h 30.".into(),
8589 shows: at,
8590 ..sioul_core::phonemsgs::Line::default()
8591 }
8592 }
8593
8594 fn messages_of(c: &Computer) -> PathBuf {
8596 c.roots.state.join(sioul_core::phonemsgs::FOLDER)
8597 }
8598
8599 #[test]
8603 fn a_phone_s_messages_reach_the_desk_sealed_and_seen_comes_back() {
8604 use sioul_core::{calls, phonemsgs};
8605 let base = scratch("phone-messages");
8606 let (folder, notes) = (base.join("folder"), base.join("notes"));
8607 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8608 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8609 let on = [phonemsgs::PART];
8610 let own = calls::own_file(&messages_of(&phone), phonemsgs::LOG, &phone.id);
8611 assert_eq!(phonemsgs::carry_into(&own, &[texted(NOW - 3_600_000)], NOW).unwrap(), 1);
8612 assert!(std::fs::read_to_string(&own).unwrap().lines().all(|l| l.len() % phonemsgs::PAD_STEP == 0), "padded");
8613 phone.exchange_with(&folder, &key, NOW, ¬es, &on);
8614 let came = desk.exchange_with(&folder, &key, NOW + MINUTE, ¬es, &on);
8615 assert!(came.written.contains(PHONE_MESSAGES_LOG), "{came:?}");
8616 let on_desk = phonemsgs::read_logs(&messages_of(&desk), NOW + MINUTE);
8617 assert_eq!(on_desk.iter().map(|l| (l.device.as_str(), l.key.as_str(), l.text.as_str())).collect::<Vec<_>>(), [(phone.id.as_str(), "+33199001234", "Votre rendez-vous est déplacé à jeudi 10 h 30.")]);
8618 calls::mark_seen(&messages_of(&desk), &desk.id, &[on_desk[0].id.clone()], NOW + 2 * MINUTE).unwrap();
8620 desk.exchange_with(&folder, &key, NOW + 3 * MINUTE, ¬es, &on);
8621 let came = phone.exchange_with(&folder, &key, NOW + 4 * MINUTE, ¬es, &on);
8622 assert!(came.written.contains(PHONE_MESSAGES_SEEN), "{came:?}");
8623 assert!(calls::read_seen(&messages_of(&phone)).seen.contains(&on_desk[0].id));
8624 let mut files = Vec::new();
8626 list_files(&folder, &folder, &[], &mut files);
8627 for (name, path) in &files {
8628 let bytes = std::fs::read(path).unwrap();
8629 for plain in [&b"199001234"[..], b"Cabinet", b"rendez-vous", b"neutral"] {
8630 assert!(!bytes.windows(plain.len()).any(|w| w == plain), "{name}");
8631 }
8632 }
8633 let every = stores_of(&Config::default(), &desk.roots, &|_| true);
8634 assert_eq!(locate(&every, &format!("{PHONE_MESSAGES_LOG}{}.jsonl", phone.id)).map(|(s, _)| s.part), Some(phonemsgs::PART));
8635 for c in [&desk, &phone] {
8636 let small = Memory::load(&c.memory, &c.id);
8637 assert!(small.entries.keys().chain(small.files.keys()).chain(small.pending.keys()).all(|k| !calls_store(k)), "memory.json stays small");
8638 let share = c.memory.parent().unwrap();
8639 let mut kept = Vec::new();
8640 list_files(share, share, &[], &mut kept);
8641 assert!(kept.iter().all(|(name, _)| !name.contains("phone-messages")), "{kept:?}");
8642 }
8643 std::fs::remove_file(sealed_path(&desk.memory)).unwrap();
8645 let again = desk.exchange_with(&folder, &key, NOW + 5 * MINUTE, ¬es, &on);
8646 assert_eq!(again.sent, 0, "{again:?}");
8647 let _ = std::fs::remove_dir_all(&base);
8648 }
8649
8650 #[test]
8654 fn the_phone_messages_part_is_off_until_turned_on_and_off_receives_nothing() {
8655 use sioul_core::{calls, phonemsgs};
8656 let base = scratch("phone-messages-off");
8657 let (folder, notes) = (base.join("folder"), base.join("notes"));
8658 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8659 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8660 assert!(!shared_by_default(phonemsgs::PART, &Config::default()));
8661 assert!(stores(&Config::default(), &desk.roots).iter().all(|s| s.part != phonemsgs::PART));
8662 phonemsgs::carry_into(&calls::own_file(&messages_of(&phone), phonemsgs::LOG, &phone.id), &[texted(NOW - 3_600_000)], NOW).unwrap();
8663 phone.exchange(&folder, &key, NOW);
8665 desk.exchange_with(&folder, &key, NOW + MINUTE, ¬es, &[phonemsgs::PART]);
8666 assert!(phonemsgs::read_logs(&messages_of(&desk), NOW + MINUTE).is_empty(), "the phone's part off");
8667 phone.exchange_with(&folder, &key, NOW + 2 * MINUTE, ¬es, &[phonemsgs::PART]);
8669 let outcome = desk.exchange(&folder, &key, NOW + 3 * MINUTE);
8670 assert_eq!(outcome.pending, 0, "{outcome:?}");
8671 assert!(!messages_of(&desk).join(phonemsgs::LOG).exists());
8672 let came = desk.exchange_with(&folder, &key, NOW + 4 * MINUTE, ¬es, &[phonemsgs::PART]);
8674 assert!(came.written.contains(PHONE_MESSAGES_LOG), "{came:?}");
8675 assert_eq!(phonemsgs::read_logs(&messages_of(&desk), NOW + 4 * MINUTE).len(), 1);
8676 let _ = std::fs::remove_dir_all(&base);
8677 }
8678
8679 #[test]
8681 fn a_trimmed_line_of_phone_messages_leaves_every_device() {
8682 use sioul_core::{calls, phonemsgs};
8683 let base = scratch("phone-messages-trim");
8684 let (folder, notes) = (base.join("folder"), base.join("notes"));
8685 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8686 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8687 let on = [phonemsgs::PART];
8688 let own = calls::own_file(&messages_of(&phone), phonemsgs::LOG, &phone.id);
8689 phonemsgs::carry_into(&own, &[texted(NOW - 3_600_000)], NOW).unwrap();
8690 phone.exchange_with(&folder, &key, NOW, ¬es, &on);
8691 desk.exchange_with(&folder, &key, NOW + MINUTE, ¬es, &on);
8692 let theirs = calls::own_file(&messages_of(&desk), phonemsgs::LOG, &phone.id);
8693 assert_eq!(phonemsgs::read_file(&theirs).len(), 1);
8694 let later = NOW + phonemsgs::KEPT_DAYS * 86_400_000;
8695 assert_eq!(phonemsgs::trim_own(&messages_of(&phone), &phone.id, later).unwrap(), 1);
8696 phone.exchange_with(&folder, &key, later, ¬es, &on);
8697 desk.exchange_with(&folder, &key, later + MINUTE, ¬es, &on);
8698 assert!(phonemsgs::read_file(&theirs).is_empty(), "gone from the desk's copy too");
8699 let _ = std::fs::remove_dir_all(&base);
8700 }
8701
8702 #[test]
8705 fn an_older_sioul_keeps_the_phone_s_messages_waiting() {
8706 use sioul_core::{calls, phonemsgs};
8707 let base = scratch("phone-messages-older");
8708 let (folder, notes) = (base.join("folder"), base.join("notes"));
8709 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8710 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8711 let on = [phonemsgs::PART];
8712 phonemsgs::carry_into(&calls::own_file(&messages_of(&phone), phonemsgs::LOG, &phone.id), &[texted(NOW - 3_600_000)], NOW).unwrap();
8713 phone.exchange_with(&folder, &key, NOW, ¬es, &on);
8714 let older_stores = desk.stores_with(¬es, &|part| part == phonemsgs::PART).into_iter().filter(|s| s.part != phonemsgs::PART).collect::<Vec<_>>();
8716 OLDER.with(|older| *older.borrow_mut() = vec![PHONE_MESSAGES_LOG.to_string(), PHONE_MESSAGES_SEEN.to_string()]);
8717 for n in 1..=2 {
8718 let outcome = exchange(&Sharing { folder: &folder, computer: &desk.id, key: &key, memory: &desk.memory, files: true, hurry: None }, &older_stores, NOW + n * MINUTE).unwrap();
8719 assert_eq!(outcome.pending, 1, "kept waiting: {outcome:?}");
8720 }
8721 assert!(!messages_of(&desk).exists(), "nothing written");
8722 OLDER.with(|older| older.borrow_mut().clear());
8723 let came = desk.exchange_with(&folder, &key, NOW + 3 * MINUTE, ¬es, &on);
8724 assert!(came.written.contains(PHONE_MESSAGES_LOG) && came.pending == 0, "{came:?}");
8725 let _ = std::fs::remove_dir_all(&base);
8726 }
8727
8728 fn texts_of(c: &Computer) -> PathBuf {
8732 c.roots.data.join(sioul_core::texts::FOLDER)
8733 }
8734
8735 fn to_send(key: &str, phone: &str, written: i64) -> sioul_core::texts::Request {
8737 sioul_core::texts::Request { key: key.into(), phone: phone.into(), to: "01 99 00 12 34".into(), body: "J'arrive dans dix minutes.".into(), sub: -1, written, ahead: 0, again: String::new(), device: String::new() }
8738 }
8739
8740 fn phone_step(phone: &Computer, ledger: &Path, seal: &crate::textseal::TextSeal, now: i64) -> Vec<String> {
8743 use sioul_core::texts;
8744 let book = texts::Ledger::open(ledger, now).unwrap();
8745 let mut sent = Vec::new();
8746 for r in texts::read_requests(&texts_of(phone), seal) {
8747 if texts::verdict(&r, &phone.id, now, &book, None) == texts::Verdict::Send {
8748 texts::record(ledger, &texts::Claim { key: r.key.clone(), at: now, state: "claimed".into(), ..texts::Claim::default() }).unwrap();
8749 let outcome = texts::Outcome { key: r.key.clone(), state: "sent".into(), at: now, row: "sms-412".into(), ..texts::Outcome::default() };
8750 texts::append(&texts::own_file(&texts_of(phone), texts::OUTCOME, &phone.id), &[outcome], seal, None).unwrap();
8751 sent.push(r.key);
8752 }
8753 }
8754 sent
8755 }
8756
8757 #[test]
8762 fn a_text_written_on_the_desk_is_sent_once_by_the_phone_and_its_outcome_comes_back() {
8763 use sioul_core::texts::{self, Sealer};
8764 let base = scratch("texts");
8765 let (folder, notes, ledger) = (base.join("folder"), base.join("notes"), base.join("phone-private"));
8766 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8767 let seal = crate::textseal::TextSeal::new(&key);
8768 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8769 let on = [texts::PART];
8770 texts::Ledger::open(&ledger, NOW - MINUTE).unwrap();
8771 let request = to_send("0123456789abcdef0123456789abcdef", &phone.id, NOW);
8772 texts::append(&texts::own_file(&texts_of(&desk), texts::SEND, &desk.id), std::slice::from_ref(&request), &seal, None).unwrap();
8773 desk.exchange_with(&folder, &key, NOW, ¬es, &on);
8774 let came = phone.exchange_with(&folder, &key, NOW + MINUTE, ¬es, &on);
8775 assert!(came.written.contains(TEXTS_SEND), "{came:?}");
8776 assert_eq!(phone_step(&phone, &ledger, &seal, NOW + MINUTE), [request.key.clone()]);
8777 phone.exchange_with(&folder, &key, NOW + 2 * MINUTE, ¬es, &on);
8778 let came = desk.exchange_with(&folder, &key, NOW + 3 * MINUTE, ¬es, &on);
8779 assert!(came.written.contains(TEXTS_OUTCOME), "{came:?}");
8780 let outcomes = texts::read_outcomes(&texts_of(&desk), &seal);
8781 assert_eq!(texts::state_of(&request.key, &outcomes).map(|o| o.state.as_str()), Some("sent"));
8782 for root in [&folder, &desk.roots.data, &phone.roots.data] {
8784 let mut files = Vec::new();
8785 list_files(root, root, &[], &mut files);
8786 for (name, path) in &files {
8787 let bytes = std::fs::read(path).unwrap();
8788 for plain in [&b"199001234"[..], b"dix minutes", b"sms-412"] {
8789 assert!(!bytes.windows(plain.len()).any(|w| w == plain), "{name}");
8790 }
8791 }
8792 }
8793 assert!(seal.open(std::fs::read_to_string(texts::own_file(&texts_of(&phone), texts::SEND, &desk.id)).unwrap().lines().next().unwrap()).is_some());
8794 for c in [&desk, &phone] {
8795 let small = Memory::load(&c.memory, &c.id);
8796 assert!(small.entries.keys().chain(small.files.keys()).all(|k| !calls_store(k)), "memory.json stays small");
8797 }
8798 std::fs::remove_file(sealed_path(&phone.memory)).unwrap();
8800 phone.exchange_with(&folder, &key, NOW + 4 * MINUTE, ¬es, &on);
8801 assert!(phone_step(&phone, &ledger, &seal, NOW + 4 * MINUTE).is_empty());
8802 let _ = std::fs::remove_dir_all(&base);
8803 }
8804
8805 #[test]
8808 fn the_texts_part_is_off_until_turned_on() {
8809 use sioul_core::texts;
8810 assert!(!shared_by_default(texts::PART, &Config::default()));
8811 let base = scratch("texts-off");
8812 let (folder, notes) = (base.join("folder"), base.join("notes"));
8813 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8814 let seal = crate::textseal::TextSeal::new(&key);
8815 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8816 assert!(stores(&Config::default(), &desk.roots).iter().all(|s| s.part != texts::PART));
8817 let text = texts::Text { id: "sms-1".into(), at: NOW - MINUTE, direction: "in".into(), with: vec!["+33199001234".into()], body: "Bonjour".into(), ..texts::Text::default() };
8818 texts::append(&texts::own_file(&texts_of(&phone), texts::LOG, &phone.id), &[text], &seal, None).unwrap();
8819 phone.exchange_with(&folder, &key, NOW, ¬es, &[texts::PART]);
8820 let outcome = desk.exchange(&folder, &key, NOW + MINUTE);
8821 assert_eq!(outcome.pending, 0, "{outcome:?}");
8822 assert!(!texts_of(&desk).exists());
8823 let came = desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, ¬es, &[texts::PART]);
8824 assert!(came.written.contains(TEXTS_LOG), "{came:?}");
8825 assert_eq!(texts::read_logs(&texts_of(&desk), &seal).len(), 1);
8826 let _ = std::fs::remove_dir_all(&base);
8827 }
8828
8829 #[test]
8833 fn the_texts_archive_only_grows_and_a_deletion_is_marked_everywhere() {
8834 use sioul_core::texts;
8835 let base = scratch("texts-archive");
8836 let (folder, notes) = (base.join("folder"), base.join("notes"));
8837 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8838 let seal = crate::textseal::TextSeal::new(&key);
8839 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8840 let on = [texts::PART];
8841 let own = texts::own_file(&texts_of(&phone), texts::LOG, &phone.id);
8842 let id = |t: &texts::Text| t.line_id();
8843 let text = texts::Text { id: "sms-1".into(), at: NOW - 3 * 365 * 86_400_000, direction: "in".into(), with: vec!["+33199001234".into()], body: "Bonjour".into(), ..texts::Text::default() };
8844 texts::append(&own, std::slice::from_ref(&text), &seal, Some(&id)).unwrap();
8845 phone.exchange_with(&folder, &key, NOW, ¬es, &on);
8846 desk.exchange_with(&folder, &key, NOW + MINUTE, ¬es, &on);
8847 let later = NOW + 400 * 86_400_000;
8848 assert_eq!(texts::trim_own(&texts_of(&phone), &phone.id, &seal, later).unwrap() + texts::trim_others(&texts_of(&desk), &desk.id, &seal, later).unwrap(), 0);
8849 texts::append(&own, &[texts::Text { deleted: later, body: String::new(), ..text }], &seal, Some(&id)).unwrap();
8850 phone.exchange_with(&folder, &key, later, ¬es, &on);
8851 desk.exchange_with(&folder, &key, later + MINUTE, ¬es, &on);
8852 let kept = texts::read_logs(&texts_of(&desk), &seal);
8853 assert_eq!(kept.iter().map(|t| (t.body.as_str(), t.deleted > 0)).collect::<Vec<_>>(), [("Bonjour", true)]);
8854 let _ = std::fs::remove_dir_all(&base);
8855 }
8856
8857 #[test]
8862 fn the_first_texts_build_s_records_are_passed_over() {
8863 let key = |folder: &str| format!("{folder}phone.jsonl#a line");
8865 assert!(retired(&key("state/texts/log/")));
8866 assert!(!retired(&key(TEXTS_LOG)));
8867 }
8868
8869 #[test]
8873 fn an_idle_exchange_writes_nothing_with_ten_thousand_texts() {
8874 use sioul_core::texts;
8875 let base = scratch("texts-idle");
8876 let (folder, notes) = (base.join("folder"), base.join("notes"));
8877 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8878 let seal = crate::textseal::TextSeal::new(&key);
8879 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8880 let on = [texts::PART];
8881 let own = texts::own_file(&texts_of(&phone), texts::LOG, &phone.id);
8882 for batch in 0..20 {
8883 let lines: Vec<texts::Text> = (0..texts::BATCH)
8884 .map(|i| {
8885 let n = batch * texts::BATCH + i;
8886 texts::Text { id: format!("sms-{n}"), at: NOW - 400 * 86_400_000 + n as i64 * MINUTE, direction: "in".into(), with: vec!["+33199001234".into()], body: format!("Message {n}, about as long as a text usually is, give or take."), ..texts::Text::default() }
8887 })
8888 .collect();
8889 texts::append(&own, &lines, &seal, None).unwrap();
8890 }
8891 desk.write("data/spam/table.bin", "a table of the desk's training");
8894 phone.exchange_with(&folder, &key, NOW, ¬es, &on);
8895 desk.exchange_with(&folder, &key, NOW + MINUTE, ¬es, &on);
8896 desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, ¬es, &on);
8897 assert!(!Memory::load_all(&desk.memory, &desk.id).sealed.mine.is_empty(), "the desk sealed its table");
8898 let sealed = sealed_path(&desk.memory);
8899 let (size, memory_size) = (std::fs::metadata(&sealed).unwrap().len(), std::fs::metadata(&desk.memory).unwrap().len());
8900 let stamp = |p: &Path| std::fs::metadata(p).unwrap().modified().unwrap();
8901 let before = stamp(&sealed);
8902 std::thread::sleep(std::time::Duration::from_millis(20));
8903 for minute in 3..33 {
8905 desk.exchange_with(&folder, &key, NOW + minute * MINUTE, ¬es, &on);
8906 }
8907 eprintln!("ten thousand texts: files.json {size} bytes, memory.json {memory_size} bytes; the phone's log {} bytes", std::fs::metadata(&own).unwrap().len());
8908 assert_eq!(stamp(&sealed), before, "files.json rewritten by idle exchanges");
8909 assert!(std::fs::metadata(&desk.memory).unwrap().len() < 64 << 10, "memory.json stays small");
8910 assert!(size < 2 * std::fs::metadata(&own).unwrap().len(), "the texts' lines not kept twice: {size}");
8911 let _ = std::fs::remove_dir_all(&base);
8912 }
8913
8914 #[test]
8915 fn a_first_import_goes_in_batches_and_never_holds_back_the_doses() {
8916 use sioul_core::texts;
8917 let base = scratch("texts-import");
8918 let (folder, notes) = (base.join("folder"), base.join("notes"));
8919 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8920 let seal = crate::textseal::TextSeal::new(&key);
8921 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8922 let on = [texts::PART];
8923 let own = texts::own_file(&texts_of(&phone), texts::LOG, &phone.id);
8924 let picture = vec![7u8; 512 * 1024];
8925 let source = base.join("picture.bin");
8926 std::fs::write(&source, &picture).unwrap();
8927 let (hash, size) = crate::blobs::hash_file(&source).unwrap();
8928 for batch in 0..6 {
8929 let now = NOW + batch * 10 * MINUTE;
8930 phone.write("state/health-state.toml", &format!("[taken]\n\"dose@{batch}\" = {}\n", now / 1000));
8932 phone.write("state/do-not-disturb.toml", &format!("[device.{}]\npressed = {now}\n", phone.id));
8933 let lines: Vec<texts::Text> = (0..texts::BATCH)
8934 .map(|i| {
8935 let n = batch as usize * texts::BATCH + i;
8936 let mut text = texts::Text { id: format!("sms-{n}"), at: NOW - 400 * 86_400_000 + n as i64 * MINUTE, direction: "in".into(), with: vec!["+33199001234".into()], body: format!("Message {n}"), ..texts::Text::default() };
8937 if i == 0 {
8938 text.parts = vec![texts::Part { seq: 0, ct: "image/png".into(), size, hash: hash.clone(), state: "here".into(), ..texts::Part::default() }];
8939 }
8940 text
8941 })
8942 .collect();
8943 crate::blobs::put(&folder, &key, &source, &hash).unwrap();
8944 texts::append(&own, &lines, &seal, None).unwrap();
8945 let sent = phone.exchange_with(&folder, &key, now, ¬es, &on);
8946 assert!(sent.sent <= texts::BATCH + 10, "a batch at a time: {sent:?}");
8947 desk.exchange_with(&folder, &key, now + MINUTE, ¬es, &on);
8948 assert!(desk.read("state/health-state.toml").contains(&format!("dose@{batch}")), "batch {batch}");
8950 assert!(desk.read("state/do-not-disturb.toml").contains(&now.to_string()), "batch {batch}");
8951 assert_eq!(texts::read_logs(&texts_of(&desk), &seal).len(), (batch as usize + 1) * texts::BATCH);
8952 }
8953 let mut files = Vec::new();
8955 list_files(&folder, &folder, &[], &mut files);
8956 let records: u64 = files.iter().filter(|(name, _)| !name.starts_with("blobs")).filter_map(|(_, p)| std::fs::metadata(p).ok()).map(|m| m.len()).sum();
8957 assert!(records < 6 * texts::BATCH as u64 * 2048, "the records hold lines, not media: {records} bytes");
8958 let target = base.join("fetched.bin");
8959 crate::blobs::get(&folder, &key, &hash, &target).unwrap();
8960 assert_eq!(std::fs::read(&target).unwrap(), picture);
8961 let _ = std::fs::remove_dir_all(&base);
8962 }
8963
8964 #[test]
8965 fn toml_entries_and_back() {
8966 let rules = &CONFIG_RULES;
8967 let text = "# mine\nlanguage = \"fr\" # kept\ncase_store = \"~/Notes\"\n\n[quiet]\npersonal = [\"joy\", \"family\"]\n\n[[account]]\nid = \"a\"\nmaildir = \"/x\"\n";
8968 let entries = toml_entries(text, rules).unwrap();
8969 let names: Vec<&str> = entries.iter().map(|(k, _)| k.as_str()).collect();
8970 assert!(names.contains(&"language") && names.contains(&"quiet\u{1f}personal") && names.contains(&"account\u{1f}\u{1e}a"), "{names:?}");
8971 assert!(!names.iter().any(|n| n.starts_with("case_store")));
8972 let account = entries.iter().find(|(k, _)| k.starts_with("account")).unwrap().1.replace("id = \"a\"", "id = \"a\"\nmuted = true");
8974 let new = toml_write(text, rules, &[("language", Some("v = \"en\"\n")), ("account\u{1f}\u{1e}a", Some(&account)), ("quiet\u{1f}work", Some("v = [\"client\"]\n"))], false).unwrap();
8975 assert!(new.starts_with("# mine\nlanguage = \"en\""), "{new}");
8976 assert!(new.contains("muted = true") && new.contains("maildir = \"/x\""), "{new}");
8977 assert!(new.contains("work = [\"client\"]"), "{new}");
8978 let money = "ignored = [\"a\", \"b\"]\n";
8980 let new = toml_write(money, &MONEY_RULES, &[("ignored\u{1f}\u{1e}v = \"a\"\n", None), ("ignored\u{1f}\u{1e}v = \"c\"\n", Some("v = \"c\"\n"))], false).unwrap();
8981 assert_eq!(new, "ignored = [\"b\", \"c\"]\n");
8982 }
8983
8984 #[test]
8990 fn a_medicines_takes_travel_whole() {
8991 let new = "[[medicine]]\nid = \"iron\"\nname = \"Iron\"\ndose = \"08:00 · 1 tablet, 20:00 · 2 tablets\"\ngeneric = \"ferrous sulfate\"\nstrength = \"80 mg\"\nsince = 2026-09-01\n\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"08:00\", \"20:00\"]\n\n[medicine.schedule.amounts]\n\"08:00\" = \"1 tablet\"\n\"20:00\" = \"2 tablets\"\n";
8992 let entries = toml_entries(new, &HEALTH_RULES).unwrap();
8993 assert_eq!(entries.len(), 1, "one entry for the medicine, its takes in it: {entries:?}");
8994 let (entry, value) = &entries[0];
8995 assert!(entry.starts_with("medicine") && value.contains("2 tablets"), "{entry} {value}");
8996 let old = "[[medicine]]\nid = \"iron\"\nname = \"Iron\"\ndose = \"1 tablet\"\n\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"08:00\", \"20:00\"]\n";
8997 let written = toml_write(old, &HEALTH_RULES, &[(entry.as_str(), Some(value.as_str()))], false).unwrap();
8998 let health: sioul_core::health::Health = toml::from_str(&written).unwrap();
8999 let takes: Vec<(String, String)> = health.medicines[0].takes().into_iter().map(|t| (t.time, t.amount)).collect();
9000 assert_eq!(takes, [("08:00".to_string(), "1 tablet".to_string()), ("20:00".to_string(), "2 tablets".to_string())]);
9001 assert_eq!((health.medicines[0].generic.as_str(), health.medicines[0].strength.as_str(), health.medicines[0].since.map(|d| d.to_string())), ("ferrous sulfate", "80 mg", Some("2026-09-01".to_string())));
9003 let before: sioul_core::health::Health = toml::from_str(old).unwrap();
9004 let day = |h: &sioul_core::health::Health| {
9005 let start: jiff::Zoned = "2026-10-08T00:00[Europe/Paris]".parse().unwrap();
9006 let end: jiff::Zoned = "2026-10-09T00:00[Europe/Paris]".parse().unwrap();
9007 h.doses(&start, &end).into_iter().map(|d| (d.key, d.dose)).collect::<Vec<_>>()
9008 };
9009 let (now, then) = (day(&health), day(&before));
9010 assert_eq!(now.iter().map(|d| &d.0).collect::<Vec<_>>(), then.iter().map(|d| &d.0).collect::<Vec<_>>(), "the same doses, the same keys");
9011 assert_eq!(now.iter().map(|d| d.1.as_str()).collect::<Vec<_>>(), ["1 tablet", "2 tablets"]);
9012 }
9013
9014 #[test]
9019 fn do_not_disturb_travels_each_device_its_table_and_the_list_a_person_at_a_time() {
9020 use sioul_core::everywhere::{People, Person, Switch, change_own, change_people};
9021 let base = scratch("dnd");
9022 let folder = base.join("Sioul");
9023 let key = quick_key(&folder, "four words make a passphrase").unwrap();
9024 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
9025 let switch = |c: &Computer| c.roots.state.join(sioul_core::everywhere::SWITCH_FILE);
9026 let people = |c: &Computer| c.roots.config.join(sioul_core::everywhere::PEOPLE_FILE);
9027 change_own(&switch(&desk), &desk.id, |s| {
9029 s.press(&desk.id, true, 0, NOW);
9030 true
9031 })
9032 .unwrap();
9033 change_people(&people(&phone), |p| {
9034 p.add(Person { name: "Alice".into(), phones: vec!["+262639981234".into()], ..Person::default() }, None);
9035 })
9036 .unwrap();
9037 change_people(&people(&desk), |p| {
9038 p.add(Person { name: "Bob".into(), emails: vec!["bob@example.org".into()], ..Person::default() }, None);
9039 })
9040 .unwrap();
9041 desk.exchange(&folder, &key, NOW + MINUTE);
9042 phone.exchange(&folder, &key, NOW + 2 * MINUTE);
9043 desk.exchange(&folder, &key, NOW + 3 * MINUTE);
9044 let heard = Switch::read(&switch(&phone)).unwrap();
9045 let latest = heard.latest().unwrap();
9046 assert!(latest.on && latest.from == desk.id, "{heard:?}");
9047 for c in [&desk, &phone] {
9048 let names: Vec<String> = People::read(&people(c)).unwrap().people.into_iter().map(|p| p.name).collect();
9049 assert!(names.contains(&"Alice".to_string()) && names.contains(&"Bob".to_string()), "{names:?}");
9050 }
9051 change_own(&switch(&phone), &phone.id, |s| {
9053 s.press(&phone.id, false, 0, NOW + 4 * MINUTE);
9054 true
9055 })
9056 .unwrap();
9057 phone.exchange(&folder, &key, NOW + 5 * MINUTE);
9058 desk.exchange(&folder, &key, NOW + 6 * MINUTE);
9059 let seen = Switch::read(&switch(&desk)).unwrap();
9060 assert!(!seen.latest().unwrap().on, "{seen:?}");
9061 assert!(seen.device[&desk.id].on, "the desk's own table as it wrote it");
9062 let bob = People::read(&people(&desk)).unwrap().people.into_iter().find(|p| p.name == "Bob").unwrap().id;
9064 change_people(&people(&desk), |p| {
9065 p.remove(&bob);
9066 })
9067 .unwrap();
9068 desk.exchange(&folder, &key, NOW + 7 * MINUTE);
9069 phone.exchange(&folder, &key, NOW + 8 * MINUTE);
9070 assert_eq!(People::read(&people(&phone)).unwrap().people.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Alice"]);
9071 let old = Computer::new(&base, "old");
9073 let older: Vec<Store> = stores(&Config::default(), &old.roots).into_iter().filter(|s| !s.name.contains("do-not-disturb") && !s.name.contains("dnd-people")).collect();
9074 exchange(&Sharing { folder: &folder, computer: &old.id, key: &key, memory: &old.memory, files: true, hurry: None }, &older, NOW + 9 * MINUTE).unwrap();
9075 assert!(!switch(&old).exists() && !people(&old).exists());
9076 desk.exchange(&folder, &key, NOW + 10 * MINUTE);
9077 assert_eq!(People::read(&people(&desk)).unwrap().people.len(), 1);
9078 assert_eq!(Switch::read(&switch(&desk)).unwrap().device.len(), 2);
9079 let _ = std::fs::remove_dir_all(&base);
9080 }
9081
9082 #[test]
9085 fn a_computers_table_written_as_sioul_quits_reaches_the_phone_as_closed() {
9086 use sioul_core::everywhere::{Follows, Switch, change_own, others};
9087 let base = scratch("dnd-closed");
9088 let folder = base.join("Sioul");
9089 let key = quick_key(&folder, "four words make a passphrase").unwrap();
9090 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
9091 let switch = |c: &Computer| c.roots.state.join(sioul_core::everywhere::SWITCH_FILE);
9092 change_own(&switch(&phone), &phone.id, |s| {
9094 s.press(&phone.id, true, 0, NOW);
9095 true
9096 })
9097 .unwrap();
9098 phone.exchange(&folder, &key, NOW + MINUTE);
9099 desk.exchange(&folder, &key, NOW + 2 * MINUTE);
9100 change_own(&switch(&desk), &desk.id, |s| {
9101 let own = s.device.entry(desk.id.clone()).or_default();
9102 (own.kind, own.why, own.silenced, own.at) = ("computer".into(), "manual".into(), true, NOW + 2 * MINUTE);
9103 true
9104 })
9105 .unwrap();
9106 desk.exchange(&folder, &key, NOW + 3 * MINUTE);
9107 phone.exchange(&folder, &key, NOW + 4 * MINUTE);
9108 let read = |at: i64| {
9109 let s = Switch::read(&switch(&phone)).unwrap();
9110 others(&s, &phone.id, &[(desk.id.clone(), at / 1000)], at / 1000, NOW, &|_| true)
9111 };
9112 assert_eq!(read(NOW + 4 * MINUTE), vec![(desk.id.clone(), Follows::Silenced)]);
9113 change_own(&switch(&desk), &desk.id, |s| {
9115 let own = s.device.get_mut(&desk.id).unwrap();
9116 own.closing(false);
9117 own.at = NOW + 5 * MINUTE;
9118 true
9119 })
9120 .unwrap();
9121 desk.exchange(&folder, &key, NOW + 5 * MINUTE);
9122 phone.exchange(&folder, &key, NOW + 6 * MINUTE);
9123 assert_eq!(read(NOW + 6 * MINUTE), vec![(desk.id.clone(), Follows::Closed)]);
9124 let table = &Switch::read(&switch(&phone)).unwrap().device[&desk.id];
9125 assert!(table.closed && !table.silenced, "{table:?}");
9126 let _ = std::fs::remove_dir_all(&base);
9127 }
9128}
9129
9130#[cfg(test)]
9131#[path = "share_format_tests.rs"]
9132mod format_tests;
9133
9134#[cfg(test)]
9135#[path = "share_switch_tests.rs"]
9136mod switch_tests;
9137
9138#[cfg(test)]
9139#[path = "share_switch_more_tests.rs"]
9140mod switch_more_tests;
9141
9142#[cfg(test)]
9143#[path = "share_switch_third_tests.rs"]
9144mod switch_third_tests;
9145
9146#[cfg(test)]
9147#[path = "share_quiet_tests.rs"]
9148mod quiet_tests;
9149
9150#[cfg(test)]
9151#[path = "share_idle_bench.rs"]
9152mod idle_bench;
9153
9154#[cfg(test)]
9155#[path = "share_correctness_tests.rs"]
9156mod correctness_tests;
9157
9158#[cfg(test)]
9159mod probe {
9160 #[test]
9162 #[ignore]
9163 fn probe() {
9164 let Some(base) = std::env::var_os("SIOUL_SHARE_PROBE").map(std::path::PathBuf::from) else { return };
9165 let roots = super::Roots { config: base.join("config"), data: base.join("data"), state: base.join("state") };
9166 let config: sioul_core::config::Config = std::fs::read_to_string(roots.config.join("config.toml")).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default();
9167 let stores = super::stores(&config, &roots);
9168 let look = super::Look { reads: &|_| true, hurry: &|| false, confirmed: &Default::default(), clock: super::clock_ms() };
9169 let found = super::gather(&stores, &Default::default(), &look);
9170 for store in &stores {
9171 let count = found.hashes.keys().filter(|k| super::file_of(k).starts_with(store.name.as_str()) && (store.folder || super::file_of(k) == store.name)).count();
9172 println!("{:32} {count}", store.name);
9173 }
9174 println!("unknown: {:?}", found.unknown);
9175 }
9176}