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