trove_core/lib.rs
1//! `trove-core` — kdbx I/O and vault primitives.
2//!
3//! Format compatibility with KeePassXC is non-negotiable: this crate must
4//! round-trip any valid `.kdbx` file. Scope is KDBX 4 with a password master
5//! key, optionally composited with a keyfile (`*_with_key`; any format
6//! KeePassXC accepts). Hardware tokens and KDBX 3 land later.
7//!
8//! As of v0.0.10, trove-core depends on the published `keepass = "0.12"` crate
9//! directly — no more vendored fork. The earlier vendored 0.7.33 + three
10//! binary-attachment patches is gone; upstream's PR #294 already restructured
11//! attachments as first-class Database-owned objects, and the new
12//! `EntryMut::add_attachment(name, Value::Unprotected(bytes))` /
13//! `EntryRef::attachment_by_name(name)` pair does what we need without any
14//! local patches. The `_SDPM_BIN_*` Protected-string fallback that v0.0.4
15//! introduced for backwards compat is also gone, since no v0.0.1–0.0.3.x
16//! production vaults exist (the project hadn't shipped yet).
17
18#![forbid(unsafe_code)]
19
20use std::path::{Path, PathBuf};
21
22use keepass::config::DatabaseVersion;
23use keepass::db::Value;
24use zeroize::Zeroize;
25
26mod error;
27pub use error::Error;
28
29pub type Result<T> = std::result::Result<T, Error>;
30
31/// Name of the database's single top-level group. KeePassXC names it "Root";
32/// keepass-rs leaves it empty, which surfaces as a nameless folder in other
33/// clients. trove names it on save and treats it as the implicit home for
34/// entries added without a group prefix — so a leading `Root/` segment in a
35/// path denotes this same group rather than a child of it.
36const DEFAULT_GROUP: &str = "Root";
37
38/// Name of the recycle-bin group we create on demand, matching KeePassXC's
39/// default so both tools resolve the same bin. The authoritative pointer is
40/// `Meta/RecycleBinUUID`; the name is only cosmetic.
41pub const RECYCLE_BIN_GROUP: &str = "Recycle Bin";
42
43/// Stable identifier for an entry within a vault.
44///
45/// Backed by the kdbx UUID, serialised as a string for wire/disk transport.
46/// We keep our own newtype rather than re-exporting `keepass::db::EntryId`
47/// because (a) the upstream type's constructors are `pub(crate)` so we can't
48/// build one from a Uuid externally anyway, and (b) the daemon control protocol
49/// already serialises entry IDs as JSON strings.
50#[derive(Debug, Clone, PartialEq, Eq, Hash)]
51pub struct EntryId(pub(crate) String);
52
53impl EntryId {
54 pub fn as_str(&self) -> &str {
55 &self.0
56 }
57}
58
59impl std::fmt::Display for EntryId {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 f.write_str(&self.0)
62 }
63}
64
65impl std::str::FromStr for EntryId {
66 type Err = std::convert::Infallible;
67 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
68 Ok(EntryId(s.to_string()))
69 }
70}
71
72/// Non-secret summary of an entry. Suitable for listing without unlocking secrets.
73#[derive(Debug, Clone)]
74pub struct EntrySummary {
75 pub id: EntryId,
76 pub title: String,
77 pub username: Option<String>,
78 pub url: Option<String>,
79 pub attachment_names: Vec<String>,
80 /// Names of the groups containing this entry, root → leaf. Root group
81 /// itself is excluded (an entry directly under root has an empty
82 /// `group_path`). Use `display_path()` to render as `Group/Sub/Title`.
83 pub group_path: Vec<String>,
84 /// Entry creation time as an RFC3339 UTC string (e.g.
85 /// `2026-07-21T14:12:00+00:00`), from the kdbx entry's `CreationTime`.
86 /// `None` when the vault does not record it.
87 pub created: Option<String>,
88 /// Entry last-modification time as an RFC3339 UTC string, from the kdbx
89 /// entry's `LastModificationTime`. `None` when unavailable.
90 pub modified: Option<String>,
91}
92
93impl EntrySummary {
94 /// Format the full path as `Group/Sub/.../Title`. Falls back to just
95 /// the title when the entry lives at the root.
96 pub fn display_path(&self) -> String {
97 if self.group_path.is_empty() {
98 self.title.clone()
99 } else {
100 let mut s = self.group_path.join("/");
101 s.push('/');
102 s.push_str(&self.title);
103 s
104 }
105 }
106}
107
108/// Counts from a [`Vault::merge_from`], by merge-event kind.
109#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
110pub struct MergeSummary {
111 pub created: usize,
112 pub updated: usize,
113 pub relocated: usize,
114 pub deleted: usize,
115}
116
117/// Non-secret database facts for `db-info`.
118#[derive(Debug, Clone)]
119pub struct DbInfo {
120 pub version: String,
121 pub cipher: String,
122 pub compression: String,
123 pub kdf: String,
124 pub entries: usize,
125 pub groups: usize,
126 pub recycle_bin: bool,
127}
128
129/// One generated TOTP code plus its validity window, for display.
130#[derive(Debug, Clone)]
131pub struct TotpCode {
132 /// The code digits (6–8 chars, or whatever the URI specifies).
133 pub code: String,
134 /// Seconds this code remains valid.
135 pub valid_for_secs: u64,
136 /// The TOTP period (usually 30s).
137 pub period_secs: u64,
138}
139
140/// An open, in-memory vault.
141///
142/// Dropping the value drops the underlying decrypted material. Best-effort
143/// memory zeroing is delegated to the `keepass` crate where supported.
144pub struct Vault {
145 pub(crate) inner: VaultInner,
146}
147
148/// Re-export of the keepass crate's challenge-response key: either a real
149/// YubiKey (serial + slot) or the software `LocalChallenge` provider using
150/// the identical HMAC-SHA1 derivation (KeePassXC's scheme).
151#[cfg(feature = "yubikey")]
152pub use keepass::ChallengeResponseKey;
153
154/// What a [`Vault::rename_attachment`] moved, so a caller can finish the job.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct RenamedAttachment {
157 /// The settings that followed the attachment, by their new names.
158 pub moved_fields: Vec<String>,
159 /// The entry has a `KeeAgent.settings` attachment, which names its key
160 /// inside XML this crate does not parse. A caller that understands SSH
161 /// should rewrite it; one that does not can ignore this.
162 pub has_keeagent_settings: bool,
163}
164
165pub(crate) struct VaultInner {
166 pub(crate) path: PathBuf,
167 pub(crate) password: String,
168 /// Raw keyfile bytes when the vault uses a composite key (password +
169 /// keyfile). Kept verbatim so `save()` derives the same composite key;
170 /// format interpretation (XML v1/v2, raw-32, hex-64, arbitrary-file
171 /// SHA-256) is the `keepass` crate's, matching KeePassXC.
172 pub(crate) keyfile: Option<Vec<u8>>,
173 /// Challenge-response provider for composite keys (YubiKey or software).
174 /// Held so every `save()` can re-answer the fresh challenge — kdbx
175 /// rotates the master seed per save, so the device/secret is consulted
176 /// again on each write.
177 #[cfg(feature = "yubikey")]
178 pub(crate) challenge_response: Option<ChallengeResponseKey>,
179 /// What the file looked like when we last read or wrote it.
180 ///
181 /// A vault is a single file that several programs write — the CLI, the
182 /// desktop app, KeePassXC, and the same vault synced onto another machine.
183 /// Without this, `save()` writes whatever is in memory over whatever is on
184 /// disk, and the other side's changes are gone with nothing said. `None`
185 /// only for a vault created in memory that has never touched disk.
186 pub(crate) stamp: Option<FileStamp>,
187 pub(crate) db: keepass::Database,
188}
189
190/// A cheap identity for the vault file, used to notice that something else
191/// wrote it.
192///
193/// Length plus modification time rather than a hash: it costs one `stat` on a
194/// path already being opened, and the failure mode is the safe one. A content
195/// change that preserved both would be missed, which needs a writer to produce
196/// an identical-length file within the filesystem's timestamp resolution; a
197/// touched-but-unchanged file is reported as changed, which costs a reopen
198/// rather than data.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub(crate) struct FileStamp {
201 len: u64,
202 modified: Option<std::time::SystemTime>,
203}
204
205impl FileStamp {
206 /// The stamp for `path`, or `None` when it cannot be read — a missing file
207 /// is not a conflict, it is a vault that no longer exists, and `save()`
208 /// recreating it is the reasonable outcome.
209 fn read(path: &Path) -> Option<Self> {
210 let meta = std::fs::metadata(path).ok()?;
211 Some(Self {
212 len: meta.len(),
213 modified: meta.modified().ok(),
214 })
215 }
216}
217
218impl Drop for VaultInner {
219 fn drop(&mut self) {
220 // Best-effort: wipe the key material we kept in memory.
221 // The `keepass::Database` carries its own SecretBox-backed protected
222 // values; we don't reach into it.
223 self.password.zeroize();
224 if let Some(k) = self.keyfile.as_mut() {
225 k.zeroize();
226 }
227 }
228}
229
230/// Stamp an entry's `LastModificationTime` — every content mutation calls
231/// this, matching KeePassXC (KDBX merge resolves conflicts by this time, so
232/// stale stamps make trove edits silently lose merges).
233fn touch_modified(entry: &mut keepass::db::EntryMut<'_>) {
234 entry.times.last_modification = Some(keepass::db::Times::now());
235}
236
237/// Stamp an entry's `LocationChanged` — every relocation calls this (the
238/// KDBX merge algorithm uses it to resolve concurrent moves).
239fn touch_location(entry: &mut keepass::db::EntryMut<'_>) {
240 entry.times.location_changed = Some(keepass::db::Times::now());
241}
242
243/// Build the composite `DatabaseKey` from a password and optional keyfile
244/// bytes — the one place the two are combined, shared by open/create/save.
245fn database_key(password: &str, keyfile: Option<&[u8]>) -> Result<keepass::DatabaseKey> {
246 let mut key = keepass::DatabaseKey::new().with_password(password);
247 if let Some(bytes) = keyfile {
248 key = key
249 .with_keyfile(&mut &bytes[..])
250 .map_err(|e| Error::Kdbx(format!("reading keyfile: {e}")))?;
251 }
252 Ok(key)
253}
254
255impl Vault {
256 /// Create a new kdbx file at `path`, encrypted with `password`.
257 /// Errors if the file already exists.
258 pub fn create(path: &Path, password: &str) -> Result<Self> {
259 Self::create_with_key(path, password, None)
260 }
261
262 /// Create a new kdbx file locked by a composite key: `password` plus the
263 /// given keyfile bytes (any format KeePassXC accepts — XML v1/v2, raw
264 /// 32-byte, hex-64, or an arbitrary file hashed with SHA-256).
265 pub fn create_with_key(path: &Path, password: &str, keyfile: Option<&[u8]>) -> Result<Self> {
266 if path.exists() {
267 return Err(Error::AlreadyExists(path.to_path_buf()));
268 }
269
270 // `Database::new()` uses the default DatabaseConfig: KDBX4 + AES-256
271 // + GZip + ChaCha20 (inner stream) + Argon2d. KeePassXC reads this fine.
272 let db = keepass::Database::new();
273
274 let mut vault = Vault {
275 inner: VaultInner {
276 path: path.to_path_buf(),
277 stamp: FileStamp::read(path),
278 password: password.to_string(),
279 keyfile: keyfile.map(<[u8]>::to_vec),
280 #[cfg(feature = "yubikey")]
281 challenge_response: None,
282 db,
283 },
284 };
285 vault.save()?;
286 Ok(vault)
287 }
288
289 /// Create a new kdbx file additionally locked by a challenge-response
290 /// key (YubiKey HMAC-SHA1 or the software `LocalChallenge` provider),
291 /// composited with the password and optional keyfile — KeePassXC's
292 /// scheme, so the same vault unlocks there with the same device.
293 #[cfg(feature = "yubikey")]
294 pub fn create_with_challenge_response(
295 path: &Path,
296 password: &str,
297 keyfile: Option<&[u8]>,
298 challenge_response: ChallengeResponseKey,
299 ) -> Result<Self> {
300 if path.exists() {
301 return Err(Error::AlreadyExists(path.to_path_buf()));
302 }
303 let mut vault = Vault {
304 inner: VaultInner {
305 path: path.to_path_buf(),
306 stamp: FileStamp::read(path),
307 password: password.to_string(),
308 keyfile: keyfile.map(<[u8]>::to_vec),
309 challenge_response: Some(challenge_response),
310 db: keepass::Database::new(),
311 },
312 };
313 vault.save()?;
314 Ok(vault)
315 }
316
317 /// Open a challenge-response-locked vault. The provider is held for the
318 /// vault's lifetime: every later save re-answers the fresh challenge
319 /// (kdbx rotates the master seed per save), so a hardware key must stay
320 /// reachable while writing.
321 #[cfg(feature = "yubikey")]
322 pub fn open_with_challenge_response(
323 path: &Path,
324 password: &str,
325 keyfile: Option<&[u8]>,
326 challenge_response: ChallengeResponseKey,
327 ) -> Result<Self> {
328 if !path.exists() {
329 return Err(Error::NotFound(path.to_path_buf()));
330 }
331 let mut file = std::fs::File::open(path)?;
332 let key = database_key(password, keyfile)?
333 .with_challenge_response_key(challenge_response.clone());
334 let db = keepass::Database::open(&mut file, key).map_err(open_err_to_error)?;
335 Ok(Vault {
336 inner: VaultInner {
337 path: path.to_path_buf(),
338 stamp: FileStamp::read(path),
339 password: password.to_string(),
340 keyfile: keyfile.map(<[u8]>::to_vec),
341 challenge_response: Some(challenge_response),
342 db,
343 },
344 })
345 }
346
347 /// Open an existing kdbx file with a password.
348 pub fn open(path: &Path, password: &str) -> Result<Self> {
349 Self::open_with_key(path, password, None)
350 }
351
352 /// Open an existing kdbx file with a composite key: `password` plus the
353 /// given keyfile bytes. A wrong or missing keyfile surfaces as
354 /// [`Error::BadPassword`], same as a wrong password — the kdbx format
355 /// cannot distinguish which credential was wrong.
356 pub fn open_with_key(path: &Path, password: &str, keyfile: Option<&[u8]>) -> Result<Self> {
357 if !path.exists() {
358 return Err(Error::NotFound(path.to_path_buf()));
359 }
360 let mut file = std::fs::File::open(path)?;
361 let key = database_key(password, keyfile)?;
362 let db = keepass::Database::open(&mut file, key).map_err(open_err_to_error)?;
363 Ok(Vault {
364 inner: VaultInner {
365 path: path.to_path_buf(),
366 stamp: FileStamp::read(path),
367 password: password.to_string(),
368 keyfile: keyfile.map(<[u8]>::to_vec),
369 #[cfg(feature = "yubikey")]
370 challenge_response: None,
371 db,
372 },
373 })
374 }
375
376 /// Re-read the vault from disk, discarding whatever this handle held.
377 ///
378 /// For a caller that has noticed [`changed_on_disk`](Self::changed_on_disk)
379 /// and has nothing of its own to lose — a GUI showing a list it did not
380 /// edit. The password and keyfile are reused, so the caller does not have
381 /// to ask for them again; anything unsaved in memory is gone, which is why
382 /// this is never automatic.
383 pub fn reload(&mut self) -> Result<()> {
384 let mut fresh = Self::open_with_key(
385 &self.inner.path,
386 &self.inner.password,
387 self.inner.keyfile.as_deref(),
388 )?;
389 // Swap rather than move: `VaultInner` implements `Drop` to wipe key
390 // material, so its fields cannot be moved out. `fresh` then carries our
391 // old database away and zeroizes on the way.
392 std::mem::swap(&mut self.inner.db, &mut fresh.inner.db);
393 self.inner.stamp = fresh.inner.stamp.clone();
394 Ok(())
395 }
396
397 /// Has the vault file changed since this handle read it?
398 ///
399 /// For a caller that would rather ask than have `save()` fail — a GUI
400 /// reloading quietly when nothing local is dirty, say.
401 pub fn changed_on_disk(&self) -> bool {
402 match &self.inner.stamp {
403 // A vault we have never seen on disk cannot have been changed by
404 // anyone else; the first save creates it.
405 None => false,
406 Some(known) => {
407 FileStamp::read(&self.inner.path).is_some_and(|current| ¤t != known)
408 }
409 }
410 }
411
412 fn check_not_stale(&self) -> Result<()> {
413 if self.changed_on_disk() {
414 return Err(Error::StaleWrite(self.inner.path.clone()));
415 }
416 Ok(())
417 }
418
419 /// Persist in-memory state back to the original path (atomic replace).
420 pub fn save(&mut self) -> Result<()> {
421 // trove only ever writes KDBX 4.1. Force the version before serializing
422 // so re-saving a legacy 4.0 vault (written by keepass 0.12.5) succeeds:
423 // the 0.13.10 writer emits only 4.1 and would otherwise reject KDB4(0)
424 // with "Unsupported database version". The re-serialize also drops
425 // 0.12.5's empty numeric <Meta> elements that made KeePassXC reject the
426 // file with "Invalid number value".
427 self.inner.db.config.version = DatabaseVersion::KDB4(1);
428 // Pin the optional <Meta> policy fields to KeePassXC's own defaults so a
429 // trove vault behaves identically in any reader. Backfill-only — a value
430 // already set (by KeePassXC, or a future trove setting) is left as-is.
431 apply_default_meta_policy(&mut self.inner.db.meta);
432 // Give the top-level group a name if it has none, so other clients
433 // (KeePassXC et al.) show a proper "Root" folder instead of a blank
434 // one. Backfills freshly created vaults (create() calls save()) and
435 // any legacy vault on its next write. trove addresses entries by the
436 // group chain *below* the root (`build_group_path` excludes it
437 // structurally), so naming it is invisible to our own paths.
438 if self.inner.db.root().name.is_empty() {
439 self.inner
440 .db
441 .root_mut()
442 .edit(|g| g.name = DEFAULT_GROUP.to_string());
443 }
444
445 // Refuse to overwrite a file something else has written since we read
446 // it. A vault is one file with several writers — the CLI, the desktop
447 // app, KeePassXC, and the same file synced onto another machine — and
448 // without this the last writer wins silently, taking the other's
449 // changes with it.
450 self.check_not_stale()?;
451
452 let dir = self
453 .inner
454 .path
455 .parent()
456 .filter(|p| !p.as_os_str().is_empty())
457 .map(Path::to_path_buf)
458 .unwrap_or_else(|| PathBuf::from("."));
459
460 let file_name = self
461 .inner
462 .path
463 .file_name()
464 .ok_or_else(|| {
465 Error::Io(std::io::Error::new(
466 std::io::ErrorKind::InvalidInput,
467 "vault path has no file name",
468 ))
469 })?
470 .to_owned();
471
472 let mut tmp_name = std::ffi::OsString::from(&file_name);
473 tmp_name.push(format!(".tmp.{}", std::process::id()));
474 let tmp_path = dir.join(&tmp_name);
475
476 // Scope the file handle so it is closed (and thus fully flushed by the
477 // OS) before we attempt the rename. We also fsync explicitly for
478 // crash-safety on POSIX.
479 {
480 let mut tmp = std::fs::File::create(&tmp_path)?;
481 #[allow(unused_mut)]
482 let mut key = database_key(&self.inner.password, self.inner.keyfile.as_deref())?;
483 #[cfg(feature = "yubikey")]
484 if let Some(cr) = &self.inner.challenge_response {
485 key = key.with_challenge_response_key(cr.clone());
486 }
487 self.inner
488 .db
489 .save(&mut tmp, key)
490 .map_err(save_err_to_error)?;
491 tmp.sync_all()?;
492 }
493
494 // Atomic replace. `rename` over an existing target is atomic on POSIX.
495 if let Err(e) = std::fs::rename(&tmp_path, &self.inner.path) {
496 let _ = std::fs::remove_file(&tmp_path);
497 return Err(Error::Io(e));
498 }
499
500 // Our own write is the new baseline; without this a second save in the
501 // same session would see the file as changed by someone else.
502 self.inner.stamp = FileStamp::read(&self.inner.path);
503 Ok(())
504 }
505
506 pub fn path(&self) -> &Path {
507 &self.inner.path
508 }
509
510 /// Add a new entry. The `title` is interpreted as a `/`-separated path:
511 /// the leading segments name a group hierarchy (created as needed,
512 /// `mkdir -p` semantics), and the trailing segment becomes the entry
513 /// title. A title with no `/` lands at the root group, matching the
514 /// previous behavior.
515 ///
516 /// A leading `Root` segment (case-insensitive) names the root group
517 /// itself, so `add_entry("Root/github")` is identical to `add_entry("github")`.
518 ///
519 /// Examples:
520 /// * `add_entry("github")` → "github" in the root group
521 /// * `add_entry("Work/SSH/github")` → group "Work" > "SSH", entry "github"
522 ///
523 /// Empty segments (`//`, `/foo`, `foo/`) and the empty title are rejected
524 /// with `Error::InvalidPath`. Group lookups are case-insensitive (matches
525 /// keepass-rs and KeePassXC behavior), so `work/ssh` resolves to an
526 /// existing `Work/SSH`. Returns the entry's stable ID.
527 pub fn add_entry(&mut self, title: &str) -> Result<EntryId> {
528 let (group_path, leaf) = parse_entry_path(title)?;
529 // Walk by GroupId rather than by mutable reference — we can't carry a
530 // GroupMut across the loop because each iteration's lookup re-borrows
531 // through the previous one.
532 let mut current_id = self.inner.db.root().id();
533 for segment in &group_path {
534 let mut current = self
535 .inner
536 .db
537 .group_mut(current_id)
538 .expect("walked GroupId always resolves");
539 let existing = current.group_by_name_mut(segment).map(|g| g.id());
540 let next_id = match existing {
541 Some(id) => id,
542 None => current.add_group().edit(|g| g.name = segment.clone()).id(),
543 };
544 current_id = next_id;
545 }
546 let mut leaf_group = self
547 .inner
548 .db
549 .group_mut(current_id)
550 .expect("leaf GroupId always resolves");
551 let mut entry = leaf_group.add_entry();
552 entry.set_unprotected("Title", &leaf);
553 Ok(EntryId(entry.id().uuid().to_string()))
554 }
555
556 /// List all entries in the vault (recursively across all groups).
557 pub fn list_entries(&self) -> Vec<EntrySummary> {
558 self.inner
559 .db
560 .iter_all_entries()
561 .map(|e| summarise(&e))
562 .collect()
563 }
564
565 /// Look up an entry by ID. Returns `None` if no such entry exists.
566 pub fn get_entry(&self, id: &EntryId) -> Option<EntrySummary> {
567 self.inner
568 .db
569 .iter_all_entries()
570 .find(|e| e.id().uuid().to_string() == id.0)
571 .map(|e| summarise(&e))
572 }
573
574 /// Look up an entry by title or path.
575 ///
576 /// * Plain title with no `/`: returns the first entry whose leaf title
577 /// matches (current behavior). Search is exact (case-sensitive) on the
578 /// leaf title across all groups.
579 /// * Path with `/`: navigates `group/sub/.../leaf` and matches only the
580 /// entry at exactly that path. Group navigation is case-insensitive
581 /// (matching keepass-rs); the leaf title comparison is exact.
582 ///
583 /// Returns `None` if no such entry exists, or if any group segment in
584 /// the path is missing.
585 pub fn find_by_title(&self, title: &str) -> Option<EntryId> {
586 if title.contains('/') {
587 let (group_path, leaf) = parse_entry_path(title).ok()?;
588 // `title.contains('/')` guarantees at least one group segment.
589 let segs: Vec<&str> = group_path.iter().map(String::as_str).collect();
590 let root = self.inner.db.root();
591 let group = root.group_by_path(&segs)?;
592 return group
593 .entries()
594 .find(|e| e.get_title() == Some(leaf.as_str()))
595 .map(|e| EntryId(e.id().uuid().to_string()));
596 }
597 self.inner
598 .db
599 .iter_all_entries()
600 .find(|e| e.get_title() == Some(title))
601 .map(|e| EntryId(e.id().uuid().to_string()))
602 }
603
604 /// Set or replace a string field on an entry. Standard fields:
605 /// `"Title"`, `"UserName"`, `"Password"`, `"URL"`, `"Notes"`. Custom fields permitted.
606 ///
607 /// `Password` and `otp` are stored with the kdbx Protected flag —
608 /// matching KeePassXC, which memory-protects both by default.
609 pub fn set_field(&mut self, id: &EntryId, field: &str, value: &str) -> Result<()> {
610 const PROTECTED_FIELDS: [&str; 2] = ["Password", "otp"];
611 let entry_id = self.lookup_entry_id(id)?;
612 let mut entry = self
613 .inner
614 .db
615 .entry_mut(entry_id)
616 .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
617 if PROTECTED_FIELDS.contains(&field) {
618 entry.set_protected(field, value);
619 } else {
620 entry.set_unprotected(field, value);
621 }
622 touch_modified(&mut entry);
623 Ok(())
624 }
625
626 /// Attach a binary blob (e.g. an SSH private key) to an entry under `name`.
627 /// Replaces any existing attachment with the same name.
628 ///
629 /// Bytes are stored as a real KDBX4 inner-header binary attachment with a
630 /// `<Binary Ref="N"/>` reference inside the entry, matching what KeePassXC
631 /// writes. The Protected flag is left at the default (off) — KeePassXC
632 /// likewise stores SSH private keys without it.
633 pub fn attach_binary(&mut self, id: &EntryId, name: &str, bytes: &[u8]) -> Result<()> {
634 let entry_id = self.lookup_entry_id(id)?;
635 let mut entry = self
636 .inner
637 .db
638 .entry_mut(entry_id)
639 .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
640 // Replace-by-name semantics: drop any existing attachment with the
641 // same name first. add_attachment doesn't dedupe, so without this
642 // we'd accumulate orphans on rewrites.
643 entry.remove_attachment_by_name(name);
644 entry.add_attachment(name, Value::Unprotected(bytes.to_vec()));
645 touch_modified(&mut entry);
646 Ok(())
647 }
648
649 /// Read an attachment's bytes. Returns `Ok(None)` if the entry exists but has no such attachment.
650 /// Errors if the entry itself does not exist.
651 pub fn read_binary(&self, id: &EntryId, name: &str) -> Result<Option<Vec<u8>>> {
652 let entry_id = self.lookup_entry_id(id)?;
653 let entry = self
654 .inner
655 .db
656 .entry(entry_id)
657 .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
658 // `Value::get()` returns the inner bytes whether the value is stored
659 // unprotected or protected (it transparently exposes the secret), so we
660 // no longer need to match the variant or depend on `secrecy`.
661 Ok(entry
662 .attachment_by_name(name)
663 .map(|att| att.data.get().clone()))
664 }
665
666 /// Rename an attachment, taking everything that names it along.
667 ///
668 /// An attachment's name is not just a label: `Materialize.<name>.Target`
669 /// and friends are keyed by it, so renaming the file alone would leave
670 /// settings describing something that no longer exists. Those move too.
671 ///
672 /// `KeeAgent.settings` also names its key attachment, but it does so inside
673 /// an XML document this layer does not parse — [`crate::Vault`] knows
674 /// nothing about SSH. Callers that deal in agent keys rewrite it after
675 /// this, which is why the returned value says whether one is present.
676 ///
677 /// Errors when `old_name` is not attached, or when `new_name` already is —
678 /// silently replacing a different file would be worse than refusing.
679 pub fn rename_attachment(
680 &mut self,
681 id: &EntryId,
682 old_name: &str,
683 new_name: &str,
684 ) -> Result<RenamedAttachment> {
685 if old_name == new_name {
686 return Ok(RenamedAttachment {
687 moved_fields: Vec::new(),
688 has_keeagent_settings: false,
689 });
690 }
691 let bytes = self
692 .read_binary(id, old_name)?
693 .ok_or_else(|| Error::AttachmentNotFound(old_name.to_string()))?;
694 if self.read_binary(id, new_name)?.is_some() {
695 return Err(Error::AttachmentExists(new_name.to_string()));
696 }
697
698 // Settings that name the old attachment, so they can follow it.
699 let prefix = format!("Materialize.{old_name}.");
700 let mut moved_fields = Vec::new();
701 for key in self.fields_with_prefix(id, &prefix)? {
702 let Some(setting) = key.strip_prefix(&prefix) else {
703 continue;
704 };
705 if let Some(value) = self.get_field(id, &key)? {
706 moved_fields.push((
707 key.clone(),
708 format!("Materialize.{new_name}.{setting}"),
709 value,
710 ));
711 }
712 }
713
714 // Remove BEFORE adding, and never let both names exist at once.
715 //
716 // Attachments live in a shared pool, and identical bytes dedupe to one
717 // pooled entry. The crate's removal then retains that pool entry's
718 // back-references by entry id alone, discarding the name — so removing
719 // one of an entry's two names for the same bytes drops the other's
720 // reference too, and the pooled attachment with it. Adding second means
721 // there is only ever one name in flight.
722 self.remove_binary(id, old_name)?;
723 self.attach_binary(id, new_name, &bytes)?;
724 for (old_key, new_key, value) in &moved_fields {
725 self.set_field(id, new_key, value)?;
726 self.remove_field(id, old_key)?;
727 }
728
729 let has_keeagent_settings = self.read_binary(id, "KeeAgent.settings")?.is_some();
730 Ok(RenamedAttachment {
731 moved_fields: moved_fields.into_iter().map(|(_, k, _)| k).collect(),
732 has_keeagent_settings,
733 })
734 }
735
736 /// Remove an attachment from an entry. No-op if the attachment is missing.
737 pub fn remove_binary(&mut self, id: &EntryId, name: &str) -> Result<()> {
738 let entry_id = self.lookup_entry_id(id)?;
739 let mut entry = self
740 .inner
741 .db
742 .entry_mut(entry_id)
743 .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
744 entry.remove_attachment_by_name(name);
745 touch_modified(&mut entry);
746 Ok(())
747 }
748
749 /// Delete an entry by ID.
750 pub fn delete_entry(&mut self, id: &EntryId) -> Result<()> {
751 let entry_id = self.lookup_entry_id(id)?;
752 let entry = self
753 .inner
754 .db
755 .entry_mut(entry_id)
756 .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
757 entry.remove();
758 Ok(())
759 }
760
761 /// Read a single string field from an entry. Returns `None` if the field
762 /// is missing. Errors if the entry itself does not exist.
763 ///
764 /// Used by the materialization layer to read `Materialize.*` custom fields
765 /// from entries that opt in.
766 pub fn get_field(&self, id: &EntryId, field: &str) -> Result<Option<String>> {
767 let entry_id = self.lookup_entry_id(id)?;
768 let entry = self
769 .inner
770 .db
771 .entry(entry_id)
772 .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
773 Ok(entry.get(field).map(|s| s.to_string()))
774 }
775
776 /// Return the names of every custom string field on an entry whose name
777 /// starts with `prefix`. Field names are returned in unspecified order.
778 /// Errors if the entry does not exist.
779 ///
780 /// Used by the materialization layer so the daemon can quickly tell which
781 /// entries opt in (any entry with at least one `Materialize.*` field).
782 pub fn fields_with_prefix(&self, id: &EntryId, prefix: &str) -> Result<Vec<String>> {
783 let entry_id = self.lookup_entry_id(id)?;
784 let entry = self
785 .inner
786 .db
787 .entry(entry_id)
788 .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
789 Ok(entry
790 .fields
791 .keys()
792 .filter(|k| k.starts_with(prefix))
793 .cloned()
794 .collect())
795 }
796
797 /// Convert our `EntryId(String)` into the upstream `keepass::db::EntryId`
798 /// by walking entries and matching on Uuid string. Upstream's EntryId has
799 /// only `pub(crate)` constructors, so this is the only way to round-trip.
800 fn lookup_entry_id(&self, id: &EntryId) -> Result<keepass::db::EntryId> {
801 self.inner
802 .db
803 .iter_all_entries()
804 .find(|e| e.id().uuid().to_string() == id.0)
805 .map(|e| e.id())
806 .ok_or_else(|| Error::EntryNotFound(id.0.clone()))
807 }
808
809 /// Remove a string field from an entry. No-op if the field is absent.
810 pub fn remove_field(&mut self, id: &EntryId, field: &str) -> Result<()> {
811 let entry_id = self.lookup_entry_id(id)?;
812 let mut entry = self
813 .inner
814 .db
815 .entry_mut(entry_id)
816 .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
817 entry.fields.remove(field);
818 Ok(())
819 }
820
821 /// Resolve a `/`-separated group path to its `GroupId`. The empty string
822 /// or a bare/leading `Root` (case-insensitive) names the root group,
823 /// mirroring [`Vault::add_entry`] path semantics. Group navigation is
824 /// case-insensitive.
825 fn resolve_group(&self, path: &str) -> Result<keepass::db::GroupId> {
826 let segs = parse_group_path(path)?;
827 if segs.is_empty() {
828 return Ok(self.inner.db.root().id());
829 }
830 let refs: Vec<&str> = segs.iter().map(String::as_str).collect();
831 self.inner
832 .db
833 .root()
834 .group_by_path(&refs)
835 .map(|g| g.id())
836 .ok_or_else(|| Error::GroupNotFound(path.to_string()))
837 }
838
839 /// Move an entry to an existing group. The target must already exist —
840 /// a typo'd destination should error, not silently grow a new hierarchy
841 /// (use [`Vault::add_group`] first to create one).
842 pub fn move_entry(&mut self, id: &EntryId, group_path: &str) -> Result<()> {
843 let target = self.resolve_group(group_path)?;
844 let entry_id = self.lookup_entry_id(id)?;
845 let mut entry = self
846 .inner
847 .db
848 .entry_mut(entry_id)
849 .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
850 entry
851 .move_to(target)
852 .map_err(|_| Error::GroupNotFound(group_path.to_string()))?;
853 touch_location(&mut entry);
854 Ok(())
855 }
856
857 /// Create a group hierarchy with `mkdir -p` semantics for intermediate
858 /// segments. Errors with [`Error::GroupExists`] if the leaf group already
859 /// exists (matching `keepassxc-cli mkdir`).
860 pub fn add_group(&mut self, path: &str) -> Result<()> {
861 let segs = parse_group_path(path)?;
862 if segs.is_empty() {
863 return Err(Error::GroupExists(DEFAULT_GROUP.to_string()));
864 }
865 let mut current_id = self.inner.db.root().id();
866 for (i, segment) in segs.iter().enumerate() {
867 let is_leaf = i == segs.len() - 1;
868 let mut current = self
869 .inner
870 .db
871 .group_mut(current_id)
872 .expect("walked GroupId always resolves");
873 let existing = current.group_by_name_mut(segment).map(|g| g.id());
874 current_id = match existing {
875 Some(_) if is_leaf => return Err(Error::GroupExists(path.to_string())),
876 Some(id) => id,
877 None => current.add_group().edit(|g| g.name = segment.clone()).id(),
878 };
879 }
880 Ok(())
881 }
882
883 /// Ensure the recycle-bin group exists, creating it and pointing
884 /// `Meta/RecycleBinUUID` at it (KeePassXC's own convention) if missing.
885 fn ensure_recycle_bin(&mut self) -> keepass::db::GroupId {
886 if let Some(bin) = self.inner.db.recycle_bin() {
887 return bin.id();
888 }
889 let id = self
890 .inner
891 .db
892 .root_mut()
893 .add_group()
894 .edit(|g| g.name = RECYCLE_BIN_GROUP.to_string())
895 .id();
896 self.inner.db.meta.recyclebin_uuid = Some(id.uuid());
897 self.inner.db.meta.recyclebin_enabled = Some(true);
898 self.inner.db.meta.recyclebin_changed = Some(keepass::db::Times::now());
899 id
900 }
901
902 /// Is this group inside the recycle-bin subtree (including the bin itself)?
903 fn is_in_recycle_bin(&self, group_id: keepass::db::GroupId) -> bool {
904 let Some(bin) = self.inner.db.recycle_bin() else {
905 return false;
906 };
907 let bin_id = bin.id();
908 let mut cur = Some(group_id);
909 while let Some(gid) = cur {
910 if gid == bin_id {
911 return true;
912 }
913 cur = self
914 .inner
915 .db
916 .group(gid)
917 .and_then(|g| g.parent().map(|p| p.id()));
918 }
919 false
920 }
921
922 /// Delete an entry the KeePassXC way: move it to the recycle bin, unless
923 /// it is already inside the bin or the bin is disabled in Meta — then it
924 /// is destroyed. `permanent` forces outright destruction.
925 ///
926 /// Returns `true` if the entry was recycled, `false` if destroyed.
927 pub fn recycle_entry(&mut self, id: &EntryId, permanent: bool) -> Result<bool> {
928 let entry_id = self.lookup_entry_id(id)?;
929 let parent_id = {
930 let entry = self
931 .inner
932 .db
933 .entry(entry_id)
934 .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
935 entry.parent().id()
936 };
937 let bin_enabled = self.inner.db.meta.recyclebin_enabled.unwrap_or(true);
938 if permanent || !bin_enabled || self.is_in_recycle_bin(parent_id) {
939 self.delete_entry(id)?;
940 return Ok(false);
941 }
942 let bin = self.ensure_recycle_bin();
943 let mut entry = self
944 .inner
945 .db
946 .entry_mut(entry_id)
947 .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
948 entry
949 .move_to(bin)
950 .expect("recycle bin group id always resolves");
951 touch_location(&mut entry);
952 Ok(true)
953 }
954
955 /// Remove a group. Default: move it (contents and all) to the recycle
956 /// bin, mirroring KeePassXC. With `permanent` (or the bin disabled, or
957 /// the group already inside the bin) it is destroyed instead — and a
958 /// non-empty group is only destroyed when `recursive` is also set.
959 ///
960 /// Returns `true` if recycled, `false` if destroyed.
961 pub fn remove_group(&mut self, path: &str, permanent: bool, recursive: bool) -> Result<bool> {
962 let gid = self.resolve_group(path)?;
963 if gid == self.inner.db.root().id() {
964 return Err(Error::InvalidPath("cannot remove the root group".into()));
965 }
966 let (empty, in_bin) = {
967 let g = self.inner.db.group(gid).expect("resolved id");
968 let empty = g.entries().next().is_none() && g.groups().next().is_none();
969 (empty, self.is_in_recycle_bin(gid))
970 };
971 let bin_enabled = self.inner.db.meta.recyclebin_enabled.unwrap_or(true);
972 if permanent || !bin_enabled || in_bin {
973 if !empty && !recursive {
974 return Err(Error::GroupNotEmpty(path.to_string()));
975 }
976 self.inner.db.group_mut(gid).expect("resolved id").remove();
977 return Ok(false);
978 }
979 let bin = self.ensure_recycle_bin();
980 self.inner
981 .db
982 .group_mut(gid)
983 .expect("resolved id")
984 .move_to(bin)
985 .map_err(|e| Error::Kdbx(format!("moving group to recycle bin: {e:?}")))?;
986 Ok(true)
987 }
988
989 /// Case-insensitive substring search over title, username, URL, notes
990 /// and the group path. Protected values are never searched.
991 pub fn search_entries(&self, term: &str) -> Vec<EntrySummary> {
992 let needle = term.to_lowercase();
993 self.inner
994 .db
995 .iter_all_entries()
996 .filter(|e| {
997 let hay = |s: Option<&str>| s.is_some_and(|v| v.to_lowercase().contains(&needle));
998 hay(e.get_title())
999 || hay(e.get_username())
1000 || hay(e.get_url())
1001 || hay(e.get("Notes"))
1002 || build_group_path(e)
1003 .join("/")
1004 .to_lowercase()
1005 .contains(&needle)
1006 })
1007 .map(|e| summarise(&e))
1008 .collect()
1009 }
1010
1011 /// Resolve a `trove://` secret reference to a field value.
1012 ///
1013 /// Format: `trove://<entry-path>` (defaults to the `Password` field) or
1014 /// `trove://<entry-path>/<Field>` (the last `/`-segment is the field name
1015 /// when the whole path doesn't itself resolve to an entry). So
1016 /// `trove://Infra/prod/postgres` yields that entry's password, and
1017 /// `trove://Infra/prod/postgres/UserName` its username. Modeled on
1018 /// 1Password's `op://` references.
1019 ///
1020 /// Errors: [`Error::InvalidPath`] if the string isn't a `trove://` ref,
1021 /// [`Error::EntryNotFound`] if no entry matches, and [`Error::InvalidPath`]
1022 /// again if the entry exists but the named field is absent.
1023 pub fn resolve_ref(&self, reference: &str) -> Result<String> {
1024 let body = reference
1025 .strip_prefix("trove://")
1026 .ok_or_else(|| Error::InvalidPath(format!("not a trove:// reference: {reference}")))?;
1027 if body.is_empty() {
1028 return Err(Error::InvalidPath("empty trove:// reference".into()));
1029 }
1030 // Prefer treating the whole body as an entry path (field = Password).
1031 if let Some(id) = self.find_by_title(body) {
1032 return self
1033 .get_field(&id, "Password")?
1034 .ok_or_else(|| Error::InvalidPath(format!("{reference}: entry has no Password")));
1035 }
1036 // Otherwise the last segment is the field name.
1037 let (entry_path, field) = body
1038 .rsplit_once('/')
1039 .ok_or_else(|| Error::EntryNotFound(body.to_string()))?;
1040 let id = self
1041 .find_by_title(entry_path)
1042 .ok_or_else(|| Error::EntryNotFound(entry_path.to_string()))?;
1043 self.get_field(&id, field)?
1044 .ok_or_else(|| Error::InvalidPath(format!("{reference}: entry has no field '{field}'")))
1045 }
1046
1047 /// Current TOTP code for an entry, computed from its `otp` field (an
1048 /// `otpauth://` URI — KeePassXC's native storage format).
1049 pub fn totp_now(&self, id: &EntryId) -> Result<TotpCode> {
1050 let now = std::time::SystemTime::now()
1051 .duration_since(std::time::UNIX_EPOCH)
1052 .map_err(|e| Error::Totp(e.to_string()))?
1053 .as_secs();
1054 self.totp_at(id, now)
1055 }
1056
1057 /// TOTP code for an entry at a specific unix time. Deterministic — used
1058 /// by tests (RFC 6238 vectors) and future countdown displays.
1059 pub fn totp_at(&self, id: &EntryId, unix_secs: u64) -> Result<TotpCode> {
1060 let entry_id = self.lookup_entry_id(id)?;
1061 let entry = self
1062 .inner
1063 .db
1064 .entry(entry_id)
1065 .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
1066 if entry.get("otp").is_none() {
1067 return Err(Error::NoTotp(id.0.clone()));
1068 }
1069 let totp = entry.get_otp().map_err(|e| Error::Totp(e.to_string()))?;
1070 let code = totp.value_at(unix_secs);
1071 Ok(TotpCode {
1072 code: code.code,
1073 valid_for_secs: code.valid_for.as_secs(),
1074 period_secs: code.period.as_secs(),
1075 })
1076 }
1077
1078 /// Set an entry's `otp` field from an `otpauth://` URI, validating it
1079 /// parses as a TOTP spec first so garbage never lands in the vault. The
1080 /// field is stored Protected (KeePassXC's own treatment).
1081 pub fn set_totp_uri(&mut self, id: &EntryId, uri: &str) -> Result<()> {
1082 uri.parse::<keepass::db::TOTP>()
1083 .map_err(|e| Error::Totp(format!("invalid otpauth URI: {e}")))?;
1084 self.set_field(id, "otp", uri)
1085 }
1086
1087 /// Merge another vault into this one (KDBX-standard three-way semantics:
1088 /// last-write-wins by modification time, histories preserved — the same
1089 /// algorithm KeePassXC applies). The source is opened with its own
1090 /// credentials; this vault is saved afterwards.
1091 pub fn merge_from(
1092 &mut self,
1093 source: &Path,
1094 source_password: &str,
1095 source_keyfile: Option<&[u8]>,
1096 ) -> Result<MergeSummary> {
1097 if !source.exists() {
1098 return Err(Error::NotFound(source.to_path_buf()));
1099 }
1100 let mut file = std::fs::File::open(source)?;
1101 let key = database_key(source_password, source_keyfile)?;
1102 let other = keepass::Database::open(&mut file, key).map_err(open_err_to_error)?;
1103 // The KDBX merge algorithm reconciles DIVERGED COPIES of one vault
1104 // (shared UUIDs). Two unrelated vaults have different root UUIDs and
1105 // the upstream merge panics on them — refuse cleanly instead.
1106 if other.root().id() != self.inner.db.root().id() {
1107 return Err(Error::Kdbx(
1108 "source is not a copy of this vault (different root UUID); merge \
1109 reconciles diverged copies — to combine unrelated vaults, import \
1110 entries explicitly"
1111 .to_string(),
1112 ));
1113 }
1114 let log = self
1115 .inner
1116 .db
1117 .merge(&other)
1118 .map_err(|e| Error::Kdbx(format!("merge: {e}")))?;
1119 let mut summary = MergeSummary::default();
1120 for event in &log.events {
1121 use keepass::db::merge::MergeEventType;
1122 match event.event_type {
1123 MergeEventType::Created => summary.created += 1,
1124 MergeEventType::Updated => summary.updated += 1,
1125 MergeEventType::LocationUpdated => summary.relocated += 1,
1126 MergeEventType::Deleted => summary.deleted += 1,
1127 // MergeEventType is #[non_exhaustive]; count anything the
1128 // crate adds later as an update rather than dropping it.
1129 _ => summary.updated += 1,
1130 }
1131 }
1132 self.save()?;
1133 Ok(summary)
1134 }
1135
1136 /// The password this vault was opened/created with. For rekey flows that
1137 /// change only one credential (e.g. adding a keyfile, keeping the
1138 /// password) — the caller already presented it to open the vault.
1139 pub fn current_password(&self) -> &str {
1140 &self.inner.password
1141 }
1142
1143 /// The keyfile bytes this vault was opened/created with, if any.
1144 pub fn current_keyfile(&self) -> Option<&[u8]> {
1145 self.inner.keyfile.as_deref()
1146 }
1147
1148 /// Change the vault's credentials: a new password and/or keyfile. Takes
1149 /// effect immediately (the vault is re-saved under the new composite key).
1150 pub fn rekey(&mut self, new_password: &str, new_keyfile: Option<&[u8]>) -> Result<()> {
1151 let old_password = std::mem::replace(&mut self.inner.password, new_password.to_string());
1152 let old_keyfile =
1153 std::mem::replace(&mut self.inner.keyfile, new_keyfile.map(<[u8]>::to_vec));
1154 if let Err(e) = self.save() {
1155 // Roll back so a failed save leaves a consistent in-memory state.
1156 self.inner.password = old_password;
1157 self.inner.keyfile = old_keyfile;
1158 return Err(e);
1159 }
1160 let mut old_password = old_password;
1161 old_password.zeroize();
1162 if let Some(mut k) = old_keyfile {
1163 k.zeroize();
1164 }
1165 Ok(())
1166 }
1167
1168 /// Tune the Argon2 KDF (memory in KiB, iterations, parallelism). Applies
1169 /// on save. Errors if the vault uses a non-Argon2 KDF (retune those by
1170 /// opening in KeePassXC — trove only writes Argon2 vaults itself).
1171 pub fn set_argon2_params(
1172 &mut self,
1173 memory_kib: Option<u64>,
1174 iterations: Option<u64>,
1175 parallelism: Option<u32>,
1176 ) -> Result<()> {
1177 match &mut self.inner.db.config.kdf_config {
1178 keepass::config::KdfConfig::Argon2 {
1179 iterations: it,
1180 memory,
1181 parallelism: par,
1182 ..
1183 } => {
1184 if let Some(m) = memory_kib {
1185 *memory = m;
1186 }
1187 if let Some(i) = iterations {
1188 *it = i;
1189 }
1190 if let Some(p) = parallelism {
1191 *par = p;
1192 }
1193 self.save()
1194 }
1195 other => Err(Error::Kdbx(format!(
1196 "vault uses a non-Argon2 KDF ({other:?}); retune it in KeePassXC"
1197 ))),
1198 }
1199 }
1200
1201 /// Non-secret database facts for `db-info`.
1202 pub fn db_info(&self) -> DbInfo {
1203 let cfg = &self.inner.db.config;
1204 let entries = self.inner.db.iter_all_entries().count();
1205 let mut groups = 0usize;
1206 // Count groups by walking ids from the root (excludes the root itself).
1207 let mut stack = vec![self.inner.db.root().id()];
1208 while let Some(gid) = stack.pop() {
1209 if let Some(g) = self.inner.db.group(gid) {
1210 for child in g.groups() {
1211 groups += 1;
1212 stack.push(child.id());
1213 }
1214 }
1215 }
1216 DbInfo {
1217 version: format!("{}", cfg.version),
1218 cipher: format!("{:?}", cfg.outer_cipher_config),
1219 compression: format!("{:?}", cfg.compression_config),
1220 kdf: format!("{:?}", cfg.kdf_config),
1221 entries,
1222 groups,
1223 recycle_bin: self.inner.db.recycle_bin().is_some(),
1224 }
1225 }
1226
1227 /// Names of an entry's custom string fields (everything beyond the five
1228 /// standard kdbx fields), sorted. For `show`-style listings.
1229 pub fn custom_field_names(&self, id: &EntryId) -> Result<Vec<String>> {
1230 const STANDARD: [&str; 5] = ["Title", "UserName", "Password", "URL", "Notes"];
1231 let entry_id = self.lookup_entry_id(id)?;
1232 let entry = self
1233 .inner
1234 .db
1235 .entry(entry_id)
1236 .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
1237 let mut names: Vec<String> = entry
1238 .fields
1239 .keys()
1240 .filter(|k| !STANDARD.contains(&k.as_str()))
1241 .cloned()
1242 .collect();
1243 names.sort();
1244 Ok(names)
1245 }
1246}
1247
1248// --- helpers ---------------------------------------------------------------
1249
1250fn summarise(e: &keepass::db::EntryRef<'_>) -> EntrySummary {
1251 let attachment_names: Vec<String> = e
1252 .attachments_named()
1253 .map(|(name, _)| name.to_string())
1254 .collect();
1255 EntrySummary {
1256 id: EntryId(e.id().uuid().to_string()),
1257 title: e.get_title().unwrap_or("").to_string(),
1258 username: e.get_username().map(str::to_owned),
1259 url: e.get_url().map(str::to_owned),
1260 attachment_names,
1261 group_path: build_group_path(e),
1262 // kdbx stores these as second-precision naive UTC datetimes; render
1263 // them as RFC3339 UTC strings. `and_utc()` reinterprets the naive
1264 // value as UTC (it already is, per the KDBX spec) without shifting it.
1265 // The datetime type is chrono's, re-exported through keepass; we call
1266 // its methods without naming it so trove-core needs no direct chrono dep.
1267 created: e.times.creation.map(|dt| dt.and_utc().to_rfc3339()),
1268 modified: e
1269 .times
1270 .last_modification
1271 .map(|dt| dt.and_utc().to_rfc3339()),
1272 }
1273}
1274
1275/// Walk an entry's parent chain to the database root, collecting group
1276/// names. The root group is excluded — entries directly under root return
1277/// an empty vec. Output is ordered root → leaf so it joins as a path.
1278///
1279/// Walks by `GroupId` rather than `GroupRef` because the borrow checker
1280/// can't see that `cur.parent()` and `cur = parent` use disjoint slots of
1281/// the same `&Database`.
1282fn build_group_path(e: &keepass::db::EntryRef<'_>) -> Vec<String> {
1283 let db = e.database();
1284 let mut rev: Vec<String> = Vec::new();
1285 let mut cur_id = e.parent().id();
1286 while let Some(g) = db.group(cur_id) {
1287 match g.parent() {
1288 // Not at root yet — record this group's name and step up.
1289 Some(parent) => {
1290 rev.push(g.name.clone());
1291 cur_id = parent.id();
1292 }
1293 // Reached root (no parent). Root is excluded from the path.
1294 None => break,
1295 }
1296 }
1297 rev.reverse();
1298 rev
1299}
1300
1301/// Split a `/`-separated entry path into `(group_segments, leaf_title)`.
1302/// Returns `Err(Error::InvalidPath)` on any empty segment, empty leaf,
1303/// or trailing slash. A path with no `/` returns `(vec![], path)`.
1304///
1305/// A leading [`DEFAULT_GROUP`] (`"Root"`, case-insensitive) segment is
1306/// dropped: it names the database's top-level group, which is where group
1307/// walks already start. So `Root/x` and bare `x` resolve to the same place
1308/// and we never nest a `Root` inside the root.
1309fn parse_entry_path(s: &str) -> Result<(Vec<String>, String)> {
1310 if s.is_empty() {
1311 return Err(Error::InvalidPath("title must not be empty".into()));
1312 }
1313 let parts: Vec<&str> = s.split('/').collect();
1314 if parts.iter().any(|p| p.is_empty()) {
1315 return Err(Error::InvalidPath(format!(
1316 "path '{s}' has empty segment; leading/trailing/double '/' is not allowed"
1317 )));
1318 }
1319 let mut iter = parts.into_iter();
1320 let last = iter
1321 .next_back()
1322 .expect("non-empty split always yields at least one element");
1323 let mut groups: Vec<String> = iter.map(String::from).collect();
1324 if groups
1325 .first()
1326 .is_some_and(|g| g.eq_ignore_ascii_case(DEFAULT_GROUP))
1327 {
1328 groups.remove(0);
1329 }
1330 Ok((groups, last.to_string()))
1331}
1332
1333/// Like [`parse_entry_path`] but for a pure group path: every segment names a
1334/// group, there is no entry leaf. The empty string or a bare `Root`
1335/// (case-insensitive) resolves to the root group → empty vec; a leading
1336/// `Root/` segment is dropped the same way `parse_entry_path` drops it.
1337fn parse_group_path(s: &str) -> Result<Vec<String>> {
1338 if s.is_empty() || s.eq_ignore_ascii_case(DEFAULT_GROUP) {
1339 return Ok(Vec::new());
1340 }
1341 let parts: Vec<&str> = s.split('/').collect();
1342 if parts.iter().any(|p| p.is_empty()) {
1343 return Err(Error::InvalidPath(format!(
1344 "path '{s}' has empty segment; leading/trailing/double '/' is not allowed"
1345 )));
1346 }
1347 let mut segs: Vec<String> = parts.into_iter().map(String::from).collect();
1348 if segs
1349 .first()
1350 .is_some_and(|g| g.eq_ignore_ascii_case(DEFAULT_GROUP))
1351 {
1352 segs.remove(0);
1353 }
1354 Ok(segs)
1355}
1356
1357fn open_err_to_error(e: keepass::error::DatabaseOpenError) -> Error {
1358 use keepass::error::{DatabaseKeyError, DatabaseOpenError};
1359 match e {
1360 DatabaseOpenError::Io(io) => Error::Io(io),
1361 DatabaseOpenError::Key(DatabaseKeyError::IncorrectKey) => Error::BadPassword,
1362 DatabaseOpenError::Key(other) => Error::Kdbx(other.to_string()),
1363 DatabaseOpenError::UnsupportedVersion => {
1364 Error::Kdbx("unsupported kdbx version".to_string())
1365 }
1366 // DatabaseOpenError is #[non_exhaustive] in 0.12; integrity errors
1367 // (header HMAC mismatch on wrong password, etc.) flow through here.
1368 // The crate's PartialEq Debug impl prints "IncorrectKey" for either
1369 // path, so a string-match against the rendered error catches them.
1370 other => {
1371 let msg = other.to_string();
1372 if msg.to_lowercase().contains("incorrect")
1373 || msg.to_lowercase().contains("header hash")
1374 {
1375 Error::BadPassword
1376 } else {
1377 Error::Kdbx(msg)
1378 }
1379 }
1380 }
1381}
1382
1383fn save_err_to_error(e: keepass::error::DatabaseSaveError) -> Error {
1384 use keepass::error::DatabaseSaveError;
1385 match e {
1386 DatabaseSaveError::Io(io) => Error::Io(io),
1387 other => Error::Kdbx(other.to_string()),
1388 }
1389}
1390
1391/// Backfill the optional `<Meta>` policy fields with KeePassXC's own defaults.
1392///
1393/// trove never sets these itself, so left alone every reader substitutes its
1394/// own defaults and the effective policy depends on whichever tool last wrote
1395/// the file. Pinning them to the values `keepassxc-cli db-create` writes makes
1396/// a trove vault behave identically anywhere (and keeps the cross-tool
1397/// conformance matrix deterministic):
1398/// * 365-day maintenance-history window,
1399/// * master-key-change recommend/force both off (`-1`, the KeePass
1400/// "disabled" sentinel — these are *not* counters),
1401/// * 10-item / 6 MiB per-entry history limits,
1402/// * recycle bin enabled.
1403///
1404/// Backfill-only: a field already `Some(_)` is left untouched, so a policy a
1405/// user set in KeePassXC survives a trove round-trip.
1406fn apply_default_meta_policy(meta: &mut keepass::db::Meta) {
1407 meta.maintenance_history_days.get_or_insert(365);
1408 meta.master_key_change_rec.get_or_insert(-1);
1409 meta.master_key_change_force.get_or_insert(-1);
1410 meta.history_max_items.get_or_insert(10);
1411 meta.history_max_size.get_or_insert(6 * 1024 * 1024);
1412 meta.recyclebin_enabled.get_or_insert(true);
1413}