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
154pub(crate) struct VaultInner {
155    pub(crate) path: PathBuf,
156    pub(crate) password: String,
157    /// Raw keyfile bytes when the vault uses a composite key (password +
158    /// keyfile). Kept verbatim so `save()` derives the same composite key;
159    /// format interpretation (XML v1/v2, raw-32, hex-64, arbitrary-file
160    /// SHA-256) is the `keepass` crate's, matching KeePassXC.
161    pub(crate) keyfile: Option<Vec<u8>>,
162    /// Challenge-response provider for composite keys (YubiKey or software).
163    /// Held so every `save()` can re-answer the fresh challenge — kdbx
164    /// rotates the master seed per save, so the device/secret is consulted
165    /// again on each write.
166    #[cfg(feature = "yubikey")]
167    pub(crate) challenge_response: Option<ChallengeResponseKey>,
168    pub(crate) db: keepass::Database,
169}
170
171impl Drop for VaultInner {
172    fn drop(&mut self) {
173        // Best-effort: wipe the key material we kept in memory.
174        // The `keepass::Database` carries its own SecretBox-backed protected
175        // values; we don't reach into it.
176        self.password.zeroize();
177        if let Some(k) = self.keyfile.as_mut() {
178            k.zeroize();
179        }
180    }
181}
182
183/// Stamp an entry's `LastModificationTime` — every content mutation calls
184/// this, matching KeePassXC (KDBX merge resolves conflicts by this time, so
185/// stale stamps make trove edits silently lose merges).
186fn touch_modified(entry: &mut keepass::db::EntryMut<'_>) {
187    entry.times.last_modification = Some(keepass::db::Times::now());
188}
189
190/// Stamp an entry's `LocationChanged` — every relocation calls this (the
191/// KDBX merge algorithm uses it to resolve concurrent moves).
192fn touch_location(entry: &mut keepass::db::EntryMut<'_>) {
193    entry.times.location_changed = Some(keepass::db::Times::now());
194}
195
196/// Build the composite `DatabaseKey` from a password and optional keyfile
197/// bytes — the one place the two are combined, shared by open/create/save.
198fn database_key(password: &str, keyfile: Option<&[u8]>) -> Result<keepass::DatabaseKey> {
199    let mut key = keepass::DatabaseKey::new().with_password(password);
200    if let Some(bytes) = keyfile {
201        key = key
202            .with_keyfile(&mut &bytes[..])
203            .map_err(|e| Error::Kdbx(format!("reading keyfile: {e}")))?;
204    }
205    Ok(key)
206}
207
208impl Vault {
209    /// Create a new kdbx file at `path`, encrypted with `password`.
210    /// Errors if the file already exists.
211    pub fn create(path: &Path, password: &str) -> Result<Self> {
212        Self::create_with_key(path, password, None)
213    }
214
215    /// Create a new kdbx file locked by a composite key: `password` plus the
216    /// given keyfile bytes (any format KeePassXC accepts — XML v1/v2, raw
217    /// 32-byte, hex-64, or an arbitrary file hashed with SHA-256).
218    pub fn create_with_key(path: &Path, password: &str, keyfile: Option<&[u8]>) -> Result<Self> {
219        if path.exists() {
220            return Err(Error::AlreadyExists(path.to_path_buf()));
221        }
222
223        // `Database::new()` uses the default DatabaseConfig: KDBX4 + AES-256
224        // + GZip + ChaCha20 (inner stream) + Argon2d. KeePassXC reads this fine.
225        let db = keepass::Database::new();
226
227        let mut vault = Vault {
228            inner: VaultInner {
229                path: path.to_path_buf(),
230                password: password.to_string(),
231                keyfile: keyfile.map(<[u8]>::to_vec),
232                #[cfg(feature = "yubikey")]
233                challenge_response: None,
234                db,
235            },
236        };
237        vault.save()?;
238        Ok(vault)
239    }
240
241    /// Create a new kdbx file additionally locked by a challenge-response
242    /// key (YubiKey HMAC-SHA1 or the software `LocalChallenge` provider),
243    /// composited with the password and optional keyfile — KeePassXC's
244    /// scheme, so the same vault unlocks there with the same device.
245    #[cfg(feature = "yubikey")]
246    pub fn create_with_challenge_response(
247        path: &Path,
248        password: &str,
249        keyfile: Option<&[u8]>,
250        challenge_response: ChallengeResponseKey,
251    ) -> Result<Self> {
252        if path.exists() {
253            return Err(Error::AlreadyExists(path.to_path_buf()));
254        }
255        let mut vault = Vault {
256            inner: VaultInner {
257                path: path.to_path_buf(),
258                password: password.to_string(),
259                keyfile: keyfile.map(<[u8]>::to_vec),
260                challenge_response: Some(challenge_response),
261                db: keepass::Database::new(),
262            },
263        };
264        vault.save()?;
265        Ok(vault)
266    }
267
268    /// Open a challenge-response-locked vault. The provider is held for the
269    /// vault's lifetime: every later save re-answers the fresh challenge
270    /// (kdbx rotates the master seed per save), so a hardware key must stay
271    /// reachable while writing.
272    #[cfg(feature = "yubikey")]
273    pub fn open_with_challenge_response(
274        path: &Path,
275        password: &str,
276        keyfile: Option<&[u8]>,
277        challenge_response: ChallengeResponseKey,
278    ) -> Result<Self> {
279        if !path.exists() {
280            return Err(Error::NotFound(path.to_path_buf()));
281        }
282        let mut file = std::fs::File::open(path)?;
283        let key = database_key(password, keyfile)?
284            .with_challenge_response_key(challenge_response.clone());
285        let db = keepass::Database::open(&mut file, key).map_err(open_err_to_error)?;
286        Ok(Vault {
287            inner: VaultInner {
288                path: path.to_path_buf(),
289                password: password.to_string(),
290                keyfile: keyfile.map(<[u8]>::to_vec),
291                challenge_response: Some(challenge_response),
292                db,
293            },
294        })
295    }
296
297    /// Open an existing kdbx file with a password.
298    pub fn open(path: &Path, password: &str) -> Result<Self> {
299        Self::open_with_key(path, password, None)
300    }
301
302    /// Open an existing kdbx file with a composite key: `password` plus the
303    /// given keyfile bytes. A wrong or missing keyfile surfaces as
304    /// [`Error::BadPassword`], same as a wrong password — the kdbx format
305    /// cannot distinguish which credential was wrong.
306    pub fn open_with_key(path: &Path, password: &str, keyfile: Option<&[u8]>) -> Result<Self> {
307        if !path.exists() {
308            return Err(Error::NotFound(path.to_path_buf()));
309        }
310        let mut file = std::fs::File::open(path)?;
311        let key = database_key(password, keyfile)?;
312        let db = keepass::Database::open(&mut file, key).map_err(open_err_to_error)?;
313        Ok(Vault {
314            inner: VaultInner {
315                path: path.to_path_buf(),
316                password: password.to_string(),
317                keyfile: keyfile.map(<[u8]>::to_vec),
318                #[cfg(feature = "yubikey")]
319                challenge_response: None,
320                db,
321            },
322        })
323    }
324
325    /// Persist in-memory state back to the original path (atomic replace).
326    pub fn save(&mut self) -> Result<()> {
327        // trove only ever writes KDBX 4.1. Force the version before serializing
328        // so re-saving a legacy 4.0 vault (written by keepass 0.12.5) succeeds:
329        // the 0.13.10 writer emits only 4.1 and would otherwise reject KDB4(0)
330        // with "Unsupported database version". The re-serialize also drops
331        // 0.12.5's empty numeric <Meta> elements that made KeePassXC reject the
332        // file with "Invalid number value".
333        self.inner.db.config.version = DatabaseVersion::KDB4(1);
334        // Pin the optional <Meta> policy fields to KeePassXC's own defaults so a
335        // trove vault behaves identically in any reader. Backfill-only — a value
336        // already set (by KeePassXC, or a future trove setting) is left as-is.
337        apply_default_meta_policy(&mut self.inner.db.meta);
338        // Give the top-level group a name if it has none, so other clients
339        // (KeePassXC et al.) show a proper "Root" folder instead of a blank
340        // one. Backfills freshly created vaults (create() calls save()) and
341        // any legacy vault on its next write. trove addresses entries by the
342        // group chain *below* the root (`build_group_path` excludes it
343        // structurally), so naming it is invisible to our own paths.
344        if self.inner.db.root().name.is_empty() {
345            self.inner
346                .db
347                .root_mut()
348                .edit(|g| g.name = DEFAULT_GROUP.to_string());
349        }
350
351        let dir = self
352            .inner
353            .path
354            .parent()
355            .filter(|p| !p.as_os_str().is_empty())
356            .map(Path::to_path_buf)
357            .unwrap_or_else(|| PathBuf::from("."));
358
359        let file_name = self
360            .inner
361            .path
362            .file_name()
363            .ok_or_else(|| {
364                Error::Io(std::io::Error::new(
365                    std::io::ErrorKind::InvalidInput,
366                    "vault path has no file name",
367                ))
368            })?
369            .to_owned();
370
371        let mut tmp_name = std::ffi::OsString::from(&file_name);
372        tmp_name.push(format!(".tmp.{}", std::process::id()));
373        let tmp_path = dir.join(&tmp_name);
374
375        // Scope the file handle so it is closed (and thus fully flushed by the
376        // OS) before we attempt the rename. We also fsync explicitly for
377        // crash-safety on POSIX.
378        {
379            let mut tmp = std::fs::File::create(&tmp_path)?;
380            #[allow(unused_mut)]
381            let mut key = database_key(&self.inner.password, self.inner.keyfile.as_deref())?;
382            #[cfg(feature = "yubikey")]
383            if let Some(cr) = &self.inner.challenge_response {
384                key = key.with_challenge_response_key(cr.clone());
385            }
386            self.inner
387                .db
388                .save(&mut tmp, key)
389                .map_err(save_err_to_error)?;
390            tmp.sync_all()?;
391        }
392
393        // Atomic replace. `rename` over an existing target is atomic on POSIX.
394        if let Err(e) = std::fs::rename(&tmp_path, &self.inner.path) {
395            let _ = std::fs::remove_file(&tmp_path);
396            return Err(Error::Io(e));
397        }
398
399        Ok(())
400    }
401
402    pub fn path(&self) -> &Path {
403        &self.inner.path
404    }
405
406    /// Add a new entry. The `title` is interpreted as a `/`-separated path:
407    /// the leading segments name a group hierarchy (created as needed,
408    /// `mkdir -p` semantics), and the trailing segment becomes the entry
409    /// title. A title with no `/` lands at the root group, matching the
410    /// previous behavior.
411    ///
412    /// A leading `Root` segment (case-insensitive) names the root group
413    /// itself, so `add_entry("Root/github")` is identical to `add_entry("github")`.
414    ///
415    /// Examples:
416    ///   * `add_entry("github")`            → "github" in the root group
417    ///   * `add_entry("Work/SSH/github")`   → group "Work" > "SSH", entry "github"
418    ///
419    /// Empty segments (`//`, `/foo`, `foo/`) and the empty title are rejected
420    /// with `Error::InvalidPath`. Group lookups are case-insensitive (matches
421    /// keepass-rs and KeePassXC behavior), so `work/ssh` resolves to an
422    /// existing `Work/SSH`. Returns the entry's stable ID.
423    pub fn add_entry(&mut self, title: &str) -> Result<EntryId> {
424        let (group_path, leaf) = parse_entry_path(title)?;
425        // Walk by GroupId rather than by mutable reference — we can't carry a
426        // GroupMut across the loop because each iteration's lookup re-borrows
427        // through the previous one.
428        let mut current_id = self.inner.db.root().id();
429        for segment in &group_path {
430            let mut current = self
431                .inner
432                .db
433                .group_mut(current_id)
434                .expect("walked GroupId always resolves");
435            let existing = current.group_by_name_mut(segment).map(|g| g.id());
436            let next_id = match existing {
437                Some(id) => id,
438                None => current.add_group().edit(|g| g.name = segment.clone()).id(),
439            };
440            current_id = next_id;
441        }
442        let mut leaf_group = self
443            .inner
444            .db
445            .group_mut(current_id)
446            .expect("leaf GroupId always resolves");
447        let mut entry = leaf_group.add_entry();
448        entry.set_unprotected("Title", &leaf);
449        Ok(EntryId(entry.id().uuid().to_string()))
450    }
451
452    /// List all entries in the vault (recursively across all groups).
453    pub fn list_entries(&self) -> Vec<EntrySummary> {
454        self.inner
455            .db
456            .iter_all_entries()
457            .map(|e| summarise(&e))
458            .collect()
459    }
460
461    /// Look up an entry by ID. Returns `None` if no such entry exists.
462    pub fn get_entry(&self, id: &EntryId) -> Option<EntrySummary> {
463        self.inner
464            .db
465            .iter_all_entries()
466            .find(|e| e.id().uuid().to_string() == id.0)
467            .map(|e| summarise(&e))
468    }
469
470    /// Look up an entry by title or path.
471    ///
472    /// * Plain title with no `/`: returns the first entry whose leaf title
473    ///   matches (current behavior). Search is exact (case-sensitive) on the
474    ///   leaf title across all groups.
475    /// * Path with `/`: navigates `group/sub/.../leaf` and matches only the
476    ///   entry at exactly that path. Group navigation is case-insensitive
477    ///   (matching keepass-rs); the leaf title comparison is exact.
478    ///
479    /// Returns `None` if no such entry exists, or if any group segment in
480    /// the path is missing.
481    pub fn find_by_title(&self, title: &str) -> Option<EntryId> {
482        if title.contains('/') {
483            let (group_path, leaf) = parse_entry_path(title).ok()?;
484            // `title.contains('/')` guarantees at least one group segment.
485            let segs: Vec<&str> = group_path.iter().map(String::as_str).collect();
486            let root = self.inner.db.root();
487            let group = root.group_by_path(&segs)?;
488            return group
489                .entries()
490                .find(|e| e.get_title() == Some(leaf.as_str()))
491                .map(|e| EntryId(e.id().uuid().to_string()));
492        }
493        self.inner
494            .db
495            .iter_all_entries()
496            .find(|e| e.get_title() == Some(title))
497            .map(|e| EntryId(e.id().uuid().to_string()))
498    }
499
500    /// Set or replace a string field on an entry. Standard fields:
501    /// `"Title"`, `"UserName"`, `"Password"`, `"URL"`, `"Notes"`. Custom fields permitted.
502    ///
503    /// `Password` and `otp` are stored with the kdbx Protected flag —
504    /// matching KeePassXC, which memory-protects both by default.
505    pub fn set_field(&mut self, id: &EntryId, field: &str, value: &str) -> Result<()> {
506        const PROTECTED_FIELDS: [&str; 2] = ["Password", "otp"];
507        let entry_id = self.lookup_entry_id(id)?;
508        let mut entry = self
509            .inner
510            .db
511            .entry_mut(entry_id)
512            .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
513        if PROTECTED_FIELDS.contains(&field) {
514            entry.set_protected(field, value);
515        } else {
516            entry.set_unprotected(field, value);
517        }
518        touch_modified(&mut entry);
519        Ok(())
520    }
521
522    /// Attach a binary blob (e.g. an SSH private key) to an entry under `name`.
523    /// Replaces any existing attachment with the same name.
524    ///
525    /// Bytes are stored as a real KDBX4 inner-header binary attachment with a
526    /// `<Binary Ref="N"/>` reference inside the entry, matching what KeePassXC
527    /// writes. The Protected flag is left at the default (off) — KeePassXC
528    /// likewise stores SSH private keys without it.
529    pub fn attach_binary(&mut self, id: &EntryId, name: &str, bytes: &[u8]) -> Result<()> {
530        let entry_id = self.lookup_entry_id(id)?;
531        let mut entry = self
532            .inner
533            .db
534            .entry_mut(entry_id)
535            .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
536        // Replace-by-name semantics: drop any existing attachment with the
537        // same name first. add_attachment doesn't dedupe, so without this
538        // we'd accumulate orphans on rewrites.
539        entry.remove_attachment_by_name(name);
540        entry.add_attachment(name, Value::Unprotected(bytes.to_vec()));
541        touch_modified(&mut entry);
542        Ok(())
543    }
544
545    /// Read an attachment's bytes. Returns `Ok(None)` if the entry exists but has no such attachment.
546    /// Errors if the entry itself does not exist.
547    pub fn read_binary(&self, id: &EntryId, name: &str) -> Result<Option<Vec<u8>>> {
548        let entry_id = self.lookup_entry_id(id)?;
549        let entry = self
550            .inner
551            .db
552            .entry(entry_id)
553            .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
554        // `Value::get()` returns the inner bytes whether the value is stored
555        // unprotected or protected (it transparently exposes the secret), so we
556        // no longer need to match the variant or depend on `secrecy`.
557        Ok(entry
558            .attachment_by_name(name)
559            .map(|att| att.data.get().clone()))
560    }
561
562    /// Remove an attachment from an entry. No-op if the attachment is missing.
563    pub fn remove_binary(&mut self, id: &EntryId, name: &str) -> Result<()> {
564        let entry_id = self.lookup_entry_id(id)?;
565        let mut entry = self
566            .inner
567            .db
568            .entry_mut(entry_id)
569            .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
570        entry.remove_attachment_by_name(name);
571        touch_modified(&mut entry);
572        Ok(())
573    }
574
575    /// Delete an entry by ID.
576    pub fn delete_entry(&mut self, id: &EntryId) -> Result<()> {
577        let entry_id = self.lookup_entry_id(id)?;
578        let entry = self
579            .inner
580            .db
581            .entry_mut(entry_id)
582            .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
583        entry.remove();
584        Ok(())
585    }
586
587    /// Read a single string field from an entry. Returns `None` if the field
588    /// is missing. Errors if the entry itself does not exist.
589    ///
590    /// Used by the materialization layer to read `Materialize.*` custom fields
591    /// from entries that opt in.
592    pub fn get_field(&self, id: &EntryId, field: &str) -> Result<Option<String>> {
593        let entry_id = self.lookup_entry_id(id)?;
594        let entry = self
595            .inner
596            .db
597            .entry(entry_id)
598            .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
599        Ok(entry.get(field).map(|s| s.to_string()))
600    }
601
602    /// Return the names of every custom string field on an entry whose name
603    /// starts with `prefix`. Field names are returned in unspecified order.
604    /// Errors if the entry does not exist.
605    ///
606    /// Used by the materialization layer so the daemon can quickly tell which
607    /// entries opt in (any entry with at least one `Materialize.*` field).
608    pub fn fields_with_prefix(&self, id: &EntryId, prefix: &str) -> Result<Vec<String>> {
609        let entry_id = self.lookup_entry_id(id)?;
610        let entry = self
611            .inner
612            .db
613            .entry(entry_id)
614            .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
615        Ok(entry
616            .fields
617            .keys()
618            .filter(|k| k.starts_with(prefix))
619            .cloned()
620            .collect())
621    }
622
623    /// Convert our `EntryId(String)` into the upstream `keepass::db::EntryId`
624    /// by walking entries and matching on Uuid string. Upstream's EntryId has
625    /// only `pub(crate)` constructors, so this is the only way to round-trip.
626    fn lookup_entry_id(&self, id: &EntryId) -> Result<keepass::db::EntryId> {
627        self.inner
628            .db
629            .iter_all_entries()
630            .find(|e| e.id().uuid().to_string() == id.0)
631            .map(|e| e.id())
632            .ok_or_else(|| Error::EntryNotFound(id.0.clone()))
633    }
634
635    /// Remove a string field from an entry. No-op if the field is absent.
636    pub fn remove_field(&mut self, id: &EntryId, field: &str) -> Result<()> {
637        let entry_id = self.lookup_entry_id(id)?;
638        let mut entry = self
639            .inner
640            .db
641            .entry_mut(entry_id)
642            .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
643        entry.fields.remove(field);
644        Ok(())
645    }
646
647    /// Resolve a `/`-separated group path to its `GroupId`. The empty string
648    /// or a bare/leading `Root` (case-insensitive) names the root group,
649    /// mirroring [`Vault::add_entry`] path semantics. Group navigation is
650    /// case-insensitive.
651    fn resolve_group(&self, path: &str) -> Result<keepass::db::GroupId> {
652        let segs = parse_group_path(path)?;
653        if segs.is_empty() {
654            return Ok(self.inner.db.root().id());
655        }
656        let refs: Vec<&str> = segs.iter().map(String::as_str).collect();
657        self.inner
658            .db
659            .root()
660            .group_by_path(&refs)
661            .map(|g| g.id())
662            .ok_or_else(|| Error::GroupNotFound(path.to_string()))
663    }
664
665    /// Move an entry to an existing group. The target must already exist —
666    /// a typo'd destination should error, not silently grow a new hierarchy
667    /// (use [`Vault::add_group`] first to create one).
668    pub fn move_entry(&mut self, id: &EntryId, group_path: &str) -> Result<()> {
669        let target = self.resolve_group(group_path)?;
670        let entry_id = self.lookup_entry_id(id)?;
671        let mut entry = self
672            .inner
673            .db
674            .entry_mut(entry_id)
675            .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
676        entry
677            .move_to(target)
678            .map_err(|_| Error::GroupNotFound(group_path.to_string()))?;
679        touch_location(&mut entry);
680        Ok(())
681    }
682
683    /// Create a group hierarchy with `mkdir -p` semantics for intermediate
684    /// segments. Errors with [`Error::GroupExists`] if the leaf group already
685    /// exists (matching `keepassxc-cli mkdir`).
686    pub fn add_group(&mut self, path: &str) -> Result<()> {
687        let segs = parse_group_path(path)?;
688        if segs.is_empty() {
689            return Err(Error::GroupExists(DEFAULT_GROUP.to_string()));
690        }
691        let mut current_id = self.inner.db.root().id();
692        for (i, segment) in segs.iter().enumerate() {
693            let is_leaf = i == segs.len() - 1;
694            let mut current = self
695                .inner
696                .db
697                .group_mut(current_id)
698                .expect("walked GroupId always resolves");
699            let existing = current.group_by_name_mut(segment).map(|g| g.id());
700            current_id = match existing {
701                Some(_) if is_leaf => return Err(Error::GroupExists(path.to_string())),
702                Some(id) => id,
703                None => current.add_group().edit(|g| g.name = segment.clone()).id(),
704            };
705        }
706        Ok(())
707    }
708
709    /// Ensure the recycle-bin group exists, creating it and pointing
710    /// `Meta/RecycleBinUUID` at it (KeePassXC's own convention) if missing.
711    fn ensure_recycle_bin(&mut self) -> keepass::db::GroupId {
712        if let Some(bin) = self.inner.db.recycle_bin() {
713            return bin.id();
714        }
715        let id = self
716            .inner
717            .db
718            .root_mut()
719            .add_group()
720            .edit(|g| g.name = RECYCLE_BIN_GROUP.to_string())
721            .id();
722        self.inner.db.meta.recyclebin_uuid = Some(id.uuid());
723        self.inner.db.meta.recyclebin_enabled = Some(true);
724        self.inner.db.meta.recyclebin_changed = Some(keepass::db::Times::now());
725        id
726    }
727
728    /// Is this group inside the recycle-bin subtree (including the bin itself)?
729    fn is_in_recycle_bin(&self, group_id: keepass::db::GroupId) -> bool {
730        let Some(bin) = self.inner.db.recycle_bin() else {
731            return false;
732        };
733        let bin_id = bin.id();
734        let mut cur = Some(group_id);
735        while let Some(gid) = cur {
736            if gid == bin_id {
737                return true;
738            }
739            cur = self
740                .inner
741                .db
742                .group(gid)
743                .and_then(|g| g.parent().map(|p| p.id()));
744        }
745        false
746    }
747
748    /// Delete an entry the KeePassXC way: move it to the recycle bin, unless
749    /// it is already inside the bin or the bin is disabled in Meta — then it
750    /// is destroyed. `permanent` forces outright destruction.
751    ///
752    /// Returns `true` if the entry was recycled, `false` if destroyed.
753    pub fn recycle_entry(&mut self, id: &EntryId, permanent: bool) -> Result<bool> {
754        let entry_id = self.lookup_entry_id(id)?;
755        let parent_id = {
756            let entry = self
757                .inner
758                .db
759                .entry(entry_id)
760                .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
761            entry.parent().id()
762        };
763        let bin_enabled = self.inner.db.meta.recyclebin_enabled.unwrap_or(true);
764        if permanent || !bin_enabled || self.is_in_recycle_bin(parent_id) {
765            self.delete_entry(id)?;
766            return Ok(false);
767        }
768        let bin = self.ensure_recycle_bin();
769        let mut entry = self
770            .inner
771            .db
772            .entry_mut(entry_id)
773            .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
774        entry
775            .move_to(bin)
776            .expect("recycle bin group id always resolves");
777        touch_location(&mut entry);
778        Ok(true)
779    }
780
781    /// Remove a group. Default: move it (contents and all) to the recycle
782    /// bin, mirroring KeePassXC. With `permanent` (or the bin disabled, or
783    /// the group already inside the bin) it is destroyed instead — and a
784    /// non-empty group is only destroyed when `recursive` is also set.
785    ///
786    /// Returns `true` if recycled, `false` if destroyed.
787    pub fn remove_group(&mut self, path: &str, permanent: bool, recursive: bool) -> Result<bool> {
788        let gid = self.resolve_group(path)?;
789        if gid == self.inner.db.root().id() {
790            return Err(Error::InvalidPath("cannot remove the root group".into()));
791        }
792        let (empty, in_bin) = {
793            let g = self.inner.db.group(gid).expect("resolved id");
794            let empty = g.entries().next().is_none() && g.groups().next().is_none();
795            (empty, self.is_in_recycle_bin(gid))
796        };
797        let bin_enabled = self.inner.db.meta.recyclebin_enabled.unwrap_or(true);
798        if permanent || !bin_enabled || in_bin {
799            if !empty && !recursive {
800                return Err(Error::GroupNotEmpty(path.to_string()));
801            }
802            self.inner.db.group_mut(gid).expect("resolved id").remove();
803            return Ok(false);
804        }
805        let bin = self.ensure_recycle_bin();
806        self.inner
807            .db
808            .group_mut(gid)
809            .expect("resolved id")
810            .move_to(bin)
811            .map_err(|e| Error::Kdbx(format!("moving group to recycle bin: {e:?}")))?;
812        Ok(true)
813    }
814
815    /// Case-insensitive substring search over title, username, URL, notes
816    /// and the group path. Protected values are never searched.
817    pub fn search_entries(&self, term: &str) -> Vec<EntrySummary> {
818        let needle = term.to_lowercase();
819        self.inner
820            .db
821            .iter_all_entries()
822            .filter(|e| {
823                let hay = |s: Option<&str>| s.is_some_and(|v| v.to_lowercase().contains(&needle));
824                hay(e.get_title())
825                    || hay(e.get_username())
826                    || hay(e.get_url())
827                    || hay(e.get("Notes"))
828                    || build_group_path(e)
829                        .join("/")
830                        .to_lowercase()
831                        .contains(&needle)
832            })
833            .map(|e| summarise(&e))
834            .collect()
835    }
836
837    /// Resolve a `trove://` secret reference to a field value.
838    ///
839    /// Format: `trove://<entry-path>` (defaults to the `Password` field) or
840    /// `trove://<entry-path>/<Field>` (the last `/`-segment is the field name
841    /// when the whole path doesn't itself resolve to an entry). So
842    /// `trove://Infra/prod/postgres` yields that entry's password, and
843    /// `trove://Infra/prod/postgres/UserName` its username. Modeled on
844    /// 1Password's `op://` references.
845    ///
846    /// Errors: [`Error::InvalidPath`] if the string isn't a `trove://` ref,
847    /// [`Error::EntryNotFound`] if no entry matches, and [`Error::InvalidPath`]
848    /// again if the entry exists but the named field is absent.
849    pub fn resolve_ref(&self, reference: &str) -> Result<String> {
850        let body = reference
851            .strip_prefix("trove://")
852            .ok_or_else(|| Error::InvalidPath(format!("not a trove:// reference: {reference}")))?;
853        if body.is_empty() {
854            return Err(Error::InvalidPath("empty trove:// reference".into()));
855        }
856        // Prefer treating the whole body as an entry path (field = Password).
857        if let Some(id) = self.find_by_title(body) {
858            return self
859                .get_field(&id, "Password")?
860                .ok_or_else(|| Error::InvalidPath(format!("{reference}: entry has no Password")));
861        }
862        // Otherwise the last segment is the field name.
863        let (entry_path, field) = body
864            .rsplit_once('/')
865            .ok_or_else(|| Error::EntryNotFound(body.to_string()))?;
866        let id = self
867            .find_by_title(entry_path)
868            .ok_or_else(|| Error::EntryNotFound(entry_path.to_string()))?;
869        self.get_field(&id, field)?
870            .ok_or_else(|| Error::InvalidPath(format!("{reference}: entry has no field '{field}'")))
871    }
872
873    /// Current TOTP code for an entry, computed from its `otp` field (an
874    /// `otpauth://` URI — KeePassXC's native storage format).
875    pub fn totp_now(&self, id: &EntryId) -> Result<TotpCode> {
876        let now = std::time::SystemTime::now()
877            .duration_since(std::time::UNIX_EPOCH)
878            .map_err(|e| Error::Totp(e.to_string()))?
879            .as_secs();
880        self.totp_at(id, now)
881    }
882
883    /// TOTP code for an entry at a specific unix time. Deterministic — used
884    /// by tests (RFC 6238 vectors) and future countdown displays.
885    pub fn totp_at(&self, id: &EntryId, unix_secs: u64) -> Result<TotpCode> {
886        let entry_id = self.lookup_entry_id(id)?;
887        let entry = self
888            .inner
889            .db
890            .entry(entry_id)
891            .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
892        if entry.get("otp").is_none() {
893            return Err(Error::NoTotp(id.0.clone()));
894        }
895        let totp = entry.get_otp().map_err(|e| Error::Totp(e.to_string()))?;
896        let code = totp.value_at(unix_secs);
897        Ok(TotpCode {
898            code: code.code,
899            valid_for_secs: code.valid_for.as_secs(),
900            period_secs: code.period.as_secs(),
901        })
902    }
903
904    /// Set an entry's `otp` field from an `otpauth://` URI, validating it
905    /// parses as a TOTP spec first so garbage never lands in the vault. The
906    /// field is stored Protected (KeePassXC's own treatment).
907    pub fn set_totp_uri(&mut self, id: &EntryId, uri: &str) -> Result<()> {
908        uri.parse::<keepass::db::TOTP>()
909            .map_err(|e| Error::Totp(format!("invalid otpauth URI: {e}")))?;
910        self.set_field(id, "otp", uri)
911    }
912
913    /// Merge another vault into this one (KDBX-standard three-way semantics:
914    /// last-write-wins by modification time, histories preserved — the same
915    /// algorithm KeePassXC applies). The source is opened with its own
916    /// credentials; this vault is saved afterwards.
917    pub fn merge_from(
918        &mut self,
919        source: &Path,
920        source_password: &str,
921        source_keyfile: Option<&[u8]>,
922    ) -> Result<MergeSummary> {
923        if !source.exists() {
924            return Err(Error::NotFound(source.to_path_buf()));
925        }
926        let mut file = std::fs::File::open(source)?;
927        let key = database_key(source_password, source_keyfile)?;
928        let other = keepass::Database::open(&mut file, key).map_err(open_err_to_error)?;
929        // The KDBX merge algorithm reconciles DIVERGED COPIES of one vault
930        // (shared UUIDs). Two unrelated vaults have different root UUIDs and
931        // the upstream merge panics on them — refuse cleanly instead.
932        if other.root().id() != self.inner.db.root().id() {
933            return Err(Error::Kdbx(
934                "source is not a copy of this vault (different root UUID); merge \
935                 reconciles diverged copies — to combine unrelated vaults, import \
936                 entries explicitly"
937                    .to_string(),
938            ));
939        }
940        let log = self
941            .inner
942            .db
943            .merge(&other)
944            .map_err(|e| Error::Kdbx(format!("merge: {e}")))?;
945        let mut summary = MergeSummary::default();
946        for event in &log.events {
947            use keepass::db::merge::MergeEventType;
948            match event.event_type {
949                MergeEventType::Created => summary.created += 1,
950                MergeEventType::Updated => summary.updated += 1,
951                MergeEventType::LocationUpdated => summary.relocated += 1,
952                MergeEventType::Deleted => summary.deleted += 1,
953                // MergeEventType is #[non_exhaustive]; count anything the
954                // crate adds later as an update rather than dropping it.
955                _ => summary.updated += 1,
956            }
957        }
958        self.save()?;
959        Ok(summary)
960    }
961
962    /// The password this vault was opened/created with. For rekey flows that
963    /// change only one credential (e.g. adding a keyfile, keeping the
964    /// password) — the caller already presented it to open the vault.
965    pub fn current_password(&self) -> &str {
966        &self.inner.password
967    }
968
969    /// The keyfile bytes this vault was opened/created with, if any.
970    pub fn current_keyfile(&self) -> Option<&[u8]> {
971        self.inner.keyfile.as_deref()
972    }
973
974    /// Change the vault's credentials: a new password and/or keyfile. Takes
975    /// effect immediately (the vault is re-saved under the new composite key).
976    pub fn rekey(&mut self, new_password: &str, new_keyfile: Option<&[u8]>) -> Result<()> {
977        let old_password = std::mem::replace(&mut self.inner.password, new_password.to_string());
978        let old_keyfile =
979            std::mem::replace(&mut self.inner.keyfile, new_keyfile.map(<[u8]>::to_vec));
980        if let Err(e) = self.save() {
981            // Roll back so a failed save leaves a consistent in-memory state.
982            self.inner.password = old_password;
983            self.inner.keyfile = old_keyfile;
984            return Err(e);
985        }
986        let mut old_password = old_password;
987        old_password.zeroize();
988        if let Some(mut k) = old_keyfile {
989            k.zeroize();
990        }
991        Ok(())
992    }
993
994    /// Tune the Argon2 KDF (memory in KiB, iterations, parallelism). Applies
995    /// on save. Errors if the vault uses a non-Argon2 KDF (retune those by
996    /// opening in KeePassXC — trove only writes Argon2 vaults itself).
997    pub fn set_argon2_params(
998        &mut self,
999        memory_kib: Option<u64>,
1000        iterations: Option<u64>,
1001        parallelism: Option<u32>,
1002    ) -> Result<()> {
1003        match &mut self.inner.db.config.kdf_config {
1004            keepass::config::KdfConfig::Argon2 {
1005                iterations: it,
1006                memory,
1007                parallelism: par,
1008                ..
1009            } => {
1010                if let Some(m) = memory_kib {
1011                    *memory = m;
1012                }
1013                if let Some(i) = iterations {
1014                    *it = i;
1015                }
1016                if let Some(p) = parallelism {
1017                    *par = p;
1018                }
1019                self.save()
1020            }
1021            other => Err(Error::Kdbx(format!(
1022                "vault uses a non-Argon2 KDF ({other:?}); retune it in KeePassXC"
1023            ))),
1024        }
1025    }
1026
1027    /// Non-secret database facts for `db-info`.
1028    pub fn db_info(&self) -> DbInfo {
1029        let cfg = &self.inner.db.config;
1030        let entries = self.inner.db.iter_all_entries().count();
1031        let mut groups = 0usize;
1032        // Count groups by walking ids from the root (excludes the root itself).
1033        let mut stack = vec![self.inner.db.root().id()];
1034        while let Some(gid) = stack.pop() {
1035            if let Some(g) = self.inner.db.group(gid) {
1036                for child in g.groups() {
1037                    groups += 1;
1038                    stack.push(child.id());
1039                }
1040            }
1041        }
1042        DbInfo {
1043            version: format!("{}", cfg.version),
1044            cipher: format!("{:?}", cfg.outer_cipher_config),
1045            compression: format!("{:?}", cfg.compression_config),
1046            kdf: format!("{:?}", cfg.kdf_config),
1047            entries,
1048            groups,
1049            recycle_bin: self.inner.db.recycle_bin().is_some(),
1050        }
1051    }
1052
1053    /// Names of an entry's custom string fields (everything beyond the five
1054    /// standard kdbx fields), sorted. For `show`-style listings.
1055    pub fn custom_field_names(&self, id: &EntryId) -> Result<Vec<String>> {
1056        const STANDARD: [&str; 5] = ["Title", "UserName", "Password", "URL", "Notes"];
1057        let entry_id = self.lookup_entry_id(id)?;
1058        let entry = self
1059            .inner
1060            .db
1061            .entry(entry_id)
1062            .ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
1063        let mut names: Vec<String> = entry
1064            .fields
1065            .keys()
1066            .filter(|k| !STANDARD.contains(&k.as_str()))
1067            .cloned()
1068            .collect();
1069        names.sort();
1070        Ok(names)
1071    }
1072}
1073
1074// --- helpers ---------------------------------------------------------------
1075
1076fn summarise(e: &keepass::db::EntryRef<'_>) -> EntrySummary {
1077    let attachment_names: Vec<String> = e
1078        .attachments_named()
1079        .map(|(name, _)| name.to_string())
1080        .collect();
1081    EntrySummary {
1082        id: EntryId(e.id().uuid().to_string()),
1083        title: e.get_title().unwrap_or("").to_string(),
1084        username: e.get_username().map(str::to_owned),
1085        url: e.get_url().map(str::to_owned),
1086        attachment_names,
1087        group_path: build_group_path(e),
1088        // kdbx stores these as second-precision naive UTC datetimes; render
1089        // them as RFC3339 UTC strings. `and_utc()` reinterprets the naive
1090        // value as UTC (it already is, per the KDBX spec) without shifting it.
1091        // The datetime type is chrono's, re-exported through keepass; we call
1092        // its methods without naming it so trove-core needs no direct chrono dep.
1093        created: e.times.creation.map(|dt| dt.and_utc().to_rfc3339()),
1094        modified: e
1095            .times
1096            .last_modification
1097            .map(|dt| dt.and_utc().to_rfc3339()),
1098    }
1099}
1100
1101/// Walk an entry's parent chain to the database root, collecting group
1102/// names. The root group is excluded — entries directly under root return
1103/// an empty vec. Output is ordered root → leaf so it joins as a path.
1104///
1105/// Walks by `GroupId` rather than `GroupRef` because the borrow checker
1106/// can't see that `cur.parent()` and `cur = parent` use disjoint slots of
1107/// the same `&Database`.
1108fn build_group_path(e: &keepass::db::EntryRef<'_>) -> Vec<String> {
1109    let db = e.database();
1110    let mut rev: Vec<String> = Vec::new();
1111    let mut cur_id = e.parent().id();
1112    while let Some(g) = db.group(cur_id) {
1113        match g.parent() {
1114            // Not at root yet — record this group's name and step up.
1115            Some(parent) => {
1116                rev.push(g.name.clone());
1117                cur_id = parent.id();
1118            }
1119            // Reached root (no parent). Root is excluded from the path.
1120            None => break,
1121        }
1122    }
1123    rev.reverse();
1124    rev
1125}
1126
1127/// Split a `/`-separated entry path into `(group_segments, leaf_title)`.
1128/// Returns `Err(Error::InvalidPath)` on any empty segment, empty leaf,
1129/// or trailing slash. A path with no `/` returns `(vec![], path)`.
1130///
1131/// A leading [`DEFAULT_GROUP`] (`"Root"`, case-insensitive) segment is
1132/// dropped: it names the database's top-level group, which is where group
1133/// walks already start. So `Root/x` and bare `x` resolve to the same place
1134/// and we never nest a `Root` inside the root.
1135fn parse_entry_path(s: &str) -> Result<(Vec<String>, String)> {
1136    if s.is_empty() {
1137        return Err(Error::InvalidPath("title must not be empty".into()));
1138    }
1139    let parts: Vec<&str> = s.split('/').collect();
1140    if parts.iter().any(|p| p.is_empty()) {
1141        return Err(Error::InvalidPath(format!(
1142            "path '{s}' has empty segment; leading/trailing/double '/' is not allowed"
1143        )));
1144    }
1145    let mut iter = parts.into_iter();
1146    let last = iter
1147        .next_back()
1148        .expect("non-empty split always yields at least one element");
1149    let mut groups: Vec<String> = iter.map(String::from).collect();
1150    if groups
1151        .first()
1152        .is_some_and(|g| g.eq_ignore_ascii_case(DEFAULT_GROUP))
1153    {
1154        groups.remove(0);
1155    }
1156    Ok((groups, last.to_string()))
1157}
1158
1159/// Like [`parse_entry_path`] but for a pure group path: every segment names a
1160/// group, there is no entry leaf. The empty string or a bare `Root`
1161/// (case-insensitive) resolves to the root group → empty vec; a leading
1162/// `Root/` segment is dropped the same way `parse_entry_path` drops it.
1163fn parse_group_path(s: &str) -> Result<Vec<String>> {
1164    if s.is_empty() || s.eq_ignore_ascii_case(DEFAULT_GROUP) {
1165        return Ok(Vec::new());
1166    }
1167    let parts: Vec<&str> = s.split('/').collect();
1168    if parts.iter().any(|p| p.is_empty()) {
1169        return Err(Error::InvalidPath(format!(
1170            "path '{s}' has empty segment; leading/trailing/double '/' is not allowed"
1171        )));
1172    }
1173    let mut segs: Vec<String> = parts.into_iter().map(String::from).collect();
1174    if segs
1175        .first()
1176        .is_some_and(|g| g.eq_ignore_ascii_case(DEFAULT_GROUP))
1177    {
1178        segs.remove(0);
1179    }
1180    Ok(segs)
1181}
1182
1183fn open_err_to_error(e: keepass::error::DatabaseOpenError) -> Error {
1184    use keepass::error::{DatabaseKeyError, DatabaseOpenError};
1185    match e {
1186        DatabaseOpenError::Io(io) => Error::Io(io),
1187        DatabaseOpenError::Key(DatabaseKeyError::IncorrectKey) => Error::BadPassword,
1188        DatabaseOpenError::Key(other) => Error::Kdbx(other.to_string()),
1189        DatabaseOpenError::UnsupportedVersion => {
1190            Error::Kdbx("unsupported kdbx version".to_string())
1191        }
1192        // DatabaseOpenError is #[non_exhaustive] in 0.12; integrity errors
1193        // (header HMAC mismatch on wrong password, etc.) flow through here.
1194        // The crate's PartialEq Debug impl prints "IncorrectKey" for either
1195        // path, so a string-match against the rendered error catches them.
1196        other => {
1197            let msg = other.to_string();
1198            if msg.to_lowercase().contains("incorrect")
1199                || msg.to_lowercase().contains("header hash")
1200            {
1201                Error::BadPassword
1202            } else {
1203                Error::Kdbx(msg)
1204            }
1205        }
1206    }
1207}
1208
1209fn save_err_to_error(e: keepass::error::DatabaseSaveError) -> Error {
1210    use keepass::error::DatabaseSaveError;
1211    match e {
1212        DatabaseSaveError::Io(io) => Error::Io(io),
1213        other => Error::Kdbx(other.to_string()),
1214    }
1215}
1216
1217/// Backfill the optional `<Meta>` policy fields with KeePassXC's own defaults.
1218///
1219/// trove never sets these itself, so left alone every reader substitutes its
1220/// own defaults and the effective policy depends on whichever tool last wrote
1221/// the file. Pinning them to the values `keepassxc-cli db-create` writes makes
1222/// a trove vault behave identically anywhere (and keeps the cross-tool
1223/// conformance matrix deterministic):
1224///   * 365-day maintenance-history window,
1225///   * master-key-change recommend/force both off (`-1`, the KeePass
1226///     "disabled" sentinel — these are *not* counters),
1227///   * 10-item / 6 MiB per-entry history limits,
1228///   * recycle bin enabled.
1229///
1230/// Backfill-only: a field already `Some(_)` is left untouched, so a policy a
1231/// user set in KeePassXC survives a trove round-trip.
1232fn apply_default_meta_policy(meta: &mut keepass::db::Meta) {
1233    meta.maintenance_history_days.get_or_insert(365);
1234    meta.master_key_change_rec.get_or_insert(-1);
1235    meta.master_key_change_force.get_or_insert(-1);
1236    meta.history_max_items.get_or_insert(10);
1237    meta.history_max_size.get_or_insert(6 * 1024 * 1024);
1238    meta.recyclebin_enabled.get_or_insert(true);
1239}