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}