Add additive KDBX importer
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,7 @@ fn accelerator(action: UiAction) -> Option<Accelerator> {
|
||||
| UiAction::GenerateOtp
|
||||
| UiAction::CopyOtp
|
||||
| UiAction::ImportOtp
|
||||
| UiAction::ImportKdbx
|
||||
| UiAction::ShowOtpUri
|
||||
| UiAction::CopyOtpUri
|
||||
| UiAction::ShowOtpQr
|
||||
|
||||
@@ -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