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 = SILENT_DAYS;
133const _: () = assert!(TOMBSTONE_DAYS >= SILENT_DAYS);
134const AGED_TOMBSTONE_DAYS: i64 = 90;
142const _: () = assert!(
143 AGED_TOMBSTONE_DAYS > sioul_core::calls::KEPT_DAYS + sioul_core::calls::LATE_DAYS
144 && AGED_TOMBSTONE_DAYS > sioul_core::phonemsgs::KEPT_DAYS + sioul_core::phonemsgs::LATE_DAYS
145 && AGED_TOMBSTONE_DAYS > sioul_core::texts::KEPT_DAYS + sioul_core::texts::LATE_DAYS
146);
147const SILENT_DAYS: i64 = 180;
149const EMPTIED_WAIT: i64 = 10 * 60_000;
153const GONE_GRACE: i64 = GONE_GRACE_DAYS * DAY;
161pub const GONE_GRACE_DAYS: i64 = crate::blobs::UNUSED_DAYS;
163
164const AWAITED: i64 = 60 * 60_000;
170
171const AWAIT_STEP: i64 = 5 * 60_000;
178
179const OWED_METERED: i64 = 6 * 60 * 60_000;
185const LONGEST_LINE: usize = 32 << 20;
188const READ_CHUNK: usize = 8 << 20;
190const MASS: usize = 10;
193const MINUTE: i64 = 60_000;
194const HOUR: i64 = 60 * MINUTE;
195const DAY: i64 = 24 * HOUR;
196
197const FILES: &str = "files/";
199
200#[derive(Debug, Clone, Copy)]
202pub enum Shape {
203 Whole,
205 Lines,
207 Toml(&'static Rules),
209 Files,
213}
214
215#[derive(Debug)]
217pub struct Rules {
218 pub keyed: &'static [Keyed],
220 pub whole: &'static [&'static str],
222 pub local: &'static [&'static str],
224 pub next: Option<&'static Rules>,
230}
231
232impl Rules {
233 pub fn at(&'static self, format: u32) -> &'static Rules {
235 match self.next {
236 Some(next) if format >= 2 => next,
237 _ => self,
238 }
239 }
240}
241
242#[derive(Debug)]
245pub struct Keyed {
246 pub list: &'static str,
249 pub by: &'static [&'static str],
250 pub local: &'static [&'static str],
251 pub also: &'static [&'static str],
255 pub ids: bool,
261}
262
263const fn keyed(list: &'static str, by: &'static [&'static str]) -> Keyed {
265 Keyed { list, by, local: &[], also: &[], ids: false }
266}
267
268const fn with_ids(list: &'static str, by: &'static [&'static str]) -> Keyed {
270 Keyed { list, by, local: &[], also: &[], ids: true }
271}
272
273const CONFIG_LOCAL: &[&str] = &["case_store", "known_senders", "blocked_senders", "reading", "history_weeks", "letters.inbox", "dnd.background"];
275static 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) };
276static CONFIG_RULES_2: Rules = Rules {
282 keyed: &[
283 Keyed { list: "account", by: &["id"], local: &["maildir", "history_weeks"], also: &[], ids: false },
284 with_ids("site", &["id"]),
285 with_ids("routine", &["id"]),
286 with_ids("tasks.kind", &["id"]),
287 with_ids("time_off", &[]),
288 keyed("words.*.*.add", &[]),
289 keyed("words.*.*.remove", &[]),
290 keyed("words.*.*.add.*", &[]),
292 ],
293 whole: &[],
294 local: CONFIG_LOCAL,
295 next: None,
296};
297static HEALTH_RULES: Rules = Rules {
298 keyed: &[keyed("prescription", &["id"]), keyed("medicine", &["id"])],
299 whole: &[],
300 local: &["watch_folder"],
303 next: None,
304};
305static LINKS_RULES: Rules = Rules { keyed: &[keyed("link", &[])], whole: &[], local: &[], next: None };
306static PORCH_RULES: Rules = Rules { keyed: &[], whole: &["done.*"], local: &[], next: None };
307static MONEY_RULES: Rules = Rules { keyed: &[keyed("ignored", &[])], whole: &[], local: &[], next: None };
308static TODAY_RULES: Rules = Rules { keyed: &[keyed("aside", &[])], whole: &[], local: &[], next: None };
309static TIME_RULES: Rules = Rules { keyed: &[keyed("session", &["start", "task", "project"])], whole: &[], local: &[], next: Some(&TIME_RULES_2) };
310static TIME_RULES_2: Rules = Rules { keyed: &[with_ids("session", &["start", "task", "project"])], whole: &[], local: &[], next: None };
313static QUIET_RULES: Rules = Rules { keyed: &[], whole: &[], local: &[], next: Some(&QUIET_RULES_2) };
316static QUIET_RULES_2: Rules = Rules { keyed: &[keyed("lighter", &[])], whole: &[], local: &[], next: None };
317static REVIEWS_RULES: Rules = Rules { keyed: &[], whole: &["*.work", "*.night"], local: &[], next: None };
321static PLAIN_RULES: Rules = Rules { keyed: &[], whole: &[], local: &[], next: None };
322static DND_RULES: Rules = Rules { keyed: &[], whole: &["device.*"], local: &[], next: None };
325static DND_PEOPLE_RULES: Rules = Rules { keyed: &[keyed("person", &["id"])], whole: &[], local: &[], next: None };
327static DOSES_RULES: Rules = Rules { keyed: &[], whole: &["dose.*.answer.*"], local: &[], next: None };
331static REMINDERS_RULES: Rules = Rules { keyed: &[], whole: &["dose.*.*"], local: &[], next: None };
335static PROJECTS_RULES: Rules = Rules { keyed: &[Keyed { list: "case", by: &["id"], local: &[], also: &["project"], ids: false }], whole: &[], local: &[], next: None };
340static BANK_RULES: Rules = Rules { keyed: &[keyed("account", &["id"]), keyed("movement", &["account", "id"])], whole: &[], local: &[], next: None };
342static LEDGER_RULES: Rules = Rules {
343 keyed: &[
344 keyed("budget", &["id"]),
345 keyed("preset", &["id"]),
346 keyed("reserve", &["id"]),
347 keyed("bank_account", &["id"]),
348 keyed("assign", &["account", "movement"]),
349 keyed("line", &[]),
351 keyed("cover", &[]),
352 keyed("mail_rule", &[]),
353 keyed("split", &[]),
354 ],
355 whole: &[],
356 local: &[],
357 next: Some(&LEDGER_RULES_2),
358};
359static LEDGER_RULES_2: Rules = Rules {
364 keyed: &[
365 keyed("budget", &["id"]),
366 with_ids("preset", &["id"]),
367 keyed("reserve", &["id"]),
368 keyed("bank_account", &["id"]),
369 keyed("assign", &["account", "movement"]),
370 with_ids("line", &[]),
371 with_ids("cover", &[]),
372 with_ids("mail_rule", &[]),
373 with_ids("split", &[]),
374 ],
375 whole: &[],
376 local: &[],
377 next: None,
378};
379static CONTRACTS_RULES: Rules = Rules { keyed: &[keyed("contract", &["id"])], whole: &[], local: &[], next: None };
380static PAPERS_RULES: Rules = Rules { keyed: &[keyed("paper", &["id"])], whole: &[], local: &[], next: None };
381const NOT_NOTES: &[&str] = &[
384 sioul_core::projects::FILE,
385 sioul_core::projects::OLD_FILE,
386 sioul_core::projects::BEFORE_RENAME,
387 sioul_core::budget::LEDGER,
388 sioul_core::bank::MANIFEST,
389 sioul_core::contracts::MANIFEST,
390 sioul_core::papers::MANIFEST,
391 sioul_core::papers::FOLDER,
392];
393
394pub const PARTS: [&str; 13] = ["settings", "senders", "calls", sioul_core::phonemsgs::PART, sioul_core::texts::PART, "spam", "health", "time", "drafts", "projects", "lists", "notes", "papers"];
401
402const RETIRED: &[&str] = &["data/watch/", "state/watch-offers.json", "state/texts/"];
410
411fn retired(key: &str) -> bool {
413 let file = file_of(key);
414 RETIRED.iter().any(|store| if store.ends_with('/') { file.starts_with(store) } else { file == *store })
415}
416
417pub const CALLS_LOG: &str = "state/calls/log/";
422pub const CALLS_SEEN: &str = "state/calls/seen/";
425const CALLS: &str = "state/calls/";
430
431pub const PHONE_MESSAGES_LOG: &str = "state/phone-messages/log/";
437pub const PHONE_MESSAGES_SEEN: &str = "state/phone-messages/seen/";
440pub const PHONE_MESSAGES_LIVE: &str = "state/phone-messages/live/";
444pub const PHONE_MESSAGES_ASK: &str = "state/phone-messages/ask/";
449pub const PHONE_MESSAGES_OUTCOME: &str = "state/phone-messages/outcome/";
452const PHONE_MESSAGES: &str = "state/phone-messages/";
456
457pub const TEXTS_LOG: &str = "data/texts/log/";
463pub const TEXTS_SEND: &str = "data/texts/send/";
465pub const TEXTS_OUTCOME: &str = "data/texts/outcome/";
467pub const TEXTS_MARKS: &str = "data/texts/marks/";
469const TEXTS: &str = "data/texts/";
472
473fn calls_store(name: &str) -> bool {
477 name.starts_with(CALLS) || name.starts_with(PHONE_MESSAGES) || name.starts_with(TEXTS)
479}
480
481fn ages_out(key: &str) -> bool {
485 key.starts_with(CALLS) || key.starts_with(PHONE_MESSAGES) || key.starts_with(TEXTS_SEND) || key.starts_with(TEXTS_OUTCOME)
486}
487
488pub const SPAM_TABLE: &str = "files/spam/table.bin";
496
497pub const SPAM_LABELS: &str = "state/spam/labels/";
503pub const SPAM_MOVED: &str = "state/spam/moved/";
507pub const SPAM_FLAGGED: &str = "state/spam/flagged/";
510
511pub fn shared_by_default(part: &str, config: &Config) -> bool {
517 match part {
518 "projects" => config.share_projects,
519 "notes" | "papers" => false,
520 sioul_core::phonemsgs::PART => false,
522 sioul_core::texts::PART => false,
524 _ => true,
525 }
526}
527
528#[derive(Debug, Clone)]
530pub struct Store {
531 pub name: String,
533 pub path: PathBuf,
534 pub folder: bool,
536 pub shape: Shape,
537 pub skip: &'static [&'static str],
539 pub part: &'static str,
541}
542
543pub struct Roots {
545 pub config: PathBuf,
546 pub data: PathBuf,
547 pub state: PathBuf,
548}
549
550impl Roots {
551 pub fn here() -> Roots {
552 Roots { config: sioul_core::config::config_dir(), data: sioul_core::config::data_dir(), state: sioul_core::config::state_dir() }
553 }
554}
555
556pub fn stores(config: &Config, roots: &Roots) -> Vec<Store> {
558 stores_of(config, roots, &|part| shared_by_default(part, config))
559}
560
561pub const FORMAT: u32 = 2;
571
572pub fn part_format(part: &str) -> u32 {
576 match part {
577 "settings" | "time" | "projects" => 2,
578 _ => 1,
579 }
580}
581
582pub fn stores_of(config: &Config, roots: &Roots, shares: &dyn Fn(&str) -> bool) -> Vec<Store> {
587 let (c, d, s) = (&roots.config, &roots.data, &roots.state);
588 let file = |part: &'static str, name: &str, path: PathBuf, shape: Shape| Store { name: name.into(), path, folder: false, shape, skip: &[], part };
589 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 };
590 let senders = |chosen: &Option<String>, name: &str| chosen.as_deref().map_or_else(|| c.join(name), sioul_core::config::expand_home);
591 let local = sioul_core::vdir::LOCAL;
592 let mut stores = vec![
593 file("settings", "config/config.toml", c.join("config.toml"), Shape::Toml(&CONFIG_RULES)),
594 file("senders", "config/known-senders.txt", senders(&config.known_senders, "known-senders.txt"), Shape::Lines),
595 file("senders", "config/blocked-senders.txt", senders(&config.blocked_senders, "blocked-senders.txt"), Shape::Lines),
596 file("senders", "config/safe-senders.txt", c.join("safe-senders.txt"), Shape::Lines),
597 file("senders", "config/neutral-senders.txt", c.join("neutral-senders.txt"), Shape::Lines),
598 file("senders", "config/restricted-senders.txt", c.join("restricted-senders.txt"), Shape::Lines),
599 file("senders", "config/dnd-people.toml", c.join(sioul_core::everywhere::PEOPLE_FILE), Shape::Toml(&DND_PEOPLE_RULES)),
601 file("settings", "data/links.toml", d.join("links.toml"), Shape::Toml(&LINKS_RULES)),
602 file("health", "data/health.toml", d.join("health.toml"), Shape::Toml(&HEALTH_RULES)),
603 file("health", "data/health-days.toml", d.join("health-days.toml"), Shape::Toml(&PLAIN_RULES)),
605 file("time", "data/time/running.toml", d.join("time").join("running.toml"), Shape::Whole),
606 folder("time", "data/time/", d.join("time"), Shape::Toml(&TIME_RULES), &["running.toml"]),
607 folder("time", "data/reviews/", d.join("reviews"), Shape::Toml(&REVIEWS_RULES), &[]),
610 folder("drafts", "data/drafts/", d.join("drafts"), Shape::Whole, &[]),
611 folder("drafts", "data/invoices/", d.join("invoices"), Shape::Whole, &[]),
612 folder("senders", "data/pgp/others/", d.join("pgp").join("others"), Shape::Whole, &[]),
613 folder("settings", "data/pgp/cards/", d.join("pgp").join(sioul_core::securitykey::SHARED_CARDS), Shape::Whole, &[]),
617 folder("lists", "data/calendars/local/", d.join("calendars").join(local), Shape::Whole, &[]),
618 folder("lists", "data/contacts/local/", d.join("contacts").join(local), Shape::Whole, &[]),
619 file("settings", "state/porch.toml", s.join("porch.toml"), Shape::Toml(&PORCH_RULES)),
620 file("settings", "state/money.toml", s.join("money.toml"), Shape::Toml(&MONEY_RULES)),
621 file("settings", "state/do-not-disturb.toml", s.join(sioul_core::everywhere::SWITCH_FILE), Shape::Toml(&DND_RULES)),
623 file("time", "state/today.toml", s.join("today.toml"), Shape::Toml(&TODAY_RULES)),
624 file("time", "state/quiet.toml", s.join("quiet.toml"), Shape::Toml(&QUIET_RULES)),
625 file("time", "state/stopped.toml", s.join("stopped.toml"), Shape::Toml(&PLAIN_RULES)),
626 file("health", "state/health-state.toml", s.join("health-state.toml"), Shape::Toml(&PLAIN_RULES)),
627 file("health", "state/health-doses.toml", s.join(sioul_core::doses::FILE), Shape::Toml(&DOSES_RULES)),
630 file("health", "state/health-reminders.toml", s.join(sioul_core::dose_reminders::FILE), Shape::Toml(&REMINDERS_RULES)),
633 folder("senders", "state/shield/", s.join("shield"), Shape::Whole, &[]),
634 folder("lists", "state/dav/local/", s.join("dav").join(local), Shape::Whole, &[]),
635 file("spam", SPAM_TABLE, d.join("spam").join("table.bin"), Shape::Files),
637 folder("spam", SPAM_LABELS, s.join("spam").join(sioul_core::spam::labels::FOLDER), Shape::Lines, &[]),
641 folder("spam", SPAM_MOVED, s.join("spam").join(sioul_core::spam::labels::MOVED), Shape::Lines, &[]),
642 folder("spam", SPAM_FLAGGED, s.join("spam").join(sioul_core::spam::labels::FLAGGED), Shape::Lines, &[]),
643 folder("calls", CALLS_LOG, s.join(sioul_core::calls::FOLDER).join(sioul_core::calls::LOG), Shape::Lines, &[]),
646 folder("calls", CALLS_SEEN, s.join(sioul_core::calls::FOLDER).join(sioul_core::calls::SEEN_LOG), Shape::Lines, &[]),
647 folder(sioul_core::phonemsgs::PART, PHONE_MESSAGES_LOG, s.join(sioul_core::phonemsgs::FOLDER).join(sioul_core::phonemsgs::LOG), Shape::Lines, &[]),
650 folder(sioul_core::phonemsgs::PART, PHONE_MESSAGES_SEEN, s.join(sioul_core::phonemsgs::FOLDER).join(sioul_core::phonemsgs::SEEN_LOG), Shape::Lines, &[]),
651 folder(sioul_core::phonemsgs::PART, PHONE_MESSAGES_LIVE, s.join(sioul_core::phonemsgs::FOLDER).join(sioul_core::replies::LIVE), Shape::Lines, &[]),
653 folder(sioul_core::phonemsgs::PART, PHONE_MESSAGES_ASK, s.join(sioul_core::phonemsgs::FOLDER).join(sioul_core::replies::ASK), Shape::Lines, &[]),
655 folder(sioul_core::phonemsgs::PART, PHONE_MESSAGES_OUTCOME, s.join(sioul_core::phonemsgs::FOLDER).join(sioul_core::replies::OUTCOME), Shape::Lines, &[]),
656 folder(sioul_core::texts::PART, TEXTS_LOG, d.join(sioul_core::texts::FOLDER).join(sioul_core::texts::LOG), Shape::Lines, &[]),
658 folder(sioul_core::texts::PART, TEXTS_SEND, d.join(sioul_core::texts::FOLDER).join(sioul_core::texts::SEND), Shape::Lines, &[]),
659 folder(sioul_core::texts::PART, TEXTS_OUTCOME, d.join(sioul_core::texts::FOLDER).join(sioul_core::texts::OUTCOME), Shape::Lines, &[]),
660 folder(sioul_core::texts::PART, TEXTS_MARKS, d.join(sioul_core::texts::FOLDER).join(sioul_core::texts::MARKS), Shape::Lines, &[]),
661 ];
662 if let Some(notes) = config.notes_root_path() {
666 stores.push(file("projects", "notes/sioul-cases.toml", sioul_core::projects::file_in(¬es), Shape::Toml(&PROJECTS_RULES)));
667 stores.push(file("projects", "notes/sioul-budgets.toml", notes.join(sioul_core::budget::LEDGER), Shape::Toml(&LEDGER_RULES)));
668 stores.push(file("projects", "notes/sioul-bank.toml", notes.join(sioul_core::bank::MANIFEST), Shape::Toml(&BANK_RULES)));
669 stores.push(file("projects", "notes/sioul-contracts.toml", notes.join(sioul_core::contracts::MANIFEST), Shape::Toml(&CONTRACTS_RULES)));
670 stores.push(file("papers", "notes/sioul-papers.toml", notes.join(sioul_core::papers::MANIFEST), Shape::Toml(&PAPERS_RULES)));
671 stores.push(folder("papers", "files/papers/", notes.join(sioul_core::papers::FOLDER), Shape::Files, &[]));
672 stores.push(folder("notes", "files/notes/", notes, Shape::Files, NOT_NOTES));
673 }
674 stores.retain(|store| shares(store.part));
675 stores
676}
677
678pub fn shaped(stores: &[Store], format: u32) -> Vec<Store> {
681 stores
682 .iter()
683 .map(|store| Store {
684 shape: match store.shape {
685 Shape::Toml(rules) => Shape::Toml(rules.at(format)),
686 other => other,
687 },
688 ..store.clone()
689 })
690 .collect()
691}
692
693pub fn estimate(stores: &[Store]) -> (usize, u64, usize) {
697 let (mut found, mut files, known, confirmed) = (Found::default(), Vec::new(), BTreeMap::new(), BTreeSet::new());
698 for store in stores.iter().filter(|s| matches!(s.shape, Shape::Files) && s.path.is_dir()) {
699 Listing { store: &store.name, root: &store.path, skip: store.skip, known: &known, confirmed: &confirmed, files: &mut files, found: &mut found, sweep: false }.list(&store.path);
700 }
701 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) })
702}
703
704fn known_part(file: &str) -> Option<&'static str> {
708 static KNOWN: std::sync::OnceLock<Vec<(String, bool, &'static str)>> = std::sync::OnceLock::new();
709 let known = KNOWN.get_or_init(|| {
710 let roots = Roots { config: PathBuf::from("config"), data: PathBuf::from("data"), state: PathBuf::from("state") };
711 let config = Config { notes_root: Some("notes".into()), ..Config::default() };
712 stores_of(&config, &roots, &|_| true).into_iter().map(|store| (store.name, store.folder, store.part)).collect()
713 });
714 #[cfg(test)]
715 if tests::OLDER.with(|older| older.borrow().iter().any(|store| file.starts_with(store.as_str()))) {
716 return None;
717 }
718 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)
719}
720
721pub fn shown(file: &str) -> &str {
724 ["files/notes/", "files/", "config/", "data/", "state/", "notes/"].iter().find_map(|root| file.strip_prefix(root)).unwrap_or(file)
725}
726
727#[derive(Debug, Default)]
731struct Found {
732 hashes: BTreeMap<String, String>,
734 values: BTreeMap<String, String>,
736 changed: BTreeMap<String, i64>,
738 unknown: BTreeSet<String>,
741 files: BTreeMap<String, Stat>,
743 too_big: Vec<String>,
745 emptied: Vec<(String, usize)>,
748 said: Vec<String>,
750 changing: BTreeMap<String, i64>,
753}
754
755#[derive(Debug, Clone, Default, Serialize, Deserialize)]
758struct Stat {
759 size: u64,
760 modified: u64,
761 entries: Vec<(String, String)>,
762 #[serde(default, skip_serializing_if = "is_zero")]
765 seen: i64,
766}
767
768fn is_zero(n: &i64) -> bool {
769 *n == 0
770}
771
772impl Found {
773 fn is_unknown(&self, key: &str) -> bool {
774 let file = file_of(key);
775 self.unknown.iter().any(|u| file == u || (u.ends_with('/') && file.starts_with(u.as_str())))
776 }
777}
778
779const WHOLE_FOLDERS: &[&str] = &["data/drafts/", "data/invoices/", "data/pgp/others/", "data/calendars/local/", "data/contacts/local/", "state/shield/", "state/dav/local/", FILES];
783
784const DRAFTS: &str = "data/drafts/";
794
795fn sent_meanwhile(met: Option<&BTreeSet<String>>, here: Option<(bool, &str, &str)>, removal: bool, base: &str) -> Option<bool> {
800 let (gone, version, replaced) = here?;
801 let known = |v: &str| !v.is_empty() && (v == version || v == replaced || met.is_some_and(|met| met.contains(v)));
802 match (removal, gone) {
803 (false, true) if known(base) => Some(false),
805 (true, false) if known(base) => Some(true),
807 _ => None,
808 }
809}
810
811fn file_of(key: &str) -> &str {
813 if WHOLE_FOLDERS.iter().any(|folder| key.starts_with(folder))
814 && let Some(file) = key.strip_suffix('#')
815 {
816 return file;
817 }
818 key.split_once('#').map_or(key, |(file, _)| file)
819}
820
821fn entry_of(key: &str) -> &str {
823 key.get(file_of(key).len() + 1..).unwrap_or("")
824}
825
826pub(crate) fn modified_ns(meta: &std::fs::Metadata) -> u64 {
827 meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_nanos() as u64)
828}
829
830struct Look<'a> {
833 reads: &'a dyn Fn(&Store) -> bool,
835 hurry: &'a dyn Fn() -> bool,
837 confirmed: &'a BTreeSet<String>,
838 clock: i64,
839 changing: &'a BTreeMap<String, i64>,
841}
842
843fn clock_ms() -> i64 {
845 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_millis() as i64)
846}
847
848#[cfg(test)]
849thread_local! {
850 static SETTLE: std::cell::Cell<i64> = const { std::cell::Cell::new(0) };
852}
853
854fn settling_ms() -> i64 {
857 #[cfg(test)]
858 return SETTLE.with(std::cell::Cell::get);
859 #[cfg(not(test))]
860 2_000
861}
862
863const LARGE_SETTLES: u64 = 1 << 20;
871
872#[cfg(test)]
873thread_local! {
874 pub(crate) static LARGE_SETTLE: std::cell::Cell<i64> = const { std::cell::Cell::new(0) };
876}
877
878pub(crate) const LARGE_SETTLING: i64 = 10 * MINUTE;
882
883fn large_settling_ms() -> i64 {
884 #[cfg(test)]
885 return LARGE_SETTLE.with(std::cell::Cell::get);
886 #[cfg(not(test))]
887 LARGE_SETTLING
888}
889
890fn gather(stores: &[Store], known: &BTreeMap<String, Stat>, look: &Look) -> Found {
894 let mut found = Found::default();
895 for store in stores {
896 if !(look.reads)(store) {
897 found.unknown.insert(store.name.clone());
899 continue;
900 }
901 if store.folder {
902 if !store.path.is_dir() {
903 let held = known.range(store.name.clone()..).take_while(|(file, _)| file.starts_with(&store.name)).count();
907 if matches!(store.shape, Shape::Files) && held == 0 {
908 continue;
909 }
910 if matches!(store.shape, Shape::Files) && !look.confirmed.iter().any(|said| confirms(said, &store.name)) {
911 found.emptied.push((store.name.clone(), held));
912 }
913 found.unknown.insert(store.name.clone());
914 continue;
915 }
916 let mut files = Vec::new();
917 if matches!(store.shape, Shape::Files) {
918 let mut listing = Listing { store: &store.name, root: &store.path, skip: store.skip, known, confirmed: look.confirmed, files: &mut files, found: &mut found, sweep: true };
919 listing.list(&store.path);
920 } else {
921 list_files(&store.path, &store.path, store.skip, &mut files);
922 }
923 for (relative, path) in files {
924 read_file(store, &format!("{}{relative}", store.name), &path, known, look, &mut found);
925 }
926 } else if store.path.is_file() {
927 read_file(store, &store.name, &store.path, known, look, &mut found);
928 } else if !matches!(store.shape, Shape::Whole | Shape::Files) {
929 found.unknown.insert(store.name.clone());
932 }
933 }
934 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();
936 found.files.extend(carried);
937 if let Ok(mut all) = LINES.lock() {
939 all.retain(|path, _| path.exists() || !stores.iter().any(|s| matches!(s.shape, Shape::Lines) && path.starts_with(&s.path)));
940 }
941 found
942}
943
944fn list_files(root: &Path, dir: &Path, skip: &[&str], out: &mut Vec<(String, PathBuf)>) {
946 let Ok(entries) = std::fs::read_dir(dir) else { return };
947 for entry in entries.filter_map(Result::ok) {
948 let name = entry.file_name().to_string_lossy().to_string();
949 if name.starts_with('.') || name.ends_with(".new") || name.ends_with(".tmp") || name.ends_with('~') || skip.contains(&name.as_str()) {
950 continue;
951 }
952 let path = entry.path();
953 if path.is_dir() {
954 list_files(root, &path, skip, out);
955 } else if let Ok(relative) = path.strip_prefix(root) {
956 out.push((relative.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/"), path));
957 }
958 }
959}
960
961struct Listing<'a> {
963 store: &'a str,
964 root: &'a Path,
965 skip: &'a [&'a str],
966 known: &'a BTreeMap<String, Stat>,
967 confirmed: &'a BTreeSet<String>,
970 files: &'a mut Vec<(String, PathBuf)>,
971 found: &'a mut Found,
972 sweep: bool,
976}
977
978impl Listing<'_> {
979 fn list(&mut self, dir: &Path) -> usize {
988 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();
989 let within = if relative.is_empty() { String::new() } else { format!("{relative}/") };
990 let prefix = format!("{}{within}", self.store);
991 let mut whole = true;
992 let mut count = 0;
993 match std::fs::read_dir(dir) {
994 Err(_) => whole = false,
995 Ok(entries) => {
996 for entry in entries {
997 let Ok(entry) = entry else {
998 whole = false;
999 continue;
1000 };
1001 let Ok(kind) = entry.file_type() else {
1002 whole = false;
1003 continue;
1004 };
1005 let name = entry.file_name();
1006 let Some(name) = name.to_str() else {
1007 self.found.said.push(format!("share-not-text:{prefix}{}", name.to_string_lossy()));
1008 continue;
1009 };
1010 let path = entry.path();
1011 if is_leftover(name) {
1012 if self.sweep {
1013 remove_if_old(&path);
1014 }
1015 continue;
1016 }
1017 if kind.is_symlink() {
1018 let place = format!("{prefix}{name}");
1024 let below = format!("{place}/");
1025 let held = self.known.range(place.clone()..).take_while(|(file, _)| file.starts_with(&place)).filter(|(file, _)| **file == place || file.starts_with(&below)).count();
1026 if held > 0 && !self.confirmed.iter().any(|said| confirms(said, &below)) {
1027 let one = self.known.contains_key(&place);
1029 self.found.emptied.push((if one { place.clone() } else { below.clone() }, held));
1030 self.found.unknown.insert(place);
1031 self.found.unknown.insert(below);
1032 }
1033 continue;
1034 }
1035 if made_by_tools(name) || (dir == self.root && self.skip.iter().any(|skip| skip.eq_ignore_ascii_case(name))) {
1036 continue;
1037 }
1038 if kind.is_dir() {
1039 if !matches!(name, "node_modules" | "target") && !path.join("seal.toml").is_file() {
1040 count += self.list(&path);
1041 }
1042 } else if kind.is_file() {
1043 self.files.push((format!("{within}{name}"), path));
1044 count += 1;
1045 }
1046 }
1047 }
1048 }
1049 let held = self.known.range(prefix.clone()..).take_while(|(file, _)| file.starts_with(&prefix)).count();
1050 let confirmed = self.confirmed.iter().any(|said| confirms(said, &prefix));
1051 if !whole || (count == 0 && held > 0 && !confirmed) {
1052 if whole {
1053 self.found.emptied.push((prefix.clone(), held));
1054 }
1055 self.found.unknown.insert(prefix);
1056 }
1057 count
1058 }
1059}
1060
1061fn confirms(said: &str, file: &str) -> bool {
1066 if said.ends_with('/') { file.starts_with(said) } else { file == said || file.strip_prefix(said).is_some_and(|rest| rest.starts_with('/')) }
1067}
1068
1069fn is_leftover(name: &str) -> bool {
1073 let replacement = name.strip_suffix(".new").and_then(|rest| rest.rsplit_once('.')).is_some_and(|(_, tail)| tail.split_once('-').is_some_and(|(process, number)| !process.is_empty() && !number.is_empty() && process.bytes().all(|b| b.is_ascii_digit()) && number.bytes().all(|b| b.is_ascii_digit())));
1074 name.starts_with('.') && ([TEMPORARY, ".shared.tmp", ".conflict.tmp"].iter().any(|end| name.ends_with(end)) || replacement)
1075}
1076
1077fn clean_leftovers_below(dir: &Path, depth: usize) {
1082 clean_leftovers(dir);
1083 if depth == 0 {
1084 return;
1085 }
1086 for entry in std::fs::read_dir(dir).into_iter().flatten().filter_map(Result::ok) {
1087 if entry.file_type().is_ok_and(|t| t.is_dir()) {
1088 clean_leftovers_below(&entry.path(), depth - 1);
1089 }
1090 }
1091}
1092
1093fn remove_if_old(path: &Path) {
1095 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) {
1096 let _ = std::fs::remove_file(path);
1097 }
1098}
1099
1100pub(crate) fn clean_leftovers(dir: &Path) {
1102 for entry in std::fs::read_dir(dir).into_iter().flatten().filter_map(Result::ok) {
1103 if entry.file_name().to_str().is_some_and(is_leftover) {
1104 remove_if_old(&entry.path());
1105 }
1106 }
1107}
1108
1109fn made_by_tools(name: &str) -> bool {
1113 let lower = name.to_lowercase();
1114 name.starts_with('.')
1115 || name.starts_with('~')
1116 || name.ends_with('~')
1117 || (name.starts_with('#') && name.ends_with('#'))
1118 || [".tmp", ".temp", ".part", ".partial", ".crdownload", ".download", ".swp", ".swx", ".new"].iter().any(|end| lower.ends_with(end))
1119 || ["conflicted copy", ".sync-conflict-", "_conflict-", "sfconflict"].iter().any(|word| lower.contains(word))
1120 || matches!(lower.as_str(), "thumbs.db" | "desktop.ini" | "4913")
1121}
1122
1123#[derive(Debug, Serialize, Deserialize)]
1125struct Reference {
1126 h: String,
1127 s: u64,
1128 t: i64,
1129}
1130
1131fn reference(h: &str, size: u64, modified_ns: u64) -> String {
1132 serde_json::to_string(&Reference { h: h.to_string(), s: size, t: (modified_ns / 1_000_000) as i64 }).unwrap_or_default()
1133}
1134
1135fn value_hash(key: &str, value: &str) -> String {
1137 if key.starts_with(FILES) { serde_json::from_str::<Reference>(value).map(|r| r.h).unwrap_or_default() } else { hash(value) }
1138}
1139
1140fn read_file(store: &Store, file: &str, path: &Path, known: &BTreeMap<String, Stat>, look: &Look, found: &mut Found) {
1141 let Ok(meta) = std::fs::metadata(path) else {
1142 found.unknown.insert(file.to_string());
1143 return;
1144 };
1145 let sealed_apart = matches!(store.shape, Shape::Files);
1146 if meta.len() > if sealed_apart { crate::blobs::LARGEST } else { LARGEST } {
1147 found.unknown.insert(file.to_string());
1148 if sealed_apart {
1149 found.too_big.push(file.to_string());
1150 }
1151 return;
1152 }
1153 let modified = modified_ns(&meta);
1154 found.changed.insert(file.to_string(), (modified / 1_000_000) as i64);
1155 let lines = matches!(store.shape, Shape::Lines);
1159 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)) {
1160 for (entry, h) in &stat.entries {
1161 found.hashes.insert(format!("{file}#{entry}"), h.clone());
1162 if sealed_apart {
1164 found.values.insert(format!("{file}#{entry}"), reference(h, stat.size, stat.modified));
1165 }
1166 }
1167 found.files.insert(file.to_string(), stat.clone());
1168 return;
1169 }
1170 if sealed_apart {
1171 let large = meta.len() >= LARGE_SETTLES && matches!(store.part, "notes" | "papers");
1181 let since = look.changing.get(file).copied().unwrap_or(look.clock);
1184 let settling = if large && look.clock - since < HOUR { large_settling_ms().max(settling_ms()) } else { settling_ms() };
1185 let fresh = look.clock - ((modified / 1_000_000) as i64) < settling;
1186 let read = (!fresh && !(look.hurry)()).then(|| crate::blobs::hash_file(path).ok()).flatten().filter(|(_, size)| *size == meta.len());
1187 let Some((h, _)) = read else {
1188 found.unknown.insert(file.to_string());
1189 if large {
1190 found.changing.insert(file.to_string(), since);
1191 }
1192 return;
1193 };
1194 let key = format!("{file}#");
1195 found.hashes.insert(key.clone(), h.clone());
1196 found.values.insert(key, reference(&h, meta.len(), modified));
1197 found.files.insert(file.to_string(), Stat { size: meta.len(), modified, entries: vec![(String::new(), h)], seen: look.clock });
1198 return;
1199 }
1200 if lines {
1201 let Some(lines) = lines_of(path, &meta, look.clock) else {
1204 found.unknown.insert(file.to_string());
1205 return;
1206 };
1207 let h = hash("");
1208 for line in lines.iter() {
1209 let key = format!("{file}#{line}");
1210 found.hashes.insert(key.clone(), h.clone());
1211 found.values.insert(key, String::new());
1212 }
1213 found.files.insert(file.to_string(), Stat { size: meta.len(), modified, entries: Vec::new(), seen: 0 });
1215 return;
1216 }
1217 let Some((entries, held)) = sioul_core::filelock::read_whole_with(path).ok().and_then(|(bytes, held)| Some((entries_of(store, &bytes)?, held))) else {
1225 found.unknown.insert(file.to_string());
1226 return;
1227 };
1228 let mut stat = Stat { size: held.len(), modified: modified_ns(&held), entries: Vec::with_capacity(entries.len()), seen: look.clock };
1229 for (entry, value) in entries {
1230 let key = format!("{file}#{entry}");
1231 let h = hash(&value);
1232 stat.entries.push((entry, h.clone()));
1233 found.hashes.insert(key.clone(), h);
1234 found.values.insert(key, value);
1235 }
1236 found.files.insert(file.to_string(), stat);
1237}
1238
1239type Lines = BTreeMap<PathBuf, (FileStamp, i64, std::sync::Arc<Vec<String>>)>;
1246static LINES: std::sync::Mutex<Lines> = std::sync::Mutex::new(BTreeMap::new());
1247
1248fn lines_of(path: &Path, meta: &std::fs::Metadata, clock: i64) -> Option<std::sync::Arc<Vec<String>>> {
1251 let stamp = file_stamp(meta);
1252 let settled = |read: i64| (stamp.modified / 1_000_000) as i64 + settling_ms() < read;
1253 if let Some((_, _, lines)) = keeping().then(|| LINES.lock().ok().and_then(|all| all.get(path).filter(|(was, read, _)| *was == stamp && settled(*read)).cloned())).flatten() {
1254 return Some(lines);
1255 }
1256 let bytes = std::fs::read(path).ok()?;
1257 #[cfg(test)]
1258 LINES_READ.with(|n| n.set(n.get() + 1));
1259 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());
1261 if keeping()
1263 && bytes.len() as u64 == stamp.size
1264 && let Ok(mut all) = LINES.lock()
1265 {
1266 all.insert(path.to_path_buf(), (stamp, clock, std::sync::Arc::clone(&lines)));
1267 }
1268 Some(lines)
1269}
1270
1271fn entries_of(store: &Store, bytes: &[u8]) -> Option<Vec<(String, String)>> {
1278 match store.shape {
1279 Shape::Whole => Some(vec![(String::new(), B64.encode(bytes))]),
1280 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()),
1281 Shape::Toml(rules) => toml_entries(std::str::from_utf8(bytes).ok()?, rules).ok(),
1282 Shape::Files => None,
1283 }
1284}
1285
1286fn text_to_write(path: &Path) -> Result<String, String> {
1290 match std::fs::read(path) {
1291 Ok(bytes) => String::from_utf8(bytes).map_err(|_| format!("share-unreadable:{}", path.display())),
1292 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
1293 Err(e) => Err(format!("share-unreadable:{}: {e}", path.display())),
1294 }
1295}
1296
1297fn value_of(stores: &[Store], key: &str) -> Option<String> {
1299 let (file, entry) = (file_of(key), entry_of(key));
1300 let (store, path) = locate(stores, file)?;
1301 if matches!(store.shape, Shape::Files) {
1302 let meta = std::fs::metadata(&path).ok()?;
1303 let (h, size) = crate::blobs::hash_file(&path).ok()?;
1304 return entry.is_empty().then(|| reference(&h, size, modified_ns(&meta)));
1305 }
1306 let bytes = sioul_core::filelock::read_whole(&path).ok()?;
1307 entries_of(store, &bytes)?.into_iter().find(|(e, _)| e == entry).map(|(_, value)| value)
1308}
1309
1310fn leaf_text(value: &toml::Value) -> String {
1312 let mut table = toml::Table::new();
1313 table.insert("v".into(), value.clone());
1314 toml::to_string(&table).unwrap_or_default()
1315}
1316
1317fn leaf_value(text: &str) -> Result<toml::Value, String> {
1318 let mut table: toml::Table = text.parse().map_err(|e: toml::de::Error| e.to_string())?;
1319 table.remove("v").ok_or_else(|| "no value".to_string())
1320}
1321
1322fn matches_rule(path: &[String], rule: &str) -> bool {
1323 let parts: Vec<&str> = rule.split('.').collect();
1324 parts.len() == path.len() && parts.iter().zip(path).all(|(r, p)| *r == "*" || r == p)
1325}
1326
1327fn identity(element: &toml::Value, by: &[&str]) -> String {
1329 if by.is_empty() {
1330 return leaf_text(element);
1331 }
1332 by.iter()
1333 .map(|field| match element.get(field) {
1334 Some(toml::Value::String(s)) => s.clone(),
1335 Some(other) => other.to_string(),
1336 None => String::new(),
1337 })
1338 .collect::<Vec<_>>()
1339 .join("\u{1d}")
1340}
1341
1342fn strip_local(element: &mut toml::Value, local: &[&str]) {
1343 if let toml::Value::Table(table) = element {
1344 for field in local {
1345 table.remove(*field);
1346 }
1347 }
1348}
1349
1350fn keyed_at<'r>(rules: &'r Rules, path: &[String]) -> Option<&'r Keyed> {
1353 rules.keyed.iter().find(|k| match path {
1354 [name] => k.list == name || k.also.contains(&name.as_str()),
1355 _ => k.list.contains('.') && matches_rule(path, k.list),
1356 })
1357}
1358
1359fn keyed_named<'r>(rules: &'r Rules, list: &[&str]) -> Option<&'r Keyed> {
1361 let path: Vec<String> = list.iter().map(|part| (*part).to_string()).collect();
1362 rules.keyed.iter().find(|k| match list {
1363 [name] => k.list == *name,
1364 _ => k.list.contains('.') && matches_rule(&path, k.list),
1365 })
1366}
1367
1368fn own_id(element: &toml::Value) -> Option<&str> {
1370 element.get("id").and_then(toml::Value::as_str).filter(|id| !id.is_empty())
1371}
1372
1373pub(crate) fn derived(list: &str, what: &str) -> String {
1380 use sha2::Digest;
1381 let mut hasher = sha2::Sha256::new();
1382 hasher.update(b"sioul-id\x1f");
1383 hasher.update(list.as_bytes());
1384 hasher.update(b"\x1f");
1385 hasher.update(what.as_bytes());
1386 let digest = hasher.finalize();
1387 let mut bytes = [0u8; 16];
1388 bytes.copy_from_slice(&digest[..16]);
1389 bytes[6] = (bytes[6] & 0x0f) | 0x80;
1390 bytes[8] = (bytes[8] & 0x3f) | 0x80;
1391 uuid::Uuid::from_bytes(bytes).to_string()
1392}
1393
1394fn names(elements: &[toml::Value], keyed: &Keyed) -> Vec<String> {
1403 if !keyed.ids {
1404 return elements.iter().map(|e| identity(e, keyed.by)).collect();
1405 }
1406 let mut ids: BTreeMap<&str, usize> = BTreeMap::new();
1407 for id in elements.iter().filter_map(own_id) {
1408 *ids.entry(id).or_default() += 1;
1409 }
1410 elements
1411 .iter()
1412 .map(|element| match own_id(element) {
1413 Some(id) if ids.get(id) == Some(&1) => id.to_string(),
1414 _ => derived(keyed.list, &identity(element, keyed.by)),
1415 })
1416 .collect()
1417}
1418
1419fn toml_entries(text: &str, rules: &Rules) -> Result<Vec<(String, String)>, String> {
1422 let table: toml::Table = text.parse().map_err(|e: toml::de::Error| e.to_string())?;
1423 let mut out = Vec::new();
1424 for (name, value) in &table {
1425 flatten(vec![name.clone()], value, rules, &mut out);
1426 }
1427 Ok(out)
1428}
1429
1430fn list_name(keyed: &Keyed, path: &[String]) -> String {
1432 if path.len() == 1 { keyed.list.to_string() } else { path.join(&SEP.to_string()) }
1433}
1434
1435fn flatten(path: Vec<String>, value: &toml::Value, rules: &Rules, out: &mut Vec<(String, String)>) {
1436 if rules.local.iter().any(|rule| matches_rule(&path, rule)) {
1437 return;
1438 }
1439 if let toml::Value::Array(elements) = value
1440 && let Some(keyed) = keyed_at(rules, &path)
1441 {
1442 let list = list_name(keyed, &path);
1443 let elements: Vec<toml::Value> = elements
1444 .iter()
1445 .map(|element| {
1446 let mut element = element.clone();
1447 strip_local(&mut element, keyed.local);
1448 element
1449 })
1450 .collect();
1451 for (element, name) in elements.iter().zip(names(&elements, keyed)) {
1452 out.push((format!("{list}{SEP}{MARK}{name}"), leaf_text(element)));
1453 }
1454 return;
1455 }
1456 match value {
1457 toml::Value::Table(table) if !rules.whole.iter().any(|rule| matches_rule(&path, rule)) => {
1458 for (name, value) in table {
1459 let mut deeper = path.clone();
1460 deeper.push(name.clone());
1461 flatten(deeper, value, rules, out);
1462 }
1463 }
1464 _ => out.push((path.join(&SEP.to_string()), leaf_text(value))),
1465 }
1466}
1467
1468fn write_entries(store: &Store, path: &Path, changes: &[(&str, Option<&str>)], keep: impl FnOnce() -> Result<(), String>) -> Result<BTreeMap<String, String>, String> {
1475 sioul_core::filelock::with_lock(path, || {
1476 let new = rewritten(store, path, changes)?;
1477 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();
1479 let old = std::fs::read(path).ok();
1480 if old == new {
1481 return Ok(written);
1482 }
1483 if old.is_none() && of_the_doses(store) && written.is_empty() {
1487 return Ok(written);
1488 }
1489 if old.is_some() {
1490 keep()?;
1491 }
1492 match new {
1493 Some(bytes) if of_the_doses(store) => sioul_core::health::write_witnessed(path, &bytes)?,
1499 Some(bytes) => write_atomically(path, &bytes)?,
1500 None => match std::fs::remove_file(path) {
1501 Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(format!("{}: {e}", path.display())),
1502 _ => {}
1503 },
1504 }
1505 Ok(written)
1506 })
1507}
1508
1509fn of_the_doses(store: &Store) -> bool {
1513 matches!(store.name.as_str(), "state/health-state.toml" | "state/health-doses.toml" | "state/health-reminders.toml")
1514}
1515
1516fn rewritten(store: &Store, path: &Path, changes: &[(&str, Option<&str>)]) -> Result<Option<Vec<u8>>, String> {
1518 match store.shape {
1519 Shape::Whole => {
1520 let (_, value) = changes.last().ok_or("nothing")?;
1521 value.map(|value| B64.decode(value).map_err(|e| e.to_string())).transpose()
1522 }
1523 Shape::Files => Err(format!("{}: sealed apart", path.display())),
1524 Shape::Lines => {
1525 let text = text_to_write(path)?;
1526 let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
1527 for (entry, value) in changes {
1528 let at = lines.iter().position(|l| l.trim() == *entry);
1529 match (value, at) {
1530 (Some(_), None) => lines.push(entry.to_string()),
1531 (None, Some(at)) => {
1532 lines.remove(at);
1533 }
1534 _ => {}
1535 }
1536 }
1537 let mut text = lines.join("\n");
1538 if !text.is_empty() {
1539 text.push('\n');
1540 }
1541 Ok(Some(text.into_bytes()))
1542 }
1543 Shape::Toml(rules) => {
1544 let text = text_to_write(path)?;
1545 let renamed = path.file_name() != Path::new(&store.name).file_name();
1548 Ok(Some(toml_write(&text, rules, changes, renamed)?.into_bytes()))
1549 }
1550 }
1551}
1552
1553pub fn write_atomically(path: &Path, bytes: &[u8]) -> Result<(), String> {
1557 write_then_rename(path, bytes, false)
1558}
1559
1560pub(crate) fn write_synced(path: &Path, bytes: &[u8]) -> Result<(), String> {
1563 write_then_rename(path, bytes, true)
1564}
1565
1566fn write_then_rename(path: &Path, bytes: &[u8], synced: bool) -> Result<(), String> {
1567 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
1568 if let Some(parent) = path.parent() {
1569 private_dirs(parent).map_err(fail)?;
1570 }
1571 let temporary = temporary(path);
1575 let written = (|| {
1576 let mut file = new_private(&temporary)?;
1577 file.write_all(bytes)?;
1578 if synced {
1579 file.sync_all()?;
1580 }
1581 std::fs::rename(&temporary, path)
1582 })();
1583 if written.is_err() {
1584 let _ = std::fs::remove_file(&temporary);
1585 }
1586 written.map_err(fail)
1587}
1588
1589pub(crate) fn read_small(path: &Path) -> Option<String> {
1592 use std::io::Read;
1593 let mut text = String::new();
1594 std::fs::File::open(path).ok()?.take(SMALL_FILE + 1).read_to_string(&mut text).ok()?;
1595 (text.len() as u64 <= SMALL_FILE).then_some(text)
1596}
1597
1598pub(crate) const SMALL_FILE: u64 = 64 << 10;
1600
1601pub(crate) fn temporary(path: &Path) -> PathBuf {
1604 static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1605 let name = path.file_name().map_or_else(|| "file".into(), |n| n.to_string_lossy().to_string());
1606 let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1607 path.with_file_name(format!(".{name}.{}-{n}{TEMPORARY}", std::process::id()))
1608}
1609
1610pub(crate) const TEMPORARY: &str = ".sioul.tmp";
1613
1614pub(crate) fn set_time(path: &Path, when: std::time::SystemTime) -> std::io::Result<()> {
1624 #[cfg(windows)]
1625 {
1626 use std::os::windows::fs::OpenOptionsExt;
1627 const FILE_WRITE_ATTRIBUTES: u32 = 0x0100;
1628 const SYNCHRONIZE: u32 = 0x0010_0000;
1629 std::fs::OpenOptions::new().access_mode(FILE_WRITE_ATTRIBUTES | SYNCHRONIZE).open(path)?.set_modified(when)
1630 }
1631 #[cfg(not(windows))]
1632 {
1633 match std::fs::File::open(path)?.set_modified(when) {
1634 Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => std::fs::File::options().write(true).open(path)?.set_modified(when),
1635 done => done,
1636 }
1637 }
1638}
1639
1640pub fn private_dirs(dir: &Path) -> std::io::Result<()> {
1653 let mut builder = std::fs::DirBuilder::new();
1654 builder.recursive(true);
1655 #[cfg(unix)]
1656 std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
1657 builder.create(dir)
1658}
1659
1660pub(crate) fn new_private(path: &Path) -> std::io::Result<std::fs::File> {
1662 let mut options = std::fs::OpenOptions::new();
1663 options.write(true).create_new(true);
1664 #[cfg(unix)]
1665 std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
1666 options.open(path)
1667}
1668
1669pub(crate) fn make_private(path: &Path) {
1672 #[cfg(unix)]
1673 {
1674 use std::os::unix::fs::PermissionsExt;
1675 if let Ok(metadata) = std::fs::symlink_metadata(path)
1676 && metadata.is_file()
1677 && metadata.permissions().mode() & 0o077 != 0
1678 {
1679 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
1680 }
1681 }
1682 #[cfg(not(unix))]
1683 let _ = path;
1684}
1685
1686pub(crate) fn copy_private(from: &Path, to: &Path) -> std::io::Result<()> {
1690 sioul_core::filelock::copy_whole(from, to)?;
1691 make_private(to);
1692 Ok(())
1693}
1694
1695const PAD_LIMIT: u64 = 4096;
1697
1698pub(crate) fn pad_for(path: &Path, size: impl Fn(usize) -> u64) -> usize {
1710 let base = size(0);
1711 let Ok(before) = std::fs::metadata(path).map(|m| m.len()) else { return 0 };
1712 if before < base + PAD_LIMIT {
1713 let mut pad = usize::try_from(before.saturating_sub(base) * 3 / 4).unwrap_or(0);
1715 while size(pad) <= before {
1716 pad += 1;
1717 }
1718 if size(pad) <= base + PAD_LIMIT {
1719 return pad;
1720 }
1721 }
1722 (0..).find(|&pad| size(pad) != before).unwrap_or(0)
1724}
1725
1726fn edit_item(text: &str) -> Result<toml_edit::Item, String> {
1728 let doc: toml_edit::DocumentMut = text.parse().map_err(|e: toml_edit::TomlError| e.to_string())?;
1729 let mut item = doc.as_table().get("v").cloned().ok_or("no value")?;
1730 match &mut item {
1731 toml_edit::Item::Value(value) => value.decor_mut().clear(),
1732 toml_edit::Item::Table(table) => table.decor_mut().clear(),
1733 _ => {}
1734 }
1735 Ok(item)
1736}
1737
1738fn toml_write(text: &str, rules: &Rules, changes: &[(&str, Option<&str>)], renamed: bool) -> Result<String, String> {
1741 let mut doc: toml_edit::DocumentMut = text.parse().map_err(|e: toml_edit::TomlError| e.to_string())?;
1742 let mut lists: Vec<(Vec<&str>, Vec<(&str, Option<&str>)>)> = Vec::new();
1744 for (entry, value) in changes {
1745 let path: Vec<&str> = entry.split(SEP).collect();
1746 if let Some((element, list)) = path.split_last()
1747 && let Some(id) = element.strip_prefix(MARK)
1748 && !list.is_empty()
1749 {
1750 match lists.iter_mut().find(|(known, _)| known.as_slice() == list) {
1751 Some((_, elements)) => elements.push((id, *value)),
1752 None => lists.push((list.to_vec(), vec![(id, *value)])),
1753 }
1754 } else {
1755 let item = value.map(edit_item).transpose()?;
1756 set_path(doc.as_table_mut(), &path, item.as_ref())?;
1757 }
1758 }
1759 for (list, elements) in lists {
1760 let keyed = keyed_named(rules, &list).ok_or_else(|| format!("{}: not a list", list.join(".")))?;
1761 let Some((name, within)) = list.split_last() else { continue };
1762 let Some(table) = table_at(doc.as_table_mut(), within, elements.iter().any(|(_, value)| value.is_some()))? else { continue };
1763 write_elements(table, keyed, name, &elements, renamed && within.is_empty())?;
1764 }
1765 Ok(doc.to_string())
1766}
1767
1768fn table_at<'t>(table: &'t mut toml_edit::Table, path: &[&str], make: bool) -> Result<Option<&'t mut toml_edit::Table>, String> {
1772 let Some((first, rest)) = path.split_first() else { return Ok(Some(table)) };
1773 if !make && !table.contains_key(first) {
1774 return Ok(None);
1775 }
1776 let item = table.entry(first).or_insert_with(|| {
1777 let mut new = toml_edit::Table::new();
1778 new.set_implicit(true);
1779 toml_edit::Item::Table(new)
1780 });
1781 if let Some(inline) = item.as_inline_table().cloned() {
1782 *item = toml_edit::Item::Table(inline.into_table());
1783 }
1784 let inner = item.as_table_mut().ok_or_else(|| format!("{first}: not a table"))?;
1785 table_at(inner, rest, make)
1786}
1787
1788fn names_read(elements: &[Option<toml::Value>], keyed: &Keyed) -> Vec<Option<String>> {
1790 let read: Vec<toml::Value> = elements.iter().flatten().cloned().collect();
1791 let mut named = names(&read, keyed).into_iter();
1792 elements.iter().map(|element| element.as_ref().and_then(|_| named.next())).collect()
1793}
1794
1795fn set_path(table: &mut toml_edit::Table, path: &[&str], value: Option<&toml_edit::Item>) -> Result<(), String> {
1796 match path {
1797 [] => Ok(()),
1798 [last] => {
1799 match value {
1800 Some(toml_edit::Item::Value(new)) if table.get(last).is_some_and(toml_edit::Item::is_value) => {
1802 if let Some(old) = table.get_mut(last).and_then(toml_edit::Item::as_value_mut) {
1803 let decor = old.decor().clone();
1804 *old = new.clone();
1805 *old.decor_mut() = decor;
1806 }
1807 }
1808 Some(item) => {
1809 table.insert(last, item.clone());
1810 }
1811 None => {
1812 table.remove(last);
1813 }
1814 }
1815 Ok(())
1816 }
1817 [first, rest @ ..] => {
1818 if value.is_none() && !table.contains_key(first) {
1819 return Ok(());
1820 }
1821 let item = table.entry(first).or_insert_with(|| {
1822 let mut new = toml_edit::Table::new();
1823 new.set_implicit(true);
1824 toml_edit::Item::Table(new)
1825 });
1826 if let Some(inline) = item.as_inline_table().cloned() {
1827 *item = toml_edit::Item::Table(inline.into_table());
1828 }
1829 let inner = item.as_table_mut().ok_or_else(|| format!("{first}: not a table"))?;
1830 set_path(inner, rest, value)
1831 }
1832 }
1833}
1834
1835fn place_of(store: &Store) -> String {
1840 let renamed = !store.folder && store.path.file_name().is_some_and(|name| name == sioul_core::projects::FILE);
1841 let path = if renamed { store.path.with_file_name(sioul_core::projects::OLD_FILE) } else { store.path.clone() };
1842 path.display().to_string()
1843}
1844
1845fn placed(mut table: toml_edit::Table, at: Option<isize>) -> toml_edit::Table {
1852 fn clear(table: &mut toml_edit::Table) {
1853 table.set_position(None);
1854 for (_, item) in table.iter_mut() {
1855 match item {
1856 toml_edit::Item::Table(inner) => clear(inner),
1857 toml_edit::Item::ArrayOfTables(list) => list.iter_mut().for_each(clear),
1858 _ => {}
1859 }
1860 }
1861 }
1862 clear(&mut table);
1863 table.set_position(at);
1864 table
1865}
1866
1867fn write_elements(table: &mut toml_edit::Table, keyed: &Keyed, list: &str, changes: &[(&str, Option<&str>)], renamed: bool) -> Result<(), String> {
1874 let normal = |text: String| -> Option<toml::Value> {
1875 let mut element = leaf_value(&text).ok()?;
1876 strip_local(&mut element, keyed.local);
1877 Some(element)
1878 };
1879 let news: Vec<(&str, Option<toml_edit::Item>)> = changes.iter().map(|(id, value)| Ok((*id, value.map(edit_item).transpose()?))).collect::<Result<_, String>>()?;
1880 let others: &[&str] = if keyed.list == list { keyed.also } else { &[] };
1881 let name = std::iter::once(list).chain(others.iter().copied()).find(|n| table.contains_key(n)).unwrap_or(match others.first() {
1882 Some(other) if renamed => other,
1883 _ => list,
1884 });
1885 if !table.contains_key(name) {
1886 match news.iter().find_map(|(_, new)| new.as_ref()) {
1887 Some(toml_edit::Item::Table(_)) => {
1888 table.insert(name, toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
1889 }
1890 Some(_) => {
1891 table.insert(name, toml_edit::value(toml_edit::Array::new()));
1892 }
1893 None => return Ok(()),
1894 }
1895 }
1896 let mut gone: Vec<usize> = Vec::new();
1897 match table.get_mut(name) {
1898 Some(toml_edit::Item::ArrayOfTables(elements)) => {
1899 let named = names_read(&elements.iter().map(|t| normal(format!("[v]\n{t}"))).collect::<Vec<_>>(), keyed);
1900 let mut added: Vec<toml_edit::Table> = Vec::new();
1901 for (id, new) in news {
1902 let at = named.iter().position(|n| n.as_deref() == Some(id));
1903 match (new, at) {
1904 (Some(toml_edit::Item::Table(mut element)), Some(at)) => {
1905 if let Some(old) = elements.get(at) {
1906 for field in keyed.local {
1907 if let Some(kept) = old.get(field) {
1908 element.insert(field, kept.clone());
1909 }
1910 }
1911 if keyed.ids
1915 && !element.contains_key("id")
1916 && let Some(id) = old.get("id")
1917 {
1918 element.insert("id", id.clone());
1919 }
1920 }
1921 if let Some(place) = elements.get_mut(at) {
1922 let position = place.position();
1923 *place = placed(element, position);
1924 }
1925 }
1926 (Some(toml_edit::Item::Table(element)), None) => added.push(element),
1927 (None, Some(at)) => gone.push(at),
1928 _ => {}
1929 }
1930 }
1931 for element in added {
1932 elements.push(placed(element, None));
1933 }
1934 gone.sort_unstable();
1935 for at in gone.into_iter().rev() {
1936 elements.remove(at);
1937 }
1938 }
1939 Some(toml_edit::Item::Value(toml_edit::Value::Array(elements))) => {
1940 let named = names_read(&elements.iter().map(|v| normal(format!("v = {v}"))).collect::<Vec<_>>(), keyed);
1941 let mut added: Vec<toml_edit::Value> = Vec::new();
1942 for (id, new) in news {
1943 let at = named.iter().position(|n| n.as_deref() == Some(id));
1944 match (new, at) {
1945 (Some(toml_edit::Item::Value(element)), Some(at)) => {
1946 elements.replace(at, element);
1947 }
1948 (Some(toml_edit::Item::Value(element)), None) => added.push(element),
1949 (None, Some(at)) => gone.push(at),
1950 _ => {}
1951 }
1952 }
1953 for element in added {
1954 elements.push(element);
1955 }
1956 gone.sort_unstable();
1957 for at in gone.into_iter().rev() {
1958 elements.remove(at);
1959 }
1960 elements.fmt();
1961 }
1962 _ => return Err(format!("{name}: not a list")),
1963 }
1964 Ok(())
1965}
1966
1967#[derive(Debug, Serialize, Deserialize)]
1972struct SealFile {
1973 version: u32,
1974 salt: String,
1975 memory_kib: u32,
1976 passes: u32,
1977 check: String,
1978}
1979
1980const CHECK: &[u8] = b"sioul: the passphrase is right";
1981
1982#[derive(Debug, PartialEq, Eq)]
1984pub enum Refused {
1985 WrongPassphrase,
1987 Other(String),
1988}
1989
1990fn derive(passphrase: &str, salt: &[u8], memory_kib: u32, passes: u32) -> Result<[u8; 32], String> {
1991 let params = argon2::Params::new(memory_kib, passes, 1, Some(32)).map_err(|e| e.to_string())?;
1992 let mut key = [0u8; 32];
1993 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())?;
1994 Ok(key)
1995}
1996
1997pub fn is_seal(bytes: &[u8]) -> bool {
2002 bytes.len() <= 4096 && std::str::from_utf8(bytes).ok().and_then(|text| toml::from_str::<SealFile>(text).ok()).is_some_and(|seal| !seal.salt.is_empty() && !seal.check.is_empty())
2003}
2004
2005pub fn key_for(folder: &Path, passphrase: &str) -> Result<[u8; 32], Refused> {
2008 key_with(folder, passphrase, 64 * 1024, 3)
2009}
2010
2011fn key_with(folder: &Path, passphrase: &str, memory_kib: u32, passes: u32) -> Result<[u8; 32], Refused> {
2012 let path = folder.join("seal.toml");
2013 if path.exists() {
2014 let text = read_small(&path).ok_or_else(|| Refused::Other(format!("{}: not a seal", path.display())))?;
2016 let seal: SealFile = toml::from_str(&text).map_err(|e| Refused::Other(format!("{}: {e}", path.display())))?;
2017 if seal.memory_kib > 1 << 20 || seal.passes > 16 {
2020 return Err(Refused::Other(format!("{}: {} KiB, {} passes", path.display(), seal.memory_kib, seal.passes)));
2021 }
2022 let salt = B64.decode(&seal.salt).map_err(|e| Refused::Other(e.to_string()))?;
2023 let key = derive(passphrase, &salt, seal.memory_kib, seal.passes).map_err(Refused::Other)?;
2024 return match open(&key, "check", &seal.check) {
2025 Some(plain) if plain == CHECK => Ok(key),
2026 _ => Err(Refused::WrongPassphrase),
2027 };
2028 }
2029 let mut salt = [0u8; 16];
2030 OsRng.fill_bytes(&mut salt);
2031 let key = derive(passphrase, &salt, memory_kib, passes).map_err(Refused::Other)?;
2032 let seal = SealFile { version: 1, salt: B64.encode(salt), memory_kib, passes, check: seal(&key, "check", CHECK) };
2033 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()))?);
2034 private_dirs(folder).map_err(|e| Refused::Other(format!("{}: {e}", folder.display())))?;
2035 write_atomically(&path, text.as_bytes()).map_err(Refused::Other)?;
2036 Ok(key)
2037}
2038
2039fn sealed_for(folder: &Path, key: &[u8; 32]) -> Result<Option<(bool, String)>, ()> {
2052 if !folder.is_dir() {
2053 return Err(());
2054 }
2055 let Some(seal) = read_small(&folder.join("seal.toml")).and_then(|text| toml::from_str::<SealFile>(&text).ok()) else { return Ok(None) };
2056 Ok(Some((open(key, "check", &seal.check).is_some_and(|plain| plain == CHECK), seal.salt)))
2057}
2058
2059pub(crate) fn seal(key: &[u8; 32], bound: &str, plain: &[u8]) -> String {
2060 let cipher = XChaCha20Poly1305::new(key.into());
2061 let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
2062 let sealed = cipher.encrypt(&nonce, Payload { msg: plain, aad: bound.as_bytes() }).unwrap_or_default();
2063 B64.encode([nonce.as_slice(), &sealed].concat())
2064}
2065
2066pub(crate) fn open(key: &[u8; 32], bound: &str, text: &str) -> Option<Vec<u8>> {
2067 let bytes = B64.decode(text).ok()?;
2068 if bytes.len() < 24 {
2069 return None;
2070 }
2071 let (nonce, sealed) = bytes.split_at(24);
2072 XChaCha20Poly1305::new(key.into()).decrypt(XNonce::from_slice(nonce), Payload { msg: sealed, aad: bound.as_bytes() }).ok()
2073}
2074
2075#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2081pub struct Here {
2082 #[serde(default)]
2083 pub id: String,
2084 #[serde(default)]
2085 pub machine: String,
2086 #[serde(default, skip_serializing_if = "Option::is_none")]
2087 pub folder: Option<String>,
2088 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2091 pub parts: BTreeMap<String, bool>,
2092 #[serde(default, skip_serializing_if = "Option::is_none")]
2096 pub notes_carried: Option<bool>,
2097 #[serde(default, skip_serializing_if = "Option::is_none")]
2103 pub notes_carried_for: Option<String>,
2104}
2105
2106impl Here {
2107 pub fn shares(&self, part: &str, config: &Config) -> bool {
2109 self.parts.get(part).copied().unwrap_or_else(|| shared_by_default(part, config))
2110 }
2111
2112 pub fn path(state: &Path) -> PathBuf {
2113 state.join("share").join("here.toml")
2114 }
2115
2116 pub fn load(state: &Path) -> Here {
2118 let path = Here::path(state);
2119 let mut here: Here = std::fs::read_to_string(&path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default();
2120 let machine = machine();
2121 if here.id.is_empty() || (!machine.is_empty() && here.machine != machine) {
2122 here.id = uuid::Uuid::new_v4().to_string();
2123 here.machine = machine;
2124 let _ = here.save(state);
2125 }
2126 here
2127 }
2128
2129 pub fn save(&self, state: &Path) -> Result<(), String> {
2131 write_synced(&Here::path(state), toml::to_string(self).map_err(|e| e.to_string())?.as_bytes())
2132 }
2133
2134 pub fn folder_path(&self) -> Option<PathBuf> {
2136 self.folder.as_deref().filter(|f| !f.is_empty()).map(sioul_core::config::expand_home)
2137 }
2138
2139 pub fn notes_said(&self, notes: &Path) -> Option<bool> {
2142 self.notes_carried.filter(|_| self.notes_carried_for.as_deref().is_none_or(|said| Path::new(said) == notes))
2143 }
2144}
2145
2146fn machine() -> String {
2148 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();
2149 let id = id.trim();
2150 if id.is_empty() { String::new() } else { format!("{:016x}", fnv(id.as_bytes())) }
2151}
2152
2153fn fnv(bytes: &[u8]) -> u64 {
2154 bytes.iter().fold(0xcbf2_9ce4_8422_2325u64, |hash, b| (hash ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3))
2155}
2156
2157pub(crate) fn hash(value: &str) -> String {
2158 format!("{:016x}", fnv(value.as_bytes()))
2159}
2160
2161#[derive(Debug, Default, Serialize, Deserialize)]
2168struct Memory {
2169 #[serde(default)]
2170 computer: String,
2171 #[serde(default)]
2173 clock: u64,
2174 #[serde(default)]
2176 round: u32,
2177 #[serde(default)]
2178 seq: u64,
2179 #[serde(default)]
2182 own_size: u64,
2183 #[serde(default)]
2187 round_base: u64,
2188 #[serde(default)]
2192 full_round: u32,
2193 #[serde(default)]
2195 grown: u64,
2196 #[serde(default, skip_serializing_if = "is_zero_u64")]
2201 full_size: u64,
2202 #[serde(default, skip_serializing_if = "is_zero_u64")]
2206 continues: u64,
2207 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2213 owed: bool,
2214 #[serde(default, skip_serializing_if = "is_zero_i64")]
2218 owed_waited: i64,
2219 #[serde(default, skip_serializing_if = "is_zero_i64")]
2220 owed_last: i64,
2221 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2226 turns: bool,
2227 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2230 seal_seen: bool,
2231 #[serde(default, skip_serializing_if = "String::is_empty")]
2234 seal_salt: String,
2235 #[serde(default, skip_serializing_if = "is_zero")]
2239 ahead_at: i64,
2240 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2243 drafts: BTreeMap<String, BTreeSet<String>>,
2244 #[serde(default)]
2247 checked: i64,
2248 #[serde(default)]
2249 tidied: i64,
2250 #[serde(default, skip_serializing_if = "is_zero")]
2254 cleaned: i64,
2255 #[serde(default)]
2257 joined: bool,
2258 #[serde(default)]
2260 read: BTreeMap<String, (u32, u64)>,
2261 #[serde(default)]
2265 read_n: BTreeMap<String, (u32, u64)>,
2266 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2273 met: BTreeMap<String, (u32, u64)>,
2274 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2279 waiting_for: BTreeMap<String, Awaited>,
2280 #[serde(default)]
2282 broken: BTreeMap<String, i64>,
2283 #[serde(default)]
2286 rebuild: BTreeSet<String>,
2287 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
2294 rejoin: BTreeSet<String>,
2295 #[serde(default)]
2298 pending: BTreeMap<String, (Option<String>, u64, String)>,
2299 #[serde(default)]
2301 entries: BTreeMap<String, Known>,
2302 #[serde(default)]
2305 places: BTreeMap<String, String>,
2306 #[serde(default)]
2309 emptied: BTreeMap<String, i64>,
2310 #[serde(default)]
2312 files: BTreeMap<String, Stat>,
2313 #[serde(default)]
2315 traffic: BTreeMap<String, (i64, i64)>,
2316 #[serde(default)]
2320 format: u32,
2321 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2324 switch: bool,
2325 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2329 floors: BTreeMap<String, Floor>,
2330 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2334 waiting: BTreeMap<String, (Option<String>, u64, String, u32)>,
2335 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2339 newer: BTreeMap<String, Newer>,
2340 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2343 released: BTreeMap<String, u64>,
2344 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2349 held: BTreeMap<String, (u64, String)>,
2350 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2354 heard_at: BTreeMap<String, i64>,
2355 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2358 forgotten: BTreeMap<String, i64>,
2359 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2362 seen: BTreeMap<String, crate::devices::Entry>,
2363 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2370 unclocked: BTreeMap<String, i64>,
2371 #[serde(default, skip_serializing_if = "Option::is_none")]
2378 counting: Option<BTreeSet<String>>,
2379 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
2380 uncounted: BTreeSet<String>,
2381 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2386 departed: BTreeMap<String, u32>,
2387 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2392 taken: BTreeMap<String, i64>,
2393 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2397 withheld: Vec<Withheld>,
2398 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2405 removed_devices: BTreeMap<String, RemovedDevice>,
2406 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2413 lists: BTreeMap<String, WholeList>,
2414 #[serde(skip)]
2416 sealed: Sealed,
2417 #[serde(skip)]
2420 saved: Vec<u8>,
2421}
2422
2423#[derive(Debug, Default, Serialize, Deserialize)]
2427struct Sealed {
2428 #[serde(default)]
2429 computer: String,
2430 #[serde(default)]
2431 entries: BTreeMap<String, Known>,
2432 #[serde(default)]
2433 files: BTreeMap<String, Stat>,
2434 #[serde(default)]
2435 pending: BTreeMap<String, (Option<String>, u64, String)>,
2436 #[serde(default)]
2438 bases: BTreeMap<String, String>,
2439 #[serde(default)]
2442 named: BTreeMap<String, BTreeSet<String>>,
2443 #[serde(default)]
2446 mine: BTreeMap<String, (i64, u64)>,
2447 #[serde(default)]
2452 named_at: BTreeMap<String, i64>,
2453 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2462 theirs: BTreeMap<String, (String, i64)>,
2463 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2472 removed: BTreeMap<String, (String, u32)>,
2473 #[serde(default)]
2476 damaged: BTreeMap<String, (u64, u64, u64)>,
2477 #[serde(default)]
2479 missing: BTreeMap<String, i64>,
2480 #[serde(default)]
2483 failing: BTreeMap<String, (String, i64, u32)>,
2484 #[serde(default)]
2487 gone: BTreeMap<String, i64>,
2488 #[serde(default)]
2493 confirmed: BTreeSet<String>,
2494 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
2498 confirmed_files: BTreeSet<String>,
2499 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
2503 bulk: BTreeSet<String>,
2504 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2512 replaced: BTreeMap<String, i64>,
2513 #[serde(default, skip_serializing_if = "Option::is_none")]
2525 needed: Option<BTreeSet<String>>,
2526 #[serde(default, skip_serializing)]
2532 checked: i64,
2533 #[serde(default, skip_serializing)]
2534 tidied: i64,
2535 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2541 tables: Vec<String>,
2542 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2550 changing: BTreeMap<String, i64>,
2551}
2552
2553const KEPT_TABLES: usize = 2;
2560
2561fn prune_tables(folder: &Path, sealed: &mut Sealed, used: &BTreeSet<String>) {
2569 sealed.tables.retain(|name| sealed.mine.contains_key(name));
2570 while sealed.tables.len() > KEPT_TABLES {
2571 let old = sealed.tables.remove(0);
2572 if used.contains(&old) {
2573 continue;
2574 }
2575 let _ = std::fs::remove_file(folder.join("blobs").join(&old));
2576 sealed.mine.remove(&old);
2577 }
2578}
2579
2580fn win(winners: &mut BTreeMap<String, (Option<String>, u64, String)>, theirs_now: &mut Vec<(String, String)>, computer: &str, key: String, change: (Option<String>, u64, String)) {
2584 let files = key.starts_with(FILES).then(|| key.clone());
2585 if let Some((Some(value), _, writer)) = winners.insert(key, change)
2586 && writer != computer
2587 && let Some(key) = files
2588 {
2589 let h = value_hash(&key, &value);
2590 if !h.is_empty() {
2591 theirs_now.push((h, writer));
2592 }
2593 }
2594}
2595
2596#[allow(clippy::too_many_arguments)]
2615fn removed_due(folder: &Path, key: &[u8; 32], sealed: &mut Sealed, removed_now: Vec<(String, String, u32)>, current: &BTreeSet<String>, reading: &BTreeSet<String>, last: &mut BTreeMap<String, i64>, days: i64, early: bool, now_ms: i64) {
2616 for (h, writer, round) in removed_now.into_iter().filter(|(h, _, _)| !current.contains(h)) {
2617 let name = crate::blobs::name(key, &h);
2618 if sealed.mine.contains_key(&name) {
2619 sealed.removed.insert(name, (writer, round));
2620 }
2621 }
2622 let current: BTreeSet<String> = current.iter().map(|h| crate::blobs::name(key, h)).collect();
2623 sealed.removed.retain(|name, _| sealed.mine.contains_key(name) && !current.contains(name));
2624 if !early {
2625 return;
2626 }
2627 let read_past = |writer: &str, round: u32| reading.iter().filter(|r| r.as_str() != writer).all(|r| read_seen(folder, r).and_then(|s| s.read.get(writer).copied()).unwrap_or(0) > round);
2628 for (name, (writer, round)) in &sealed.removed {
2632 if read_past(writer, *round)
2633 && !sealed.replaced.contains_key(name)
2634 && let Some(at) = last.get_mut(name)
2635 {
2636 *at = (*at).min(now_ms - days * DAY);
2637 }
2638 }
2639}
2640
2641const FILE_PARTS: [&str; 3] = ["spam", "papers", "notes"];
2645
2646fn sweep_theirs(sharing: &Sharing, stores: &[Store], memory: &mut Memory, counted: &[(String, Counted)], theirs_now: Vec<(String, String)>, current_before: &BTreeSet<String>, now_ms: i64) {
2665 let blobs = sharing.folder.join("blobs");
2666 memory.taken.retain(|name, at| now_ms - *at < crate::blobs::UNUSED_DAYS * DAY && !blobs.join(name).exists());
2667 let current: BTreeSet<String> = memory.entries.iter().filter(|(key, known)| key.starts_with(FILES) && !known.h.is_empty()).map(|(_, known)| known.h.clone()).collect();
2668 for (h, writer) in theirs_now.into_iter().filter(|(h, _)| !current.contains(h)) {
2669 memory.sealed.theirs.insert(crate::blobs::name(sharing.key, &h), (writer, now_ms));
2670 }
2671 if memory.sealed.theirs.is_empty() {
2672 return;
2673 }
2674 let days = unused_days(sharing, memory, now_ms);
2675 let departed: BTreeSet<&str> = counted.iter().filter(|(_, counted)| counted.departed()).map(|(id, _)| id.as_str()).collect();
2676 memory.sealed.theirs.retain(|name, (writer, at)| departed.contains(writer.as_str()) || now_ms - *at < days * DAY || blobs.join(name).exists());
2678 let mut last: BTreeMap<String, i64> = memory.sealed.theirs.iter().filter(|(_, (writer, _))| departed.contains(writer.as_str())).map(|(name, (_, at))| (name.clone(), *at)).collect();
2679 if last.is_empty() {
2680 return;
2681 }
2682 let removed: BTreeSet<&str> = memory.removed_devices.keys().map(String::as_str).collect();
2686 let orphaned: BTreeSet<&String> = if removed.is_empty() {
2687 BTreeSet::new()
2688 } else {
2689 let files = memory.entries.iter().filter(|(key, known)| key.starts_with(FILES) && !known.h.is_empty());
2690 let named: BTreeSet<&String> = files.clone().filter(|(_, known)| !removed.contains(known.w.as_str())).map(|(_, known)| &known.h).collect();
2691 files.filter(|(_, known)| removed.contains(known.w.as_str()) && !named.contains(&known.h)).map(|(_, known)| &known.h).collect()
2692 };
2693 let used: BTreeSet<String> = current
2694 .iter()
2695 .chain(current_before)
2696 .filter(|h| !orphaned.contains(h))
2697 .cloned()
2698 .chain(memory.pending.iter().filter(|(key, _)| key.starts_with(FILES)).filter_map(|(key, (value, _, _))| value.as_deref().map(|v| value_hash(key, v))))
2699 .map(|h| crate::blobs::name(sharing.key, &h))
2700 .chain(memory.sealed.named_at.keys().cloned())
2701 .chain(memory.sealed.mine.keys().cloned())
2702 .collect();
2703 let unused = days * DAY;
2704 let fresh = |name: &str| std::fs::metadata(blobs.join(name)).ok().and_then(|m| m.modified().ok()).and_then(|t| t.elapsed().ok()).is_some_and(|age| (age.as_millis() as i64) < unused);
2705 let due = last.iter().any(|(name, at)| !used.contains(name) && now_ms - *at >= unused && !fresh(name));
2706 let every_part = FILE_PARTS.iter().all(|part| stores.iter().any(|s| s.part == *part && matches!(s.shape, Shape::Files)));
2707 if due && every_part && let Some(media) = texts_media(sharing, stores, memory, counted) {
2708 let used: BTreeSet<String> = used.into_iter().chain(media).collect();
2709 let before: Vec<String> = last.keys().cloned().collect();
2710 crate::blobs::sweep_after(sharing.folder, &mut last, &used, now_ms, days);
2711 for name in before.into_iter().filter(|name| !last.contains_key(name)) {
2712 memory.sealed.theirs.remove(&name);
2713 memory.taken.insert(name, now_ms);
2714 }
2715 } else {
2716 for (_, at) in last.iter_mut().filter(|(name, _)| used.contains(*name)) {
2718 if now_ms - *at >= DAY {
2719 *at = now_ms;
2720 }
2721 }
2722 }
2723 for (name, at) in last {
2724 if let Some(theirs) = memory.sealed.theirs.get_mut(&name) {
2725 theirs.1 = at;
2726 }
2727 }
2728}
2729
2730fn texts_media(sharing: &Sharing, stores: &[Store], memory: &Memory, counted: &[(String, Counted)]) -> Option<BTreeSet<String>> {
2741 if let Some(store) = stores.iter().find(|s| s.name == TEXTS_LOG) {
2742 use sioul_core::texts::Sealer;
2743 #[derive(Deserialize)]
2744 struct Text {
2745 #[serde(default)]
2746 parts: Vec<Part>,
2747 }
2748 #[derive(Deserialize)]
2749 struct Part {
2750 #[serde(default)]
2751 hash: String,
2752 }
2753 let seal = crate::textseal::TextSeal::new(sharing.key);
2754 let mut out = BTreeSet::new();
2755 for entry in std::fs::read_dir(&store.path).into_iter().flatten().filter_map(Result::ok) {
2756 let path = entry.path();
2757 if !path.is_file() || path.extension().is_none_or(|e| e != "jsonl") || entry.file_name().to_string_lossy().starts_with('.') {
2758 continue;
2759 }
2760 let text = std::fs::read_to_string(&path).ok()?;
2761 for line in text.split_inclusive('\n') {
2762 let opened = seal.open(line.trim()).and_then(|plain| serde_json::from_str::<Text>(&plain).ok());
2764 let Some(text) = opened else {
2765 if line.trim().is_empty() || !line.ends_with('\n') {
2766 continue;
2767 }
2768 return None;
2769 };
2770 out.extend(text.parts.into_iter().filter(|part| !part.hash.is_empty()).map(|part| crate::blobs::name(sharing.key, &part.hash)));
2771 }
2772 }
2773 return Some(out);
2774 }
2775 let phone = |entry: Option<&crate::devices::Entry>| entry.is_none_or(|e| e.kind == crate::devices::PHONE);
2776 let phones = crate::devices::own(&sharing.memory.with_file_name("device.toml")).kind == crate::devices::PHONE || counted.iter().any(|(id, _)| phone(memory.seen.get(id)));
2777 (!phones).then(BTreeSet::new)
2778}
2779
2780#[derive(Debug, Clone, Serialize, Deserialize)]
2781struct Known {
2782 c: u64,
2783 w: String,
2784 h: String,
2785 #[serde(default, skip_serializing_if = "String::is_empty")]
2787 b: String,
2788}
2789
2790impl Memory {
2791 fn load(path: &Path, computer: &str) -> Memory {
2793 let memory: Memory = std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
2794 if memory.computer == computer { memory } else { Memory { computer: computer.to_string(), round: 1, ..Memory::default() } }
2795 }
2796
2797 fn load_all(path: &Path, computer: &str) -> Memory {
2802 if let Some(kept) = Memory::kept(path, computer) {
2803 return kept;
2804 }
2805 let mut memory = Memory::load(path, computer);
2806 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) {
2807 Some(sealed) => {
2808 memory.checked = memory.checked.max(sealed.checked);
2810 memory.tidied = memory.tidied.max(sealed.tidied);
2811 memory.sealed = sealed;
2812 }
2813 None => memory.places.retain(|store, _| !store.starts_with(FILES) && !calls_store(store)),
2817 }
2818 memory.entries.append(&mut memory.sealed.entries);
2819 memory.files.append(&mut memory.sealed.files);
2820 memory.pending.append(&mut memory.sealed.pending);
2821 memory
2822 }
2823
2824 fn save(&mut self, path: &Path) -> Result<(), String> {
2830 self.prepare_save(path)?.commit()
2831 }
2832
2833 fn prepare_save(&mut self, path: &Path) -> Result<SavePrepared, String> {
2840 let apart = |key: &String| key.starts_with(FILES) || calls_store(key);
2841 let (sealed, kept): (BTreeMap<_, _>, BTreeMap<_, _>) = std::mem::take(&mut self.entries).into_iter().partition(|(key, _)| apart(key));
2842 (self.sealed.entries, self.entries) = (sealed, kept);
2843 let (sealed, kept): (BTreeMap<_, _>, BTreeMap<_, _>) = std::mem::take(&mut self.files).into_iter().partition(|(file, _)| apart(file));
2844 (self.sealed.files, self.files) = (sealed, kept);
2845 let (sealed, kept): (BTreeMap<_, _>, BTreeMap<_, _>) = std::mem::take(&mut self.pending).into_iter().partition(|(key, _)| apart(key));
2846 (self.sealed.pending, self.pending) = (sealed, kept);
2847 self.sealed.computer = self.computer.clone();
2848 let mut prepared = SavePrepared { renames: Vec::new() };
2849 let mut write = |target: PathBuf, text: &[u8]| -> Result<(), String> {
2850 if std::fs::read(&target).is_ok_and(|old| old == text) {
2851 return Ok(());
2852 }
2853 let fail = |e: std::io::Error| format!("{}: {e}", target.display());
2854 if let Some(parent) = target.parent() {
2855 private_dirs(parent).map_err(fail)?;
2856 }
2857 let temporary = temporary(&target);
2858 let written = (|| {
2859 let mut file = new_private(&temporary)?;
2860 file.write_all(text)?;
2861 file.sync_all()
2862 })();
2863 if let Err(e) = written {
2864 let _ = std::fs::remove_file(&temporary);
2865 return Err(fail(e));
2866 }
2867 slow_memory_write();
2868 prepared.renames.push((temporary, target));
2869 Ok(())
2870 };
2871 write(sealed_path(path), serde_json::to_string(&self.sealed).map_err(|e| e.to_string())?.as_bytes())?;
2872 let text = serde_json::to_string(self).map_err(|e| e.to_string())?;
2873 write(path.to_path_buf(), text.as_bytes())?;
2874 self.saved = text.into_bytes();
2875 Ok(prepared)
2876 }
2877
2878 fn keep(mut self, path: &Path) {
2881 let text = std::mem::take(&mut self.saved);
2882 if text.is_empty() {
2883 return;
2884 }
2885 let sealed = stamp_at(&sealed_path(path));
2886 if let Ok(mut mine) = MINE.lock() {
2887 mine.insert(path.to_path_buf(), (sealed, self.computer.clone(), self.sealed.mine.iter().map(|(name, (_, size))| (name.clone(), *size)).collect()));
2888 }
2889 if !keeping() {
2890 return;
2891 }
2892 self.entries.append(&mut self.sealed.entries);
2893 self.files.append(&mut self.sealed.files);
2894 self.pending.append(&mut self.sealed.pending);
2895 let kept = Kept { own: stamp_at(path), sealed, text, memory: self };
2896 if let Ok(mut all) = KEPT.lock() {
2897 all.insert(path.to_path_buf(), kept);
2898 }
2899 }
2900
2901 fn kept(path: &Path, computer: &str) -> Option<Memory> {
2905 if !keeping() {
2906 return None;
2907 }
2908 let kept = KEPT.lock().ok()?.remove(path)?;
2909 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);
2910 same.then_some(kept.memory)
2911 }
2912
2913 fn tick(&mut self, physical: i64) -> u64 {
2915 self.clock = (self.clock + 1).max((physical.max(0) as u64) << 16);
2916 self.clock
2917 }
2918}
2919
2920fn sealed_path(memory: &Path) -> PathBuf {
2922 memory.with_file_name("files.json")
2923}
2924
2925#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2928struct FileStamp {
2929 ino: u64,
2930 size: u64,
2931 modified: u64,
2932}
2933
2934fn file_stamp(meta: &std::fs::Metadata) -> FileStamp {
2935 #[cfg(unix)]
2936 let ino = std::os::unix::fs::MetadataExt::ino(meta);
2937 #[cfg(not(unix))]
2938 let ino = 0;
2939 FileStamp { ino, size: meta.len(), modified: modified_ns(meta) }
2940}
2941
2942fn stamp_at(path: &Path) -> Option<FileStamp> {
2943 std::fs::metadata(path).ok().map(|meta| file_stamp(&meta))
2944}
2945
2946struct Kept {
2953 memory: Memory,
2954 text: Vec<u8>,
2955 own: Option<FileStamp>,
2956 sealed: Option<FileStamp>,
2957}
2958
2959#[cfg(test)]
2960thread_local! {
2961 pub(crate) static SLOW_MEMORY_WRITE: std::cell::Cell<std::time::Duration> = const { std::cell::Cell::new(std::time::Duration::ZERO) };
2964}
2965
2966fn slow_memory_write() {
2967 #[cfg(test)]
2968 std::thread::sleep(SLOW_MEMORY_WRITE.with(std::cell::Cell::get));
2969}
2970
2971struct SavePrepared {
2975 renames: Vec<(PathBuf, PathBuf)>,
2976}
2977
2978impl SavePrepared {
2979 fn commit(mut self) -> Result<(), String> {
2981 for (temporary, target) in std::mem::take(&mut self.renames) {
2982 if let Err(e) = std::fs::rename(&temporary, &target) {
2983 let _ = std::fs::remove_file(&temporary);
2984 return Err(format!("{}: {e}", target.display()));
2985 }
2986 }
2987 Ok(())
2988 }
2989}
2990
2991impl Drop for SavePrepared {
2992 fn drop(&mut self) {
2993 for (temporary, _) in &self.renames {
2994 let _ = std::fs::remove_file(temporary);
2995 }
2996 }
2997}
2998
2999static KEPT: std::sync::Mutex<BTreeMap<PathBuf, Kept>> = std::sync::Mutex::new(BTreeMap::new());
3001
3002static KEEPING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(cfg!(test));
3005
3006pub fn set_keeping(on: bool) {
3013 KEEPING.store(on, std::sync::atomic::Ordering::Relaxed);
3014 if !on {
3015 KEPT.lock().map(|mut all| all.clear()).ok();
3016 LINES.lock().map(|mut all| all.clear()).ok();
3017 }
3018}
3019
3020fn keeping() -> bool {
3021 KEEPING.load(std::sync::atomic::Ordering::Relaxed)
3022}
3023
3024type Mine = BTreeMap<PathBuf, (Option<FileStamp>, String, BTreeMap<String, u64>)>;
3028static MINE: std::sync::Mutex<Mine> = std::sync::Mutex::new(BTreeMap::new());
3029
3030#[derive(Debug, Serialize, Deserialize)]
3032struct Line {
3033 n: u64,
3034 c: u64,
3035 s: String,
3036}
3037
3038#[derive(Debug, Serialize, Deserialize)]
3040struct Change {
3041 k: String,
3042 v: Option<String>,
3043 #[serde(default, skip_serializing_if = "String::is_empty")]
3047 b: String,
3048 #[serde(default, skip_serializing_if = "String::is_empty")]
3054 build: String,
3055 #[serde(default, skip_serializing_if = "first_format")]
3061 f: u32,
3062}
3063
3064fn first_format(f: &u32) -> bool {
3065 *f <= 1
3066}
3067
3068#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3078struct Turn {
3079 #[serde(default, skip_serializing_if = "is_zero_u64")]
3081 p: u64,
3082 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
3084 full: bool,
3085}
3086
3087fn is_zero_i64(n: &i64) -> bool {
3088 *n == 0
3089}
3090
3091fn is_zero_u64(n: &u64) -> bool {
3092 *n == 0
3093}
3094
3095#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3097struct Awaited {
3098 round: u32,
3100 waited: i64,
3102 last: i64,
3104}
3105
3106const UNKNOWN_TURN: u64 = u64::MAX;
3113
3114#[derive(Deserialize)]
3116struct Opened {
3117 k: String,
3118 v: Option<String>,
3119 #[serde(default)]
3120 b: String,
3121 #[serde(default)]
3122 build: String,
3123 #[serde(default)]
3124 f: u32,
3125 #[serde(default)]
3126 p: u64,
3127 #[serde(default)]
3128 full: bool,
3129}
3130
3131impl Opened {
3132 fn parts(self) -> (Change, Turn) {
3133 (Change { k: self.k, v: self.v, b: self.b, build: self.build, f: self.f }, Turn { p: self.p, full: self.full })
3134 }
3135}
3136
3137#[derive(Serialize)]
3140struct Sealing<'a> {
3141 #[serde(flatten)]
3142 change: &'a Change,
3143 #[serde(flatten)]
3144 turn: Turn,
3145}
3146
3147#[derive(Debug, Default, Serialize, Deserialize)]
3149struct Seen {
3150 #[serde(default)]
3151 at: i64,
3152 #[serde(default)]
3153 round: u32,
3154 #[serde(default)]
3155 read: BTreeMap<String, u32>,
3156 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3162 full: BTreeMap<String, u32>,
3163 #[serde(default, skip_serializing_if = "String::is_empty")]
3165 pad: String,
3166}
3167
3168fn bound(computer: &str, round: u32, n: u64, clock: u64) -> String {
3169 format!("{computer}:{round}:{n}:{clock}")
3170}
3171
3172fn round_file(folder: &Path, computer: &str, round: u32) -> PathBuf {
3173 folder.join(format!("{computer}-{round}.jsonl"))
3174}
3175
3176fn computers(folder: &Path) -> BTreeSet<String> {
3180 let mut out: BTreeSet<String> = rounds_with(folder).into_keys().collect();
3181 for dir in std::iter::once(folder.to_path_buf()).chain(crate::remote::overlay(folder)) {
3182 for entry in std::fs::read_dir(&dir).into_iter().flatten().filter_map(Result::ok) {
3183 let name = entry.file_name().to_string_lossy().to_string();
3184 if let Some(id) = name.strip_suffix(".toml").filter(|id| id.len() == 36 && uuid::Uuid::parse_str(id).is_ok()) {
3185 out.insert(id.to_string());
3186 }
3187 }
3188 }
3189 out
3190}
3191
3192fn rounds_with(folder: &Path) -> BTreeMap<String, Vec<u32>> {
3195 let mut all = rounds(folder);
3196 if let Some(fetched) = crate::remote::overlay(folder) {
3197 for (computer, theirs) in rounds(&fetched) {
3198 let list = all.entry(computer).or_default();
3199 list.extend(theirs);
3200 list.sort_unstable();
3201 list.dedup();
3202 }
3203 }
3204 all
3205}
3206
3207fn open_round(folder: &Path, computer: &str, round: u32) -> std::io::Result<std::fs::File> {
3212 let here = round_file(folder, computer, round);
3213 let Some(fetched) = crate::remote::overlay(folder).map(|f| round_file(&f, computer, round)) else { return std::fs::File::open(here) };
3214 let size = |path: &Path| std::fs::metadata(path).map(|m| m.len()).ok();
3215 match (size(&here), size(&fetched)) {
3216 (Some(a), Some(b)) if b > a => std::fs::File::open(fetched),
3217 (None, Some(_)) => std::fs::File::open(fetched),
3218 _ => std::fs::File::open(here),
3219 }
3220}
3221
3222fn rounds(folder: &Path) -> BTreeMap<String, Vec<u32>> {
3224 let mut out: BTreeMap<String, Vec<u32>> = BTreeMap::new();
3225 for entry in std::fs::read_dir(folder).into_iter().flatten().filter_map(Result::ok) {
3226 let name = entry.file_name().to_string_lossy().to_string();
3227 let Some(stem) = name.strip_suffix(".jsonl") else { continue };
3228 let Some((computer, round)) = stem.rsplit_once('-') else { continue };
3230 if computer.len() != 36 || uuid::Uuid::parse_str(computer).is_err() {
3231 continue;
3232 }
3233 if let Ok(round) = round.parse::<u32>() {
3234 out.entry(computer.to_string()).or_default().push(round);
3235 }
3236 }
3237 for list in out.values_mut() {
3238 list.sort_unstable();
3239 }
3240 out
3241}
3242
3243fn wait(waiting: &mut BTreeMap<String, (Option<String>, u64, String, u32)>, key: String, change: (Option<String>, u64, String, u32)) {
3247 if waiting.get(&key).is_none_or(|(_, c, w, _)| (change.1, change.2.as_str()) > (*c, w.as_str())) {
3248 waiting.insert(key, change);
3249 }
3250}
3251
3252#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3254struct Newer {
3255 format: u32,
3257 #[serde(default)]
3259 build: String,
3260 #[serde(default)]
3264 writers: BTreeSet<String>,
3265}
3266
3267#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3273struct Floor {
3274 c: u64,
3275 w: String,
3276 #[serde(default, skip_serializing_if = "Option::is_none")]
3280 held: Option<BTreeSet<String>>,
3281 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3285 removed: BTreeMap<String, u64>,
3286}
3287
3288#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3295struct WholeList {
3296 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3297 held_at: BTreeMap<String, u64>,
3298 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3299 lacked_at: BTreeMap<String, u64>,
3300 #[serde(default)]
3302 top: u64,
3303}
3304
3305impl WholeList {
3306 fn saw(&mut self, c: u64, names: &BTreeSet<String>) {
3309 for name in self.held_at.keys().filter(|name| !names.contains(*name)) {
3310 let lacked = self.lacked_at.entry(name.clone()).or_default();
3311 *lacked = (*lacked).max(c);
3312 }
3313 for name in names {
3314 if !self.held_at.contains_key(name) && self.top > 0 {
3316 self.lacked_at.insert(name.clone(), self.top);
3317 }
3318 let held = self.held_at.entry(name.clone()).or_default();
3319 *held = (*held).max(c);
3320 }
3321 self.top = self.top.max(c);
3322 }
3323
3324 fn removed_at(&self, name: &str) -> Option<u64> {
3327 let held = self.held_at.get(name)?;
3328 self.lacked_at.get(name).copied().filter(|lacked| lacked > held)
3329 }
3330
3331 fn removed(&self) -> BTreeMap<String, u64> {
3332 self.held_at.keys().filter_map(|name| self.removed_at(name).map(|at| (name.clone(), at))).collect()
3333 }
3334}
3335
3336fn note_whole_record(stores: &[Store], memory: &mut Memory, key: &str, value: Option<&str>, c: u64) {
3340 let (file, entry) = (file_of(key), entry_of(key));
3341 let Some((store, _)) = locate(stores, file) else { return };
3342 let Shape::Toml(rules) = store.shape else { return };
3343 if rules.next.is_none() {
3344 return;
3345 }
3346 let Renamed::Whole(keyed) = renamed(rules, rules.at(2), entry) else { return };
3347 let elements: Vec<toml::Value> = value
3348 .and_then(|v| leaf_value(v).ok())
3349 .and_then(|v| v.as_array().cloned())
3350 .unwrap_or_default()
3351 .into_iter()
3352 .map(|mut element| {
3353 strip_local(&mut element, keyed.local);
3354 element
3355 })
3356 .collect();
3357 let held: BTreeSet<String> = names(&elements, keyed).into_iter().collect();
3358 memory.lists.entry(key.to_string()).or_default().saw(c, &held);
3359}
3360
3361fn note_whole_lists(stores: &[Store], memory: &mut Memory) {
3368 for (file, path, store) in divided(stores) {
3369 let Shape::Toml(rules) = store.shape else { continue };
3370 let next = rules.at(2);
3371 let Ok(text) = std::fs::read_to_string(&path) else { continue };
3372 if text.parse::<toml::Table>().is_err() {
3373 continue;
3374 }
3375 let mut now: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
3376 for (before, after, _) in aligned(&text, rules, next) {
3377 if matches!(renamed(rules, next, &before), Renamed::Whole(_)) && !after.is_empty() {
3378 now.entry(format!("{file}#{before}")).or_default().insert(element_name(&after).to_string());
3379 }
3380 }
3381 let keys: BTreeSet<String> = memory.lists.keys().filter(|key| file_of(key) == file).cloned().chain(now.keys().cloned()).collect();
3382 for key in keys {
3383 let held = now.remove(&key).unwrap_or_default();
3384 let clock = memory.entries.get(&key).map_or(memory.clock, |known| known.c);
3385 memory.lists.entry(key).or_default().saw(clock, &held);
3386 }
3387 }
3388}
3389
3390fn floor_key(key: &str) -> String {
3392 derived("floor", key)
3393}
3394
3395fn below_whole_floor(memory: &Memory, key: &str, c: u64) -> bool {
3405 let (file, entry) = (file_of(key), entry_of(key));
3406 let Some((list, element)) = entry.rsplit_once(SEP) else { return false };
3407 let Some(name) = element.strip_prefix(MARK) else { return false };
3408 let whole = format!("{file}#{list}");
3409 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));
3410 by_floor || memory.lists.get(&whole).and_then(|list| list.removed_at(name)).is_some_and(|at| c <= at)
3412}
3413
3414#[derive(Debug, Clone, PartialEq, Eq)]
3416pub enum Holds {
3417 Older,
3420 Unread,
3422 Unheard,
3424}
3425
3426fn entry_stamp(entry: &crate::devices::Entry) -> i64 {
3429 entry.started.max(entry.imported).max(entry.closed).max(entry.exported)
3430}
3431
3432fn note_entries(folder: &Path, key: &[u8; 32], memory: &mut Memory) {
3436 for entry in crate::devices::all(folder, key).0 {
3437 if memory.seen.get(&entry.id).is_none_or(|seen| entry_stamp(&entry) >= entry_stamp(seen)) {
3438 memory.seen.insert(entry.id.clone(), entry);
3439 }
3440 }
3441}
3442
3443fn fill_heard_at(folder: &Path, key: &[u8; 32], computer: &str, memory: &mut Memory, now_ms: i64) {
3453 for (other, their_rounds) in rounds_with(folder) {
3454 if other == computer || memory.heard_at.contains_key(&other) {
3455 continue;
3456 }
3457 let Some(&round) = their_rounds.last() else { continue };
3458 if let Some(clock) = last_record_clock(folder, key, &other, round) {
3459 memory.heard_at.insert(other, (clock >> 16) as i64);
3460 }
3461 }
3462 let read: Vec<String> = memory.read_n.keys().filter(|id| *id != computer && !memory.heard_at.contains_key(*id)).cloned().collect();
3463 for other in read {
3464 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();
3465 match held {
3466 Some(clock) => {
3467 memory.heard_at.insert(other.clone(), (clock >> 16) as i64);
3468 memory.unclocked.remove(&other);
3469 }
3470 None => {
3471 memory.unclocked.entry(other).or_insert(now_ms);
3472 }
3473 }
3474 }
3475 let heard: Vec<String> = memory.unclocked.keys().filter(|id| memory.heard_at.contains_key(*id)).cloned().collect();
3476 for id in heard {
3477 memory.unclocked.remove(&id);
3478 }
3479}
3480
3481fn last_record_clock(folder: &Path, key: &[u8; 32], computer: &str, round: u32) -> Option<u64> {
3483 use std::io::{Read, Seek};
3484 let mut file = open_round(folder, computer, round).ok()?;
3485 let size = file.metadata().ok()?.len();
3486 let from = size.saturating_sub(256 * 1024);
3487 let mut tail = Vec::new();
3488 file.seek(std::io::SeekFrom::Start(from)).ok()?;
3489 file.read_to_end(&mut tail).ok()?;
3490 tail.split(|b| *b == b'\n').rev().find_map(|line| {
3491 let record = std::str::from_utf8(line).ok().and_then(|t| serde_json::from_str::<Line>(t).ok())?;
3492 open(key, &bound(computer, round, record.n, record.c), &record.s).map(|_| record.c)
3493 })
3494}
3495
3496fn last_heard(entry: Option<&crate::devices::Entry>, memory: &Memory, id: &str) -> i64 {
3501 let entry = entry.map_or(0, |e| entry_stamp(e) * 1000);
3502 entry.max(memory.heard_at.get(id).copied().unwrap_or(0))
3503}
3504
3505fn present(folder: &Path, key: &[u8; 32], computer: &str, memory: &Memory, now_ms: i64) -> Vec<(String, Option<crate::devices::Entry>, bool)> {
3519 counted(folder, key, computer, memory, now_ms)
3520 .into_iter()
3521 .filter_map(|(id, counted)| match counted {
3522 Counted::Yes(entry, unread) => Some((id, entry, unread)),
3523 _ => None,
3524 })
3525 .collect()
3526}
3527
3528#[derive(Debug, Clone, PartialEq, Eq)]
3531enum Counted {
3532 Yes(Option<crate::devices::Entry>, bool),
3535 Left,
3537 Forgotten,
3539 Replaced,
3542 Silent,
3544 Stray,
3547}
3548
3549impl Counted {
3550 fn departed(&self) -> bool {
3555 matches!(self, Counted::Left | Counted::Forgotten | Counted::Replaced)
3556 }
3557}
3558
3559fn counted(folder: &Path, key: &[u8; 32], computer: &str, memory: &Memory, now_ms: i64) -> Vec<(String, Counted)> {
3564 let (entries, unread) = crate::devices::all(folder, key);
3565 let replaced: Vec<crate::devices::Replaced> = entries.iter().chain(memory.seen.values()).flat_map(|e| e.replaces.iter().cloned()).collect();
3568 let recorded: BTreeSet<String> = rounds_with(folder).into_keys().chain(memory.read_n.keys().cloned()).chain(memory.heard_at.keys().cloned()).collect();
3569 let mut ids = computers(folder);
3570 ids.extend(entries.iter().map(|e| e.id.clone()));
3571 ids.extend(unread.iter().cloned());
3572 ids.extend(recorded.iter().cloned());
3573 ids.into_iter()
3574 .filter(|id| id != computer)
3575 .map(|id| {
3576 let here = entries.iter().find(|e| e.id == id).cloned();
3577 let entry = match (here, memory.seen.get(&id)) {
3578 (Some(here), Some(seen)) if entry_stamp(seen) > entry_stamp(&here) => Some(seen.clone()),
3579 (None, Some(seen)) => Some(seen.clone()),
3580 (here, _) => here,
3581 };
3582 let last = last_heard(entry.as_ref(), memory, &id);
3583 let forgotten = memory.forgotten.get(&id).is_some_and(|mark| last <= *mark);
3584 let replaces = replaced.iter().any(|r| r.id == id && r.holds(entry.as_ref().map(|e| e.started), last));
3585 let counted = if entry.is_none() && !recorded.contains(&id) {
3590 if replaces {
3591 Counted::Replaced
3592 } else if forgotten {
3593 Counted::Forgotten
3594 } else {
3595 Counted::Stray
3596 }
3597 } else if entry.as_ref().is_some_and(|e| e.left) {
3598 Counted::Left
3599 } else if replaces {
3600 Counted::Replaced
3601 } else if forgotten {
3602 Counted::Forgotten
3603 } else {
3604 let aged = if last > 0 { last } else { memory.unclocked.get(&id).copied().unwrap_or(0) };
3606 if aged > 0 && now_ms - aged > SILENT_DAYS * DAY {
3607 Counted::Silent
3608 } else {
3609 let unread = unread.contains(&id) && entries.iter().all(|e| e.id != id);
3610 Counted::Yes(entry, unread)
3611 }
3612 };
3613 (id, counted)
3614 })
3615 .collect()
3616}
3617
3618fn readers(folder: &Path, counted: &[(String, Counted)], now_ms: i64) -> BTreeSet<String> {
3630 counted
3631 .iter()
3632 .filter(|(id, counted)| match counted {
3633 Counted::Yes(..) => true,
3634 Counted::Stray => read_seen(folder, id).is_some_and(|s| now_ms / 1000 - s.at < SILENT_DAYS * 86_400),
3635 _ => false,
3636 })
3637 .map(|(id, _)| id.clone())
3638 .collect()
3639}
3640
3641fn format_holders(folder: &Path, key: &[u8; 32], computer: &str, memory: &Memory, now_ms: i64) -> Vec<(String, Holds)> {
3650 let heard = Heard { read: memory.read_n.clone(), broken: BTreeMap::new() };
3651 present(folder, key, computer, memory, now_ms)
3652 .into_iter()
3653 .filter_map(|(id, entry, unread)| match entry {
3654 Some(entry) if entry.format >= 2 => entry.wrote.is_some_and(|wrote| !heard.complete(&id, wrote)).then_some((id, Holds::Unheard)),
3655 _ if unread => Some((id, Holds::Unread)),
3656 _ => Some((id, Holds::Older)),
3657 })
3658 .collect()
3659}
3660
3661fn unused_days(sharing: &Sharing, memory: &Memory, now_ms: i64) -> i64 {
3672 let all = present(sharing.folder, sharing.key, sharing.computer, memory, now_ms).iter().all(|(_, entry, _)| entry.as_ref().is_some_and(|e| e.keeping >= crate::blobs::KEEPING_TWO_WEEKS));
3673 if all { crate::blobs::UNUSED_DAYS } else { crate::blobs::UNUSED_DAYS_BEFORE }
3674}
3675
3676fn format_open(sharing: &Sharing, memory: &Memory, now_ms: i64) -> bool {
3682 !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)
3683}
3684
3685pub fn heard_of(folder: &Path, key: &[u8; 32], memory: &Path, computer: &str, device: &str) -> i64 {
3690 let state = Memory::load_all(memory, computer);
3691 let (entries, _) = crate::devices::all(folder, key);
3692 let here = entries.iter().find(|e| e.id == device);
3693 let entry = match (here, state.seen.get(device)) {
3694 (Some(here), Some(seen)) if entry_stamp(seen) > entry_stamp(here) => Some(seen),
3695 (None, Some(seen)) => Some(seen),
3696 (here, _) => here,
3697 };
3698 last_heard(entry, &state, device)
3699}
3700
3701pub fn forget_device(folder: &Path, key: &[u8; 32], memory: &Path, computer: &str, device: &str) -> Result<(), String> {
3706 let _running = exchange_lock(memory, true)?;
3707 let mut state = Memory::load_all(memory, computer);
3708 let (entries, _) = crate::devices::all(folder, key);
3709 let mark = last_heard(entries.iter().find(|e| e.id == device), &state, device);
3710 state.forgotten.insert(device.to_string(), mark.max(1));
3711 state.save(memory)?;
3712 state.keep(memory);
3713 Ok(())
3714}
3715
3716pub fn count_device_again(memory: &Path, computer: &str, device: &str) -> Result<(), String> {
3718 let _running = exchange_lock(memory, true)?;
3719 let mut state = Memory::load_all(memory, computer);
3720 if state.forgotten.remove(device).is_some() {
3721 state.save(memory)?;
3722 state.keep(memory);
3723 }
3724 Ok(())
3725}
3726
3727#[derive(Debug, Default, Clone)]
3733pub struct Formats {
3734 pub writes: u32,
3735 pub newer: Vec<(String, u32, String)>,
3736 pub holders: Vec<(String, Holds)>,
3737 pub older: Vec<String>,
3740 pub heard: BTreeMap<String, i64>,
3745}
3746
3747pub fn formats(folder: &Path, key: &[u8; 32], memory: &Path, computer: &str, now_ms: i64) -> Formats {
3748 let state = Memory::load(memory, computer);
3749 let holders = if state.format < 2 && state.joined { format_holders(folder, key, computer, &state, now_ms) } else { Vec::new() };
3750 let counting = present(folder, key, computer, &state, now_ms);
3751 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() };
3752 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();
3753 Formats { writes: state.format.max(1), newer: state.newer.into_iter().map(|(part, newer)| (part, newer.format, newer.build)).collect(), holders, older, heard }
3754}
3755
3756pub fn holds(memory: &Path, part: &str) -> bool {
3761 #[derive(Deserialize)]
3763 struct Held {
3764 #[serde(default)]
3765 newer: BTreeMap<String, serde::de::IgnoredAny>,
3766 }
3767 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))
3768}
3769
3770pub fn writes(memory: &Path) -> u32 {
3772 format_of(memory).max(1)
3773}
3774
3775fn format_of(memory: &Path) -> u32 {
3777 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)
3778}
3779
3780fn release_newer(sharing: &Sharing, memory: &mut Memory, now_ms: i64) {
3784 if memory.newer.is_empty() {
3785 return;
3786 }
3787 let counting: BTreeSet<String> = present(sharing.folder, sharing.key, sharing.computer, memory, now_ms).into_iter().map(|(id, _, _)| id).collect();
3791 let gone: Vec<String> = memory.newer.iter().filter(|(_, newer)| newer.writers.iter().all(|w| !counting.contains(w))).map(|(part, _)| part.clone()).collect();
3792 for part in gone {
3793 memory.newer.remove(&part);
3794 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();
3795 for key in dropped {
3796 if let Some((_, c, _, _)) = memory.waiting.remove(&key) {
3797 let released = memory.released.entry(part.clone()).or_default();
3798 *released = (*released).max(c);
3799 }
3800 }
3801 }
3802}
3803
3804enum Renamed<'r> {
3806 Same,
3808 Element(&'r Keyed),
3810 Whole(&'r Keyed),
3812}
3813
3814fn renamed<'r>(rules: &Rules, next: &'r Rules, entry: &str) -> Renamed<'r> {
3815 let path: Vec<&str> = entry.split(SEP).collect();
3816 match path.split_last() {
3817 Some((element, list)) if element.starts_with(MARK) && !list.is_empty() => match (keyed_named(rules, list), keyed_named(next, list)) {
3818 (Some(before), Some(after)) if before.ids == after.ids && before.by == after.by => Renamed::Same,
3819 (_, Some(after)) => Renamed::Element(after),
3820 _ => Renamed::Same,
3821 },
3822 _ => match (keyed_named(rules, &path), keyed_named(next, &path)) {
3823 (None, Some(after)) => Renamed::Whole(after),
3824 _ => Renamed::Same,
3825 },
3826 }
3827}
3828
3829fn aligned(text: &str, rules: &Rules, next: &Rules) -> Vec<(String, String, String)> {
3833 let Ok(table) = text.parse::<toml::Table>() else { return Vec::new() };
3834 let mut out = Vec::new();
3835 for (name, value) in &table {
3836 align(vec![name.clone()], value, rules, next, &mut out);
3837 }
3838 out
3839}
3840
3841fn align(path: Vec<String>, value: &toml::Value, rules: &Rules, next: &Rules, out: &mut Vec<(String, String, String)>) {
3842 if next.local.iter().any(|rule| matches_rule(&path, rule)) {
3843 return;
3844 }
3845 if let toml::Value::Array(elements) = value
3846 && let Some(keyed) = keyed_at(next, &path)
3847 {
3848 let (before, after) = list_names(elements, &path, rules, keyed);
3849 let list = list_name(keyed, &path);
3850 for ((entry, name), element) in before.into_iter().zip(names(&after, keyed)).zip(&after) {
3851 out.push((entry, format!("{list}{SEP}{MARK}{name}"), leaf_text(element)));
3852 }
3853 return;
3854 }
3855 if let toml::Value::Table(table) = value
3856 && !next.whole.iter().any(|rule| matches_rule(&path, rule))
3857 {
3858 for (name, value) in table {
3859 let mut deeper = path.clone();
3860 deeper.push(name.clone());
3861 align(deeper, value, rules, next, out);
3862 }
3863 }
3864}
3865
3866fn list_names(elements: &[toml::Value], path: &[String], rules: &Rules, keyed: &Keyed) -> (Vec<String>, Vec<toml::Value>) {
3870 let strip = |local: &[&str]| -> Vec<toml::Value> {
3871 elements
3872 .iter()
3873 .map(|element| {
3874 let mut element = element.clone();
3875 strip_local(&mut element, local);
3876 element
3877 })
3878 .collect()
3879 };
3880 let before: Vec<String> = match keyed_at(rules, path) {
3881 Some(before) => {
3882 let list = list_name(before, path);
3883 names(&strip(before.local), before).into_iter().map(|name| format!("{list}{SEP}{MARK}{name}")).collect()
3884 }
3885 None => vec![path.join(&SEP.to_string()); elements.len()],
3886 };
3887 (before, strip(keyed.local))
3888}
3889
3890fn stateless(keyed: &Keyed, ident: &str) -> String {
3896 if keyed.by.is_empty()
3897 && let Some(id) = leaf_value(ident).ok().as_ref().and_then(own_id).map(str::to_string)
3898 {
3899 return id;
3900 }
3901 if keyed.by == ["id"] && !ident.is_empty() {
3902 return ident.to_string();
3903 }
3904 derived(keyed.list, ident)
3905}
3906
3907fn element_name(entry: &str) -> &str {
3909 entry.rsplit_once(MARK).map_or("", |(_, name)| name)
3910}
3911
3912fn without_id(text: &str) -> String {
3914 match leaf_value(text) {
3915 Ok(toml::Value::Table(mut table)) => {
3916 table.remove("id");
3917 leaf_text(&toml::Value::Table(table))
3918 }
3919 Ok(other) => leaf_text(&other),
3920 Err(_) => text.to_string(),
3921 }
3922}
3923
3924fn 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)>) {
3948 let mut versions: BTreeMap<String, Vec<(Option<String>, u64, String)>> = BTreeMap::new();
3949 let mut out = Vec::new();
3950 let mut again = Vec::new();
3951 let mut here: BTreeMap<String, Vec<(String, String, String)>> = BTreeMap::new();
3952 for (key, (value, c, w)) in older {
3953 let (file, entry) = (file_of(key), entry_of(key));
3954 let Some((store, path)) = locate(stores, file) else { continue };
3955 let Shape::Toml(rules) = store.shape else { continue };
3956 let next = rules.at(2);
3957 let renaming = renamed(rules, next, entry);
3958 if matches!(renaming, Renamed::Same) {
3959 out.push((key.clone(), value.clone(), *c, w.clone()));
3960 continue;
3961 }
3962 let floor = floor_key(key);
3963 let previous = memory.floors.get(&floor).cloned();
3964 if previous.as_ref().is_some_and(|f| (*c, w.as_str()) <= (f.c, f.w.as_str())) {
3965 continue;
3966 }
3967 let elements = here.entry(file.to_string()).or_insert_with(|| aligned(&std::fs::read_to_string(&path).unwrap_or_default(), rules, next));
3968 match renaming {
3969 Renamed::Same => {}
3970 Renamed::Element(keyed) => {
3971 let (list, ident) = entry.rsplit_once(SEP).map_or(("", ""), |(list, element)| (list, element.trim_start_matches(MARK)));
3972 let given = value.as_deref().and_then(|v| leaf_value(v).ok()).map(|mut element| {
3973 strip_local(&mut element, keyed.local);
3974 element
3975 });
3976 let fallback = stateless(keyed, ident);
3977 let derived_here = elements.iter().any(|(_, after, _)| element_name(after) == fallback);
3982 let mine = (!keyed.by.is_empty() && !derived_here).then(|| elements.iter().find(|(before, _, _)| before == entry)).flatten();
3983 let mut value = value.clone();
3984 let mut kept = false;
3986 let name = match (&given, given.as_ref().and_then(own_id), mine) {
3987 (Some(_), Some(id), _) => id.to_string(),
3988 (Some(_), None, Some((_, after, text))) => {
3989 kept = leaf_value(text).ok().as_ref().and_then(own_id).is_some();
3990 element_name(after).to_string()
3991 }
3992 (None, _, Some((_, after, _))) => element_name(after).to_string(),
3993 _ => fallback.clone(),
3994 };
3995 if let Some(element) = given.as_ref().filter(|e| own_id(e).is_none() && e.is_table()) {
3998 let mut element = element.clone();
3999 if let toml::Value::Table(table) = &mut element {
4000 table.insert("id".into(), toml::Value::String(name.clone()));
4001 }
4002 value = Some(leaf_text(&element));
4003 }
4004 if given.is_none() && keyed.by.is_empty() && !modern.contains(w) {
4010 let key2 = format!("{file}#{list}{SEP}{MARK}{name}");
4011 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()));
4012 if current.is_some_and(|text| without_id(&text) != without_id(ident)) {
4013 memory.floors.insert(floor, Floor { c: *c, w: w.clone(), held: None, removed: BTreeMap::new() });
4014 continue;
4015 }
4016 }
4017 memory.floors.insert(floor, Floor { c: *c, w: w.clone(), held: None, removed: BTreeMap::new() });
4018 if kept && name != fallback {
4019 again.push((format!("{file}#{list}{SEP}{MARK}{name}"), format!("{file}#{list}{SEP}{MARK}{fallback}")));
4020 }
4021 versions.entry(format!("{file}#{list}{SEP}{MARK}{name}")).or_default().push((value, *c, w.clone()));
4022 }
4023 Renamed::Whole(keyed) => {
4024 let path: Vec<String> = entry.split(SEP).map(str::to_string).collect();
4025 let prefix = format!("{file}#{}{SEP}{MARK}", list_name(keyed, &path));
4026 let elements: Vec<toml::Value> = value
4027 .as_deref()
4028 .and_then(|v| leaf_value(v).ok())
4029 .and_then(|v| v.as_array().cloned())
4030 .unwrap_or_default()
4031 .into_iter()
4032 .map(|mut element| {
4033 strip_local(&mut element, keyed.local);
4034 element
4035 })
4036 .collect();
4037 let listed: BTreeSet<String> = names(&elements, keyed).into_iter().collect();
4038 for (element, name) in elements.iter().zip(names(&elements, keyed)) {
4039 versions.entry(format!("{prefix}{name}")).or_default().push((Some(leaf_text(element)), *c, w.clone()));
4040 }
4041 let mut removed = previous.as_ref().map(|f| f.removed.clone()).unwrap_or_default();
4043 for gone in previous.and_then(|f| f.held).unwrap_or_default().difference(&listed) {
4044 versions.entry(format!("{prefix}{gone}")).or_default().push((None, *c, w.clone()));
4045 removed.insert(gone.clone(), *c);
4046 }
4047 removed.retain(|name, _| !listed.contains(name));
4048 memory.floors.insert(floor, Floor { c: *c, w: w.clone(), held: Some(listed), removed });
4049 }
4050 }
4051 }
4052 for (key, list) in versions {
4053 let Some(last) = list.iter().max_by(|a, b| (a.1, &a.2).cmp(&(b.1, &b.2))) else { continue };
4054 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());
4055 out.push((key, held, last.1, last.2.clone()));
4056 }
4057 (out, again)
4058}
4059
4060fn divided<'s>(stores: &'s [Store]) -> Vec<(String, PathBuf, &'s Store)> {
4063 let mut out = Vec::new();
4064 for store in stores.iter().filter(|s| matches!(s.shape, Shape::Toml(rules) if rules.next.is_some())) {
4065 if store.folder {
4066 let mut files = Vec::new();
4067 list_files(&store.path, &store.path, store.skip, &mut files);
4068 out.extend(files.into_iter().map(|(relative, path)| (format!("{}{relative}", store.name), path, store)));
4069 } else if store.path.is_file() {
4070 out.push((store.name.clone(), store.path.clone(), store));
4071 }
4072 }
4073 out
4074}
4075
4076fn switch_format(sharing: &Sharing, memory: &mut Memory, stores: &[Store], now_ms: i64, problems: &mut Vec<String>) -> Result<bool, String> {
4096 let files = divided(stores);
4097 for (name, path, _) in &files {
4098 let text = std::fs::read_to_string(path);
4099 let held = memory.entries.iter().any(|(key, known)| file_of(key) == name && !known.h.is_empty());
4100 let reads = match &text {
4101 Ok(text) if text.trim().is_empty() => !held,
4102 Ok(text) => text.parse::<toml::Table>().is_ok(),
4103 Err(_) => false,
4104 };
4105 if !reads {
4106 problems.push(format!("share-format-wait:{name}"));
4107 return Ok(false);
4108 }
4109 }
4110 if memory.joined {
4111 flush(sharing, memory, stores, now_ms)?;
4112 }
4113 note_whole_lists(stores, memory);
4115 let history = crate::history::root(sharing.memory);
4120 let mut done = Vec::new();
4121 for (name, path, store) in &files {
4122 match give_ids(store, name, path, memory, &history, now_ms) {
4123 Ok(mut placed) => {
4124 placed.extend(whole_lists(store, name, path, memory));
4125 done.push((name, *store, placed));
4126 }
4127 Err(e) => {
4128 problems.push(e);
4129 problems.push(format!("share-format-wait:{name}"));
4130 return Ok(false);
4131 }
4132 }
4133 }
4134 for (name, store, placed) in done {
4135 rename_memory(memory, store, name, &placed);
4136 }
4137 let belongs = |file: &str| locate(stores, file).is_some_and(|(store, _)| matches!(store.shape, Shape::Toml(rules) if rules.next.is_some()));
4138 memory.files.retain(|file, _| !belongs(file));
4139 let pending: Vec<String> = memory.pending.keys().filter(|key| belongs(file_of(key))).cloned().collect();
4141 for key in pending {
4142 if let Some((value, c, w)) = memory.pending.remove(&key) {
4143 wait(&mut memory.waiting, key, (value, c, w, 1));
4144 }
4145 }
4146 memory.format = 2;
4147 memory.switch = false;
4148 Ok(true)
4149}
4150
4151fn flush(sharing: &Sharing, memory: &mut Memory, stores: &[Store], now_ms: i64) -> Result<(), String> {
4155 let joining = |store: &Store| !memory.places.is_empty() && memory.places.get(&store.name) != Some(&place_of(store));
4159 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();
4160 let confirmed = BTreeSet::new();
4161 let found = gather(&subset, &memory.files, &Look { reads: &|_| true, hurry: &|| false, confirmed: &confirmed, clock: clock_ms(), changing: &BTreeMap::new() });
4162 let mut out: Vec<(String, Option<String>, u64, String)> = Vec::new();
4163 for (key, h) in &found.hashes {
4164 if memory.entries.get(key).is_some_and(|k| k.h == *h) {
4165 continue;
4166 }
4167 let Some(value) = found.values.get(key).cloned().or_else(|| value_of(&subset, key)) else { continue };
4168 let clock = memory.tick(found.changed.get(file_of(key)).copied().unwrap_or(now_ms).min(now_ms));
4169 memory.entries.insert(key.clone(), Known { c: clock, w: sharing.computer.to_string(), h: h.clone(), b: String::new() });
4170 out.push((key.clone(), Some(value), clock, String::new()));
4171 }
4172 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();
4173 for key in gone {
4174 let clock = memory.tick(now_ms);
4175 memory.entries.insert(key.clone(), Known { c: clock, w: sharing.computer.to_string(), h: String::new(), b: String::new() });
4176 out.push((key, None, clock, String::new()));
4177 }
4178 if memory.round == 0 {
4179 memory.round = 1;
4180 }
4181 append(sharing, memory, &out)?;
4182 for (file, stat) in found.files {
4183 memory.files.insert(file, stat);
4184 }
4185 Ok(())
4186}
4187
4188struct Placed {
4193 before: String,
4194 after: String,
4195 text: String,
4196 stands: bool,
4197 exact: bool,
4201}
4202
4203fn give_ids(store: &Store, file: &str, path: &Path, memory: &Memory, history: &Path, now_ms: i64) -> Result<Vec<Placed>, String> {
4216 let Shape::Toml(rules) = store.shape else { return Ok(Vec::new()) };
4217 let next = rules.at(2);
4218 sioul_core::filelock::with_lock(path, || {
4219 let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
4220 let mut doc: toml_edit::DocumentMut = text.parse().map_err(|e: toml_edit::TomlError| format!("{}: {e}", path.display()))?;
4221 let mut before: Vec<(Vec<String>, Vec<String>, Vec<(bool, bool)>)> = Vec::new();
4222 let mut presets: BTreeMap<String, String> = BTreeMap::new();
4223 let mut changed = false;
4224 each_id_list(doc.as_table_mut(), Vec::new(), next, &mut |path, keyed, list| {
4225 let normal = |t: &toml_edit::Table| leaf_value(&format!("[v]\n{t}")).ok();
4226 let read: Vec<toml::Value> = list.iter().map(|t| normal(t).unwrap_or_else(|| toml::Value::Table(toml::Table::new()))).collect();
4227 let (entries, _) = list_names(&read, path, rules, keyed);
4228 let whole = keyed_at(rules, path).is_none();
4229 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)));
4231 let texts: Vec<String> = read
4232 .iter()
4233 .map(|element| {
4234 let mut element = element.clone();
4235 strip_local(&mut element, keyed_at(rules, path).map_or(keyed.local, |b| b.local));
4236 leaf_text(&element)
4237 })
4238 .collect();
4239 let mut ids: BTreeMap<String, Vec<usize>> = BTreeMap::new();
4240 for (i, element) in read.iter().enumerate() {
4241 if let Some(id) = own_id(element) {
4242 ids.entry(id.to_string()).or_default().push(i);
4243 }
4244 }
4245 let mut alike: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
4251 for (i, entry) in entries.iter().enumerate() {
4252 alike.entry(entry.as_str()).or_default().push(i);
4253 }
4254 let standing = |i: usize| -> (bool, bool) {
4255 if whole {
4256 return (whole_stands, true);
4257 }
4258 let Some(known) = memory.entries.get(&format!("{file}#{}", entries[i])).filter(|k| !k.h.is_empty()) else { return (false, false) };
4259 let at = &alike[entries[i].as_str()];
4260 let chosen = if at.len() == 1 { at[0] } else if known.w == memory.computer { at[at.len() - 1] } else { at[0] };
4261 (chosen == i, known.h == hash(&texts[i]))
4262 };
4263 let mut claimed: BTreeSet<String> = BTreeSet::new();
4264 let mut stands = vec![(false, false); read.len()];
4265 for (i, element) in read.iter().enumerate() {
4266 let entry = &entries[i];
4267 let (shared, exact) = standing(i);
4268 let ident = element_name(entry).to_string();
4269 let new_id = match own_id(element) {
4270 Some(id) if ids.get(id).is_some_and(|at| at.len() == 1) => {
4271 stands[i] = (shared, exact);
4272 None
4273 }
4274 Some(id) => {
4275 let at = &ids[id];
4276 let keeper = at.iter().copied().max_by(|a, b| texts[*a].cmp(&texts[*b]).then(b.cmp(a))).unwrap_or(i);
4277 stands[i] = (shared, exact);
4278 (keeper != i).then(|| derived(keyed.list, if whole { &texts[i] } else { &ident }))
4279 }
4280 None => {
4281 let what = if whole { texts[i].clone() } else { ident.clone() };
4282 if (shared || !memory.joined) && claimed.insert(what.clone()) {
4283 stands[i] = (shared, exact);
4284 Some(derived(keyed.list, &what))
4285 } else {
4286 Some(sioul_core::ids::new())
4287 }
4288 }
4289 };
4290 if let Some(id) = new_id {
4291 if keyed.list == "preset" && path.len() == 1 {
4292 presets.insert(format!("#{i}"), id.clone());
4293 }
4294 if let Some(table) = list.get_mut(i) {
4295 table.insert("id", toml_edit::value(id));
4296 changed = true;
4297 }
4298 }
4299 }
4300 before.push((path.to_vec(), entries, stands));
4301 });
4302 let mut repointed: BTreeSet<(String, usize)> = BTreeSet::new();
4307 if !presets.is_empty() {
4308 for list in ["split", "line", "mail_rule"] {
4309 if let Some(tables) = doc.get_mut(list).and_then(toml_edit::Item::as_array_of_tables_mut) {
4310 for (at, table) in tables.iter_mut().enumerate() {
4311 if let Some(id) = table.get("preset").and_then(toml_edit::Item::as_str).and_then(|place| presets.get(place)).cloned() {
4312 table.insert("preset", toml_edit::value(id));
4313 repointed.insert((list.to_string(), at));
4314 }
4315 }
4316 }
4317 }
4318 }
4319 let written = doc.to_string();
4320 if changed {
4321 crate::history::keep(history, store.part, file, path, now_ms)?;
4322 write_atomically(path, written.as_bytes())?;
4323 }
4324 let now: toml::Table = written.parse().map_err(|e: toml::de::Error| format!("{}: {e}", path.display()))?;
4326 let mut placed = Vec::new();
4327 for (list_path, entries, stands) in before {
4328 let Some(keyed) = keyed_at(next, &list_path) else { continue };
4329 let Some(elements) = array_at(&now, &list_path) else { continue };
4330 let (_, after) = list_names(elements, &list_path, rules, keyed);
4331 let list = list_name(keyed, &list_path);
4332 for (at, (((entry, (stands, exact)), name), element)) in entries.into_iter().zip(stands).zip(names(&after, keyed)).zip(&after).enumerate() {
4333 let exact = exact && !(list_path.len() == 1 && repointed.contains(&(list_path[0].clone(), at)));
4334 placed.push(Placed { before: entry, after: format!("{list}{SEP}{MARK}{name}"), text: leaf_text(element), stands, exact });
4335 }
4336 }
4337 Ok(placed)
4338 })
4339}
4340
4341fn array_at<'t>(table: &'t toml::Table, path: &[String]) -> Option<&'t Vec<toml::Value>> {
4343 let (last, within) = path.split_last()?;
4344 let mut at = table;
4345 for part in within {
4346 at = at.get(part)?.as_table()?;
4347 }
4348 at.get(last)?.as_array()
4349}
4350
4351fn whole_lists(store: &Store, file: &str, path: &Path, memory: &Memory) -> Vec<Placed> {
4355 let Shape::Toml(rules) = store.shape else { return Vec::new() };
4356 let next = rules.at(2);
4357 let Ok(text) = std::fs::read_to_string(path) else { return Vec::new() };
4358 let sent: BTreeMap<String, String> = toml_entries(&text, rules).unwrap_or_default().into_iter().map(|(entry, value)| (entry, hash(&value))).collect();
4359 aligned(&text, rules, next)
4360 .into_iter()
4361 .filter(|(before, after, _)| matches!(renamed(rules, next, before), Renamed::Whole(keyed) if !keyed.ids) && !after.is_empty())
4362 .map(|(before, after, text)| {
4363 let stands = memory.entries.get(&format!("{file}#{before}")).is_some_and(|known| !known.h.is_empty() && sent.get(&before) == Some(&known.h));
4364 Placed { before, after, text, stands, exact: true }
4365 })
4366 .collect()
4367}
4368
4369fn each_id_list(table: &mut toml_edit::Table, path: Vec<String>, next: &Rules, f: &mut dyn FnMut(&[String], &Keyed, &mut toml_edit::ArrayOfTables)) {
4371 let keys: Vec<String> = table.iter().map(|(key, _)| key.to_string()).collect();
4372 for key in keys {
4373 let mut deeper = path.clone();
4374 deeper.push(key.clone());
4375 if next.local.iter().any(|rule| matches_rule(&deeper, rule)) {
4376 continue;
4377 }
4378 let Some(item) = table.get_mut(&key) else { continue };
4379 match item {
4380 toml_edit::Item::ArrayOfTables(list) => {
4381 if let Some(keyed) = keyed_at(next, &deeper).filter(|k| k.ids) {
4382 f(&deeper, keyed, list);
4383 }
4384 }
4385 toml_edit::Item::Table(inner) if !next.whole.iter().any(|rule| matches_rule(&deeper, rule)) => each_id_list(inner, deeper, next, f),
4386 _ => {}
4387 }
4388 }
4389}
4390
4391fn rename_memory(memory: &mut Memory, store: &Store, file: &str, placed: &[Placed]) {
4400 let Shape::Toml(rules) = store.shape else { return };
4401 let next = rules.at(2);
4402 let mut grouped: BTreeMap<String, Vec<Known>> = BTreeMap::new();
4403 let mut wholes: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
4404 for element in placed {
4405 let before = format!("{file}#{}", element.before);
4406 if let Renamed::Whole(_) = renamed(rules, next, &element.before) {
4407 wholes.entry(before.clone()).or_default().insert(element_name(&element.after).to_string());
4408 }
4409 if !element.stands {
4410 continue;
4411 }
4412 if let Some(known) = memory.entries.get(&before) {
4413 let h = if element.exact { hash(&element.text) } else { "?".to_string() };
4415 grouped.entry(format!("{file}#{}", element.after)).or_default().push(Known { c: known.c, w: known.w.clone(), h, b: String::new() });
4416 }
4417 }
4418 let keys: Vec<String> = memory.entries.keys().filter(|key| file_of(key) == file).cloned().collect();
4419 for key in keys {
4420 let entry = entry_of(&key).to_string();
4421 let renaming = renamed(rules, next, &entry);
4422 if matches!(renaming, Renamed::Same) {
4423 continue;
4424 }
4425 let Some(known) = memory.entries.remove(&key) else { continue };
4426 match renaming {
4427 Renamed::Same => {}
4428 Renamed::Whole(_) => {
4429 let held = wholes.get(&key).cloned().filter(|_| !known.h.is_empty());
4430 let removed = memory.lists.get(&key).map(WholeList::removed).unwrap_or_default();
4431 memory.floors.insert(floor_key(&key), Floor { c: known.c, w: known.w.clone(), held: Some(held.unwrap_or_default()), removed });
4432 }
4433 Renamed::Element(keyed) => {
4434 memory.floors.insert(floor_key(&key), Floor { c: known.c, w: known.w.clone(), held: None, removed: BTreeMap::new() });
4435 if !placed.iter().any(|p| p.stands && format!("{file}#{}", p.before) == key) {
4436 let (list, ident) = entry.rsplit_once(SEP).map_or(("", ""), |(list, element)| (list, element.trim_start_matches(MARK)));
4437 grouped.entry(format!("{file}#{list}{SEP}{MARK}{}", stateless(keyed, ident))).or_default().push(Known { h: String::new(), ..known });
4438 }
4439 }
4440 }
4441 }
4442 for (key, versions) in grouped {
4443 let Some(last) = versions.iter().max_by(|a, b| (a.c, &a.w).cmp(&(b.c, &b.w))).cloned() else { continue };
4444 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();
4445 memory.entries.insert(key, Known { c: last.c, w: last.w, h: held, b: String::new() });
4446 }
4447}
4448
4449pub struct Sharing<'a> {
4453 pub folder: &'a Path,
4454 pub computer: &'a str,
4455 pub key: &'a [u8; 32],
4456 pub memory: &'a Path,
4458 pub files: bool,
4463 pub hurry: Option<&'a std::sync::atomic::AtomicBool>,
4466}
4467
4468#[derive(Debug, Default)]
4470pub struct Outcome {
4471 pub written: BTreeSet<String>,
4473 pub waits: BTreeMap<String, usize>,
4482 pub sent: usize,
4484 pub received: usize,
4486 pub problems: Vec<String>,
4488 pub pending: usize,
4490 pub looked: i64,
4494 pub wrote: Option<(u32, u64)>,
4497 pub quiet: bool,
4500 pub withheld: Option<Vec<Withheld>>,
4504}
4505
4506fn keep_copies(stores: &[Store], memory: &Path, today: &str) {
4515 let Some(base) = memory.parent() else { return };
4516 let aside = base.join(format!("before-sharing-{today}"));
4517 let keep = |from: &Path, to: &Path| {
4518 if to.exists() {
4519 return;
4520 }
4521 let _ = to.parent().map(private_dirs);
4522 let _ = copy_private(from, to);
4523 };
4524 for store in stores.iter().filter(|s| !matches!(s.shape, Shape::Files) && !calls_store(&s.name)) {
4525 let target = aside.join(store.name.trim_end_matches('/'));
4526 if store.folder {
4527 let mut files = Vec::new();
4528 list_files(&store.path, &store.path, store.skip, &mut files);
4529 for (relative, path) in files {
4530 keep(&path, &target.join(relative));
4531 }
4532 } else if store.path.is_file() {
4533 keep(&store.path, &target);
4534 }
4535 }
4536}
4537
4538pub fn draft_copies_kept_before_sharing(memory: &Path) -> Vec<PathBuf> {
4544 let Some(base) = memory.parent() else { return Vec::new() };
4545 let mut out = Vec::new();
4546 for aside in std::fs::read_dir(base).into_iter().flatten().filter_map(Result::ok) {
4547 if !aside.file_name().to_string_lossy().starts_with("before-sharing-") || !aside.file_type().is_ok_and(|t| t.is_dir()) {
4548 continue;
4549 }
4550 for copy in std::fs::read_dir(aside.path().join("data").join("drafts")).into_iter().flatten().filter_map(Result::ok) {
4551 if copy.file_type().is_ok_and(|t| t.is_file()) && copy.path().extension().is_some_and(|e| e == "eml") {
4552 out.push(copy.path());
4553 }
4554 }
4555 }
4556 out.sort();
4557 out
4558}
4559
4560pub fn replacements_hold(memory: &Path) -> impl Fn() -> Option<Box<dyn sioul_core::draftcopies::Held>> + '_ {
4567 move || {
4568 if !memory.parent().is_some_and(Path::exists) {
4569 return sioul_core::draftcopies::unshared();
4570 }
4571 exchange_lock(memory, true).ok().flatten().map(|file| Box::new(HandedOff(memory.to_path_buf(), Some(file))) as Box<dyn sioul_core::draftcopies::Held>)
4572 }
4573}
4574
4575struct HandedOff(PathBuf, Option<std::fs::File>);
4579
4580impl sioul_core::draftcopies::Held for HandedOff {
4581 fn wanted(&self) -> bool {
4582 waiting_here(&self.0)
4583 }
4584}
4585
4586impl Drop for HandedOff {
4587 fn drop(&mut self) {
4588 hand_off(&self.0, self.1.take(), std::time::Duration::ZERO);
4589 }
4590}
4591
4592pub fn exchange(sharing: &Sharing, stores: &[Store], now_ms: i64) -> Result<Outcome, String> {
4596 let (sealed, salt) = match sealed_for(sharing.folder, sharing.key) {
4599 Ok(Some((opens, salt))) => (Some(opens), salt),
4600 Ok(None) => (None, String::new()),
4601 Err(()) => return Ok(Outcome { problems: vec!["share-no-seal".into()], ..Outcome::default() }),
4602 };
4603 let Some(_running) = exchange_lock(sharing.memory, !sharing.files)? else { return Ok(Outcome { problems: vec!["share-busy".into()], ..Outcome::default() }) };
4604 crate::remote::attach(sharing.folder, sharing.memory);
4608 let mut outcome = Outcome::default();
4609 let hurried = || sharing.hurry.is_some_and(|hurry| hurry.load(std::sync::atomic::Ordering::Relaxed));
4613 let reads = |store: &Store| reads_now(sharing, store);
4616 let standing = (!sharing.files).then(|| {
4622 let taken = clock_ms();
4623 (Standing::now(sharing, stores, &reads), taken)
4624 });
4625 if let Some(outcome) = standing.as_ref().and_then(|(standing, taken)| quiet(sharing, standing, *taken, now_ms)) {
4626 return Ok(outcome);
4627 }
4628 let mut memory = Memory::load_all(sharing.memory, sharing.computer);
4629 if sealed == Some(true) && !memory.seal_salt.is_empty() && memory.seal_salt != salt {
4636 let round = last_own(sharing.folder, sharing.computer).map_or(1, |(round, _, _)| round + 1);
4640 memory = Memory { computer: sharing.computer.to_string(), round, full_round: round, ..Memory::default() };
4641 }
4642 if sealed == Some(true) {
4643 memory.seal_salt = salt;
4644 }
4645 match (sealed, memory.seal_seen) {
4651 (None, true) => return Ok(Outcome { problems: vec!["share-no-seal".into()], ..Outcome::default() }),
4652 (Some(false), true) => return Ok(Outcome { problems: vec!["share-sealed-otherwise".into()], ..Outcome::default() }),
4653 (Some(true), false) => memory.seal_seen = true,
4654 _ => {}
4655 }
4656 let mut restate = false;
4663 if let Some((round, n, size)) = last_own(sharing.folder, sharing.computer)
4664 && (round, n) > (memory.round, memory.seq)
4665 {
4666 outcome.problems.push("share-own-ahead".into());
4667 if appended(sharing.memory, sharing.computer).is_none_or(|end| (round, n) > end) {
4677 if memory.ahead_at > 0 && now_ms - memory.ahead_at < TWIN_WITHIN {
4678 outcome.problems.push("share-twin".into());
4679 }
4680 memory.ahead_at = now_ms;
4681 }
4682 memory.round = round;
4683 memory.seq = n;
4684 memory.own_size = size;
4685 memory.round_base = 0;
4686 memory.continues = 0;
4691 if n == 0 && round > 1 {
4692 match last_line(sharing.folder, sharing.computer, round - 1) {
4693 Some(last) if last > 0 => memory.continues = last,
4694 _ => restate = true,
4695 }
4696 }
4697 memory.full_round = 1;
4704 memory.full_size = 0;
4705 memory.read.clear();
4706 memory.rebuild.insert("*".into());
4707 }
4708 memory.entries.retain(|key, _| !retired(key));
4715 if !memory.turns {
4716 if memory.joined {
4717 memory.read.clear();
4718 }
4719 memory.turns = true;
4720 }
4721 if !memory.joined {
4722 let today = jiff::Timestamp::from_millisecond(now_ms).map(|t| t.strftime("%Y-%m-%d").to_string()).unwrap_or_default();
4723 keep_copies(stores, sharing.memory, &today);
4724 }
4725 memory.newer.retain(|part, newer| newer.format > part_format(part));
4734 note_entries(sharing.folder, sharing.key, &mut memory);
4736 fill_heard_at(sharing.folder, sharing.key, sharing.computer, &mut memory, now_ms);
4737 release_newer(sharing, &mut memory, now_ms);
4738 if memory.format < 2 && (memory.switch || format_open(sharing, &memory, now_ms)) {
4739 switch_format(sharing, &mut memory, stores, now_ms, &mut outcome.problems)?;
4740 }
4741 let originals = stores;
4742 let shaped_stores = shaped(stores, memory.format);
4743 let stores = shaped_stores.as_slice();
4744 let held: BTreeSet<&'static str> = memory.newer.keys().filter_map(|part| PARTS.iter().find(|p| **p == part.as_str()).copied()).collect();
4746 let holds = |key: &str| known_part(file_of(key)).is_some_and(|part| held.contains(part));
4747 let clock = clock_ms();
4748 let confirmed = memory.sealed.confirmed.clone();
4749 let mut found = gather(stores, &memory.files, &Look { reads: &reads, hurry: &hurried, confirmed: &confirmed, clock, changing: &memory.sealed.changing });
4750 let mut changing = std::mem::take(&mut found.changing);
4753 changing.extend(memory.sealed.changing.iter().filter(|(file, _)| locate(stores, file).is_some_and(|(store, _)| !reads(store))).map(|(file, since)| (file.clone(), *since)));
4754 memory.sealed.changing = changing;
4755 outcome.problems.extend(found.too_big.iter().map(|file| format!("share-too-big:{file}")));
4756 outcome.problems.append(&mut found.said);
4757 outcome.problems.extend(found.emptied.iter().map(|(folder, held)| format!("share-vanished:{folder}:{held}")));
4758 let mut out: Vec<(String, Option<String>, u64, String)> = Vec::new();
4759
4760 let place = place_of;
4768 if memory.joined && memory.places.is_empty() {
4769 memory.places = stores.iter().map(|s| (s.name.clone(), place(s))).collect();
4770 }
4771 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() };
4772 let joins_store = |key: &str| locate(stores, file_of(key)).is_some_and(|(store, _)| joining.contains(&store.name));
4773 if !joining.is_empty() {
4774 memory.entries.retain(|key, _| !joins_store(key));
4775 memory.read.clear();
4776 memory.rebuild.extend(joining.iter().cloned());
4777 }
4778 let rebuilt_here: BTreeSet<String> = joining.iter().cloned().chain(std::mem::take(&mut memory.rejoin)).collect();
4791 let joins = |key: &str| {
4792 let file = file_of(key);
4793 rebuilt_here.contains(file) || rebuilt_here.iter().any(|r| r.ends_with('/') && file.starts_with(r.as_str()))
4794 };
4795
4796 let mut held_empty: BTreeSet<String> = BTreeSet::new();
4799 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();
4800 for file in emptied_now {
4801 let since = *memory.emptied.entry(file.clone()).or_insert(now_ms);
4802 if now_ms - since < EMPTIED_WAIT {
4803 found.hashes.remove(&format!("{file}#"));
4804 found.unknown.insert(file.clone());
4805 if let Some(before) = memory.files.get(&file) {
4806 found.files.insert(file.clone(), before.clone());
4807 }
4808 held_empty.insert(file);
4809 }
4810 }
4811 let unread = |file: &str| locate(stores, file).is_some_and(|(store, _)| !reads(store));
4813 memory.emptied.retain(|file, _| !file.starts_with(FILES) || held_empty.contains(file) || unread(file));
4814
4815 let mut held_gone: BTreeSet<String> = BTreeSet::new();
4824 let mut gone_since: BTreeMap<String, i64> = memory.sealed.gone.iter().filter(|(file, _)| unread(file)).map(|(file, since)| (file.clone(), *since)).collect();
4825 let mut mass_held: BTreeMap<String, BTreeMap<String, i64>> = BTreeMap::new();
4828 let mut bulk_now: BTreeSet<String> = memory.sealed.bulk.iter().filter(|file| unread(file)).cloned().collect();
4829 let mut withheld: Vec<Withheld> = std::mem::take(&mut memory.withheld).into_iter().filter(|w| stores.iter().any(|s| s.name == w.store && !reads(s))).collect();
4830 if memory.joined {
4831 let mut by_store: BTreeMap<String, (usize, Vec<String>)> = BTreeMap::new();
4832 for key in memory.entries.iter().filter(|(key, known)| key.starts_with(FILES) && !known.h.is_empty()).map(|(key, _)| key) {
4833 let Some((store, _)) = locate(stores, file_of(key)) else { continue };
4834 if !reads(store) || joining.contains(&store.name) {
4835 continue;
4836 }
4837 let counted = by_store.entry(store.name.clone()).or_default();
4838 counted.0 += 1;
4839 if !found.hashes.contains_key(key) && !found.is_unknown(key) {
4840 counted.1.push(file_of(key).to_string());
4841 }
4842 }
4843 for (store, (held, gone)) in by_store.into_iter().filter(|(_, (_, gone))| !gone.is_empty()) {
4844 let confirmed = |file: &String| memory.sealed.confirmed.iter().any(|said| confirms(said, file)) || memory.sealed.confirmed_files.contains(file);
4848 let mass = gone.len() > MASS || (gone.len() >= 3 && gone.len() * 5 > held);
4849 let since_of = |file: &String| memory.sealed.gone.get(file).copied().unwrap_or(now_ms);
4850 let bulk = |file: &String| mass || memory.sealed.bulk.contains(file);
4852 let held_now = gone.iter().any(|file| bulk(file) && !confirmed(file) && now_ms - since_of(file) < GONE_GRACE);
4853 if held_now {
4854 outcome.problems.push(format!("share-vanished:{store}:{}", gone.len()));
4855 }
4856 for file in gone {
4857 let since = since_of(&file);
4858 if bulk(&file) && !confirmed(&file) && now_ms - since < GONE_GRACE {
4859 gone_since.insert(file.clone(), since);
4860 mass_held.entry(store.clone()).or_default().insert(file.clone(), since);
4861 bulk_now.insert(file.clone());
4862 if let Some(before) = memory.files.get(&file) {
4864 found.files.insert(file.clone(), before.clone());
4865 }
4866 found.unknown.insert(file);
4867 } else if !bulk(&file) && !confirmed(&file) && now_ms - since < EMPTIED_WAIT {
4868 gone_since.insert(file.clone(), since);
4869 held_gone.insert(file);
4870 }
4871 }
4872 }
4873 }
4874 memory.sealed.gone = gone_since;
4875 memory.sealed.bulk = bulk_now;
4876 let gone_now = &memory.sealed.gone;
4878 memory.sealed.confirmed_files.retain(|file| gone_now.contains_key(file));
4879 withheld.extend(mass_held.iter().map(|(store, files)| withheld_of(store, files)));
4880 withheld.sort_by(|a, b| a.store.cmp(&b.store));
4881 memory.withheld = withheld;
4882 let value = |key: &str| found.values.get(key).cloned().or_else(|| value_of(stores, key));
4883 let current_before: BTreeSet<String> = memory.entries.iter().filter(|(key, known)| key.starts_with(FILES) && !known.h.is_empty()).map(|(_, known)| known.h.clone()).collect();
4888 let mut theirs_now: Vec<(String, String)> = memory.entries.iter().filter(|(key, known)| key.starts_with(FILES) && !known.h.is_empty() && known.w != sharing.computer).map(|(_, known)| (known.h.clone(), known.w.clone())).collect();
4892 let mut removed_now: Vec<(String, String, u32)> = Vec::new();
4895 let mut replaced_now: Vec<String> = Vec::new();
4899
4900 let mut older: Vec<String> = Vec::new();
4903 if memory.joined {
4904 for (key, h) in &found.hashes {
4905 if memory.entries.get(key).is_some_and(|k| k.h == *h) || joins(key) {
4906 memory.held.remove(key);
4907 continue;
4908 }
4909 if holds(key) {
4912 if memory.held.get(key).is_none_or(|(_, held)| held != h) {
4913 let at = memory.tick(found.changed.get(file_of(key)).copied().unwrap_or(now_ms).min(now_ms));
4914 memory.held.insert(key.clone(), (at, h.clone()));
4915 }
4916 continue;
4917 }
4918 if key.starts_with(FILES) {
4919 let file = file_of(key);
4923 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) {
4924 older.push(key.clone());
4925 continue;
4926 }
4927 if hurried() {
4928 continue;
4929 }
4930 }
4931 let Some(value) = value(key) else { continue };
4932 if !seal_apart(sharing, stores, key, &value, &mut memory.sealed, &mut outcome.problems, now_ms) {
4934 continue;
4935 }
4936 let base = base_of(&memory, key);
4937 let clock = match memory.held.remove(key) {
4938 Some((at, held)) if held == *h => at,
4939 _ => memory.tick(found.changed.get(file_of(key)).copied().unwrap_or(now_ms).min(now_ms)),
4940 };
4941 memory.entries.insert(key.clone(), Known { c: clock, w: sharing.computer.to_string(), h: h.clone(), b: base.clone() });
4942 met_draft(&mut memory, key, [&base, h]);
4943 out.push((key.clone(), Some(value), clock, base));
4944 }
4945 let settings = |file: &str| stores.iter().find(|s| !s.folder && s.name == file && matches!(s.shape, Shape::Toml(_)));
4954 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();
4955 let mut waiting: BTreeSet<String> = BTreeSet::new();
4956 for file in held {
4957 let nothing = settings(&file).is_some_and(|s| std::fs::metadata(&s.path).map_or(true, |m| m.len() == 0));
4958 if !nothing || found.hashes.keys().any(|k| file_of(k) == file) {
4959 memory.emptied.remove(&file);
4960 } else if now_ms - *memory.emptied.entry(file.clone()).or_insert(now_ms) < EMPTIED_WAIT {
4961 outcome.problems.push(format!("share-emptied:{file}"));
4962 waiting.insert(file);
4963 }
4964 }
4965 let gone: Vec<String> = memory
4966 .entries
4967 .iter()
4968 .filter(|(key, known)| !known.h.is_empty() && !found.hashes.contains_key(*key) && !found.is_unknown(key) && !held_gone.contains(file_of(key)))
4969 .filter(|(key, _)| locate(stores, file_of(key)).is_some_and(|(store, _)| reads(store)) && !waiting.contains(file_of(key)))
4970 .map(|(key, _)| key.clone())
4971 .collect();
4972 for key in gone {
4973 if holds(&key) {
4974 if memory.held.get(&key).is_none_or(|(_, held)| !held.is_empty()) {
4975 let at = memory.tick(now_ms);
4976 memory.held.insert(key.clone(), (at, String::new()));
4977 }
4978 continue;
4979 }
4980 let base = base_of(&memory, &key);
4981 let clock = match memory.held.remove(&key) {
4982 Some((at, held)) if held.is_empty() => at,
4983 _ => memory.tick(now_ms),
4984 };
4985 memory.entries.insert(key.clone(), Known { c: clock, w: sharing.computer.to_string(), h: String::new(), b: base.clone() });
4986 met_draft(&mut memory, &key, [&base, ""]);
4987 out.push((key, None, clock, base));
4988 }
4989 }
4990
4991 let newer = |c: u64, w: &str, than: Option<(u64, &str)>| than.is_none_or(|(tc, tw)| (c, w) > (tc, tw));
4995 let off = |key: &str| retired(key) || (locate(stores, file_of(key)).is_none() && known_part(file_of(key)).is_some());
4997 let mut winners: BTreeMap<String, (Option<String>, u64, String)> = std::mem::take(&mut memory.pending)
4998 .into_iter()
4999 .filter(|(key, (_, c, w))| !off(key) && newer(*c, w, memory.entries.get(key).map(|k| (k.c, k.w.as_str()))))
5000 .collect();
5001 let mut older_format: BTreeMap<String, (Option<String>, u64, String)> = BTreeMap::new();
5005 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()));
5006 let later = |older_format: &mut BTreeMap<String, (Option<String>, u64, String)>, key: String, value: Option<String>, c: u64, w: &str| {
5007 if older_format.get(&key).is_none_or(|(_, tc, tw)| (c, w) > (*tc, tw.as_str())) {
5008 older_format.insert(key, (value, c, w.to_string()));
5009 }
5010 };
5011 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();
5014 for key in ready {
5015 let Some((value, c, w, f)) = memory.waiting.remove(&key) else { continue };
5016 if off(&key) {
5017 continue;
5018 }
5019 if f < 2 && divided_otherwise(&key) {
5020 later(&mut older_format, key, value, c, &w);
5021 } 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()))) {
5022 winners.insert(key, (value, c, w));
5023 }
5024 }
5025 let mut builds: BTreeMap<String, String> = BTreeMap::new();
5027 let mut bases: BTreeMap<String, String> = std::mem::take(&mut memory.sealed.bases).into_iter().filter(|(key, _)| winners.contains_key(key)).collect();
5032 let mut named: BTreeMap<String, BTreeSet<String>> = std::mem::take(&mut memory.sealed.named).into_iter().filter(|(key, _)| winners.contains_key(key)).collect();
5033 for (key, (value, _, _)) in winners.iter().filter(|(key, _)| key.starts_with(FILES)) {
5034 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()));
5035 }
5036 let all = rounds_with(sharing.folder);
5037 let rebuilding = std::mem::take(&mut memory.rebuild);
5039 let rebuilt = |file: &str| rebuilding.contains("*") || rebuilding.contains(file) || rebuilding.iter().any(|r| r.ends_with('/') && file.starts_with(r.as_str()));
5040 for (computer, their_rounds) in all.iter().filter(|(c, _)| *c != sharing.computer || !rebuilding.is_empty()) {
5041 let own = computer == sharing.computer;
5042 let (mut start, mut skip) = if own { (0, 0) } else { memory.read.get(computer).copied().unwrap_or((0, 0)) };
5043 if !their_rounds.contains(&start) {
5044 (start, skip) = (their_rounds[0], 0);
5046 }
5047 let mut from_round = start;
5050 'rounds: for &round in their_rounds.iter().filter(|r| **r >= start) {
5051 if round < from_round {
5052 continue;
5053 }
5054 let mut offset = if round == start { skip } else { 0 };
5055 let opened = if own { std::fs::File::open(round_file(sharing.folder, computer, round)) } else { open_round(sharing.folder, computer, round) };
5056 let Ok(mut file) = opened else {
5057 if !own {
5058 memory.read.insert(computer.clone(), (round, offset));
5059 }
5060 continue;
5061 };
5062 if !own && file.metadata().is_ok_and(|m| m.len() < offset) {
5066 outcome.problems.push(format!("share-other-cut:{computer}"));
5067 say_broken(&mut memory, computer, now_ms / 1000);
5068 offset = 0;
5069 }
5070 {
5071 use std::io::Seek;
5072 let _ = file.seek(std::io::SeekFrom::Start(offset));
5073 }
5074 let record_of = |line: &[u8]| std::str::from_utf8(line).ok().and_then(|text| serde_json::from_str::<Line>(text).ok());
5078 let opens = |record: &Line| {
5079 let plain = open(sharing.key, &bound(computer, round, record.n, record.c), &record.s)?;
5080 serde_json::from_slice::<Opened>(&plain).ok().map(Opened::parts)
5083 };
5084 let mut good: Option<(u64, Option<u64>)> = memory.read_n.get(computer).filter(|(r, _)| *r == round && offset > 0).map(|(_, n)| (*n, None));
5087 let mut lines = LineReader::new(file);
5088 let mut next = lines.next();
5089 while let Some((line, length)) = next.take() {
5090 next = lines.next();
5091 let Some(record) = record_of(&line) else {
5092 offset += length;
5094 if !own {
5095 if let Some(at) = lost_line(memory.read_n.get(computer).copied(), round, good, next.as_ref().map(|(n, _)| n.as_slice()), now_ms) {
5096 outcome.problems.push(format!("share-other-line:{computer}"));
5097 say_broken(&mut memory, computer, at);
5098 }
5099 lost_here(&mut memory, computer, round, None);
5100 }
5101 continue;
5102 };
5103 let Some((change, turn)) = opens(&record) else {
5104 outcome.problems.push(format!("share-other-seal:{computer}"));
5105 if !own && next.as_ref().and_then(|(next, _)| record_of(next)).is_some_and(|next| opens(&next).is_some()) {
5109 offset += length;
5110 if let Some(at) = lost_line(memory.read_n.get(computer).copied(), round, good, next.as_ref().map(|(n, _)| n.as_slice()), now_ms) {
5111 say_broken(&mut memory, computer, at);
5112 }
5113 lost_here(&mut memory, computer, round, Some(record.n));
5114 continue;
5115 }
5116 if !own {
5117 if let Some(full) = later_full(sharing, computer, their_rounds, round) {
5121 from_round = full;
5122 continue 'rounds;
5123 }
5124 memory.read.insert(computer.clone(), (round, offset));
5125 }
5126 break 'rounds;
5127 };
5128 if !own
5145 && record.n == 1
5146 && turn.p > 0
5147 && let Some(before) = round.checked_sub(1)
5148 && memory.met.get(computer).or_else(|| memory.read_n.get(computer)).is_some_and(|met| *met < (before, turn.p))
5149 {
5150 if let Some(full) = later_full(sharing, computer, their_rounds, round) {
5151 memory.waiting_for.remove(computer);
5152 from_round = full;
5153 continue 'rounds;
5154 }
5155 if their_rounds.iter().any(|r| *r <= before) {
5156 let waiting = memory.waiting_for.entry(computer.clone()).or_default();
5157 if waiting.round == round {
5158 waiting.waited += (now_ms - waiting.last).clamp(0, AWAIT_STEP);
5159 } else {
5160 *waiting = Awaited { round, waited: 0, last: now_ms };
5161 }
5162 waiting.last = now_ms;
5163 if waiting.waited < AWAITED {
5164 break 'rounds;
5165 }
5166 }
5167 }
5168 if !own && record.n == 1 {
5169 memory.waiting_for.remove(computer);
5170 }
5171 offset += length;
5172 good = Some((record.n, Some(record.c)));
5173 if !change.build.is_empty() {
5174 builds.insert(computer.clone(), change.build.clone());
5175 }
5176 let first = memory.met.get(computer).or_else(|| memory.read_n.get(computer)).is_none_or(|met| (round, record.n) > *met);
5186 if !own
5187 && first
5188 && change.k.starts_with(FILES)
5189 && let Some(value) = change.v.as_deref()
5190 {
5191 memory.sealed.named_at.insert(crate::blobs::name(sharing.key, &value_hash(&change.k, value)), now_ms);
5192 }
5193 if own {
5194 if rebuilt(file_of(&change.k)) && !off(&change.k) && memory.format < 2 && change.f >= 2 {
5196 memory.switch = true;
5201 wait(&mut memory.waiting, change.k, (change.v, record.c, computer.clone(), change.f));
5202 } else if rebuilt(file_of(&change.k)) && !off(&change.k) && memory.format >= 2 && change.f < 2 && divided_otherwise(&change.k) {
5203 later(&mut older_format, change.k, change.v, record.c, computer);
5204 } 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())) {
5205 bases.insert(change.k.clone(), change.b);
5206 win(&mut winners, &mut theirs_now, sharing.computer, change.k, (change.v, record.c, computer.clone()));
5207 }
5208 continue;
5209 }
5210 if memory.met.get(computer).is_none_or(|met| (round, record.n) > *met) {
5219 memory.met.insert(computer.clone(), (round, record.n));
5220 }
5221 let last = memory.read_n.get(computer).copied();
5222 if last.is_none_or(|last| (round, record.n) > last) {
5223 let follows = match last {
5224 Some((r, n)) if r == round => record.n == n + 1,
5225 _ if record.n != 1 => false,
5226 _ if turn.full => true,
5227 _ if turn.p > 0 => last.is_some() && last == round.checked_sub(1).map(|before| (before, turn.p)),
5228 _ => true,
5229 };
5230 if follows {
5231 memory.read_n.insert(computer.clone(), (round, record.n));
5232 } else if last.is_some() {
5233 if !outcome.problems.iter().any(|p| *p == format!("share-other-gap:{computer}")) {
5234 outcome.problems.push(format!("share-other-gap:{computer}"));
5235 }
5236 say_broken(&mut memory, computer, ((record.c >> 16) as i64 / 1000).min(now_ms / 1000));
5238 }
5239 }
5240 memory.clock = memory.clock.max(record.c);
5241 let heard_at = (record.c >> 16) as i64;
5243 if memory.heard_at.get(computer).is_none_or(|at| *at < heard_at) {
5244 memory.heard_at.insert(computer.clone(), heard_at);
5245 }
5246 if off(&change.k) {
5247 continue;
5248 }
5249 let format = change.f.max(1);
5253 if let Some(part) = known_part(file_of(&change.k))
5254 && format > part_format(part)
5255 {
5256 if memory.released.get(part).is_some_and(|c| record.c <= *c) {
5258 continue;
5259 }
5260 if !memory.newer.contains_key(part) {
5261 outcome.problems.push(format!("share-newer:{part}"));
5262 }
5263 let newer = memory.newer.entry(part.to_string()).or_default();
5264 newer.format = newer.format.max(format);
5265 if let Some(build) = builds.get(computer) {
5266 newer.build = build.clone();
5267 }
5268 newer.writers.insert(computer.clone());
5269 wait(&mut memory.waiting, change.k, (change.v, record.c, computer.clone(), format));
5270 continue;
5271 }
5272 if format < 2 {
5275 note_whole_record(originals, &mut memory, &change.k, change.v.as_deref(), record.c);
5276 }
5277 if format >= 2 && memory.format < 2 {
5281 memory.switch = true;
5282 wait(&mut memory.waiting, change.k, (change.v, record.c, computer.clone(), format));
5283 continue;
5284 }
5285 if format < 2 && memory.format >= 2 && divided_otherwise(&change.k) {
5287 later(&mut older_format, change.k, change.v, record.c, computer);
5288 continue;
5289 }
5290 if change.k.starts_with(FILES) {
5291 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()));
5292 if change.v.is_some() && !change.b.is_empty() {
5294 replaced_now.push(change.b.clone());
5295 }
5296 }
5297 let sent = change.k.starts_with(DRAFTS).then(|| {
5299 let here = match winners.get(&change.k) {
5300 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())),
5301 None => memory.entries.get(&change.k).map(|k| (k.h.is_empty(), k.h.clone(), k.b.clone())),
5302 };
5303 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);
5304 let version = change.v.as_deref().map(|v| value_hash(&change.k, v)).unwrap_or_default();
5305 met_draft(&mut memory, &change.k, [&change.b, &version]);
5306 decided
5307 });
5308 let known = memory.entries.get(&change.k).map(|k| (k.c, k.w.as_str()));
5309 let pending = winners.get(&change.k).map(|(_, c, w)| (*c, w.as_str()));
5310 let wins = match sent.flatten() {
5312 Some(wins) => wins,
5313 None => newer(record.c, computer, known) && newer(record.c, computer, pending) && !below_whole_floor(&memory, &change.k, record.c),
5314 };
5315 if wins {
5316 if change.v.is_none() && change.k.starts_with(FILES) {
5318 let before = match winners.get(&change.k) {
5319 Some((value, _, _)) => value.as_deref().map(|v| value_hash(&change.k, v)).unwrap_or_default(),
5320 None => memory.entries.get(&change.k).map(|k| k.h.clone()).unwrap_or_default(),
5321 };
5322 if !before.is_empty() {
5323 removed_now.push((before, computer.clone(), round));
5324 }
5325 }
5326 bases.insert(change.k.clone(), change.b);
5327 win(&mut winners, &mut theirs_now, sharing.computer, change.k, (change.v, record.c, computer.clone()));
5328 }
5329 }
5330 if !own {
5331 memory.read.insert(computer.clone(), (round, offset));
5332 }
5333 }
5334 }
5335
5336 let mut again: Vec<(String, String)> = Vec::new();
5338 if !older_format.is_empty() {
5339 let modern: BTreeSet<String> = memory.seen.values().filter(|e| e.format >= 2).map(|e| e.id.clone()).collect();
5341 let (translated, named_here) = translate(originals, &older_format, &mut memory, &winners, &modern);
5342 again = named_here;
5343 for (key, value, c, w) in translated {
5344 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()))) {
5345 bases.remove(&key);
5346 winners.insert(key, (value, c, w));
5347 }
5348 }
5349 }
5350
5351 let mut by_file: BTreeMap<String, Vec<(String, Option<String>)>> = BTreeMap::new();
5354 for (key, (value, _, _)) in &winners {
5355 by_file.entry(file_of(key).to_string()).or_default().push((key.clone(), value.clone()));
5356 }
5357 let mut order: Vec<&String> = by_file.keys().collect();
5358 order.sort_by_key(|file| !(file.starts_with(FILES) && by_file[*file].first().is_some_and(|(_, value)| value.is_none())));
5359 let mut written_keys: BTreeSet<String> = BTreeSet::new();
5360 let mut written_hashes: BTreeMap<String, String> = BTreeMap::new();
5362 let mut written_files: BTreeMap<String, Option<Stat>> = BTreeMap::new();
5364 let mut settled: BTreeSet<String> = BTreeSet::new();
5366 let history = crate::history::root(sharing.memory);
5367 let mut kept = false;
5368 let mut names: Option<Names> = None;
5369 let receiving = Receiving { sharing, history: &history, now_ms, clock, copied: std::cell::Cell::new(0) };
5370 let mut wanted: BTreeMap<String, (String, u64)> = BTreeMap::new();
5372 let mut there: BTreeSet<String> = BTreeSet::new();
5373 for file in order {
5374 let changes = &by_file[file];
5375 let Some((store, path)) = locate(stores, file) else {
5376 if file.starts_with(FILES) {
5377 *outcome.waits.entry("no-store".into()).or_default() += 1;
5378 }
5379 continue;
5380 };
5381 if matches!(store.shape, Shape::Files) {
5382 let Some((key, value)) = changes.first() else { continue };
5385 let why = if !reads(store) { "not-read" } else if hurried() { "hurried" } else if found.is_unknown(file) { "unknown" } else { "" };
5386 if !why.is_empty() {
5387 *outcome.waits.entry(why.into()).or_default() += 1;
5388 if keeps_itself(sharing)
5394 && let Some(reference) = value.as_deref().and_then(|v| serde_json::from_str::<Reference>(v).ok())
5395 {
5396 let blob = crate::blobs::name(sharing.key, &reference.h);
5397 if std::fs::metadata(sharing.folder.join("blobs").join(&blob)).is_ok_and(|m| m.len() > 0) {
5398 there.insert(blob);
5399 } else {
5400 wanted.insert(blob, (reference.h.clone(), reference.s));
5401 }
5402 }
5403 continue;
5404 }
5405 if value.is_none() && receiving.copied.get() >= take_out_copies() {
5407 *outcome.waits.entry("copies".into()).or_default() += 1;
5408 continue;
5409 }
5410 let reference = value.as_deref().and_then(|v| serde_json::from_str::<Reference>(v).ok());
5411 let blob = reference.as_ref().map(|r| crate::blobs::name(sharing.key, &r.h)).unwrap_or_default();
5412 if memory.sealed.failing.get(key).is_some_and(|(failed, again, _)| *failed == blob && now_ms < *again) {
5414 *outcome.waits.entry("backoff".into()).or_default() += 1;
5415 continue;
5416 }
5417 if !writable(store, &path, sharing) {
5418 outcome.problems.push(format!("share-refused:{file}"));
5419 continue;
5420 }
5421 let names = names.get_or_insert_with(|| Names::new(&memory.entries, &found));
5422 if value.is_some() && names.clash(file) {
5423 outcome.problems.push(format!("share-name-clash:{file}"));
5424 continue;
5425 }
5426 let base = bases.get(key).map_or("", String::as_str);
5427 let seen = |h: &str| h == base || named.get(key).is_some_and(|n| n.contains(h));
5428 let back_off = |failing: &mut BTreeMap<String, (String, i64, u32)>| {
5429 let tried = failing.get(key).map_or(0, |(_, _, n)| *n);
5430 let wait = [MINUTE, 10 * MINUTE, HOUR, 6 * HOUR][(tried as usize).min(3)];
5431 failing.insert(key.clone(), (blob.clone(), now_ms + wait, tried + 1));
5432 };
5433 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);
5434 match (&came, &reference) {
5436 (Err(crate::blobs::Fault::Missing), Some(reference)) => {
5437 wanted.insert(blob.clone(), (reference.h.clone(), reference.s));
5438 }
5439 (_, Some(_)) => {
5440 there.insert(blob.clone());
5441 }
5442 _ => {}
5443 }
5444 match came {
5445 Ok(Received::AlreadySo) => {
5446 settled.insert(key.clone());
5447 memory.sealed.missing.remove(key);
5448 memory.sealed.failing.remove(key);
5449 }
5450 Ok(Received::Later) => *outcome.waits.entry("later".into()).or_default() += 1,
5451 Ok(Received::Written { stat, copy, kept: into_history }) => {
5452 kept |= into_history;
5453 outcome.written.insert(store.name.clone());
5454 outcome.received += 1;
5455 written_keys.insert(key.clone());
5456 written_hashes.insert(key.clone(), stat.as_ref().and_then(|s| s.entries.first()).map(|(_, h)| h.clone()).unwrap_or_default());
5457 names.written(file, stat.is_some());
5458 written_files.insert(file.clone(), stat);
5459 memory.sealed.missing.remove(key);
5460 memory.sealed.failing.remove(key);
5461 if let Some(copy) = copy {
5462 outcome.problems.push(format!("{}:{copy}", if value.is_some() { "share-conflict" } else { "share-conflict-gone" }));
5463 }
5464 }
5465 Err(crate::blobs::Fault::Missing) => {
5468 *outcome.waits.entry("no-blob".into()).or_default() += 1;
5469 if now_ms - *memory.sealed.missing.entry(key.clone()).or_insert(now_ms) > DAY {
5470 outcome.problems.push(format!("share-missing:{file}"));
5471 }
5472 }
5473 Err(crate::blobs::Fault::Broken) => outcome.problems.push(format!("share-damaged:{file}")),
5474 Err(crate::blobs::Fault::Room) => {
5475 outcome.problems.push(format!("share-no-room:{file}"));
5476 back_off(&mut memory.sealed.failing);
5477 }
5478 Err(crate::blobs::Fault::Local(e)) => {
5479 outcome.problems.push(e);
5480 back_off(&mut memory.sealed.failing);
5481 }
5482 }
5483 continue;
5484 }
5485 if found.unknown.contains(file) && !matches!(store.shape, Shape::Whole) && path.exists() {
5486 outcome.problems.push(format!("share-unreadable:{}", path.display()));
5488 continue;
5489 }
5490 if store.folder && !writable(store, &path, sharing) {
5491 outcome.problems.push(format!("share-refused:{file}"));
5492 continue;
5493 }
5494 let entries: Vec<(&str, Option<&str>)> = changes.iter().map(|(key, value)| (entry_of(key), value.as_deref())).collect();
5495 let keep = || {
5498 if calls_store(file) {
5499 return Ok(());
5500 }
5501 kept = true;
5502 crate::history::keep(&history, store.part, file, &path, now_ms)
5503 };
5504 match write_entries(store, &path, &entries, keep) {
5505 Ok(hashes) => {
5506 outcome.written.insert(store.name.clone());
5507 outcome.received += changes.len();
5508 for (key, _) in changes {
5509 written_keys.insert(key.clone());
5510 written_hashes.insert(key.clone(), hashes.get(entry_of(key)).cloned().unwrap_or_default());
5511 }
5512 written_files.insert(file.clone(), None);
5513 }
5514 Err(e) => outcome.problems.push(e),
5515 }
5516 }
5517 for (key, (value, clock, computer)) in winners {
5518 if written_keys.contains(&key) || settled.contains(&key) {
5519 let h = written_hashes.get(&key).cloned().unwrap_or_else(|| value.as_deref().map(|v| value_hash(&key, v)).unwrap_or_default());
5520 memory.entries.insert(key, Known { c: clock, w: computer, h, b: String::new() });
5521 } else {
5522 if let Some(base) = bases.remove(&key).filter(|b| !b.is_empty()) {
5523 memory.sealed.bases.insert(key.clone(), base);
5524 }
5525 if let Some(contents) = named.remove(&key) {
5526 memory.sealed.named.insert(key.clone(), contents);
5527 }
5528 memory.pending.insert(key, (value, clock, computer));
5529 }
5530 }
5531 for (name, fallback) in again.into_iter().filter(|(name, _)| written_keys.contains(name)) {
5536 let value = value_of(stores, &name);
5537 let clock = memory.tick(now_ms);
5538 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() });
5539 out.push((name.clone(), value, clock, String::new()));
5540 if fallback != name {
5541 let clock = memory.tick(now_ms);
5542 memory.entries.insert(fallback.clone(), Known { c: clock, w: sharing.computer.to_string(), h: String::new(), b: String::new() });
5543 out.push((fallback, None, clock, String::new()));
5544 }
5545 }
5546 let mut files = found.files.clone();
5551 for (file, stat) in written_files {
5552 match stat {
5553 Some(stat) => files.insert(file, stat),
5554 None => files.remove(&file),
5555 };
5556 }
5557
5558 if !memory.joined || !rebuilt_here.is_empty() {
5560 for (key, h) in &found.hashes {
5561 if memory.entries.contains_key(key) || memory.pending.contains_key(key) || (memory.joined && !joins(key)) || (key.starts_with(FILES) && hurried()) || holds(key) {
5562 continue;
5563 }
5564 let Some(value) = value(key) else { continue };
5565 if !seal_apart(sharing, stores, key, &value, &mut memory.sealed, &mut outcome.problems, now_ms) {
5566 continue;
5567 }
5568 let clock = memory.tick(found.changed.get(file_of(key)).copied().unwrap_or(now_ms).min(now_ms));
5569 memory.entries.insert(key.clone(), Known { c: clock, w: sharing.computer.to_string(), h: h.clone(), b: String::new() });
5570 out.push((key.clone(), Some(value), clock, String::new()));
5571 }
5572 memory.joined = true;
5573 }
5574
5575 for key in older.into_iter().filter(|key| !written_keys.contains(key)) {
5579 let file = file_of(&key).to_string();
5580 let (Some((store, path)), Some(known), Some(before)) = (locate(stores, &file), memory.entries.get(&key), memory.files.get(&file).cloned()) else { continue };
5581 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();
5582 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) };
5583 match came {
5584 Ok(Received::Written { stat, copy, kept: into_history }) => {
5585 kept |= into_history;
5586 outcome.problems.push(format!("share-older-copy:{}", copy.unwrap_or_else(|| shown(&file).to_string())));
5587 if let Some(stat) = stat {
5588 files.insert(file, stat);
5589 }
5590 }
5591 _ => {
5592 outcome.problems.push(format!("share-older-copy:{}", shown(&file)));
5593 files.insert(file, before);
5594 }
5595 }
5596 }
5597
5598 outcome.sent = out.len();
5599 outcome.pending = memory.pending.len();
5600 if memory.round == 0 {
5601 memory.round = 1;
5602 }
5603 let own = |memory: &Memory| std::fs::metadata(round_file(sharing.folder, sharing.computer, memory.round)).map_or(0, |m| m.len());
5608 if memory.own_size > own(&memory) || restate {
5609 if !restate {
5610 outcome.problems.push("share-own-cut".into());
5611 }
5612 new_round(sharing, &mut memory, stores, &found, &hurried, now_ms, true)?;
5613 memory.round_base = own(&memory);
5614 }
5615 if !out.is_empty() && own(&memory) >= SEGMENT {
5618 if memory.full_round == 0 {
5619 memory.full_round = memory.round;
5620 }
5621 memory.continues = memory.seq;
5623 memory.round += 1;
5624 memory.seq = 0;
5625 memory.round_base = 0;
5626 }
5627 let before = own(&memory);
5628 append(sharing, &mut memory, &out)?;
5629 for (key, value, _, base) in &out {
5632 if value.is_none() && key.starts_with(FILES) && !base.is_empty() {
5633 removed_now.push((base.clone(), sharing.computer.to_string(), memory.round));
5634 }
5635 if value.is_some() && key.starts_with(FILES) && !base.is_empty() {
5636 replaced_now.push(base.clone());
5637 }
5638 }
5639 memory.grown += own(&memory).saturating_sub(before);
5640 if memory.full_round == 0 && memory.round == 1 {
5642 memory.full_round = 1;
5643 memory.grown = 0;
5644 }
5645 let part_of = |key: &str| locate(stores, file_of(key)).map(|(store, _)| store.part);
5647 for part in out.iter().filter_map(|(key, ..)| part_of(key)) {
5648 memory.traffic.entry(part.to_string()).or_default().0 = now_ms / 1000;
5649 }
5650 for part in written_keys.iter().filter_map(|key| part_of(key)) {
5651 memory.traffic.entry(part.to_string()).or_default().1 = now_ms / 1000;
5652 }
5653 if sharing.files && !hurried() && now_ms - memory.checked >= 10 * MINUTE {
5655 look_after_sealed(sharing, stores, &mut memory, &found, &hurried, &mut outcome.problems, now_ms);
5656 memory.checked = now_ms;
5657 }
5658 note_wanted(sharing, &memory, &wanted, &there, now_ms);
5659 if !memory.sealed.damaged.is_empty() {
5661 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();
5662 memory.sealed.damaged.retain(|name, _| waited.contains(name));
5663 }
5664 let counts = counted(sharing.folder, sharing.key, sharing.computer, &memory, now_ms);
5667 let reading = readers(sharing.folder, &counts, now_ms);
5668 for h in replaced_now {
5670 memory.sealed.replaced.insert(crate::blobs::name(sharing.key, &h), now_ms);
5671 }
5672 memory.sealed.replaced.retain(|_, at| now_ms - *at < (crate::history::SEALED_DAYS + 1) * DAY);
5673 let days = if memory.sealed.named_at.is_empty() && memory.sealed.mine.is_empty() { crate::blobs::UNUSED_DAYS } else { unused_days(sharing, &memory, now_ms) };
5676 memory.sealed.named_at.retain(|_, at| now_ms - *at < days * DAY);
5677 if !memory.sealed.mine.is_empty() {
5678 let current: BTreeSet<String> = memory.entries.iter().filter(|(key, known)| key.starts_with(FILES) && !known.h.is_empty()).map(|(_, known)| known.h.clone()).collect();
5679 let used: BTreeSet<String> = current
5680 .iter()
5681 .cloned()
5682 .chain(memory.pending.iter().filter(|(key, _)| key.starts_with(FILES)).filter_map(|(key, (value, _, _))| value.as_deref().map(|v| value_hash(key, v))))
5683 .map(|h| crate::blobs::name(sharing.key, &h))
5684 .chain(memory.sealed.named_at.keys().cloned())
5685 .collect();
5686 prune_tables(sharing.folder, &mut memory.sealed, &used);
5687 let used: BTreeSet<String> = current_before.difference(¤t).map(|h| crate::blobs::name(sharing.key, h)).chain(used).collect();
5689 let mut last: BTreeMap<String, i64> = memory.sealed.mine.iter().map(|(name, (at, _))| (name.clone(), *at)).collect();
5690 let early = counts.iter().all(|(_, counted)| match counted {
5693 Counted::Yes(entry, _) => entry.as_ref().is_some_and(|e| e.keeping >= crate::blobs::KEEPING_MOVED),
5694 _ => true,
5695 });
5696 removed_due(sharing.folder, sharing.key, &mut memory.sealed, std::mem::take(&mut removed_now), ¤t, &reading, &mut last, days, early, now_ms);
5697 crate::blobs::sweep_after(sharing.folder, &mut last, &used, now_ms, days);
5698 memory.sealed.mine.retain(|name, (at, _)| last.get(name).map(|kept| *at = *kept).is_some());
5699 let mine = &memory.sealed.mine;
5700 memory.sealed.removed.retain(|name, _| mine.contains_key(name));
5701 } else {
5702 memory.sealed.removed.clear();
5703 }
5704 sweep_theirs(sharing, stores, &mut memory, &counts, theirs_now, ¤t_before, now_ms);
5707 if kept || now_ms - memory.tidied >= HOUR {
5709 crate::history::prune(&history, now_ms, crate::history::cap(&history), &|_, file| locate(stores, file).is_some_and(|(_, path)| path.exists()));
5710 memory.tidied = now_ms;
5711 }
5712 if let Some(dir) = sharing.memory.parent() {
5718 clean_leftovers(dir);
5719 }
5720 if now_ms - memory.cleaned >= DAY {
5721 clean_leftovers(&sharing.folder.join("blobs"));
5722 for store in stores.iter().filter(|s| s.folder && matches!(s.shape, Shape::Whole)) {
5723 clean_leftovers_below(&store.path, 3);
5724 }
5725 memory.cleaned = now_ms;
5726 }
5727 let waits = if importing(sharing.memory) { memory.grown <= IMPORT_ROUND_SIZE } else { frugal(sharing.memory) && memory.grown <= FRUGAL_ROUND_SIZE };
5732 if !memory.owed && asked_for_full(sharing, &memory, now_ms) {
5735 memory.owed = true;
5736 }
5737 let metered = frugal(sharing.memory) && !importing(sharing.memory);
5749 if memory.owed && metered {
5750 if memory.owed_last > 0 {
5751 memory.owed_waited += (now_ms - memory.owed_last).clamp(0, AWAIT_STEP);
5752 }
5753 memory.owed_last = now_ms;
5754 }
5755 let thrifty = importing(sharing.memory) || (metered && memory.owed_waited < OWED_METERED);
5756 let owed = memory.owed && !thrifty && restatement(sharing, &memory, stores, &found).1 == 0;
5757 if (memory.grown > full_round_after(sharing, &mut memory) && !waits) || owed {
5758 new_round(sharing, &mut memory, stores, &found, &hurried, now_ms, false)?;
5759 memory.round_base = own(&memory);
5760 }
5761 memory.own_size = own(&memory);
5762 memory.files = files;
5763 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();
5765 memory.places = places;
5766 memory.sealed.confirmed.retain(|folder| !stores.iter().any(|s| folder.starts_with(s.name.as_str()) && reads(s)));
5768 memory.sealed.missing.retain(|key, _| memory.pending.contains_key(key));
5769 memory.sealed.failing.retain(|key, _| memory.pending.contains_key(key));
5770 let seen_due = write_seen(sharing, &memory, &all, now_ms);
5771 outcome.looked = if memory.newer.contains_key("health") { 0 } else { clock };
5776 outcome.wrote = memory.joined.then_some((memory.round.max(1), memory.seq));
5777 memory.counting = Some(counts.iter().filter(|(_, counted)| matches!(counted, Counted::Yes(..))).map(|(id, _)| id.clone()).collect());
5781 memory.uncounted = counts.iter().filter(|(_, counted)| !matches!(counted, Counted::Yes(..) | Counted::Stray)).map(|(id, _)| id.clone()).collect();
5782 memory.departed = departed_rounds(sharing, &memory, &counts, &reading);
5783 memory.removed_devices.retain(|id, _| counts.iter().any(|(other, counted)| other == id && counted.departed()));
5787 let judged: BTreeMap<String, (bool, bool)> = memory.removed_devices.keys().filter_map(|id| counts.iter().find(|(other, _)| other == id).map(|(_, counted)| (id.clone(), small_go(counted, last_heard(memory.seen.get(id), &memory, id), now_ms)))).collect();
5789 for (id, removed) in memory.removed_devices.iter_mut() {
5790 let floor = memory.departed.entry(id.clone()).or_default();
5791 *floor = (*floor).max(removed.last);
5792 if let Some((entry, notes)) = judged.get(id) {
5793 (removed.entry, removed.notes) = (removed.entry || *entry, removed.notes || *notes);
5794 }
5795 let last = std::iter::once(sharing.folder.to_path_buf()).chain(crate::remote::overlay(sharing.folder)).map(|dir| round_file(&dir, id, removed.last)).filter(|path| std::fs::metadata(path).is_ok_and(|m| removed.holds_round(removed.last, m.len())));
5796 let as_it_went = |path: &Path, place: &str| match removed.small_seen.get(place) {
5798 Some(h) => content_hash(path).as_deref() == Some(h.as_str()),
5799 None => removed.small.get(place).is_some_and(|size| std::fs::metadata(path).is_ok_and(|m| m.len() == *size)),
5800 };
5801 let small = device_small_places(sharing.folder, id, removed.entry, removed.notes).into_iter().filter(|(path, place)| as_it_went(path, place)).map(|(path, _)| path);
5802 for path in small.chain(last).collect::<Vec<_>>() {
5803 let _ = std::fs::remove_file(path);
5804 }
5805 }
5806 remove_old_rounds(sharing, &memory, &reading);
5807 if memory.format < 2 {
5809 note_whole_lists(originals, &mut memory);
5810 }
5811 let settled = outcome.problems.is_empty()
5816 && memory.joined
5817 && !memory.switch
5818 && memory.newer.is_empty()
5819 && memory.waiting.is_empty()
5820 && memory.held.is_empty()
5821 && memory.emptied.is_empty()
5822 && memory.sealed.gone.is_empty()
5823 && memory.sealed.failing.is_empty()
5824 && memory.sealed.missing.is_empty()
5825 && memory.waiting_for.is_empty()
5826 && !memory.owed
5827 && memory.pending.keys().all(|key| locate(stores, file_of(key)).is_some_and(|(store, _)| !reads(store)));
5828 let until = (now_ms + HOUR).min(memory.tidied + HOUR).min(memory.cleaned + DAY).min(seen_due);
5829 let (round, own_size) = (memory.round, memory.own_size);
5830 let drafts: BTreeSet<String> = memory.drafts.keys().filter(|key| !memory.entries.contains_key(*key) && !memory.pending.contains_key(*key)).cloned().collect();
5832 for key in drafts {
5833 memory.drafts.remove(&key);
5834 }
5835 outcome.withheld = Some(memory.withheld.clone());
5836 let needed: Option<BTreeSet<String>> = keeps_itself(sharing).then(|| {
5837 let name = |h: &str| crate::blobs::name(sharing.key, h);
5838 let waiting = memory.pending.iter().filter(|(key, _)| key.starts_with(FILES)).filter_map(|(key, (value, _, _))| value.as_deref().map(|v| name(&value_hash(key, v))));
5839 let current = memory.entries.iter().filter(|(key, known)| key.starts_with(FILES) && !known.h.is_empty() && (locate(stores, file_of(key)).is_some() || memory.sealed.gone.contains_key(file_of(key)))).map(|(_, known)| name(&known.h));
5841 waiting.chain(current).chain(memory.sealed.replaced.keys().cloned()).collect()
5842 });
5843 memory.sealed.needed = needed;
5844 memory.save(sharing.memory)?;
5845 if let Some((standing, _)) = standing.filter(|(standing, taken)| settled && (standing.newest / 1_000_000) as i64 + settling_ms() < *taken) {
5846 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 };
5847 if let Ok(mut all) = QUIET.lock() {
5848 all.insert(sharing.memory.to_path_buf(), quiet);
5849 }
5850 }
5851 memory.keep(sharing.memory);
5852 Ok(outcome)
5853}
5854
5855#[derive(Debug, Clone, Default, PartialEq, Eq)]
5865struct Standing {
5866 sharing: (PathBuf, String, String),
5867 stores: Vec<(String, PathBuf)>,
5868 files: BTreeMap<PathBuf, FileStamp>,
5869 folder: BTreeMap<PathBuf, Option<FileStamp>>,
5870 entries: BTreeMap<String, String>,
5871 overlay: Option<PathBuf>,
5872 waits: (bool, bool),
5875 in_use: bool,
5876 newest: u64,
5878}
5879
5880impl Standing {
5881 fn now(sharing: &Sharing, stores: &[Store], reads: &dyn Fn(&Store) -> bool) -> Standing {
5882 let mut standing = Standing { sharing: (sharing.folder.to_path_buf(), sharing.computer.to_string(), hash(&B64.encode(sharing.key))), ..Standing::default() };
5883 for store in stores.iter().filter(|s| reads(s)) {
5884 standing.stores.push((store.name.clone(), store.path.clone()));
5885 stamp_below(&store.path, &mut standing.files);
5886 }
5887 let overlay = crate::remote::overlay(sharing.folder);
5888 for dir in std::iter::once(sharing.folder.to_path_buf()).chain(overlay.clone()) {
5889 for entry in std::fs::read_dir(&dir).into_iter().flatten().filter_map(Result::ok) {
5890 let name = entry.file_name().to_string_lossy().to_string();
5891 if name.starts_with('.') || entry.file_type().is_ok_and(|t| t.is_dir()) {
5892 continue;
5893 }
5894 let stamped = !name.starts_with(sharing.computer) && (name.ends_with(".jsonl") || name == "seal.toml");
5896 standing.folder.insert(entry.path(), if stamped { entry.metadata().ok().map(|m| file_stamp(&m)) } else { None });
5897 }
5898 }
5899 let (entries, unread) = crate::devices::all(sharing.folder, sharing.key);
5900 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();
5901 standing.overlay = overlay;
5902 standing.waits = (frugal(sharing.memory), importing(sharing.memory));
5903 standing.in_use = crate::devices::in_use(sharing.memory);
5904 standing.newest = standing.files.values().chain(standing.folder.values().flatten()).map(|s| s.modified).max().unwrap_or(0);
5905 standing
5906 }
5907}
5908
5909fn stamp_below(path: &Path, out: &mut BTreeMap<PathBuf, FileStamp>) {
5911 let Ok(meta) = std::fs::metadata(path) else { return };
5912 if meta.is_file() {
5913 out.insert(path.to_path_buf(), file_stamp(&meta));
5914 return;
5915 }
5916 for entry in std::fs::read_dir(path).into_iter().flatten().filter_map(Result::ok) {
5917 if entry.file_name().to_string_lossy().starts_with('.') {
5918 continue;
5919 }
5920 stamp_below(&entry.path(), out);
5921 }
5922}
5923
5924struct Quiet {
5929 standing: Standing,
5930 at: i64,
5931 until: i64,
5932 own: (Option<FileStamp>, Option<FileStamp>),
5933 round: u32,
5934 own_size: u64,
5935 wrote: Option<(u32, u64)>,
5936 pending: usize,
5937}
5938
5939static QUIET: std::sync::Mutex<BTreeMap<PathBuf, Quiet>> = std::sync::Mutex::new(BTreeMap::new());
5941
5942fn quiet(sharing: &Sharing, standing: &Standing, taken: i64, now_ms: i64) -> Option<Outcome> {
5951 let mut all = QUIET.lock().ok()?;
5952 let quiet = all.get(sharing.memory)?;
5953 let own_size = std::fs::metadata(round_file(sharing.folder, sharing.computer, quiet.round)).map_or(0, |m| m.len());
5954 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;
5955 if !still {
5956 all.remove(sharing.memory);
5957 return None;
5958 }
5959 #[cfg(test)]
5960 QUIETED.with(|n| n.set(n.get() + 1));
5961 Some(Outcome { looked: taken, wrote: quiet.wrote, pending: quiet.pending, quiet: true, ..Outcome::default() })
5962}
5963
5964#[cfg(test)]
5965thread_local! {
5966 pub(super) static QUIETED: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
5969 pub(super) static LINES_READ: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
5970}
5971
5972struct LineReader {
5978 file: std::fs::File,
5979 held: Vec<u8>,
5980 start: usize,
5981 skipping: Option<u64>,
5983 end: bool,
5984}
5985
5986impl LineReader {
5987 fn new(file: std::fs::File) -> LineReader {
5988 LineReader { file, held: Vec::new(), start: 0, skipping: None, end: false }
5989 }
5990
5991 fn next(&mut self) -> Option<(Vec<u8>, u64)> {
5993 use std::io::Read;
5994 loop {
5995 if let Some(at) = self.held[self.start..].iter().position(|b| *b == b'\n') {
5996 let line = &self.held[self.start..=self.start + at];
5997 self.start += at + 1;
5998 return Some(match self.skipping.take() {
5999 Some(skipped) => (Vec::new(), skipped + line.len() as u64),
6000 None => (line.to_vec(), line.len() as u64),
6001 });
6002 }
6003 self.held.drain(..self.start);
6004 self.start = 0;
6005 if self.skipping.is_some() || self.held.len() > LONGEST_LINE {
6006 *self.skipping.get_or_insert(0) += self.held.len() as u64;
6007 self.held.clear();
6008 }
6009 if self.end {
6010 return None;
6011 }
6012 let mut piece = vec![0u8; READ_CHUNK];
6013 match self.file.read(&mut piece) {
6014 Ok(0) | Err(_) => self.end = true,
6015 Ok(n) => self.held.extend_from_slice(&piece[..n]),
6016 }
6017 }
6018 }
6019}
6020
6021fn lost_here(memory: &mut Memory, computer: &str, round: u32, n: Option<u64>) {
6032 let met = memory.met.get(computer).or_else(|| memory.read_n.get(computer)).copied();
6033 let n = n.unwrap_or(match met {
6034 Some((r, m)) if r == round => m + 1,
6035 _ => 1,
6036 });
6037 if met.is_none_or(|met| (round, n) > met) {
6038 memory.met.insert(computer.to_string(), (round, n));
6039 }
6040}
6041
6042fn say_broken(memory: &mut Memory, computer: &str, at: i64) {
6046 let kept = memory.broken.entry(computer.to_string()).or_insert(at);
6047 *kept = (*kept).max(at);
6048}
6049
6050fn later_full(sharing: &Sharing, computer: &str, rounds: &[u32], round: u32) -> Option<u32> {
6055 rounds.iter().copied().filter(|r| *r > round).find(|r| opens_full(sharing, computer, *r))
6056}
6057
6058fn last_full(sharing: &Sharing, computer: &str, rounds: &[u32]) -> Option<u32> {
6062 rounds.iter().rev().copied().find(|r| opens_full(sharing, computer, *r))
6063}
6064
6065fn opens_full(sharing: &Sharing, computer: &str, round: u32) -> bool {
6068 let at = (sharing.folder.to_path_buf(), computer.to_string(), round);
6070 if let Some(full) = FIRST_LINES.lock().ok().and_then(|seen| seen.get(&at).copied()) {
6071 return full;
6072 }
6073 let Ok(file) = open_round(sharing.folder, computer, round) else { return false };
6074 let Some((line, _)) = LineReader::new(file).next() else { return false };
6075 let Some(record) = std::str::from_utf8(&line).ok().and_then(|text| serde_json::from_str::<Line>(text).ok()) else { return false };
6076 let Some(turn) = open(sharing.key, &bound(computer, round, record.n, record.c), &record.s).and_then(|plain| serde_json::from_slice::<Turn>(&plain).ok()) else { return false };
6077 let full = record.n == 1 && turn.full;
6078 if let Ok(mut seen) = FIRST_LINES.lock() {
6079 seen.insert(at, full);
6080 }
6081 full
6082}
6083
6084static FIRST_LINES: std::sync::Mutex<BTreeMap<(PathBuf, String, u32), bool>> = std::sync::Mutex::new(BTreeMap::new());
6088
6089fn lost_line(read_n: Option<(u32, u64)>, round: u32, good: Option<(u64, Option<u64>)>, next: Option<&[u8]>, now_ms: i64) -> Option<i64> {
6099 let before = good.map_or(0, |(n, _)| n);
6100 if read_n.is_some_and(|read| read >= (round, before + 1)) {
6101 return None;
6102 }
6103 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);
6104 Some(after.map_or(now_ms / 1000, |c| ((c >> 16) as i64 / 1000).min(now_ms / 1000)))
6105}
6106
6107fn look_after_sealed(sharing: &Sharing, stores: &[Store], memory: &mut Memory, found: &Found, hurried: &dyn Fn() -> bool, problems: &mut Vec<String>, now_ms: i64) {
6113 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();
6114 for (key, h) in ours {
6115 if hurried() {
6116 return;
6117 }
6118 let name = crate::blobs::name(sharing.key, &h);
6119 let there = std::fs::metadata(sharing.folder.join("blobs").join(&name)).map_or(0, |m| m.len());
6120 if there > 0 && memory.sealed.mine.get(&name).is_none_or(|(_, size)| *size == there) {
6121 continue;
6122 }
6123 let Some((_, path)) = locate(stores, file_of(&key)) else { continue };
6124 match crate::blobs::put_again(sharing.folder, sharing.key, &path, &h) {
6125 Ok(size) => {
6126 memory.sealed.mine.insert(name, (now_ms, size));
6127 }
6128 Err(e) => problems.push(e),
6129 }
6130 }
6131}
6132
6133struct Names {
6137 by_folded: BTreeMap<String, String>,
6138}
6139
6140impl Names {
6141 fn new(entries: &BTreeMap<String, Known>, found: &Found) -> Names {
6142 let known = entries.iter().filter(|(key, known)| key.starts_with(FILES) && !known.h.is_empty()).map(|(key, _)| key);
6143 let here = found.hashes.keys().filter(|key| key.starts_with(FILES));
6144 Names { by_folded: known.chain(here).map(|key| (folded(file_of(key)), file_of(key).to_string())).collect() }
6145 }
6146
6147 fn clash(&self, file: &str) -> bool {
6149 self.by_folded.get(&folded(file)).is_some_and(|other| other != file)
6150 }
6151
6152 fn written(&mut self, file: &str, there: bool) {
6153 let name = folded(file);
6154 if there {
6155 self.by_folded.insert(name, file.to_string());
6156 } else if self.by_folded.get(&name).is_some_and(|other| other == file) {
6157 self.by_folded.remove(&name);
6158 }
6159 }
6160}
6161
6162fn folded(name: &str) -> String {
6164 icu_normalizer::ComposingNormalizerBorrowed::new_nfc().normalize(name).to_lowercase()
6165}
6166
6167fn note_wanted(sharing: &Sharing, memory: &Memory, missing: &BTreeMap<String, (String, u64)>, there: &BTreeSet<String>, now_ms: i64) {
6175 if missing.is_empty() && !crate::blobs::Wanted::path(sharing.memory).exists() {
6176 return;
6177 }
6178 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();
6179 let _ = crate::blobs::Wanted::change(sharing.memory, |wanted| {
6180 wanted.blobs.retain(|name, _| waited.contains(name) && !there.contains(name));
6181 for (name, (hash, size)) in missing.iter().filter(|(name, _)| waited.contains(*name)) {
6182 wanted.blobs.entry(name.clone()).or_insert_with(|| crate::blobs::Want { hash: hash.clone(), size: *size, since: now_ms / 1000, ..Default::default() });
6183 }
6184 });
6185}
6186
6187enum Received {
6189 AlreadySo,
6191 Written { stat: Option<Stat>, copy: Option<String>, kept: bool },
6195 Later,
6198}
6199
6200struct Receiving<'a> {
6202 sharing: &'a Sharing<'a>,
6203 history: &'a Path,
6204 now_ms: i64,
6205 clock: i64,
6207 copied: std::cell::Cell<u64>,
6209}
6210
6211pub(crate) const TAKE_OUT_COPIES: u64 = 64 << 20;
6219
6220#[cfg(test)]
6221thread_local! {
6222 pub(crate) static TAKE_OUT_BUDGET: std::cell::Cell<u64> = const { std::cell::Cell::new(TAKE_OUT_COPIES) };
6224}
6225
6226fn take_out_copies() -> u64 {
6227 #[cfg(test)]
6228 return TAKE_OUT_BUDGET.with(std::cell::Cell::get);
6229 #[cfg(not(test))]
6230 TAKE_OUT_COPIES
6231}
6232
6233#[cfg(test)]
6234thread_local! {
6235 static BEFORE_WRITING: std::cell::RefCell<Option<Box<dyn Fn(&Path)>>> = std::cell::RefCell::new(None);
6237}
6238
6239#[allow(clippy::too_many_arguments)]
6249fn 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> {
6250 use crate::blobs::Fault;
6251 let sharing = how.sharing;
6252 let wanted = value.map(serde_json::from_str::<Reference>).transpose().map_err(|_| Fault::Broken)?;
6253 let current = match current {
6255 Some(here) => Some(here.to_string()),
6256 None if path.is_file() => crate::blobs::hash_file(path).ok().map(|(h, _)| h),
6257 None => None,
6258 };
6259 match (&wanted, ¤t) {
6260 (Some(wanted), Some(here)) if wanted.h == *here => return Ok(Received::AlreadySo),
6261 (None, None) => return Ok(Received::AlreadySo),
6262 _ => {}
6263 }
6264 let local = |e: std::io::Error| Fault::Local(format!("{}: {e}", path.display()));
6265 let size_here = std::fs::metadata(path).map_or(0, |m| m.len());
6266 let received = |here: &str| known.is_some_and(|k| k.h == here && k.w != sharing.computer);
6273 let diverged = current.as_deref().is_some_and(|here| !seen(here) && !received(here));
6274 let sealed_there = |h: &str| {
6280 let name = crate::blobs::name(sharing.key, h);
6281 std::iter::once(sharing.folder.to_path_buf()).chain(crate::remote::overlay(sharing.folder)).any(|dir| std::fs::metadata(dir.join("blobs").join(&name)).is_ok_and(|m| m.len() > 0))
6282 };
6283 let by_reference = wanted.is_some() && current.as_deref().is_some_and(|here| (known.is_some_and(|k| k.h == here) || seen(here)) && sealed_there(here));
6289 let room = wanted.as_ref().map_or(0, |w| w.s) + if by_reference || wanted.is_none() { 0 } else { size_here } + if diverged { size_here } else { 0 };
6290 if wanted.as_ref().is_some_and(|w| w.s > crate::blobs::LARGEST) {
6291 return Err(Fault::Broken);
6292 }
6293 if !crate::disk::fits(path, room) {
6294 return Err(Fault::Room);
6295 }
6296 let temporary = temporary(path);
6297 if let Some(wanted) = &wanted {
6298 let blob = crate::blobs::name(sharing.key, &wanted.h);
6301 let copy = std::fs::metadata(crate::blobs::path(sharing.folder, sharing.key, &wanted.h)).ok().map(|m| crate::blobs::fingerprint(&m));
6302 let known_damaged = copy.is_some() && damaged.get(&blob) == copy.as_ref();
6303 if let Some(parent) = path.parent() {
6304 private_dirs(parent).map_err(local)?;
6305 }
6306 match crate::blobs::get_unless(sharing.folder, sharing.key, &wanted.h, &temporary, known_damaged) {
6307 Err(Fault::Broken) => {
6308 damaged.extend(copy.map(|copy| (blob, copy)));
6309 return Err(Fault::Broken);
6310 }
6311 Err(fault) => return Err(fault),
6312 Ok(()) => {
6313 damaged.remove(&blob);
6314 }
6315 }
6316 }
6317 let placed = (|| {
6318 #[cfg(test)]
6319 BEFORE_WRITING.with(|hook| {
6320 if let Some(hook) = hook.borrow().as_ref() {
6321 hook(path);
6322 }
6323 });
6324 let same = match (std::fs::symlink_metadata(path).ok(), gathered) {
6327 (None, None) => true,
6328 (Some(now), Some(then)) => now.len() == then.size && modified_ns(&now) == then.modified,
6329 _ => false,
6330 };
6331 if !same {
6332 return Ok(None);
6333 }
6334 let copy = match ¤t {
6335 Some(here) if diverged => Some(conflict_copy(path, here, known, how.now_ms).map_err(Fault::Local)?),
6336 _ => None,
6337 };
6338 let mut kept = false;
6339 if let Some(here) = current.as_deref()
6340 && path.exists()
6341 {
6342 let kept_now = if by_reference {
6343 crate::history::keep_sealed(how.history, store.part, file, here, size_here, how.now_ms)
6344 } else if wanted.is_none() {
6345 crate::history::keep_moved(how.history, store.part, file, path, how.now_ms).map(|copied| how.copied.set(how.copied.get() + copied))
6346 } else {
6347 crate::history::keep(how.history, store.part, file, path, how.now_ms)
6348 };
6349 kept_now.map_err(|e| if e.starts_with("share-no-room") { Fault::Room } else { Fault::Local(e) })?;
6350 kept = true;
6351 }
6352 let stat = match &wanted {
6353 Some(wanted) => {
6354 if store.name == SPAM_TABLE {
6356 sioul_core::spam::table::keep_previous(path);
6357 }
6358 std::fs::rename(&temporary, path).map_err(local)?;
6359 let _ = set_time(path, std::time::UNIX_EPOCH + std::time::Duration::from_millis(wanted.t.min(how.clock).max(0) as u64));
6361 let now = std::fs::metadata(path).map_err(local)?;
6362 Some(Stat { size: now.len(), modified: modified_ns(&now), entries: vec![(String::new(), wanted.h.clone())], seen: how.clock })
6363 }
6364 None => {
6365 match std::fs::remove_file(path) {
6366 Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(local(e)),
6367 _ => {}
6368 }
6369 let mut dir = path.parent();
6371 while let Some(folder) = dir.filter(|d| *d != store.path && d.starts_with(&store.path)) {
6372 if std::fs::remove_dir(folder).is_err() {
6373 break;
6374 }
6375 dir = folder.parent();
6376 }
6377 None
6378 }
6379 };
6380 Ok(Some((stat, copy, kept)))
6381 })();
6382 if !matches!(placed, Ok(Some(_))) {
6383 let _ = std::fs::remove_file(&temporary);
6384 }
6385 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("/"));
6386 Ok(match placed? {
6387 Some((stat, copy, kept)) => Received::Written { stat, copy: copy.map(in_store), kept },
6388 None => Received::Later,
6389 })
6390}
6391
6392pub fn conflict_name(stem: &str, at_ms: i64) -> String {
6396 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();
6397 format!("{stem} (conflict {when})")
6398}
6399
6400fn conflict_copy(path: &Path, here: &str, known: Option<&Known>, now_ms: i64) -> Result<PathBuf, String> {
6405 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);
6406 let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
6407 let (stem, extension) = match name.rsplit_once('.') {
6408 Some((stem, extension)) if !stem.is_empty() => (stem.to_string(), format!(".{extension}")),
6409 _ => (name.clone(), String::new()),
6410 };
6411 let named = conflict_name(&stem, at);
6412 let mut n = 1;
6413 loop {
6414 let copy = path.with_file_name(if n == 1 { format!("{named}{extension}") } else { format!("{named} {n}{extension}") });
6415 if !copy.exists() {
6416 let temporary = temporary(©);
6417 return copy_private(path, &temporary).and_then(|()| std::fs::rename(&temporary, ©)).map(|()| copy.clone()).map_err(|e| {
6418 let _ = std::fs::remove_file(&temporary);
6419 format!("{}: {e}", copy.display())
6420 });
6421 }
6422 if crate::blobs::hash_file(©).is_ok_and(|(h, _)| h == here) {
6424 return Ok(copy);
6425 }
6426 n += 1;
6427 }
6428}
6429
6430fn writable(store: &Store, path: &Path, sharing: &Sharing) -> bool {
6437 writable_in(store, path, Some(sharing.folder), sharing.memory)
6438}
6439
6440fn writable_in(store: &Store, path: &Path, folder: Option<&Path>, memory: &Path) -> bool {
6441 let Ok(relative) = path.strip_prefix(&store.path) else { return false };
6442 let mut at = store.path.clone();
6443 for part in relative.components() {
6444 at.push(part);
6445 match std::fs::symlink_metadata(&at) {
6446 Ok(meta) if meta.file_type().is_symlink() => return false,
6447 Ok(_) => {}
6448 Err(_) => break,
6449 }
6450 }
6451 let Some(there) = path.ancestors().skip(1).find(|p| p.exists()).and_then(|p| p.canonicalize().ok()) else { return true };
6453 let inside = |root: &Path| root.canonicalize().is_ok_and(|root| there.starts_with(root));
6454 let state = memory.parent().and_then(Path::parent);
6455 let own_state = store.name.starts_with("state/") && state.is_some_and(|state| store.path.starts_with(state));
6458 !folder.is_some_and(inside) && (own_state || !state.is_some_and(inside))
6459}
6460
6461fn kept_by_windows(name: &str) -> bool {
6464 let stem = name.split('.').next().unwrap_or(name).to_ascii_uppercase();
6465 let numbered = (stem.starts_with("COM") || stem.starts_with("LPT")) && stem.len() == 4 && matches!(stem.as_bytes()[3], b'1'..=b'9');
6466 matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") || numbered || name.ends_with('.') || name.ends_with(' ')
6467}
6468
6469fn locate<'a>(stores: &'a [Store], file: &str) -> Option<(&'a Store, PathBuf)> {
6471 if let Some(store) = stores.iter().find(|s| !s.folder && s.name == file) {
6472 return Some((store, store.path.clone()));
6473 }
6474 let store = stores.iter().filter(|s| s.folder && file.starts_with(s.name.as_str())).max_by_key(|s| s.name.len())?;
6475 let relative = &file[store.name.len()..];
6476 if relative.is_empty() || relative.split('/').any(|part| part.is_empty() || part == ".." || part == "." || part.contains(['\\', ':'])) {
6479 return None;
6480 }
6481 if matches!(store.shape, Shape::Files)
6484 && (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))))
6485 {
6486 return None;
6487 }
6488 Some((store, relative.split('/').fold(store.path.clone(), |path, part| path.join(part))))
6489}
6490
6491#[derive(Debug, Default, PartialEq, Eq)]
6493pub struct Back {
6494 pub whole: bool,
6496 pub changed: usize,
6498 pub returning: usize,
6500 pub kept: usize,
6502}
6503
6504pub fn put_back_preview(memory: &Path, stores: &[Store], part: &str, file: &str, stamp: &str) -> Result<Back, String> {
6506 let shaped_stores = shaped(stores, format_of(memory));
6508 let stores = shaped_stores.as_slice();
6509 let (store, path) = locate(stores, file).filter(|(store, _)| store.part == part).ok_or_else(|| format!("{file}: not shared"))?;
6510 let kept = crate::history::version(&crate::history::root(memory), part, file, stamp).ok_or_else(|| format!("{file}: {stamp}: not kept"))?;
6511 let (crate::history::Kept::Copy(version), Shape::Toml(_) | Shape::Lines) = (kept, store.shape) else { return Ok(Back { whole: true, ..Back::default() }) };
6512 let (old, now) = entries_then_and_now(store, &version, &path)?;
6513 Ok(Back {
6514 whole: false,
6515 changed: old.iter().filter(|(entry, value)| now.get(*entry).is_some_and(|v| v != *value)).count(),
6516 returning: old.keys().filter(|entry| !now.contains_key(*entry)).count(),
6517 kept: now.keys().filter(|entry| !old.contains_key(*entry)).count(),
6518 })
6519}
6520
6521fn entries_then_and_now(store: &Store, version: &Path, path: &Path) -> Result<(BTreeMap<String, String>, BTreeMap<String, String>), String> {
6523 let old = std::fs::read(version).ok().and_then(|bytes| entries_of(store, &bytes)).ok_or_else(|| format!("{}: not readable", version.display()))?;
6524 let now = std::fs::read(path).ok().and_then(|bytes| entries_of(store, &bytes)).unwrap_or_default();
6525 Ok((old.into_iter().collect(), now.into_iter().collect()))
6526}
6527
6528pub fn put_back(memory: &Path, vault: Option<(&Path, &[u8; 32])>, stores: &[Store], part: &str, file: &str, stamp: &str, now_ms: i64) -> Result<(), String> {
6538 let shaped_stores = shaped(stores, format_of(memory));
6539 let stores = shaped_stores.as_slice();
6540 let (store, path) = locate(stores, file).filter(|(store, _)| store.part == part).ok_or_else(|| format!("{file}: not shared"))?;
6541 let root = crate::history::root(memory);
6542 let kept = crate::history::version(&root, part, file, stamp).ok_or_else(|| format!("{file}: {stamp}: not kept"))?;
6543 let not_there = || format!("{}: not written there", path.display());
6544 if let (crate::history::Kept::Copy(version), Shape::Toml(_) | Shape::Lines) = (&kept, store.shape) {
6545 let _running = exchange_lock(memory, true)?;
6547 if store.folder && !writable_in(store, &path, vault.map(|(folder, _)| folder), memory) {
6548 return Err(not_there());
6549 }
6550 let (old, now) = entries_then_and_now(store, version, &path)?;
6551 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();
6552 if back.is_empty() {
6553 return Ok(());
6554 }
6555 return write_entries(store, &path, &back, || crate::history::keep(&root, part, file, &path, now_ms)).map(|_| ());
6556 }
6557 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
6558 let bring = |to: &Path| match &kept {
6559 crate::history::Kept::Copy(version) => copy_private(version, to).map_err(fail),
6560 crate::history::Kept::Sealed { hash, .. } => {
6561 let (folder, key) = vault.ok_or_else(|| format!("{file}: kept in the sharing folder, which is not set here"))?;
6562 crate::blobs::get(folder, key, hash, to).map_err(|fault| match fault {
6563 crate::blobs::Fault::Missing => format!("share-missing:{file}"),
6564 crate::blobs::Fault::Broken => format!("share-damaged:{file}"),
6565 crate::blobs::Fault::Room => format!("share-no-room:{file}"),
6566 crate::blobs::Fault::Local(e) => e,
6567 })
6568 }
6569 };
6570 let name = path.file_name().ok_or_else(not_there)?;
6577 let there = path.ancestors().skip(1).find(|dir| dir.is_dir()).filter(|dir| path.parent() == Some(*dir) || dir.starts_with(&store.path)).ok_or_else(not_there)?;
6578 let opened = temporary(&there.join(name));
6579 if let Err(e) = bring(&opened) {
6580 let _ = std::fs::remove_file(&opened);
6581 return Err(e);
6582 }
6583 let _ = set_time(&opened, std::time::SystemTime::now());
6584 let prepare = || {
6587 let prepared = if path.exists() { crate::history::prepare(&root, part, file, &path).map(Some) } else { Ok(None) };
6588 after_prepare();
6589 prepared
6590 };
6591 let mut prepared = match prepare() {
6592 Ok(prepared) => prepared,
6593 Err(e) => {
6594 let _ = std::fs::remove_file(&opened);
6595 return Err(e);
6596 }
6597 };
6598 let mut again = 0;
6602 let placed = loop {
6603 let changed = |prepared: &Option<crate::history::Prepared>| prepared.as_ref().and_then(crate::history::Prepared::changed);
6604 let looked = (|| {
6605 let running = exchange_lock(memory, true)?;
6606 if again < PREPARE_AGAIN && changed(&prepared).is_some() {
6607 return Ok(None);
6608 }
6609 Ok(Some(running))
6610 })();
6611 let running = match looked {
6612 Ok(Some(running)) => running,
6613 Ok(None) => {
6614 again += 1;
6615 prepared = match prepare() {
6616 Ok(prepared) => prepared,
6617 Err(e) => break Err(e),
6618 };
6619 continue;
6620 }
6621 Err(e) => break Err(e),
6622 };
6623 break (|| {
6624 let _running = running;
6625 if store.folder && !writable_in(store, &path, vault.map(|(folder, _)| folder), memory) {
6627 return Err(not_there());
6628 }
6629 let parent = path.parent().ok_or_else(not_there)?;
6630 private_dirs(parent).map_err(fail)?;
6631 let beside = if opened.parent() == Some(parent) {
6633 opened.clone()
6634 } else {
6635 let beside = temporary(&path);
6636 if std::fs::rename(&opened, &beside).is_err() {
6637 let _ = std::fs::remove_file(&opened);
6638 if let Err(e) = bring(&beside) {
6639 let _ = std::fs::remove_file(&beside);
6640 return Err(e);
6641 }
6642 }
6643 beside
6644 };
6645 let mut put = || {
6646 let why = match &prepared {
6648 Some(prepared) => prepared.changed().unwrap_or_else(|| "its copy could not be renamed".into()),
6649 None => "it came meanwhile".into(),
6650 };
6651 let kept_ahead = prepared.take().is_some_and(|prepared| prepared.keep(now_ms));
6652 if path.exists() && !kept_ahead {
6653 crate::history::copied_under_lock(&why);
6654 crate::history::keep(&root, part, file, &path, now_ms)?;
6655 }
6656 let _ = set_time(&beside, std::time::SystemTime::now());
6658 std::fs::rename(&beside, &path).map_err(fail)
6659 };
6660 let done = if matches!(store.shape, Shape::Files) && !sioul_core::filelock::has_lock(&path) { put() } else { sioul_core::filelock::with_lock(&path, put) };
6662 if done.is_err() {
6663 let _ = std::fs::remove_file(&beside);
6664 }
6665 done
6666 })();
6667 };
6668 let _ = std::fs::remove_file(&opened);
6669 placed
6670}
6671
6672const PREPARE_AGAIN: usize = 3;
6675
6676fn base_of(memory: &Memory, key: &str) -> String {
6678 if key.starts_with(FILES) || key.starts_with(DRAFTS) { memory.entries.get(key).map(|k| k.h.clone()).unwrap_or_default() } else { String::new() }
6679}
6680
6681fn met_draft(memory: &mut Memory, key: &str, versions: [&str; 2]) {
6683 if key.starts_with(DRAFTS) {
6684 memory.drafts.entry(key.to_string()).or_default().extend(versions.iter().filter(|v| !v.is_empty()).map(|v| (*v).to_string()));
6685 }
6686}
6687
6688fn seal_apart(sharing: &Sharing, stores: &[Store], key: &str, value: &str, sealed: &mut Sealed, problems: &mut Vec<String>, now_ms: i64) -> bool {
6692 if !key.starts_with(FILES) {
6693 return true;
6694 }
6695 let (Some((_, path)), Ok(reference)) = (locate(stores, file_of(key)), serde_json::from_str::<Reference>(value)) else { return false };
6696 match crate::blobs::put(sharing.folder, sharing.key, &path, &reference.h) {
6697 Ok(written) => {
6698 let name = crate::blobs::name(sharing.key, &reference.h);
6699 if let Some(size) = written {
6700 sealed.mine.insert(name.clone(), (now_ms, size));
6701 } else if keeps_itself(sharing) && !sealed.mine.contains_key(&name) {
6702 let size = std::fs::metadata(crate::blobs::path(sharing.folder, sharing.key, &reference.h)).map_or(0, |m| m.len());
6710 sealed.mine.insert(name.clone(), (now_ms, size));
6711 }
6712 if file_of(key) == SPAM_TABLE && sealed.mine.contains_key(&name) {
6714 sealed.tables.retain(|t| *t != name);
6715 sealed.tables.push(name);
6716 }
6717 true
6718 }
6719 Err(e) => {
6720 problems.push(e);
6721 false
6722 }
6723 }
6724}
6725
6726fn keeps_itself(sharing: &Sharing) -> bool {
6729 crate::remote::mirror_of(sharing.memory) == sharing.folder
6730}
6731
6732fn append(sharing: &Sharing, memory: &mut Memory, changes: &[(String, Option<String>, u64, String)]) -> Result<(), String> {
6733 if changes.is_empty() {
6734 return Ok(());
6735 }
6736 let path = round_file(sharing.folder, sharing.computer, memory.round);
6737 let mut text = String::new();
6738 for (at, (key, value, clock, base)) in changes.iter().enumerate() {
6739 let turn = if at == 0 && memory.seq == 0 {
6743 if memory.continues > 0 {
6744 Turn { p: memory.continues, full: false }
6745 } else if memory.full_round == memory.round || memory.round <= 1 {
6746 Turn { p: 0, full: true }
6747 } else {
6748 Turn::default()
6749 }
6750 } else {
6751 Turn::default()
6752 };
6753 memory.seq += 1;
6754 let build = if at == 0 { sioul_core::build::DESCRIBED.to_string() } else { String::new() };
6755 let f = if memory.format >= 2 { known_part(file_of(key)).map_or(1, part_format) } else { 1 };
6757 let change = Change { k: key.clone(), v: value.clone(), b: base.clone(), build, f };
6758 let plain = serde_json::to_vec(&Sealing { change: &change, turn }).map_err(|e| e.to_string())?;
6759 let line = Line { n: memory.seq, c: *clock, s: seal(sharing.key, &bound(sharing.computer, memory.round, memory.seq, *clock), &plain) };
6760 text.push_str(&serde_json::to_string(&line).map_err(|e| e.to_string())?);
6761 text.push('\n');
6762 }
6763 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
6764 write_synced(&sharing.memory.with_file_name("appended.toml"), format!("computer = \"{}\"\nround = {}\nn = {}\n", sharing.computer, memory.round, memory.seq).as_bytes())?;
6769 if let Some(whole) = unfinished(&path) {
6772 let file = std::fs::OpenOptions::new().write(true).open(&path).map_err(fail)?;
6773 file.set_len(whole).map_err(fail)?;
6774 }
6775 let mut options = std::fs::OpenOptions::new();
6776 options.create(true).append(true);
6777 #[cfg(unix)]
6778 std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
6779 let mut file = options.open(&path).map_err(fail)?;
6780 make_private(&path);
6782 file.write_all(text.as_bytes()).map_err(fail)?;
6783 file.sync_all().map_err(fail)
6784}
6785
6786fn appended(memory: &Path, computer: &str) -> Option<(u32, u64)> {
6792 #[derive(Deserialize)]
6793 struct Appended {
6794 computer: String,
6795 round: u32,
6796 n: u64,
6797 }
6798 let text = read_small(&memory.with_file_name("appended.toml"))?;
6799 toml::from_str::<Appended>(&text).ok().filter(|a| a.computer == computer).map(|a| (a.round, a.n))
6800}
6801
6802fn unfinished(path: &Path) -> Option<u64> {
6806 use std::io::{Read, Seek, SeekFrom};
6807 let mut file = std::fs::File::open(path).ok()?;
6808 let size = file.metadata().ok()?.len();
6809 let mut last = [0u8];
6810 file.seek(SeekFrom::Start(size.checked_sub(1)?)).ok()?;
6811 file.read_exact(&mut last).ok()?;
6812 if last[0] == b'\n' {
6813 return None;
6814 }
6815 let mut end = size;
6816 while end > 0 {
6817 let from = end.saturating_sub(64 << 10);
6818 let mut piece = vec![0u8; (end - from) as usize];
6819 file.seek(SeekFrom::Start(from)).ok()?;
6820 file.read_exact(&mut piece).ok()?;
6821 if let Some(at) = piece.iter().rposition(|b| *b == b'\n') {
6822 return Some(from + at as u64 + 1);
6823 }
6824 end = from;
6825 }
6826 Some(0)
6827}
6828
6829fn last_own(folder: &Path, computer: &str) -> Option<(u32, u64, u64)> {
6831 let round = *rounds(folder).get(computer)?.last()?;
6832 let (n, size) = last_of(&round_file(folder, computer, round))?;
6833 Some((round, n, size))
6834}
6835
6836fn last_line(folder: &Path, computer: &str, round: u32) -> Option<u64> {
6838 last_of(&round_file(folder, computer, round)).map(|(n, _)| n)
6839}
6840
6841fn last_of(path: &Path) -> Option<(u64, u64)> {
6843 use std::io::{Read, Seek};
6844 let mut file = std::fs::File::open(path).ok()?;
6845 let size = file.metadata().ok()?.len();
6846 let from = size.saturating_sub(64 * 1024);
6848 let mut tail = Vec::new();
6849 file.seek(std::io::SeekFrom::Start(from)).ok()?;
6850 file.read_to_end(&mut tail).ok()?;
6851 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);
6852 Some((n, size))
6853}
6854
6855fn restatement(sharing: &Sharing, memory: &Memory, stores: &[Store], found: &Found) -> (Vec<(String, Option<String>, u64, String)>, usize) {
6864 let mut omitted = 0usize;
6865 let ours = memory
6866 .entries
6867 .iter()
6868 .filter(|(_, known)| known.w == sharing.computer)
6869 .filter_map(|(key, known)| {
6870 if known.h.is_empty() {
6871 return (!found.hashes.contains_key(key)).then(|| (key.clone(), None, known.c, known.b.clone()));
6872 }
6873 let (store, _) = locate(stores, file_of(key))?;
6874 let value = match found.values.get(key) {
6876 Some(value) => Some(value.clone()),
6877 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))),
6878 None => value_of(stores, key),
6879 };
6880 match value {
6881 Some(value) if value_hash(key, &value) == known.h => Some((key.clone(), Some(value), known.c, known.b.clone())),
6882 _ => {
6883 if reads_now(sharing, store) {
6884 omitted += 1;
6885 }
6886 None
6887 }
6888 }
6889 })
6890 .collect();
6891 (ours, omitted)
6892}
6893
6894fn reads_now(sharing: &Sharing, store: &Store) -> bool {
6897 sharing.files || !matches!(store.shape, Shape::Files) || !store.folder
6898}
6899
6900fn new_round(sharing: &Sharing, memory: &mut Memory, stores: &[Store], found: &Found, hurried: &dyn Fn() -> bool, now_ms: i64, cut: bool) -> Result<(), String> {
6907 let before = |days: i64| ((now_ms - days * 86_400_000).max(0) as u64) << 16;
6908 let (oldest, aged) = (before(TOMBSTONE_DAYS), before(AGED_TOMBSTONE_DAYS));
6909 memory.entries.retain(|key, known| !known.h.is_empty() || known.c >= if ages_out(key) { aged } else { oldest });
6910 let (ours, omitted) = restatement(sharing, memory, stores, found);
6911 let mut problems = Vec::new();
6912 for (key, value, _, _) in ours.iter().filter(|_| sharing.files && !hurried()) {
6913 if let Some(value) = value {
6914 seal_apart(sharing, stores, key, value, &mut memory.sealed, &mut problems, now_ms);
6915 }
6916 }
6917 let previous = memory.seq;
6924 memory.round += 1;
6925 memory.seq = 0;
6926 memory.grown = 0;
6927 if omitted == 0 {
6928 memory.full_round = memory.round;
6929 memory.continues = 0;
6930 memory.owed = false;
6931 (memory.owed_waited, memory.owed_last) = (0, 0);
6932 } else {
6933 memory.continues = if previous > 0 { previous } else { UNKNOWN_TURN };
6934 memory.owed |= cut;
6935 }
6936 append(sharing, memory, &ours)?;
6937 if omitted == 0 {
6939 memory.full_size = std::fs::metadata(round_file(sharing.folder, sharing.computer, memory.round)).map_or(0, |m| m.len());
6940 }
6941 Ok(())
6942}
6943
6944fn full_round_after(sharing: &Sharing, memory: &mut Memory) -> u64 {
6954 if memory.full_size == 0 {
6955 let full = if memory.full_round == 0 { memory.round } else { memory.full_round };
6956 memory.full_size = std::fs::metadata(round_file(sharing.folder, sharing.computer, full)).map_or(0, |m| m.len());
6957 }
6958 ROUND_SIZE.max(memory.full_size)
6959}
6960
6961pub fn own_sealed(memory: &Path, computer: &str) -> BTreeMap<String, u64> {
6969 let path = sealed_path(memory);
6970 let stamp = stamp_at(&path);
6971 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()))) {
6972 return if whose == computer { mine } else { BTreeMap::new() };
6973 }
6974 #[derive(Deserialize)]
6975 struct Part {
6976 #[serde(default)]
6977 computer: String,
6978 #[serde(default)]
6979 mine: BTreeMap<String, (i64, u64)>,
6980 }
6981 let Some(read) = std::fs::read(&path).ok().and_then(|bytes| serde_json::from_slice::<Part>(&bytes).ok()) else { return BTreeMap::new() };
6982 let mine: BTreeMap<String, u64> = read.mine.into_iter().map(|(name, (_, size))| (name, size)).collect();
6983 if let Ok(mut all) = MINE.lock() {
6984 all.insert(memory.to_path_buf(), (stamp, read.computer.clone(), mine.clone()));
6985 }
6986 if read.computer == computer { mine } else { BTreeMap::new() }
6987}
6988
6989pub fn written(memory: &Path, computer: &str) -> Option<(u32, u64)> {
6992 let memory = Memory::load(memory, computer);
6993 memory.joined.then_some((memory.round.max(1), memory.seq))
6994}
6995
6996#[derive(Debug, Default, Clone)]
6998pub struct Heard {
6999 pub read: BTreeMap<String, (u32, u64)>,
7001 pub broken: BTreeMap<String, i64>,
7003}
7004
7005impl Heard {
7006 pub fn complete(&self, computer: &str, wrote: (u32, u64)) -> bool {
7010 wrote.1 == 0 || self.read.get(computer).is_some_and(|read| *read >= wrote)
7011 }
7012}
7013
7014pub fn traffic(memory: &Path, computer: &str) -> BTreeMap<String, (i64, i64)> {
7016 Memory::load(memory, computer).traffic
7017}
7018
7019pub fn heard(memory: &Path, computer: &str) -> Heard {
7020 let memory = Memory::load(memory, computer);
7021 let mut read = memory.read_n;
7022 for (key, (_, _, writer, f)) in &memory.waiting {
7025 if known_part(file_of(key)) == Some("health") && *f > part_format("health") {
7026 read.remove(writer);
7027 }
7028 }
7029 Heard { read, broken: memory.broken }
7030}
7031
7032pub fn rebuild(memory: &Path, computer: &str, files: &[&str]) -> Result<(), String> {
7042 rebuild_with(memory, computer, files, true, || ())
7043}
7044
7045pub fn rebuild_then<T>(memory: &Path, computer: &str, files: &[&str], then: impl FnOnce() -> T) -> Result<T, String> {
7056 rebuild_with(memory, computer, files, false, then)
7057}
7058
7059fn rebuild_with<T>(memory: &Path, computer: &str, files: &[&str], rejoin: bool, then: impl FnOnce() -> T) -> Result<T, String> {
7060 let _running = exchange_lock(memory, true)?;
7061 let mut state = Memory::load_all(memory, computer);
7062 state.entries.retain(|key, _| !files.contains(&file_of(key)));
7063 state.pending.retain(|key, _| !files.contains(&file_of(key)));
7064 for file in files {
7065 state.files.remove(*file);
7066 state.rebuild.insert((*file).to_string());
7067 if rejoin {
7068 state.rejoin.insert((*file).to_string());
7069 } else {
7070 state.rejoin.remove(*file);
7071 }
7072 }
7073 state.read.clear();
7075 state.save(memory)?;
7076 state.keep(memory);
7077 Ok(then())
7078}
7079
7080#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
7087pub struct Withheld {
7088 pub store: String,
7089 pub count: usize,
7090 pub first: i64,
7091 pub last: i64,
7092 pub leaves: i64,
7093 #[serde(default, skip_serializing_if = "Vec::is_empty")]
7094 pub folders: Vec<String>,
7095}
7096
7097fn withheld_of(store: &str, gone: &BTreeMap<String, i64>) -> Withheld {
7101 let mut sum = Withheld { store: store.to_string(), first: i64::MAX, ..Withheld::default() };
7102 for since in gone.values() {
7103 sum.count += 1;
7104 sum.first = sum.first.min(*since);
7105 sum.last = sum.last.max(*since);
7106 }
7107 sum.first = sum.first.min(sum.last);
7108 sum.leaves = sum.last + GONE_GRACE;
7109 sum.folders = bulk_folders(gone.keys().map(|file| file.strip_prefix(store).unwrap_or(file)));
7110 sum
7111}
7112
7113fn bulk_folders<'a>(files: impl Iterator<Item = &'a str>) -> Vec<String> {
7121 let mut under: BTreeMap<String, usize> = BTreeMap::new();
7122 let mut total = 0;
7123 for file in files {
7124 total += 1;
7125 let dirs: Vec<&str> = file.split('/').collect();
7126 for depth in 0..dirs.len() {
7127 *under.entry(dirs[..depth].join("/")).or_default() += 1;
7128 }
7129 }
7130 let children = |folder: &str| -> Vec<(usize, String)> {
7131 let mut out: Vec<(usize, String)> = under.iter().filter(|(child, _)| !child.is_empty() && (if folder.is_empty() { !child.contains('/') } else { child.strip_prefix(folder).and_then(|rest| rest.strip_prefix('/')).is_some_and(|rest| !rest.contains('/')) })).map(|(child, n)| (*n, child.clone())).collect();
7132 out.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
7133 out
7134 };
7135 let most = |n: usize| n * 5 >= total * 4;
7136 let mut at = String::new();
7137 loop {
7138 match children(&at).first() {
7139 Some((n, child)) if most(*n) => at = child.clone(),
7140 _ => break,
7141 }
7142 }
7143 let few = children(&at);
7144 let mut held = 0;
7145 let mut named = Vec::new();
7146 for (n, child) in few.into_iter().take(3) {
7147 held += n;
7148 named.push(child);
7149 if most(held) {
7150 return named;
7151 }
7152 }
7153 vec![at]
7154}
7155
7156pub fn withheld(memory: &Path, computer: &str) -> Vec<Withheld> {
7161 #[derive(Deserialize)]
7162 struct Part {
7163 #[serde(default)]
7164 computer: String,
7165 #[serde(default)]
7166 withheld: Vec<Withheld>,
7167 }
7168 std::fs::read(memory).ok().and_then(|bytes| serde_json::from_slice::<Part>(&bytes).ok()).filter(|part| part.computer == computer).map(|part| part.withheld).unwrap_or_default()
7169}
7170
7171pub fn put_back_withheld(folder: &Path, key: &[u8; 32], memory: &Path, computer: &str, stores: &[Store], store: &str) -> Result<PutBack, String> {
7202 let (held, count) = {
7204 let _running = exchange_lock(memory, true)?;
7205 let state = Memory::load_all(memory, computer);
7206 let gone: Vec<&String> = state.sealed.gone.keys().filter(|file| file.starts_with(store) && state.sealed.bulk.contains(*file)).collect();
7207 let held: Vec<(String, String, std::time::SystemTime, Option<u64>)> = gone
7208 .iter()
7209 .copied()
7210 .filter_map(|file| {
7211 let known = state.entries.get(&format!("{file}#")).filter(|known| !known.h.is_empty())?;
7212 let when = match state.files.get(file) {
7213 Some(stat) => std::time::UNIX_EPOCH + std::time::Duration::from_nanos(stat.modified),
7214 None => std::time::UNIX_EPOCH + std::time::Duration::from_millis(known.c >> 16),
7215 };
7216 Some((file.clone(), known.h.clone(), when, state.files.get(file).map(|stat| stat.size)))
7217 })
7218 .collect();
7219 (held, gone.len())
7220 };
7221 let mut back = 0;
7222 let mut rest = held.as_slice();
7223 while !rest.is_empty() {
7224 let (started, mut done) = (std::time::Instant::now(), 0);
7227 let mut ready: Vec<(std::path::PathBuf, std::path::PathBuf, &Store, std::time::SystemTime, &String)> = Vec::new();
7228 let mut stop = None;
7229 for (file, h, when, size) in rest {
7230 if done >= PUT_BACK_BATCH || started.elapsed() >= PUT_BACK_HOLD {
7231 break;
7232 }
7233 done += 1;
7234 let Some((store, path)) = locate(stores, file) else { continue };
7235 if std::fs::symlink_metadata(&path).is_ok() || !writable_in(store, &path, Some(folder), memory) {
7236 continue;
7237 }
7238 let Some(name) = path.file_name() else { continue };
7240 let Some(there) = path.ancestors().skip(1).find(|dir| dir.is_dir()).filter(|dir| dir.starts_with(&store.path)) else { continue };
7241 let size = size.unwrap_or_else(|| std::fs::metadata(crate::blobs::path(folder, key, h)).map_or(0, |m| m.len()).saturating_mul(4));
7243 if !crate::disk::fits(there, size) {
7244 stop = Some(format!("share-no-room:{file}"));
7245 break;
7246 }
7247 let opened = temporary(&there.join(name));
7249 if crate::blobs::get(folder, key, h, &opened).is_err() {
7250 let _ = std::fs::remove_file(&opened);
7251 continue;
7252 }
7253 ready.push((opened, path, store, *when, h));
7254 }
7255 rest = &rest[done..];
7256 let placed = in_short_holds(memory, ready.into_iter(), |(opened, path, store, when, h)| {
7263 if place_put_back(folder, key, memory, &opened, &path, store, when, h, &mut stop) {
7264 back += 1;
7265 }
7266 });
7267 if let Err((e, left)) = placed {
7268 for (opened, ..) in left {
7269 let _ = std::fs::remove_file(opened);
7270 }
7271 return Err(e);
7272 }
7273 if let Some(said) = stop {
7274 return Err(said);
7275 }
7276 if !rest.is_empty() {
7277 std::thread::sleep(PUT_BACK_FREE);
7278 }
7279 }
7280 Ok(PutBack { back, held: count })
7281}
7282
7283#[allow(clippy::too_many_arguments)]
7290fn place_put_back(folder: &Path, key: &[u8; 32], memory: &Path, opened: &Path, path: &Path, store: &Store, when: std::time::SystemTime, h: &str, stop: &mut Option<String>) -> bool {
7291 slow_under_lock();
7292 if std::fs::symlink_metadata(path).is_ok() || !writable_in(store, path, Some(folder), memory) {
7293 let _ = std::fs::remove_file(opened);
7294 return false;
7295 }
7296 let Some(parent) = path.parent() else {
7298 let _ = std::fs::remove_file(opened);
7299 return false;
7300 };
7301 if let Err(e) = private_dirs(parent) {
7302 let _ = std::fs::remove_file(opened);
7303 stop.get_or_insert_with(|| format!("{}: {e}", parent.display()));
7304 return false;
7305 }
7306 let beside = if opened.parent() == Some(parent) {
7309 opened.to_path_buf()
7310 } else {
7311 let beside = temporary(path);
7312 if std::fs::rename(opened, &beside).is_err() {
7313 let _ = std::fs::remove_file(opened);
7314 if crate::blobs::get(folder, key, h, &beside).is_err() {
7315 let _ = std::fs::remove_file(&beside);
7316 return false;
7317 }
7318 }
7319 beside
7320 };
7321 let _ = set_time(&beside, when);
7324 place_new(&beside, path)
7325}
7326
7327fn in_short_holds<I: Iterator>(memory: &Path, items: I, mut each: impl FnMut(I::Item)) -> Result<(), (String, std::iter::Peekable<I>)> {
7338 let mut items = items.peekable();
7339 while items.peek().is_some() {
7340 let running = match exchange_lock(memory, true) {
7341 Ok(running) => running,
7342 Err(e) => return Err((e, items)),
7343 };
7344 let held = std::time::Instant::now();
7345 while let Some(item) = items.next() {
7346 each(item);
7347 if held.elapsed() >= SHORT_HOLD || waiting_here(memory) {
7348 break;
7349 }
7350 }
7351 if items.peek().is_some() {
7353 hand_off(memory, running, PUT_BACK_FREE);
7354 } else {
7355 drop(running);
7356 }
7357 }
7358 Ok(())
7359}
7360
7361const REMOVAL_PLANS: u32 = 5;
7364
7365#[cfg(test)]
7366thread_local! {
7367 pub(crate) static AFTER_PLAN: std::cell::RefCell<Option<Box<dyn FnMut()>>> = const { std::cell::RefCell::new(None) };
7370}
7371
7372#[cfg(test)]
7373thread_local! {
7374 pub(crate) static AFTER_PREPARE: std::cell::RefCell<Option<Box<dyn FnMut()>>> = const { std::cell::RefCell::new(None) };
7377}
7378
7379fn after_prepare() {
7381 #[cfg(test)]
7382 AFTER_PREPARE.with(|hook| {
7383 if let Some(hook) = hook.borrow_mut().as_mut() {
7384 hook();
7385 }
7386 });
7387}
7388
7389fn after_plan() {
7391 #[cfg(test)]
7392 AFTER_PLAN.with(|hook| {
7393 if let Some(hook) = hook.borrow_mut().as_mut() {
7394 hook();
7395 }
7396 });
7397}
7398
7399const SHORT_HOLD: std::time::Duration = std::time::Duration::from_millis(200);
7404
7405#[cfg(test)]
7406thread_local! {
7407 pub(crate) static SLOW_UNDER_LOCK: std::cell::Cell<std::time::Duration> = const { std::cell::Cell::new(std::time::Duration::ZERO) };
7411}
7412
7413fn slow_under_lock() {
7415 #[cfg(test)]
7416 std::thread::sleep(SLOW_UNDER_LOCK.with(std::cell::Cell::get));
7417}
7418
7419#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7424pub struct PutBack {
7425 pub back: usize,
7427 pub held: usize,
7429}
7430
7431const PUT_BACK_BATCH: usize = 200;
7439const PUT_BACK_HOLD: std::time::Duration = std::time::Duration::from_millis(250);
7440const PUT_BACK_FREE: std::time::Duration = std::time::Duration::from_millis(25);
7441
7442fn place_new(temporary: &Path, path: &Path) -> bool {
7447 let placed = match std::fs::hard_link(temporary, path) {
7448 Ok(()) => true,
7449 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => false,
7450 Err(_) => std::fs::symlink_metadata(path).is_err() && std::fs::rename(temporary, path).is_ok(),
7451 };
7452 let _ = std::fs::remove_file(temporary);
7453 placed
7454}
7455
7456pub fn confirm_gone(memory: &Path, computer: &str, store: &str) -> Result<(), String> {
7462 let _running = exchange_lock(memory, true)?;
7463 let mut state = Memory::load_all(memory, computer);
7464 state.sealed.confirmed.insert(store.to_string());
7465 state.save(memory)?;
7466 state.keep(memory);
7467 Ok(())
7468}
7469
7470pub fn confirm_withheld(memory: &Path, computer: &str, store: &str) -> Result<usize, String> {
7477 let _running = exchange_lock(memory, true)?;
7478 let mut state = Memory::load_all(memory, computer);
7479 let held: Vec<String> = state.sealed.gone.keys().filter(|file| file.starts_with(store) && state.sealed.bulk.contains(*file)).cloned().collect();
7480 let count = held.len();
7481 state.sealed.confirmed_files.extend(held);
7482 state.save(memory)?;
7483 state.keep(memory);
7484 Ok(count)
7485}
7486
7487pub fn exchange_lock(memory: &Path, wait: bool) -> Result<Option<std::fs::File>, String> {
7492 let path = memory.with_file_name("exchange.lock");
7493 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
7494 if let Some(parent) = path.parent() {
7495 private_dirs(parent).map_err(fail)?;
7496 }
7497 let file = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(&path).map_err(fail)?;
7498 let _waiting = Waiting::start(&path);
7500 if wait {
7501 lock_waiting(&path, &file).map_err(fail)?;
7502 _waiting.taken();
7503 return Ok(Some(file));
7504 }
7505 for _ in 0..50 {
7509 match file.try_lock() {
7510 Ok(()) => {
7511 _waiting.taken();
7512 return Ok(Some(file));
7513 }
7514 Err(std::fs::TryLockError::WouldBlock) => std::thread::sleep(std::time::Duration::from_millis(20)),
7515 Err(std::fs::TryLockError::Error(e)) => return Err(fail(e)),
7516 }
7517 }
7518 Ok(None)
7519}
7520
7521static WAITERS: std::sync::Mutex<BTreeMap<PathBuf, (usize, u64)>> = std::sync::Mutex::new(BTreeMap::new());
7524
7525struct Waiting(PathBuf);
7527
7528impl Waiting {
7529 fn start(path: &Path) -> Waiting {
7530 if let Ok(mut all) = WAITERS.lock() {
7531 all.entry(path.to_path_buf()).or_default().0 += 1;
7532 }
7533 Waiting(path.to_path_buf())
7534 }
7535
7536 fn taken(&self) {
7537 if let Ok(mut all) = WAITERS.lock() {
7538 all.entry(self.0.clone()).or_default().1 += 1;
7539 }
7540 }
7541}
7542
7543impl Drop for Waiting {
7544 fn drop(&mut self) {
7545 if let Ok(mut all) = WAITERS.lock()
7546 && let Some(counts) = all.get_mut(&self.0)
7547 {
7548 counts.0 = counts.0.saturating_sub(1);
7549 }
7550 }
7551}
7552
7553fn waiting_here(memory: &Path) -> bool {
7555 let path = memory.with_file_name("exchange.lock");
7556 WAITERS.lock().ok().and_then(|all| all.get(&path).map(|c| c.0 > 0)).unwrap_or(false)
7557}
7558
7559const HAND_OFF_MOST: std::time::Duration = std::time::Duration::from_secs(1);
7562
7563pub(crate) fn hand_off(memory: &Path, running: Option<std::fs::File>, free: std::time::Duration) {
7578 let path = memory.with_file_name("exchange.lock");
7579 let (waiting, taken) = WAITERS.lock().ok().and_then(|all| all.get(&path).copied()).unwrap_or((0, 0));
7580 drop(running);
7581 #[cfg(test)]
7582 if let Ok(mut released) = RELEASED.lock() {
7583 released.insert(path.clone(), std::time::Instant::now());
7584 }
7585 if waiting == 0 {
7586 std::thread::sleep(free);
7587 return;
7588 }
7589 let since = std::time::Instant::now();
7590 while since.elapsed() < HAND_OFF_MOST && WAITERS.lock().ok().and_then(|all| all.get(&path).copied()).is_some_and(|(now_waiting, now_taken)| now_taken == taken && now_waiting >= waiting) {
7591 std::thread::sleep(std::time::Duration::from_millis(1));
7592 }
7593}
7594
7595#[cfg(test)]
7596thread_local! {
7597 pub(crate) static SLOW_WAKE: std::cell::Cell<std::time::Duration> = const { std::cell::Cell::new(std::time::Duration::ZERO) };
7601}
7602
7603#[cfg(test)]
7606static RELEASED: std::sync::Mutex<BTreeMap<PathBuf, std::time::Instant>> = std::sync::Mutex::new(BTreeMap::new());
7607
7608fn lock_waiting(#[cfg_attr(not(test), allow(unused_variables))] path: &Path, file: &std::fs::File) -> std::io::Result<()> {
7611 #[cfg(test)]
7612 {
7613 let slow = SLOW_WAKE.with(std::cell::Cell::get);
7614 if !slow.is_zero() {
7615 loop {
7616 let since = RELEASED.lock().ok().and_then(|r| r.get(path).copied()).map(|r| r.elapsed());
7618 if let Some(since) = since.filter(|s| *s < slow) {
7619 std::thread::sleep(slow - since);
7620 continue;
7621 }
7622 match file.try_lock() {
7623 Ok(()) => return Ok(()),
7624 Err(std::fs::TryLockError::WouldBlock) => std::thread::sleep(std::time::Duration::from_millis(1)),
7625 Err(std::fs::TryLockError::Error(e)) => return Err(e),
7626 }
7627 }
7628 }
7629 }
7630 file.lock()
7631}
7632
7633fn seen_path(folder: &Path, computer: &str) -> PathBuf {
7634 folder.join(format!("{computer}.toml"))
7635}
7636
7637fn write_seen(sharing: &Sharing, memory: &Memory, all: &BTreeMap<String, Vec<u32>>, now_ms: i64) -> i64 {
7645 let again = if crate::devices::in_use(sharing.memory) { 15 * 60 } else { 24 * 3600 };
7646 let mut seen = Seen {
7647 at: now_ms / 1000,
7648 round: memory.round,
7649 read: memory.read.iter().filter(|(c, _)| all.contains_key(*c)).map(|(c, (round, _))| (c.clone(), *round)).collect(),
7650 full: full_asked(memory, all),
7651 pad: String::new(),
7652 };
7653 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) {
7654 return (old.at + again) * 1000;
7655 }
7656 let path = seen_path(sharing.folder, sharing.computer);
7657 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();
7658 seen.pad = ".".repeat(pad_for(&path, |pad| text(&seen, pad).len() as u64));
7659 if let Ok(text) = toml::to_string(&seen) {
7660 let _ = write_atomically(&path, text.as_bytes());
7661 }
7662 (seen.at + again) * 1000
7663}
7664
7665fn full_asked(memory: &Memory, all: &BTreeMap<String, Vec<u32>>) -> BTreeMap<String, u32> {
7671 memory.met.iter().filter(|(computer, met)| all.contains_key(*computer) && memory.read_n.get(*computer) != Some(*met)).map(|(computer, (round, _))| (computer.clone(), *round)).collect()
7672}
7673
7674fn asked_for_full(sharing: &Sharing, memory: &Memory, now_ms: i64) -> bool {
7678 let last_full = memory.full_round.max(1);
7679 computers(sharing.folder)
7680 .iter()
7681 .filter(|c| *c != sharing.computer)
7682 .filter_map(|c| read_seen(sharing.folder, c))
7683 .filter(|s| now_ms / 1000 - s.at < SILENT_DAYS * 86_400)
7684 .any(|s| s.full.get(sharing.computer).is_some_and(|round| (last_full..=memory.round).contains(round)))
7685}
7686
7687fn read_seen_in(folder: &Path, computer: &str) -> Option<Seen> {
7688 read_small(&seen_path(folder, computer)).and_then(|t| toml::from_str(&t).ok())
7689}
7690
7691fn read_seen(folder: &Path, computer: &str) -> Option<Seen> {
7694 let here = read_seen_in(folder, computer);
7695 match (here, crate::remote::overlay(folder).and_then(|fetched| read_seen_in(&fetched, computer))) {
7696 (Some(here), Some(fetched)) => Some(if fetched.at > here.at { fetched } else { here }),
7697 (here, fetched) => here.or(fetched),
7698 }
7699}
7700
7701fn remove_old_rounds(sharing: &Sharing, memory: &Memory, readers: &BTreeSet<String>) {
7707 let all = rounds(sharing.folder);
7708 let below = past_below(sharing.folder, sharing.computer, memory.round, memory.full_round, readers);
7709 for round in all.get(sharing.computer).into_iter().flatten().filter(|r| **r < below) {
7710 let _ = std::fs::remove_file(round_file(sharing.folder, sharing.computer, *round));
7711 }
7712 if memory.departed.is_empty() {
7713 return;
7714 }
7715 for dir in std::iter::once(sharing.folder.to_path_buf()).chain(crate::remote::overlay(sharing.folder)) {
7716 let theirs = if dir == sharing.folder { all.clone() } else { rounds(&dir) };
7717 for (id, floor) in &memory.departed {
7718 for round in theirs.get(id).into_iter().flatten().filter(|r| **r < *floor) {
7719 let _ = std::fs::remove_file(round_file(&dir, id, *round));
7720 }
7721 }
7722 }
7723}
7724
7725fn past_below(folder: &Path, computer: &str, round: u32, full_round: u32, readers: &BTreeSet<String>) -> u32 {
7734 let read_up_to = readers.iter().filter(|r| *r != computer).map(|r| read_seen(folder, r).and_then(|s| s.read.get(computer).copied()).unwrap_or(0)).min().unwrap_or(round);
7735 let full = if full_round == 0 { round } else { full_round };
7736 full.min(read_up_to)
7737}
7738
7739fn readers_by_notes(folder: &Path, computer: &str, now_ms: i64) -> BTreeSet<String> {
7744 computers(folder).into_iter().filter(|c| c != computer).filter(|c| read_seen(folder, c).is_some_and(|s| now_ms / 1000 - s.at < SILENT_DAYS * 86_400)).collect()
7745}
7746
7747pub fn rounds_past(folder: &Path, memory: &Path, computer: &str, now_ms: i64) -> Option<u32> {
7757 #[derive(Deserialize)]
7758 struct Part {
7759 #[serde(default)]
7760 computer: String,
7761 #[serde(default)]
7762 round: u32,
7763 #[serde(default)]
7764 full_round: u32,
7765 #[serde(default)]
7766 counting: Option<BTreeSet<String>>,
7767 #[serde(default)]
7768 uncounted: BTreeSet<String>,
7769 }
7770 let part = std::fs::read(memory).ok().and_then(|bytes| serde_json::from_slice::<Part>(&bytes).ok())?;
7771 if part.computer != computer || part.round == 0 {
7772 return None;
7773 }
7774 let readers = match part.counting {
7775 Some(counting) => {
7776 let strays: Vec<String> = readers_by_notes(folder, computer, now_ms).into_iter().filter(|id| !counting.contains(id) && !part.uncounted.contains(id)).collect();
7777 counting.into_iter().chain(strays).collect()
7778 }
7779 None => readers_by_notes(folder, computer, now_ms),
7780 };
7781 Some(past_below(folder, computer, part.round, part.full_round, &readers))
7782}
7783
7784fn departed_rounds(sharing: &Sharing, memory: &Memory, counted: &[(String, Counted)], readers: &BTreeSet<String>) -> BTreeMap<String, u32> {
7798 let all = rounds_with(sharing.folder);
7799 let mut out = BTreeMap::new();
7800 for (id, _) in counted.iter().filter(|(_, counted)| counted.departed()) {
7801 let Some(theirs) = all.get(id).filter(|rounds| !rounds.is_empty()) else { continue };
7802 let Some(full) = last_full(sharing, id, theirs) else { continue };
7803 let own = memory.read.get(id).map_or(0, |(round, _)| *round);
7804 let others = readers.iter().filter(|r| *r != id).map(|r| read_seen(sharing.folder, r).and_then(|s| s.read.get(id).copied()).unwrap_or(0));
7805 let floor = std::iter::once(full).chain(std::iter::once(own)).chain(others).min().unwrap_or(0);
7806 out.insert(id.clone(), floor);
7807 }
7808 out
7809}
7810
7811pub fn departed_past(memory: &Path, computer: &str) -> BTreeMap<String, u32> {
7817 #[derive(Deserialize)]
7818 struct Part {
7819 #[serde(default)]
7820 computer: String,
7821 #[serde(default)]
7822 departed: BTreeMap<String, u32>,
7823 }
7824 std::fs::read(memory).ok().and_then(|bytes| serde_json::from_slice::<Part>(&bytes).ok()).filter(|part| part.computer == computer).map(|part| part.departed).unwrap_or_default()
7825}
7826
7827pub fn swept_theirs(memory: &Path, computer: &str) -> BTreeMap<String, i64> {
7833 #[derive(Deserialize)]
7834 struct Part {
7835 #[serde(default)]
7836 computer: String,
7837 #[serde(default)]
7838 taken: BTreeMap<String, i64>,
7839 }
7840 std::fs::read(memory).ok().and_then(|bytes| serde_json::from_slice::<Part>(&bytes).ok()).filter(|part| part.computer == computer).map(|part| part.taken).unwrap_or_default()
7841}
7842
7843#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
7858pub struct RemovedDevice {
7859 pub at: i64,
7860 pub last: u32,
7861 #[serde(default)]
7862 pub last_size: u64,
7863 #[serde(default)]
7864 pub entry: bool,
7865 #[serde(default)]
7866 pub notes: bool,
7867 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
7868 pub small: BTreeMap<String, u64>,
7869 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
7874 pub small_seen: BTreeMap<String, String>,
7875}
7876
7877impl RemovedDevice {
7878 pub fn holds_round(&self, round: u32, size: u64) -> bool {
7882 round < self.last || (round == self.last && size <= self.last_size)
7883 }
7884}
7885
7886fn small_go(counted: &Counted, heard_ms: i64, now_ms: i64) -> (bool, bool) {
7898 let entry = match counted {
7899 Counted::Replaced => true,
7900 Counted::Left => now_ms - heard_ms > SILENT_DAYS * DAY,
7901 _ => false,
7902 };
7903 (entry, matches!(counted, Counted::Left | Counted::Replaced))
7904}
7905
7906fn device_small_files(folder: &Path, id: &str, entry: bool, notes: bool) -> Vec<PathBuf> {
7910 device_small_places(folder, id, entry, notes).into_iter().map(|(path, _)| path).collect()
7911}
7912
7913fn content_hash(path: &Path) -> Option<String> {
7916 use sha2::Digest;
7917 let bytes = std::fs::read(path).ok()?;
7918 Some(sha2::Sha256::digest(&bytes).iter().map(|b| format!("{b:02x}")).collect())
7919}
7920
7921fn device_small_places(folder: &Path, id: &str, entry: bool, notes: bool) -> Vec<(PathBuf, String)> {
7923 let mut out = Vec::new();
7924 for dir in std::iter::once(folder.to_path_buf()).chain(crate::remote::overlay(folder)) {
7925 let mut names = vec![format!("texts/send/{id}.jsonl"), format!("phone-messages/ask/{id}.jsonl")];
7926 if entry {
7927 names.push(format!("devices/{id}.device"));
7928 }
7929 if notes {
7930 names.push(format!("{id}.toml"));
7931 }
7932 for part in std::fs::read_dir(dir.join("leases")).into_iter().flatten().filter_map(Result::ok).filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) {
7933 names.push(format!("leases/{}/{id}.lease", part.file_name().to_string_lossy()));
7934 }
7935 out.extend(names.into_iter().map(|place| (dir.join(&place), place)).filter(|(path, _)| path.is_file()));
7936 }
7937 out
7938}
7939
7940#[derive(Debug, Clone, Default, PartialEq, Eq)]
7943pub struct DeviceFiles {
7944 pub id: String,
7945 pub counts: bool,
7947 pub rounds: usize,
7949 pub round_bytes: u64,
7950 pub small: usize,
7953 pub small_bytes: u64,
7954 pub notes_stay: bool,
7956 pub entry_stays: bool,
7959 pub entry_goes: bool,
7961 pub notes_go: bool,
7962 pub sealed: usize,
7968 pub sealed_bytes: u64,
7969 pub sealed_later: usize,
7970 pub later_days: i64,
7972 pub heard: i64,
7974 pub sealed_known: bool,
7978 pub unread_by: Vec<(String, String, String)>,
7982 pub last_round: u32,
7984 pub last_size: u64,
7985 pub name: String,
7987 pub kind: String,
7988}
7989
7990fn plan_removal(sharing: &Sharing, stores: &[Store], memory: &Memory, device: &str, now_ms: i64) -> Option<(DeviceFiles, Vec<PathBuf>, Vec<String>, Vec<String>)> {
7994 let counts = counted(sharing.folder, sharing.key, sharing.computer, memory, now_ms);
7995 let standing = counts.iter().find(|(id, _)| id == device).map(|(_, counted)| counted.clone())?;
7996 let departed = standing.departed();
7997 let mut files = DeviceFiles { id: device.to_string(), counts: !departed, ..DeviceFiles::default() };
7998 if let Some(entry) = memory.seen.get(device) {
7999 (files.name, files.kind) = (entry.name.clone(), entry.kind.clone());
8000 }
8001 let mut paths = Vec::new();
8003 let mut sizes: BTreeMap<u32, u64> = BTreeMap::new();
8004 for dir in std::iter::once(sharing.folder.to_path_buf()).chain(crate::remote::overlay(sharing.folder)) {
8005 for round in rounds(&dir).get(device).into_iter().flatten() {
8006 let path = round_file(&dir, device, *round);
8007 let size = sizes.entry(*round).or_default();
8008 *size = (*size).max(std::fs::metadata(&path).map_or(0, |m| m.len()));
8009 paths.push(path);
8010 }
8011 }
8012 (files.rounds, files.round_bytes) = (sizes.len(), sizes.values().sum());
8013 let last_round = sizes.keys().max().copied().unwrap_or(0);
8014 (files.last_round, files.last_size) = (last_round, sizes.get(&last_round).copied().unwrap_or(0));
8015 let entry_seen = memory.seen.get(device).cloned().or_else(|| crate::devices::all(sharing.folder, sharing.key).0.into_iter().find(|e| e.id == device));
8017 files.heard = last_heard(entry_seen.as_ref(), memory, device);
8018 let (entry, notes) = if departed { small_go(&standing, files.heard, now_ms) } else { (false, false) };
8019 (files.entry_goes, files.notes_go) = (entry, notes);
8020 files.notes_stay = !notes && seen_path(sharing.folder, device).is_file();
8021 files.entry_stays = !entry && sharing.folder.join("devices").join(format!("{device}.device")).is_file();
8022 for path in device_small_files(sharing.folder, device, entry, notes) {
8023 files.small += usize::from(path.starts_with(sharing.folder));
8024 files.small_bytes += std::fs::metadata(&path).map_or(0, |m| m.len()) * u64::from(path.starts_with(sharing.folder));
8025 paths.push(path);
8026 }
8027 let reading = readers(sharing.folder, &counts, now_ms);
8028 files.unread_by = reading
8029 .iter()
8030 .filter(|r| *r != device)
8031 .filter(|r| read_seen(sharing.folder, r).and_then(|s| s.read.get(device).copied()).unwrap_or(0) < last_round)
8032 .map(|r| {
8033 let entry = memory.seen.get(r);
8034 (r.clone(), entry.map(|e| e.name.clone()).unwrap_or_default(), entry.map(|e| e.kind.clone()).unwrap_or_default())
8035 })
8036 .collect();
8037 let every_part = FILE_PARTS.iter().all(|part| stores.iter().any(|s| s.part == *part && matches!(s.shape, Shape::Files)));
8046 let media = if every_part { texts_media(sharing, stores, memory, &counts) } else { None };
8047 files.sealed_known = media.is_some();
8048 let (mut names, mut later) = (Vec::new(), Vec::new());
8049 if let Some(media) = media {
8050 let keeps = present(sharing.folder, sharing.key, sharing.computer, memory, now_ms).iter().filter(|(id, _, _)| id != device).all(|(_, entry, _)| entry.as_ref().is_some_and(|e| e.keeping >= crate::blobs::KEEPING_TWO_WEEKS));
8052 let unused = if keeps { crate::blobs::UNUSED_DAYS } else { crate::blobs::UNUSED_DAYS_BEFORE } * DAY;
8053 files.later_days = unused / DAY;
8054 let fresh = |path: &Path| std::fs::metadata(path).ok().and_then(|m| m.modified().ok()).and_then(|t| t.elapsed().ok()).is_some_and(|age| (age.as_millis() as i64) < unused);
8055 let theirs: BTreeSet<String> = memory.entries.iter().filter(|(key, known)| key.starts_with(FILES) && !known.h.is_empty() && known.w == device).map(|(_, known)| crate::blobs::name(sharing.key, &known.h)).collect();
8056 let used: BTreeSet<String> = memory
8060 .entries
8061 .iter()
8062 .filter(|(key, known)| key.starts_with(FILES) && !known.h.is_empty() && known.w != device)
8063 .map(|(_, known)| known.h.clone())
8064 .chain(memory.pending.iter().filter(|(key, _)| key.starts_with(FILES)).filter_map(|(key, (value, _, _))| value.as_deref().map(|v| value_hash(key, v))))
8065 .map(|h| crate::blobs::name(sharing.key, &h))
8066 .chain(memory.sealed.theirs.iter().filter(|(_, (writer, at))| writer != device && now_ms - *at < unused).map(|(name, _)| name.clone()))
8067 .chain(memory.sealed.mine.keys().cloned())
8068 .chain(memory.sealed.gone.keys().filter_map(|file| memory.entries.get(&format!("{file}#")).filter(|known| !known.h.is_empty()).map(|known| crate::blobs::name(sharing.key, &known.h))))
8072 .chain(media)
8073 .collect();
8074 for name in theirs.difference(&used) {
8075 let path = sharing.folder.join("blobs").join(name);
8076 let Ok(meta) = std::fs::metadata(&path) else { continue };
8077 if fresh(&path) {
8078 files.sealed_later += 1;
8079 later.push(name.clone());
8080 } else {
8081 files.sealed += 1;
8082 files.sealed_bytes += meta.len();
8083 names.push(name.clone());
8084 }
8085 }
8086 }
8087 Some((files, paths, names, later))
8088}
8089
8090pub fn device_files(folder: &Path, key: &[u8; 32], memory: &Path, computer: &str, stores: &[Store], device: &str, now_ms: i64) -> Option<DeviceFiles> {
8093 let state = Memory::load_all(memory, computer);
8094 let sharing = Sharing { folder, computer, key, memory, files: false, hurry: None };
8095 plan_removal(&sharing, stores, &state, device, now_ms).map(|(files, ..)| files)
8096}
8097
8098pub fn remove_device_files(folder: &Path, key: &[u8; 32], memory: &Path, computer: &str, stores: &[Store], device: &str, now_ms: i64) -> Result<DeviceFiles, String> {
8110 let sharing = Sharing { folder, computer, key, memory, files: false, hurry: None };
8111 let small_seen = || -> BTreeMap<String, String> { device_small_places(folder, device, true, true).into_iter().filter_map(|(path, place)| content_hash(&path).map(|h| (place, h))).collect() };
8117 let mut tries = 0;
8122 let (files, going, state) = loop {
8123 let read_from = (stamp_at(memory), stamp_at(&sealed_path(memory)));
8124 let mut state = Memory::load_all(memory, computer);
8125 let planned = plan_removal(&sharing, stores, &state, device, now_ms);
8126 let small_seen = small_seen();
8127 after_plan();
8128 let Some((files, paths, names, later)) = planned else { return Err("share-device-unknown".into()) };
8129 if files.counts {
8130 return Err("share-device-counts".into());
8131 }
8132 let mut going = paths;
8133 for name in names {
8134 going.push(folder.join("blobs").join(&name));
8135 state.sealed.theirs.remove(&name);
8136 state.taken.insert(name, now_ms);
8137 }
8138 for name in later {
8140 state.sealed.theirs.insert(name, (device.to_string(), now_ms));
8141 }
8142 state.removed_devices.insert(device.to_string(), RemovedDevice { at: now_ms, last: files.last_round, last_size: files.last_size, entry: files.entry_goes, notes: files.notes_go, small: BTreeMap::new(), small_seen });
8143 let floor = state.departed.entry(device.to_string()).or_default();
8144 *floor = (*floor).max(files.last_round);
8145 let prepared = state.prepare_save(memory)?;
8146 let running = exchange_lock(memory, true)?;
8147 if (stamp_at(memory), stamp_at(&sealed_path(memory))) == read_from {
8148 prepared.commit()?;
8149 drop(running);
8150 break (files, going, state);
8151 }
8152 drop(prepared);
8153 tries += 1;
8154 if tries >= REMOVAL_PLANS {
8155 return Err("share-busy".into());
8156 }
8157 hand_off(memory, running, PUT_BACK_FREE);
8158 };
8159 state.keep(memory);
8162 in_short_holds(memory, going.into_iter(), |path| {
8169 slow_under_lock();
8170 let _ = std::fs::remove_file(path);
8171 })
8172 .map_err(|(e, _)| e)?;
8173 Ok(files)
8174}
8175
8176pub fn needed_sealed(memory: &Path, computer: &str) -> Option<BTreeSet<String>> {
8183 #[derive(Deserialize)]
8184 struct Part {
8185 #[serde(default)]
8186 computer: String,
8187 #[serde(default)]
8188 needed: Option<BTreeSet<String>>,
8189 }
8190 std::fs::read(sealed_path(memory)).ok().and_then(|bytes| serde_json::from_slice::<Part>(&bytes).ok()).filter(|part| part.computer == computer).and_then(|part| part.needed)
8191}
8192
8193pub fn removed_devices(memory: &Path, computer: &str) -> BTreeMap<String, RemovedDevice> {
8198 #[derive(Deserialize)]
8199 struct Part {
8200 #[serde(default)]
8201 computer: String,
8202 #[serde(default)]
8203 removed_devices: BTreeMap<String, RemovedDevice>,
8204 }
8205 std::fs::read(memory).ok().and_then(|bytes| serde_json::from_slice::<Part>(&bytes).ok()).filter(|part| part.computer == computer).map(|part| part.removed_devices).unwrap_or_default()
8206}
8207
8208#[derive(Debug, Clone, Default, PartialEq, Eq)]
8213pub struct GoneDevice {
8214 pub id: String,
8215 pub why: &'static str,
8216 pub bytes: u64,
8217 pub name: String,
8218 pub kind: String,
8219}
8220
8221pub fn departed_devices(folder: &Path, key: &[u8; 32], memory: &Path, computer: &str, now_ms: i64) -> Vec<GoneDevice> {
8225 let state = Memory::load(memory, computer);
8226 let all = rounds_with(folder);
8227 counted(folder, key, computer, &state, now_ms)
8228 .into_iter()
8229 .filter_map(|(id, counted)| {
8230 let why = match counted {
8231 Counted::Left => "left",
8232 Counted::Forgotten => "forgotten",
8233 Counted::Replaced => "replaced",
8234 _ => return None,
8235 };
8236 let bytes: u64 = all.get(&id).into_iter().flatten().map(|round| std::fs::metadata(round_file(folder, &id, *round)).map_or(0, |m| m.len())).sum();
8237 let entry = state.seen.get(&id);
8238 let (entry_goes, notes_go) = small_go(&counted, last_heard(entry, &state, &id), now_ms);
8239 let small = device_small_files(folder, &id, entry_goes, notes_go);
8240 let bytes = bytes + small.iter().filter(|path| path.starts_with(folder)).map(|path| std::fs::metadata(path).map_or(0, |m| m.len())).sum::<u64>();
8241 (bytes > 0 || !small.is_empty()).then(|| GoneDevice { why, bytes, name: entry.map(|e| e.name.clone()).unwrap_or_default(), kind: entry.map(|e| e.kind.clone()).unwrap_or_default(), id })
8242 })
8243 .collect()
8244}
8245
8246pub fn counting(folder: &Path, key: &[u8; 32], memory: &Path, computer: &str, now_ms: i64) -> BTreeSet<String> {
8251 let state = Memory::load(memory, computer);
8252 present(folder, key, computer, &state, now_ms).into_iter().map(|(id, _, _)| id).collect()
8253}
8254
8255pub fn asking(folder: &Path, key: &[u8; 32], memory: &Path, computer: &str, now_ms: i64) -> (BTreeSet<String>, BTreeSet<String>) {
8263 let state = Memory::load(memory, computer);
8264 let (mut counts, mut gone) = (BTreeSet::new(), BTreeSet::new());
8265 for (id, standing) in counted(folder, key, computer, &state, now_ms) {
8266 match standing {
8267 Counted::Yes(..) => {
8268 counts.insert(id);
8269 }
8270 standing if standing.departed() => {
8271 gone.insert(id);
8272 }
8273 _ => {}
8274 }
8275 }
8276 (counts, gone)
8277}
8278
8279#[derive(Debug, Clone, PartialEq, Eq)]
8281pub struct Other {
8282 pub id: String,
8283 pub heard: i64,
8284}
8285
8286pub fn others(folder: &Path, computer: &str) -> Vec<Other> {
8288 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()
8289}
8290
8291pub fn others_heard(folder: &Path, key: &[u8; 32], computer: &str) -> Vec<Other> {
8299 let (entries, _) = crate::devices::all(folder, key);
8300 let all = rounds_with(folder);
8301 let mut out = others(folder, computer);
8302 for other in &mut out {
8303 if let Some(entry) = entries.iter().find(|e| e.id == other.id) {
8304 other.heard = other.heard.max(entry_stamp(entry));
8305 }
8306 if let Some(clock) = all.get(&other.id).and_then(|rounds| rounds.last()).and_then(|round| last_record_clock(folder, key, &other.id, *round)) {
8307 other.heard = other.heard.max((clock >> 16) as i64 / 1000);
8308 }
8309 }
8310 out
8311}
8312
8313pub fn sealed(folder: &Path) -> bool {
8315 folder.join("seal.toml").is_file()
8316}
8317
8318#[cfg(test)]
8319mod tests {
8320 use super::*;
8321
8322 thread_local! {
8323 pub(super) static OLDER: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
8326 }
8327
8328 pub(super) struct Computer {
8330 pub(super) roots: Roots,
8331 pub(super) id: String,
8332 pub(super) memory: PathBuf,
8333 }
8334
8335 impl Computer {
8336 pub(super) fn new(base: &Path, name: &str) -> Computer {
8337 let root = base.join(name);
8338 let roots = Roots { config: root.join("config"), data: root.join("data"), state: root.join("state") };
8339 for dir in [&roots.config, &roots.data, &roots.state] {
8340 std::fs::create_dir_all(dir).unwrap();
8341 }
8342 let memory = roots.state.join("share").join("memory.json");
8343 Computer { roots, id: uuid::Uuid::new_v4().to_string(), memory }
8344 }
8345
8346 pub(super) fn exchange(&self, folder: &Path, key: &[u8; 32], now: i64) -> Outcome {
8347 let stores = stores(&Config::default(), &self.roots);
8348 exchange(&Sharing { folder, computer: &self.id, key, memory: &self.memory, files: true, hurry: None }, &stores, now).unwrap()
8349 }
8350
8351 pub(super) fn exchange_notes(&self, folder: &Path, key: &[u8; 32], now: i64, notes: &Path) -> Outcome {
8353 let config = Config { share_projects: true, notes_root: Some(notes.display().to_string()), ..Config::default() };
8354 let stores = stores(&config, &self.roots);
8355 exchange(&Sharing { folder, computer: &self.id, key, memory: &self.memory, files: true, hurry: None }, &stores, now).unwrap()
8356 }
8357
8358 fn exchange_with(&self, folder: &Path, key: &[u8; 32], now: i64, notes: &Path, on: &[&str]) -> Outcome {
8360 let stores = self.stores_with(notes, &|part| on.contains(&part));
8361 exchange(&Sharing { folder, computer: &self.id, key, memory: &self.memory, files: true, hurry: None }, &stores, now).unwrap()
8362 }
8363
8364 fn stores_with(&self, notes: &Path, on: &dyn Fn(&str) -> bool) -> Vec<Store> {
8365 let config = Config { notes_root: Some(notes.display().to_string()), ..Config::default() };
8366 stores_of(&config, &self.roots, &|part| on(part) || shared_by_default(part, &config))
8367 }
8368
8369 pub(super) fn write(&self, relative: &str, text: &str) {
8370 let path = self.path(relative);
8371 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
8372 std::fs::write(path, text).unwrap();
8373 }
8374
8375 pub(super) fn read(&self, relative: &str) -> String {
8376 std::fs::read_to_string(self.path(relative)).unwrap_or_default()
8377 }
8378
8379 pub(super) fn path(&self, relative: &str) -> PathBuf {
8380 let (root, rest) = relative.split_once('/').unwrap();
8381 match root {
8382 "config" => self.roots.config.join(rest),
8383 "data" => self.roots.data.join(rest),
8384 _ => self.roots.state.join(rest),
8385 }
8386 }
8387 }
8388
8389 pub(super) fn scratch(name: &str) -> PathBuf {
8390 let dir = std::env::temp_dir().join(format!("sioul-share-{name}-{}", std::process::id()));
8391 let _ = std::fs::remove_dir_all(&dir);
8392 std::fs::create_dir_all(&dir).unwrap();
8393 dir
8394 }
8395
8396 pub(super) fn quick_key(folder: &Path, passphrase: &str) -> Result<[u8; 32], Refused> {
8397 key_with(folder, passphrase, 64, 1)
8398 }
8399
8400 #[test]
8406 fn a_file_is_dated_without_a_handle_that_writes_it() {
8407 let base = scratch("set-time");
8408 let path = base.join("note.md");
8409 std::fs::write(&path, b"# Note\n").unwrap();
8410 let when = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_789_990_000);
8411 set_time(&path, when).unwrap();
8412 assert_eq!(std::fs::metadata(&path).unwrap().modified().unwrap(), when);
8413 assert_eq!(std::fs::read(&path).unwrap(), b"# Note\n");
8414 #[cfg(unix)]
8415 {
8416 use std::os::unix::fs::PermissionsExt;
8417 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400)).unwrap();
8418 let later = when + std::time::Duration::from_secs(60);
8419 set_time(&path, later).unwrap();
8420 assert_eq!(std::fs::metadata(&path).unwrap().modified().unwrap(), later, "read-only: dated all the same");
8421 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
8422 }
8423 assert!(set_time(&base.join("gone.md"), when).is_err());
8424 let _ = std::fs::remove_dir_all(&base);
8425 }
8426
8427 fn put(dir: &Path, relative: &str, bytes: &[u8]) {
8428 let path = dir.join(relative);
8429 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
8430 std::fs::write(path, bytes).unwrap();
8431 }
8432
8433 fn noise(length: usize, seed: u32) -> Vec<u8> {
8435 let mut x = seed.wrapping_mul(2_654_435_761).wrapping_add(1);
8436 (0..length)
8437 .map(|_| {
8438 x ^= x << 13;
8439 x ^= x >> 17;
8440 x ^= x << 5;
8441 x as u8
8442 })
8443 .collect()
8444 }
8445
8446 fn files_in(dir: &Path) -> BTreeMap<String, Vec<u8>> {
8448 let mut found = Vec::new();
8449 list_files(dir, dir, &[], &mut found);
8450 found.into_iter().map(|(name, path)| (name, std::fs::read(path).unwrap())).collect()
8451 }
8452
8453 fn version_text(history: &Path, part: &str, file: &str, stamp: &str, folder: &Path, key: &[u8; 32]) -> String {
8455 match crate::history::version(history, part, file, stamp) {
8456 Some(crate::history::Kept::Copy(path)) => std::fs::read_to_string(path).unwrap(),
8457 Some(crate::history::Kept::Sealed { hash, .. }) => {
8458 let target = temporary(&history.join("opened"));
8459 crate::blobs::get(folder, key, &hash, &target).unwrap();
8460 let text = std::fs::read_to_string(&target).unwrap();
8461 std::fs::remove_file(target).unwrap();
8462 text
8463 }
8464 None => panic!("{file}: {stamp}: not kept"),
8465 }
8466 }
8467
8468 fn copies_of(dir: &Path, stem: &str) -> Vec<Vec<u8>> {
8470 files_in(dir).into_iter().filter(|(name, _)| name.starts_with(&format!("{stem} (")) && name.ends_with(").md") && !made_by_tools(name)).map(|(_, bytes)| bytes).collect()
8471 }
8472
8473 pub(super) const MINUTE: i64 = 60_000;
8474 pub(super) const NOW: i64 = 1_790_000_000_000;
8475
8476 #[derive(Default)]
8483 struct Edrive {
8484 known: BTreeMap<String, (String, String)>,
8486 }
8487
8488 impl Edrive {
8489 fn files(root: &Path) -> BTreeMap<String, Vec<u8>> {
8490 let mut found = Vec::new();
8491 list_files(root, root, &[], &mut found);
8492 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()
8493 }
8494
8495 fn carry(&mut self, server: &Path, phone: &Path) {
8496 let digest = |bytes: &[u8]| hash(&String::from_utf8_lossy(bytes));
8497 let (up, down) = (Self::files(phone), Self::files(server));
8498 for (name, bytes) in &up {
8499 let changed_here = self.known.get(name).is_none_or(|(_, local)| *local != digest(bytes));
8500 if changed_here {
8501 let to = server.join(name);
8502 std::fs::create_dir_all(to.parent().unwrap()).unwrap();
8503 std::fs::write(&to, bytes).unwrap();
8504 self.known.insert(name.clone(), (digest(bytes), digest(bytes)));
8505 }
8506 }
8507 for (name, bytes) in &down {
8508 let local = phone.join(name);
8509 match (self.known.get(name), std::fs::read(&local)) {
8510 (Some((remote, _)), _) if *remote == digest(bytes) => {}
8511 (Some(_), Ok(here)) if here.len() == bytes.len() => {
8513 self.known.insert(name.clone(), (digest(bytes), digest(&here)));
8514 }
8515 _ => {
8516 std::fs::create_dir_all(local.parent().unwrap()).unwrap();
8517 std::fs::write(&local, bytes).unwrap();
8518 self.known.insert(name.clone(), (digest(bytes), digest(bytes)));
8519 }
8520 }
8521 }
8522 }
8523 }
8524
8525 #[derive(Clone, Copy, Debug, PartialEq)]
8529 enum Carrying {
8530 Exact,
8532 SizeOnly,
8534 Late,
8536 StaleCopies,
8538 Placeholders,
8540 }
8541
8542 struct Carrier {
8543 how: Carrying,
8544 turn: u64,
8545 known: BTreeMap<String, (String, String)>,
8547 history: BTreeMap<String, Vec<Vec<u8>>>,
8549 }
8550
8551 impl Carrier {
8552 fn new(how: Carrying) -> Carrier {
8553 Carrier { how, turn: 0, known: BTreeMap::new(), history: BTreeMap::new() }
8554 }
8555
8556 fn files(root: &Path) -> BTreeMap<String, Vec<u8>> {
8557 let mut found = Vec::new();
8558 list_files(root, root, &[], &mut found);
8559 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()
8560 }
8561
8562 fn write(path: &Path, bytes: &[u8]) {
8563 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
8564 std::fs::write(path, bytes).unwrap();
8565 }
8566
8567 fn carry(&mut self, server: &Path, phone: &Path) {
8568 self.turn += 1;
8569 let digest = |bytes: &[u8]| hash(&String::from_utf8_lossy(bytes));
8570 let (up, down) = (Self::files(phone), Self::files(server));
8571 for (name, bytes) in &up {
8572 let changed_here = self.known.get(name).is_none_or(|(_, local)| *local != digest(bytes));
8573 if changed_here && !(self.how == Carrying::Placeholders && bytes.is_empty() && down.contains_key(name)) {
8575 Self::write(&server.join(name), bytes);
8576 self.known.insert(name.clone(), (digest(bytes), digest(bytes)));
8577 }
8578 }
8579 for (i, (name, bytes)) in down.iter().enumerate() {
8580 self.history.entry(name.clone()).or_default().push(bytes.clone());
8581 if self.how == Carrying::Late && (i as u64 + self.turn) % 2 == 0 {
8582 continue;
8583 }
8584 let local = phone.join(name);
8585 let here = std::fs::read(&local).ok();
8586 match (self.known.get(name), here) {
8587 (Some((remote, _)), _) if *remote == digest(bytes) => {}
8588 (Some(_), Some(here)) if self.how == Carrying::SizeOnly && here.len() == bytes.len() => {
8589 self.known.insert(name.clone(), (digest(bytes), digest(&here)));
8590 }
8591 (None, None) if self.how == Carrying::Placeholders => {
8592 Self::write(&local, b"");
8593 self.known.insert(name.clone(), (String::new(), digest(b"")));
8594 }
8595 _ => {
8596 Self::write(&local, bytes);
8597 self.known.insert(name.clone(), (digest(bytes), digest(bytes)));
8598 }
8599 }
8600 }
8601 if self.how == Carrying::Exact {
8602 for name in up.keys().filter(|n| !down.contains_key(*n) && self.known.contains_key(*n)) {
8603 let _ = std::fs::remove_file(phone.join(name));
8604 }
8605 }
8606 if self.how == Carrying::StaleCopies && self.turn % 7 == 0 {
8608 for (name, versions) in self.history.iter().filter(|(n, _)| n.ends_with(".jsonl")) {
8609 if versions.len() > 2 {
8610 let current = std::fs::read(server.join(name)).unwrap_or_default();
8611 Self::write(&server.join(format!("{name} (conflicted copy)")), ¤t);
8612 Self::write(&server.join(name), &versions[versions.len() / 2]);
8613 }
8614 }
8615 }
8616 }
8617 }
8618
8619 #[test]
8620 fn doses_are_never_known_wrongly_whatever_carries_them() {
8621 for how in [Carrying::Exact, Carrying::SizeOnly, Carrying::Late, Carrying::StaleCopies, Carrying::Placeholders] {
8622 let base = scratch(&format!("carrier-{how:?}").to_lowercase());
8623 let (server, phone_folder) = (base.join("server").join("Sioul"), base.join("phone").join("Sioul"));
8624 let key = quick_key(&server, "four words make a passphrase").unwrap();
8625 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8626 let mut carrier = Carrier::new(how);
8627 let mut marked: Vec<String> = Vec::new();
8628 for minute in 0..120 {
8629 let now = NOW + minute * MINUTE;
8630 if minute % 10 == 0 {
8632 marked.push(format!("dose@{minute}"));
8633 let record: String = marked.iter().map(|d| format!("\"{d}\" = {minute}\n")).collect();
8634 desk.write("state/health-state.toml", &format!("[taken]\n{record}"));
8635 }
8636 desk.exchange(&server, &key, now);
8637 let wrote = written(&desk.memory, &desk.id);
8638 crate::lease::renew(&server, &key, "health", &desk.id, now / 1000, now / 1000, false, crate::lease::Rule::FollowsYou, wrote).unwrap();
8639 carrier.carry(&server, &phone_folder);
8640 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();
8642 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();
8643 if minute % 10 == 0 {
8644 let picture = base.join(format!("picture-{minute}.bin"));
8645 std::fs::write(&picture, vec![minute as u8; 256 * 1024]).unwrap();
8646 let (hash, _) = crate::blobs::hash_file(&picture).unwrap();
8647 crate::blobs::put(&phone_folder, &key, &picture, &hash).unwrap();
8648 }
8649 phone.exchange_with(&phone_folder, &key, now + 30_000, &base.join("phone-notes"), &[sioul_core::texts::PART]);
8650 if let Some(claim) = crate::lease::claims(&phone_folder, &key, "health").into_iter().find(|c| c.computer == desk.id)
8652 && let Some(wrote) = claim.wrote
8653 && heard(&phone.memory, &phone.id).complete(&desk.id, wrote)
8654 {
8655 let record = phone.read("state/health-state.toml");
8656 let claimed = (claim.renewed - NOW / 1000) / 60;
8657 for dose in marked.iter().filter(|d| d[5..].parse::<i64>().unwrap() < claimed) {
8658 assert!(record.contains(dose.as_str()), "{how:?}, minute {minute}: the phone takes {dose} as known, its record lacks it:\n{record}");
8659 }
8660 }
8661 }
8662 for minute in 120..140 {
8664 let now = NOW + minute * MINUTE;
8665 desk.exchange(&server, &key, now);
8666 carrier.carry(&server, &phone_folder);
8667 phone.exchange(&phone_folder, &key, now + 30_000);
8668 }
8669 let record = phone.read("state/health-state.toml");
8670 for dose in &marked {
8671 assert!(record.contains(dose.as_str()), "{how:?}: {dose} never came:\n{record}");
8672 }
8673 let _ = std::fs::remove_dir_all(&base);
8674 }
8675 }
8676
8677 #[test]
8678 fn edrive_brings_every_rewrite() {
8679 let base = scratch("edrive");
8680 let (server, phone_folder) = (base.join("server").join("Sioul"), base.join("phone").join("Sioul"));
8681 let key = quick_key(&server, "four words make a passphrase").unwrap();
8682 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8683 let mut edrive = Edrive::default();
8684 let health = |computer: &Computer, folder: &Path, now: i64, closed: bool| {
8685 let wrote = written(&computer.memory, &computer.id);
8686 if closed {
8687 crate::lease::close(folder, &key, "health", &computer.id, now / 1000, wrote).unwrap();
8688 } else {
8689 crate::lease::renew(folder, &key, "health", &computer.id, now / 1000, now / 1000, false, crate::lease::Rule::FollowsYou, wrote).unwrap();
8690 }
8691 };
8692
8693 desk.write("state/health-state.toml", "[taken]\n\"levothyroxine@2026-10-05T07:30\" = 1790000000\n");
8695 desk.exchange(&server, &key, NOW);
8696 health(&desk, &server, NOW, false);
8697 edrive.carry(&server, &phone_folder);
8698 phone.exchange(&phone_folder, &key, NOW + MINUTE);
8699 assert!(phone.read("state/health-state.toml").contains("levothyroxine@2026-10-05T07:30"));
8700
8701 for minute in 2..200 {
8703 let now = NOW + minute * MINUTE;
8704 if minute == 120 {
8705 desk.write("state/health-state.toml", &format!("{}\"levothyroxine@2026-10-06T07:30\" = 1790090000\n", desk.read("state/health-state.toml")));
8706 }
8707 desk.exchange(&server, &key, now);
8708 health(&desk, &server, now, minute == 199);
8709 edrive.carry(&server, &phone_folder);
8710 for name in [format!("{}.toml", desk.id), format!("leases/health/{}.lease", desk.id)] {
8711 assert_eq!(std::fs::read(phone_folder.join(&name)).ok(), std::fs::read(server.join(&name)).ok(), "minute {minute}: {name} stale on the phone");
8712 }
8713 }
8714 phone.exchange(&phone_folder, &key, NOW + 200 * MINUTE);
8716 assert!(phone.read("state/health-state.toml").contains("levothyroxine@2026-10-06T07:30"));
8717 let claim = crate::lease::claims(&phone_folder, &key, "health").into_iter().find(|c| c.computer == desk.id).unwrap();
8718 assert!(claim.closed && claim.wrote == written(&desk.memory, &desk.id), "{claim:?}");
8719 let phone_record = format!("{}-1.jsonl", phone.id);
8721 assert_eq!(std::fs::read(server.join(&phone_record)).ok(), std::fs::read(phone_folder.join(&phone_record)).ok());
8722 let _ = std::fs::remove_dir_all(&base);
8723 }
8724
8725 #[test]
8729 fn each_batch_of_records_says_its_build() {
8730 let base = scratch("build");
8731 let folder = base.join("Sioul");
8732 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8733 let desk = Computer::new(&base, "desk");
8734 desk.write("config/safe-senders.txt", "a@example.org\nb@example.org\n");
8735 desk.exchange(&folder, &key, NOW);
8736 let own = round_file(&folder, &desk.id, 1);
8737 let first = std::fs::read_to_string(&own).unwrap().lines().count();
8738 desk.write("config/safe-senders.txt", "a@example.org\nb@example.org\nc@example.org\n");
8739 desk.exchange(&folder, &key, NOW + MINUTE);
8740 let text = std::fs::read_to_string(&own).unwrap();
8741 let builds: Vec<String> = text
8742 .lines()
8743 .map(|l| {
8744 let line: Line = serde_json::from_str(l).unwrap();
8745 serde_json::from_slice::<Change>(&open(&key, &bound(&desk.id, 1, line.n, line.c), &line.s).unwrap()).unwrap().build
8746 })
8747 .collect();
8748 assert!(first >= 2 && builds.len() > first, "{builds:?}");
8749 for (n, build) in builds.iter().enumerate() {
8750 assert_eq!(build.as_str(), if n == 0 || n == first { sioul_core::build::DESCRIBED } else { "" }, "line {n}");
8751 }
8752 assert!(!text.contains(sioul_core::build::DESCRIBED), "sealed: the server never learns it");
8753 std::fs::write(&own, text.lines().next().unwrap().to_string() + "\n").unwrap();
8755 desk.exchange(&folder, &key, NOW + 2 * MINUTE);
8756 let line: Line = serde_json::from_str(std::fs::read_to_string(round_file(&folder, &desk.id, 2)).unwrap().lines().next().unwrap()).unwrap();
8757 let change: Change = serde_json::from_slice(&open(&key, &bound(&desk.id, 2, line.n, line.c), &line.s).unwrap()).unwrap();
8758 assert_eq!(change.build, sioul_core::build::DESCRIBED);
8759 let laptop = Computer::new(&base, "laptop");
8761 let stranger = uuid::Uuid::new_v4().to_string();
8762 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 }));
8763 laptop.exchange(&folder, &key, NOW + 3 * MINUTE);
8764 let safe = laptop.read("config/safe-senders.txt");
8765 assert!(["a@", "b@", "c@", "d@"].iter().all(|a| safe.contains(a)), "{safe}");
8766 let _ = std::fs::remove_dir_all(&base);
8767 }
8768
8769 #[test]
8770 fn a_record_put_back_shorter_starts_again() {
8771 let base = scratch("cut");
8772 let folder = base.join("Sioul");
8773 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8774 let desk = Computer::new(&base, "desk");
8775 desk.write("config/safe-senders.txt", "a@example.org\n");
8776 desk.exchange(&folder, &key, NOW);
8777 desk.write("config/safe-senders.txt", "a@example.org\nb@example.org\n");
8778 desk.exchange(&folder, &key, NOW + MINUTE);
8779 let own = round_file(&folder, &desk.id, 1);
8781 let text = std::fs::read_to_string(&own).unwrap();
8782 std::fs::write(&own, text.lines().next().unwrap().to_string() + "\n").unwrap();
8783 desk.write("config/safe-senders.txt", "a@example.org\nb@example.org\nc@example.org\n");
8784 let outcome = desk.exchange(&folder, &key, NOW + 2 * MINUTE);
8785 assert!(outcome.problems.iter().any(|p| p == "share-own-cut"), "{outcome:?}");
8786 assert!(round_file(&folder, &desk.id, 2).exists(), "a new file, opening on all the desk holds");
8787 let laptop = Computer::new(&base, "laptop");
8789 laptop.exchange(&folder, &key, NOW + 3 * MINUTE);
8790 let safe = laptop.read("config/safe-senders.txt");
8791 assert!(["a@", "b@", "c@"].iter().all(|a| safe.contains(a)), "{safe}");
8792 let _ = std::fs::remove_dir_all(&base);
8793 }
8794
8795 #[test]
8796 fn a_memory_restored_or_lost_goes_on_numbering() {
8797 let base = scratch("restored");
8798 let folder = base.join("Sioul");
8799 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8800 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8801 desk.write("state/health-state.toml", "[taken]\n\"a@100\" = 100\n");
8802 desk.exchange(&folder, &key, NOW);
8803 phone.exchange(&folder, &key, NOW + MINUTE);
8804 let backup = (std::fs::read(&desk.memory).unwrap(), desk.read("state/health-state.toml"));
8806 desk.write("state/health-state.toml", "[taken]\n\"a@100\" = 100\n\"b@800\" = 800\n");
8807 desk.exchange(&folder, &key, NOW + 2 * MINUTE);
8808 phone.exchange(&folder, &key, NOW + 3 * MINUTE);
8809 assert!(phone.read("state/health-state.toml").contains("b@800"));
8810 let before = written(&desk.memory, &desk.id).unwrap();
8811 std::fs::write(&desk.memory, &backup.0).unwrap();
8813 desk.write("state/health-state.toml", &backup.1);
8814 let outcome = desk.exchange(&folder, &key, NOW + 4 * MINUTE);
8815 assert!(outcome.problems.iter().any(|p| p == "share-own-ahead"), "{outcome:?}");
8816 assert!(desk.read("state/health-state.toml").contains("b@800"), "{}", desk.read("state/health-state.toml"));
8817 desk.write("state/health-state.toml", &format!("{}\"c@900\" = 900\n", desk.read("state/health-state.toml")));
8818 desk.exchange(&folder, &key, NOW + 5 * MINUTE);
8819 let after = written(&desk.memory, &desk.id).unwrap();
8820 assert!(after > before && after.0 == before.0, "numbering goes on in the same round: {before:?} → {after:?}");
8821 let outcome = phone.exchange(&folder, &key, NOW + 6 * MINUTE);
8823 assert!(!outcome.problems.iter().any(|p| p.starts_with("share-other-gap")), "{outcome:?}");
8824 assert!(phone.read("state/health-state.toml").contains("c@900"));
8825 assert!(heard(&phone.memory, &phone.id).complete(&desk.id, after));
8826 std::fs::remove_file(&desk.memory).unwrap();
8828 desk.write("state/health-state.toml", &format!("{}\"d@950\" = 950\n", desk.read("state/health-state.toml")));
8829 desk.exchange(&folder, &key, NOW + 7 * MINUTE);
8830 assert!(written(&desk.memory, &desk.id).unwrap() > after);
8831 phone.exchange(&folder, &key, NOW + 8 * MINUTE);
8832 assert!(phone.read("state/health-state.toml").contains("d@950"));
8833 assert!(heard(&phone.memory, &phone.id).complete(&desk.id, written(&desk.memory, &desk.id).unwrap()));
8834 let _ = std::fs::remove_dir_all(&base);
8835 }
8836
8837 #[test]
8838 fn a_gap_is_a_doubt_and_a_damaged_line_is_one_line() {
8839 let base = scratch("gap");
8840 let folder = base.join("Sioul");
8841 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8842 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8843 desk.write("config/safe-senders.txt", "a@example.org\n");
8844 desk.exchange(&folder, &key, NOW);
8845 phone.exchange(&folder, &key, NOW + MINUTE);
8846 let own = round_file(&folder, &desk.id, 1);
8848 let mut bytes = std::fs::read(&own).unwrap();
8849 bytes.extend_from_slice(b"\xff\xfe broken\n");
8850 std::fs::write(&own, &bytes).unwrap();
8851 desk.write("config/safe-senders.txt", "a@example.org\nb@example.org\n");
8852 desk.exchange(&folder, &key, NOW + 2 * MINUTE);
8853 let outcome = phone.exchange(&folder, &key, NOW + 3 * MINUTE);
8854 assert!(outcome.problems.iter().any(|p| p.starts_with("share-other-line")), "{outcome:?}");
8855 assert!(phone.read("config/safe-senders.txt").contains("b@example.org"));
8856 let line = |n: u64, value: &str| {
8858 let clock = (NOW as u64) << 16 | n;
8859 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();
8860 serde_json::to_string(&Line { n, c: clock, s: seal(&key, &bound(&desk.id, 1, n, clock), &plain) }).unwrap() + "\n"
8861 };
8862 let last = written(&desk.memory, &desk.id).unwrap().1;
8863 let mut bytes = std::fs::read(&own).unwrap();
8864 bytes.extend_from_slice(line(last + 3, "c@example.org").as_bytes());
8865 std::fs::write(&own, bytes).unwrap();
8866 let outcome = phone.exchange(&folder, &key, NOW + 4 * MINUTE);
8867 assert!(outcome.problems.iter().any(|p| p.starts_with("share-other-gap")), "{outcome:?}");
8868 assert!(!heard(&phone.memory, &phone.id).complete(&desk.id, (1, last + 3)), "past a gap, nothing is known");
8869 let _ = std::fs::remove_dir_all(&base);
8870 }
8871
8872 #[test]
8873 fn stores_that_leave_arrive_or_move_take_nothing_out() {
8874 let base = scratch("stores");
8875 let folder = base.join("Sioul");
8876 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8877 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8878 let (desk_notes, phone_notes, moved) = (base.join("desk-notes"), base.join("phone-notes"), base.join("moved-notes"));
8879 let ledger = |notes: &Path| std::fs::read_to_string(notes.join("sioul-budgets.toml")).unwrap_or_default();
8880 let line = |label: &str, amount: f64| format!("\n[[line]]\nbudget = \"home\"\ndate = \"2026-10-01\"\namount = {amount}\nlabel = \"{label}\"\n");
8881 std::fs::create_dir_all(&desk_notes).unwrap();
8882 std::fs::write(desk_notes.join("sioul-budgets.toml"), format!("[[budget]]\nid = \"home\"\ntitle = \"Home\"\n{}", line("Rent", -620.0))).unwrap();
8883 std::fs::create_dir_all(&phone_notes).unwrap();
8885 std::fs::write(phone_notes.join("sioul-budgets.toml"), format!("[[budget]]\nid = \"home\"\ntitle = \"Old home\"\n{}", line("Old", -1.0))).unwrap();
8886 let old = std::fs::File::options().write(true).open(phone_notes.join("sioul-budgets.toml")).unwrap();
8887 old.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000)).unwrap();
8888 desk.exchange_notes(&folder, &key, NOW, &desk_notes);
8889 phone.exchange(&folder, &key, NOW + MINUTE);
8890 phone.exchange_notes(&folder, &key, NOW + 2 * MINUTE, &phone_notes);
8892 desk.exchange_notes(&folder, &key, NOW + 3 * MINUTE, &desk_notes);
8893 assert!(ledger(&phone_notes).contains("title = \"Home\"") && ledger(&phone_notes).contains("Rent"), "{}", ledger(&phone_notes));
8894 assert!(ledger(&desk_notes).contains("title = \"Home\""), "{}", ledger(&desk_notes));
8895 phone.exchange_notes(&folder, &key, NOW + 4 * MINUTE, &moved);
8897 desk.exchange_notes(&folder, &key, NOW + 5 * MINUTE, &desk_notes);
8898 assert!(ledger(&moved).contains("Rent"), "{}", ledger(&moved));
8899 assert!(ledger(&desk_notes).contains("Rent"), "{}", ledger(&desk_notes));
8900 phone.exchange(&folder, &key, NOW + 6 * MINUTE);
8902 desk.exchange_notes(&folder, &key, NOW + 7 * MINUTE, &desk_notes);
8903 assert!(ledger(&desk_notes).contains("Rent"), "{}", ledger(&desk_notes));
8904 let _ = std::fs::remove_dir_all(&base);
8905 }
8906
8907 #[test]
8908 fn a_file_emptied_takes_nothing_out_at_once() {
8909 let base = scratch("emptied");
8910 let folder = base.join("Sioul");
8911 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8912 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
8913 let settings = "language = \"fr\"\n\n[tasks]\nkind = \"plan\"\n";
8914 desk.write("config/config.toml", settings);
8915 desk.exchange(&folder, &key, NOW);
8916 laptop.exchange(&folder, &key, NOW + MINUTE);
8917 desk.write("config/config.toml", "");
8919 let outcome = desk.exchange(&folder, &key, NOW + 2 * MINUTE);
8920 assert!(outcome.problems.iter().any(|p| p.starts_with("share-emptied")), "{outcome:?}");
8921 laptop.exchange(&folder, &key, NOW + 3 * MINUTE);
8922 assert!(laptop.read("config/config.toml").contains("language = \"fr\""));
8923 desk.write("config/config.toml", settings);
8925 assert_eq!(desk.exchange(&folder, &key, NOW + 4 * MINUTE).sent, 0);
8926 desk.write("config/safe-senders.txt", "a@example.org\n");
8928 desk.exchange(&folder, &key, NOW + 5 * MINUTE);
8929 desk.write("config/safe-senders.txt", "");
8930 desk.exchange(&folder, &key, NOW + 6 * MINUTE);
8931 laptop.exchange(&folder, &key, NOW + 7 * MINUTE);
8932 assert!(!laptop.read("config/safe-senders.txt").contains("a@example.org"));
8933 let _ = std::fs::remove_dir_all(&base);
8934 }
8935
8936 #[test]
8942 fn a_change_sends_a_small_file_and_a_late_joiner_reads_everything() {
8943 let base = scratch("segments");
8944 let folder = base.join("Sioul");
8945 let key = quick_key(&folder, "four words make a passphrase").unwrap();
8946 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
8947 let many: String = (0..3000).map(|n| format!("sender{n}@example.org\n")).collect();
8949 desk.write("config/known-senders.txt", &many);
8950 desk.exchange(&folder, &key, NOW);
8951 laptop.exchange(&folder, &key, NOW + 30_000);
8952 let current = |c: &Computer| {
8953 let all = rounds(&folder);
8954 let last = *all.get(&c.id).unwrap().last().unwrap();
8955 std::fs::metadata(round_file(&folder, &c.id, last)).unwrap().len()
8956 };
8957 assert!(current(&desk) > SEGMENT, "the first file holds it all");
8958 let mut list = many.clone();
8960 for minute in 1..=60 {
8961 list.push_str(&format!("new{minute}@example.org\n"));
8962 desk.write("config/known-senders.txt", &list);
8963 desk.exchange(&folder, &key, NOW + minute * MINUTE);
8964 assert!(current(&desk) <= SEGMENT + 4096, "minute {minute}: the file sent is {} bytes", current(&desk));
8965 laptop.exchange(&folder, &key, NOW + minute * MINUTE + 30_000);
8966 }
8967 assert!(laptop.read("config/known-senders.txt").contains("new60@example.org"));
8968 let more: String = (0..8000).map(|n| format!("later{n}@example.org\n")).collect();
8970 list.push_str(&more);
8971 desk.write("config/known-senders.txt", &list);
8972 desk.exchange(&folder, &key, NOW + 61 * MINUTE);
8973 let full = Memory::load(&desk.memory, &desk.id).full_round;
8974 assert!(full > 1, "a full round opened");
8975 for minute in 62..66 {
8976 laptop.exchange(&folder, &key, NOW + minute * MINUTE + 30_000);
8977 list.push_str(&format!("after{minute}@example.org\n"));
8978 desk.write("config/known-senders.txt", &list);
8979 desk.exchange(&folder, &key, NOW + minute * MINUTE);
8980 }
8981 let kept = rounds(&folder).get(&desk.id).cloned().unwrap();
8982 assert!(kept[0] >= full && kept.len() > 1, "only the last full round and those continuing it: {kept:?} (full {full})");
8983 let late = Computer::new(&base, "late");
8985 late.exchange(&folder, &key, NOW + 70 * MINUTE);
8986 let known = late.read("config/known-senders.txt");
8987 for address in ["sender0@", "sender2999@", "new1@", "new60@", "later7999@", "after65@"] {
8988 assert!(known.contains(address), "{address} missing for the late joiner");
8989 }
8990 let _ = std::fs::remove_dir_all(&base);
8991 }
8992
8993 #[test]
8996 fn a_full_round_waits_while_the_connection_is_metered() {
8997 let base = scratch("frugal");
8998 let folder = base.join("Sioul");
8999 let key = quick_key(&folder, "four words make a passphrase").unwrap();
9000 let desk = Computer::new(&base, "desk");
9001 let memory_full = || Memory::load(&desk.memory, &desk.id).full_round;
9002 let lines = |from: usize, count: usize| -> String { (from..from + count).map(|n| format!("s{n}@example.org\n")).collect() };
9003 set_frugal(&desk.memory, true);
9004 let mut list = lines(0, 6000);
9005 desk.write("config/known-senders.txt", &list);
9006 desk.exchange(&folder, &key, NOW);
9007 list.push_str(&lines(6000, 7000));
9008 desk.write("config/known-senders.txt", &list);
9009 desk.exchange(&folder, &key, NOW + MINUTE);
9010 let waited = memory_full();
9011 assert!(Memory::load(&desk.memory, &desk.id).grown > ROUND_SIZE && waited <= 1, "past a megabyte, metered: no full round yet ({waited})");
9012 set_frugal(&desk.memory, false);
9013 list.push_str(&lines(13000, 10));
9014 desk.write("config/known-senders.txt", &list);
9015 desk.exchange(&folder, &key, NOW + 2 * MINUTE);
9016 assert!(memory_full() > 1, "not metered any more: the full round opens");
9017 let _ = std::fs::remove_dir_all(&base);
9018 }
9019
9020 #[test]
9021 fn a_big_round_does_not_start_another_each_minute() {
9022 let base = scratch("big");
9023 let folder = base.join("Sioul");
9024 let key = quick_key(&folder, "four words make a passphrase").unwrap();
9025 let desk = Computer::new(&base, "desk");
9026 desk.write("data/drafts/big.toml", &format!("body = \"{}\"\n", "x".repeat(ROUND_SIZE as usize + 1000)));
9027 desk.exchange(&folder, &key, NOW);
9028 desk.write("config/safe-senders.txt", "a@example.org\n");
9029 desk.exchange(&folder, &key, NOW + MINUTE);
9030 let rounds_then = rounds(&folder).get(&desk.id).map_or(0, |r| *r.last().unwrap());
9031 for minute in 2..6 {
9032 desk.write("config/safe-senders.txt", &format!("a@example.org\nn{minute}@example.org\n"));
9033 desk.exchange(&folder, &key, NOW + minute * MINUTE);
9034 }
9035 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");
9036 let _ = std::fs::remove_dir_all(&base);
9037 }
9038
9039 #[test]
9040 fn a_half_written_line_is_cut_before_the_next() {
9041 let base = scratch("half");
9042 let folder = base.join("Sioul");
9043 let key = quick_key(&folder, "four words make a passphrase").unwrap();
9044 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
9045 desk.write("config/safe-senders.txt", "a@example.org\n");
9046 desk.exchange(&folder, &key, NOW);
9047 let own = round_file(&folder, &desk.id, 1);
9048 let mut text = std::fs::read_to_string(&own).unwrap();
9049 text.push_str("{\"n\":99,\"c\":1,\"s\":\"half");
9050 std::fs::write(&own, text).unwrap();
9051 desk.write("config/safe-senders.txt", "a@example.org\nb@example.org\n");
9052 desk.exchange(&folder, &key, NOW + MINUTE);
9053 assert!(std::fs::read_to_string(&own).unwrap().lines().all(|l| l.ends_with('}')), "whole lines only");
9054 laptop.exchange(&folder, &key, NOW + 2 * MINUTE);
9055 assert!(laptop.read("config/safe-senders.txt").contains("b@example.org"));
9056 let _ = std::fs::remove_dir_all(&base);
9057 }
9058
9059 #[test]
9060 fn the_days_reviews_travel_whole() {
9061 use sioul_core::reviews::{Felt, Mix, Reviews};
9062 let base = scratch("reviews");
9063 let folder = base.join("Nextcloud").join("sioul-shared");
9064 let key = quick_key(&folder, "four words make a passphrase").unwrap();
9065 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
9066 let at = |ms: i64| std::time::UNIX_EPOCH + std::time::Duration::from_millis(ms as u64);
9067 let month = "data/reviews/2026-10.toml";
9068 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");
9070 dated(&desk.path(month), at(NOW - 10 * MINUTE));
9071 desk.exchange(&folder, &key, NOW);
9072 phone.exchange(&folder, &key, NOW + MINUTE);
9073 assert!(phone.read(month).contains("felt = \"heavy\""));
9074 desk.write(month, &desk.read(month).replace("felt = \"heavy\"", "felt = \"usual\""));
9077 dated(&desk.path(month), at(NOW + 90_000));
9078 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\"")));
9079 dated(&phone.path(month), at(NOW + 100_000));
9080 phone.exchange(&folder, &key, NOW + 2 * MINUTE);
9081 desk.exchange(&folder, &key, NOW + 3 * MINUTE);
9082 phone.exchange(&folder, &key, NOW + 4 * MINUTE);
9083 for computer in [&desk, &phone] {
9084 let text = computer.read(month);
9085 let reviews = Reviews::load(&computer.path("data/reviews"));
9086 let day = reviews.day("2026-10-06".parse().unwrap()).unwrap();
9087 assert_eq!(day.weather, Some(sioul_core::today::Weather::Haze), "{text}");
9088 assert_eq!(day.work.as_ref().map(|r| (r.felt, r.mix)), Some((Some(Felt::Heavy), Some(Mix::AboutRight))), "{text}");
9090 assert_eq!(day.night.as_ref().map(|r| (r.mix, r.memo.trim().to_string())), Some((Some(Mix::AboutRight), "A walk.".to_string())), "{text}");
9091 }
9092 let _ = std::fs::remove_dir_all(&base);
9093 }
9094
9095 #[test]
9096 fn two_computers_agree() {
9097 let base = scratch("agree");
9098 let folder = base.join("Nextcloud").join("sioul-shared");
9099 let key = quick_key(&folder, "four words make a passphrase").unwrap();
9100 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
9101
9102 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");
9104 desk.write("config/blocked-senders.txt", "spam@example.org\n");
9105 desk.write("data/drafts/a1.toml", "subject = \"Hello\"\n");
9106 desk.write("data/time/2026-10.toml", "[[session]]\ntask = \"t1\"\nstart = 100\nminutes = 25\n");
9107 assert_eq!(desk.exchange(&folder, &key, NOW).received, 0);
9108
9109 laptop.write("config/config.toml", "language = \"en\"\ncase_store = \"~/Nextcloud/Notes\"\n");
9111 laptop.write("config/blocked-senders.txt", "*@pushy.example.com\n");
9112 let outcome = laptop.exchange(&folder, &key, NOW + MINUTE);
9113 assert!(outcome.received > 0 && outcome.sent > 0, "{outcome:?}");
9114 let config = laptop.read("config/config.toml");
9115 assert!(config.contains("language = \"fr\""), "the shared settings win when joining: {config}");
9116 assert!(config.contains("case_store = \"~/Nextcloud/Notes\""), "where things are stays here: {config}");
9117 assert!(config.contains("id = \"perso\"") && !config.contains("/desk/mail"), "accounts come, without this computer's paths: {config}");
9118 assert_eq!(laptop.read("data/drafts/a1.toml"), "subject = \"Hello\"\n");
9119 assert!(laptop.read("data/time/2026-10.toml").contains("task = \"t1\""));
9120 let blocked = laptop.read("config/blocked-senders.txt");
9121 assert!(blocked.contains("spam@example.org") && blocked.contains("*@pushy.example.com"), "{blocked}");
9122
9123 desk.exchange(&folder, &key, NOW + 2 * MINUTE);
9125 assert!(desk.read("config/blocked-senders.txt").contains("*@pushy.example.com"));
9126
9127 desk.write("data/time/2026-10.toml", "[[session]]\ntask = \"t1\"\nstart = 100\nminutes = 25\n\n[[session]]\ntask = \"t2\"\nstart = 200\nminutes = 50\n");
9129 desk.write("config/config.toml", &desk.read("config/config.toml").replace("kind = \"plan\"", "kind = \"list\""));
9130 laptop.write("data/time/2026-10.toml", &format!("{}\n[[session]]\nproject = \"p\"\nstart = 300\nminutes = 10\n", laptop.read("data/time/2026-10.toml")));
9131 let config = laptop.read("config/config.toml").replace("language = \"fr\"", "language = \"de\"");
9132 laptop.write("config/config.toml", &config);
9133 std::fs::remove_file(laptop.path("data/drafts/a1.toml")).unwrap();
9134 desk.exchange(&folder, &key, NOW + 3 * MINUTE);
9135 laptop.exchange(&folder, &key, NOW + 4 * MINUTE);
9136 desk.exchange(&folder, &key, NOW + 5 * MINUTE);
9137 for computer in [&desk, &laptop] {
9138 let time = computer.read("data/time/2026-10.toml");
9139 assert!(time.contains("\"t1\"") && time.contains("\"t2\"") && time.contains("project = \"p\""), "{time}");
9140 let config = computer.read("config/config.toml");
9141 assert!(config.contains("language = \"de\"") && config.contains("kind = \"list\""), "{config}");
9142 assert!(!computer.path("data/drafts/a1.toml").exists(), "a draft sent on one is gone on both");
9143 }
9144 assert!(desk.read("config/config.toml").contains("maildir = \"/desk/mail\""), "the desk keeps its own path");
9145
9146 assert_eq!(desk.exchange(&folder, &key, NOW + 6 * MINUTE).sent, 0);
9148 assert_eq!(laptop.exchange(&folder, &key, NOW + 7 * MINUTE).sent, 0);
9149 let _ = std::fs::remove_dir_all(&base);
9150 }
9151
9152 #[test]
9153 fn projects_and_budgets_travel_when_asked() {
9154 let base = scratch("projects");
9155 let folder = base.join("Nextcloud").join("Documents").join("Sioul");
9156 let key = quick_key(&folder, "four words make a passphrase").unwrap();
9157 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
9158 let (desk_notes, phone_notes) = (base.join("desk-notes"), base.join("phone-notes"));
9159 let projects = |notes: &Path| std::fs::read_to_string(sioul_core::projects::file_in(notes)).unwrap_or_default();
9160 let ledger = |notes: &Path| std::fs::read_to_string(notes.join("sioul-budgets.toml")).unwrap_or_default();
9161 let bank = |notes: &Path| std::fs::read_to_string(notes.join("sioul-bank.toml")).unwrap_or_default();
9162 let movement = |id: &str, label: &str| format!("\n[[movement]]\naccount = \"main\"\ndate = 2026-10-01\namount = -20.0\nlabel = \"{label}\"\nid = \"{id}\"\n");
9163 std::fs::create_dir_all(&desk_notes).unwrap();
9164 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();
9165 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();
9166 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();
9167 desk.exchange_notes(&folder, &key, NOW, &desk_notes);
9168
9169 phone.exchange_notes(&folder, &key, NOW + MINUTE, &phone_notes);
9171 assert!(projects(&phone_notes).contains("id = \"acme\"") && projects(&phone_notes).contains("acme.example"), "{}", projects(&phone_notes));
9172 assert!(ledger(&phone_notes).contains("Rent"), "{}", ledger(&phone_notes));
9173 assert!(bank(&phone_notes).contains("fitid-1") && bank(&phone_notes).contains("1200"), "{}", bank(&phone_notes));
9174 assert!(phone_notes.join(sioul_core::projects::FILE).exists() && projects(&phone_notes).contains("[[project]]") && !projects(&phone_notes).contains("[[case"), "{}", projects(&phone_notes));
9176 assert!(projects(&desk_notes).contains("[[case]]") && !desk_notes.join(sioul_core::projects::FILE).exists());
9177
9178 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();
9180 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();
9181 std::fs::write(desk_notes.join("sioul-bank.toml"), format!("{}{}", bank(&desk_notes), movement("fitid-2", "Bread"))).unwrap();
9183 std::fs::write(phone_notes.join("sioul-bank.toml"), format!("{}{}", bank(&phone_notes), movement("fitid-3", "Coffee"))).unwrap();
9184 desk.exchange_notes(&folder, &key, NOW + 2 * MINUTE, &desk_notes);
9185 phone.exchange_notes(&folder, &key, NOW + 3 * MINUTE, &phone_notes);
9186 desk.exchange_notes(&folder, &key, NOW + 4 * MINUTE, &desk_notes);
9187 for notes in [&desk_notes, &phone_notes] {
9188 let text = ledger(notes);
9189 assert!(text.contains("Rent") && text.contains("Bread") && text.contains("Coffee"), "{text}");
9190 let mut ids: Vec<String> = sioul_core::bank::Bank::load(notes).unwrap().movements.into_iter().map(|m| m.id).collect();
9191 ids.sort();
9192 assert_eq!(ids, ["fitid-1", "fitid-2", "fitid-3"], "{}", bank(notes));
9193 }
9194
9195 use sioul_core::projects::{ProjectEdit, ProjectStore, file_in, rename_file, save_project, set_ai};
9199 save_project(&file_in(&phone_notes), "", &ProjectEdit { title: "Garden".into(), ..ProjectEdit::default() }).unwrap();
9200 phone.exchange_notes(&folder, &key, NOW + 5 * MINUTE, &phone_notes);
9201 desk.exchange_notes(&folder, &key, NOW + 6 * MINUTE, &desk_notes);
9202 assert!(projects(&desk_notes).matches("[[case]]").count() == 2 && !projects(&desk_notes).contains("[[project"), "{}", projects(&desk_notes));
9203 let desk_store = ProjectStore::load(&desk_notes).unwrap();
9205 assert!(desk_store.get("acme").unwrap().routes.len() == 1 && desk_store.get("garden").unwrap().routes.is_empty(), "{}", projects(&desk_notes));
9206 rename_file(&desk_notes).unwrap();
9207 set_ai(&file_in(&desk_notes), "acme", true).unwrap();
9208 desk.exchange_notes(&folder, &key, NOW + 7 * MINUTE, &desk_notes);
9209 phone.exchange_notes(&folder, &key, NOW + 8 * MINUTE, &phone_notes);
9210 desk.exchange_notes(&folder, &key, NOW + 9 * MINUTE, &desk_notes);
9211 for notes in [&desk_notes, &phone_notes] {
9212 let store = ProjectStore::load(notes).unwrap();
9213 let mut ids: Vec<&str> = store.projects.iter().map(|p| p.id.as_str()).collect();
9214 ids.sort();
9215 assert_eq!(ids, ["acme", "garden"], "{}", projects(notes));
9216 assert!(store.get("acme").unwrap().ai && store.get("acme").unwrap().routes.len() == 1 && store.get("garden").unwrap().routes.is_empty(), "{}", projects(notes));
9217 assert!(!projects(notes).contains("[[case"), "{}", projects(notes));
9218 }
9219 assert!(desk_notes.join(sioul_core::projects::BEFORE_RENAME).exists());
9220
9221 let laptop = Computer::new(&base, "laptop");
9223 laptop.write("config/config.toml", &format!("case_store = '{}'\n", base.join("laptop-notes").display()));
9225 let config: Config = toml::from_str(&laptop.read("config/config.toml")).unwrap();
9226 assert!(stores(&config, &laptop.roots).iter().all(|s| !s.name.starts_with("notes/")));
9227 let _ = std::fs::remove_dir_all(&base);
9228 }
9229
9230 #[test]
9231 fn what_was_read_is_known_and_a_lost_record_comes_back() {
9232 let base = scratch("doses");
9233 let folder = base.join("shared");
9234 let key = quick_key(&folder, "four words make a passphrase").unwrap();
9235 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
9236 desk.write("state/health-state.toml", "[taken]\n\"d@100\" = 110\n");
9237 desk.exchange(&folder, &key, NOW);
9238 phone.exchange(&folder, &key, NOW + MINUTE);
9239 assert!(phone.read("state/health-state.toml").contains("d@100"));
9240 let wrote = written(&desk.memory, &desk.id).unwrap();
9242 assert!(wrote.1 > 0);
9243 let read = heard(&phone.memory, &phone.id);
9244 assert!(read.complete(&desk.id, wrote));
9245 assert!(!read.complete(&desk.id, (wrote.0, wrote.1 + 1)), "a record not read yet is not known");
9246 assert!(read.complete("someone", (1, 0)), "nothing written, nothing to read");
9247
9248 desk.write("state/health-state.toml", "[taken]\n\"d@100\" = 110\n\"d@200\" = 210\n");
9250 desk.exchange(&folder, &key, NOW + 2 * MINUTE);
9251 let round = round_file(&folder, &desk.id, wrote.0);
9252 let mut text = std::fs::read_to_string(&round).unwrap();
9253 let last = text.trim_end().rfind('\n').map_or(0, |i| i + 1);
9254 text.insert_str(last, "{broken\n");
9255 std::fs::write(&round, text).unwrap();
9256 let outcome = phone.exchange(&folder, &key, NOW + 3 * MINUTE);
9257 assert!(outcome.problems.iter().any(|p| p == &format!("share-other-line:{}", desk.id)), "{:?}", outcome.problems);
9258 assert!(heard(&phone.memory, &phone.id).broken.contains_key(&desk.id));
9259 assert!(phone.read("state/health-state.toml").contains("d@200"), "the next line came");
9260
9261 phone.write("state/health-state.toml", &format!("{}\"d@300\" = 310\n", phone.read("state/health-state.toml")));
9263 phone.exchange(&folder, &key, NOW + 4 * MINUTE);
9264 assert!(desk.exchange(&folder, &key, NOW + 5 * MINUTE).received > 0);
9265 assert!(desk.read("state/health-state.toml").contains("d@300"));
9266 std::fs::remove_file(phone.path("state/health-state.toml")).unwrap();
9267 rebuild(&phone.memory, &phone.id, &["state/health-state.toml"]).unwrap();
9268 phone.write("state/health-state.toml", "[taken]\n\"d@400\" = 410\n");
9270 phone.exchange(&folder, &key, NOW + 6 * MINUTE);
9271 let back = phone.read("state/health-state.toml");
9272 assert!(back.contains("d@100") && back.contains("d@200") && back.contains("d@300") && back.contains("d@400"), "{back}");
9273 desk.exchange(&folder, &key, NOW + 7 * MINUTE);
9275 let there = desk.read("state/health-state.toml");
9276 assert!(there.contains("d@100") && there.contains("d@200") && there.contains("d@300") && there.contains("d@400"), "{there}");
9277 let _ = std::fs::remove_dir_all(&base);
9278 }
9279
9280 #[test]
9281 fn the_later_word_wins() {
9282 let base = scratch("later");
9283 let folder = base.join("shared");
9284 let key = quick_key(&folder, "a passphrase of some length").unwrap();
9285 let (a, b) = (Computer::new(&base, "a"), Computer::new(&base, "b"));
9286 a.write("config/safe-senders.txt", "friend@example.org\n");
9287 a.exchange(&folder, &key, NOW);
9288 b.exchange(&folder, &key, NOW + MINUTE);
9289 assert!(b.read("config/safe-senders.txt").contains("friend@example.org"));
9290
9291 b.write("config/safe-senders.txt", "");
9293 b.write("config/blocked-senders.txt", "friend@example.org\n");
9294 b.exchange(&folder, &key, NOW + 2 * MINUTE);
9295 a.exchange(&folder, &key, NOW + 3 * MINUTE);
9296 assert!(!a.read("config/safe-senders.txt").contains("friend@example.org"));
9297 assert!(a.read("config/blocked-senders.txt").contains("friend@example.org"));
9298
9299 a.write("state/quiet.toml", "work_until = 5\n");
9301 let sent = a.exchange(&folder, &key, NOW + 4 * MINUTE);
9302 let received = b.exchange(&folder, &key, NOW + 5 * MINUTE);
9303 assert!(b.read("state/quiet.toml").contains("work_until = 5"), "{sent:?} {received:?}");
9304 b.write("state/quiet.toml", "work_until = [ broken");
9305 b.exchange(&folder, &key, NOW + 6 * MINUTE);
9306 a.exchange(&folder, &key, NOW + 7 * MINUTE);
9307 assert!(a.read("state/quiet.toml").contains("work_until = 5"));
9308 let _ = std::fs::remove_dir_all(&base);
9309 }
9310
9311 #[test]
9312 fn sealed_with_one_passphrase() {
9313 let base = scratch("seal");
9314 let folder = base.join("shared");
9315 let key = quick_key(&folder, "the right passphrase").unwrap();
9316 assert_eq!(quick_key(&folder, "the right passphrase"), Ok(key));
9317 assert_eq!(quick_key(&folder, "another passphrase"), Err(Refused::WrongPassphrase));
9318
9319 let a = Computer::new(&base, "a");
9321 a.write("config/safe-senders.txt", "friend@example.org\n");
9322 a.exchange(&folder, &key, NOW);
9323 let file = std::fs::read_to_string(round_file(&folder, &a.id, 1)).unwrap();
9324 assert!(!file.contains("friend") && !file.contains("safe-senders"), "{file}");
9325 assert_eq!(others(&folder, "someone else").len(), 1);
9326 let text = std::fs::read_to_string(folder.join("seal.toml")).unwrap();
9328 std::fs::write(folder.join("seal.toml"), text.replace("memory_kib = 64", "memory_kib = 4294967295")).unwrap();
9329 assert!(matches!(quick_key(&folder, "the right passphrase"), Err(Refused::Other(_))));
9330 let _ = std::fs::remove_dir_all(&base);
9331 }
9332
9333 #[test]
9334 fn records_stay_in_their_folders() {
9335 let roots = Roots { config: PathBuf::from("config"), data: PathBuf::from("data"), state: PathBuf::from("state") };
9336 let stores = stores(&Config::default(), &roots);
9337 assert!(locate(&stores, "data/drafts/a1.toml").is_some());
9338 for file in ["data/drafts/../x", "data/drafts/", "data/drafts//x", "data/drafts/a\\..\\..\\x", "data/drafts/C:x"] {
9339 assert!(locate(&stores, file).is_none(), "{file}");
9340 }
9341 }
9342
9343 #[test]
9344 fn rounds_start_over_and_old_ones_go() {
9345 let base = scratch("rounds");
9346 let folder = base.join("shared");
9347 let key = quick_key(&folder, "a passphrase of some length").unwrap();
9348 let (a, b) = (Computer::new(&base, "a"), Computer::new(&base, "b"));
9349 a.write("config/safe-senders.txt", "friend@example.org\n");
9350 a.exchange(&folder, &key, NOW);
9351 b.exchange(&folder, &key, NOW + MINUTE);
9352 let mut now = NOW + MINUTE;
9354 while Memory::load(&a.memory, &a.id).full_round <= 1 {
9355 now += MINUTE;
9356 a.write("data/drafts/2026-10-01.json", &format!("{{\"steps\": {now}, \"pad\": \"{}\"}}", "x".repeat(100_000)));
9357 a.exchange(&folder, &key, now);
9358 }
9359 assert!(round_file(&folder, &a.id, 1).exists(), "kept until b has read past it");
9360 b.exchange(&folder, &key, now + MINUTE);
9362 assert!(b.read("data/drafts/2026-10-01.json").contains(&format!("\"steps\": {now}")));
9363 a.exchange(&folder, &key, now + 2 * MINUTE);
9364 assert!(!round_file(&folder, &a.id, 1).exists(), "read by everyone: removed");
9365 let c = Computer::new(&base, "c");
9367 c.exchange(&folder, &key, now + 3 * MINUTE);
9368 assert!(c.read("data/drafts/2026-10-01.json").contains(&format!("\"steps\": {now}")));
9369 let _ = std::fs::remove_dir_all(&base);
9370 }
9371
9372 #[test]
9373 fn notes_travel_file_by_file_whatever_carries_them() {
9374 for how in [Carrying::Exact, Carrying::SizeOnly, Carrying::Late, Carrying::StaleCopies, Carrying::Placeholders] {
9375 let base = scratch(&format!("notes-{how:?}").to_lowercase());
9376 let (server, phone_folder) = (base.join("server").join("Sioul"), base.join("phone").join("Sioul"));
9377 let key = quick_key(&server, "four words make a passphrase").unwrap();
9378 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
9379 let (desk_notes, phone_notes) = (base.join("desk-notes"), base.join("phone-notes"));
9380 put(&desk_notes, "lease.md", b"# Lease\nSigned on 2 October.\n");
9381 put(&desk_notes, "admin/taxes.md", b"# Taxes\nNotice received.\n");
9382 put(&desk_notes, "admin/scan.pdf", &noise(1_500_000, 1));
9383 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"] {
9385 put(&desk_notes, name, b"stays home");
9386 }
9387 let mut carrier = Carrier::new(how);
9388 let run = |carrier: &mut Carrier, minutes: std::ops::Range<i64>| {
9389 for minute in minutes {
9390 let now = NOW + minute * MINUTE;
9391 desk.exchange_with(&server, &key, now, &desk_notes, &["notes"]);
9392 carrier.carry(&server, &phone_folder);
9393 phone.exchange_with(&phone_folder, &key, now + 30_000, &phone_notes, &["notes"]);
9394 }
9395 };
9396 run(&mut carrier, 0..20);
9397 let there = files_in(&phone_notes);
9398 assert_eq!(there.keys().map(String::as_str).collect::<Vec<_>>(), ["admin/scan.pdf", "admin/taxes.md", "lease.md"], "{how:?}");
9399 assert!(there["admin/scan.pdf"] == noise(1_500_000, 1), "{how:?}: the scan came whole");
9400 put(&phone_notes, "lease.md", b"# Lease\nSigned on 2 October.\nKeys: two.\n");
9402 put(&phone_notes, "new.md", b"# New\n");
9403 std::fs::remove_file(desk_notes.join("admin/taxes.md")).unwrap();
9404 run(&mut carrier, 20..40);
9405 for notes in [&desk_notes, &phone_notes] {
9406 let files = files_in(notes);
9407 assert!(!files.contains_key("admin/taxes.md"), "{how:?}: {}", notes.display());
9408 assert_eq!(files.get("lease.md").map(Vec::as_slice), Some(&b"# Lease\nSigned on 2 October.\nKeys: two.\n"[..]), "{how:?}: {}", notes.display());
9409 assert_eq!(files.get("new.md").map(Vec::as_slice), Some(&b"# New\n"[..]), "{how:?}: {}", notes.display());
9410 assert!(copies_of(notes, "lease").is_empty(), "{how:?}: no conflict where there was none");
9411 }
9412 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();
9414 assert_eq!(sealed, 5, "{how:?}: the lease twice, the taxes, the scan, the new note");
9415 let mut everything = Vec::new();
9416 list_files(&server, &server, &[], &mut everything);
9417 for (_, path) in everything {
9418 let bytes = std::fs::read(&path).unwrap();
9419 for secret in [&b"Signed on"[..], b"Taxes", b"Keys: two", b"stays home", b"lease.md"] {
9420 assert!(!bytes.windows(secret.len()).any(|w| w == secret), "{how:?}: {} shows {}", path.display(), String::from_utf8_lossy(secret));
9421 }
9422 }
9423 let _ = std::fs::remove_dir_all(&base);
9424 }
9425 }
9426
9427 #[test]
9428 fn a_note_changed_on_both_keeps_both_whatever_carries_them() {
9429 for how in [Carrying::Exact, Carrying::SizeOnly, Carrying::Late, Carrying::StaleCopies, Carrying::Placeholders] {
9430 let base = scratch(&format!("notes-both-{how:?}").to_lowercase());
9431 let (server, phone_folder) = (base.join("server").join("Sioul"), base.join("phone").join("Sioul"));
9432 let key = quick_key(&server, "four words make a passphrase").unwrap();
9433 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
9434 let (desk_notes, phone_notes) = (base.join("desk-notes"), base.join("phone-notes"));
9435 put(&desk_notes, "lease.md", b"Rent: 620\n");
9436 put(&desk_notes, "taxes.md", b"Notice received.\n");
9437 let mut carrier = Carrier::new(how);
9438 let run = |carrier: &mut Carrier, minutes: std::ops::Range<i64>| {
9439 for minute in minutes {
9440 let now = NOW + minute * MINUTE;
9441 desk.exchange_with(&server, &key, now, &desk_notes, &["notes"]);
9442 carrier.carry(&server, &phone_folder);
9443 phone.exchange_with(&phone_folder, &key, now + 30_000, &phone_notes, &["notes"]);
9444 }
9445 };
9446 run(&mut carrier, 0..10);
9447 assert_eq!(files_in(&phone_notes).len(), 2, "{how:?}");
9448 put(&desk_notes, "lease.md", b"Rent: 640\n");
9450 put(&phone_notes, "lease.md", b"Rent: 620\nKeys: two\n");
9451 std::fs::remove_file(desk_notes.join("taxes.md")).unwrap();
9452 put(&phone_notes, "taxes.md", b"Notice received.\nPaid on 3 October.\n");
9453 run(&mut carrier, 10..40);
9454 for notes in [&desk_notes, &phone_notes] {
9455 let files = files_in(notes);
9456 assert_eq!(files.get("lease.md").map(Vec::as_slice), Some(&b"Rent: 620\nKeys: two\n"[..]), "{how:?}: {}", notes.display());
9457 assert_eq!(copies_of(notes, "lease"), [b"Rent: 640\n".to_vec()], "{how:?}: {}: {:?}", notes.display(), files.keys().collect::<Vec<_>>());
9458 assert_eq!(files.get("taxes.md").map(Vec::as_slice), Some(&b"Notice received.\nPaid on 3 October.\n"[..]), "{how:?}: {}", notes.display());
9459 assert_eq!(files.len(), 3, "{how:?}: {}: {:?}", notes.display(), files.keys().collect::<Vec<_>>());
9460 }
9461 let _ = std::fs::remove_dir_all(&base);
9462 }
9463 }
9464
9465 #[test]
9466 fn a_big_folder_sends_only_what_changed() {
9467 let base = scratch("big-folder");
9468 let notes = base.join("notes");
9469 let folder = notes.join("sioul-shared");
9471 let key = quick_key(&folder, "four words make a passphrase").unwrap();
9472 let desk = Computer::new(&base, "desk");
9473 for n in 0..300 {
9474 put(¬es, &format!("days/{n:03}.md"), format!("# Day {n}\n").as_bytes());
9475 }
9476 for n in 0..3 {
9477 put(¬es, &format!("scans/{n}.pdf"), &noise(1_500_000, n));
9478 }
9479 std::thread::sleep(std::time::Duration::from_millis(5));
9481 let blobs = || std::fs::read_dir(folder.join("blobs")).unwrap().count();
9482 assert_eq!(desk.exchange_with(&folder, &key, NOW, ¬es, &["notes"]).sent, 303);
9483 assert_eq!(blobs(), 303);
9484 crate::blobs::HASHED.with(|n| n.set(0));
9486 assert_eq!(desk.exchange_with(&folder, &key, NOW + MINUTE, ¬es, &["notes"]).sent, 0);
9487 assert_eq!(crate::blobs::HASHED.with(std::cell::Cell::get), 0, "a file of the same size and time is not read");
9488 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();
9490 put(¬es, "scans/1.pdf", &noise(1_500_000, 9));
9491 let outcome = desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, ¬es, &["notes"]);
9492 assert_eq!(outcome.sent, 1, "{outcome:?}");
9493 assert_eq!(crate::blobs::HASHED.with(std::cell::Cell::get), 2);
9494 assert_eq!(blobs(), 304);
9495 std::fs::File::create(notes.join("scans/film.mp4")).unwrap().set_len(crate::blobs::LARGEST + 1).unwrap();
9497 let outcome = desk.exchange_with(&folder, &key, NOW + 3 * MINUTE, ¬es, &["notes"]);
9498 assert!(outcome.problems.iter().any(|p| p == "share-too-big:files/notes/scans/film.mp4"), "{outcome:?}");
9499 assert_eq!(outcome.sent, 0);
9500 let _ = std::fs::remove_dir_all(&base);
9501 }
9502
9503 #[test]
9504 fn a_note_changed_on_both_keeps_both() {
9505 for desk_later in [false, true] {
9506 let base = scratch(&format!("both-{desk_later}"));
9507 let folder = base.join("Sioul");
9508 let key = quick_key(&folder, "four words make a passphrase").unwrap();
9509 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
9510 let (desk_notes, laptop_notes) = (base.join("desk-notes"), base.join("laptop-notes"));
9511 put(&desk_notes, "lease.md", b"Rent: 620\n");
9512 desk.exchange_with(&folder, &key, NOW, &desk_notes, &["notes"]);
9513 laptop.exchange_with(&folder, &key, NOW + MINUTE, &laptop_notes, &["notes"]);
9514 assert_eq!(std::fs::read(laptop_notes.join("lease.md")).unwrap(), b"Rent: 620\n");
9515 put(&desk_notes, "lease.md", b"Rent: 640\n");
9517 put(&laptop_notes, "lease.md", b"Rent: 620\nKeys: two\n");
9518 let (first, second): (&Computer, &Computer) = if desk_later { (&laptop, &desk) } else { (&desk, &laptop) };
9519 let notes = |c: &Computer| if std::ptr::eq(c, &desk) { desk_notes.clone() } else { laptop_notes.clone() };
9520 let mut said = Vec::new();
9521 for minute in 2..10 {
9522 let (who, at) = if minute % 2 == 0 { (first, minute) } else { (second, minute) };
9523 said.extend(who.exchange_with(&folder, &key, NOW + at * MINUTE, ¬es(who), &["notes"]).problems);
9524 }
9525 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") };
9526 for notes in [&desk_notes, &laptop_notes] {
9527 assert_eq!(std::fs::read(notes.join("lease.md")).unwrap(), later, "desk later: {desk_later}");
9528 assert_eq!(copies_of(notes, "lease"), [earlier.to_vec()], "desk later: {desk_later}: {:?}", files_in(notes).keys().collect::<Vec<_>>());
9529 }
9530 assert!(said.iter().any(|p| p.starts_with("share-conflict:lease (")), "{said:?}");
9531 assert_eq!(desk.exchange_with(&folder, &key, NOW + 10 * MINUTE, &desk_notes, &["notes"]).sent, 0);
9533 assert_eq!(laptop.exchange_with(&folder, &key, NOW + 11 * MINUTE, &laptop_notes, &["notes"]).sent, 0);
9534 let _ = std::fs::remove_dir_all(&base);
9535 }
9536 }
9537
9538 #[test]
9539 fn a_part_switched_off_takes_nothing_out_and_sends_nothing() {
9540 let base = scratch("part-off");
9541 let folder = base.join("Sioul");
9542 let key = quick_key(&folder, "four words make a passphrase").unwrap();
9543 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
9544 let (desk_notes, phone_notes) = (base.join("desk-notes"), base.join("phone-notes"));
9545 for name in ["a", "b", "c", "d", "e", "f"] {
9546 put(&desk_notes, &format!("{name}.md"), format!("# {name}\n").as_bytes());
9547 }
9548 desk.exchange_with(&folder, &key, NOW, &desk_notes, &["notes"]);
9549 phone.exchange_with(&folder, &key, NOW + MINUTE, &phone_notes, &["notes"]);
9550 assert_eq!(files_in(&phone_notes).len(), 6);
9551 phone.exchange_with(&folder, &key, NOW + 2 * MINUTE, &phone_notes, &[]);
9553 std::fs::remove_file(phone_notes.join("a.md")).unwrap();
9554 put(&phone_notes, "b.md", b"# b, changed on the phone while off\n");
9555 put(&phone_notes, "new.md", b"# new\n");
9556 put(&desk_notes, "c.md", b"# c, changed on the desk\n");
9557 desk.exchange_with(&folder, &key, NOW + 3 * MINUTE, &desk_notes, &["notes"]);
9558 assert_eq!(phone.exchange_with(&folder, &key, NOW + 4 * MINUTE, &phone_notes, &[]).sent, 0, "a part switched off sends nothing");
9559 desk.exchange_with(&folder, &key, NOW + 5 * MINUTE, &desk_notes, &["notes"]);
9560 let desk_files = files_in(&desk_notes);
9561 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:?}");
9562 assert_eq!(std::fs::read(phone_notes.join("c.md")).unwrap(), b"# c\n", "nothing comes into a part switched off");
9563 phone.exchange_with(&folder, &key, NOW + 6 * MINUTE, &phone_notes, &["notes"]);
9566 desk.exchange_with(&folder, &key, NOW + 7 * MINUTE, &desk_notes, &["notes"]);
9567 phone.exchange_with(&folder, &key, NOW + 8 * MINUTE, &phone_notes, &["notes"]);
9568 desk.exchange_with(&folder, &key, NOW + 9 * MINUTE, &desk_notes, &["notes"]);
9569 for notes in [&desk_notes, &phone_notes] {
9570 let files = files_in(notes);
9571 assert_eq!(files.get("a.md").map(Vec::as_slice), Some(&b"# a\n"[..]), "{}", notes.display());
9572 assert_eq!(files.get("b.md").map(Vec::as_slice), Some(&b"# b\n"[..]), "{}", notes.display());
9573 assert_eq!(files.get("c.md").map(Vec::as_slice), Some(&b"# c, changed on the desk\n"[..]), "{}", notes.display());
9574 assert_eq!(files.get("new.md").map(Vec::as_slice), Some(&b"# new\n"[..]), "{}", notes.display());
9575 assert_eq!(copies_of(notes, "b"), [b"# b, changed on the phone while off\n".to_vec()], "{}", notes.display());
9576 }
9577 let _ = std::fs::remove_dir_all(&base);
9578 }
9579
9580 #[cfg(unix)]
9587 #[test]
9588 fn what_the_sharing_writes_is_yours_alone() {
9589 use std::os::unix::fs::PermissionsExt;
9590 let mode = |path: &Path| std::fs::symlink_metadata(path).unwrap().permissions().mode() & 0o777;
9591 let set = |path: &Path, mode: u32| std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
9592 let base = scratch("yours-alone");
9593 let folder = base.join("Sioul");
9594 let key = quick_key(&folder, "four words make a passphrase").unwrap();
9595 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
9596 let (desk_notes, laptop_notes) = (base.join("desk-notes"), base.join("laptop-notes"));
9597 put(&desk_notes, "admin/lease.md", b"Rent: 620\n");
9598 desk.write("config/config.toml", "language = \"fr\"\n");
9599 put(&laptop_notes, "admin/lease.md", b"Rent: 600\n");
9603 put(&laptop_notes, "mine.md", b"Mine\n");
9604 laptop.write("config/config.toml", "theme = \"dark\"\n");
9605 for path in [laptop_notes.join("admin/lease.md"), laptop_notes.join("mine.md"), laptop.path("config/config.toml")] {
9606 set(&path, 0o644);
9607 }
9608 set(&laptop_notes.join("admin"), 0o755);
9609 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();
9611 let mut now = NOW;
9612 for _ in 0..3 {
9613 now += MINUTE;
9614 desk.exchange_with(&folder, &key, now, &desk_notes, &["notes"]);
9615 now += MINUTE;
9616 laptop.exchange_with(&folder, &key, now, &laptop_notes, &["notes"]);
9617 }
9618 assert_eq!(std::fs::read(laptop_notes.join("admin/lease.md")).unwrap(), b"Rent: 620\n");
9619 assert!(laptop.read("config/config.toml").contains("language = \"fr\""), "{}", laptop.read("config/config.toml"));
9620 assert_eq!(copies_of(&laptop_notes.join("admin"), "lease").len(), 1, "the laptop's version kept beside");
9621 assert_eq!(mode(&laptop_notes.join("admin/lease.md")), 0o600);
9623 assert_eq!(mode(&laptop.path("config/config.toml")), 0o600);
9624 for (name, _) in files_in(&laptop_notes.join("admin")).into_iter().filter(|(n, _)| n.starts_with("lease (")) {
9625 assert_eq!(mode(&laptop_notes.join("admin").join(&name)), 0o600, "{name}");
9626 }
9627 assert_eq!(mode(&desk_notes.join("mine.md")), 0o600, "brought to the desk");
9628 assert_eq!(mode(&laptop_notes.join("mine.md")), 0o644);
9630 assert_eq!(mode(&desk_notes.join("admin/lease.md")), 0o644);
9631 assert_eq!(mode(&laptop_notes.join("admin")), 0o755, "a folder there already keeps its mode");
9632 let history = crate::history::root(&laptop.memory);
9634 assert!(!crate::history::files(&history, "settings").is_empty(), "a version kept");
9635 for root in [folder.clone(), history.clone()] {
9636 let mut stack = vec![root];
9637 while let Some(dir) = stack.pop() {
9638 assert_eq!(mode(&dir), 0o700, "{}", dir.display());
9639 for entry in std::fs::read_dir(&dir).unwrap().map(Result::unwrap) {
9640 let path = entry.path();
9641 if path.is_dir() {
9642 stack.push(path);
9643 } else {
9644 assert_eq!(mode(&path), 0o600, "{}", path.display());
9645 }
9646 }
9647 }
9648 }
9649 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();
9651 assert!(!rounds.is_empty(), "the desk's records");
9652 for round in &rounds {
9653 set(round, 0o644);
9654 }
9655 desk.write("config/config.toml", "language = \"en\"\n");
9656 desk.exchange_with(&folder, &key, now + MINUTE, &desk_notes, &["notes"]);
9657 let written: Vec<&PathBuf> = rounds.iter().filter(|r| mode(r) == 0o600).collect();
9658 assert_eq!(written.len(), 1, "the round written to, and it alone: {rounds:?}");
9659 let _ = std::fs::remove_dir_all(&base);
9660 }
9661
9662 #[test]
9663 fn earlier_versions_are_kept_and_put_back() {
9664 let base = scratch("history");
9665 let folder = base.join("Sioul");
9666 let key = quick_key(&folder, "four words make a passphrase").unwrap();
9667 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
9668 let (desk_notes, laptop_notes) = (base.join("desk-notes"), base.join("laptop-notes"));
9669 let history = crate::history::root(&laptop.memory);
9670 let mut now = NOW;
9671 let both = |now: &mut i64| {
9672 *now += MINUTE;
9673 desk.exchange_with(&folder, &key, *now, &desk_notes, &["notes"]);
9674 *now += MINUTE;
9675 laptop.exchange_with(&folder, &key, *now, &laptop_notes, &["notes"])
9676 };
9677 desk.write("config/config.toml", "language = \"fr\"\n");
9680 for n in 1..=3 {
9681 put(&desk_notes, "lease.md", format!("version {n}\n").as_bytes());
9682 let at = std::time::UNIX_EPOCH + std::time::Duration::from_nanos(NOW as u64 * 1_000_000 + n * 100);
9683 std::fs::File::options().write(true).open(desk_notes.join("lease.md")).unwrap().set_modified(at).unwrap();
9684 both(&mut now);
9685 }
9686 desk.write("config/config.toml", "language = \"en\"\n");
9687 both(&mut now);
9688 assert_eq!(std::fs::read(laptop_notes.join("lease.md")).unwrap(), b"version 3\n");
9689 let kept = crate::history::versions(&history, "notes", "files/notes/lease.md");
9690 let texts: Vec<String> = kept.iter().map(|v| version_text(&history, "notes", "files/notes/lease.md", &v.stamp, &folder, &key)).collect();
9691 assert_eq!(texts, ["version 2\n", "version 1\n"], "the newest first");
9692 assert_eq!(crate::history::files(&history, "settings").iter().map(|(f, _)| f.as_str()).collect::<Vec<_>>(), ["config/config.toml"]);
9693 let every = laptop.stores_with(&laptop_notes, &|_| true);
9695 put_back(&laptop.memory, Some((&folder, &key)), &every, "notes", "files/notes/lease.md", &kept[1].stamp, now).unwrap();
9696 assert_eq!(std::fs::read(laptop_notes.join("lease.md")).unwrap(), b"version 1\n");
9697 assert_eq!(crate::history::versions(&history, "notes", "files/notes/lease.md").len(), 3);
9698 let setting = crate::history::versions(&history, "settings", "config/config.toml");
9700 put_back(&laptop.memory, Some((&folder, &key)), &every, "settings", "config/config.toml", &setting[0].stamp, now).unwrap();
9701 assert!(laptop.read("config/config.toml").contains("\"fr\""));
9702 assert!(put_back(&laptop.memory, Some((&folder, &key)), &every, "settings", "files/notes/lease.md", &kept[1].stamp, now).is_err());
9704 assert!(put_back(&laptop.memory, Some((&folder, &key)), &every, "notes", "files/notes/lease.md", "2020-01-01T00-00-00.000Z", now).is_err());
9705 now += MINUTE;
9706 assert!(laptop.exchange_with(&folder, &key, now, &laptop_notes, &["notes"]).sent >= 2);
9707 now += MINUTE;
9708 desk.exchange_with(&folder, &key, now, &desk_notes, &["notes"]);
9709 assert_eq!(std::fs::read(desk_notes.join("lease.md")).unwrap(), b"version 1\n", "put back on one, put back on all");
9710 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<_>>());
9711 assert!(desk.read("config/config.toml").contains("\"fr\""));
9712 put(&desk_notes, "other.md", b"other\n");
9715 both(&mut now);
9716 std::fs::remove_file(desk_notes.join("lease.md")).unwrap();
9717 both(&mut now);
9718 assert!(laptop_notes.join("lease.md").exists(), "not before ten minutes");
9719 for _ in 0..6 {
9720 both(&mut now);
9721 }
9722 assert!(!laptop_notes.join("lease.md").exists());
9723 let kept = crate::history::versions(&history, "notes", "files/notes/lease.md");
9724 put_back(&laptop.memory, Some((&folder, &key)), &every, "notes", "files/notes/lease.md", &kept[0].stamp, now).unwrap();
9725 both(&mut now);
9726 both(&mut now);
9727 assert_eq!(std::fs::read(desk_notes.join("lease.md")).unwrap(), b"version 1\n");
9728 let _ = std::fs::remove_dir_all(&base);
9729 }
9730
9731 #[test]
9732 fn a_damaged_blob_or_record_never_erases_the_file_here() {
9733 let base = scratch("damaged");
9734 let folder = base.join("Sioul");
9735 let key = quick_key(&folder, "four words make a passphrase").unwrap();
9736 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
9737 let (desk_notes, laptop_notes) = (base.join("desk-notes"), base.join("laptop-notes"));
9738 put(&desk_notes, "lease.md", b"version 1\n");
9739 desk.exchange_with(&folder, &key, NOW, &desk_notes, &["notes"]);
9740 laptop.exchange_with(&folder, &key, NOW + MINUTE, &laptop_notes, &["notes"]);
9741 put(&desk_notes, "lease.md", b"version 2, longer\n");
9743 desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &desk_notes, &["notes"]);
9744 let sealed = crate::blobs::path(&folder, &key, &crate::blobs::hash_file(&desk_notes.join("lease.md")).unwrap().0);
9745 let mut bytes = std::fs::read(&sealed).unwrap();
9746 let middle = bytes.len() / 2;
9747 bytes[middle] ^= 0x20;
9748 std::fs::write(&sealed, &bytes).unwrap();
9749 for minute in [3, 4] {
9750 let outcome = laptop.exchange_with(&folder, &key, NOW + minute * MINUTE, &laptop_notes, &["notes"]);
9751 assert!(outcome.problems.iter().any(|p| p == "share-damaged:files/notes/lease.md") && outcome.pending == 1, "{outcome:?}");
9752 assert_eq!(std::fs::read(laptop_notes.join("lease.md")).unwrap(), b"version 1\n");
9753 }
9754 put(&desk_notes, "lease.md", b"version 3\n");
9756 desk.exchange_with(&folder, &key, NOW + 5 * MINUTE, &desk_notes, &["notes"]);
9757 let next = crate::blobs::path(&folder, &key, &crate::blobs::hash_file(&desk_notes.join("lease.md")).unwrap().0);
9758 let whole = std::fs::read(&next).unwrap();
9759 std::fs::write(&next, b"").unwrap();
9760 let outcome = laptop.exchange_with(&folder, &key, NOW + 6 * MINUTE, &laptop_notes, &["notes"]);
9761 assert!(outcome.problems.is_empty() && outcome.pending == 1, "{outcome:?}");
9762 assert_eq!(std::fs::read(laptop_notes.join("lease.md")).unwrap(), b"version 1\n");
9763 let last = written(&desk.memory, &desk.id).unwrap();
9765 let clock = ((NOW + 7 * MINUTE) as u64) << 16;
9766 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();
9767 let mut text = std::fs::read_to_string(round_file(&folder, &desk.id, last.0)).unwrap();
9768 text.push_str("{\"n\": 99, \"c\": 1, \"s\": \"broken\"}\n");
9769 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"));
9770 std::fs::write(round_file(&folder, &desk.id, last.0), text).unwrap();
9771 let outcome = laptop.exchange_with(&folder, &key, NOW + 8 * MINUTE, &laptop_notes, &["notes"]);
9772 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:?}");
9773 assert_eq!(std::fs::read(laptop_notes.join("lease.md")).unwrap(), b"version 1\n");
9774 std::fs::write(&next, &whole).unwrap();
9776 put(&desk_notes, "lease.md", b"version 4\n");
9777 desk.exchange_with(&folder, &key, NOW + 9 * MINUTE, &desk_notes, &["notes"]);
9778 laptop.exchange_with(&folder, &key, NOW + 10 * MINUTE, &laptop_notes, &["notes"]);
9779 assert_eq!(std::fs::read(laptop_notes.join("lease.md")).unwrap(), b"version 4\n");
9780 assert!(copies_of(&laptop_notes, "lease").is_empty());
9781 let _ = std::fs::remove_dir_all(&base);
9782 }
9783
9784 #[test]
9785 fn a_notes_folder_found_empty_takes_nothing_out() {
9786 let base = scratch("notes-emptied");
9787 let folder = base.join("Sioul");
9788 let key = quick_key(&folder, "four words make a passphrase").unwrap();
9789 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
9790 let (desk_notes, laptop_notes) = (base.join("desk-notes"), base.join("laptop-notes"));
9791 for n in 0..6 {
9792 put(&desk_notes, &format!("note-{n}.md"), format!("# {n}\n").as_bytes());
9793 }
9794 desk.exchange_with(&folder, &key, NOW, &desk_notes, &["notes"]);
9795 laptop.exchange_with(&folder, &key, NOW + MINUTE, &laptop_notes, &["notes"]);
9796 for n in 0..6 {
9798 std::fs::remove_file(desk_notes.join(format!("note-{n}.md"))).unwrap();
9799 }
9800 let outcome = desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &desk_notes, &["notes"]);
9801 assert!(outcome.problems.iter().any(|p| p == "share-vanished:files/notes/:6") && outcome.sent == 0, "{outcome:?}");
9802 std::fs::remove_dir_all(&desk_notes).unwrap();
9804 assert_eq!(desk.exchange_with(&folder, &key, NOW + 3 * MINUTE, &desk_notes, &["notes"]).sent, 0);
9805 laptop.exchange_with(&folder, &key, NOW + 4 * MINUTE, &laptop_notes, &["notes"]);
9806 assert_eq!(files_in(&laptop_notes).len(), 6);
9807 std::fs::remove_file(laptop_notes.join("note-0.md")).unwrap();
9809 put(&laptop_notes, "note-1.md", b"# 1, changed\n");
9810 laptop.exchange_with(&folder, &key, NOW + 5 * MINUTE, &laptop_notes, &["notes"]);
9811 laptop.exchange_with(&folder, &key, NOW + 16 * MINUTE, &laptop_notes, &["notes"]);
9812 assert!(desk.exchange_with(&folder, &key, NOW + 17 * MINUTE, &desk_notes, &["notes"]).pending > 0);
9813 assert!(!desk_notes.exists());
9814 desk.exchange_with(&folder, &key, NOW + 18 * MINUTE, &desk_notes, &[]);
9816 desk.exchange_with(&folder, &key, NOW + 19 * MINUTE, &desk_notes, &["notes"]);
9817 let files = files_in(&desk_notes);
9818 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"]);
9819 assert_eq!(files["note-1.md"], b"# 1, changed\n");
9820 let _ = std::fs::remove_dir_all(&base);
9821 }
9822
9823 pub(super) fn record_of(key: &[u8; 32], computer: &str, n: u64, clock: u64, change: &Change) -> String {
9825 let plain = serde_json::to_vec(change).unwrap();
9826 serde_json::to_string(&Line { n, c: clock, s: seal(key, &bound(computer, 1, n, clock), &plain) }).unwrap() + "\n"
9827 }
9828
9829 pub(super) fn append_to(path: &Path, text: &str) {
9830 let mut file = std::fs::OpenOptions::new().create(true).append(true).open(path).unwrap();
9831 file.write_all(text.as_bytes()).unwrap();
9832 }
9833
9834 pub(super) fn copy_dir(from: &Path, to: &Path) {
9835 for entry in std::fs::read_dir(from).unwrap().filter_map(Result::ok) {
9836 let target = to.join(entry.file_name());
9837 if entry.path().is_dir() {
9838 std::fs::create_dir_all(&target).unwrap();
9839 copy_dir(&entry.path(), &target);
9840 } else {
9841 std::fs::create_dir_all(to).unwrap();
9842 std::fs::copy(entry.path(), target).unwrap();
9843 }
9844 }
9845 }
9846
9847 fn dated(path: &Path, at: std::time::SystemTime) {
9848 std::fs::File::options().write(true).open(path).unwrap().set_modified(at).unwrap();
9849 }
9850
9851 #[test]
9853 fn a_put_back_takes_out_nothing_added_since() {
9854 let base = scratch("put-back-entries");
9855 let folder = base.join("Sioul");
9856 let key = quick_key(&folder, "four words make a passphrase").unwrap();
9857 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
9858 let mut now = NOW;
9859 let both = |now: &mut i64| {
9860 *now += MINUTE;
9861 desk.exchange(&folder, &key, *now);
9862 *now += MINUTE;
9863 phone.exchange(&folder, &key, *now);
9864 };
9865 desk.write("state/health-state.toml", "[taken]\nd1 = 1\n");
9866 desk.write("config/config.toml", "language = \"fr\"\n");
9867 both(&mut now);
9868 phone.write("state/health-state.toml", "[taken]\nd1 = 1\nd2 = 2\n");
9870 both(&mut now);
9871 phone.write("state/health-state.toml", "[taken]\nd1 = 1\nd2 = 2\nd3 = 3\n");
9872 phone.write("config/config.toml", "language = \"en\"\n\n[[account]]\nid = \"new\"\nkind = \"imap\"\n");
9873 both(&mut now);
9874 both(&mut now);
9875 let history = crate::history::root(&desk.memory);
9876 let doses = crate::history::versions(&history, "health", "state/health-state.toml");
9877 assert_eq!(doses.len(), 2, "{doses:?}");
9878 let every = stores_of(&Config::default(), &desk.roots, &|_| true);
9879 let back = put_back_preview(&desk.memory, &every, "health", "state/health-state.toml", &doses[0].stamp).unwrap();
9881 assert_eq!(back, Back { whole: false, changed: 0, returning: 0, kept: 1 });
9882 put_back(&desk.memory, Some((&folder, &key)), &every, "health", "state/health-state.toml", &doses[0].stamp, now).unwrap();
9883 let settings = crate::history::versions(&history, "settings", "config/config.toml");
9885 let back = put_back_preview(&desk.memory, &every, "settings", "config/config.toml", &settings[0].stamp).unwrap();
9886 assert_eq!(back, Back { whole: false, changed: 1, returning: 0, kept: 1 });
9887 put_back(&desk.memory, Some((&folder, &key)), &every, "settings", "config/config.toml", &settings[0].stamp, now).unwrap();
9888 both(&mut now);
9889 both(&mut now);
9890 for computer in [&desk, &phone] {
9891 let doses = computer.read("state/health-state.toml");
9892 assert!(["d1", "d2", "d3"].iter().all(|d| doses.contains(d)), "a dose marked after that version was taken out: {doses}");
9893 let config = computer.read("config/config.toml");
9894 assert!(config.contains("language = \"fr\"") && config.contains("id = \"new\""), "{config}");
9895 }
9896 let _ = std::fs::remove_dir_all(&base);
9897 }
9898
9899 #[test]
9901 fn a_quick_exchange_leaves_notes_for_later() {
9902 let base = scratch("quick");
9903 let folder = base.join("Sioul");
9904 let key = quick_key(&folder, "four words make a passphrase").unwrap();
9905 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
9906 let (dn, pn) = (base.join("desk-notes"), base.join("phone-notes"));
9907 put(&dn, "a.md", b"# a\n");
9908 desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
9909 phone.exchange_with(&folder, &key, NOW + MINUTE, &pn, &["notes"]);
9910 desk.write("state/health-state.toml", "[taken]\nd1 = 1\n");
9912 put(&dn, "a.md", b"# a, changed\n");
9913 desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &dn, &["notes"]);
9914 let stores = phone.stores_with(&pn, &|part| part == "notes");
9915 let quick = exchange(&Sharing { folder: &folder, computer: &phone.id, key: &key, memory: &phone.memory, files: false, hurry: None }, &stores, NOW + 3 * MINUTE).unwrap();
9916 assert!(phone.read("state/health-state.toml").contains("d1"));
9917 assert_eq!(std::fs::read(pn.join("a.md")).unwrap(), b"# a\n");
9918 assert_eq!(quick.pending, 1, "{quick:?}");
9919 let whole = phone.exchange_with(&folder, &key, NOW + 4 * MINUTE, &pn, &["notes"]);
9921 assert_eq!(std::fs::read(pn.join("a.md")).unwrap(), b"# a, changed\n");
9922 assert_eq!(whole.sent, 0, "{whole:?}");
9923 put(&dn, "a.md", b"# a, again\n");
9925 desk.write("state/health-state.toml", "[taken]\nd1 = 1\nd2 = 2\n");
9926 desk.exchange_with(&folder, &key, NOW + 5 * MINUTE, &dn, &["notes"]);
9927 let hurry = std::sync::atomic::AtomicBool::new(true);
9928 exchange(&Sharing { folder: &folder, computer: &phone.id, key: &key, memory: &phone.memory, files: true, hurry: Some(&hurry) }, &stores, NOW + 6 * MINUTE).unwrap();
9929 assert!(phone.read("state/health-state.toml").contains("d2"));
9930 assert_eq!(std::fs::read(pn.join("a.md")).unwrap(), b"# a, changed\n");
9931 hurry.store(false, std::sync::atomic::Ordering::Relaxed);
9932 exchange(&Sharing { folder: &folder, computer: &phone.id, key: &key, memory: &phone.memory, files: true, hurry: Some(&hurry) }, &stores, NOW + 7 * MINUTE).unwrap();
9933 assert_eq!(std::fs::read(pn.join("a.md")).unwrap(), b"# a, again\n");
9934 let _ = std::fs::remove_dir_all(&base);
9935 }
9936
9937 #[test]
9939 fn notes_gone_at_once_wait_for_a_word() {
9940 let base = scratch("vanished");
9941 let folder = base.join("Sioul");
9942 let key = quick_key(&folder, "four words make a passphrase").unwrap();
9943 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
9944 let (dn, ln) = (base.join("desk-notes"), base.join("laptop-notes"));
9945 put(&dn, "today.md", b"# Today\n");
9946 for n in 0..20 {
9947 put(&dn, &format!("archive/{n}.md"), format!("# {n}\n").as_bytes());
9948 }
9949 desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
9950 laptop.exchange_with(&folder, &key, NOW + MINUTE, &ln, &["notes"]);
9951 std::fs::remove_dir_all(dn.join("archive")).unwrap();
9953 std::fs::create_dir(dn.join("archive")).unwrap();
9954 let outcome = desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &dn, &["notes"]);
9955 assert!(outcome.problems.iter().any(|p| p == "share-vanished:files/notes/archive/:20") && outcome.sent == 0, "{outcome:?}");
9956 laptop.exchange_with(&folder, &key, NOW + 3 * MINUTE, &ln, &["notes"]);
9957 assert_eq!(files_in(&ln).len(), 21);
9958 std::fs::remove_dir(dn.join("archive")).unwrap();
9960 for minute in [4, 20] {
9961 let outcome = desk.exchange_with(&folder, &key, NOW + minute * MINUTE, &dn, &["notes"]);
9962 assert!(outcome.problems.iter().any(|p| p == "share-vanished:files/notes/:20") && outcome.sent == 0, "{outcome:?}");
9963 }
9964 laptop.exchange_with(&folder, &key, NOW + 21 * MINUTE, &ln, &["notes"]);
9965 assert_eq!(files_in(&ln).len(), 21);
9966 #[cfg(unix)]
9968 {
9969 use std::os::unix::fs::PermissionsExt;
9970 put(&dn, "locked/x.md", b"# x\n");
9971 desk.exchange_with(&folder, &key, NOW + 22 * MINUTE, &dn, &["notes"]);
9972 std::fs::set_permissions(dn.join("locked"), std::fs::Permissions::from_mode(0o000)).unwrap();
9973 if std::fs::read_dir(dn.join("locked")).is_err() {
9974 for minute in [23, 40] {
9975 desk.exchange_with(&folder, &key, NOW + minute * MINUTE, &dn, &["notes"]);
9976 }
9977 laptop.exchange_with(&folder, &key, NOW + 41 * MINUTE, &ln, &["notes"]);
9978 assert!(ln.join("locked/x.md").exists());
9979 }
9980 std::fs::set_permissions(dn.join("locked"), std::fs::Permissions::from_mode(0o755)).unwrap();
9981 }
9982 confirm_gone(&desk.memory, &desk.id, "files/notes/").unwrap();
9984 let outcome = desk.exchange_with(&folder, &key, NOW + 42 * MINUTE, &dn, &["notes"]);
9985 assert!(outcome.sent >= 20, "{outcome:?}");
9986 laptop.exchange_with(&folder, &key, NOW + 43 * MINUTE, &ln, &["notes"]);
9987 let left: Vec<String> = files_in(&ln).into_keys().collect();
9988 assert!(left.iter().all(|f| !f.starts_with("archive/")) && left.contains(&"today.md".to_string()), "{left:?}");
9989 assert!(!ln.join("archive").exists(), "its folder, left empty, goes too");
9990 let _ = std::fs::remove_dir_all(&base);
9991 }
9992
9993 #[test]
9995 fn no_room_holds_a_file_back_and_tries_again_later() {
9996 let base = scratch("no-room");
9997 let folder = base.join("Sioul");
9998 let key = quick_key(&folder, "four words make a passphrase").unwrap();
9999 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
10000 let (dn, ln) = (base.join("desk-notes"), base.join("laptop-notes"));
10001 put(&dn, "scan.pdf", &noise(100_000, 1));
10002 put(&dn, "other.md", b"other\n");
10003 desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
10004 laptop.exchange_with(&folder, &key, NOW + MINUTE, &ln, &["notes"]);
10005 put(&dn, "scan.pdf", &noise(100_000, 2));
10006 desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &dn, &["notes"]);
10007 crate::disk::ROOM.with(|room| room.set(Some(1000)));
10009 let outcome = laptop.exchange_with(&folder, &key, NOW + 3 * MINUTE, &ln, &["notes"]);
10010 assert!(outcome.problems.iter().any(|p| p == "share-no-room:files/notes/scan.pdf") && outcome.pending == 1, "{outcome:?}");
10011 let outcome = laptop.exchange_with(&folder, &key, NOW + 3 * MINUTE + 30_000, &ln, &["notes"]);
10012 assert!(outcome.problems.is_empty(), "not opened again within the minute: {outcome:?}");
10013 assert_eq!(std::fs::read(ln.join("scan.pdf")).unwrap(), noise(100_000, 1));
10014 crate::disk::ROOM.with(|room| room.set(Some(u64::MAX)));
10015 laptop.exchange_with(&folder, &key, NOW + 5 * MINUTE, &ln, &["notes"]);
10016 assert_eq!(std::fs::read(ln.join("scan.pdf")).unwrap(), noise(100_000, 2));
10017 let kept = crate::history::versions(&crate::history::root(&laptop.memory), "notes", "files/notes/scan.pdf");
10019 assert!(kept.len() == 1 && kept[0].sealed.is_some(), "{kept:?}");
10020 assert_eq!(version_text_bytes(&crate::history::root(&laptop.memory), "notes", "files/notes/scan.pdf", &kept[0].stamp, &folder, &key), noise(100_000, 1));
10021 let _ = std::fs::remove_dir_all(&base);
10022 }
10023
10024 fn version_text_bytes(history: &Path, part: &str, file: &str, stamp: &str, folder: &Path, key: &[u8; 32]) -> Vec<u8> {
10025 let Some(crate::history::Kept::Sealed { hash, .. }) = crate::history::version(history, part, file, stamp) else { panic!("{file}: not by reference") };
10026 let target = temporary(&history.join("opened"));
10027 crate::blobs::get(folder, key, &hash, &target).unwrap();
10028 let bytes = std::fs::read(&target).unwrap();
10029 std::fs::remove_file(target).unwrap();
10030 bytes
10031 }
10032
10033 #[test]
10035 fn huge_or_endless_files_in_the_folder_are_never_read_whole() {
10036 let base = scratch("huge");
10037 let folder = base.join("Sioul");
10038 let key = quick_key(&folder, "four words make a passphrase").unwrap();
10039 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
10040 desk.write("config/safe-senders.txt", "a@example.org\n");
10041 desk.exchange(&folder, &key, NOW);
10042 let stranger = uuid::Uuid::new_v4().to_string();
10044 {
10045 let mut file = std::fs::File::create(round_file(&folder, &stranger, 1)).unwrap();
10046 let piece = vec![b'x'; 1 << 20];
10047 for _ in 0..40 {
10048 file.write_all(&piece).unwrap();
10049 }
10050 file.write_all(b"\n").unwrap();
10051 }
10052 let clock = ((NOW + MINUTE) as u64) << 16;
10053 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 }));
10054 std::fs::write(seen_path(&folder, &stranger), "x".repeat(100_000)).unwrap();
10055 let outcome = laptop.exchange(&folder, &key, NOW + 2 * MINUTE);
10056 assert!(outcome.problems.iter().any(|p| *p == format!("share-other-line:{stranger}")), "{outcome:?}");
10057 let safe = laptop.read("config/safe-senders.txt");
10058 assert!(safe.contains("a@example.org") && safe.contains("b@example.org"), "{safe}");
10059 assert!(others(&folder, &laptop.id).iter().any(|o| o.id == stranger && o.heard == 0), "its notes, too long, not read");
10060 let own = round_file(&folder, &desk.id, 1);
10062 let whole = std::fs::metadata(&own).unwrap().len();
10063 append_to(&own, "{\"n\": 99, \"c\": 1, \"s\": \"half");
10064 assert_eq!(unfinished(&own), Some(whole));
10065 desk.write("config/safe-senders.txt", "a@example.org\nc@example.org\n");
10066 desk.exchange(&folder, &key, NOW + 3 * MINUTE);
10067 assert!(std::fs::read_to_string(&own).unwrap().lines().all(|l| l.ends_with('}')));
10068 let _ = std::fs::remove_dir_all(&base);
10069 }
10070
10071 #[test]
10073 fn an_edit_made_while_receiving_is_never_lost() {
10074 let base = scratch("while-receiving");
10075 let folder = base.join("Sioul");
10076 let key = quick_key(&folder, "four words make a passphrase").unwrap();
10077 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
10078 let (dn, ln) = (base.join("desk-notes"), base.join("laptop-notes"));
10079 for name in ["a.md", "b.md", "lease.md"] {
10080 put(&dn, name, format!("{name}: one\n").as_bytes());
10081 }
10082 desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
10083 laptop.exchange_with(&folder, &key, NOW + MINUTE, &ln, &["notes"]);
10084 for name in ["a.md", "b.md", "lease.md"] {
10085 put(&dn, name, format!("{name}: two, from the desk\n").as_bytes());
10086 }
10087 desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &dn, &["notes"]);
10088 let (lease, first) = (ln.join("lease.md"), ln.join("a.md"));
10091 BEFORE_WRITING.with(|hook| {
10092 *hook.borrow_mut() = Some(Box::new(move |path: &Path| {
10093 if path.ends_with("lease.md") {
10094 std::fs::write(&lease, "lease.md: edited on the laptop\n").unwrap();
10095 }
10096 if path.ends_with("b.md") {
10097 std::fs::write(&first, "a.md: edited on the laptop\n").unwrap();
10098 }
10099 }))
10100 });
10101 laptop.exchange_with(&folder, &key, NOW + 3 * MINUTE, &ln, &["notes"]);
10102 BEFORE_WRITING.with(|hook| *hook.borrow_mut() = None);
10103 assert_eq!(std::fs::read_to_string(ln.join("lease.md")).unwrap(), "lease.md: edited on the laptop\n", "not written over");
10104 for minute in 4..10 {
10105 if minute % 2 == 0 {
10106 laptop.exchange_with(&folder, &key, NOW + minute * MINUTE, &ln, &["notes"]);
10107 } else {
10108 desk.exchange_with(&folder, &key, NOW + minute * MINUTE, &dn, &["notes"]);
10109 }
10110 }
10111 for notes in [&dn, &ln] {
10114 let all: Vec<String> = files_in(notes).into_values().map(|b| String::from_utf8(b).unwrap()).collect();
10115 for text in ["lease.md: edited on the laptop\n", "lease.md: two, from the desk\n", "a.md: edited on the laptop\n"] {
10116 assert!(all.iter().any(|t| t == text), "{}: {text:?} lost: {all:?}", notes.display());
10117 }
10118 assert_eq!(std::fs::read_to_string(notes.join("a.md")).unwrap(), "a.md: edited on the laptop\n");
10119 }
10120 let _ = std::fs::remove_dir_all(&base);
10121 }
10122
10123 #[test]
10125 fn a_part_switched_off_keeps_nothing_waiting() {
10126 let base = scratch("off-pending");
10127 let folder = base.join("Sioul");
10128 let key = quick_key(&folder, "four words make a passphrase").unwrap();
10129 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
10130 let off = stores_of(&Config::default(), &phone.roots, &|part| part != "drafts");
10131 for n in 0..20 {
10132 desk.write(&format!("data/drafts/{n}.eml"), &"x".repeat(200_000));
10133 desk.exchange(&folder, &key, NOW + 2 * n * MINUTE);
10134 exchange(&Sharing { folder: &folder, computer: &phone.id, key: &key, memory: &phone.memory, files: true, hurry: None }, &off, NOW + (2 * n + 1) * MINUTE).unwrap();
10135 }
10136 assert!(std::fs::metadata(&phone.memory).unwrap().len() < 200_000, "memory.json holds the drafts of a part switched off");
10137 phone.exchange(&folder, &key, NOW + 41 * MINUTE);
10139 assert_eq!(phone.read("data/drafts/19.eml").len(), 200_000);
10140 let _ = std::fs::remove_dir_all(&base);
10141 }
10142
10143 #[test]
10148 fn an_older_sioul_s_watch_is_left_aside() {
10149 let base = scratch("watch-older");
10150 let folder = base.join("Sioul");
10151 let key = quick_key(&folder, "four words make a passphrase").unwrap();
10152 let (older, desk) = (Computer::new(&base, "older"), Computer::new(&base, "desk"));
10153 let mut with_watch = stores(&Config::default(), &older.roots);
10155 with_watch.push(Store { name: "data/watch/".into(), path: older.roots.data.join("watch"), folder: true, shape: Shape::Whole, skip: &["imported.json"], part: "watch" });
10156 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" });
10157 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();
10158 older.write("data/watch/2026-10-07.json", "{\"steps\": 4200}");
10159 older.write("state/watch-offers.json", "{\"declined\": {}}");
10160 older.write("config/safe-senders.txt", "friend@example.org\n");
10161 let sent = older_exchange(NOW);
10162 assert!(sent.problems.is_empty() && sent.sent >= 3, "{sent:?}");
10163 for n in 1..=2 {
10164 let came = desk.exchange(&folder, &key, NOW + n * MINUTE);
10165 assert!(came.problems.is_empty() && came.pending == 0, "{came:?}");
10166 }
10167 assert_eq!(desk.read("config/safe-senders.txt"), "friend@example.org\n", "the rest came in");
10168 assert!(!desk.path("data/watch").exists() && !desk.path("state/watch-offers.json").exists(), "nothing of the watch written");
10169 let memory = std::fs::read_to_string(&desk.memory).unwrap();
10171 assert!(!memory.contains("data/watch/") && !memory.contains("watch-offers"), "nothing of the watch kept waiting");
10172 older.write("data/watch/2026-10-08.json", "{\"steps\": 900}");
10174 older_exchange(NOW + 3 * MINUTE);
10175 desk.write("config/safe-senders.txt", "friend@example.org\nneighbour@example.org\n");
10176 let came = desk.exchange(&folder, &key, NOW + 4 * MINUTE);
10177 assert!(came.problems.is_empty() && came.pending == 0 && came.sent >= 1, "{came:?}");
10178 let back = older_exchange(NOW + 5 * MINUTE);
10179 assert!(back.problems.is_empty() && back.received >= 1, "{back:?}");
10180 assert_eq!(older.read("config/safe-senders.txt"), "friend@example.org\nneighbour@example.org\n");
10181 assert_eq!(older.read("data/watch/2026-10-07.json"), "{\"steps\": 4200}", "its own files as they were");
10182 assert!(!desk.path("data/watch").exists());
10183 let _ = std::fs::remove_dir_all(&base);
10184 }
10185
10186 #[cfg(unix)]
10188 #[test]
10189 fn nothing_is_written_through_a_link_nor_into_the_sharing_folder() {
10190 let base = scratch("through-link");
10191 let notes = base.join("desk-notes");
10192 let folder = notes.join("sioul-shared");
10194 let key = quick_key(&folder, "four words make a passphrase").unwrap();
10195 let desk = Computer::new(&base, "desk");
10196 let outside = base.join("outside");
10197 put(¬es, "lease.md", b"x\n");
10198 std::fs::create_dir_all(&outside).unwrap();
10199 std::os::unix::fs::symlink(&outside, notes.join("link")).unwrap();
10200 desk.exchange_with(&folder, &key, NOW, ¬es, &["notes"]);
10201 let source = base.join("evil");
10203 std::fs::write(&source, b"[Desktop Entry]\nExec=sh -c id\n").unwrap();
10204 let (h, s) = crate::blobs::hash_file(&source).unwrap();
10205 crate::blobs::put(&folder, &key, &source, &h).unwrap();
10206 let other = uuid::Uuid::new_v4().to_string();
10207 let value = reference(&h, s, 0);
10208 let mut text = String::new();
10209 for (n, file) in [(1, "files/notes/link/evil.desktop#"), (2, "files/notes/sioul-shared/plain.md#")] {
10210 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 });
10211 }
10212 std::fs::write(round_file(&folder, &other, 1), text).unwrap();
10213 let outcome = desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, ¬es, &["notes"]);
10214 assert!(!outside.join("evil.desktop").exists(), "written through the link");
10215 assert!(!folder.join("plain.md").exists(), "written in plain into the sharing folder");
10216 assert!(outcome.problems.iter().filter(|p| p.starts_with("share-refused:")).count() == 2, "{outcome:?}");
10217 let _ = std::fs::remove_dir_all(&base);
10218 }
10219
10220 #[test]
10222 fn a_content_used_again_is_never_lost_to_a_sweep() {
10223 let base = scratch("sweep-race");
10224 let (server, phone_folder) = (base.join("server").join("Sioul"), base.join("phone").join("Sioul"));
10225 let key = quick_key(&server, "four words make a passphrase").unwrap();
10226 let (desk, phone, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"), Computer::new(&base, "laptop"));
10227 let (dn, pn, ln) = (base.join("desk-notes"), base.join("phone-notes"), base.join("laptop-notes"));
10228 let day = 86_400_000;
10229 put(&dn, "a.md", b"template\n");
10230 put(&dn, "keep.md", b"keep\n");
10231 desk.exchange_with(&server, &key, NOW, &dn, &["notes"]);
10232 put(&dn, "a.md", b"changed\n");
10233 desk.exchange_with(&server, &key, NOW + MINUTE, &dn, &["notes"]);
10234 std::fs::write(base.join("template"), b"template\n").unwrap();
10236 let sealed = crate::blobs::path(&server, &key, &crate::blobs::hash_file(&base.join("template")).unwrap().0);
10237 dated(&sealed, std::time::SystemTime::now() - std::time::Duration::from_secs(100 * 86_400));
10238 copy_dir(&server, &phone_folder);
10239 put(&pn, "b.md", b"template\n");
10241 phone.exchange_with(&phone_folder, &key, NOW + 91 * day, &pn, &["notes"]);
10242 desk.exchange_with(&server, &key, NOW + 91 * day + MINUTE, &dn, &["notes"]);
10244 assert!(!sealed.exists(), "swept, as unused for 90 days by what the desk knew");
10245 for entry in std::fs::read_dir(&phone_folder).unwrap().filter_map(Result::ok) {
10247 if entry.file_name().to_string_lossy().ends_with(".jsonl") {
10248 std::fs::copy(entry.path(), server.join(entry.file_name())).unwrap();
10249 }
10250 }
10251 let _ = std::fs::remove_file(phone_folder.join("blobs").join(sealed.file_name().unwrap()));
10252 laptop.exchange_with(&server, &key, NOW + 91 * day + 2 * MINUTE, &ln, &["notes"]);
10253 assert!(!ln.join("b.md").exists());
10254 let outcome = laptop.exchange_with(&server, &key, NOW + 92 * day + 3 * MINUTE, &ln, &["notes"]);
10256 assert!(outcome.problems.iter().any(|p| p == "share-missing:files/notes/b.md"), "{outcome:?}");
10257 phone.exchange_with(&phone_folder, &key, NOW + 92 * day + 4 * MINUTE, &pn, &["notes"]);
10259 assert!(phone_folder.join("blobs").join(sealed.file_name().unwrap()).exists());
10260 copy_dir(&phone_folder.join("blobs"), &server.join("blobs"));
10261 laptop.exchange_with(&server, &key, NOW + 92 * day + 5 * MINUTE, &ln, &["notes"]);
10262 assert_eq!(std::fs::read(ln.join("b.md")).ok().as_deref(), Some(&b"template\n"[..]));
10263 let _ = std::fs::remove_dir_all(&base);
10264 }
10265
10266 #[test]
10268 fn one_exchange_at_a_time() {
10269 let base = scratch("one-at-a-time");
10270 let folder = base.join("Sioul");
10271 let key = quick_key(&folder, "four words make a passphrase").unwrap();
10272 let desk = Computer::new(&base, "desk");
10273 desk.write("config/safe-senders.txt", "a@example.org\n");
10274 let running = exchange_lock(&desk.memory, true).unwrap();
10276 let outcome = desk.exchange(&folder, &key, NOW);
10277 assert_eq!((outcome.problems.as_slice(), outcome.sent), (&["share-busy".to_string()][..], 0));
10278 drop(running);
10279 assert_eq!(desk.exchange(&folder, &key, NOW + MINUTE).sent, 1);
10280 let memory = desk.memory.clone();
10283 let (held, release) = std::sync::mpsc::channel();
10284 let holder = std::thread::spawn(move || {
10285 let running = exchange_lock(&memory, true).unwrap();
10286 held.send(()).unwrap();
10287 std::thread::sleep(std::time::Duration::from_millis(150));
10288 drop(running);
10289 });
10290 release.recv().unwrap();
10291 desk.write("config/safe-senders.txt", "a@example.org\nb@example.org\n");
10292 let outcome = desk.exchange(&folder, &key, NOW + 2 * MINUTE);
10293 assert_eq!((outcome.problems.len(), outcome.sent), (0, 1), "{outcome:?}");
10294 holder.join().unwrap();
10295 let path = folder.join("x.toml");
10297 let (one, two) = (temporary(&path), temporary(&path));
10298 assert!(one != two && one.file_name().unwrap().to_string_lossy().starts_with('.'));
10299 let _ = std::fs::remove_dir_all(&base);
10300 }
10301
10302 #[test]
10304 fn names_one_storage_takes_for_one_are_never_both_written() {
10305 let known = |file: &str| (format!("{file}#"), Known { c: 1, w: "desk".into(), h: "h".into(), b: String::new() });
10308 let names = Names::new(&[known("files/notes/Lease.md"), known("files/notes/\u{e9}t\u{e9}.md")].into(), &Found::default());
10309 assert!(names.clash("files/notes/lease.md") && names.clash("files/notes/LEASE.md") && names.clash("files/notes/e\u{301}te\u{301}.md"));
10310 assert!(!names.clash("files/notes/Lease.md") && !names.clash("files/notes/\u{e9}t\u{e9}.md") && !names.clash("files/notes/other.md"));
10311 let base = scratch("case");
10315 let folder = base.join("Sioul");
10316 let key = quick_key(&folder, "four words make a passphrase").unwrap();
10317 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
10318 let (dn, ln) = (base.join("desk-notes"), base.join("laptop-notes"));
10319 let apart = |one: &str, other: &str| {
10320 let probe = base.join("probe");
10321 std::fs::create_dir_all(&probe).unwrap();
10322 std::fs::write(probe.join(one), b"").unwrap();
10323 let apart = !probe.join(other).exists();
10324 std::fs::remove_dir_all(&probe).unwrap();
10325 apart
10326 };
10327 put(&dn, "other.md", b"other\n");
10328 let mut pairs = 0;
10329 for (one, other) in [("Lease.md", "lease.md"), ("\u{e9}t\u{e9}.md", "e\u{301}te\u{301}.md")] {
10330 if apart(one, other) {
10331 put(&dn, one, b"one\n");
10332 put(&dn, other, b"other way\n");
10333 pairs += 1;
10334 }
10335 }
10336 desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
10337 let outcome = laptop.exchange_with(&folder, &key, NOW + MINUTE, &ln, &["notes"]);
10338 assert_eq!(outcome.problems.iter().filter(|p| p.starts_with("share-name-clash:")).count(), pairs, "{outcome:?}");
10339 assert_eq!(files_in(&ln).len(), 1 + pairs);
10340 let config = Config { notes_root: Some("/notes".into()), ..Config::default() };
10342 let every = stores_of(&config, &laptop.roots, &|_| true);
10343 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());
10344 assert_eq!(kept_by_windows("aux.md"), true);
10345 assert_eq!(kept_by_windows("auxiliary.md"), false);
10346 let _ = std::fs::remove_dir_all(&base);
10347 }
10348
10349 #[test]
10351 fn a_name_with_a_hash_travels() {
10352 let base = scratch("hash-name");
10353 let folder = base.join("Sioul");
10354 let key = quick_key(&folder, "four words make a passphrase").unwrap();
10355 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
10356 let (dn, pn) = (base.join("desk-notes"), base.join("phone-notes"));
10357 put(&dn, "Issue #12.md", b"# Issue 12\n");
10358 put(&dn, "#ideas.md", b"# Ideas\n");
10359 put(&dn, "papers/Facture #123.pdf", &noise(10_000, 3));
10360 desk.write("data/drafts/a#1.toml", "subject = \"Hash\"\n");
10361 for m in 0..3 {
10362 desk.exchange_with(&folder, &key, NOW + 2 * m * MINUTE, &dn, &["notes", "papers"]);
10363 phone.exchange_with(&folder, &key, NOW + (2 * m + 1) * MINUTE, &pn, &["notes", "papers"]);
10364 }
10365 let files = files_in(&pn);
10366 assert_eq!(files.get("Issue #12.md").map(Vec::as_slice), Some(&b"# Issue 12\n"[..]), "{:?}", files.keys().collect::<Vec<_>>());
10367 assert!(files.contains_key("#ideas.md") && files.get("papers/Facture #123.pdf") == Some(&noise(10_000, 3)));
10368 assert_eq!(phone.read("data/drafts/a#1.toml"), "subject = \"Hash\"\n");
10369 assert_eq!(file_of("files/papers/Facture #123.pdf#"), "files/papers/Facture #123.pdf");
10370 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"));
10371 let _ = std::fs::remove_dir_all(&base);
10372 }
10373
10374 #[test]
10376 fn an_old_copy_does_not_roll_the_others_back() {
10377 let base = scratch("old-copy");
10378 let folder = base.join("Sioul");
10379 let key = quick_key(&folder, "four words make a passphrase").unwrap();
10380 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
10381 let (dn, ln) = (base.join("desk-notes"), base.join("laptop-notes"));
10382 let old = std::time::UNIX_EPOCH + std::time::Duration::from_millis(NOW as u64);
10383 put(&dn, "lease.md", b"Rent: 620\n");
10384 put(&dn, "other.md", b"other\n");
10385 dated(&dn.join("lease.md"), old);
10386 desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
10387 laptop.exchange_with(&folder, &key, NOW + MINUTE, &ln, &["notes"]);
10388 put(&ln, "lease.md", b"Rent: 640\n");
10389 laptop.exchange_with(&folder, &key, NOW + 86_400_000, &ln, &["notes"]);
10390 desk.exchange_with(&folder, &key, NOW + 86_400_000 + MINUTE, &dn, &["notes"]);
10391 put(&dn, "lease.md", b"Rent: 620\n");
10393 dated(&dn.join("lease.md"), old);
10394 let outcome = desk.exchange_with(&folder, &key, NOW + 86_400_000 + 2 * MINUTE, &dn, &["notes"]);
10395 assert!(outcome.problems.iter().any(|p| p.starts_with("share-older-copy:lease (conflict ")), "{outcome:?}");
10396 assert_eq!(std::fs::read(dn.join("lease.md")).unwrap(), b"Rent: 640\n", "the version last seen came back");
10397 desk.exchange_with(&folder, &key, NOW + 86_400_000 + 3 * MINUTE, &dn, &["notes"]);
10398 laptop.exchange_with(&folder, &key, NOW + 86_400_000 + 4 * MINUTE, &ln, &["notes"]);
10399 assert_eq!(std::fs::read(ln.join("lease.md")).unwrap(), b"Rent: 640\n");
10400 assert_eq!(copies_of(&ln, "lease"), [b"Rent: 620\n".to_vec()], "the older copy kept beside, everywhere");
10401 let _ = std::fs::remove_dir_all(&base);
10402 }
10403
10404 #[test]
10406 fn a_file_being_written_waits() {
10407 let base = scratch("settling");
10408 let folder = base.join("Sioul");
10409 let key = quick_key(&folder, "four words make a passphrase").unwrap();
10410 let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
10411 let (dn, ln) = (base.join("desk-notes"), base.join("laptop-notes"));
10412 put(&dn, "keep.md", b"keep\n");
10413 dated(&dn.join("keep.md"), std::time::SystemTime::now() - std::time::Duration::from_secs(120));
10414 SETTLE.with(|settle| settle.set(60_000));
10416 put(&dn, "a.md", b"# a\n");
10417 assert_eq!(desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]).sent, 1);
10418 dated(&dn.join("a.md"), std::time::SystemTime::now() - std::time::Duration::from_secs(120));
10419 assert_eq!(desk.exchange_with(&folder, &key, NOW + MINUTE, &dn, &["notes"]).sent, 1);
10420 SETTLE.with(|settle| settle.set(0));
10421 laptop.exchange_with(&folder, &key, NOW + 2 * MINUTE, &ln, &["notes"]);
10422 std::fs::write(dn.join("a.md"), b"").unwrap();
10424 assert_eq!(desk.exchange_with(&folder, &key, NOW + 3 * MINUTE, &dn, &["notes"]).sent, 0);
10425 laptop.exchange_with(&folder, &key, NOW + 4 * MINUTE, &ln, &["notes"]);
10426 assert_eq!(std::fs::read(ln.join("a.md")).unwrap(), b"# a\n");
10427 put(&dn, "a.md", b"# a, written\n");
10428 assert_eq!(desk.exchange_with(&folder, &key, NOW + 5 * MINUTE, &dn, &["notes"]).sent, 1);
10429 let _ = std::fs::remove_dir_all(&base);
10430 }
10431
10432 #[test]
10434 fn the_doses_never_read_the_notes_memory() {
10435 let base = scratch("memory-apart");
10436 let folder = base.join("Sioul");
10437 let key = quick_key(&folder, "four words make a passphrase").unwrap();
10438 let desk = Computer::new(&base, "desk");
10439 let dn = base.join("desk-notes");
10440 for n in 0..50 {
10441 put(&dn, &format!("note-{n}.md"), format!("# {n}\n").as_bytes());
10442 }
10443 desk.write("state/health-state.toml", "[taken]\nd1 = 1\n");
10444 desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
10445 let memory = std::fs::read_to_string(&desk.memory).unwrap();
10446 assert!(!memory.contains("files/notes/note-") && memory.contains("health-state"), "{memory}");
10447 assert!(std::fs::read_to_string(sealed_path(&desk.memory)).unwrap().contains("files/notes/note-7.md"));
10448 assert!(written(&desk.memory, &desk.id).is_some_and(|(_, n)| n > 50));
10449 std::fs::remove_file(sealed_path(&desk.memory)).unwrap();
10451 let outcome = desk.exchange_with(&folder, &key, NOW + MINUTE, &dn, &["notes"]);
10452 assert_eq!(outcome.sent, 0, "{outcome:?}");
10453 assert_eq!(files_in(&dn).len(), 50);
10454 let _ = std::fs::remove_dir_all(&base);
10455 }
10456
10457 #[test]
10459 fn what_is_said_and_left_behind() {
10460 let at = NOW;
10462 assert_eq!(conflict_name("lease", at), conflict_name("lease", at));
10463 assert!(conflict_name("lease", at).starts_with("lease (conflict 20"));
10464 let base = scratch("left-behind");
10465 let folder = base.join("Sioul");
10466 let key = quick_key(&folder, "four words make a passphrase").unwrap();
10467 let desk = Computer::new(&base, "desk");
10468 let dn = base.join("desk-notes");
10469 put(&dn, "lease.md", b"lease\n");
10470 put(&dn, ".lease.md.1-1.sioul.tmp", b"half");
10472 dated(&dn.join(".lease.md.1-1.sioul.tmp"), std::time::SystemTime::now() - std::time::Duration::from_secs(7200));
10473 put(&dn, ".lease.md.1-2.sioul.tmp", b"being written");
10474 #[cfg(unix)]
10476 let latin = {
10477 use std::os::unix::ffi::OsStrExt;
10478 std::fs::write(dn.join(std::ffi::OsStr::from_bytes(b"caf\xe9.md")), b"latin-1").is_ok()
10479 };
10480 #[cfg(not(unix))]
10481 let latin = false;
10482 let outcome = desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
10483 assert!(!dn.join(".lease.md.1-1.sioul.tmp").exists() && dn.join(".lease.md.1-2.sioul.tmp").exists());
10484 assert_eq!(outcome.problems.iter().any(|p| p.starts_with("share-not-text:files/notes/caf")), latin, "{outcome:?}");
10485 assert_eq!(outcome.sent, 1, "{outcome:?}");
10486 let _ = std::fs::remove_dir_all(&base);
10487 }
10488
10489 #[test]
10490 fn each_device_chooses_its_parts() {
10491 let roots = Roots { config: PathBuf::from("config"), data: PathBuf::from("data"), state: PathBuf::from("state") };
10492 let config = Config { notes_root: Some("/notes".into()), ..Config::default() };
10493 let parts = |stores: &[Store]| stores.iter().map(|s| s.part).collect::<BTreeSet<_>>();
10494 assert_eq!(parts(&stores(&config, &roots)), ["calls", "drafts", "health", "lists", "senders", "settings", "spam", "time"].into());
10496 let old = Config { share_projects: true, ..config.clone() };
10497 assert!(parts(&stores(&old, &roots)).contains("projects"), "projects as the old setting said");
10498 let mut here = Here::default();
10500 here.parts.insert("notes".into(), true);
10501 here.parts.insert("health".into(), false);
10502 here.parts.insert("projects".into(), false);
10503 let chosen = stores_of(&old, &roots, &|part| here.shares(part, &old));
10504 assert!(chosen.iter().any(|s| s.name == "files/notes/") && !chosen.iter().any(|s| s.part == "health" || s.part == "projects"));
10505 let notes = chosen.iter().find(|s| s.name == "files/notes/").unwrap();
10506 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");
10507 let back: Here = toml::from_str(&toml::to_string(&here).unwrap()).unwrap();
10508 assert_eq!(back.parts, here.parts);
10509 assert!(stores_of(&config, &roots, &|_| true).iter().all(|s| s.name != "state/share/here.toml"));
10510 let every = stores_of(&config, &roots, &|_| true);
10512 assert!(locate(&every, "files/notes/admin/lease.md").is_some());
10513 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"] {
10514 assert!(locate(&every, file).is_none(), "{file}");
10515 }
10516 assert_eq!(locate(&every, "files/papers/id.pdf").map(|(s, _)| s.part), Some("papers"));
10517 assert_eq!(shown("files/notes/admin/lease.md"), "admin/lease.md");
10518 assert_eq!(shown("files/papers/id.pdf"), "papers/id.pdf");
10519 assert_eq!(shown("config/config.toml"), "config.toml");
10520 }
10521
10522 #[test]
10527 fn the_spam_table_travels_alone_in_every_exchange() {
10528 use sioul_core::spam::features::{FEATURES, N};
10529 use sioul_core::spam::table::{self, Table};
10530 let base = scratch("spam-table");
10531 let folder = base.join("folder");
10532 let key = quick_key(&folder, "four words make a passphrase").unwrap();
10533 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
10534 let made = |bias: f32| {
10535 let mut made = Table {
10536 tokenizer: sioul_core::spam::tokenize::TOKENIZER,
10537 features: FEATURES,
10538 dim: 2,
10539 minn: 3,
10540 maxn: 6,
10541 bucket: 4,
10542 words: vec![(table::word_hash("lotteri"), 2.0)],
10543 buckets: vec![0.0; 4],
10544 weights: vec![0.0; N],
10545 means: vec![0.0; N],
10546 bias,
10547 text_mean: 0.0,
10548 platt_a: -1.0,
10549 platt_b: 0.0,
10550 meta: table::Meta { device: "desk".into(), ..table::Meta::default() },
10551 };
10552 made.sort();
10553 made
10554 };
10555 let trained = |bias: f32, second: u64| {
10557 let path = desk.path("data/spam/table.bin");
10558 made(bias).write(&path).unwrap();
10559 std::fs::File::options().write(true).open(&path).unwrap().set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(second)).unwrap();
10560 };
10561 trained(0.5, 1_789_990_000);
10563 desk.write("data/spam/language.bin", "the vocabulary in plain text: Jane, the lease, the lottery");
10564 desk.write("data/spam/corpus/home/INBOX.jsonl.gz", "Subject: the lease");
10565 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();
10567 quick(&desk, NOW);
10568 let came = quick(&phone, NOW + MINUTE);
10569 assert!(came.written.contains(SPAM_TABLE), "{came:?}");
10570 assert_eq!(std::fs::read(phone.path("data/spam/table.bin")).unwrap(), std::fs::read(desk.path("data/spam/table.bin")).unwrap());
10571 assert!(!phone.path("data/spam/language.bin").exists() && !phone.path("data/spam/corpus").exists());
10572 assert!(!table::previous(&phone.path("data/spam/table.bin")).exists(), "nothing before the first");
10573 let mut files = Vec::new();
10575 list_files(&folder, &folder, &[], &mut files);
10576 for (name, path) in &files {
10577 let bytes = std::fs::read(path).unwrap();
10578 for plain in [&b"SIOULSPM"[..], b"vocabulary", b"lottery"] {
10579 assert!(!bytes.windows(plain.len()).any(|w| w == plain), "{name}");
10580 }
10581 }
10582 trained(1.5, 1_789_990_600);
10584 quick(&desk, NOW + 2 * MINUTE);
10585 assert!(quick(&phone, NOW + 3 * MINUTE).written.contains(SPAM_TABLE));
10586 let path = phone.path("data/spam/table.bin");
10587 assert_eq!((Table::read(&path).unwrap().bias, Table::read(&table::previous(&path)).unwrap().bias), (1.5, 0.5));
10588 let every = stores_of(&Config::default(), &desk.roots, &|_| true);
10590 for file in ["files/spam/language.bin", "files/spam/corpus/home/INBOX.jsonl.gz", "files/spam/", "data/spam/language.bin"] {
10591 assert!(locate(&every, file).is_none(), "{file}");
10592 }
10593 assert_eq!(locate(&every, SPAM_TABLE).map(|(s, p)| (s.part, p)), Some(("spam", desk.path("data/spam/table.bin"))));
10594 assert_eq!(shown(SPAM_TABLE), "spam/table.bin");
10595 let _ = std::fs::remove_dir_all(&base);
10596 }
10597
10598 #[test]
10605 fn only_the_two_newest_spam_tables_stay_in_the_folder() {
10606 let base = scratch("spam-tables");
10607 let folder = base.join("folder");
10608 let key = quick_key(&folder, "four words make a passphrase").unwrap();
10609 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
10610 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();
10611 let stranger = folder.join("blobs").join("0f".repeat(32));
10613 put(&folder, &format!("blobs/{}", "0f".repeat(32)), b"another device's");
10614 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();
10615 let trained = |n: u64| {
10617 let path = desk.path("data/spam/table.bin");
10618 put(path.parent().unwrap(), "table.bin", &noise(4096, n as u32));
10619 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();
10620 };
10621 let mut now = NOW;
10622 for n in 1..=4 {
10623 trained(n);
10624 now += MINUTE;
10625 quick(&desk, now);
10626 now += MINUTE;
10627 let came = quick(&phone, now);
10628 assert!(came.problems.is_empty(), "{came:?}");
10629 assert_eq!(std::fs::read(phone.path("data/spam/table.bin")).unwrap(), noise(4096, n as u32));
10630 assert_eq!(tables(), n.min(2) as usize, "after training {n}");
10631 }
10632 assert_eq!(Memory::load_all(&desk.memory, &desk.id).sealed.tables.len(), 2);
10633 for n in 5..=7 {
10635 trained(n);
10636 now += MINUTE;
10637 quick(&desk, now);
10638 }
10639 assert_eq!(tables(), 2);
10640 now += MINUTE;
10641 let came = quick(&phone, now);
10642 assert!(came.problems.is_empty() && came.pending == 0, "{came:?}");
10643 assert_eq!(std::fs::read(phone.path("data/spam/table.bin")).unwrap(), noise(4096, 7));
10644 let came = quick(&phone, now + 2 * DAY);
10645 assert!(came.problems.is_empty() && came.pending == 0, "nothing missing, a day on: {came:?}");
10646 assert!(stranger.exists(), "another device's file stays");
10647 let _ = std::fs::remove_dir_all(&base);
10648 }
10649
10650 #[test]
10655 fn a_label_on_one_device_reaches_the_others() {
10656 use sioul_core::spam::labels::{self, Entry, Label, Moved, Source};
10657 let base = scratch("spam-labels");
10658 let folder = base.join("folder");
10659 let key = quick_key(&folder, "four words make a passphrase").unwrap();
10660 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
10661 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();
10662 let spam_state = |c: &Computer| c.roots.state.join("spam");
10663 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();
10665 card.account = Some("home".into());
10666 card.origin = Some(sioul_core::card::ImapOrigin { validity: 7, uid: 42 });
10667 assert!(!labels::said_ham_in(&labels::read_all_in(&spam_state(&desk)), &card));
10668 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 };
10670 labels::append_to(&labels::own_log(&spam_state(&phone), &phone.id), &said).unwrap();
10671 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 };
10672 labels::append_to(&labels::own_moved_log(&spam_state(&phone), &phone.id), &moved).unwrap();
10673 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() };
10675 labels::append_to(&labels::own_flagged_log(&spam_state(&phone), &phone.id), &flagged).unwrap();
10676 quick(&phone, NOW);
10677 let came = quick(&desk, NOW + MINUTE);
10678 let theirs = format!("{SPAM_LABELS}{}.jsonl", phone.id);
10679 assert!(came.written.contains(SPAM_LABELS) && came.written.contains(SPAM_MOVED) && came.written.contains(SPAM_FLAGGED) && came.received == 3, "{came:?}");
10680 assert!(desk.roots.state.join("spam/labels").join(format!("{}.jsonl", phone.id)).exists(), "the phone's own file, beside the desk's");
10681 assert!(labels::said_ham_in(&labels::read_all_in(&spam_state(&desk)), &card));
10683 assert_eq!(labels::read_moved_in(&spam_state(&desk)), vec![moved]);
10684 assert_eq!(labels::read_flagged_in(&spam_state(&desk)), vec![flagged]);
10685 let later = Entry { at: 1_791_360_500, label: Label::Spam, source: Source::Junk, ..said.clone() };
10687 labels::append_to(&labels::own_log(&spam_state(&desk), &desk.id), &later).unwrap();
10688 quick(&desk, NOW + 2 * MINUTE);
10689 quick(&phone, NOW + 3 * MINUTE);
10690 for device in [&desk, &phone] {
10691 let all = labels::read_all_in(&spam_state(device));
10692 assert_eq!(all.len(), 2);
10693 assert!(!labels::said_ham_in(&all, &card));
10694 }
10695 assert_eq!(labels::read_from::<Entry>(&labels::own_log(&spam_state(&phone), &phone.id)), vec![said]);
10697 let mut files = Vec::new();
10699 list_files(&folder, &folder, &[], &mut files);
10700 for (name, path) in &files {
10701 let bytes = std::fs::read(path).unwrap();
10702 for plain in [&b"win-1@lottery.test"[..], b"not-spam", b"deal-2", b"flag-3"] {
10703 assert!(!bytes.windows(plain.len()).any(|w| w == plain), "{name}");
10704 }
10705 }
10706 let every = stores_of(&Config::default(), &desk.roots, &|_| true);
10707 assert_eq!(locate(&every, &theirs).map(|(s, _)| s.part), Some("spam"));
10708 let _ = std::fs::remove_dir_all(&base);
10709 }
10710
10711 fn declined(at: i64, key: &str) -> sioul_core::calls::Held {
10713 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() }
10714 }
10715
10716 fn calls_of(c: &Computer) -> PathBuf {
10718 c.roots.state.join(sioul_core::calls::FOLDER)
10719 }
10720
10721 #[test]
10726 fn a_phone_s_calls_reach_the_desk_and_seen_comes_back() {
10727 use sioul_core::calls;
10728 let base = scratch("calls");
10729 let folder = base.join("folder");
10730 let key = quick_key(&folder, "four words make a passphrase").unwrap();
10731 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
10732 let own = calls::own_file(&calls_of(&phone), calls::LOG, &phone.id);
10733 assert_eq!(calls::carry_into(&own, &[declined(NOW - 3_600_000, "+33199001234")], None, NOW).unwrap(), 1);
10734 phone.exchange(&folder, &key, NOW);
10735 let came = desk.exchange(&folder, &key, NOW + MINUTE);
10736 assert!(came.written.contains(CALLS_LOG), "{came:?}");
10737 let on_desk = calls::read_logs(&calls_of(&desk), NOW + MINUTE);
10738 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")]);
10739 calls::mark_seen(&calls_of(&desk), &desk.id, &[on_desk[0].id()], NOW + 2 * MINUTE).unwrap();
10741 desk.exchange(&folder, &key, NOW + 3 * MINUTE);
10742 let came = phone.exchange(&folder, &key, NOW + 4 * MINUTE);
10743 assert!(came.written.contains(CALLS_SEEN), "{came:?}");
10744 assert!(calls::read_seen(&calls_of(&phone)).seen.contains(&on_desk[0].id()));
10745 assert!(!calls::own_file(&calls_of(&phone), calls::SEEN_LOG, &phone.id).exists());
10747 assert!(!calls::own_file(&calls_of(&desk), calls::LOG, &desk.id).exists());
10748 let mut files = Vec::new();
10750 list_files(&folder, &folder, &[], &mut files);
10751 for (name, path) in &files {
10752 let bytes = std::fs::read(path).unwrap();
10753 for plain in [&b"199001234"[..], b"Cabinet", b"stranger"] {
10754 assert!(!bytes.windows(plain.len()).any(|w| w == plain), "{name}");
10755 }
10756 }
10757 let every = stores_of(&Config::default(), &desk.roots, &|_| true);
10758 assert_eq!(locate(&every, &format!("{CALLS_LOG}{}.jsonl", phone.id)).map(|(s, _)| s.part), Some("calls"));
10759 for c in [&desk, &phone] {
10760 let small = Memory::load(&c.memory, &c.id);
10762 assert!(small.entries.keys().chain(small.files.keys()).chain(small.pending.keys()).all(|k| !calls_store(k)), "memory.json stays small");
10763 assert!(Memory::load_all(&c.memory, &c.id).entries.keys().any(|k| calls_store(k)));
10764 let share = c.memory.parent().unwrap();
10766 let mut kept = Vec::new();
10767 list_files(share, share, &[], &mut kept);
10768 assert!(kept.iter().all(|(name, _)| !name.contains("calls")), "{kept:?}");
10769 }
10770 std::fs::remove_file(sealed_path(&desk.memory)).unwrap();
10772 let again = desk.exchange(&folder, &key, NOW + 5 * MINUTE);
10773 assert_eq!(again.sent, 0, "{again:?}");
10774 assert_eq!(calls::read_logs(&calls_of(&desk), NOW + 5 * MINUTE).len(), 1);
10775 let _ = std::fs::remove_dir_all(&base);
10776 }
10777
10778 #[test]
10780 fn several_phones_each_keep_their_own_log() {
10781 use sioul_core::calls;
10782 let base = scratch("calls-phones");
10783 let folder = base.join("folder");
10784 let key = quick_key(&folder, "four words make a passphrase").unwrap();
10785 let (desk, a, b) = (Computer::new(&base, "desk"), Computer::new(&base, "phone-a"), Computer::new(&base, "phone-b"));
10786 calls::carry_into(&calls::own_file(&calls_of(&a), calls::LOG, &a.id), &[declined(NOW - 7_200_000, "+33199001234")], None, NOW).unwrap();
10787 calls::carry_into(&calls::own_file(&calls_of(&b), calls::LOG, &b.id), &[declined(NOW - 3_600_000, "+33465710042")], None, NOW).unwrap();
10788 for (n, c) in [&a, &b, &desk, &a, &b].iter().enumerate() {
10789 c.exchange(&folder, &key, NOW + n as i64 * MINUTE);
10790 }
10791 for c in [&desk, &a, &b] {
10792 let heard = calls::read_logs(&calls_of(c), NOW + 10 * MINUTE);
10793 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())]);
10794 }
10795 let _ = std::fs::remove_dir_all(&base);
10796 }
10797
10798 #[test]
10801 fn an_older_sioul_keeps_the_calls_waiting() {
10802 use sioul_core::calls;
10803 let base = scratch("calls-older");
10804 let folder = base.join("folder");
10805 let key = quick_key(&folder, "four words make a passphrase").unwrap();
10806 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
10807 let own = calls::own_file(&calls_of(&phone), calls::LOG, &phone.id);
10808 calls::carry_into(&own, &[declined(NOW - 3_600_000, "+33199001234")], None, NOW).unwrap();
10809 phone.exchange(&folder, &key, NOW);
10810 let older_stores = stores(&Config::default(), &desk.roots).into_iter().filter(|s| !calls_store(&s.name)).collect::<Vec<_>>();
10812 OLDER.with(|older| *older.borrow_mut() = vec![CALLS_LOG.to_string(), CALLS_SEEN.to_string()]);
10813 for n in 1..=3 {
10814 let outcome = exchange(&Sharing { folder: &folder, computer: &desk.id, key: &key, memory: &desk.memory, files: true, hurry: None }, &older_stores, NOW + n * MINUTE).unwrap();
10815 assert_eq!(outcome.pending, 1, "kept waiting: {outcome:?}");
10816 }
10817 assert!(!calls_of(&desk).exists(), "nothing written");
10818 phone.exchange(&folder, &key, NOW + 4 * MINUTE);
10819 assert_eq!(calls::read_held(&own).len(), 1, "nothing taken out");
10820 OLDER.with(|older| older.borrow_mut().clear());
10822 let came = desk.exchange(&folder, &key, NOW + 5 * MINUTE);
10823 assert!(came.written.contains(CALLS_LOG) && came.pending == 0, "{came:?}");
10824 assert_eq!(calls::read_logs(&calls_of(&desk), NOW + 5 * MINUTE).len(), 1);
10825 let _ = std::fs::remove_dir_all(&base);
10826 }
10827
10828 #[test]
10833 fn an_older_sioul_keeps_your_security_keys_waiting() {
10834 let base = scratch("cards-older");
10835 let folder = base.join("folder");
10836 let key = quick_key(&folder, "four words make a passphrase").unwrap();
10837 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
10838 let store = "data/pgp/cards/";
10839 let card_file = desk.roots.data.join("pgp").join(sioul_core::securitykey::SHARED_CARDS).join("0006-12345678.toml");
10840 std::fs::create_dir_all(card_file.parent().unwrap()).unwrap();
10841 std::fs::write(&card_file, "[card]\nident = \"0006:12345678\"\ncert = \"AB\"\n").unwrap();
10842 desk.exchange(&folder, &key, NOW);
10843 let older_stores = stores(&Config::default(), &phone.roots).into_iter().filter(|s| s.name != store).collect::<Vec<_>>();
10845 OLDER.with(|older| *older.borrow_mut() = vec![store.to_string()]);
10846 for n in 1..=2 {
10847 let outcome = exchange(&Sharing { folder: &folder, computer: &phone.id, key: &key, memory: &phone.memory, files: true, hurry: None }, &older_stores, NOW + n * MINUTE).unwrap();
10848 assert_eq!(outcome.pending, 1, "kept waiting: {outcome:?}");
10849 }
10850 assert!(!phone.roots.data.join("pgp").join("cards").exists(), "nothing written");
10851 desk.exchange(&folder, &key, NOW + 3 * MINUTE);
10852 assert!(card_file.exists(), "nothing taken out");
10853 OLDER.with(|older| older.borrow_mut().clear());
10855 let came = phone.exchange(&folder, &key, NOW + 4 * MINUTE);
10856 assert!(came.written.contains(store) && came.pending == 0, "{came:?}");
10857 assert_eq!(std::fs::read(phone.roots.data.join("pgp").join("cards").join("0006-12345678.toml")).unwrap(), std::fs::read(&card_file).unwrap());
10858 let _ = std::fs::remove_dir_all(&base);
10859 }
10860
10861 #[test]
10863 fn the_calls_part_switched_off_receives_nothing_and_keeps_nothing_waiting() {
10864 use sioul_core::calls;
10865 let base = scratch("calls-off");
10866 let folder = base.join("folder");
10867 let key = quick_key(&folder, "four words make a passphrase").unwrap();
10868 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
10869 calls::carry_into(&calls::own_file(&calls_of(&phone), calls::LOG, &phone.id), &[declined(NOW - 3_600_000, "+33199001234")], None, NOW).unwrap();
10870 phone.exchange(&folder, &key, NOW);
10871 let off = stores_of(&Config::default(), &desk.roots, &|part| part != "calls" && shared_by_default(part, &Config::default()));
10872 let outcome = exchange(&Sharing { folder: &folder, computer: &desk.id, key: &key, memory: &desk.memory, files: true, hurry: None }, &off, NOW + MINUTE).unwrap();
10873 assert_eq!(outcome.pending, 0, "{outcome:?}");
10874 assert!(!calls_of(&desk).exists());
10875 let came = desk.exchange(&folder, &key, NOW + 2 * MINUTE);
10877 assert!(came.written.contains(CALLS_LOG), "{came:?}");
10878 let _ = std::fs::remove_dir_all(&base);
10879 }
10880
10881 #[test]
10883 fn a_trimmed_line_leaves_every_device() {
10884 use sioul_core::calls;
10885 let base = scratch("calls-trim");
10886 let folder = base.join("folder");
10887 let key = quick_key(&folder, "four words make a passphrase").unwrap();
10888 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
10889 let own = calls::own_file(&calls_of(&phone), calls::LOG, &phone.id);
10890 calls::carry_into(&own, &[declined(NOW - 3_600_000, "+33199001234")], None, NOW).unwrap();
10891 phone.exchange(&folder, &key, NOW);
10892 desk.exchange(&folder, &key, NOW + MINUTE);
10893 assert_eq!(calls::read_held(&calls::own_file(&calls_of(&desk), calls::LOG, &phone.id)).len(), 1);
10894 let later = NOW + (calls::KEPT_DAYS + 1) * 86_400_000;
10895 assert_eq!(calls::trim_own(&calls_of(&phone), &phone.id, later).unwrap(), 1);
10896 phone.exchange(&folder, &key, later);
10897 desk.exchange(&folder, &key, later + MINUTE);
10898 assert!(calls::read_held(&calls::own_file(&calls_of(&desk), calls::LOG, &phone.id)).is_empty(), "gone from the desk's copy too");
10899 let _ = std::fs::remove_dir_all(&base);
10900 }
10901
10902 fn texted(at: i64) -> sioul_core::phonemsgs::Line {
10904 sioul_core::phonemsgs::Line {
10905 at,
10906 id: format!("{at}-0123456789abcdef"),
10907 app: "foundation.e.message".into(),
10908 label: "Message".into(),
10909 kind: "text".into(),
10910 name: "Cabinet du Dr Martin".into(),
10911 key: "+33199001234".into(),
10912 who: "neutral".into(),
10913 text: "Votre rendez-vous est déplacé à jeudi 10 h 30.".into(),
10914 shows: at,
10915 notice: sioul_core::replies::notice("foundation.e.message", "0|foundation.e.message|7|null|10123"),
10916 ..sioul_core::phonemsgs::Line::default()
10917 }
10918 }
10919
10920 fn messages_of(c: &Computer) -> PathBuf {
10922 c.roots.state.join(sioul_core::phonemsgs::FOLDER)
10923 }
10924
10925 #[test]
10929 fn a_phone_s_messages_reach_the_desk_sealed_and_seen_comes_back() {
10930 use sioul_core::{calls, phonemsgs};
10931 let base = scratch("phone-messages");
10932 let (folder, notes) = (base.join("folder"), base.join("notes"));
10933 let key = quick_key(&folder, "four words make a passphrase").unwrap();
10934 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
10935 let on = [phonemsgs::PART];
10936 let own = calls::own_file(&messages_of(&phone), phonemsgs::LOG, &phone.id);
10937 assert_eq!(phonemsgs::carry_into(&own, &[texted(NOW - 3_600_000)], NOW).unwrap(), 1);
10938 assert!(std::fs::read_to_string(&own).unwrap().lines().all(|l| l.len() % phonemsgs::PAD_STEP == 0), "padded");
10939 phone.exchange_with(&folder, &key, NOW, ¬es, &on);
10940 let came = desk.exchange_with(&folder, &key, NOW + MINUTE, ¬es, &on);
10941 assert!(came.written.contains(PHONE_MESSAGES_LOG), "{came:?}");
10942 let on_desk = phonemsgs::read_logs(&messages_of(&desk), NOW + MINUTE);
10943 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.")]);
10944 calls::mark_seen(&messages_of(&desk), &desk.id, &[on_desk[0].id.clone()], NOW + 2 * MINUTE).unwrap();
10946 desk.exchange_with(&folder, &key, NOW + 3 * MINUTE, ¬es, &on);
10947 let came = phone.exchange_with(&folder, &key, NOW + 4 * MINUTE, ¬es, &on);
10948 assert!(came.written.contains(PHONE_MESSAGES_SEEN), "{came:?}");
10949 assert!(calls::read_seen(&messages_of(&phone)).seen.contains(&on_desk[0].id));
10950 let live = sioul_core::replies::Live { notice: on_desk[0].notice.clone(), app: "foundation.e.message".into(), reply: true, read: true, at: NOW + 4 * MINUTE, ..Default::default() };
10952 assert!(!live.notice.is_empty());
10953 assert!(sioul_core::replies::offer(&sioul_core::replies::live_file(&messages_of(&phone), &phone.id), &live).unwrap());
10954 phone.exchange_with(&folder, &key, NOW + 4 * MINUTE + 1_000, ¬es, &on);
10955 let came = desk.exchange_with(&folder, &key, NOW + 4 * MINUTE + 2_000, ¬es, &on);
10956 assert!(came.written.contains(PHONE_MESSAGES_LIVE), "{came:?}");
10957 let offered = sioul_core::replies::read_live(&messages_of(&desk), NOW + 5 * MINUTE);
10958 assert_eq!(offered.get(&(phone.id.clone(), live.notice.clone())).map(|l| (l.reply, l.read)), Some((true, true)));
10959 let mut files = Vec::new();
10961 list_files(&folder, &folder, &[], &mut files);
10962 for (name, path) in &files {
10963 let bytes = std::fs::read(path).unwrap();
10964 for plain in [&b"199001234"[..], b"Cabinet", b"rendez-vous", b"neutral", b"foundation.e.message", on_desk[0].notice.as_bytes()] {
10965 assert!(!bytes.windows(plain.len()).any(|w| w == plain), "{name}");
10966 }
10967 }
10968 let every = stores_of(&Config::default(), &desk.roots, &|_| true);
10969 assert_eq!(locate(&every, &format!("{PHONE_MESSAGES_LOG}{}.jsonl", phone.id)).map(|(s, _)| s.part), Some(phonemsgs::PART));
10970 for c in [&desk, &phone] {
10971 let small = Memory::load(&c.memory, &c.id);
10972 assert!(small.entries.keys().chain(small.files.keys()).chain(small.pending.keys()).all(|k| !calls_store(k)), "memory.json stays small");
10973 let share = c.memory.parent().unwrap();
10974 let mut kept = Vec::new();
10975 list_files(share, share, &[], &mut kept);
10976 assert!(kept.iter().all(|(name, _)| !name.contains("phone-messages")), "{kept:?}");
10977 }
10978 std::fs::remove_file(sealed_path(&desk.memory)).unwrap();
10980 let again = desk.exchange_with(&folder, &key, NOW + 5 * MINUTE, ¬es, &on);
10981 assert_eq!(again.sent, 0, "{again:?}");
10982 let _ = std::fs::remove_dir_all(&base);
10983 }
10984
10985 #[test]
10990 fn a_reply_written_on_the_desk_is_decided_once_and_its_outcome_comes_back() {
10991 use sioul_core::{calls, phonemsgs, replies, texts::Sealer};
10992 let base = scratch("replies");
10993 let (folder, notes, private) = (base.join("folder"), base.join("notes"), base.join("phone-private"));
10994 let key = quick_key(&folder, "four words make a passphrase").unwrap();
10995 let seal = crate::textseal::ReplySeal::new(&key);
10996 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
10997 let on = [phonemsgs::PART];
10998 let line = texted(NOW - 3 * MINUTE);
11000 phonemsgs::carry_into(&calls::own_file(&messages_of(&phone), phonemsgs::LOG, &phone.id), std::slice::from_ref(&line), NOW).unwrap();
11001 replies::mark_ledger(&private, NOW).unwrap();
11003 phone.exchange_with(&folder, &key, NOW, ¬es, &on);
11004 desk.exchange_with(&folder, &key, NOW + 1_000, ¬es, &on);
11005 let ask = replies::Ask { key: "0123456789abcdef0123456789abcdef".into(), phone: phone.id.clone(), verb: "reply".into(), notice: line.notice.clone(), app: line.app.clone(), line: line.id.clone(), words: "J'arrive dans dix minutes.".into(), written: NOW + MINUTE, ..replies::Ask::default() };
11006 assert!(replies::write_ask(&messages_of(&desk), &desk.id, &ask, &seal).unwrap());
11007 desk.exchange_with(&folder, &key, NOW + MINUTE, ¬es, &on);
11008 let came = phone.exchange_with(&folder, &key, NOW + 2 * MINUTE, ¬es, &on);
11009 assert!(came.written.contains(PHONE_MESSAGES_ASK), "{came:?}");
11010 let (asks, _) = replies::read_asks(&[messages_of(&phone)], &seal);
11011 assert_eq!(asks.iter().map(|a| (a.key.as_str(), a.device.as_str(), a.words.as_str())).collect::<Vec<_>>(), [(ask.key.as_str(), desk.id.as_str(), "J'arrive dans dix minutes.")]);
11012 let counted = counting(&folder, &key, &phone.memory, &phone.id, NOW + 2 * MINUTE);
11013 assert!(counted.contains(&desk.id), "{counted:?}");
11014 let decide = |now: i64| {
11015 let ledger = replies::open_ledger(&private, now).unwrap();
11016 let lines = phonemsgs::read_file(&calls::own_file(&messages_of(&phone), phonemsgs::LOG, &phone.id));
11017 let (asks, cancelled) = replies::read_asks(&[messages_of(&phone)], &seal);
11018 let (unsent, went) = (replies::unsent(&private), replies::went_keys(&private));
11019 let judge = replies::Judge { here: &phone.id, now_ms: now, ledger: &ledger, lines: &lines, standing: &|d: &str| if counted.contains(d) { sioul_core::texts::Standing::Counted } else { sioul_core::texts::Standing::Gone }, cancelled: &cancelled, answers: &|_: &str| true, unsent: &unsent, went: &went };
11020 asks.iter().map(|a| replies::verdict(a, &judge)).collect::<Vec<_>>()
11021 };
11022 assert_eq!(decide(NOW + 2 * MINUTE), [replies::Verdict::Send]);
11023 sioul_core::texts::record(&private, &replies::claim_of(&ask, &line.talk, NOW + 2 * MINUTE)).unwrap();
11024 let own = calls::own_file(&messages_of(&phone), replies::OUTCOME, &phone.id);
11026 replies::say_outcomes(&own, &[replies::Outcome { key: ask.key.clone(), state: "handed".into(), at: NOW + 2 * MINUTE, ..replies::Outcome::default() }]).unwrap();
11027 phone.exchange_with(&folder, &key, NOW + 3 * MINUTE, ¬es, &on);
11028 let came = desk.exchange_with(&folder, &key, NOW + 4 * MINUTE, ¬es, &on);
11029 assert!(came.written.contains(PHONE_MESSAGES_OUTCOME), "{came:?}");
11030 let outcomes = replies::read_outcomes(&messages_of(&desk));
11031 assert_eq!(outcomes.iter().map(|o| (o.key.as_str(), o.state.as_str(), o.device.as_str())).collect::<Vec<_>>(), [(ask.key.as_str(), "handed", phone.id.as_str())]);
11032 for root in [&folder, &desk.roots.state, &phone.roots.state] {
11034 let mut files = Vec::new();
11035 list_files(root, root, &[], &mut files);
11036 for (name, path) in &files {
11037 let bytes = std::fs::read(path).unwrap();
11038 assert!(!bytes.windows(b"dix minutes".len()).any(|w| w == b"dix minutes"), "{name}");
11039 }
11040 }
11041 assert!(seal.open(std::fs::read_to_string(calls::own_file(&messages_of(&phone), replies::ASK, &desk.id)).unwrap().lines().next().unwrap()).is_some());
11042 std::fs::remove_file(sealed_path(&phone.memory)).unwrap();
11044 phone.exchange_with(&folder, &key, NOW + 5 * MINUTE, ¬es, &on);
11045 assert_eq!(decide(NOW + 5 * MINUTE), [replies::Verdict::Decided]);
11046 let _ = std::fs::remove_dir_all(&base);
11047 }
11048
11049 #[test]
11056 fn a_forgotten_computer_counts_again_only_by_a_sealed_fact() {
11057 let base = scratch("counting-forgotten");
11058 let folder = base.join("folder");
11059 let key = quick_key(&folder, "four words make a passphrase").unwrap();
11060 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
11061 desk.write("data/drafts/2026-10-01.json", "{\"steps\": 1}");
11062 desk.exchange(&folder, &key, NOW);
11063 phone.exchange(&folder, &key, NOW + MINUTE);
11064 assert!(counting(&folder, &key, &phone.memory, &phone.id, NOW + MINUTE).contains(&desk.id));
11065 forget_device(&folder, &key, &phone.memory, &phone.id, &desk.id).unwrap();
11066 assert!(!counting(&folder, &key, &phone.memory, &phone.id, NOW + 2 * MINUTE).contains(&desk.id), "forgotten");
11067 std::fs::write(seen_path(&folder, &desk.id), format!("at = {}\nround = 9\n", (NOW + 3 * MINUTE) / 1000)).unwrap();
11069 phone.exchange(&folder, &key, NOW + 3 * MINUTE);
11070 assert!(!counting(&folder, &key, &phone.memory, &phone.id, NOW + 3 * MINUTE).contains(&desk.id), "plain notes are no sealed fact");
11071 desk.write("data/drafts/2026-10-02.json", "{\"steps\": 2}");
11073 desk.exchange(&folder, &key, NOW + 4 * MINUTE);
11074 phone.exchange(&folder, &key, NOW + 5 * MINUTE);
11075 assert!(counting(&folder, &key, &phone.memory, &phone.id, NOW + 5 * MINUTE).contains(&desk.id), "a sealed record newer than the forgetting");
11076 let _ = std::fs::remove_dir_all(&base);
11077 }
11078
11079 #[test]
11083 fn a_device_not_known_yet_is_neither_counted_nor_gone() {
11084 let base = scratch("asking");
11085 let folder = base.join("folder");
11086 let key = quick_key(&folder, "four words make a passphrase").unwrap();
11087 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
11088 phone.exchange(&folder, &key, NOW);
11089 let (counted, gone) = asking(&folder, &key, &phone.memory, &phone.id, NOW);
11090 assert!(!counted.contains(&desk.id) && !gone.contains(&desk.id), "no record of the desk yet");
11091 desk.write("data/drafts/2026-10-01.json", "{\"steps\": 1}");
11092 desk.exchange(&folder, &key, NOW + MINUTE);
11093 phone.exchange(&folder, &key, NOW + 2 * MINUTE);
11094 let (counted, gone) = asking(&folder, &key, &phone.memory, &phone.id, NOW + 2 * MINUTE);
11095 assert!(counted.contains(&desk.id) && !gone.contains(&desk.id));
11096 forget_device(&folder, &key, &phone.memory, &phone.id, &desk.id).unwrap();
11097 let (counted, gone) = asking(&folder, &key, &phone.memory, &phone.id, NOW + 3 * MINUTE);
11098 assert!(!counted.contains(&desk.id) && gone.contains(&desk.id), "forgotten: gone");
11099 let (counted, gone) = asking(&folder, &key, &phone.memory, &phone.id, NOW + 3 * MINUTE);
11100 assert!(!counted.contains("laptop-never-seen") && !gone.contains("laptop-never-seen"));
11101 let _ = std::fs::remove_dir_all(&base);
11102 }
11103
11104 #[test]
11108 fn the_phone_messages_part_is_off_until_turned_on_and_off_receives_nothing() {
11109 use sioul_core::{calls, phonemsgs};
11110 let base = scratch("phone-messages-off");
11111 let (folder, notes) = (base.join("folder"), base.join("notes"));
11112 let key = quick_key(&folder, "four words make a passphrase").unwrap();
11113 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
11114 assert!(!shared_by_default(phonemsgs::PART, &Config::default()));
11115 assert!(stores(&Config::default(), &desk.roots).iter().all(|s| s.part != phonemsgs::PART));
11116 phonemsgs::carry_into(&calls::own_file(&messages_of(&phone), phonemsgs::LOG, &phone.id), &[texted(NOW - 3_600_000)], NOW).unwrap();
11117 phone.exchange(&folder, &key, NOW);
11119 desk.exchange_with(&folder, &key, NOW + MINUTE, ¬es, &[phonemsgs::PART]);
11120 assert!(phonemsgs::read_logs(&messages_of(&desk), NOW + MINUTE).is_empty(), "the phone's part off");
11121 phone.exchange_with(&folder, &key, NOW + 2 * MINUTE, ¬es, &[phonemsgs::PART]);
11123 let outcome = desk.exchange(&folder, &key, NOW + 3 * MINUTE);
11124 assert_eq!(outcome.pending, 0, "{outcome:?}");
11125 assert!(!messages_of(&desk).join(phonemsgs::LOG).exists());
11126 let came = desk.exchange_with(&folder, &key, NOW + 4 * MINUTE, ¬es, &[phonemsgs::PART]);
11128 assert!(came.written.contains(PHONE_MESSAGES_LOG), "{came:?}");
11129 assert_eq!(phonemsgs::read_logs(&messages_of(&desk), NOW + 4 * MINUTE).len(), 1);
11130 let _ = std::fs::remove_dir_all(&base);
11131 }
11132
11133 #[test]
11135 fn a_trimmed_line_of_phone_messages_leaves_every_device() {
11136 use sioul_core::{calls, phonemsgs};
11137 let base = scratch("phone-messages-trim");
11138 let (folder, notes) = (base.join("folder"), base.join("notes"));
11139 let key = quick_key(&folder, "four words make a passphrase").unwrap();
11140 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
11141 let on = [phonemsgs::PART];
11142 let own = calls::own_file(&messages_of(&phone), phonemsgs::LOG, &phone.id);
11143 phonemsgs::carry_into(&own, &[texted(NOW - 3_600_000)], NOW).unwrap();
11144 phone.exchange_with(&folder, &key, NOW, ¬es, &on);
11145 desk.exchange_with(&folder, &key, NOW + MINUTE, ¬es, &on);
11146 let theirs = calls::own_file(&messages_of(&desk), phonemsgs::LOG, &phone.id);
11147 assert_eq!(phonemsgs::read_file(&theirs).len(), 1);
11148 let later = NOW + phonemsgs::KEPT_DAYS * 86_400_000;
11149 assert_eq!(phonemsgs::trim_own(&messages_of(&phone), &phone.id, later).unwrap(), 1);
11150 phone.exchange_with(&folder, &key, later, ¬es, &on);
11151 desk.exchange_with(&folder, &key, later + MINUTE, ¬es, &on);
11152 assert!(phonemsgs::read_file(&theirs).is_empty(), "gone from the desk's copy too");
11153 let _ = std::fs::remove_dir_all(&base);
11154 }
11155
11156 #[test]
11159 fn an_older_sioul_keeps_the_phone_s_messages_waiting() {
11160 use sioul_core::{calls, phonemsgs};
11161 let base = scratch("phone-messages-older");
11162 let (folder, notes) = (base.join("folder"), base.join("notes"));
11163 let key = quick_key(&folder, "four words make a passphrase").unwrap();
11164 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
11165 let on = [phonemsgs::PART];
11166 phonemsgs::carry_into(&calls::own_file(&messages_of(&phone), phonemsgs::LOG, &phone.id), &[texted(NOW - 3_600_000)], NOW).unwrap();
11167 phone.exchange_with(&folder, &key, NOW, ¬es, &on);
11168 let older_stores = desk.stores_with(¬es, &|part| part == phonemsgs::PART).into_iter().filter(|s| s.part != phonemsgs::PART).collect::<Vec<_>>();
11170 OLDER.with(|older| *older.borrow_mut() = vec![PHONE_MESSAGES_LOG.to_string(), PHONE_MESSAGES_SEEN.to_string()]);
11171 for n in 1..=2 {
11172 let outcome = exchange(&Sharing { folder: &folder, computer: &desk.id, key: &key, memory: &desk.memory, files: true, hurry: None }, &older_stores, NOW + n * MINUTE).unwrap();
11173 assert_eq!(outcome.pending, 1, "kept waiting: {outcome:?}");
11174 }
11175 assert!(!messages_of(&desk).exists(), "nothing written");
11176 OLDER.with(|older| older.borrow_mut().clear());
11177 let came = desk.exchange_with(&folder, &key, NOW + 3 * MINUTE, ¬es, &on);
11178 assert!(came.written.contains(PHONE_MESSAGES_LOG) && came.pending == 0, "{came:?}");
11179 let _ = std::fs::remove_dir_all(&base);
11180 }
11181
11182 fn texts_of(c: &Computer) -> PathBuf {
11186 c.roots.data.join(sioul_core::texts::FOLDER)
11187 }
11188
11189 fn to_send(key: &str, phone: &str, written: i64) -> sioul_core::texts::Request {
11191 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, ..sioul_core::texts::Request::default() }
11192 }
11193
11194 fn phone_step(phone: &Computer, ledger: &Path, seal: &crate::textseal::TextSeal, now: i64) -> Vec<String> {
11197 use sioul_core::texts;
11198 let book = texts::Ledger::open(ledger, now).unwrap();
11199 let mut sent = Vec::new();
11200 for r in texts::read_requests(&texts_of(phone), seal) {
11201 if texts::verdict(&r, &phone.id, now, &book, None, &|_| texts::Standing::Counted) == texts::Verdict::Send {
11202 texts::record(ledger, &texts::Claim { key: r.key.clone(), at: now, state: "claimed".into(), ..texts::Claim::default() }).unwrap();
11203 let outcome = texts::Outcome { key: r.key.clone(), state: "sent".into(), at: now, row: "sms-412".into(), ..texts::Outcome::default() };
11204 texts::append(&texts::own_file(&texts_of(phone), texts::OUTCOME, &phone.id), &[outcome], seal, None).unwrap();
11205 sent.push(r.key);
11206 }
11207 }
11208 sent
11209 }
11210
11211 #[test]
11216 fn a_text_written_on_the_desk_is_sent_once_by_the_phone_and_its_outcome_comes_back() {
11217 use sioul_core::texts::{self, Sealer};
11218 let base = scratch("texts");
11219 let (folder, notes, ledger) = (base.join("folder"), base.join("notes"), base.join("phone-private"));
11220 let key = quick_key(&folder, "four words make a passphrase").unwrap();
11221 let seal = crate::textseal::TextSeal::new(&key);
11222 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
11223 let on = [texts::PART];
11224 texts::Ledger::open(&ledger, NOW - MINUTE).unwrap();
11225 let request = to_send("0123456789abcdef0123456789abcdef", &phone.id, NOW);
11226 texts::write_request(&texts_of(&desk), &desk.id, &request, &seal).unwrap();
11227 desk.exchange_with(&folder, &key, NOW, ¬es, &on);
11228 let came = phone.exchange_with(&folder, &key, NOW + MINUTE, ¬es, &on);
11229 assert!(came.written.contains(TEXTS_SEND), "{came:?}");
11230 assert_eq!(phone_step(&phone, &ledger, &seal, NOW + MINUTE), [request.key.clone()]);
11231 phone.exchange_with(&folder, &key, NOW + 2 * MINUTE, ¬es, &on);
11232 let came = desk.exchange_with(&folder, &key, NOW + 3 * MINUTE, ¬es, &on);
11233 assert!(came.written.contains(TEXTS_OUTCOME), "{came:?}");
11234 let outcomes = texts::read_outcomes(&texts_of(&desk), &seal);
11235 assert_eq!(texts::state_of(&request.key, &outcomes).map(|o| o.state.as_str()), Some("sent"));
11236 for root in [&folder, &desk.roots.data, &phone.roots.data] {
11238 let mut files = Vec::new();
11239 list_files(root, root, &[], &mut files);
11240 for (name, path) in &files {
11241 let bytes = std::fs::read(path).unwrap();
11242 for plain in [&b"199001234"[..], b"dix minutes", b"sms-412"] {
11243 assert!(!bytes.windows(plain.len()).any(|w| w == plain), "{name}");
11244 }
11245 }
11246 }
11247 assert!(seal.open(std::fs::read_to_string(texts::own_file(&texts_of(&phone), texts::SEND, &desk.id)).unwrap().lines().next().unwrap()).is_some());
11248 for c in [&desk, &phone] {
11249 let small = Memory::load(&c.memory, &c.id);
11250 assert!(small.entries.keys().chain(small.files.keys()).all(|k| !calls_store(k)), "memory.json stays small");
11251 }
11252 std::fs::remove_file(sealed_path(&phone.memory)).unwrap();
11254 phone.exchange_with(&folder, &key, NOW + 4 * MINUTE, ¬es, &on);
11255 assert!(phone_step(&phone, &ledger, &seal, NOW + 4 * MINUTE).is_empty());
11256 let _ = std::fs::remove_dir_all(&base);
11257 }
11258
11259 #[test]
11262 fn the_texts_part_is_off_until_turned_on() {
11263 use sioul_core::texts;
11264 assert!(!shared_by_default(texts::PART, &Config::default()));
11265 let base = scratch("texts-off");
11266 let (folder, notes) = (base.join("folder"), base.join("notes"));
11267 let key = quick_key(&folder, "four words make a passphrase").unwrap();
11268 let seal = crate::textseal::TextSeal::new(&key);
11269 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
11270 assert!(stores(&Config::default(), &desk.roots).iter().all(|s| s.part != texts::PART));
11271 let text = texts::Text { id: "sms-1".into(), at: NOW - MINUTE, direction: "in".into(), with: vec!["+33199001234".into()], body: "Bonjour".into(), ..texts::Text::default() };
11272 texts::append(&texts::own_file(&texts_of(&phone), texts::LOG, &phone.id), &[text], &seal, None).unwrap();
11273 phone.exchange_with(&folder, &key, NOW, ¬es, &[texts::PART]);
11274 let outcome = desk.exchange(&folder, &key, NOW + MINUTE);
11275 assert_eq!(outcome.pending, 0, "{outcome:?}");
11276 assert!(!texts_of(&desk).exists());
11277 let came = desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, ¬es, &[texts::PART]);
11278 assert!(came.written.contains(TEXTS_LOG), "{came:?}");
11279 assert_eq!(texts::read_logs(&texts_of(&desk), &seal).len(), 1);
11280 let _ = std::fs::remove_dir_all(&base);
11281 }
11282
11283 #[test]
11287 fn the_texts_archive_only_grows_and_a_deletion_is_marked_everywhere() {
11288 use sioul_core::texts;
11289 let base = scratch("texts-archive");
11290 let (folder, notes) = (base.join("folder"), base.join("notes"));
11291 let key = quick_key(&folder, "four words make a passphrase").unwrap();
11292 let seal = crate::textseal::TextSeal::new(&key);
11293 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
11294 let on = [texts::PART];
11295 let own = texts::own_file(&texts_of(&phone), texts::LOG, &phone.id);
11296 let id = |t: &texts::Text| t.line_id();
11297 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() };
11298 texts::append(&own, std::slice::from_ref(&text), &seal, Some(&id)).unwrap();
11299 phone.exchange_with(&folder, &key, NOW, ¬es, &on);
11300 desk.exchange_with(&folder, &key, NOW + MINUTE, ¬es, &on);
11301 let later = NOW + 400 * 86_400_000;
11302 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);
11303 texts::append(&own, &[texts::Text { deleted: later, body: String::new(), ..text }], &seal, Some(&id)).unwrap();
11304 phone.exchange_with(&folder, &key, later, ¬es, &on);
11305 desk.exchange_with(&folder, &key, later + MINUTE, ¬es, &on);
11306 let kept = texts::read_logs(&texts_of(&desk), &seal);
11307 assert_eq!(kept.iter().map(|t| (t.body.as_str(), t.deleted > 0)).collect::<Vec<_>>(), [("Bonjour", true)]);
11308 let _ = std::fs::remove_dir_all(&base);
11309 }
11310
11311 #[test]
11316 fn the_first_texts_build_s_records_are_passed_over() {
11317 let key = |folder: &str| format!("{folder}phone.jsonl#a line");
11319 assert!(retired(&key("state/texts/log/")));
11320 assert!(!retired(&key(TEXTS_LOG)));
11321 }
11322
11323 #[test]
11327 fn an_idle_exchange_writes_nothing_with_ten_thousand_texts() {
11328 use sioul_core::texts;
11329 let base = scratch("texts-idle");
11330 let (folder, notes) = (base.join("folder"), base.join("notes"));
11331 let key = quick_key(&folder, "four words make a passphrase").unwrap();
11332 let seal = crate::textseal::TextSeal::new(&key);
11333 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
11334 let on = [texts::PART];
11335 let own = texts::own_file(&texts_of(&phone), texts::LOG, &phone.id);
11336 for batch in 0..20 {
11337 let lines: Vec<texts::Text> = (0..texts::BATCH)
11338 .map(|i| {
11339 let n = batch * texts::BATCH + i;
11340 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() }
11341 })
11342 .collect();
11343 texts::append(&own, &lines, &seal, None).unwrap();
11344 }
11345 desk.write("data/spam/table.bin", "a table of the desk's training");
11348 phone.exchange_with(&folder, &key, NOW, ¬es, &on);
11349 desk.exchange_with(&folder, &key, NOW + MINUTE, ¬es, &on);
11350 desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, ¬es, &on);
11351 assert!(!Memory::load_all(&desk.memory, &desk.id).sealed.mine.is_empty(), "the desk sealed its table");
11352 let sealed = sealed_path(&desk.memory);
11353 let (size, memory_size) = (std::fs::metadata(&sealed).unwrap().len(), std::fs::metadata(&desk.memory).unwrap().len());
11354 let stamp = |p: &Path| std::fs::metadata(p).unwrap().modified().unwrap();
11355 let before = stamp(&sealed);
11356 std::thread::sleep(std::time::Duration::from_millis(20));
11357 for minute in 3..33 {
11359 desk.exchange_with(&folder, &key, NOW + minute * MINUTE, ¬es, &on);
11360 }
11361 eprintln!("ten thousand texts: files.json {size} bytes, memory.json {memory_size} bytes; the phone's log {} bytes", std::fs::metadata(&own).unwrap().len());
11362 assert_eq!(stamp(&sealed), before, "files.json rewritten by idle exchanges");
11363 assert!(std::fs::metadata(&desk.memory).unwrap().len() < 64 << 10, "memory.json stays small");
11364 assert!(size < 2 * std::fs::metadata(&own).unwrap().len(), "the texts' lines not kept twice: {size}");
11365 let _ = std::fs::remove_dir_all(&base);
11366 }
11367
11368 #[test]
11369 fn a_first_import_goes_in_batches_and_never_holds_back_the_doses() {
11370 use sioul_core::texts;
11371 let base = scratch("texts-import");
11372 let (folder, notes) = (base.join("folder"), base.join("notes"));
11373 let key = quick_key(&folder, "four words make a passphrase").unwrap();
11374 let seal = crate::textseal::TextSeal::new(&key);
11375 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
11376 let on = [texts::PART];
11377 let own = texts::own_file(&texts_of(&phone), texts::LOG, &phone.id);
11378 let picture = vec![7u8; 512 * 1024];
11379 let source = base.join("picture.bin");
11380 std::fs::write(&source, &picture).unwrap();
11381 let (hash, size) = crate::blobs::hash_file(&source).unwrap();
11382 for batch in 0..6 {
11383 let now = NOW + batch * 10 * MINUTE;
11384 phone.write("state/health-state.toml", &format!("[taken]\n\"dose@{batch}\" = {}\n", now / 1000));
11386 phone.write("state/do-not-disturb.toml", &format!("[device.{}]\npressed = {now}\n", phone.id));
11387 let lines: Vec<texts::Text> = (0..texts::BATCH)
11388 .map(|i| {
11389 let n = batch as usize * texts::BATCH + i;
11390 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() };
11391 if i == 0 {
11392 text.parts = vec![texts::Part { seq: 0, ct: "image/png".into(), size, hash: hash.clone(), state: "here".into(), ..texts::Part::default() }];
11393 }
11394 text
11395 })
11396 .collect();
11397 crate::blobs::put(&folder, &key, &source, &hash).unwrap();
11398 texts::append(&own, &lines, &seal, None).unwrap();
11399 let sent = phone.exchange_with(&folder, &key, now, ¬es, &on);
11400 assert!(sent.sent <= texts::BATCH + 10, "a batch at a time: {sent:?}");
11401 desk.exchange_with(&folder, &key, now + MINUTE, ¬es, &on);
11402 assert!(desk.read("state/health-state.toml").contains(&format!("dose@{batch}")), "batch {batch}");
11404 assert!(desk.read("state/do-not-disturb.toml").contains(&now.to_string()), "batch {batch}");
11405 assert_eq!(texts::read_logs(&texts_of(&desk), &seal).len(), (batch as usize + 1) * texts::BATCH);
11406 }
11407 let mut files = Vec::new();
11409 list_files(&folder, &folder, &[], &mut files);
11410 let records: u64 = files.iter().filter(|(name, _)| !name.starts_with("blobs")).filter_map(|(_, p)| std::fs::metadata(p).ok()).map(|m| m.len()).sum();
11411 assert!(records < 6 * texts::BATCH as u64 * 2048, "the records hold lines, not media: {records} bytes");
11412 let target = base.join("fetched.bin");
11413 crate::blobs::get(&folder, &key, &hash, &target).unwrap();
11414 assert_eq!(std::fs::read(&target).unwrap(), picture);
11415 let _ = std::fs::remove_dir_all(&base);
11416 }
11417
11418 #[test]
11419 fn toml_entries_and_back() {
11420 let rules = &CONFIG_RULES;
11421 let text = "# mine\nlanguage = \"fr\" # kept\ncase_store = \"~/Notes\"\n\n[quiet]\npersonal = [\"joy\", \"family\"]\n\n[[account]]\nid = \"a\"\nmaildir = \"/x\"\n";
11422 let entries = toml_entries(text, rules).unwrap();
11423 let names: Vec<&str> = entries.iter().map(|(k, _)| k.as_str()).collect();
11424 assert!(names.contains(&"language") && names.contains(&"quiet\u{1f}personal") && names.contains(&"account\u{1f}\u{1e}a"), "{names:?}");
11425 assert!(!names.iter().any(|n| n.starts_with("case_store")));
11426 let account = entries.iter().find(|(k, _)| k.starts_with("account")).unwrap().1.replace("id = \"a\"", "id = \"a\"\nmuted = true");
11428 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();
11429 assert!(new.starts_with("# mine\nlanguage = \"en\""), "{new}");
11430 assert!(new.contains("muted = true") && new.contains("maildir = \"/x\""), "{new}");
11431 assert!(new.contains("work = [\"client\"]"), "{new}");
11432 let money = "ignored = [\"a\", \"b\"]\n";
11434 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();
11435 assert_eq!(new, "ignored = [\"b\", \"c\"]\n");
11436 }
11437
11438 #[test]
11444 fn a_medicines_takes_travel_whole() {
11445 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";
11446 let entries = toml_entries(new, &HEALTH_RULES).unwrap();
11447 assert_eq!(entries.len(), 1, "one entry for the medicine, its takes in it: {entries:?}");
11448 let (entry, value) = &entries[0];
11449 assert!(entry.starts_with("medicine") && value.contains("2 tablets"), "{entry} {value}");
11450 let old = "[[medicine]]\nid = \"iron\"\nname = \"Iron\"\ndose = \"1 tablet\"\n\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"08:00\", \"20:00\"]\n";
11451 let written = toml_write(old, &HEALTH_RULES, &[(entry.as_str(), Some(value.as_str()))], false).unwrap();
11452 let health: sioul_core::health::Health = toml::from_str(&written).unwrap();
11453 let takes: Vec<(String, String)> = health.medicines[0].takes().into_iter().map(|t| (t.time, t.amount)).collect();
11454 assert_eq!(takes, [("08:00".to_string(), "1 tablet".to_string()), ("20:00".to_string(), "2 tablets".to_string())]);
11455 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())));
11457 let before: sioul_core::health::Health = toml::from_str(old).unwrap();
11458 let day = |h: &sioul_core::health::Health| {
11459 let start: jiff::Zoned = "2026-10-08T00:00[Europe/Paris]".parse().unwrap();
11460 let end: jiff::Zoned = "2026-10-09T00:00[Europe/Paris]".parse().unwrap();
11461 h.doses(&start, &end).into_iter().map(|d| (d.key, d.dose)).collect::<Vec<_>>()
11462 };
11463 let (now, then) = (day(&health), day(&before));
11464 assert_eq!(now.iter().map(|d| &d.0).collect::<Vec<_>>(), then.iter().map(|d| &d.0).collect::<Vec<_>>(), "the same doses, the same keys");
11465 assert_eq!(now.iter().map(|d| d.1.as_str()).collect::<Vec<_>>(), ["1 tablet", "2 tablets"]);
11466 }
11467
11468 #[test]
11473 fn do_not_disturb_travels_each_device_its_table_and_the_list_a_person_at_a_time() {
11474 use sioul_core::everywhere::{People, Person, Switch, change_own, change_people};
11475 let base = scratch("dnd");
11476 let folder = base.join("Sioul");
11477 let key = quick_key(&folder, "four words make a passphrase").unwrap();
11478 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
11479 let switch = |c: &Computer| c.roots.state.join(sioul_core::everywhere::SWITCH_FILE);
11480 let people = |c: &Computer| c.roots.config.join(sioul_core::everywhere::PEOPLE_FILE);
11481 change_own(&switch(&desk), &desk.id, |s| {
11483 s.press(&desk.id, true, 0, NOW);
11484 true
11485 })
11486 .unwrap();
11487 change_people(&people(&phone), |p| {
11488 p.add(Person { name: "Alice".into(), phones: vec!["+262639981234".into()], ..Person::default() }, None);
11489 })
11490 .unwrap();
11491 change_people(&people(&desk), |p| {
11492 p.add(Person { name: "Bob".into(), emails: vec!["bob@example.org".into()], ..Person::default() }, None);
11493 })
11494 .unwrap();
11495 desk.exchange(&folder, &key, NOW + MINUTE);
11496 phone.exchange(&folder, &key, NOW + 2 * MINUTE);
11497 desk.exchange(&folder, &key, NOW + 3 * MINUTE);
11498 let heard = Switch::read(&switch(&phone)).unwrap();
11499 let latest = heard.latest().unwrap();
11500 assert!(latest.on && latest.from == desk.id, "{heard:?}");
11501 for c in [&desk, &phone] {
11502 let names: Vec<String> = People::read(&people(c)).unwrap().people.into_iter().map(|p| p.name).collect();
11503 assert!(names.contains(&"Alice".to_string()) && names.contains(&"Bob".to_string()), "{names:?}");
11504 }
11505 change_own(&switch(&phone), &phone.id, |s| {
11507 s.press(&phone.id, false, 0, NOW + 4 * MINUTE);
11508 true
11509 })
11510 .unwrap();
11511 phone.exchange(&folder, &key, NOW + 5 * MINUTE);
11512 desk.exchange(&folder, &key, NOW + 6 * MINUTE);
11513 let seen = Switch::read(&switch(&desk)).unwrap();
11514 assert!(!seen.latest().unwrap().on, "{seen:?}");
11515 assert!(seen.device[&desk.id].on, "the desk's own table as it wrote it");
11516 let bob = People::read(&people(&desk)).unwrap().people.into_iter().find(|p| p.name == "Bob").unwrap().id;
11518 change_people(&people(&desk), |p| {
11519 p.remove(&bob);
11520 })
11521 .unwrap();
11522 desk.exchange(&folder, &key, NOW + 7 * MINUTE);
11523 phone.exchange(&folder, &key, NOW + 8 * MINUTE);
11524 assert_eq!(People::read(&people(&phone)).unwrap().people.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Alice"]);
11525 let old = Computer::new(&base, "old");
11527 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();
11528 exchange(&Sharing { folder: &folder, computer: &old.id, key: &key, memory: &old.memory, files: true, hurry: None }, &older, NOW + 9 * MINUTE).unwrap();
11529 assert!(!switch(&old).exists() && !people(&old).exists());
11530 desk.exchange(&folder, &key, NOW + 10 * MINUTE);
11531 assert_eq!(People::read(&people(&desk)).unwrap().people.len(), 1);
11532 assert_eq!(Switch::read(&switch(&desk)).unwrap().device.len(), 2);
11533 let _ = std::fs::remove_dir_all(&base);
11534 }
11535
11536 #[test]
11539 fn a_computers_table_written_as_sioul_quits_reaches_the_phone_as_closed() {
11540 use sioul_core::everywhere::{Follows, Switch, change_own, others};
11541 let base = scratch("dnd-closed");
11542 let folder = base.join("Sioul");
11543 let key = quick_key(&folder, "four words make a passphrase").unwrap();
11544 let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
11545 let switch = |c: &Computer| c.roots.state.join(sioul_core::everywhere::SWITCH_FILE);
11546 change_own(&switch(&phone), &phone.id, |s| {
11548 s.press(&phone.id, true, 0, NOW);
11549 true
11550 })
11551 .unwrap();
11552 phone.exchange(&folder, &key, NOW + MINUTE);
11553 desk.exchange(&folder, &key, NOW + 2 * MINUTE);
11554 change_own(&switch(&desk), &desk.id, |s| {
11555 let own = s.device.entry(desk.id.clone()).or_default();
11556 (own.kind, own.why, own.silenced, own.at) = ("computer".into(), "manual".into(), true, NOW + 2 * MINUTE);
11557 true
11558 })
11559 .unwrap();
11560 desk.exchange(&folder, &key, NOW + 3 * MINUTE);
11561 phone.exchange(&folder, &key, NOW + 4 * MINUTE);
11562 let read = |at: i64| {
11563 let s = Switch::read(&switch(&phone)).unwrap();
11564 others(&s, &phone.id, &[(desk.id.clone(), at / 1000)], at / 1000, NOW, &|_| true)
11565 };
11566 assert_eq!(read(NOW + 4 * MINUTE), vec![(desk.id.clone(), Follows::Silenced)]);
11567 change_own(&switch(&desk), &desk.id, |s| {
11569 let own = s.device.get_mut(&desk.id).unwrap();
11570 own.closing(false);
11571 own.at = NOW + 5 * MINUTE;
11572 true
11573 })
11574 .unwrap();
11575 desk.exchange(&folder, &key, NOW + 5 * MINUTE);
11576 phone.exchange(&folder, &key, NOW + 6 * MINUTE);
11577 assert_eq!(read(NOW + 6 * MINUTE), vec![(desk.id.clone(), Follows::Closed)]);
11578 let table = &Switch::read(&switch(&phone)).unwrap().device[&desk.id];
11579 assert!(table.closed && !table.silenced, "{table:?}");
11580 let _ = std::fs::remove_dir_all(&base);
11581 }
11582}
11583
11584#[cfg(test)]
11585#[path = "share_format_tests.rs"]
11586mod format_tests;
11587
11588#[cfg(test)]
11589#[path = "share_switch_tests.rs"]
11590mod switch_tests;
11591
11592#[cfg(test)]
11593#[path = "share_switch_more_tests.rs"]
11594mod switch_more_tests;
11595
11596#[cfg(test)]
11597#[path = "share_switch_third_tests.rs"]
11598mod switch_third_tests;
11599
11600#[cfg(test)]
11601#[path = "share_quiet_tests.rs"]
11602mod quiet_tests;
11603
11604#[cfg(test)]
11605#[path = "share_idle_bench.rs"]
11606mod idle_bench;
11607
11608#[cfg(test)]
11609#[path = "share_correctness_tests.rs"]
11610mod correctness_tests;
11611
11612#[cfg(test)]
11613#[path = "share_storage_tests.rs"]
11614mod storage_tests;
11615
11616#[cfg(test)]
11617#[path = "share_rounds_tests.rs"]
11618mod rounds_tests;
11619
11620#[cfg(test)]
11621#[path = "share_gc_tests.rs"]
11622mod gc_tests;
11623
11624#[cfg(test)]
11625#[path = "share_drafts_tests.rs"]
11626mod drafts_tests;
11627
11628#[cfg(test)]
11629mod probe {
11630 #[test]
11632 #[ignore]
11633 fn probe() {
11634 let Some(base) = std::env::var_os("SIOUL_SHARE_PROBE").map(std::path::PathBuf::from) else { return };
11635 let roots = super::Roots { config: base.join("config"), data: base.join("data"), state: base.join("state") };
11636 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();
11637 let stores = super::stores(&config, &roots);
11638 let look = super::Look { reads: &|_| true, hurry: &|| false, confirmed: &Default::default(), clock: super::clock_ms(), changing: &Default::default() };
11639 let found = super::gather(&stores, &Default::default(), &look);
11640 for store in &stores {
11641 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();
11642 println!("{:32} {count}", store.name);
11643 }
11644 println!("unknown: {:?}", found.unknown);
11645 }
11646}