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