use std::path::PathBuf;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use zeroize::Zeroizing;
use crate::clipboard;
use crate::db::Database;
use crate::error::{Error, Result};
use crate::model::{Item, ItemData, ItemType};
use crate::search::{self, Filter};
use crate::store;
use crate::vault;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
Continue,
Quit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PassKind {
Open,
Create,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Mode {
Normal,
Search,
PromptPassphrase(PassKind),
NewItem(ItemType),
EditItem,
ConfirmDelete,
CategoryMgr,
TagMgr,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Field {
Title,
Data(DataField),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DataField {
Username,
Password,
Url,
TotpSecret,
Notes,
Format,
Content,
Holder,
Number,
Expiry,
Cvv,
Bank,
}
#[derive(Debug, Clone)]
pub struct EditorState {
pub draft: Item,
pub field: Field,
}
pub struct App {
pub db: Option<Database>,
pub path: Option<PathBuf>,
pub passphrase: Option<Zeroizing<String>>,
pub mode: Mode,
pub filter: Filter,
pub items: Vec<Item>,
pub selected: usize,
pub categories: Vec<crate::model::Category>,
pub tags: Vec<String>,
pub message: Option<String>,
pub editor: Option<EditorState>,
pub input: String,
pub input_mask: bool,
pub quit: bool,
}
impl App {
pub fn for_open(path: PathBuf) -> App {
App {
db: None,
path: Some(path),
passphrase: None,
mode: Mode::PromptPassphrase(PassKind::Open),
filter: Filter::default(),
items: Vec::new(),
selected: 0,
categories: Vec::new(),
tags: Vec::new(),
message: None,
editor: None,
input: String::new(),
input_mask: true,
quit: false,
}
}
pub fn for_create(path: PathBuf) -> App {
App {
db: None,
path: Some(path),
passphrase: None,
mode: Mode::PromptPassphrase(PassKind::Create),
filter: Filter::default(),
items: Vec::new(),
selected: 0,
categories: Vec::new(),
tags: Vec::new(),
message: None,
editor: None,
input: String::new(),
input_mask: true,
quit: false,
}
}
pub fn from_unlocked(db: Database, path: PathBuf, passphrase: String) -> Result<App> {
let mut app = App {
db: Some(db),
path: Some(path),
passphrase: Some(Zeroizing::new(passphrase)),
mode: Mode::Normal,
filter: Filter::default(),
items: Vec::new(),
selected: 0,
categories: Vec::new(),
tags: Vec::new(),
message: None,
editor: None,
input: String::new(),
input_mask: false,
quit: false,
};
app.reload()?;
Ok(app)
}
pub fn reload(&mut self) -> Result<()> {
let Some(ref db) = self.db else {
self.items.clear();
self.categories.clear();
self.tags.clear();
return Ok(());
};
let conn = db.conn();
self.items = search::search(conn, &self.filter)?;
self.categories = store::list_categories(conn)?;
self.tags = store::list_tags(conn)?
.into_iter()
.map(|t| t.name)
.collect();
if self.selected >= self.items.len() {
self.selected = self.items.len().saturating_sub(1);
}
Ok(())
}
pub fn save(&self) -> Result<()> {
let path = self.path.as_ref().ok_or_else(|| {
Error::Other("save: no vault path".into())
})?;
let passphrase = self
.passphrase
.as_ref()
.map(|p| p.as_str())
.ok_or_else(|| Error::Other("save: no passphrase (locked?)".into()))?;
let db = self
.db
.as_ref()
.ok_or_else(|| Error::Other("save: no database".into()))?;
vault::save(path, passphrase, db)
}
pub fn lock(&mut self) {
self.db = None;
self.passphrase = None;
self.mode = Mode::Normal;
self.items.clear();
self.categories.clear();
self.tags.clear();
self.selected = 0;
self.filter = Filter::default();
self.input.clear();
self.input_mask = false;
self.editor = None;
self.message = Some("locked".into());
}
pub fn selected_item(&self) -> Option<&Item> {
self.items.get(self.selected)
}
fn copy_password_of_selected(&mut self) {
let Some(item) = self.items.get(self.selected).cloned() else {
self.message = Some("no item selected".into());
return;
};
let pw = match &item.data {
ItemData::Password { password, .. } => password.clone(),
_ => {
self.message = Some("selected item has no password".into());
return;
}
};
match clipboard::copy_and_clear_after(&pw, 20) {
Ok(()) => self.message = Some("password copied (clears in 20s)".into()),
Err(e) => self.message = Some(format!("clipboard unavailable: {e}")),
}
}
pub fn handle_key(&mut self, key: KeyEvent) -> Result<Action> {
match self.mode.clone() {
Mode::PromptPassphrase(kind) => self.handle_passphrase(key, kind),
Mode::Normal => self.handle_normal(key),
Mode::Search => self.handle_search(key),
Mode::NewItem(ty) => self.handle_editor(key, true, ty),
Mode::EditItem => self.handle_editor(key, false, ItemType::Password),
Mode::ConfirmDelete => self.handle_confirm_delete(key),
Mode::CategoryMgr => self.handle_category_mgr(key),
Mode::TagMgr => self.handle_tag_mgr(key),
}
}
fn handle_passphrase(&mut self, key: KeyEvent, kind: PassKind) -> Result<Action> {
match key.code {
KeyCode::Char(c) => {
self.input.push(c);
self.input_mask = true;
}
KeyCode::Backspace => {
self.input.pop();
}
KeyCode::Enter => {
let pass = std::mem::take(&mut self.input);
self.input_mask = false;
let path = match self.path.as_ref() {
Some(p) => p.clone(),
None => {
self.message = Some("no vault path".into());
return Ok(Action::Continue);
}
};
match kind {
PassKind::Create => {
match vault::create(&path, &pass) {
Ok(()) => {
match vault::unlock(&path, &pass) {
Ok(db) => {
self.passphrase = Some(Zeroizing::new(pass));
self.db = Some(db);
self.mode = Mode::Normal;
self.reload()?;
self.message = Some("vault created".into());
}
Err(e) => {
self.message =
Some(format!("unlock after create failed: {e}"));
}
}
}
Err(e) => {
self.message =
Some(format!("create failed: {e}"));
}
}
}
PassKind::Open => match vault::unlock(&path, &pass) {
Ok(db) => {
self.passphrase = Some(Zeroizing::new(pass));
self.db = Some(db);
self.mode = Mode::Normal;
self.reload()?;
self.message = Some("unlocked".into());
}
Err(e) => {
self.message = Some(format!("unlock failed: {e}"));
}
},
}
}
KeyCode::Esc => {
self.quit = true;
return Ok(Action::Quit);
}
_ => {}
}
Ok(Action::Continue)
}
fn handle_normal(&mut self, key: KeyEvent) -> Result<Action> {
if self.db.is_none() {
if matches!(key.code, KeyCode::Char('q')) {
self.quit = true;
return Ok(Action::Quit);
}
return Ok(Action::Continue);
}
match key.code {
KeyCode::Char('j') | KeyCode::Down => {
if !self.items.is_empty() && self.selected + 1 < self.items.len() {
self.selected += 1;
}
}
KeyCode::Char('k') | KeyCode::Up => {
if self.selected > 0 {
self.selected -= 1;
}
}
KeyCode::Char('/') => {
self.mode = Mode::Search;
self.input.clear();
self.input_mask = false;
}
KeyCode::Char('n') => {
self.start_editor_new(ItemType::Password);
}
KeyCode::Char('e') => {
self.start_editor_edit();
}
KeyCode::Char('x') => {
if self.selected_item().is_some() {
self.mode = Mode::ConfirmDelete;
} else {
self.message = Some("no item selected".into());
}
}
KeyCode::Char('y') => {
self.copy_password_of_selected();
}
KeyCode::Char('l') => {
self.lock();
}
KeyCode::Char('c') => {
self.mode = Mode::CategoryMgr;
self.message = Some("category manager (Esc to return)".into());
}
KeyCode::Char('t') => {
self.mode = Mode::TagMgr;
self.message = Some("tag manager (Esc to return)".into());
}
KeyCode::Char('q') => {
self.quit = true;
return Ok(Action::Quit);
}
_ => {}
}
Ok(Action::Continue)
}
fn handle_search(&mut self, key: KeyEvent) -> Result<Action> {
match key.code {
KeyCode::Char(c) => {
self.input.push(c);
}
KeyCode::Backspace => {
self.input.pop();
}
KeyCode::Enter => {
self.filter.query = Some(self.input.clone());
self.reload()?;
self.mode = Mode::Normal;
}
KeyCode::Esc => {
self.mode = Mode::Normal;
self.input.clear();
}
_ => {}
}
Ok(Action::Continue)
}
fn handle_editor(
&mut self,
key: KeyEvent,
is_new: bool,
_ty: ItemType,
) -> Result<Action> {
let Some(ref mut ed) = self.editor else {
self.mode = Mode::Normal;
return Ok(Action::Continue);
};
match key.code {
KeyCode::Char(c) => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
return Ok(Action::Continue);
}
Self::write_field(&mut ed.draft, &ed.field, c);
}
KeyCode::Backspace => {
Self::backspace_field(&mut ed.draft, &ed.field);
}
KeyCode::Tab | KeyCode::Down => {
ed.field = Self::next_field(&ed.draft.item_type, &ed.field);
}
KeyCode::Up => {
ed.field = Self::prev_field(&ed.draft.item_type, &ed.field);
}
KeyCode::Enter => {
let draft = ed.draft.clone();
let saved = if is_new {
self.save_new_item(draft)
} else {
self.save_edit_item(draft)
};
match saved {
Ok(()) => {
self.editor = None;
self.mode = Mode::Normal;
}
Err(e) => {
self.message = Some(format!("save failed: {e}"));
}
}
}
KeyCode::Esc => {
self.editor = None;
self.mode = Mode::Normal;
}
_ => {}
}
Ok(Action::Continue)
}
fn handle_confirm_delete(&mut self, key: KeyEvent) -> Result<Action> {
match key.code {
KeyCode::Char('y') => {
if let Some(id) = self
.items
.get(self.selected)
.and_then(|i| i.id)
{
let res = (|| -> Result<()> {
let db = self.db.as_ref().ok_or_else(|| {
Error::Other("delete: no database".into())
})?;
store::delete_item(db.conn(), id)?;
self.save()?;
self.reload()?;
Ok(())
})();
match res {
Ok(()) => self.message = Some("deleted".into()),
Err(e) => self.message = Some(format!("delete failed: {e}")),
}
}
self.mode = Mode::Normal;
}
KeyCode::Char('n') | KeyCode::Esc => {
self.mode = Mode::Normal;
}
_ => {}
}
Ok(Action::Continue)
}
fn handle_category_mgr(&mut self, key: KeyEvent) -> Result<Action> {
match key.code {
KeyCode::Esc => {
self.mode = Mode::Normal;
}
_ => {}
}
Ok(Action::Continue)
}
fn handle_tag_mgr(&mut self, key: KeyEvent) -> Result<Action> {
match key.code {
KeyCode::Esc => {
self.mode = Mode::Normal;
}
_ => {}
}
Ok(Action::Continue)
}
fn start_editor_new(&mut self, ty: ItemType) {
let draft = Item {
id: None,
item_type: ty,
title: String::new(),
category_id: None,
data: default_data(ty),
favorite: false,
tags: Vec::new(),
created_at: 0,
updated_at: 0,
};
self.editor = Some(EditorState {
draft,
field: Field::Title,
});
self.mode = Mode::NewItem(ty);
}
fn start_editor_edit(&mut self) {
let Some(item) = self.items.get(self.selected).cloned() else {
self.message = Some("no item selected".into());
return;
};
self.editor = Some(EditorState {
draft: item,
field: Field::Title,
});
self.mode = Mode::EditItem;
}
fn save_new_item(&mut self, mut draft: Item) -> Result<()> {
let db = self
.db
.as_ref()
.ok_or_else(|| Error::Other("save_new: no database".into()))?;
store::insert_item(db.conn(), &mut draft)?;
self.save()?;
self.reload()?;
Ok(())
}
fn save_edit_item(&mut self, draft: Item) -> Result<()> {
let db = self
.db
.as_ref()
.ok_or_else(|| Error::Other("save_edit: no database".into()))?;
store::update_item(db.conn(), &draft)?;
self.save()?;
self.reload()?;
Ok(())
}
fn write_field(draft: &mut Item, field: &Field, c: char) {
match field {
Field::Title => draft.title.push(c),
Field::Data(d) => match (&mut draft.data, d) {
(ItemData::Password { username, .. }, DataField::Username) => username.push(c),
(ItemData::Password { password, .. }, DataField::Password) => password.push(c),
(ItemData::Password { url, .. }, DataField::Url) => url.push(c),
(ItemData::Password { totp_secret, .. }, DataField::TotpSecret) => {
totp_secret.push(c)
}
(ItemData::Password { notes, .. }, DataField::Notes) => notes.push(c),
(ItemData::Note { format, .. }, DataField::Format) => format.push(c),
(ItemData::Note { content, .. }, DataField::Content) => content.push(c),
(ItemData::Card { holder, .. }, DataField::Holder) => holder.push(c),
(ItemData::Card { number, .. }, DataField::Number) => number.push(c),
(ItemData::Card { expiry, .. }, DataField::Expiry) => expiry.push(c),
(ItemData::Card { cvv, .. }, DataField::Cvv) => cvv.push(c),
(ItemData::Card { bank, .. }, DataField::Bank) => bank.push(c),
(ItemData::Card { notes, .. }, DataField::Notes) => notes.push(c),
_ => {}
},
}
}
fn backspace_field(draft: &mut Item, field: &Field) {
match field {
Field::Title => {
draft.title.pop();
}
Field::Data(d) => match (&mut draft.data, d) {
(ItemData::Password { username, .. }, DataField::Username) => {
username.pop();
}
(ItemData::Password { password, .. }, DataField::Password) => {
password.pop();
}
(ItemData::Password { url, .. }, DataField::Url) => {
url.pop();
}
(ItemData::Password { totp_secret, .. }, DataField::TotpSecret) => {
totp_secret.pop();
}
(ItemData::Password { notes, .. }, DataField::Notes) => {
notes.pop();
}
(ItemData::Note { format, .. }, DataField::Format) => {
format.pop();
}
(ItemData::Note { content, .. }, DataField::Content) => {
content.pop();
}
(ItemData::Card { holder, .. }, DataField::Holder) => {
holder.pop();
}
(ItemData::Card { number, .. }, DataField::Number) => {
number.pop();
}
(ItemData::Card { expiry, .. }, DataField::Expiry) => {
expiry.pop();
}
(ItemData::Card { cvv, .. }, DataField::Cvv) => {
cvv.pop();
}
(ItemData::Card { bank, .. }, DataField::Bank) => {
bank.pop();
}
(ItemData::Card { notes, .. }, DataField::Notes) => {
notes.pop();
}
_ => {}
},
}
}
fn fields_for(ty: ItemType) -> Vec<Field> {
let mut v = vec![Field::Title];
match ty {
ItemType::Password => {
v.extend([
Field::Data(DataField::Username),
Field::Data(DataField::Password),
Field::Data(DataField::Url),
Field::Data(DataField::TotpSecret),
Field::Data(DataField::Notes),
]);
}
ItemType::Note => {
v.extend([
Field::Data(DataField::Format),
Field::Data(DataField::Content),
]);
}
ItemType::Card => {
v.extend([
Field::Data(DataField::Holder),
Field::Data(DataField::Number),
Field::Data(DataField::Expiry),
Field::Data(DataField::Cvv),
Field::Data(DataField::Bank),
Field::Data(DataField::Notes),
]);
}
}
v
}
fn next_field(ty: &ItemType, cur: &Field) -> Field {
let seq = Self::fields_for(*ty);
let idx = seq.iter().position(|f| f == cur).unwrap_or(0);
let next = (idx + 1) % seq.len();
seq[next].clone()
}
fn prev_field(ty: &ItemType, cur: &Field) -> Field {
let seq = Self::fields_for(*ty);
let idx = seq.iter().position(|f| f == cur).unwrap_or(0);
let prev = if idx == 0 {
seq.len().saturating_sub(1)
} else {
idx - 1
};
seq[prev].clone()
}
}
fn default_data(ty: ItemType) -> ItemData {
match ty {
ItemType::Password => ItemData::Password {
username: String::new(),
password: String::new(),
url: String::new(),
totp_secret: String::new(),
notes: String::new(),
},
ItemType::Note => ItemData::Note {
format: "text".into(),
content: String::new(),
},
ItemType::Card => ItemData::Card {
holder: String::new(),
number: String::new(),
expiry: String::new(),
cvv: String::new(),
bank: String::new(),
notes: String::new(),
},
}
}
#[allow(dead_code)]
fn str_to_keys(s: &str) -> Vec<KeyEvent> {
s.chars()
.map(|c| KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
.collect()
}
#[cfg(test)]
fn key(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
}
#[cfg(test)]
fn key_code(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::KdfParams;
use crate::db::Database;
use crate::model::{ItemData, ItemType};
use crate::store;
fn app_with_items(n: usize) -> App {
let db = Database::open_in_memory().unwrap();
let conn = db.conn();
for i in 0..n {
let mut it = Item {
id: None,
item_type: ItemType::Password,
title: format!("item-{i}"),
category_id: None,
data: ItemData::Password {
username: format!("user-{i}"),
password: format!("pw-{i}"),
url: String::new(),
totp_secret: String::new(),
notes: String::new(),
},
favorite: false,
tags: Vec::new(),
created_at: 0,
updated_at: 0,
};
store::insert_item(conn, &mut it).unwrap();
}
let tmp = std::env::temp_dir().join(format!(
"zkv_app_test_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = tmp;
App::from_unlocked(db, std::path::PathBuf::from("/tmp/zkv_unused.zkv"), "dummy".into())
.unwrap()
}
#[test]
fn from_unlocked_loads_items() {
let app = app_with_items(3);
assert_eq!(app.items.len(), 3);
assert!(matches!(app.mode, Mode::Normal));
assert!(app.db.is_some());
}
#[test]
fn normal_n_enters_new_item_mode() {
let mut app = app_with_items(1);
let act = app.handle_key(key('n')).unwrap();
assert_eq!(act, Action::Continue);
assert!(matches!(app.mode, Mode::NewItem(ItemType::Password)));
assert!(app.editor.is_some());
}
#[test]
fn create_item_increments_count() {
let path = tmp_path("create_item");
cleanup(&path);
let kdf = KdfParams { m_kib: 4096, t_cost: 1, p_cost: 1 };
crate::vault::create_with_params(&path, "pw", &kdf).unwrap();
let db = crate::vault::unlock(&path, "pw").unwrap();
{
let conn = db.conn();
let mut seed = Item {
id: None,
item_type: ItemType::Password,
title: "seed".into(),
category_id: None,
data: ItemData::Password {
username: "u".into(),
password: "p".into(),
url: String::new(),
totp_secret: String::new(),
notes: String::new(),
},
favorite: false,
tags: Vec::new(),
created_at: 0,
updated_at: 0,
};
store::insert_item(conn, &mut seed).unwrap();
}
let mut app = App::from_unlocked(db, path.clone(), "pw".into()).unwrap();
let before = app.items.len();
app.handle_key(key('n')).unwrap();
assert!(matches!(app.mode, Mode::NewItem(_)));
for c in "new".chars() {
app.handle_key(key(c)).unwrap();
}
app.handle_key(key_code(KeyCode::Tab)).unwrap();
for c in "bob".chars() {
app.handle_key(key(c)).unwrap();
}
app.handle_key(key_code(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Normal));
assert_eq!(app.items.len(), before + 1);
let db2 = crate::vault::unlock(&path, "pw").unwrap();
let cnt = db2
.conn()
.query_row("SELECT COUNT(*) FROM items", [], |r| r.get::<_, i64>(0))
.unwrap();
assert_eq!(cnt as usize, before + 1);
cleanup(&path);
}
#[test]
fn normal_q_returns_quit() {
let mut app = app_with_items(1);
let act = app.handle_key(key('q')).unwrap();
assert_eq!(act, Action::Quit);
assert!(app.quit);
}
#[test]
fn normal_l_locks_clears_db() {
let mut app = app_with_items(2);
app.handle_key(key('l')).unwrap();
assert!(app.db.is_none());
assert!(app.passphrase.is_none());
assert!(app.items.is_empty());
assert!(matches!(app.mode, Mode::Normal));
}
#[test]
fn normal_y_does_not_panic_without_clipboard() {
let mut app = app_with_items(1);
app.selected = 0;
app.handle_key(key('y')).unwrap();
assert!(app.message.is_some());
}
#[test]
fn normal_jk_moves_selection() {
let mut app = app_with_items(3);
app.selected = 0;
app.handle_key(key('j')).unwrap();
assert_eq!(app.selected, 1);
app.handle_key(key('j')).unwrap();
assert_eq!(app.selected, 2);
app.handle_key(key('j')).unwrap();
assert_eq!(app.selected, 2);
app.handle_key(key('k')).unwrap();
assert_eq!(app.selected, 1);
}
#[test]
fn search_filters_items() {
let db = Database::open_in_memory().unwrap();
{
let conn = db.conn();
let mut a = Item {
id: None,
item_type: ItemType::Password,
title: "GitHub".into(),
category_id: None,
data: ItemData::Password {
username: "u".into(),
password: "p".into(),
url: String::new(),
totp_secret: String::new(),
notes: String::new(),
},
favorite: false,
tags: Vec::new(),
created_at: 0,
updated_at: 0,
};
let mut b = a.clone();
b.title = "GitLab".into();
store::insert_item(conn, &mut a).unwrap();
store::insert_item(conn, &mut b).unwrap();
}
let mut app =
App::from_unlocked(db, std::path::PathBuf::from("/tmp/zkv_unused.zkv"), "x".into())
.unwrap();
assert_eq!(app.items.len(), 2);
app.handle_key(key('/')).unwrap();
assert!(matches!(app.mode, Mode::Search));
for c in "github".chars() {
app.handle_key(key(c)).unwrap();
}
app.handle_key(key_code(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Normal));
assert_eq!(app.items.len(), 1, "应只命中 GitHub");
assert_eq!(app.filter.query.as_deref(), Some("github"));
}
#[test]
fn search_esc_does_not_apply() {
let mut app = app_with_items(3);
app.handle_key(key('/')).unwrap();
for c in "zzz".chars() {
app.handle_key(key(c)).unwrap();
}
app.handle_key(key_code(KeyCode::Esc)).unwrap();
assert!(matches!(app.mode, Mode::Normal));
assert!(app.filter.query.is_none());
assert_eq!(app.items.len(), 3);
}
#[test]
fn confirm_delete_y_removes_item() {
let path = tmp_path("del_item");
cleanup(&path);
let kdf = KdfParams { m_kib: 4096, t_cost: 1, p_cost: 1 };
crate::vault::create_with_params(&path, "pw", &kdf).unwrap();
let db = crate::vault::unlock(&path, "pw").unwrap();
{
let conn = db.conn();
let mut it = Item {
id: None,
item_type: ItemType::Password,
title: "to-delete".into(),
category_id: None,
data: ItemData::Password {
username: "u".into(),
password: "p".into(),
url: String::new(),
totp_secret: String::new(),
notes: String::new(),
},
favorite: false,
tags: Vec::new(),
created_at: 0,
updated_at: 0,
};
store::insert_item(conn, &mut it).unwrap();
}
let mut app = App::from_unlocked(db, path.clone(), "pw".into()).unwrap();
assert_eq!(app.items.len(), 1);
app.handle_key(key('x')).unwrap();
assert!(matches!(app.mode, Mode::ConfirmDelete));
app.handle_key(key('y')).unwrap();
assert!(matches!(app.mode, Mode::Normal));
assert_eq!(app.items.len(), 0);
cleanup(&path);
}
#[test]
fn editor_tab_cycles_password_fields() {
let mut app = app_with_items(0);
app.start_editor_new(ItemType::Password);
let ed = app.editor.as_ref().unwrap();
assert_eq!(ed.field, Field::Title);
let next = App::next_field(&ItemType::Password, &Field::Title);
assert_eq!(next, Field::Data(DataField::Username));
let last = Field::Data(DataField::Notes);
let wrap = App::next_field(&ItemType::Password, &last);
assert_eq!(wrap, Field::Title);
}
#[test]
fn passphrase_wrong_stays_in_prompt() {
let path = tmp_path("wrong_pass");
cleanup(&path);
let kdf = KdfParams { m_kib: 4096, t_cost: 1, p_cost: 1 };
crate::vault::create_with_params(&path, "correct", &kdf).unwrap();
let mut app = App::for_open(path.clone());
for c in "wrong".chars() {
app.handle_key(key(c)).unwrap();
}
let act = app.handle_key(key_code(KeyCode::Enter)).unwrap();
assert_eq!(act, Action::Continue);
assert!(matches!(app.mode, Mode::PromptPassphrase(PassKind::Open)));
assert!(app.db.is_none());
assert!(app.message.is_some());
cleanup(&path);
}
#[test]
fn passphrase_correct_opens() {
let path = tmp_path("right_pass");
cleanup(&path);
let kdf = KdfParams { m_kib: 4096, t_cost: 1, p_cost: 1 };
crate::vault::create_with_params(&path, "correct", &kdf).unwrap();
let mut app = App::for_open(path.clone());
for c in "correct".chars() {
app.handle_key(key(c)).unwrap();
}
app.handle_key(key_code(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Normal));
assert!(app.db.is_some());
cleanup(&path);
}
fn tmp_path(tag: &str) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static C: AtomicU64 = AtomicU64::new(0);
let n = C.fetch_add(1, Ordering::Relaxed);
let mut p = std::env::temp_dir();
p.push(format!("zkv_app_{tag}_{}_{}", std::process::id(), n));
p
}
fn cleanup(p: &std::path::Path) {
let _ = std::fs::remove_file(p);
let mut t = p.as_os_str().to_owned();
t.push(".tmp");
let _ = std::fs::remove_file(std::path::PathBuf::from(t));
}
}