git.lucas.co / cce-secrets
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 }