secrets manager
git clone https://git.lucas.co/cce-secrets.git
src/bin/cce-keyring-sync/sync.rs (28.9K)
1 //! The three-way merge against an `Interchange` — the 1Password path.
2 //!
3 //! Same table as KEYRING-SYNC.md's, keyed by the interchange's item id
4 //! (`op-item` on the keyring side). Unlike the kdbx merge this replaced,
5 //! there is no file, so nothing is batched — every remote write is
6 //! one `op` call, and the first remote failure stops the apply loop with the
7 //! base snapshot kept for everything not yet applied, so the next run
8 //! re-plans from the same place. Conflict losers need no History push:
9 //! 1Password records item history on every edit.
10 //!
11 //! Change detection on the remote side is by `updated_at`: an entry whose
12 //! timestamp still equals the base's is unchanged and never fetched. The
13 //! keyring side does the same by `Modified`, which gnome-keyring bumps on
14 //! every label, attribute and secret edit and leaves alone on a read
15 //! (measured 2026-10-06): an item whose time still equals the base's is not
16 //! read past its attributes. So a quiet tick is one `op item list`, two
17 //! keyring calls per item, and no secrets — until 2026-10-06 it read and
18 //! decrypted every password (~1,500 calls for 379 items) to find nothing.
19
20 use std::collections::HashMap;
21
22 use secret_service::{EncryptionType, SecretService};
23
24 use crate::adopt::{OP_ITEM_ATTR, OP_VAULT_ATTR};
25 use crate::op::{Interchange, RemoteEntry};
26 use crate::{journal_append, keyring_get, now_unix, take_lock, write_state, EntryState, KrEntry, State, APP};
27
28 /// Allowed clock skew before "newer" means anything (the keyring's
29 /// `Modified` is local time; the remote's is the server's).
30 pub const SKEW_TOLERANCE_SECS: i64 = 3;
31
32 /// Items the remote lists that must never reach the keyring: 1Password's
33 /// own account item carries the Secret Key and account password.
34 pub fn excluded_title(title: &str) -> bool {
35 title.starts_with("1Password Account")
36 }
37
38 /// What one entry needs done.
39 #[derive(Debug, Clone, Copy, PartialEq)]
40 pub enum Plan {
41 ToKeyring,
42 ToRemote,
43 /// Born in the keyring (or resurrected there): create remotely, stamp.
44 CreateRemote,
45 /// Both changed: newer wins, tie to the remote.
46 ConflictRemoteWins,
47 ConflictKeyringWins,
48 DeleteKeyring,
49 RecycleRemote,
50 InSync,
51 /// Gone on both sides: drop the base.
52 Forget,
53 }
54
55 /// One side's view of an entry for planning: its field hash and mtime.
56 #[derive(Debug, Clone, PartialEq)]
57 pub struct Side {
58 pub hash: String,
59 pub time: i64,
60 }
61
62 /// The merge table, pure. `base` is the last-synced hash, if any.
63 pub fn plan(base: Option<&str>, remote: Option<&Side>, keyring: Option<&Side>) -> Plan {
64 let newer_remote = |r: &Side, k: &Side| (r.time - k.time).abs() <= SKEW_TOLERANCE_SECS || r.time >= k.time;
65 match (base, remote, keyring) {
66 (None, Some(_), None) => Plan::ToKeyring,
67 (None, None, Some(_)) => Plan::CreateRemote,
68 (None, Some(r), Some(k)) => {
69 // Stamped but no base (a run died before writing state).
70 if r.hash == k.hash {
71 Plan::InSync
72 } else if newer_remote(r, k) {
73 Plan::ConflictRemoteWins
74 } else {
75 Plan::ConflictKeyringWins
76 }
77 }
78 (Some(b), Some(r), Some(k)) => match (r.hash != b, k.hash != b) {
79 (false, false) => Plan::InSync,
80 (true, false) => Plan::ToKeyring,
81 (false, true) => Plan::ToRemote,
82 (true, true) => {
83 if newer_remote(r, k) {
84 Plan::ConflictRemoteWins
85 } else {
86 Plan::ConflictKeyringWins
87 }
88 }
89 },
90 // Deleted on one side; modification on the other beats deletion.
91 (Some(b), None, Some(k)) => {
92 if k.hash != b {
93 Plan::CreateRemote
94 } else {
95 Plan::DeleteKeyring
96 }
97 }
98 (Some(b), Some(r), None) => {
99 if r.hash != b {
100 Plan::ToKeyring
101 } else {
102 Plan::RecycleRemote
103 }
104 }
105 (Some(_), None, None) | (None, None, None) => Plan::Forget,
106 }
107 }
108
109 fn remote_to_kr(e: &RemoteEntry, modified: u64) -> KrEntry {
110 KrEntry {
111 title: e.title.clone(),
112 username: e.username.clone(),
113 password: e.password.clone(),
114 url: e.url.clone(),
115 notes: e.notes.clone(),
116 group: e.vault.clone(),
117 modified,
118 }
119 }
120
121 fn kr_to_remote(k: &KrEntry, id: &str, vault: &str) -> RemoteEntry {
122 RemoteEntry {
123 id: id.to_string(),
124 vault: vault.to_string(),
125 title: k.title.clone(),
126 username: k.username.clone(),
127 password: k.password.clone(),
128 url: k.url.clone(),
129 notes: k.notes.clone(),
130 updated: 0,
131 updated_raw: String::new(),
132 }
133 }
134
135 fn keyring_attrs<'a>(k: &'a KrEntry, id: &'a str, extra: &'a HashMap<String, String>) -> HashMap<&'a str, &'a str> {
136 // Keep whatever else the item carried (kdbx-uuid, xdg:schema, …).
137 let mut a: HashMap<&str, &str> = extra.iter().map(|(x, y)| (x.as_str(), y.as_str())).collect();
138 a.insert(OP_ITEM_ATTR, id);
139 a.insert(OP_VAULT_ATTR, k.group.as_str());
140 a.insert("UserName", k.username.as_str());
141 a.insert("URL", k.url.as_str());
142 a.insert("Notes", k.notes.as_str());
143 a
144 }
145
146 struct Local<'a> {
147 item: secret_service::Item<'a>,
148 attrs: HashMap<String, String>,
149 modified: u64,
150 /// The item's synced fields, or `None` when its `Modified` still equals
151 /// the base's: unchanged since the last sync, so its hash is the base's
152 /// and its label and secret were never read. Every plan that writes
153 /// keyring fields elsewhere reads them first ([`ensure_read`]).
154 entry: Option<KrEntry>,
155 }
156
157 /// Whether a keyring item can stand on its base unread: it has one, and its
158 /// `Modified` is the one the base recorded. A zero time is never trusted —
159 /// it is what a failed read once defaulted to.
160 fn unchanged_since_base(base: Option<&EntryState>, modified: u64) -> bool {
161 base.is_some_and(|b| modified != 0 && b.keyring_modified == modified)
162 }
163
164 /// Read an item's synced fields. Any failure stops the pass (see the
165 /// snapshot below): a read that could not be answered never stands in as
166 /// an empty value.
167 async fn read_entry(
168 item: &secret_service::Item<'_>,
169 attrs: &HashMap<String, String>,
170 modified: u64,
171 vault: &str,
172 ) -> Result<KrEntry, String> {
173 let unreadable = |what: &str, e: &dyn std::fmt::Display| {
174 let which = attrs.get(OP_ITEM_ATTR).map(String::as_str).unwrap_or("an unpaired item");
175 format!("reading the {what} of {which} failed: {e}; nothing synced this pass")
176 };
177 let title = item.get_label().await.map_err(|e| unreadable("title", &e))?;
178 let secret = item.get_secret().await.map_err(|e| unreadable("password", &e))?;
179 Ok(KrEntry {
180 title,
181 username: attrs.get("UserName").cloned().unwrap_or_default(),
182 password: String::from_utf8_lossy(&secret).into_owned(),
183 url: attrs.get("URL").cloned().unwrap_or_default(),
184 notes: attrs.get("Notes").cloned().unwrap_or_default(),
185 group: attrs.get(OP_VAULT_ATTR).cloned().unwrap_or_else(|| vault.to_string()),
186 modified,
187 })
188 }
189
190 /// A local item's fields, reading them now if the snapshot skipped them.
191 async fn ensure_read(l: &mut Local<'_>, vault: &str) -> Result<(), String> {
192 if l.entry.is_none() {
193 l.entry = Some(read_entry(&l.item, &l.attrs, l.modified, vault).await?);
194 }
195 Ok(())
196 }
197
198 /// One merge pass. Always writes the state file on a real run (with
199 /// `last_result` set to the outcome, success or not) unless it could not
200 /// even start. Returns the one-line summary, or the error.
201 /// Removals one pass may make before it is refused: deletions from the
202 /// keyring plus archives in 1Password, at most 10% of the synced entries
203 /// and never fewer than this.
204 const MASS_REMOVAL_FLOOR: usize = 5;
205
206 /// Whether a pass removing `removals` items, with `synced` entries in the
207 /// base, looks like a mistake rather than an edit.
208 ///
209 /// Nothing else bounded it. The vault is named, not pinned by id, and `op`
210 /// uses its default account, so a second account with its own "Personal"
211 /// vault becoming the default would list entirely different items: every
212 /// synced entry would read as deleted in 1Password, and the pass would
213 /// hard-delete all of them from the keyring. People delete a few items at a
214 /// time; a pass that would remove a tenth of everything stops and says so.
215 pub fn is_mass_removal(removals: usize, synced: usize) -> bool {
216 removals > MASS_REMOVAL_FLOOR.max(synced / 10)
217 }
218
219 pub async fn sync_remote<I: Interchange>(
220 remote: &mut I,
221 state_path: &std::path::Path,
222 state: &mut State,
223 dry_run: bool,
224 allow_mass_removal: bool,
225 ) -> Result<String, String> {
226 let Some(_lock) = take_lock() else {
227 return Err("another cce-keyring-sync is running".into());
228 };
229 if state.backend != "onepassword" {
230 return Err("the sync base is not 1Password's — run `cce-keyring-sync adopt` first".into());
231 }
232 let vault = state.vault.clone();
233
234 let ss = SecretService::connect(EncryptionType::Dh)
235 .await
236 .map_err(|e| format!("Secret Service unavailable: {e}"))?;
237 let hash_key: [u8; 32] = match keyring_get(&ss, "state-hash-key").await {
238 Ok(Some(b)) if b.len() == 32 => b.try_into().unwrap(),
239 _ => return Err("no state hash key — run `cce-keyring-sync adopt` first".into()),
240 };
241 let col = ss.get_default_collection().await.map_err(|e| format!("no default collection: {e}"))?;
242 // A read the keyring could not answer must stop the pass, never stand
243 // in as an empty value. Until 2026-10-02 every read below fell back to a
244 // default — a lock state of "unlocked", an empty password and title, a
245 // modified time of 0 — so a keyring that locked or restarted mid-pass
246 // read as every item having been blanked here, which the plan then
247 // pushed to 1Password as edits (and other machines pulled back). An item
248 // whose attributes failed to read was skipped, which read as deleted and
249 // archived its 1Password copy. A pass that errs writes nothing: the
250 // snapshot is taken before any plan is applied, and the daemon retries.
251 let locked = col.is_locked().await.map_err(|e| format!("cannot tell whether the keyring is locked: {e}"))?;
252 if locked && col.unlock().await.is_err() {
253 return Err("collection locked".into());
254 }
255
256 // ---- keyring snapshot ----
257 let mut kr: HashMap<String, Local<'_>> = HashMap::new();
258 let mut born: Vec<Local<'_>> = Vec::new();
259 for item in col.get_all_items().await.map_err(|e| format!("listing collection failed: {e}"))? {
260 let attrs = item
261 .get_attributes()
262 .await
263 .map_err(|e| format!("reading an item's attributes failed: {e}; nothing synced this pass"))?;
264 if attrs.get("application").map(String::as_str) == Some(APP) {
265 continue;
266 }
267 let id = attrs.get(OP_ITEM_ATTR).cloned();
268 if id.is_none() && !attrs.contains_key("UserName") && !attrs.contains_key("kdbx-uuid") {
269 continue; // some other app's item — never ours to sync
270 }
271 let modified = item.get_modified().await.map_err(|e| {
272 let which = attrs.get(OP_ITEM_ATTR).map(String::as_str).unwrap_or("an unpaired item");
273 format!("reading the modified time of {which} failed: {e}; nothing synced this pass")
274 })?;
275 // Unchanged since the base: the label and the secret are not read.
276 let unchanged = unchanged_since_base(id.as_ref().and_then(|id| state.entries.get(id)), modified);
277 let entry = if unchanged { None } else { Some(read_entry(&item, &attrs, modified, &vault).await?) };
278 let local = Local { item, attrs, modified, entry };
279 match id {
280 Some(id) => {
281 // Two items with one stamp (a tool that re-created rather than
282 // edited): the newer one is the person's latest word.
283 let newer = kr.get(&id).is_none_or(|old| local.modified >= old.modified);
284 if newer {
285 kr.insert(id, local);
286 }
287 }
288 None => born.push(local),
289 }
290 }
291
292 // ---- remote snapshot: the list, then fetches only where needed ----
293 let summaries = remote.list().await?;
294 let mut rs: HashMap<String, crate::op::RemoteSummary> = HashMap::new();
295 for s in summaries {
296 if excluded_title(&s.title) {
297 continue;
298 }
299 rs.insert(s.id.clone(), s);
300 }
301 let mut fetched: HashMap<String, RemoteEntry> = HashMap::new();
302 let mut fetches = 0usize;
303
304 let mut ids: Vec<String> = state.entries.keys().chain(rs.keys()).chain(kr.keys()).cloned().collect();
305 ids.sort();
306 ids.dedup();
307
308 // ---- plan ----
309 let mut plans: Vec<(String, Plan)> = Vec::new();
310 for id in &ids {
311 let base = state.entries.get(id);
312 let k_side = kr.get(id).map(|l| Side {
313 hash: match &l.entry {
314 Some(e) => e.hash(&hash_key),
315 // Not read: unchanged since the base, which is what it hashed to.
316 None => base.map(|b| b.h.clone()).unwrap_or_default(),
317 },
318 time: l.modified as i64,
319 });
320 let r_side = match rs.get(id) {
321 None => None,
322 Some(s) => {
323 let unchanged = base.is_some_and(|b| !b.op_updated_at.is_empty() && b.op_updated_at == s.updated_raw);
324 if unchanged {
325 Some(Side { hash: base.unwrap().h.clone(), time: s.updated })
326 } else {
327 let e = remote.fetch(id).await?;
328 fetches += 1;
329 let h = remote_to_kr(&e, 0).hash(&hash_key);
330 fetched.insert(id.clone(), e);
331 Some(Side { hash: h, time: s.updated })
332 }
333 }
334 };
335 plans.push((id.clone(), plan(base.map(|b| b.h.as_str()), r_side.as_ref(), k_side.as_ref())));
336 }
337
338 // The plans that copy keyring fields out need them read. An item left
339 // unread hashes to its base, so these never pick one — but the fields
340 // are read rather than trusted to that.
341 for (id, p) in &plans {
342 if matches!(p, Plan::ToRemote | Plan::ConflictKeyringWins | Plan::CreateRemote) {
343 if let Some(l) = kr.get_mut(id) {
344 ensure_read(l, &vault).await?;
345 }
346 }
347 }
348 let kr = kr;
349
350 // ---- report ----
351 let title_of = |id: &str| -> String {
352 rs.get(id)
353 .map(|s| s.title.clone())
354 .or_else(|| kr.get(id).and_then(|l| l.entry.as_ref()).map(|e| e.title.clone()))
355 .unwrap_or_else(|| id.to_string())
356 };
357 let mut journal = String::new();
358 let mut counts: HashMap<&'static str, usize> = HashMap::new();
359 for (id, p) in &plans {
360 let verb = match p {
361 Plan::ToKeyring => "1Password -> keyring",
362 Plan::ToRemote => "keyring -> 1Password",
363 Plan::CreateRemote => "create in 1Password",
364 Plan::ConflictRemoteWins => "CONFLICT: 1Password wins (loser in item history)",
365 Plan::ConflictKeyringWins => "CONFLICT: keyring wins (loser in item history)",
366 Plan::DeleteKeyring => "delete from keyring",
367 Plan::RecycleRemote => "archive in 1Password",
368 Plan::InSync | Plan::Forget => continue,
369 };
370 *counts
371 .entry(match p {
372 Plan::ToKeyring | Plan::ConflictRemoteWins => "to-keyring",
373 Plan::ToRemote | Plan::ConflictKeyringWins => "to-remote",
374 Plan::CreateRemote => "created",
375 Plan::DeleteKeyring => "deleted",
376 Plan::RecycleRemote => "archived",
377 _ => unreachable!(),
378 })
379 .or_default() += 1;
380 println!(" {verb}: {}", title_of(id));
381 journal.push_str(&format!("{} sync {verb}: {}\n", now_unix(), title_of(id)));
382 }
383 let full = |l: &Local<'_>| -> KrEntry { l.entry.clone().expect("read before the plan used it") };
384 for l in &born {
385 let title = &l.entry.as_ref().expect("born items are always read").title;
386 println!(" create in 1Password: {title}");
387 journal.push_str(&format!("{} sync create in 1Password: {title}\n", now_unix()));
388 *counts.entry("created").or_default() += 1;
389 }
390 let c = |k: &str| counts.get(k).copied().unwrap_or(0);
391 let quiet = plans.iter().all(|(_, p)| matches!(p, Plan::InSync | Plan::Forget)) && born.is_empty();
392 let summary = if quiet {
393 "in sync".to_string()
394 } else {
395 format!(
396 "synced: {} -> keyring, {} -> 1Password, {} created, {} deleted, {} archived",
397 c("to-keyring"),
398 c("to-remote"),
399 c("created"),
400 c("deleted"),
401 c("archived")
402 )
403 };
404 if dry_run {
405 println!("{summary} (dry run — nothing changed; {fetches} fetched)");
406 return Ok(summary);
407 }
408 let removals = c("deleted") + c("archived");
409 if !allow_mass_removal && is_mass_removal(removals, state.entries.len()) {
410 return Err(format!(
411 "refusing a pass that would remove {removals} of {} synced items ({}) — nothing changed. \
412 If 1Password's default account or vault changed, fix that; if the removals are meant, \
413 run `cce-keyring-sync sync --dry-run` to review them, then `cce-keyring-sync sync --allow-mass-delete`",
414 state.entries.len(),
415 summary
416 ));
417 }
418
419 // ---- apply ----
420 // `next` starts as the old base and is rewritten entry by entry, so a
421 // remote failure mid-way leaves untouched entries with their old base.
422 let mut next: HashMap<String, EntryState> = state.entries.drain().collect();
423 let mut failure: Option<String> = None;
424 let mut applied = 0usize;
425
426 let snapshot = |k: &KrEntry, updated_raw: String, keyring_modified: u64| EntryState {
427 h: k.hash(&hash_key),
428 keyring_modified,
429 op_updated_at: updated_raw,
430 };
431 'apply: for (id, p) in &plans {
432 match p {
433 Plan::InSync => {
434 // Keep the base timestamp on the list's value: it was unknown
435 // after adopt (the drift marker), and the server may stamp a
436 // write a second later than the reply we recorded. Either way
437 // the entry would be fetched every tick until this catches up.
438 if let (Some(s), Some(b)) = (rs.get(id), next.get_mut(id)) {
439 if b.op_updated_at != s.updated_raw {
440 b.op_updated_at = s.updated_raw.clone();
441 }
442 }
443 // The keyring's time likewise: an item that was read (its
444 // time moved) yet hashes to the base was touched without a
445 // change. Recording the time lets the next pass skip it.
446 if let (Some(l), Some(b)) = (kr.get(id), next.get_mut(id)) {
447 if l.entry.is_some() && b.keyring_modified != l.modified {
448 b.keyring_modified = l.modified;
449 }
450 }
451 }
452 Plan::Forget => {
453 next.remove(id);
454 }
455 Plan::ToKeyring | Plan::ConflictRemoteWins => {
456 let e = match fetched.get(id) {
457 Some(e) => e.clone(),
458 None => match remote.fetch(id).await {
459 Ok(e) => {
460 fetches += 1;
461 e
462 }
463 Err(err) => {
464 failure = Some(err);
465 break 'apply;
466 }
467 },
468 };
469 let k = remote_to_kr(&e, 0);
470 let empty = HashMap::new();
471 let modified = match kr.get(id) {
472 Some(l) => {
473 let attrs = keyring_attrs(&k, id, &l.attrs);
474 let r = async {
475 l.item.set_label(&k.title).await?;
476 l.item.set_attributes(attrs).await?;
477 l.item.set_secret(k.password.as_bytes(), "text/plain").await?;
478 l.item.get_modified().await
479 }
480 .await;
481 match r {
482 Ok(m) => m,
483 Err(err) => {
484 eprintln!(" keyring write failed for {}: {err}", k.title);
485 continue;
486 }
487 }
488 }
489 None => {
490 let attrs = keyring_attrs(&k, id, &empty);
491 match col.create_item(&k.title, attrs, k.password.as_bytes(), true, "text/plain").await {
492 Ok(item) => item.get_modified().await.unwrap_or(now_unix() as u64),
493 Err(err) => {
494 eprintln!(" keyring create failed for {}: {err}", k.title);
495 continue;
496 }
497 }
498 }
499 };
500 next.insert(id.clone(), snapshot(&k, e.updated_raw.clone(), modified));
501 applied += 1;
502 }
503 Plan::ToRemote | Plan::ConflictKeyringWins => {
504 let k = full(&kr[id]);
505 let e = kr_to_remote(&k, id, &k.group);
506 match remote.update(&e).await {
507 Ok(updated_raw) => {
508 next.insert(id.clone(), snapshot(&k, updated_raw, k.modified));
509 applied += 1;
510 }
511 Err(err) => {
512 failure = Some(err);
513 break 'apply;
514 }
515 }
516 }
517 Plan::CreateRemote => {
518 // A keyring entry whose stamp points at nothing any more
519 // (archived remotely, edited locally): create afresh, restamp.
520 let l = &kr[id];
521 let entry = full(l);
522 let mut e = kr_to_remote(&entry, "", &vault);
523 e.vault = vault.clone();
524 match remote.create(&e).await {
525 Ok((new_id, updated_raw)) => {
526 let mut k = entry.clone();
527 k.group = vault.clone();
528 let attrs = keyring_attrs(&k, &new_id, &l.attrs);
529 let modified = match async {
530 l.item.set_attributes(attrs).await?;
531 l.item.get_modified().await
532 }
533 .await
534 {
535 Ok(m) => m,
536 Err(err) => {
537 eprintln!(" could not restamp {}: {err}", k.title);
538 entry.modified
539 }
540 };
541 next.remove(id);
542 next.insert(new_id, snapshot(&k, updated_raw, modified));
543 applied += 1;
544 }
545 Err(err) => {
546 failure = Some(err);
547 break 'apply;
548 }
549 }
550 }
551 Plan::DeleteKeyring => {
552 let l = &kr[id];
553 match l.item.delete().await {
554 Ok(()) => {
555 next.remove(id);
556 applied += 1;
557 }
558 Err(err) => eprintln!(" keyring delete failed for {}: {err}", title_of(id)),
559 }
560 }
561 Plan::RecycleRemote => match remote.recycle(id).await {
562 Ok(()) => {
563 next.remove(id);
564 applied += 1;
565 }
566 Err(err) => {
567 failure = Some(err);
568 break 'apply;
569 }
570 },
571 }
572 }
573 if failure.is_none() {
574 for l in &born {
575 let mut k = full(l);
576 k.group = vault.clone();
577 let e = kr_to_remote(&k, "", &vault);
578 match remote.create(&e).await {
579 Ok((new_id, updated_raw)) => {
580 let attrs = keyring_attrs(&k, &new_id, &l.attrs);
581 let modified = match async {
582 l.item.set_attributes(attrs).await?;
583 l.item.get_modified().await
584 }
585 .await
586 {
587 Ok(m) => m,
588 Err(err) => {
589 eprintln!(" could not stamp {}: {err}", k.title);
590 l.modified
591 }
592 };
593 next.insert(new_id, snapshot(&k, updated_raw, modified));
594 applied += 1;
595 }
596 Err(err) => {
597 failure = Some(err);
598 break;
599 }
600 }
601 }
602 }
603
604 state.entries = next;
605 state.last_run = now_unix();
606 let result = match failure {
607 None => Ok(summary.clone()),
608 Some(err) => {
609 let changes = plans.iter().filter(|(_, p)| !matches!(p, Plan::InSync | Plan::Forget)).count() + born.len();
610 Err(format!("{err} (after {applied} of {changes} changes; the rest retry next run)"))
611 }
612 };
613 state.last_result = match &result {
614 Ok(s) => s.clone(),
615 Err(e) => format!("failed: {e}"),
616 };
617 write_state(state_path, state);
618 if !journal.is_empty() {
619 journal_append(&journal);
620 }
621 if let Err(e) = &result {
622 journal_append(&format!("{} sync FAILED: {e}\n", now_unix()));
623 }
624 println!("{} ({fetches} fetched)", state.last_result);
625 result
626 }
627
628 #[cfg(test)]
629 mod mass_removal_tests {
630 use super::is_mass_removal;
631
632 #[test]
633 fn a_pass_removing_a_tenth_of_the_vault_is_refused() {
634 // The live vault is ~378 entries: 37 removals pass, 38 stop.
635 assert!(!is_mass_removal(37, 378));
636 assert!(is_mass_removal(38, 378));
637 // Every entry reading as gone — the wrong-account case.
638 assert!(is_mass_removal(378, 378));
639 // Small vaults still allow a handful.
640 assert!(!is_mass_removal(5, 12));
641 assert!(is_mass_removal(6, 12));
642 assert!(!is_mass_removal(0, 0));
643 }
644 }
645
646 #[cfg(test)]
647 mod tests {
648 use super::*;
649
650 fn side(h: &str, t: i64) -> Side {
651 Side { hash: h.into(), time: t }
652 }
653
654 #[test]
655 fn the_merge_table() {
656 let b = Some("B");
657 assert_eq!(plan(None, Some(&side("R", 0)), None), Plan::ToKeyring);
658 assert_eq!(plan(None, None, Some(&side("K", 0))), Plan::CreateRemote);
659 assert_eq!(plan(b, Some(&side("B", 0)), Some(&side("B", 0))), Plan::InSync);
660 assert_eq!(plan(b, Some(&side("R", 0)), Some(&side("B", 0))), Plan::ToKeyring);
661 assert_eq!(plan(b, Some(&side("B", 0)), Some(&side("K", 0))), Plan::ToRemote);
662 assert_eq!(plan(b, None, Some(&side("B", 0))), Plan::DeleteKeyring);
663 assert_eq!(plan(b, None, Some(&side("K", 0))), Plan::CreateRemote, "modification beats deletion");
664 assert_eq!(plan(b, Some(&side("B", 0)), None), Plan::RecycleRemote);
665 assert_eq!(plan(b, Some(&side("R", 0)), None), Plan::ToKeyring, "modification beats deletion");
666 assert_eq!(plan(b, None, None), Plan::Forget);
667 assert_eq!(plan(None, None, None), Plan::Forget);
668 }
669
670 #[test]
671 fn conflicts_go_to_the_newer_side_and_ties_to_the_remote() {
672 let b = Some("B");
673 assert_eq!(plan(b, Some(&side("R", 100)), Some(&side("K", 50))), Plan::ConflictRemoteWins);
674 assert_eq!(plan(b, Some(&side("R", 50)), Some(&side("K", 100))), Plan::ConflictKeyringWins);
675 assert_eq!(plan(b, Some(&side("R", 98)), Some(&side("K", 100))), Plan::ConflictRemoteWins, "inside the skew tolerance is a tie");
676 assert_eq!(plan(b, Some(&side("R", 100)), Some(&side("K", 100))), Plan::ConflictRemoteWins);
677 }
678
679 #[test]
680 fn a_stamped_entry_without_a_base_is_reconciled_by_hash() {
681 assert_eq!(plan(None, Some(&side("X", 0)), Some(&side("X", 0))), Plan::InSync);
682 assert_eq!(plan(None, Some(&side("R", 10)), Some(&side("K", 0))), Plan::ConflictRemoteWins);
683 }
684
685 #[test]
686 fn an_item_is_skipped_only_at_its_base_time() {
687 let base = EntryState { h: "B".into(), keyring_modified: 1_790_000_000, op_updated_at: String::new() };
688 assert!(unchanged_since_base(Some(&base), 1_790_000_000));
689 assert!(!unchanged_since_base(Some(&base), 1_790_000_001), "edited since: read it");
690 assert!(!unchanged_since_base(None, 1_790_000_000), "no base: read it");
691 let zero = EntryState { keyring_modified: 0, ..base };
692 assert!(!unchanged_since_base(Some(&zero), 0), "a zero time proves nothing");
693 }
694
695 #[test]
696 fn the_account_item_is_excluded() {
697 assert!(excluded_title("1Password Account (alice)"));
698 assert!(!excluded_title("Account at 1Password"));
699 }
700 }