#![forbid(unsafe_code)]
use std::path::{Path, PathBuf};
use keepass::config::DatabaseVersion;
use keepass::db::Value;
use zeroize::Zeroize;
mod error;
pub use error::Error;
pub type Result<T> = std::result::Result<T, Error>;
const DEFAULT_GROUP: &str = "Root";
pub const RECYCLE_BIN_GROUP: &str = "Recycle Bin";
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct EntryId(pub(crate) String);
impl EntryId {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for EntryId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::str::FromStr for EntryId {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
Ok(EntryId(s.to_string()))
}
}
#[derive(Debug, Clone)]
pub struct EntrySummary {
pub id: EntryId,
pub title: String,
pub username: Option<String>,
pub url: Option<String>,
pub attachment_names: Vec<String>,
pub group_path: Vec<String>,
pub created: Option<String>,
pub modified: Option<String>,
}
impl EntrySummary {
pub fn display_path(&self) -> String {
if self.group_path.is_empty() {
self.title.clone()
} else {
let mut s = self.group_path.join("/");
s.push('/');
s.push_str(&self.title);
s
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct MergeSummary {
pub created: usize,
pub updated: usize,
pub relocated: usize,
pub deleted: usize,
}
#[derive(Debug, Clone)]
pub struct DbInfo {
pub version: String,
pub cipher: String,
pub compression: String,
pub kdf: String,
pub entries: usize,
pub groups: usize,
pub recycle_bin: bool,
}
#[derive(Debug, Clone)]
pub struct TotpCode {
pub code: String,
pub valid_for_secs: u64,
pub period_secs: u64,
}
pub struct Vault {
pub(crate) inner: VaultInner,
}
#[cfg(feature = "yubikey")]
pub use keepass::ChallengeResponseKey;
pub(crate) struct VaultInner {
pub(crate) path: PathBuf,
pub(crate) password: String,
pub(crate) keyfile: Option<Vec<u8>>,
#[cfg(feature = "yubikey")]
pub(crate) challenge_response: Option<ChallengeResponseKey>,
pub(crate) db: keepass::Database,
}
impl Drop for VaultInner {
fn drop(&mut self) {
self.password.zeroize();
if let Some(k) = self.keyfile.as_mut() {
k.zeroize();
}
}
}
fn touch_modified(entry: &mut keepass::db::EntryMut<'_>) {
entry.times.last_modification = Some(keepass::db::Times::now());
}
fn touch_location(entry: &mut keepass::db::EntryMut<'_>) {
entry.times.location_changed = Some(keepass::db::Times::now());
}
fn database_key(password: &str, keyfile: Option<&[u8]>) -> Result<keepass::DatabaseKey> {
let mut key = keepass::DatabaseKey::new().with_password(password);
if let Some(bytes) = keyfile {
key = key
.with_keyfile(&mut &bytes[..])
.map_err(|e| Error::Kdbx(format!("reading keyfile: {e}")))?;
}
Ok(key)
}
impl Vault {
pub fn create(path: &Path, password: &str) -> Result<Self> {
Self::create_with_key(path, password, None)
}
pub fn create_with_key(path: &Path, password: &str, keyfile: Option<&[u8]>) -> Result<Self> {
if path.exists() {
return Err(Error::AlreadyExists(path.to_path_buf()));
}
let db = keepass::Database::new();
let mut vault = Vault {
inner: VaultInner {
path: path.to_path_buf(),
password: password.to_string(),
keyfile: keyfile.map(<[u8]>::to_vec),
#[cfg(feature = "yubikey")]
challenge_response: None,
db,
},
};
vault.save()?;
Ok(vault)
}
#[cfg(feature = "yubikey")]
pub fn create_with_challenge_response(
path: &Path,
password: &str,
keyfile: Option<&[u8]>,
challenge_response: ChallengeResponseKey,
) -> Result<Self> {
if path.exists() {
return Err(Error::AlreadyExists(path.to_path_buf()));
}
let mut vault = Vault {
inner: VaultInner {
path: path.to_path_buf(),
password: password.to_string(),
keyfile: keyfile.map(<[u8]>::to_vec),
challenge_response: Some(challenge_response),
db: keepass::Database::new(),
},
};
vault.save()?;
Ok(vault)
}
#[cfg(feature = "yubikey")]
pub fn open_with_challenge_response(
path: &Path,
password: &str,
keyfile: Option<&[u8]>,
challenge_response: ChallengeResponseKey,
) -> Result<Self> {
if !path.exists() {
return Err(Error::NotFound(path.to_path_buf()));
}
let mut file = std::fs::File::open(path)?;
let key = database_key(password, keyfile)?
.with_challenge_response_key(challenge_response.clone());
let db = keepass::Database::open(&mut file, key).map_err(open_err_to_error)?;
Ok(Vault {
inner: VaultInner {
path: path.to_path_buf(),
password: password.to_string(),
keyfile: keyfile.map(<[u8]>::to_vec),
challenge_response: Some(challenge_response),
db,
},
})
}
pub fn open(path: &Path, password: &str) -> Result<Self> {
Self::open_with_key(path, password, None)
}
pub fn open_with_key(path: &Path, password: &str, keyfile: Option<&[u8]>) -> Result<Self> {
if !path.exists() {
return Err(Error::NotFound(path.to_path_buf()));
}
let mut file = std::fs::File::open(path)?;
let key = database_key(password, keyfile)?;
let db = keepass::Database::open(&mut file, key).map_err(open_err_to_error)?;
Ok(Vault {
inner: VaultInner {
path: path.to_path_buf(),
password: password.to_string(),
keyfile: keyfile.map(<[u8]>::to_vec),
#[cfg(feature = "yubikey")]
challenge_response: None,
db,
},
})
}
pub fn save(&mut self) -> Result<()> {
self.inner.db.config.version = DatabaseVersion::KDB4(1);
apply_default_meta_policy(&mut self.inner.db.meta);
if self.inner.db.root().name.is_empty() {
self.inner
.db
.root_mut()
.edit(|g| g.name = DEFAULT_GROUP.to_string());
}
let dir = self
.inner
.path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let file_name = self
.inner
.path
.file_name()
.ok_or_else(|| {
Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"vault path has no file name",
))
})?
.to_owned();
let mut tmp_name = std::ffi::OsString::from(&file_name);
tmp_name.push(format!(".tmp.{}", std::process::id()));
let tmp_path = dir.join(&tmp_name);
{
let mut tmp = std::fs::File::create(&tmp_path)?;
#[allow(unused_mut)]
let mut key = database_key(&self.inner.password, self.inner.keyfile.as_deref())?;
#[cfg(feature = "yubikey")]
if let Some(cr) = &self.inner.challenge_response {
key = key.with_challenge_response_key(cr.clone());
}
self.inner
.db
.save(&mut tmp, key)
.map_err(save_err_to_error)?;
tmp.sync_all()?;
}
if let Err(e) = std::fs::rename(&tmp_path, &self.inner.path) {
let _ = std::fs::remove_file(&tmp_path);
return Err(Error::Io(e));
}
Ok(())
}
pub fn path(&self) -> &Path {
&self.inner.path
}
pub fn add_entry(&mut self, title: &str) -> Result<EntryId> {
let (group_path, leaf) = parse_entry_path(title)?;
let mut current_id = self.inner.db.root().id();
for segment in &group_path {
let mut current = self
.inner
.db
.group_mut(current_id)
.expect("walked GroupId always resolves");
let existing = current.group_by_name_mut(segment).map(|g| g.id());
let next_id = match existing {
Some(id) => id,
None => current.add_group().edit(|g| g.name = segment.clone()).id(),
};
current_id = next_id;
}
let mut leaf_group = self
.inner
.db
.group_mut(current_id)
.expect("leaf GroupId always resolves");
let mut entry = leaf_group.add_entry();
entry.set_unprotected("Title", &leaf);
Ok(EntryId(entry.id().uuid().to_string()))
}
pub fn list_entries(&self) -> Vec<EntrySummary> {
self.inner
.db
.iter_all_entries()
.map(|e| summarise(&e))
.collect()
}
pub fn get_entry(&self, id: &EntryId) -> Option<EntrySummary> {
self.inner
.db
.iter_all_entries()
.find(|e| e.id().uuid().to_string() == id.0)
.map(|e| summarise(&e))
}
pub fn find_by_title(&self, title: &str) -> Option<EntryId> {
if title.contains('/') {
let (group_path, leaf) = parse_entry_path(title).ok()?;
let segs: Vec<&str> = group_path.iter().map(String::as_str).collect();
let root = self.inner.db.root();
let group = root.group_by_path(&segs)?;
return group
.entries()
.find(|e| e.get_title() == Some(leaf.as_str()))
.map(|e| EntryId(e.id().uuid().to_string()));
}
self.inner
.db
.iter_all_entries()
.find(|e| e.get_title() == Some(title))
.map(|e| EntryId(e.id().uuid().to_string()))
}
pub fn set_field(&mut self, id: &EntryId, field: &str, value: &str) -> Result<()> {
const PROTECTED_FIELDS: [&str; 2] = ["Password", "otp"];
let entry_id = self.lookup_entry_id(id)?;
let mut entry = self
.inner
.db
.entry_mut(entry_id)
.ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
if PROTECTED_FIELDS.contains(&field) {
entry.set_protected(field, value);
} else {
entry.set_unprotected(field, value);
}
touch_modified(&mut entry);
Ok(())
}
pub fn attach_binary(&mut self, id: &EntryId, name: &str, bytes: &[u8]) -> Result<()> {
let entry_id = self.lookup_entry_id(id)?;
let mut entry = self
.inner
.db
.entry_mut(entry_id)
.ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
entry.remove_attachment_by_name(name);
entry.add_attachment(name, Value::Unprotected(bytes.to_vec()));
touch_modified(&mut entry);
Ok(())
}
pub fn read_binary(&self, id: &EntryId, name: &str) -> Result<Option<Vec<u8>>> {
let entry_id = self.lookup_entry_id(id)?;
let entry = self
.inner
.db
.entry(entry_id)
.ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
Ok(entry
.attachment_by_name(name)
.map(|att| att.data.get().clone()))
}
pub fn remove_binary(&mut self, id: &EntryId, name: &str) -> Result<()> {
let entry_id = self.lookup_entry_id(id)?;
let mut entry = self
.inner
.db
.entry_mut(entry_id)
.ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
entry.remove_attachment_by_name(name);
touch_modified(&mut entry);
Ok(())
}
pub fn delete_entry(&mut self, id: &EntryId) -> Result<()> {
let entry_id = self.lookup_entry_id(id)?;
let entry = self
.inner
.db
.entry_mut(entry_id)
.ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
entry.remove();
Ok(())
}
pub fn get_field(&self, id: &EntryId, field: &str) -> Result<Option<String>> {
let entry_id = self.lookup_entry_id(id)?;
let entry = self
.inner
.db
.entry(entry_id)
.ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
Ok(entry.get(field).map(|s| s.to_string()))
}
pub fn fields_with_prefix(&self, id: &EntryId, prefix: &str) -> Result<Vec<String>> {
let entry_id = self.lookup_entry_id(id)?;
let entry = self
.inner
.db
.entry(entry_id)
.ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
Ok(entry
.fields
.keys()
.filter(|k| k.starts_with(prefix))
.cloned()
.collect())
}
fn lookup_entry_id(&self, id: &EntryId) -> Result<keepass::db::EntryId> {
self.inner
.db
.iter_all_entries()
.find(|e| e.id().uuid().to_string() == id.0)
.map(|e| e.id())
.ok_or_else(|| Error::EntryNotFound(id.0.clone()))
}
pub fn remove_field(&mut self, id: &EntryId, field: &str) -> Result<()> {
let entry_id = self.lookup_entry_id(id)?;
let mut entry = self
.inner
.db
.entry_mut(entry_id)
.ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
entry.fields.remove(field);
Ok(())
}
fn resolve_group(&self, path: &str) -> Result<keepass::db::GroupId> {
let segs = parse_group_path(path)?;
if segs.is_empty() {
return Ok(self.inner.db.root().id());
}
let refs: Vec<&str> = segs.iter().map(String::as_str).collect();
self.inner
.db
.root()
.group_by_path(&refs)
.map(|g| g.id())
.ok_or_else(|| Error::GroupNotFound(path.to_string()))
}
pub fn move_entry(&mut self, id: &EntryId, group_path: &str) -> Result<()> {
let target = self.resolve_group(group_path)?;
let entry_id = self.lookup_entry_id(id)?;
let mut entry = self
.inner
.db
.entry_mut(entry_id)
.ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
entry
.move_to(target)
.map_err(|_| Error::GroupNotFound(group_path.to_string()))?;
touch_location(&mut entry);
Ok(())
}
pub fn add_group(&mut self, path: &str) -> Result<()> {
let segs = parse_group_path(path)?;
if segs.is_empty() {
return Err(Error::GroupExists(DEFAULT_GROUP.to_string()));
}
let mut current_id = self.inner.db.root().id();
for (i, segment) in segs.iter().enumerate() {
let is_leaf = i == segs.len() - 1;
let mut current = self
.inner
.db
.group_mut(current_id)
.expect("walked GroupId always resolves");
let existing = current.group_by_name_mut(segment).map(|g| g.id());
current_id = match existing {
Some(_) if is_leaf => return Err(Error::GroupExists(path.to_string())),
Some(id) => id,
None => current.add_group().edit(|g| g.name = segment.clone()).id(),
};
}
Ok(())
}
fn ensure_recycle_bin(&mut self) -> keepass::db::GroupId {
if let Some(bin) = self.inner.db.recycle_bin() {
return bin.id();
}
let id = self
.inner
.db
.root_mut()
.add_group()
.edit(|g| g.name = RECYCLE_BIN_GROUP.to_string())
.id();
self.inner.db.meta.recyclebin_uuid = Some(id.uuid());
self.inner.db.meta.recyclebin_enabled = Some(true);
self.inner.db.meta.recyclebin_changed = Some(keepass::db::Times::now());
id
}
fn is_in_recycle_bin(&self, group_id: keepass::db::GroupId) -> bool {
let Some(bin) = self.inner.db.recycle_bin() else {
return false;
};
let bin_id = bin.id();
let mut cur = Some(group_id);
while let Some(gid) = cur {
if gid == bin_id {
return true;
}
cur = self
.inner
.db
.group(gid)
.and_then(|g| g.parent().map(|p| p.id()));
}
false
}
pub fn recycle_entry(&mut self, id: &EntryId, permanent: bool) -> Result<bool> {
let entry_id = self.lookup_entry_id(id)?;
let parent_id = {
let entry = self
.inner
.db
.entry(entry_id)
.ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
entry.parent().id()
};
let bin_enabled = self.inner.db.meta.recyclebin_enabled.unwrap_or(true);
if permanent || !bin_enabled || self.is_in_recycle_bin(parent_id) {
self.delete_entry(id)?;
return Ok(false);
}
let bin = self.ensure_recycle_bin();
let mut entry = self
.inner
.db
.entry_mut(entry_id)
.ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
entry
.move_to(bin)
.expect("recycle bin group id always resolves");
touch_location(&mut entry);
Ok(true)
}
pub fn remove_group(&mut self, path: &str, permanent: bool, recursive: bool) -> Result<bool> {
let gid = self.resolve_group(path)?;
if gid == self.inner.db.root().id() {
return Err(Error::InvalidPath("cannot remove the root group".into()));
}
let (empty, in_bin) = {
let g = self.inner.db.group(gid).expect("resolved id");
let empty = g.entries().next().is_none() && g.groups().next().is_none();
(empty, self.is_in_recycle_bin(gid))
};
let bin_enabled = self.inner.db.meta.recyclebin_enabled.unwrap_or(true);
if permanent || !bin_enabled || in_bin {
if !empty && !recursive {
return Err(Error::GroupNotEmpty(path.to_string()));
}
self.inner.db.group_mut(gid).expect("resolved id").remove();
return Ok(false);
}
let bin = self.ensure_recycle_bin();
self.inner
.db
.group_mut(gid)
.expect("resolved id")
.move_to(bin)
.map_err(|e| Error::Kdbx(format!("moving group to recycle bin: {e:?}")))?;
Ok(true)
}
pub fn search_entries(&self, term: &str) -> Vec<EntrySummary> {
let needle = term.to_lowercase();
self.inner
.db
.iter_all_entries()
.filter(|e| {
let hay = |s: Option<&str>| s.is_some_and(|v| v.to_lowercase().contains(&needle));
hay(e.get_title())
|| hay(e.get_username())
|| hay(e.get_url())
|| hay(e.get("Notes"))
|| build_group_path(e)
.join("/")
.to_lowercase()
.contains(&needle)
})
.map(|e| summarise(&e))
.collect()
}
pub fn resolve_ref(&self, reference: &str) -> Result<String> {
let body = reference
.strip_prefix("trove://")
.ok_or_else(|| Error::InvalidPath(format!("not a trove:// reference: {reference}")))?;
if body.is_empty() {
return Err(Error::InvalidPath("empty trove:// reference".into()));
}
if let Some(id) = self.find_by_title(body) {
return self
.get_field(&id, "Password")?
.ok_or_else(|| Error::InvalidPath(format!("{reference}: entry has no Password")));
}
let (entry_path, field) = body
.rsplit_once('/')
.ok_or_else(|| Error::EntryNotFound(body.to_string()))?;
let id = self
.find_by_title(entry_path)
.ok_or_else(|| Error::EntryNotFound(entry_path.to_string()))?;
self.get_field(&id, field)?
.ok_or_else(|| Error::InvalidPath(format!("{reference}: entry has no field '{field}'")))
}
pub fn totp_now(&self, id: &EntryId) -> Result<TotpCode> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| Error::Totp(e.to_string()))?
.as_secs();
self.totp_at(id, now)
}
pub fn totp_at(&self, id: &EntryId, unix_secs: u64) -> Result<TotpCode> {
let entry_id = self.lookup_entry_id(id)?;
let entry = self
.inner
.db
.entry(entry_id)
.ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
if entry.get("otp").is_none() {
return Err(Error::NoTotp(id.0.clone()));
}
let totp = entry.get_otp().map_err(|e| Error::Totp(e.to_string()))?;
let code = totp.value_at(unix_secs);
Ok(TotpCode {
code: code.code,
valid_for_secs: code.valid_for.as_secs(),
period_secs: code.period.as_secs(),
})
}
pub fn set_totp_uri(&mut self, id: &EntryId, uri: &str) -> Result<()> {
uri.parse::<keepass::db::TOTP>()
.map_err(|e| Error::Totp(format!("invalid otpauth URI: {e}")))?;
self.set_field(id, "otp", uri)
}
pub fn merge_from(
&mut self,
source: &Path,
source_password: &str,
source_keyfile: Option<&[u8]>,
) -> Result<MergeSummary> {
if !source.exists() {
return Err(Error::NotFound(source.to_path_buf()));
}
let mut file = std::fs::File::open(source)?;
let key = database_key(source_password, source_keyfile)?;
let other = keepass::Database::open(&mut file, key).map_err(open_err_to_error)?;
if other.root().id() != self.inner.db.root().id() {
return Err(Error::Kdbx(
"source is not a copy of this vault (different root UUID); merge \
reconciles diverged copies — to combine unrelated vaults, import \
entries explicitly"
.to_string(),
));
}
let log = self
.inner
.db
.merge(&other)
.map_err(|e| Error::Kdbx(format!("merge: {e}")))?;
let mut summary = MergeSummary::default();
for event in &log.events {
use keepass::db::merge::MergeEventType;
match event.event_type {
MergeEventType::Created => summary.created += 1,
MergeEventType::Updated => summary.updated += 1,
MergeEventType::LocationUpdated => summary.relocated += 1,
MergeEventType::Deleted => summary.deleted += 1,
_ => summary.updated += 1,
}
}
self.save()?;
Ok(summary)
}
pub fn current_password(&self) -> &str {
&self.inner.password
}
pub fn current_keyfile(&self) -> Option<&[u8]> {
self.inner.keyfile.as_deref()
}
pub fn rekey(&mut self, new_password: &str, new_keyfile: Option<&[u8]>) -> Result<()> {
let old_password = std::mem::replace(&mut self.inner.password, new_password.to_string());
let old_keyfile =
std::mem::replace(&mut self.inner.keyfile, new_keyfile.map(<[u8]>::to_vec));
if let Err(e) = self.save() {
self.inner.password = old_password;
self.inner.keyfile = old_keyfile;
return Err(e);
}
let mut old_password = old_password;
old_password.zeroize();
if let Some(mut k) = old_keyfile {
k.zeroize();
}
Ok(())
}
pub fn set_argon2_params(
&mut self,
memory_kib: Option<u64>,
iterations: Option<u64>,
parallelism: Option<u32>,
) -> Result<()> {
match &mut self.inner.db.config.kdf_config {
keepass::config::KdfConfig::Argon2 {
iterations: it,
memory,
parallelism: par,
..
} => {
if let Some(m) = memory_kib {
*memory = m;
}
if let Some(i) = iterations {
*it = i;
}
if let Some(p) = parallelism {
*par = p;
}
self.save()
}
other => Err(Error::Kdbx(format!(
"vault uses a non-Argon2 KDF ({other:?}); retune it in KeePassXC"
))),
}
}
pub fn db_info(&self) -> DbInfo {
let cfg = &self.inner.db.config;
let entries = self.inner.db.iter_all_entries().count();
let mut groups = 0usize;
let mut stack = vec![self.inner.db.root().id()];
while let Some(gid) = stack.pop() {
if let Some(g) = self.inner.db.group(gid) {
for child in g.groups() {
groups += 1;
stack.push(child.id());
}
}
}
DbInfo {
version: format!("{}", cfg.version),
cipher: format!("{:?}", cfg.outer_cipher_config),
compression: format!("{:?}", cfg.compression_config),
kdf: format!("{:?}", cfg.kdf_config),
entries,
groups,
recycle_bin: self.inner.db.recycle_bin().is_some(),
}
}
pub fn custom_field_names(&self, id: &EntryId) -> Result<Vec<String>> {
const STANDARD: [&str; 5] = ["Title", "UserName", "Password", "URL", "Notes"];
let entry_id = self.lookup_entry_id(id)?;
let entry = self
.inner
.db
.entry(entry_id)
.ok_or_else(|| Error::EntryNotFound(id.0.clone()))?;
let mut names: Vec<String> = entry
.fields
.keys()
.filter(|k| !STANDARD.contains(&k.as_str()))
.cloned()
.collect();
names.sort();
Ok(names)
}
}
fn summarise(e: &keepass::db::EntryRef<'_>) -> EntrySummary {
let attachment_names: Vec<String> = e
.attachments_named()
.map(|(name, _)| name.to_string())
.collect();
EntrySummary {
id: EntryId(e.id().uuid().to_string()),
title: e.get_title().unwrap_or("").to_string(),
username: e.get_username().map(str::to_owned),
url: e.get_url().map(str::to_owned),
attachment_names,
group_path: build_group_path(e),
created: e.times.creation.map(|dt| dt.and_utc().to_rfc3339()),
modified: e
.times
.last_modification
.map(|dt| dt.and_utc().to_rfc3339()),
}
}
fn build_group_path(e: &keepass::db::EntryRef<'_>) -> Vec<String> {
let db = e.database();
let mut rev: Vec<String> = Vec::new();
let mut cur_id = e.parent().id();
while let Some(g) = db.group(cur_id) {
match g.parent() {
Some(parent) => {
rev.push(g.name.clone());
cur_id = parent.id();
}
None => break,
}
}
rev.reverse();
rev
}
fn parse_entry_path(s: &str) -> Result<(Vec<String>, String)> {
if s.is_empty() {
return Err(Error::InvalidPath("title must not be empty".into()));
}
let parts: Vec<&str> = s.split('/').collect();
if parts.iter().any(|p| p.is_empty()) {
return Err(Error::InvalidPath(format!(
"path '{s}' has empty segment; leading/trailing/double '/' is not allowed"
)));
}
let mut iter = parts.into_iter();
let last = iter
.next_back()
.expect("non-empty split always yields at least one element");
let mut groups: Vec<String> = iter.map(String::from).collect();
if groups
.first()
.is_some_and(|g| g.eq_ignore_ascii_case(DEFAULT_GROUP))
{
groups.remove(0);
}
Ok((groups, last.to_string()))
}
fn parse_group_path(s: &str) -> Result<Vec<String>> {
if s.is_empty() || s.eq_ignore_ascii_case(DEFAULT_GROUP) {
return Ok(Vec::new());
}
let parts: Vec<&str> = s.split('/').collect();
if parts.iter().any(|p| p.is_empty()) {
return Err(Error::InvalidPath(format!(
"path '{s}' has empty segment; leading/trailing/double '/' is not allowed"
)));
}
let mut segs: Vec<String> = parts.into_iter().map(String::from).collect();
if segs
.first()
.is_some_and(|g| g.eq_ignore_ascii_case(DEFAULT_GROUP))
{
segs.remove(0);
}
Ok(segs)
}
fn open_err_to_error(e: keepass::error::DatabaseOpenError) -> Error {
use keepass::error::{DatabaseKeyError, DatabaseOpenError};
match e {
DatabaseOpenError::Io(io) => Error::Io(io),
DatabaseOpenError::Key(DatabaseKeyError::IncorrectKey) => Error::BadPassword,
DatabaseOpenError::Key(other) => Error::Kdbx(other.to_string()),
DatabaseOpenError::UnsupportedVersion => {
Error::Kdbx("unsupported kdbx version".to_string())
}
other => {
let msg = other.to_string();
if msg.to_lowercase().contains("incorrect")
|| msg.to_lowercase().contains("header hash")
{
Error::BadPassword
} else {
Error::Kdbx(msg)
}
}
}
}
fn save_err_to_error(e: keepass::error::DatabaseSaveError) -> Error {
use keepass::error::DatabaseSaveError;
match e {
DatabaseSaveError::Io(io) => Error::Io(io),
other => Error::Kdbx(other.to_string()),
}
}
fn apply_default_meta_policy(meta: &mut keepass::db::Meta) {
meta.maintenance_history_days.get_or_insert(365);
meta.master_key_change_rec.get_or_insert(-1);
meta.master_key_change_force.get_or_insert(-1);
meta.history_max_items.get_or_insert(10);
meta.history_max_size.get_or_insert(6 * 1024 * 1024);
meta.recyclebin_enabled.get_or_insert(true);
}