Skip to main content

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| &current != 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}