#![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";
#[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>,
}
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
}
}
}
pub struct Vault {
pub(crate) inner: VaultInner,
}
pub(crate) struct VaultInner {
pub(crate) path: PathBuf,
pub(crate) password: String,
pub(crate) db: keepass::Database,
}
impl Drop for VaultInner {
fn drop(&mut self) {
self.password.zeroize();
}
}
impl Vault {
pub fn create(path: &Path, password: &str) -> 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(),
db,
},
};
vault.save()?;
Ok(vault)
}
pub fn open(path: &Path, password: &str) -> Result<Self> {
if !path.exists() {
return Err(Error::NotFound(path.to_path_buf()));
}
let mut file = std::fs::File::open(path)?;
let key = keepass::DatabaseKey::new().with_password(password);
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(),
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)?;
let key = keepass::DatabaseKey::new().with_password(&self.inner.password);
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<()> {
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 field == "Password" {
entry.set_protected(field, value);
} else {
entry.set_unprotected(field, value);
}
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()));
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);
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()))
}
}
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),
}
}
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 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);
}