Add additive KDBX importer

This commit is contained in:
2026-08-11 09:04:48 +02:00
parent f84f7247d2
commit bf75465642
23 changed files with 1959 additions and 82 deletions

View File

@@ -60,6 +60,7 @@ pub enum UiAction {
GenerateOtp,
CopyOtp,
ImportOtp,
ImportKdbx,
ShowOtpUri,
CopyOtpUri,
ShowOtpQr,
@@ -106,6 +107,7 @@ impl UiAction {
Self::GenerateOtp => "generate-otp",
Self::CopyOtp => "copy-otp",
Self::ImportOtp => "import-otp",
Self::ImportKdbx => "import-kdbx",
Self::ShowOtpUri => "show-otp-uri",
Self::CopyOtpUri => "copy-otp-uri",
Self::ShowOtpQr => "show-otp-qr",
@@ -302,6 +304,12 @@ pub const ACTIONS: &[ActionSpec] = &[
spec(UiAction::ShowOtpQr, MenuGroup::Entry, "Show OTP QR", None),
spec(UiAction::RemoveOtp, MenuGroup::Entry, "Remove OTP", None),
spec(UiAction::GitStatus, MenuGroup::Tools, "Git Status…", None),
spec(
UiAction::ImportKdbx,
MenuGroup::Tools,
"Import KeePass Database…",
None,
),
spec(UiAction::GitPull, MenuGroup::Tools, "Pull", None),
spec(UiAction::GitPush, MenuGroup::Tools, "Push", None),
spec(UiAction::GitSync, MenuGroup::Tools, "Synchronize", None),
@@ -454,7 +462,7 @@ pub fn enabled(action: UiAction, context: ActionContext) -> bool {
&& !context.switching_vault
&& !context.modal_open
}
UiAction::ImportOtp => {
UiAction::ImportOtp | UiAction::ImportKdbx => {
context.storage_ready
&& context.unlocked
&& !context.dirty
@@ -623,12 +631,20 @@ pub fn disabled_reason(action: UiAction, context: ActionContext) -> Option<&'sta
| UiAction::CopyOtpUri
| UiAction::ShowOtpQr
| UiAction::RemoveOtp => "Close the current screen first",
UiAction::ImportOtp if !context.storage_ready => "Shared configuration is unavailable",
UiAction::ImportOtp if !context.unlocked => "Unlock the password store first",
UiAction::ImportOtp if context.dirty => "Save or discard the current draft first",
UiAction::ImportOtp if context.saving => "Wait for the active save",
UiAction::ImportOtp if context.switching_vault => "Wait for vault validation",
UiAction::ImportOtp => "Close the current screen first",
UiAction::ImportOtp | UiAction::ImportKdbx if !context.storage_ready => {
"Shared configuration is unavailable"
}
UiAction::ImportOtp | UiAction::ImportKdbx if !context.unlocked => {
"Unlock the password store first"
}
UiAction::ImportOtp | UiAction::ImportKdbx if context.dirty => {
"Save or discard the current draft first"
}
UiAction::ImportOtp | UiAction::ImportKdbx if context.saving => "Wait for the active save",
UiAction::ImportOtp | UiAction::ImportKdbx if context.switching_vault => {
"Wait for vault validation"
}
UiAction::ImportOtp | UiAction::ImportKdbx => "Close the current screen first",
UiAction::GitStatus | UiAction::GitPull | UiAction::GitPush | UiAction::GitSync
if !context.storage_ready =>
{
@@ -695,6 +711,7 @@ pub const fn aliases(action: UiAction) -> &'static [&'static str] {
UiAction::GenerateOtp => &["totp", "hotp", "one time password"],
UiAction::CopyOtp => &["copy totp", "copy hotp", "otp clipboard"],
UiAction::ImportOtp => &["add otp", "scan qr", "import otpauth"],
UiAction::ImportKdbx => &["keepass", "kdbx", "import database"],
UiAction::ShowOtpUri => &["show otpauth", "otp secret"],
UiAction::CopyOtpUri => &["copy otpauth", "copy otp secret"],
UiAction::ShowOtpQr => &["otp qr", "export otp"],

View File

@@ -32,6 +32,30 @@ pub async fn pick_qr_image() -> Result<Option<SecretBytes>, String> {
.transpose()
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
pub async fn pick_kdbx_file() -> Result<Option<PathBuf>, String> {
pick_file("Import KeePass Database", "KeePass database", &["kdbx"]).await
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
pub async fn pick_key_file() -> Result<Option<PathBuf>, String> {
pick_file("Select KeePass Key File", "Key file", &["key", "keyx"]).await
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
async fn pick_file(
title: &str,
filter_name: &str,
extensions: &[&str],
) -> Result<Option<PathBuf>, String> {
Ok(rfd::AsyncFileDialog::new()
.set_title(title)
.add_filter(filter_name, extensions)
.pick_file()
.await
.map(|file| file.path().to_owned()))
}
#[cfg(target_os = "linux")]
pub async fn pick_folder(initial: Option<PathBuf>) -> Result<Option<PathBuf>, String> {
use ashpd::{
@@ -96,6 +120,45 @@ pub async fn pick_qr_image() -> Result<Option<SecretBytes>, String> {
.map_err(|error| error.to_string())
}
#[cfg(target_os = "linux")]
pub async fn pick_kdbx_file() -> Result<Option<PathBuf>, String> {
pick_file("Import KeePass Database", "Import").await
}
#[cfg(target_os = "linux")]
pub async fn pick_key_file() -> Result<Option<PathBuf>, String> {
pick_file("Select KeePass Key File", "Select").await
}
#[cfg(target_os = "linux")]
async fn pick_file(title: &str, accept_label: &str) -> Result<Option<PathBuf>, String> {
use ashpd::{
PortalError,
desktop::{file_chooser::SelectedFiles, request::ResponseError},
};
let response = SelectedFiles::open_file()
.title(title)
.accept_label(accept_label)
.modal(true)
.multiple(false)
.send()
.await
.and_then(|request| request.response());
let selected = match response {
Ok(selected) => selected,
Err(ashpd::Error::Response(ResponseError::Cancelled))
| Err(ashpd::Error::Portal(PortalError::Cancelled(_))) => return Ok(None),
Err(error) => return Err(error.to_string()),
};
selected
.uris()
.first()
.ok_or_else(|| "file portal returned no selection".to_owned())
.and_then(|uri| file_uri_path(uri.as_str()))
.map(Some)
}
#[cfg(target_os = "linux")]
fn file_uri_path(uri: &str) -> Result<PathBuf, String> {
let uri = url::Url::parse(uri).map_err(|_| "folder portal returned an invalid URI")?;

View File

@@ -52,6 +52,7 @@ use ironstorage::{
GitConflict, GitConflictChoice, GitConflictResolution, GitError, GitOperationControl,
GitProgressPhase, GitSnapshot,
},
kdbx::{KdbxImportMode, KdbxImportRequest},
mutation::{MutationAction, MutationOutcome, MutationSelection},
otp::{OtpCodeValidity, OtpKind},
presentation::{ClipboardWait, NativeClipboardManager, QrMatrix},
@@ -184,6 +185,20 @@ enum Message {
generation: u64,
completion: OtpCompletion,
},
KdbxSourceChanged(String),
KdbxKeyFileChanged(String),
KdbxPasswordChanged(Zeroizing<String>),
PickKdbxSource,
KdbxSourcePicked(Result<Option<PathBuf>, String>),
PickKdbxKeyFile,
KdbxKeyFilePicked(Result<Option<PathBuf>, String>),
ToggleKdbxQuickAdd,
ToggleKdbxConfirmation,
SubmitKdbxImport,
KdbxFinished {
generation: u64,
result: Box<Result<(ironstorage::kdbx::KdbxImportOutcome, TreeModel), String>>,
},
#[cfg(target_os = "macos")]
PollNativeMenu,
StartupLoaded(Box<Result<(DesktopStorage, NativeAuthenticationSession, KeyInfo), String>>),
@@ -374,6 +389,7 @@ enum UtilityView {
Mutation(MutationForm),
Git(GitForm),
Otp(OtpForm),
Kdbx(KdbxForm),
Help,
}
@@ -402,6 +418,72 @@ struct OtpForm {
error: Option<String>,
}
#[derive(Clone, Eq, PartialEq)]
struct KdbxForm {
source: String,
key_file: String,
password: Zeroizing<String>,
quick_add: bool,
confirmed: bool,
running: bool,
error: Option<String>,
summary: Option<String>,
}
impl std::fmt::Debug for KdbxForm {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("KdbxForm")
.field("source", &self.source)
.field("key_file", &self.key_file)
.field("password", &"[REDACTED]")
.field("quick_add", &self.quick_add)
.field("confirmed", &self.confirmed)
.field("running", &self.running)
.field("error", &self.error)
.field("summary", &self.summary)
.finish()
}
}
impl Default for KdbxForm {
fn default() -> Self {
Self {
source: String::new(),
key_file: String::new(),
password: Zeroizing::new(String::new()),
quick_add: false,
confirmed: false,
running: false,
error: None,
summary: None,
}
}
}
impl KdbxForm {
fn request(&self) -> Result<(KdbxImportRequest, SecretBytes), String> {
if self.source.trim().is_empty() {
return Err("Choose a KDBX database file.".to_owned());
}
if !self.confirmed {
return Err("Confirm the additive import before continuing.".to_owned());
}
Ok((
KdbxImportRequest::new(
self.source.trim(),
(!self.key_file.trim().is_empty()).then(|| self.key_file.trim().into()),
if self.quick_add {
KdbxImportMode::QuickAdd
} else {
KdbxImportMode::AddAndUpdate
},
),
SecretBytes::new(self.password.as_bytes().to_vec()),
))
}
}
impl OtpForm {
fn new(entry: String) -> Self {
Self {
@@ -837,6 +919,7 @@ enum PendingAction {
SearchContents(GrepRequest),
Mutate(MutationForm),
Git(DesktopGitRequest),
ImportKdbx(KdbxForm),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -972,6 +1055,7 @@ impl App {
|| matches!(utility, UtilityView::Mutation(form) if form.running)
|| matches!(utility, UtilityView::Git(form) if form.running)
|| matches!(utility, UtilityView::Otp(form) if form.running)
|| matches!(utility, UtilityView::Kdbx(form) if form.running)
}) {
self.status = "Wait for the active workflow to finish…".to_owned();
} else {
@@ -1867,6 +1951,130 @@ impl App {
}
}
}
Message::KdbxSourceChanged(source) => {
if let Some(UtilityView::Kdbx(form)) = &mut self.utility
&& !form.running
{
form.source = source;
form.error = None;
form.summary = None;
}
}
Message::KdbxKeyFileChanged(key_file) => {
if let Some(UtilityView::Kdbx(form)) = &mut self.utility
&& !form.running
{
form.key_file = key_file;
form.error = None;
form.summary = None;
}
}
Message::KdbxPasswordChanged(password) => {
if let Some(UtilityView::Kdbx(form)) = &mut self.utility
&& !form.running
{
form.password = password;
form.error = None;
form.summary = None;
}
}
Message::PickKdbxSource => {
return Task::perform(folder_picker::pick_kdbx_file(), Message::KdbxSourcePicked);
}
Message::KdbxSourcePicked(result) => match result {
Ok(Some(path)) => {
if let Some(UtilityView::Kdbx(form)) = &mut self.utility {
form.source = path.display().to_string();
form.error = None;
form.summary = None;
}
}
Ok(None) => {}
Err(error) => {
if let Some(UtilityView::Kdbx(form)) = &mut self.utility {
form.error = Some(error);
}
}
},
Message::PickKdbxKeyFile => {
return Task::perform(folder_picker::pick_key_file(), Message::KdbxKeyFilePicked);
}
Message::KdbxKeyFilePicked(result) => match result {
Ok(Some(path)) => {
if let Some(UtilityView::Kdbx(form)) = &mut self.utility {
form.key_file = path.display().to_string();
form.error = None;
form.summary = None;
}
}
Ok(None) => {}
Err(error) => {
if let Some(UtilityView::Kdbx(form)) = &mut self.utility {
form.error = Some(error);
}
}
},
Message::ToggleKdbxQuickAdd => {
if let Some(UtilityView::Kdbx(form)) = &mut self.utility
&& !form.running
{
form.quick_add = !form.quick_add;
form.summary = None;
}
}
Message::ToggleKdbxConfirmation => {
if let Some(UtilityView::Kdbx(form)) = &mut self.utility
&& !form.running
{
form.confirmed = !form.confirmed;
}
}
Message::SubmitKdbxImport => {
let Some(UtilityView::Kdbx(form)) = &self.utility else {
return Task::none();
};
if form.running {
return Task::none();
}
if self.handle.is_none() {
self.after_authentication = Some(PendingAction::ImportKdbx(form.clone()));
return self.begin_authentication();
}
return self.begin_kdbx_import(form.clone());
}
Message::KdbxFinished { generation, result } => {
if generation != self.workflow_generation {
return Task::none();
}
match *result {
Ok((outcome, tree)) => {
self.navigation.replace(&tree);
self.tree_state = tree_state_from_result(Ok(self.navigation.is_empty()));
let summary = format!(
"KDBX import: {} added, {} updated, {} unchanged, {} skipped.",
outcome.added(),
outcome.updated(),
outcome.unchanged(),
outcome.skipped()
);
if let Some(UtilityView::Kdbx(form)) = &mut self.utility {
form.running = false;
form.error = None;
form.summary = Some(summary.clone());
form.password = Zeroizing::new(String::new());
form.confirmed = false;
}
self.status = summary;
}
Err(error) => {
if let Some(UtilityView::Kdbx(form)) = &mut self.utility {
form.running = false;
form.error = Some(error.clone());
}
self.status = format!("KDBX import failed: {error}");
}
}
}
#[cfg(target_os = "macos")]
Message::PollNativeMenu => {
if self.native_menu.is_none() {
@@ -2419,6 +2627,9 @@ impl App {
self.entry_path.trim().to_owned(),
)));
}
UiAction::ImportKdbx => {
self.utility = Some(UtilityView::Kdbx(KdbxForm::default()));
}
UiAction::GenerateOtp
| UiAction::CopyOtp
| UiAction::ShowOtpUri
@@ -2632,9 +2843,47 @@ impl App {
self.begin_authentication()
}
PendingAction::Git(request) => self.begin_git(request),
PendingAction::ImportKdbx(form) if self.handle.is_none() => {
self.after_authentication = Some(PendingAction::ImportKdbx(form));
self.begin_authentication()
}
PendingAction::ImportKdbx(form) => self.begin_kdbx_import(form),
}
}
fn begin_kdbx_import(&mut self, form: KdbxForm) -> Task<Message> {
let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else {
return Task::none();
};
let (request, password) = match form.request() {
Ok(request) => request,
Err(error) => {
if let Some(UtilityView::Kdbx(form)) = &mut self.utility {
form.error = Some(error);
}
return Task::none();
}
};
self.workflow_generation = self.workflow_generation.wrapping_add(1);
let generation = self.workflow_generation;
if let Some(UtilityView::Kdbx(form)) = &mut self.utility {
form.running = true;
form.error = None;
form.summary = None;
}
self.status = "Importing KeePass database through crates/storage…".to_owned();
Task::perform(
async move {
Box::new(
storage
.import_kdbx_active(&handle, &request, password)
.map_err(|error| error.to_string()),
)
},
move |result| Message::KdbxFinished { generation, result },
)
}
fn update_search_form(&mut self, update: impl FnOnce(&mut SearchForm)) {
if let Some(UtilityView::Search(form)) = &mut self.utility
&& !form.running
@@ -3307,6 +3556,12 @@ impl App {
form.uri.clear();
form.error = Some(reason.clone());
}
Some(UtilityView::Kdbx(form)) => {
form.running = false;
form.password = Zeroizing::new(String::new());
form.confirmed = false;
form.error = Some(reason.clone());
}
_ => {}
}
self.authentication = AuthenticationView::Locked;
@@ -4444,6 +4699,66 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
content = content.push(text(format!("OTP error: {error}")));
}
}
UtilityView::Kdbx(form) => {
content = content
.push(text("Import KeePass Database").size(28))
.push(text(
"The import is additive: full mode adds new entries and updates changed entries; quick-add mode only adds entries that do not exist. Nothing is deleted.",
))
.push(text("KDBX database"))
.push(
row![
text_input("Database.kdbx", &form.source)
.on_input(Message::KdbxSourceChanged)
.on_submit(Message::SubmitKdbxImport),
if form.running {
button("Choose…")
} else {
button("Choose…").on_press(Message::PickKdbxSource)
},
]
.spacing(8),
)
.push(text("Optional KeePass key file"))
.push(
row![
text_input("No key file", &form.key_file)
.on_input(Message::KdbxKeyFileChanged)
.on_submit(Message::SubmitKdbxImport),
if form.running {
button("Choose…")
} else {
button("Choose…").on_press(Message::PickKdbxKeyFile)
},
]
.spacing(8),
)
.push(text("Database password"))
.push(
text_input("Password", &form.password)
.secure(true)
.on_input(|value| Message::KdbxPasswordChanged(Zeroizing::new(value)))
.on_submit(Message::SubmitKdbxImport),
)
.push(search_option(
"Quick add: only add entries not already present",
form.quick_add,
Message::ToggleKdbxQuickAdd,
form.running,
))
.push(search_option(
"Confirm additive import and per-entry Git commits",
form.confirmed,
Message::ToggleKdbxConfirmation,
form.running,
));
if let Some(summary) = &form.summary {
content = content.push(text(summary));
}
if let Some(error) = &form.error {
content = content.push(text(format!("KDBX import error: {error}")));
}
}
UtilityView::Help => {
content = content
.push(text("IronStorage Help").size(28))
@@ -4487,7 +4802,8 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
|| matches!(utility, UtilityView::Search(form) if form.running)
|| matches!(utility, UtilityView::Mutation(form) if form.running)
|| matches!(utility, UtilityView::Git(form) if form.running)
|| matches!(utility, UtilityView::Otp(form) if form.running);
|| matches!(utility, UtilityView::Otp(form) if form.running)
|| matches!(utility, UtilityView::Kdbx(form) if form.running);
let done = if busy {
done
} else {
@@ -4684,6 +5000,24 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
.push(done)
}
}
UtilityView::Kdbx(form) => {
let import = button(if form.running {
"Importing…"
} else if form.quick_add {
"Quick Add"
} else {
"Import"
});
row![
if form.running {
import
} else {
import.on_press(Message::SubmitKdbxImport)
},
done,
]
.spacing(8)
}
UtilityView::About | UtilityView::Help => row![done],
};
container(
@@ -5337,6 +5671,7 @@ fn confirmation_view(action: &PendingAction) -> Element<'_, Message> {
form.source.path().display()
),
PendingAction::Git(request) => format!("Git {}", git_request_name(request)),
PendingAction::ImportKdbx(form) => format!("Import {}", form.source),
};
container(
column![
@@ -7161,4 +7496,39 @@ mod tests {
}))
));
}
#[test]
fn kdbx_form_builds_a_confirmed_storage_request_and_is_cleared_on_lock() {
let mut form = KdbxForm {
source: "/tmp/passwords.kdbx".to_owned(),
key_file: "/tmp/passwords.key".to_owned(),
password: Zeroizing::new("database password".to_owned()),
quick_add: true,
confirmed: true,
..KdbxForm::default()
};
let (request, password) = form.request().expect("confirmed request");
assert_eq!(
request.source(),
std::path::Path::new("/tmp/passwords.kdbx")
);
assert_eq!(
request.key_file(),
Some(std::path::Path::new("/tmp/passwords.key"))
);
assert_eq!(request.mode(), KdbxImportMode::QuickAdd);
assert_eq!(password.expose(), b"database password");
assert!(!format!("{form:?}").contains("database password"));
form.running = true;
let mut app = App::new().0;
app.utility = Some(UtilityView::Kdbx(form));
app.authentication_lost("locked".to_owned());
let Some(UtilityView::Kdbx(form)) = app.utility else {
panic!("expected KDBX form");
};
assert!(form.password.is_empty());
assert!(!form.confirmed);
assert!(!form.running);
}
}

View File

@@ -168,6 +168,7 @@ fn accelerator(action: UiAction) -> Option<Accelerator> {
| UiAction::GenerateOtp
| UiAction::CopyOtp
| UiAction::ImportOtp
| UiAction::ImportKdbx
| UiAction::ShowOtpUri
| UiAction::CopyOtpUri
| UiAction::ShowOtpQr