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

@@ -26,6 +26,7 @@ use ironstorage::{
crypto::KeyStore,
generate::{GeneratorConfig, PasswordGenerator},
git::{GitChangeKind, GitIdentity, GitRepository, PullOutcome},
kdbx::KdbxImporter,
mutation::{
MutationError, NoGitTreeCommitter, TreeCommit, TreeCommitError, TreeCommitter, TreeMutator,
},
@@ -177,6 +178,7 @@ fn needs_secret_store(request: &CommandRequest) -> bool {
| CommandRequest::Remove(_)
| CommandRequest::Move(_)
| CommandRequest::Copy(_)
| CommandRequest::ImportKdbx(_)
| CommandRequest::Otp(
OtpRequest::Code(_)
| OtpRequest::Insert(_)
@@ -651,6 +653,32 @@ fn execute_secure_with_services<
interaction,
stderr,
),
CommandRequest::ImportKdbx(request) => {
let password = match interaction.read_kdbx_password(request.source()) {
Ok(password) => password,
Err(error) => return operation_error(stderr, error),
};
match KdbxImporter::new(&repository, &keys).import(
request,
password,
secrets,
git_identity(),
) {
Ok(outcome) => {
writeln!(
stdout,
"KDBX import: {} added, {} updated, {} unchanged, {} skipped",
outcome.added(),
outcome.updated(),
outcome.unchanged(),
outcome.skipped()
)
.map_err(|_| ())?;
Ok(EXIT_SUCCESS)
}
Err(error) => operation_error(stderr, error),
}
}
CommandRequest::Otp(request) => execute_otp(
config,
request,
@@ -1026,6 +1054,8 @@ trait OtpInteraction {
}
trait CliInteraction: OtpInteraction {
fn read_kdbx_password(&mut self, source: &Path) -> Result<SecretBytes, CliInteractionError>;
fn read_insert(
&mut self,
plan: InputPlan,
@@ -1130,6 +1160,12 @@ impl OtpInteraction for NativeOtpInteraction {
}
impl CliInteraction for NativeOtpInteraction {
fn read_kdbx_password(&mut self, source: &Path) -> Result<SecretBytes, CliInteractionError> {
rpassword::prompt_password(format!("Enter password for {}: ", source.display()))
.map(|password| SecretBytes::new(password.into_bytes()))
.map_err(|_| CliInteractionError::Input)
}
fn read_insert(
&mut self,
plan: InputPlan,
@@ -1213,6 +1249,10 @@ impl OtpInteraction for UnavailableOtpInteraction {
#[cfg(test)]
impl CliInteraction for UnavailableOtpInteraction {
fn read_kdbx_password(&mut self, _source: &Path) -> Result<SecretBytes, CliInteractionError> {
Err(CliInteractionError::Input)
}
fn read_insert(
&mut self,
_plan: InputPlan,
@@ -1611,6 +1651,7 @@ mod tests {
inputs: VecDeque<OtpInput>,
insert_inputs: VecDeque<ironstorage::write::InsertContent>,
edit_replacements: VecDeque<SecretBytes>,
kdbx_passwords: VecDeque<SecretBytes>,
decisions: VecDeque<OverwriteDecision>,
plans: Vec<InputPlan>,
prompts: Vec<String>,
@@ -1644,6 +1685,15 @@ mod tests {
}
impl super::CliInteraction for MemoryOtpInteraction {
fn read_kdbx_password(
&mut self,
_source: &std::path::Path,
) -> Result<SecretBytes, super::CliInteractionError> {
self.kdbx_passwords
.pop_front()
.ok_or(super::CliInteractionError::Input)
}
fn read_insert(
&mut self,
_plan: InputPlan,

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

View File

@@ -21,7 +21,7 @@ Secret-bearing commands are masked and omitted from history.
| Tree | `j`/`k`, arrows, `h`/`l`, `Enter`, `/`, `n`/`N` | move, collapse/expand, open, filter, cycle matches |
| Entry | `j`/`k`, arrows, `Tab`/`Shift-Tab`, `v`/`V`, `y`, `e`, `Esc` | focus, reveal/hide, timed copy, edit, close |
| Editor | `i`, `a`, `d`, `K`/`J`, `g`, `C-s` | edit/add/remove/reorder/generate/save fields |
| Store | `I`, `i`, `p`, `\\`, `d d`, `m`, `c` | init, insert, generate, grep, remove, move, copy |
| Store | `I`, `i`, `p`, `\\`, `K`, `d d`, `m`, `c` | init, insert, generate, grep, KDBX import, remove, move, copy |
| Git | `g p`, `g P` | pull, push; all other Git operations use `:git …` |
| OTP | `o c`, `o y`, `o u`, `o x`, `o q` | code, copy code, URI, copy URI, QR |
| OTP write | `o i`, `o a`, `o v` | insert, append, validate URI forms |
@@ -62,6 +62,7 @@ where one is listed.
| remove | confirmed removal form | `d d` | `:remove [OPTIONS] ENTRY` |
| move | move form | `m` | `:move [OPTIONS] SOURCE DESTINATION` |
| copy | copy form | `c` | `:copy [OPTIONS] SOURCE DESTINATION` |
| KeePass KDBX import | masked additive-import form | `K` | `:import-kdbx [--key-file PATH] [--quick-add] SOURCE` |
| git init/status/log/diff/add/commit | Git dashboard/detail pane | — | `:git SUBCOMMAND …` |
| git remote/config | Git dashboard | — | `:git remote …`, `:git config …` |
| git fetch/sync | cancellable progress view | — | `:git fetch …`, `:git sync …` |

View File

@@ -45,6 +45,7 @@ pub enum Action {
InsertEntry,
GenerateEntry,
Grep,
ImportKdbx,
RemoveEntry,
MoveEntry,
CopyEntry,
@@ -66,6 +67,7 @@ pub enum WorkflowAction {
InsertEntry,
GenerateEntry,
Grep,
ImportKdbx,
RemoveEntry,
MoveEntry,
CopyEntry,
@@ -80,6 +82,7 @@ impl Action {
Self::InsertEntry => Some(WorkflowAction::InsertEntry),
Self::GenerateEntry => Some(WorkflowAction::GenerateEntry),
Self::Grep => Some(WorkflowAction::Grep),
Self::ImportKdbx => Some(WorkflowAction::ImportKdbx),
Self::RemoveEntry => Some(WorkflowAction::RemoveEntry),
Self::MoveEntry => Some(WorkflowAction::MoveEntry),
Self::CopyEntry => Some(WorkflowAction::CopyEntry),
@@ -498,6 +501,13 @@ pub static ACTIONS: &[ActionSpec] = &[
bindings: keys!((KeyCode::Char('\\'), KeyModifiers::NONE, "\\")),
modes: BROWSER_LIKE,
},
ActionSpec {
action: Action::ImportKdbx,
label: "import KeePass KDBX",
command: "import-kdbx",
bindings: keys!((KeyCode::Char('K'), KeyModifiers::SHIFT, "K")),
modes: BROWSER_LIKE,
},
ActionSpec {
action: Action::RemoveEntry,
label: "remove entry",

View File

@@ -1200,6 +1200,7 @@ impl App {
| Action::InsertEntry
| Action::GenerateEntry
| Action::Grep
| Action::ImportKdbx
| Action::RemoveEntry
| Action::MoveEntry
| Action::CopyEntry
@@ -1212,6 +1213,7 @@ impl App {
| WorkflowAction::InsertEntry
| WorkflowAction::GenerateEntry
| WorkflowAction::Grep
| WorkflowAction::ImportKdbx
| WorkflowAction::RemoveEntry
| WorkflowAction::MoveEntry
| WorkflowAction::CopyEntry
@@ -1626,7 +1628,8 @@ impl App {
| CommandInvocation::Storage(request @ CommandRequest::Grep(_))
| CommandInvocation::Storage(request @ CommandRequest::Remove(_))
| CommandInvocation::Storage(request @ CommandRequest::Move(_))
| CommandInvocation::Storage(request @ CommandRequest::Copy(_)) => {
| CommandInvocation::Storage(request @ CommandRequest::Copy(_))
| CommandInvocation::Storage(request @ CommandRequest::ImportKdbx(_)) => {
self.transition(Transition::Dismiss);
let workflow = match &request {
CommandRequest::Init(_) => WorkflowAction::Initialize,
@@ -1636,6 +1639,7 @@ impl App {
CommandRequest::Remove(_) => WorkflowAction::RemoveEntry,
CommandRequest::Move(_) => WorkflowAction::MoveEntry,
CommandRequest::Copy(_) => WorkflowAction::CopyEntry,
CommandRequest::ImportKdbx(_) => WorkflowAction::ImportKdbx,
_ => unreachable!(),
};
self.open_workflow(workflow, Some(request));
@@ -1727,6 +1731,10 @@ impl App {
WorkflowForm::grep(Some(request))
}
(WorkflowAction::Grep, None) => WorkflowForm::grep(None),
(WorkflowAction::ImportKdbx, Some(CommandRequest::ImportKdbx(request))) => {
WorkflowForm::kdbx(Some(request))
}
(WorkflowAction::ImportKdbx, None) => WorkflowForm::kdbx(None),
(WorkflowAction::RemoveEntry, Some(CommandRequest::Remove(request))) => {
WorkflowForm::remove(Some(request), None)
}
@@ -2132,6 +2140,7 @@ fn workflow_label(workflow: WorkflowAction) -> &'static str {
WorkflowAction::InsertEntry => "entry insertion",
WorkflowAction::GenerateEntry => "entry generation",
WorkflowAction::Grep => "decrypted grep",
WorkflowAction::ImportKdbx => "KDBX import",
WorkflowAction::RemoveEntry => "entry removal",
WorkflowAction::MoveEntry => "entry move",
WorkflowAction::CopyEntry => "entry copy",

View File

@@ -9,8 +9,25 @@ use crate::action::{ACTIONS, Action};
const HISTORY_LIMIT: usize = 100;
const ROOT_COMMANDS: &[&str] = &[
"init", "list", "show", "find", "grep", "insert", "edit", "generate", "remove", "move", "copy",
"git", "otp", "lock", "unlock", "help", "version", "quit",
"init",
"list",
"show",
"find",
"grep",
"insert",
"edit",
"generate",
"remove",
"move",
"copy",
"import-kdbx",
"git",
"otp",
"lock",
"unlock",
"help",
"version",
"quit",
];
const GIT_COMMANDS: &[&str] = &[
"init",
@@ -31,8 +48,20 @@ const GIT_COMMANDS: &[&str] = &[
const GIT_REMOTE_COMMANDS: &[&str] = &["get-url", "add", "set-url", "remove"];
const OTP_COMMANDS: &[&str] = &["code", "insert", "append", "uri", "validate", "version"];
const HELP_TOPICS: &[&str] = &[
"init", "list", "show", "find", "grep", "insert", "edit", "generate", "remove", "move", "copy",
"git", "otp",
"init",
"list",
"show",
"find",
"grep",
"insert",
"edit",
"generate",
"remove",
"move",
"copy",
"import-kdbx",
"git",
"otp",
];
/// Auditable mapping from every milestone-01 command family to the TUI command surface.
@@ -54,6 +83,7 @@ pub const COMMAND_COVERAGE: &[CommandCoverage] = &[
"copy entries or directories",
":copy [OPTIONS] SOURCE DESTINATION",
),
coverage("import a KeePass database", ":import-kdbx [OPTIONS] SOURCE"),
coverage("initialize Git", ":git init"),
coverage("show Git status", ":git status"),
coverage("show Git log", ":git log [OPTIONS]"),
@@ -171,6 +201,12 @@ pub const TUI_COVERAGE: &[TuiCoverage] = &[
Some(Action::CopyEntry),
":copy [OPTIONS] SOURCE DESTINATION",
),
tui(
"import a KeePass database",
"KDBX import form",
Some(Action::ImportKdbx),
":import-kdbx [OPTIONS] SOURCE",
),
tui("initialize Git", "Git dashboard", None, ":git init"),
tui("show Git status", "Git dashboard", None, ":git status"),
tui("show Git log", "Git dashboard", None, ":git log [OPTIONS]"),
@@ -669,6 +705,7 @@ pub fn operation_name(request: &CommandRequest) -> &'static str {
CommandRequest::Remove(_) => "remove",
CommandRequest::Move(_) => "move",
CommandRequest::Copy(_) => "copy",
CommandRequest::ImportKdbx(_) => "KDBX import",
CommandRequest::Git(request) => match request {
GitRequest::Init => "Git init",
GitRequest::Status => "Git status",

View File

@@ -1258,6 +1258,21 @@ fn execute_workflow(
},
)
}
WorkflowSubmission::Kdbx { request, password } => {
let outcome = ironstorage::kdbx::KdbxImporter::new(&repository, &keys)
.import(&request, password, provider, identity)
.map_err(|error| error.to_string())?;
(
None,
format!(
"KDBX import: {} added, {} updated, {} unchanged, {} skipped",
outcome.added(),
outcome.updated(),
outcome.unchanged(),
outcome.skipped()
),
)
}
WorkflowSubmission::Remove(request) => {
let target = request.entry.clone();
let mut committer = AutomaticTreeCommitter::for_source(&repository, &target, identity)

View File

@@ -10,7 +10,9 @@ use ironstorage::{
RemoveRequest,
},
crypto::KeyInfo,
kdbx::{KdbxImportMode, KdbxImportRequest},
otp::OtpInput,
repository::SecretBytes,
write::{InsertContent, OverwriteDecision},
};
use zeroize::Zeroize;
@@ -120,6 +122,16 @@ pub struct GrepForm {
focus: usize,
}
#[derive(Debug)]
pub struct KdbxForm {
source: String,
key_file: String,
password: SecretText,
quick_add: bool,
confirmed: bool,
focus: usize,
}
#[derive(Debug)]
pub struct RemoveForm {
target: String,
@@ -161,6 +173,7 @@ pub enum WorkflowForm {
Insert(InsertForm),
Generate(GenerateForm),
Grep(GrepForm),
Kdbx(KdbxForm),
Remove(RemoveForm),
Move(TransferForm),
Copy(TransferForm),
@@ -180,6 +193,10 @@ pub enum WorkflowSubmission {
overwrite: OverwriteDecision,
},
Grep(GrepRequest),
Kdbx {
request: KdbxImportRequest,
password: SecretBytes,
},
Remove(RemoveRequest),
Move {
request: MoveRequest,
@@ -321,6 +338,21 @@ impl WorkflowForm {
})
}
pub fn kdbx(request: Option<KdbxImportRequest>) -> Self {
let request = request
.unwrap_or_else(|| KdbxImportRequest::new("", None, KdbxImportMode::AddAndUpdate));
Self::Kdbx(KdbxForm {
source: request.source().to_string_lossy().into_owned(),
key_file: request
.key_file()
.map_or_else(String::new, |path| path.to_string_lossy().into_owned()),
password: SecretText::default(),
quick_add: request.mode() == KdbxImportMode::QuickAdd,
confirmed: false,
focus: 0,
})
}
pub fn remove(request: Option<RemoveRequest>, selected: Option<(&str, bool)>) -> Self {
let request = request.unwrap_or_else(|| RemoveRequest {
entry: selected.map_or_else(String::new, |(path, _)| path.to_owned()),
@@ -368,6 +400,7 @@ impl WorkflowForm {
Self::Insert(_) => "Insert entry",
Self::Generate(_) => "Generate password",
Self::Grep(_) => "Search decrypted entries",
Self::Kdbx(_) => "Import KeePass KDBX",
Self::Remove(_) => "Remove entry or folder",
Self::Move(_) => "Move or rename",
Self::Copy(_) => "Copy entry or folder",
@@ -453,6 +486,33 @@ impl WorkflowForm {
row(form.focus == 3, "Line numbers", yes_no(form.line_number)),
row(form.focus == 4, "Fixed string", yes_no(form.fixed_strings)),
],
Self::Kdbx(form) => vec![
row(form.focus == 0, "KDBX file", &form.source),
row(
form.focus == 1,
"Key file",
if form.key_file.is_empty() {
"(none)"
} else {
&form.key_file
},
),
row(
form.focus == 2,
"Database password",
if form.password.is_empty() {
"(empty)"
} else {
"••••••••"
},
),
row(
form.focus == 3,
"Only add new entries",
yes_no(form.quick_add),
),
row(form.focus == 4, "Confirm import", yes_no(form.confirmed)),
],
Self::Remove(form) => vec![
row(form.focus == 0, "Target", &form.target),
row(form.focus == 1, "Recursive folder", yes_no(form.recursive)),
@@ -633,6 +693,26 @@ impl WorkflowForm {
fixed_strings: form.fixed_strings,
}))
}
Self::Kdbx(form) => {
if form.source.trim().is_empty() {
return Err("KDBX file path is required".to_owned());
}
if !form.confirmed {
return Err("Explicitly confirm the KDBX import".to_owned());
}
Ok(WorkflowSubmission::Kdbx {
request: KdbxImportRequest::new(
form.source.trim(),
(!form.key_file.trim().is_empty()).then(|| form.key_file.trim().into()),
if form.quick_add {
KdbxImportMode::QuickAdd
} else {
KdbxImportMode::AddAndUpdate
},
),
password: SecretBytes::new(form.password.bytes()),
})
}
Self::Remove(form) => {
if form.target.trim().is_empty() {
return Err("Removal target is required".to_owned());
@@ -718,6 +798,7 @@ impl WorkflowForm {
Self::Insert(_) => 5,
Self::Generate(_) => 6,
Self::Grep(_) => 5,
Self::Kdbx(_) => 5,
Self::Remove(_) | Self::Move(_) | Self::Copy(_) => 4,
Self::Otp(_) => 4,
}
@@ -730,6 +811,7 @@ impl WorkflowForm {
Self::Insert(form) => &mut form.focus,
Self::Generate(form) => &mut form.focus,
Self::Grep(form) => &mut form.focus,
Self::Kdbx(form) => &mut form.focus,
Self::Remove(form) => &mut form.focus,
Self::Move(form) | Self::Copy(form) => &mut form.focus,
Self::Otp(form) => &mut form.focus,
@@ -770,6 +852,8 @@ impl WorkflowForm {
Self::Grep(form) if form.focus == 2 => form.invert_match ^= true,
Self::Grep(form) if form.focus == 3 => form.line_number ^= true,
Self::Grep(form) if form.focus == 4 => form.fixed_strings ^= true,
Self::Kdbx(form) if form.focus == 3 => form.quick_add ^= true,
Self::Kdbx(form) if form.focus == 4 => form.confirmed ^= true,
Self::Remove(form) if form.focus == 1 => form.recursive ^= true,
Self::Remove(form) if form.focus == 2 => form.force ^= true,
Self::Remove(form) if form.focus == 3 => form.confirmed ^= true,
@@ -813,6 +897,13 @@ impl WorkflowForm {
Self::Grep(form) if form.focus == 0 => {
form.pattern.pop();
}
Self::Kdbx(form) if form.focus == 0 => {
form.source.pop();
}
Self::Kdbx(form) if form.focus == 1 => {
form.key_file.pop();
}
Self::Kdbx(form) if form.focus == 2 => form.password.pop(),
Self::Remove(form) if form.focus == 0 => {
form.target.pop();
}
@@ -851,6 +942,11 @@ impl WorkflowForm {
form.length.push(character)
}
Self::Grep(form) if form.focus == 0 => form.pattern.push(character),
Self::Kdbx(form) if form.focus == 0 => form.source.push(character),
Self::Kdbx(form) if form.focus == 1 => form.key_file.push(character),
Self::Kdbx(form) if form.focus == 2 && character != '\n' => {
form.password.push(character)
}
Self::Remove(form) if form.focus == 0 => form.target.push(character),
Self::Move(form) | Self::Copy(form) if form.focus == 0 => form.source.push(character),
Self::Move(form) | Self::Copy(form) if form.focus == 1 => {
@@ -919,6 +1015,37 @@ fn generated_presentation_label(presentation: GeneratedPresentation) -> &'static
mod tests {
use super::*;
#[test]
fn kdbx_form_keeps_password_secret_and_submits_both_import_modes() {
let mut form = WorkflowForm::kdbx(Some(KdbxImportRequest::new(
"fixture.kdbx",
Some("fixture.key".into()),
KdbxImportMode::AddAndUpdate,
)));
form.handle_key(KeyCode::Tab, KeyModifiers::NONE);
form.handle_key(KeyCode::Tab, KeyModifiers::NONE);
for character in "database password".chars() {
form.handle_key(KeyCode::Char(character), KeyModifiers::NONE);
}
form.handle_key(KeyCode::Tab, KeyModifiers::NONE);
form.handle_key(KeyCode::Char(' '), KeyModifiers::NONE);
form.handle_key(KeyCode::Tab, KeyModifiers::NONE);
form.handle_key(KeyCode::Char(' '), KeyModifiers::NONE);
let WorkflowSubmission::Kdbx { request, password } =
form.submission().expect("confirmed import")
else {
panic!("expected KDBX submission");
};
assert_eq!(request.source(), std::path::Path::new("fixture.kdbx"));
assert_eq!(
request.key_file(),
Some(std::path::Path::new("fixture.key"))
);
assert_eq!(request.mode(), KdbxImportMode::QuickAdd);
assert_eq!(password.expose(), b"database password");
}
fn type_text(form: &mut WorkflowForm, text: &str) {
for character in text.chars() {
form.handle_key(KeyCode::Char(character), KeyModifiers::NONE);