1#![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
31const DEFAULT_GROUP: &str = "Root";
37
38pub const RECYCLE_BIN_GROUP: &str = "Recycle Bin";
42
43#[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#[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 pub group_path: Vec<String>,
84}
85
86impl EntrySummary {
87 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#[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#[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#[derive(Debug, Clone)]
124pub struct TotpCode {
125 pub code: String,
127 pub valid_for_secs: u64,
129 pub period_secs: u64,
131}
132
133pub struct Vault {
138 pub(crate) inner: VaultInner,
139}
140
141#[cfg(feature = "yubikey")]
145pub use keepass::ChallengeResponseKey;
146
147pub(crate) struct VaultInner {
148 pub(crate) path: PathBuf,
149 pub(crate) password: String,
150 pub(crate) keyfile: Option<Vec<u8>>,
155 #[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 self.password.zeroize();
170 if let Some(k) = self.keyfile.as_mut() {
171 k.zeroize();
172 }
173 }
174}
175
176fn touch_modified(entry: &mut keepass::db::EntryMut<'_>) {
180 entry.times.last_modification = Some(keepass::db::Times::now());
181}
182
183fn touch_location(entry: &mut keepass::db::EntryMut<'_>) {
186 entry.times.location_changed = Some(keepass::db::Times::now());
187}
188
189fn 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 pub fn create(path: &Path, password: &str) -> Result<Self> {
205 Self::create_with_key(path, password, None)
206 }
207
208 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 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 #[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 #[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 pub fn open(path: &Path, password: &str) -> Result<Self> {
292 Self::open_with_key(path, password, None)
293 }
294
295 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 pub fn save(&mut self) -> Result<()> {
320 self.inner.db.config.version = DatabaseVersion::KDB4(1);
327 apply_default_meta_policy(&mut self.inner.db.meta);
331 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 {
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 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 pub fn add_entry(&mut self, title: &str) -> Result<EntryId> {
417 let (group_path, leaf) = parse_entry_path(title)?;
418 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 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 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 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 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 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 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 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 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 Ok(entry
551 .attachment_by_name(name)
552 .map(|att| att.data.get().clone()))
553 }
554
555 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 _ => summary.updated += 1,
949 }
950 }
951 self.save()?;
952 Ok(summary)
953 }
954
955 pub fn current_password(&self) -> &str {
959 &self.inner.password
960 }
961
962 pub fn current_keyfile(&self) -> Option<&[u8]> {
964 self.inner.keyfile.as_deref()
965 }
966
967 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 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 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 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 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 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
1067fn 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
1084fn 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 Some(parent) => {
1099 rev.push(g.name.clone());
1100 cur_id = parent.id();
1101 }
1102 None => break,
1104 }
1105 }
1106 rev.reverse();
1107 rev
1108}
1109
1110fn 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
1142fn 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 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
1200fn 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}