use crate::error::{FnoxError, Result};
use crate::providers::ProviderCapability;
use async_trait::async_trait;
use keepass::DatabaseKey;
use keepass::db::{Database, Entry, Group, Value};
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<'a>(group: &'a Group, path: &[&str]) -> Option<&'a Entry> {
if path.is_empty() {
return None;
}
if path.len() == 1 {
let entry_name = path[0];
Self::find_entry_by_title(group, entry_name)
} else {
let group_name = path[0];
for subgroup in &group.groups {
if subgroup.name == group_name {
return Self::find_entry(subgroup, &path[1..]);
}
}
None
}
}
fn find_entry_by_title<'a>(group: &'a Group, title: &str) -> Option<&'a Entry> {
for entry in &group.entries {
if entry.get_title() == Some(title) {
return Some(entry);
}
}
for subgroup in &group.groups {
if let Some(entry) = Self::find_entry_by_title(subgroup, title) {
return Some(entry);
}
}
None
}
fn find_entry_by_title_mut<'a>(group: &'a mut Group, title: &str) -> Option<&'a mut Entry> {
let found_entry_idx = group
.entries
.iter()
.position(|e| e.get_title() == Some(title));
if let Some(idx) = found_entry_idx {
return Some(&mut group.entries[idx]);
}
let found_in_subgroup_idx = group
.groups
.iter()
.position(|sg| Self::find_entry_by_title(sg, title).is_some());
if let Some(idx) = found_in_subgroup_idx {
return Self::find_entry_by_title_mut(&mut group.groups[idx], title);
}
None
}
fn find_or_create_entry(
group: &mut Group,
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 field_value = if field == "Password" {
Value::protected(value.to_string())
} else {
Value::Unprotected(value.to_string())
};
if path.len() == 1 {
let entry_name = path[0];
if let Some(entry) = Self::find_entry_by_title_mut(group, entry_name) {
entry.fields.insert(field.to_string(), field_value);
return Ok(entry_name.to_string());
}
let mut entry = Entry::new();
entry.fields.insert(
"Title".to_string(),
Value::Unprotected(entry_name.to_string()),
);
entry.fields.insert(field.to_string(), field_value);
group.entries.push(entry);
Ok(entry_name.to_string())
} else {
let group_name = path[0];
for subgroup in &mut group.groups {
if subgroup.name == group_name {
return Self::find_or_create_entry(subgroup, &path[1..], value, field);
}
}
let mut new_group = Group::new(group_name);
let result = Self::find_or_create_entry(&mut new_group, &path[1..], value, field)?;
group.groups.push(new_group);
Ok(result)
}
}
}
#[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 = Self::find_entry(&db.root, &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(),
}
})?;
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(keepass::config::DatabaseConfig::default())
};
let entry_name = Self::find_or_create_entry(&mut db.root, &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");
}
}