use crate::error::{FnoxError, Result};
use crate::providers::ProviderCapability;
use async_trait::async_trait;
use keepass::DatabaseKey;
use keepass::db::{Database, EntryId, GroupId, GroupRef};
use std::fs::File;
use std::io::{BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
use tempfile::NamedTempFile;
pub struct KeePassProvider {
database_path: PathBuf,
keyfile_path: Option<PathBuf>,
password: Option<String>,
}
impl KeePassProvider {
pub fn new(
database: String,
keyfile: Option<String>,
password: Option<String>,
) -> Result<Self> {
Ok(Self {
database_path: PathBuf::from(shellexpand::tilde(&database).to_string()),
keyfile_path: keyfile.map(|k| PathBuf::from(shellexpand::tilde(&k).to_string())),
password,
})
}
fn get_password(&self) -> Result<String> {
if let Some(password) = keepass_password() {
return Ok(password);
}
if let Some(password) = &self.password {
return Ok(password.clone());
}
Err(FnoxError::ProviderAuthFailed {
provider: "KeePass".to_string(),
details: "Database password not set".to_string(),
hint: "Set FNOX_KEEPASS_PASSWORD or KEEPASS_PASSWORD environment variable, or configure password in provider config".to_string(),
url: "https://fnox.jdx.dev/providers/keepass".to_string(),
})
}
fn build_key(&self) -> Result<DatabaseKey> {
let password = self.get_password()?;
let mut key = DatabaseKey::new();
key = key.with_password(&password);
if let Some(keyfile_path) = &self.keyfile_path {
let mut keyfile =
File::open(keyfile_path).map_err(|e| FnoxError::ProviderApiError {
provider: "KeePass".to_string(),
details: format!("Failed to open keyfile '{}': {}", keyfile_path.display(), e),
hint: "Check that the keyfile exists and is readable".to_string(),
url: "https://fnox.jdx.dev/providers/keepass".to_string(),
})?;
key = key
.with_keyfile(&mut keyfile)
.map_err(|e| FnoxError::ProviderApiError {
provider: "KeePass".to_string(),
details: format!("Failed to read keyfile: {}", e),
hint: "Check that the keyfile is valid".to_string(),
url: "https://fnox.jdx.dev/providers/keepass".to_string(),
})?;
}
Ok(key)
}
fn open_database(&self) -> Result<Database> {
let file = File::open(&self.database_path).map_err(|e| FnoxError::ProviderApiError {
provider: "KeePass".to_string(),
details: format!(
"Failed to open database '{}': {}",
self.database_path.display(),
e
),
hint: "Check that the database file exists and is readable".to_string(),
url: "https://fnox.jdx.dev/providers/keepass".to_string(),
})?;
let mut reader = BufReader::new(file);
let key = self.build_key()?;
Database::open(&mut reader, key).map_err(|e| FnoxError::ProviderAuthFailed {
provider: "KeePass".to_string(),
details: format!("Failed to decrypt database: {}", e),
hint: "Check that the password and/or keyfile are correct".to_string(),
url: "https://fnox.jdx.dev/providers/keepass".to_string(),
})
}
fn save_database(&self, db: &Database) -> Result<()> {
let parent_dir = self.database_path.parent().unwrap_or(Path::new("."));
if !parent_dir.exists() {
std::fs::create_dir_all(parent_dir).map_err(|e| FnoxError::ProviderApiError {
provider: "KeePass".to_string(),
details: format!(
"Failed to create directory '{}': {}",
parent_dir.display(),
e
),
hint: "Check directory permissions".to_string(),
url: "https://fnox.jdx.dev/providers/keepass".to_string(),
})?;
}
let temp_file =
NamedTempFile::new_in(parent_dir).map_err(|e| FnoxError::ProviderApiError {
provider: "KeePass".to_string(),
details: format!(
"Failed to create temporary file in '{}': {}",
parent_dir.display(),
e
),
hint: "Check directory permissions".to_string(),
url: "https://fnox.jdx.dev/providers/keepass".to_string(),
})?;
let mut writer = BufWriter::new(temp_file);
let key = self.build_key()?;
db.save(&mut writer, key)
.map_err(|e| FnoxError::ProviderApiError {
provider: "KeePass".to_string(),
details: format!("Failed to save database: {}", e),
hint: "Check that you have write permissions".to_string(),
url: "https://fnox.jdx.dev/providers/keepass".to_string(),
})?;
writer.flush().map_err(|e| FnoxError::ProviderApiError {
provider: "KeePass".to_string(),
details: format!("Failed to flush database: {}", e),
hint: "Check disk space and permissions".to_string(),
url: "https://fnox.jdx.dev/providers/keepass".to_string(),
})?;
writer
.get_ref()
.as_file()
.sync_all()
.map_err(|e| FnoxError::ProviderApiError {
provider: "KeePass".to_string(),
details: format!("Failed to sync database to disk: {}", e),
hint: "Check disk space and permissions".to_string(),
url: "https://fnox.jdx.dev/providers/keepass".to_string(),
})?;
let temp_file = writer
.into_inner()
.map_err(|e| FnoxError::ProviderApiError {
provider: "KeePass".to_string(),
details: format!("Failed to finalize temp file: {}", e),
hint: "Check disk space and permissions".to_string(),
url: "https://fnox.jdx.dev/providers/keepass".to_string(),
})?;
temp_file
.persist(&self.database_path)
.map_err(|e| FnoxError::ProviderApiError {
provider: "KeePass".to_string(),
details: format!(
"Failed to persist database to '{}': {}",
self.database_path.display(),
e
),
hint: "Check file permissions".to_string(),
url: "https://fnox.jdx.dev/providers/keepass".to_string(),
})?;
Ok(())
}
fn parse_reference(value: &str) -> (Vec<&str>, &str) {
let parts: Vec<&str> = value.split('/').collect();
let known_fields = ["password", "username", "url", "notes", "title"];
if parts.len() == 1 {
(parts, "Password")
} else {
let last = parts.last().unwrap().to_lowercase();
if known_fields.contains(&last.as_str()) {
let field = match last.as_str() {
"password" => "Password",
"username" => "UserName",
"url" => "URL",
"notes" => "Notes",
"title" => "Title",
_ => "Password",
};
(parts[..parts.len() - 1].to_vec(), field)
} else {
(parts, "Password")
}
}
}
fn find_entry_id(db: &Database, path: &[&str]) -> Option<EntryId> {
Self::walk_group(db.root(), path)
}
fn walk_group(group: GroupRef<'_>, path: &[&str]) -> Option<EntryId> {
if path.is_empty() {
return None;
}
if path.len() == 1 {
Self::find_entry_id_by_title(group, path[0])
} else {
let subgroup = group.groups().find(|g| g.name == path[0])?;
Self::walk_group(subgroup, &path[1..])
}
}
fn find_entry_id_by_title(group: GroupRef<'_>, title: &str) -> Option<EntryId> {
for entry in group.entries() {
if entry.get_title() == Some(title) {
return Some(entry.id());
}
}
for subgroup in group.groups() {
if let Some(id) = Self::find_entry_id_by_title(subgroup, title) {
return Some(id);
}
}
None
}
fn navigate_or_create_group_path(db: &mut Database, group_path: &[&str]) -> GroupId {
let mut current_id = db.root().id();
for name in group_path {
let next_id = db
.group(current_id)
.expect("current group exists")
.groups()
.find(|g| &g.name == name)
.map(|g| g.id());
current_id = match next_id {
Some(id) => id,
None => {
let mut group_mut = db.group_mut(current_id).expect("current group exists");
let mut new_group = group_mut.add_group();
new_group.name = (*name).to_string();
new_group.as_ref().id()
}
};
}
current_id
}
fn find_or_create_entry(
db: &mut Database,
path: &[&str],
value: &str,
field: &str,
) -> Result<String> {
if path.is_empty() {
return Err(FnoxError::ProviderInvalidResponse {
provider: "KeePass".to_string(),
details: "Empty path for entry".to_string(),
hint: "Provide an entry name or path".to_string(),
url: "https://fnox.jdx.dev/providers/keepass".to_string(),
});
}
if field == "Title" {
return Err(FnoxError::ProviderInvalidResponse {
provider: "KeePass".to_string(),
details: "Cannot write to 'Title' field".to_string(),
hint: "The 'Title' field is used for entry identification. Use a different field name.".to_string(),
url: "https://fnox.jdx.dev/providers/keepass".to_string(),
});
}
let entry_name = path[path.len() - 1];
let group_path = &path[..path.len() - 1];
let target_group_id = Self::navigate_or_create_group_path(db, group_path);
let existing_id = Self::find_entry_id_by_title(
db.group(target_group_id).expect("target group exists"),
entry_name,
);
if let Some(eid) = existing_id {
let mut entry_mut = db.entry_mut(eid).expect("entry exists");
if field == "Password" {
entry_mut.set_protected(field, value);
} else {
entry_mut.set_unprotected(field, value);
}
} else {
let mut group_mut = db.group_mut(target_group_id).expect("target group exists");
let mut entry_mut = group_mut.add_entry();
entry_mut.set_unprotected("Title", entry_name);
if field == "Password" {
entry_mut.set_protected(field, value);
} else {
entry_mut.set_unprotected(field, value);
}
}
Ok(entry_name.to_string())
}
}
#[async_trait]
impl crate::providers::Provider for KeePassProvider {
fn capabilities(&self) -> Vec<ProviderCapability> {
vec![ProviderCapability::RemoteStorage]
}
async fn get_secret(&self, value: &str) -> Result<String> {
let (entry_path, field) = Self::parse_reference(value);
tracing::debug!(
"Getting KeePass secret '{}' field '{}' from '{}'",
entry_path.join("/"),
field,
self.database_path.display()
);
let db = self.open_database()?;
let entry_id = Self::find_entry_id(&db, &entry_path).ok_or_else(|| {
FnoxError::ProviderSecretNotFound {
provider: "KeePass".to_string(),
secret: entry_path.join("/"),
hint: "Check that the entry exists in the database".to_string(),
url: "https://fnox.jdx.dev/providers/keepass".to_string(),
}
})?;
let entry = db.entry(entry_id).expect("entry exists");
entry
.get(field)
.map(|s| s.to_string())
.ok_or_else(|| FnoxError::ProviderInvalidResponse {
provider: "KeePass".to_string(),
details: format!(
"Field '{}' not found in entry '{}'",
field,
entry_path.join("/")
),
hint: "Available fields: password, username, url, notes, title".to_string(),
url: "https://fnox.jdx.dev/providers/keepass".to_string(),
})
}
async fn put_secret(&self, key: &str, value: &str) -> Result<String> {
let (entry_path, field) = Self::parse_reference(key);
tracing::debug!(
"Storing KeePass secret '{}' field '{}' in '{}'",
entry_path.join("/"),
field,
self.database_path.display()
);
let mut db = if self.database_path.exists() {
self.open_database()?
} else {
tracing::info!(
"Creating new KeePass database at '{}'",
self.database_path.display()
);
Database::new()
};
let entry_name = Self::find_or_create_entry(&mut db, &entry_path, value, field)?;
self.save_database(&db)?;
tracing::debug!(
"Successfully stored secret in KeePass entry '{}'",
entry_name
);
Ok(key.to_string())
}
async fn test_connection(&self) -> Result<()> {
tracing::debug!(
"Testing connection to KeePass database '{}'",
self.database_path.display()
);
self.get_password()?;
if let Some(keyfile_path) = &self.keyfile_path
&& !keyfile_path.exists()
{
return Err(FnoxError::ProviderApiError {
provider: "KeePass".to_string(),
details: format!("Keyfile '{}' does not exist", keyfile_path.display()),
hint: "Check the keyfile path in your provider configuration".to_string(),
url: "https://fnox.jdx.dev/providers/keepass".to_string(),
});
}
if self.database_path.exists() {
self.open_database()?;
tracing::debug!("KeePass database connection test successful");
} else {
tracing::debug!(
"KeePass database '{}' does not exist yet (will be created on first write)",
self.database_path.display()
);
}
Ok(())
}
}
pub fn env_dependencies() -> &'static [&'static str] {
&["KEEPASS_PASSWORD", "FNOX_KEEPASS_PASSWORD"]
}
fn keepass_password() -> Option<String> {
std::env::var("FNOX_KEEPASS_PASSWORD")
.or_else(|_| std::env::var("KEEPASS_PASSWORD"))
.ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_reference_simple() {
let (path, field) = KeePassProvider::parse_reference("my-entry");
assert_eq!(path, vec!["my-entry"]);
assert_eq!(field, "Password");
}
#[test]
fn test_parse_reference_with_field() {
let (path, field) = KeePassProvider::parse_reference("my-entry/username");
assert_eq!(path, vec!["my-entry"]);
assert_eq!(field, "UserName");
let (path, field) = KeePassProvider::parse_reference("my-entry/password");
assert_eq!(path, vec!["my-entry"]);
assert_eq!(field, "Password");
let (path, field) = KeePassProvider::parse_reference("my-entry/url");
assert_eq!(path, vec!["my-entry"]);
assert_eq!(field, "URL");
let (path, field) = KeePassProvider::parse_reference("my-entry/notes");
assert_eq!(path, vec!["my-entry"]);
assert_eq!(field, "Notes");
}
#[test]
fn test_parse_reference_with_group() {
let (path, field) = KeePassProvider::parse_reference("group/my-entry");
assert_eq!(path, vec!["group", "my-entry"]);
assert_eq!(field, "Password");
let (path, field) = KeePassProvider::parse_reference("group/subgroup/my-entry");
assert_eq!(path, vec!["group", "subgroup", "my-entry"]);
assert_eq!(field, "Password");
}
#[test]
fn test_parse_reference_with_group_and_field() {
let (path, field) = KeePassProvider::parse_reference("group/my-entry/username");
assert_eq!(path, vec!["group", "my-entry"]);
assert_eq!(field, "UserName");
let (path, field) = KeePassProvider::parse_reference("group/subgroup/my-entry/password");
assert_eq!(path, vec!["group", "subgroup", "my-entry"]);
assert_eq!(field, "Password");
}
}