secrets manager
git clone https://git.lucas.co/cce-secrets.git
feat: cce-keyring-sync phase 2 — bidirectional sync, doctor, timer
The three-way merge from KEYRING-SYNC.md, implemented as specified: state
snapshot as base, per-entry change detection by keyed hash on both sides,
newer-wins on true conflicts with the loser preserved as a kdbx History
revision, deletions to the Recycle Bin, modification beating deletion.
Keyring items that look cce-secrets-born (a UserName attribute, no
kdbx-uuid, not our own bookkeeping) are adopted into the kdbx with a
minted UUID stamped back onto the item.
Ordering is the safety story: the kdbx is saved first — temp, fsync,
rename, aborted if the on-disk file no longer hashes to what this run
read, and the serialized bytes are re-opened with the same password
before they may replace the real file — then the keyring is written, then
the state. A failed save leaves the keyring untouched; failed keyring
writes fall out of the new state and retry next run.
Exercised end to end in an isolated dbus-run-session keyring, all eight
scenarios: edit propagation both directions, idempotence, keyring
deletion landing in the kdbx Recycle Bin (confirmed in keepassxc-cli's
own recursive listing, which also proves a database written by the
keepass crate re-opens in the reference implementation), new entries both
ways, adoption, conflicted-copy refusal pointing at doctor, and doctor
merging a staged conflicted copy via keepassxc-cli --same-credentials
with backups taken first and the copy archived out of Dropbox.
The fixture caught a real gap: keepassxc-cli db-create produces KDBX3,
which the crate will not serialize. Pre-4 databases are now upgraded
in-memory to current v4 defaults on the first write, loudly. The user's
real database is already 4.1, so for them this path never fires.
Ships systemd user units (service + 15-minute timer, randomized 90s).
Refusals — conflicted copies, Dropbox settling, the flock — exit nonzero
by design and the timer just tries again; SuccessExitStatus tolerates it.
Co-Authored-By: Claude Opus 5 <[email protected]>
Cargo.toml | 1 +
KEYRING-SYNC.md | 11 +-
cce-keyring-sync.service | 11 +
cce-keyring-sync.timer | 10 +
src/bin/cce-keyring-sync.rs | 698 +++++++++++++++++++++++++++++++++++++++++++-
5 files changed, 716 insertions(+), 15 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
index 3db6b57..edb521e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -19,3 +19,4 @@ serde_json = "1"
blake3 = "1"
rpassword = "7"
getrandom = "0.2"
+rustix = { version = "0.38", features = ["fs"] }
diff --git a/KEYRING-SYNC.md b/KEYRING-SYNC.md
index 6e74ce4..821ac45 100644
--- a/KEYRING-SYNC.md
+++ b/KEYRING-SYNC.md
@@ -4,8 +4,15 @@ Status: **phase 1 shipped** (2026-08-31) — `cce-keyring-sync import` with
`--dry-run`, `status`, the state file, quiescence + conflicted-copy detection.
Verified end to end in an isolated `dbus-run-session` keyring against a fixture
kdbx: create, idempotent re-run, update, secret round-trip, and a state file
-holding only keyed hashes. Phases 2–3 (bidirectional sync, `doctor`, timer, UI)
-remain scoping.
+holding only keyed hashes. Phase 2 shipped the same day: `sync`
+(the full three-way merge), `doctor`, and the 15-minute timer units. The whole
+merge table was exercised in an isolated keyring — kdbx→keyring edit, keyring
+deletion → kdbx Recycle Bin (verified in keepassxc-cli's own listing), new
+entries both directions, adoption of a keyring-born entry with UUID stamping,
+conflicted-copy refusal, and doctor merging a staged conflict whose entry then
+synced through. Files written by the `keepass` crate re-open in keepassxc-cli,
+and pre-KDBX4 databases are upgraded loudly on first write. Phase 3 (a Sync
+action in the cce-secrets UI) remains.
## Goal
diff --git a/cce-keyring-sync.service b/cce-keyring-sync.service
new file mode 100644
index 0000000..6a536cc
--- /dev/null
+++ b/cce-keyring-sync.service
@@ -0,0 +1,11 @@
+# Runs one bidirectional pass of cce-keyring-sync (see KEYRING-SYNC.md).
+# Driven by cce-keyring-sync.timer; harmless to start by hand.
+[Unit]
+Description=Sync gnome-keyring with the Dropbox kdbx
+
+[Service]
+Type=oneshot
+ExecStart=%h/.local/bin/cce-keyring-sync sync
+# Refusals (conflicted copies, Dropbox settling, another run holding the
+# lock) exit nonzero on purpose; the timer just tries again next tick.
+SuccessExitStatus=0 1
diff --git a/cce-keyring-sync.timer b/cce-keyring-sync.timer
new file mode 100644
index 0000000..6cabda0
--- /dev/null
+++ b/cce-keyring-sync.timer
@@ -0,0 +1,10 @@
+[Unit]
+Description=Sync gnome-keyring with the Dropbox kdbx every 15 minutes
+
+[Timer]
+OnBootSec=3min
+OnUnitActiveSec=15min
+RandomizedDelaySec=90
+
+[Install]
+WantedBy=timers.target
diff --git a/src/bin/cce-keyring-sync.rs b/src/bin/cce-keyring-sync.rs
index 0b76e6d..c3a264f 100644
--- a/src/bin/cce-keyring-sync.rs
+++ b/src/bin/cce-keyring-sync.rs
@@ -1,10 +1,11 @@
-//! cce-keyring-sync — phase 1: one-way import, kdbx -> gnome-keyring.
+//! cce-keyring-sync — keep gnome-keyring and the Dropbox kdbx in step.
//!
-//! See KEYRING-SYNC.md for the full design. This phase makes cce-secrets
-//! show the real entries: it reads the Dropbox kdbx (read-only — the file is
-//! never written), creates or updates matching items in the default Secret
-//! Service collection, and records a state snapshot that phase 2's three-way
-//! merge will use as its base.
+//! See KEYRING-SYNC.md for the design. `import` is the one-way first run;
+//! `sync` is the bidirectional three-way merge against the state snapshot;
+//! `doctor` reconciles Dropbox conflicted copies via keepassxc-cli merge.
+//! The merge never destroys a value: conflict losers go into the kdbx
+//! entry's native History, deletions into its Recycle Bin, and modification
+//! beats deletion.
//!
//! Discipline inherited from the scope:
//! - the kdbx is read once into memory; nothing holds it open;
@@ -93,10 +94,19 @@ fn conflicted_copies(kdbx: &Path) -> Vec<PathBuf> {
out
}
-fn read_kdbx(path: &Path, password: &str) -> Result<Vec<KdbxEntry>, String> {
- // One read, then the file is closed: nothing holds a Dropbox-synced
- // file open across the parse.
+/// The kdbx, read once (nothing holds a Dropbox-synced file open), plus the
+/// blake3 of the exact bytes read — the write path re-hashes the on-disk file
+/// before renaming over it, so a Dropbox delivery mid-merge aborts the save.
+struct Kdbx {
+ db: Database,
+ entries: Vec<KdbxEntry>,
+ ids: HashMap<String, keepass::db::EntryId>,
+ file_hash: blake3::Hash,
+}
+
+fn read_kdbx(path: &Path, password: &str) -> Result<Kdbx, String> {
let bytes = std::fs::read(path).map_err(|e| format!("reading {}: {e}", path.display()))?;
+ let file_hash = blake3::hash(&bytes);
let key = DatabaseKey::new().with_password(password);
let db = Database::open(&mut Cursor::new(bytes), key)
.map_err(|e| format!("opening database: {e}"))?;
@@ -106,6 +116,7 @@ fn read_kdbx(path: &Path, password: &str) -> Result<Vec<KdbxEntry>, String> {
let recycle = db.recycle_bin().map(|g| g.id());
let mut out = Vec::new();
+ let mut ids = HashMap::new();
let mut skipped_recycled = 0usize;
for entry in db.iter_all_entries() {
if recycle.is_some() && Some(entry.parent().id()) == recycle {
@@ -113,6 +124,7 @@ fn read_kdbx(path: &Path, password: &str) -> Result<Vec<KdbxEntry>, String> {
continue;
}
let group = entry.parent().name.clone();
+ ids.insert(entry.id().uuid().to_string(), entry.id());
out.push(KdbxEntry {
// EntryId is the kdbx UUID (EntryId(Uuid)); stable across machines.
uuid: entry.id().uuid().to_string(),
@@ -132,7 +144,7 @@ fn read_kdbx(path: &Path, password: &str) -> Result<Vec<KdbxEntry>, String> {
if skipped_recycled > 0 {
println!(" (skipping {skipped_recycled} recycled entries)");
}
- Ok(out)
+ Ok(Kdbx { db, entries: out, ids, file_hash })
}
fn canonical_hash(key: &[u8; 32], e: &KdbxEntry) -> String {
@@ -206,11 +218,14 @@ async fn main() {
.cloned();
let cmd = match cmd.as_deref() {
Some("import") => "import",
+ Some("sync") => "sync",
+ Some("doctor") => "doctor",
Some("status") => "status",
_ => {
- eprintln!("usage: cce-keyring-sync import [--dry-run] [--kdbx <path>]");
+ eprintln!("usage: cce-keyring-sync sync [--dry-run] [--kdbx <path>]");
+ eprintln!(" cce-keyring-sync import [--dry-run] [--kdbx <path>]");
+ eprintln!(" cce-keyring-sync doctor [--kdbx <path>]");
eprintln!(" cce-keyring-sync status");
- eprintln!("(bidirectional sync and doctor arrive in phase 2 — see KEYRING-SYNC.md)");
std::process::exit(2);
}
};
@@ -235,6 +250,15 @@ async fn main() {
return;
}
+ if cmd == "doctor" {
+ doctor(&kdbx).await;
+ return;
+ }
+ if cmd == "sync" {
+ sync(&kdbx, &state_path, state, dry_run).await;
+ return;
+ }
+
// ---- import ----
if !kdbx.exists() {
eprintln!("no database at {} (pass --kdbx <path>)", kdbx.display());
@@ -284,7 +308,7 @@ async fn main() {
println!("reading {} …", kdbx.display());
let entries = match read_kdbx(&kdbx, &password) {
- Ok(e) => e,
+ Ok(k) => k.entries,
Err(e) => {
eprintln!("{e}");
std::process::exit(1);
@@ -441,3 +465,651 @@ async fn main() {
}
println!("state: {}", state_path.display());
}
+
+// ===================== phase 2: bidirectional sync =====================
+
+/// A keyring item's synced fields, snapshotted once per run.
+struct KrEntry {
+ title: String,
+ username: String,
+ password: String,
+ url: String,
+ notes: String,
+ group: String,
+ modified: u64,
+}
+
+impl KrEntry {
+ fn hash(&self, key: &[u8; 32]) -> String {
+ let mut h = blake3::Hasher::new_keyed(key);
+ for part in [&self.title, &self.username, &self.password, &self.url, &self.notes, &self.group] {
+ h.update(part.as_bytes());
+ h.update(&[0]);
+ }
+ h.finalize().to_hex().to_string()
+ }
+}
+
+/// What one entry needs done, per KEYRING-SYNC.md's merge table.
+#[derive(Debug, Clone, Copy, PartialEq)]
+enum Plan {
+ ToKeyring,
+ ToKdbx,
+ /// Both changed: newer wins, loser into kdbx History.
+ ConflictKdbxWins,
+ ConflictKeyringWins,
+ DeleteKeyring,
+ RecycleKdbx,
+ InSync,
+}
+
+/// Allowed clock skew between machines before "newer" means anything.
+const SKEW_TOLERANCE_SECS: i64 = 3;
+
+/// A crude cross-process lock: sync and import must not interleave with a
+/// timer run. Advisory flock on a file in the state dir (local, never in
+/// Dropbox — Dropbox syncing lock files is its own disaster).
+fn take_lock() -> Option<std::fs::File> {
+ let _ = std::fs::create_dir_all(state_dir());
+ let f = std::fs::OpenOptions::new()
+ .create(true)
+ .write(true)
+ .open(state_dir().join("lock"))
+ .ok()?;
+ match rustix::fs::flock(&f, rustix::fs::FlockOperation::NonBlockingLockExclusive) {
+ Ok(()) => Some(f),
+ Err(_) => None,
+ }
+}
+
+fn journal_append(lines: &str) {
+ use std::io::Write;
+ if let Ok(mut f) = std::fs::OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open(state_dir().join("journal.log"))
+ {
+ let _ = f.write_all(lines.as_bytes());
+ }
+}
+
+/// Push the entry's current values into its own History before overwriting —
+/// the "never destroy a value" rule. Mirrors what KeePass apps do on edit.
+fn push_history(db: &mut Database, id: keepass::db::EntryId) {
+ if let Some(mut em) = db.entry_mut(id) {
+ let snapshot = (*em).clone();
+ em.history.get_or_insert_with(Default::default).add_entry(snapshot);
+ }
+}
+
+fn set_kdbx_fields(db: &mut Database, id: keepass::db::EntryId, kr: &KrEntry) {
+ use keepass::db::Value;
+ if let Some(mut em) = db.entry_mut(id) {
+ em.set("Title", Value::Unprotected(kr.title.clone()));
+ em.set("UserName", Value::Unprotected(kr.username.clone()));
+ em.set("Password", Value::protected(kr.password.clone()));
+ em.set("URL", Value::Unprotected(kr.url.clone()));
+ em.set("Notes", Value::Unprotected(kr.notes.clone()));
+ em.times.last_modification = Some(keepass::db::Times::now());
+ }
+}
+
+/// The Recycle Bin group, created if the database has never had one.
+fn recycle_bin_id(db: &mut Database) -> keepass::db::GroupId {
+ if let Some(g) = db.recycle_bin() {
+ return g.id();
+ }
+ let mut root = db.root_mut();
+ let mut g = root.add_group();
+ g.name = "Recycle Bin".to_string();
+ let id = g.as_ref().id();
+ db.meta.recyclebin_uuid = Some(id.uuid());
+ db.meta.recyclebin_enabled = Some(true);
+ id
+}
+
+/// Atomic kdbx save: temp + fsync + rename, aborted if the on-disk file no
+/// longer matches the bytes this run read (Dropbox delivered mid-merge).
+fn save_kdbx_atomic(
+ db: &mut Database,
+ path: &Path,
+ password: &str,
+ read_hash: &blake3::Hash,
+) -> Result<(), String> {
+ // The crate only writes KDBX4. An older database (keepassxc still creates
+ // KDBX3 for AES-KDF) is upgraded in-memory to the current v4 defaults —
+ // argon2, same password — which every modern KeePass app reads. One-time,
+ // loud, and only on a run that was going to write anyway.
+ if !matches!(db.config.version, keepass::config::DatabaseVersion::KDB4(_)) {
+ println!("note: upgrading database format to KDBX4 (was pre-4; modern apps read v4)");
+ db.config = keepass::config::DatabaseConfig::default();
+ }
+ let current = std::fs::read(path).map_err(|e| format!("re-reading {}: {e}", path.display()))?;
+ if blake3::hash(¤t) != *read_hash {
+ return Err("the kdbx changed on disk during the merge (Dropbox?) — aborting the save; retry".into());
+ }
+ let mut buf = Vec::new();
+ db.save(&mut buf, DatabaseKey::new().with_password(password))
+ .map_err(|e| format!("serializing kdbx: {e}"))?;
+ // Paranoia that pays for itself: the file we are about to install must
+ // itself open with the same password before it replaces the real one.
+ Database::open(&mut Cursor::new(buf.clone()), DatabaseKey::new().with_password(password))
+ .map_err(|e| format!("round-trip verification failed, NOT saving: {e}"))?;
+ let tmp = path.with_extension("kdbx.cce-tmp");
+ {
+ use std::io::Write;
+ let mut f = std::fs::File::create(&tmp).map_err(|e| format!("temp file: {e}"))?;
+ f.write_all(&buf).map_err(|e| format!("writing temp: {e}"))?;
+ f.sync_all().map_err(|e| format!("fsync: {e}"))?;
+ }
+ std::fs::rename(&tmp, path).map_err(|e| format!("rename into place: {e}"))
+}
+
+async fn sync(kdbx_path: &Path, state_path: &Path, mut state: State, dry_run: bool) {
+ let Some(_lock) = take_lock() else {
+ eprintln!("another cce-keyring-sync is running");
+ std::process::exit(1);
+ };
+ if state.entries.is_empty() {
+ eprintln!("no sync base — run `cce-keyring-sync import` first");
+ std::process::exit(1);
+ }
+ let conflicts = conflicted_copies(kdbx_path);
+ if !conflicts.is_empty() {
+ eprintln!("Dropbox conflicted copies exist — sync refuses to guess which side is real:");
+ for c in &conflicts {
+ eprintln!(" {}", c.display());
+ }
+ eprintln!("run `cce-keyring-sync doctor` first");
+ std::process::exit(1);
+ }
+ if let Ok(meta) = std::fs::metadata(kdbx_path) {
+ if let Ok(age) = meta.modified().and_then(|m| m.elapsed().map_err(std::io::Error::other)) {
+ if age.as_secs() < 5 {
+ eprintln!("kdbx changed {}s ago — letting Dropbox settle; retry shortly", age.as_secs());
+ std::process::exit(1);
+ }
+ }
+ }
+
+ let ss = match SecretService::connect(EncryptionType::Dh).await {
+ Ok(ss) => ss,
+ Err(e) => {
+ eprintln!("Secret Service unavailable: {e}");
+ std::process::exit(1);
+ }
+ };
+ let password = match keyring_get(&ss, "kdbx-password").await {
+ Ok(Some(b)) => String::from_utf8_lossy(&b).into_owned(),
+ _ => {
+ eprintln!("no stored master password — run `cce-keyring-sync import` once");
+ std::process::exit(1);
+ }
+ };
+ let hash_key: [u8; 32] = match keyring_get(&ss, "state-hash-key").await {
+ Ok(Some(b)) if b.len() == 32 => b.try_into().unwrap(),
+ _ => {
+ eprintln!("no state hash key — run `cce-keyring-sync import` once");
+ std::process::exit(1);
+ }
+ };
+
+ let mut kdbx = match read_kdbx(kdbx_path, &password) {
+ Ok(k) => k,
+ Err(e) => {
+ eprintln!("{e}");
+ std::process::exit(1);
+ }
+ };
+ let kdbx_by_uuid: HashMap<String, usize> = kdbx
+ .entries
+ .iter()
+ .enumerate()
+ .map(|(i, e)| (e.uuid.clone(), i))
+ .collect();
+
+ let col = match ss.get_default_collection().await {
+ Ok(c) => c,
+ Err(e) => {
+ eprintln!("no default collection: {e}");
+ std::process::exit(1);
+ }
+ };
+ if col.is_locked().await.unwrap_or(false) {
+ if col.unlock().await.is_err() {
+ eprintln!("collection locked");
+ std::process::exit(1);
+ }
+ }
+
+ // Keyring snapshot: synced items by uuid, plus adoption candidates —
+ // items that look like cce-secrets-born logins (a UserName attribute, no
+ // kdbx-uuid, not our own bookkeeping items).
+ let mut kr: HashMap<String, (secret_service::Item<'_>, KrEntry)> = HashMap::new();
+ let mut adopt: Vec<(secret_service::Item<'_>, KrEntry)> = Vec::new();
+ match col.get_all_items().await {
+ Ok(items) => {
+ for item in items {
+ let Ok(attrs) = item.get_attributes().await else { continue };
+ if attrs.get("application").map(String::as_str) == Some(APP) {
+ continue; // our own password/hash-key items
+ }
+ let has_user = attrs.contains_key("UserName");
+ let uuid = attrs.get("kdbx-uuid").cloned();
+ if uuid.is_none() && !has_user {
+ continue; // some other app's item — never ours to sync
+ }
+ let e = KrEntry {
+ title: item.get_label().await.unwrap_or_default(),
+ username: attrs.get("UserName").cloned().unwrap_or_default(),
+ password: String::from_utf8_lossy(&item.get_secret().await.unwrap_or_default())
+ .into_owned(),
+ url: attrs.get("URL").cloned().unwrap_or_default(),
+ notes: attrs.get("Notes").cloned().unwrap_or_default(),
+ group: attrs.get("kdbx-group").cloned().unwrap_or_default(),
+ modified: item.get_modified().await.unwrap_or(0),
+ };
+ match uuid {
+ Some(u) => {
+ kr.insert(u, (item, e));
+ }
+ None => adopt.push((item, e)),
+ }
+ }
+ }
+ Err(e) => {
+ eprintln!("listing collection failed: {e}");
+ std::process::exit(1);
+ }
+ }
+
+ // ---- plan ----
+ let mut uuids: Vec<String> = state
+ .entries
+ .keys()
+ .chain(kdbx_by_uuid.keys())
+ .chain(kr.keys())
+ .cloned()
+ .collect();
+ uuids.sort();
+ uuids.dedup();
+
+ let mut plans: Vec<(String, Plan)> = Vec::new();
+ for uuid in &uuids {
+ let base = state.entries.get(uuid);
+ let kx = kdbx_by_uuid.get(uuid).map(|&i| &kdbx.entries[i]);
+ let k = kr.get(uuid);
+ let kx_hash = kx.map(|e| canonical_hash(&hash_key, e));
+ let kr_hash = k.map(|(_, e)| e.hash(&hash_key));
+ let plan = match (base, kx, k) {
+ // Never seen: whichever side has it, the other gets it.
+ (None, Some(_), None) => Plan::ToKeyring,
+ (None, None, Some(_)) => Plan::ToKdbx,
+ (None, Some(_), Some(_)) => {
+ // Both born independently with the same uuid — cross-machine
+ // import. Fields equal → adopt as in sync; else newest wins.
+ if kx_hash == kr_hash {
+ Plan::InSync
+ } else if kx.unwrap().mtime >= k.unwrap().1.modified as i64 {
+ Plan::ConflictKdbxWins
+ } else {
+ Plan::ConflictKeyringWins
+ }
+ }
+ (Some(b), Some(_), Some(_)) => {
+ let kx_changed = kx_hash.as_deref() != Some(b.h.as_str());
+ let kr_changed = kr_hash.as_deref() != Some(b.h.as_str());
+ match (kx_changed, kr_changed) {
+ (false, false) => Plan::InSync,
+ (true, false) => Plan::ToKeyring,
+ (false, true) => Plan::ToKdbx,
+ (true, true) => {
+ let (km, im) = (kx.unwrap().mtime, k.unwrap().1.modified as i64);
+ if (km - im).abs() <= SKEW_TOLERANCE_SECS || km >= im {
+ Plan::ConflictKdbxWins // tie or newer: kdbx, documented
+ } else {
+ Plan::ConflictKeyringWins
+ }
+ }
+ }
+ }
+ // Deleted on one side; modification on the other beats deletion.
+ (Some(b), None, Some(_)) => {
+ if kr_hash.as_deref() != Some(b.h.as_str()) {
+ Plan::ToKdbx // modified in keyring: resurrect
+ } else {
+ Plan::DeleteKeyring
+ }
+ }
+ (Some(b), Some(_), None) => {
+ if kx_hash.as_deref() != Some(b.h.as_str()) {
+ Plan::ToKeyring // modified in kdbx: resurrect
+ } else {
+ Plan::RecycleKdbx
+ }
+ }
+ (Some(_), None, None) | (None, None, None) => Plan::InSync, // gone: forget
+ };
+ plans.push((uuid.clone(), plan));
+ }
+
+ // ---- report ----
+ let mut journal = String::new();
+ let mut counts: HashMap<&'static str, usize> = HashMap::new();
+ let describe = |uuid: &str, kx: Option<&KdbxEntry>, k: Option<&(secret_service::Item<'_>, KrEntry)>| {
+ kx.map(|e| e.title.clone())
+ .or_else(|| k.map(|(_, e)| e.title.clone()))
+ .unwrap_or_else(|| uuid.to_string())
+ };
+ for (uuid, plan) in &plans {
+ if *plan == Plan::InSync {
+ continue;
+ }
+ let kx = kdbx_by_uuid.get(uuid).map(|&i| &kdbx.entries[i]);
+ let label = describe(uuid, kx, kr.get(uuid));
+ let verb = match plan {
+ Plan::ToKeyring => "kdbx -> keyring",
+ Plan::ToKdbx => "keyring -> kdbx",
+ Plan::ConflictKdbxWins => "CONFLICT: kdbx wins (loser -> History)",
+ Plan::ConflictKeyringWins => "CONFLICT: keyring wins (loser -> History)",
+ Plan::DeleteKeyring => "delete from keyring",
+ Plan::RecycleKdbx => "kdbx -> Recycle Bin",
+ Plan::InSync => unreachable!(),
+ };
+ *counts.entry(match plan {
+ Plan::ToKeyring | Plan::ConflictKdbxWins => "to-keyring",
+ Plan::ToKdbx | Plan::ConflictKeyringWins => "to-kdbx",
+ Plan::DeleteKeyring => "deleted",
+ Plan::RecycleKdbx => "recycled",
+ Plan::InSync => unreachable!(),
+ }).or_default() += 1;
+ println!(" {verb}: {label}");
+ journal.push_str(&format!("{} sync {verb}: {label}\n", now_unix()));
+ }
+ for (_, e) in &adopt {
+ println!(" adopt -> kdbx: {}", e.title);
+ journal.push_str(&format!("{} sync adopt: {}\n", now_unix(), e.title));
+ }
+ let quiet = plans.iter().all(|(_, p)| *p == Plan::InSync) && adopt.is_empty();
+ if quiet {
+ println!("in sync — nothing to do");
+ }
+ if dry_run || quiet {
+ if !dry_run {
+ state.last_run = now_unix();
+ write_state(state_path, &state);
+ }
+ return;
+ }
+
+ // ---- apply: kdbx first (atomic), keyring after, state last ----
+ // If the kdbx save aborts, the keyring is untouched and the next run
+ // re-plans from the same base. Keyring failures leave those entries out
+ // of the new state, so they are retried.
+ let mut kdbx_dirty = false;
+ let mut new_uuids_for_adopt: Vec<String> = Vec::new();
+
+ for (uuid, plan) in &plans {
+ match plan {
+ Plan::ToKdbx | Plan::ConflictKeyringWins => {
+ let (_, ke) = kr.get(uuid).unwrap();
+ match kdbx.ids.get(uuid) {
+ Some(&id) => {
+ push_history(&mut kdbx.db, id);
+ set_kdbx_fields(&mut kdbx.db, id, ke);
+ }
+ None => {
+ // Resurrect or first arrival from the keyring side.
+ let uu = match uuid.parse() {
+ Ok(u) => u,
+ Err(_) => continue,
+ };
+ let id = keepass::db::EntryId::from_uuid(uu);
+ let mut root = kdbx.db.root_mut();
+ if root.add_entry_with_id(id).is_err() {
+ continue; // id exists (recycled): leave to doctor
+ }
+ set_kdbx_fields(&mut kdbx.db, id, ke);
+ kdbx.ids.insert(uuid.clone(), id);
+ }
+ }
+ kdbx_dirty = true;
+ }
+ Plan::ConflictKdbxWins => {
+ // kdbx keeps its value; the keyring's losing value is still
+ // preserved as a History revision before the keyring is
+ // overwritten in the second pass.
+ if let (Some(&id), Some((_, ke))) = (kdbx.ids.get(uuid), kr.get(uuid)) {
+ push_history(&mut kdbx.db, id);
+ let winner_restore = kdbx_by_uuid.get(uuid).map(|&i| &kdbx.entries[i]);
+ set_kdbx_fields(&mut kdbx.db, id, ke); // loser becomes current...
+ push_history(&mut kdbx.db, id); // ...is archived...
+ if let Some(w) = winner_restore {
+ // ...and the winner is restored as current.
+ let back = KrEntry {
+ title: w.title.clone(),
+ username: w.username.clone(),
+ password: w.password.clone(),
+ url: w.url.clone(),
+ notes: w.notes.clone(),
+ group: w.group.clone(),
+ modified: 0,
+ };
+ set_kdbx_fields(&mut kdbx.db, id, &back);
+ }
+ kdbx_dirty = true;
+ }
+ }
+ Plan::RecycleKdbx => {
+ let rb = recycle_bin_id(&mut kdbx.db);
+ if let Some(&id) = kdbx.ids.get(uuid) {
+ if let Some(mut em) = kdbx.db.entry_mut(id) {
+ let _ = em.move_to(rb);
+ kdbx_dirty = true;
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+ for (_, ke) in &adopt {
+ let id = keepass::db::EntryId::new();
+ let uuid = id.uuid().to_string();
+ let mut root = kdbx.db.root_mut();
+ if root.add_entry_with_id(id).is_ok() {
+ set_kdbx_fields(&mut kdbx.db, id, ke);
+ kdbx.ids.insert(uuid.clone(), id);
+ new_uuids_for_adopt.push(uuid);
+ kdbx_dirty = true;
+ }
+ }
+
+ if kdbx_dirty {
+ if let Err(e) = save_kdbx_atomic(&mut kdbx.db, kdbx_path, &password, &kdbx.file_hash) {
+ eprintln!("{e}");
+ std::process::exit(1);
+ }
+ println!("kdbx saved");
+ }
+
+ // Keyring side.
+ for (uuid, plan) in &plans {
+ match plan {
+ Plan::ToKeyring | Plan::ConflictKdbxWins => {
+ if let Some(&i) = kdbx_by_uuid.get(uuid) {
+ let e = &kdbx.entries[i];
+ if let Err(err) = col
+ .create_item(&e.title, attrs_for(e), e.password.as_bytes(), true, "text/plain")
+ .await
+ {
+ eprintln!(" keyring write failed for {}: {err}", e.title);
+ }
+ }
+ }
+ Plan::DeleteKeyring => {
+ if let Some((item, e)) = kr.get(uuid) {
+ if let Err(err) = item.delete().await {
+ eprintln!(" keyring delete failed for {}: {err}", e.title);
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+ // Adopted items gain their kdbx-uuid so next run pairs them.
+ for ((item, ke), uuid) in adopt.iter().zip(&new_uuids_for_adopt) {
+ let mut attrs = HashMap::new();
+ attrs.insert("kdbx-uuid", uuid.as_str());
+ attrs.insert("kdbx-group", ke.group.as_str());
+ attrs.insert("UserName", ke.username.as_str());
+ attrs.insert("URL", ke.url.as_str());
+ attrs.insert("Notes", ke.notes.as_str());
+ if let Err(e) = item.set_attributes(attrs).await {
+ eprintln!(" could not stamp kdbx-uuid on {}: {e}", ke.title);
+ }
+ }
+
+ // ---- new state: re-read both sides' final values cheaply from the plan ----
+ state.entries.clear();
+ let mut fresh_kr: HashMap<String, u64> = HashMap::new();
+ if let Ok(items) = col.get_all_items().await {
+ for item in items {
+ if let Ok(attrs) = item.get_attributes().await {
+ if let Some(u) = attrs.get("kdbx-uuid") {
+ fresh_kr.insert(u.clone(), item.get_modified().await.unwrap_or(0));
+ }
+ }
+ }
+ }
+ // Final field values per uuid: kdbx entries after merge are authoritative
+ // for hashes (both sides were written to match them).
+ let final_entries: Vec<KdbxEntry> = {
+ let mut list = Vec::new();
+ let recycle = kdbx.db.recycle_bin().map(|g| g.id());
+ for entry in kdbx.db.iter_all_entries() {
+ if recycle.is_some() && Some(entry.parent().id()) == recycle {
+ continue;
+ }
+ list.push(KdbxEntry {
+ uuid: entry.id().uuid().to_string(),
+ title: entry.get_title().unwrap_or("").to_string(),
+ username: entry.get_username().unwrap_or("").to_string(),
+ password: entry.get_password().unwrap_or("").to_string(),
+ url: entry.get_url().unwrap_or("").to_string(),
+ notes: entry.get("Notes").unwrap_or("").to_string(),
+ group: entry.parent().name.clone(),
+ mtime: entry.times.last_modification.map(|t| t.and_utc().timestamp()).unwrap_or(0),
+ });
+ }
+ list
+ };
+ for e in &final_entries {
+ state.entries.insert(
+ e.uuid.clone(),
+ EntryState {
+ h: canonical_hash(&hash_key, e),
+ kdbx_mtime: e.mtime,
+ keyring_modified: fresh_kr.get(&e.uuid).copied().unwrap_or(0),
+ },
+ );
+ }
+ state.version = 1;
+ state.kdbx_path = kdbx_path.display().to_string();
+ state.last_run = now_unix();
+ write_state(state_path, &state);
+ journal_append(&journal);
+ let c = |k: &str| counts.get(k).copied().unwrap_or(0);
+ println!(
+ "synced: {} -> keyring, {} -> kdbx, {} adopted, {} deleted, {} recycled",
+ c("to-keyring"), c("to-kdbx"), adopt.len(), c("deleted"), c("recycled")
+ );
+}
+
+fn write_state(state_path: &Path, state: &State) {
+ let _ = std::fs::create_dir_all(state_dir());
+ let tmp = state_path.with_extension("json.tmp");
+ if std::fs::write(&tmp, serde_json::to_vec_pretty(state).unwrap()).is_ok() {
+ let _ = std::fs::rename(&tmp, state_path);
+ }
+}
+
+// ===================== doctor: Dropbox conflicted copies =====================
+
+/// Merge each conflicted copy into the main database with keepassxc-cli
+/// (battle-tested merge; same credentials), back everything up first, then
+/// archive the conflict files out of Dropbox.
+async fn doctor(kdbx: &Path) {
+ let conflicts = conflicted_copies(kdbx);
+ if conflicts.is_empty() {
+ println!("no conflicted copies — healthy");
+ return;
+ }
+ let ss = match SecretService::connect(EncryptionType::Dh).await {
+ Ok(ss) => ss,
+ Err(e) => {
+ eprintln!("Secret Service unavailable: {e}");
+ std::process::exit(1);
+ }
+ };
+ let password = match keyring_get(&ss, "kdbx-password").await {
+ Ok(Some(b)) => String::from_utf8_lossy(&b).into_owned(),
+ _ => {
+ eprintln!("no stored master password — run `cce-keyring-sync import` once");
+ std::process::exit(1);
+ }
+ };
+
+ let backups = state_dir().join("backups");
+ let _ = std::fs::create_dir_all(&backups);
+ let ts = now_unix();
+ let back = |p: &Path| backups.join(format!("{}.{ts}", p.file_name().unwrap().to_string_lossy()));
+ if std::fs::copy(kdbx, back(kdbx)).is_err() {
+ eprintln!("could not back up the main database — refusing to continue");
+ std::process::exit(1);
+ }
+ for c in &conflicts {
+ let _ = std::fs::copy(c, back(c));
+ }
+ println!("backups in {}", backups.display());
+
+ for c in &conflicts {
+ println!("merging {} …", c.display());
+ // Same credentials: conflicted copies share the master password.
+ let mut child = match std::process::Command::new("keepassxc-cli")
+ .args(["merge", "--same-credentials"])
+ .arg(kdbx)
+ .arg(c)
+ .stdin(std::process::Stdio::piped())
+ .stdout(std::process::Stdio::piped())
+ .stderr(std::process::Stdio::piped())
+ .spawn()
+ {
+ Ok(ch) => ch,
+ Err(e) => {
+ eprintln!("keepassxc-cli not runnable: {e}");
+ std::process::exit(1);
+ }
+ };
+ {
+ use std::io::Write;
+ let _ = child.stdin.take().unwrap().write_all(format!("{password}\n").as_bytes());
+ }
+ match child.wait_with_output() {
+ Ok(out) if out.status.success() => {
+ // Resolved: archive the conflict file out of Dropbox (copy
+ // then remove — the backup dir is on another filesystem).
+ let dest = backups.join(c.file_name().unwrap());
+ if std::fs::copy(c, &dest).is_ok() {
+ let _ = std::fs::remove_file(c);
+ println!(" merged; archived to {}", dest.display());
+ }
+ journal_append(&format!("{} doctor merged {}\n", now_unix(), c.display()));
+ }
+ Ok(out) => {
+ eprintln!(" merge FAILED (left in place): {}", String::from_utf8_lossy(&out.stderr).trim());
+ }
+ Err(e) => eprintln!(" merge failed to run: {e}"),
+ }
+ }
+ println!("now run: cce-keyring-sync sync");
+}