Add additive KDBX importer
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user