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

@@ -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);