mod ui;
use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
use ratatui::widgets::ListState;
use std::path::{Path, PathBuf};
use crate::meta::{AccessConfig, AllowAgent, LoginMethod, VaultMeta, VaultSettings};
use crate::session;
use crate::vault::{list_vault_dirs, Vault, SVAULT_DIR};
pub fn run() -> Result<()> {
let mut terminal = ratatui::init();
let mut app = App::new();
let result = app.event_loop(&mut terminal);
ratatui::restore();
result
}
#[derive(Clone, Copy, PartialEq)]
pub enum MsgKind {
Info,
Ok,
Warn,
Error,
}
pub struct Status {
pub kind: MsgKind,
pub text: String,
}
#[derive(Clone)]
pub struct VaultRow {
pub name: String,
pub dir: PathBuf,
pub description: String,
pub unlocked: bool,
}
fn load_vaults() -> Vec<VaultRow> {
list_vault_dirs()
.into_iter()
.filter_map(|dir| {
let meta = VaultMeta::load_unverified(&dir).ok()?;
let unlocked = session::is_unlocked(&dir);
Some(VaultRow {
name: meta.name,
dir,
description: meta.description,
unlocked,
})
})
.collect()
}
#[derive(Clone, Copy)]
pub enum Pending {
List,
Secrets,
Settings,
}
pub struct CreateForm {
pub name: String,
pub description: String,
pub allow_mode: usize, pub allow_list: String,
pub rate_limit: String,
pub autolock: bool,
pub autolock_timer: String,
pub login_method: usize, pub passphrase: String,
pub confirm: String,
pub focus: usize,
pub error: Option<String>,
}
impl CreateForm {
const FIELDS: usize = 10;
fn new() -> Self {
let default_name = std::env::current_dir()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
.unwrap_or_else(|| "my-vault".to_string());
Self {
name: default_name,
description: String::new(),
allow_mode: 0,
allow_list: String::new(),
rate_limit: "10/hour".to_string(),
autolock: true,
autolock_timer: "1d".to_string(),
login_method: 0,
passphrase: String::new(),
confirm: String::new(),
focus: 0,
error: None,
}
}
fn text_field(&mut self) -> Option<&mut String> {
Some(match self.focus {
0 => &mut self.name,
1 => &mut self.description,
3 => &mut self.allow_list,
4 => &mut self.rate_limit,
6 => &mut self.autolock_timer,
8 => &mut self.passphrase,
9 => &mut self.confirm,
_ => return None,
})
}
}
pub struct SettingsForm {
pub vault_dir: PathBuf,
pub name: String,
pub description: String,
pub allow_mode: usize,
pub allow_list: String,
pub rate_limit: String,
pub autolock: bool,
pub autolock_timer: String,
pub login_method: usize,
pub focus: usize,
pub error: Option<String>,
}
impl SettingsForm {
const FIELDS: usize = 7;
fn from_meta(vault_dir: PathBuf, meta: VaultMeta) -> Self {
let (allow_mode, allow_list) = match &meta.access.allow_agent {
AllowAgent::Bool(true) => (0, String::new()),
AllowAgent::Bool(false) => (1, String::new()),
AllowAgent::List(v) => (2, v.join(", ")),
};
let login_method = match meta.settings.login_method {
LoginMethod::Passphrase => 0,
LoginMethod::Yubikey => 1,
LoginMethod::GoogleAuth => 2,
};
Self {
vault_dir,
name: meta.name,
description: meta.description,
allow_mode,
allow_list,
rate_limit: meta.access.rate_limit,
autolock: meta.settings.autolock,
autolock_timer: meta.settings.autolock_timer,
login_method,
focus: 0,
error: None,
}
}
fn text_field(&mut self) -> Option<&mut String> {
Some(match self.focus {
0 => &mut self.description,
2 => &mut self.allow_list,
3 => &mut self.rate_limit,
5 => &mut self.autolock_timer,
_ => return None,
})
}
}
pub struct UnlockForm {
pub vault_dir: PathBuf,
pub name: String,
pub passphrase: String,
pub error: Option<String>,
pub pending: Pending,
}
pub struct Reveal {
pub name: String,
pub value: String,
pub masked: bool,
}
pub struct SecretScreen {
pub vault_dir: PathBuf,
pub name: String,
pub secrets: Vec<String>,
pub list_state: ListState,
pub reveal: Option<Reveal>,
pub pending_delete: Option<String>,
}
impl SecretScreen {
fn selected_name(&self) -> Option<String> {
self.list_state
.selected()
.and_then(|i| self.secrets.get(i).cloned())
}
}
pub struct SecretAddForm {
pub vault_dir: PathBuf,
pub vault_name: String,
pub name: String,
pub value: String,
pub focus: usize, pub error: Option<String>,
}
pub enum Screen {
List,
Create(CreateForm),
Settings(SettingsForm),
Unlock(UnlockForm),
Secrets(SecretScreen),
SecretAdd(SecretAddForm),
}
pub struct App {
pub screen: Screen,
pub vaults: Vec<VaultRow>,
pub list_state: ListState,
pub status: Option<Status>,
pub should_quit: bool,
}
impl App {
fn new() -> Self {
let vaults = load_vaults();
let mut list_state = ListState::default();
if !vaults.is_empty() {
list_state.select(Some(0));
}
Self {
screen: Screen::List,
vaults,
list_state,
status: None,
should_quit: false,
}
}
fn event_loop(&mut self, terminal: &mut ratatui::DefaultTerminal) -> Result<()> {
while !self.should_quit {
terminal.draw(|frame| ui::draw(frame, self))?;
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
self.on_key(key)?;
}
}
}
Ok(())
}
fn set_status(&mut self, kind: MsgKind, text: impl Into<String>) {
self.status = Some(Status {
kind,
text: text.into(),
});
}
fn refresh_vaults(&mut self) {
self.vaults = load_vaults();
if self.vaults.is_empty() {
self.list_state.select(None);
} else {
let i = self
.list_state
.selected()
.unwrap_or(0)
.min(self.vaults.len() - 1);
self.list_state.select(Some(i));
}
}
fn selected_vault(&self) -> Option<VaultRow> {
self.list_state
.selected()
.and_then(|i| self.vaults.get(i).cloned())
}
fn select_next(&mut self) {
if self.vaults.is_empty() {
return;
}
let i = self
.list_state
.selected()
.map_or(0, |i| (i + 1) % self.vaults.len());
self.list_state.select(Some(i));
}
fn select_prev(&mut self) {
if self.vaults.is_empty() {
return;
}
let len = self.vaults.len();
let i = self
.list_state
.selected()
.map_or(0, |i| (i + len - 1) % len);
self.list_state.select(Some(i));
}
fn on_key(&mut self, key: KeyEvent) -> Result<()> {
let screen = std::mem::replace(&mut self.screen, Screen::List);
match screen {
Screen::List => self.key_list(key)?,
Screen::Create(form) => self.key_create(form, key)?,
Screen::Settings(form) => self.key_settings(form, key)?,
Screen::Unlock(form) => self.key_unlock(form, key)?,
Screen::Secrets(scr) => self.key_secrets(scr, key)?,
Screen::SecretAdd(form) => self.key_secret_add(form, key)?,
}
Ok(())
}
fn key_list(&mut self, key: KeyEvent) -> Result<()> {
self.screen = Screen::List;
match key.code {
KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true,
KeyCode::Down | KeyCode::Char('j') => self.select_next(),
KeyCode::Up | KeyCode::Char('k') => self.select_prev(),
KeyCode::Char('c') => self.screen = Screen::Create(CreateForm::new()),
KeyCode::Char('u') => self.unlock_selected()?,
KeyCode::Char('l') => self.lock_selected()?,
KeyCode::Char('s') => self.open_settings()?,
KeyCode::Enter => self.open_secrets()?,
_ => {}
}
Ok(())
}
fn unlock_selected(&mut self) -> Result<()> {
let Some(v) = self.selected_vault() else {
return Ok(());
};
if v.unlocked {
self.set_status(
MsgKind::Info,
format!("Vault '{}' is already unlocked", v.name),
);
} else {
self.screen = Screen::Unlock(UnlockForm {
vault_dir: v.dir,
name: v.name,
passphrase: String::new(),
error: None,
pending: Pending::List,
});
}
Ok(())
}
fn lock_selected(&mut self) -> Result<()> {
let Some(v) = self.selected_vault() else {
return Ok(());
};
if !v.unlocked {
self.set_status(
MsgKind::Info,
format!("Vault '{}' is already locked", v.name),
);
return Ok(());
}
session::lock(&v.dir)?;
self.set_status(MsgKind::Ok, format!("Vault '{}' locked", v.name));
self.refresh_vaults();
Ok(())
}
fn open_secrets(&mut self) -> Result<()> {
let Some(v) = self.selected_vault() else {
return Ok(());
};
if v.unlocked {
self.enter_secrets(&v.dir, &v.name)?;
} else {
self.screen = Screen::Unlock(UnlockForm {
vault_dir: v.dir,
name: v.name,
passphrase: String::new(),
error: None,
pending: Pending::Secrets,
});
}
Ok(())
}
fn open_settings(&mut self) -> Result<()> {
let Some(v) = self.selected_vault() else {
return Ok(());
};
if !v.unlocked {
self.screen = Screen::Unlock(UnlockForm {
vault_dir: v.dir,
name: v.name,
passphrase: String::new(),
error: None,
pending: Pending::Settings,
});
return Ok(());
}
let meta = VaultMeta::load_unverified(&v.dir)?;
self.screen = Screen::Settings(SettingsForm::from_meta(v.dir, meta));
Ok(())
}
fn enter_secrets(&mut self, dir: &Path, name: &str) -> Result<()> {
let Some(pass) = session::get_passphrase(dir) else {
self.screen = Screen::Unlock(UnlockForm {
vault_dir: dir.to_path_buf(),
name: name.to_string(),
passphrase: String::new(),
error: None,
pending: Pending::Secrets,
});
return Ok(());
};
match Vault::open(dir, &pass) {
Ok(vault) => {
let secrets = vault.list_secret_names().unwrap_or_default();
let mut list_state = ListState::default();
if !secrets.is_empty() {
list_state.select(Some(0));
}
self.screen = Screen::Secrets(SecretScreen {
vault_dir: dir.to_path_buf(),
name: name.to_string(),
secrets,
list_state,
reveal: None,
pending_delete: None,
});
}
Err(e) => self.set_status(MsgKind::Error, format!("Cannot open vault: {e}")),
}
Ok(())
}
fn key_create(&mut self, mut form: CreateForm, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => {
self.screen = Screen::List;
return Ok(());
}
KeyCode::Tab | KeyCode::Down => form.focus = (form.focus + 1) % CreateForm::FIELDS,
KeyCode::BackTab | KeyCode::Up => {
form.focus = (form.focus + CreateForm::FIELDS - 1) % CreateForm::FIELDS
}
KeyCode::Enter => {
if form.focus == CreateForm::FIELDS - 1 {
return self.submit_create(form);
}
form.focus += 1;
}
KeyCode::Left => create_adjust(&mut form, false),
KeyCode::Right => create_adjust(&mut form, true),
KeyCode::Backspace => {
if let Some(s) = form.text_field() {
s.pop();
}
}
KeyCode::Char(c) => {
if form.focus == 5 && c == ' ' {
form.autolock = !form.autolock; } else if let Some(s) = form.text_field() {
s.push(c);
form.error = None;
}
}
_ => {}
}
self.screen = Screen::Create(form);
Ok(())
}
fn submit_create(&mut self, mut form: CreateForm) -> Result<()> {
let name = form.name.trim().to_string();
if name.is_empty() {
form.error = Some("Name is required".into());
self.screen = Screen::Create(form);
return Ok(());
}
let vault_dir = PathBuf::from(SVAULT_DIR).join(&name);
if vault_dir.exists() {
form.error = Some(format!("Vault '{name}' already exists"));
self.screen = Screen::Create(form);
return Ok(());
}
if form.passphrase.is_empty() {
form.error = Some("Passphrase is required".into());
self.screen = Screen::Create(form);
return Ok(());
}
if form.passphrase != form.confirm {
form.error = Some("Passphrases do not match".into());
self.screen = Screen::Create(form);
return Ok(());
}
let allow_agent = match form.allow_mode {
0 => AllowAgent::Bool(true),
1 => AllowAgent::Bool(false),
_ => AllowAgent::List(parse_agents(&form.allow_list)),
};
let login_note = form.login_method != 0;
let meta = VaultMeta::new(
name.clone(),
form.description.clone(),
AccessConfig {
allow_agent,
rate_limit: form.rate_limit.clone(),
},
VaultSettings {
autolock: form.autolock,
autolock_timer: form.autolock_timer.clone(),
login_method: LoginMethod::Passphrase,
},
);
match Vault::init(&vault_dir, &form.passphrase, meta) {
Ok(_) => {
self.refresh_vaults();
if login_note {
self.set_status(
MsgKind::Warn,
format!("Vault '{name}' created (only passphrase is wired today)"),
);
} else {
self.set_status(MsgKind::Ok, format!("Vault '{name}' created"));
}
self.screen = Screen::List;
}
Err(e) => {
form.error = Some(format!("{e}"));
self.screen = Screen::Create(form);
}
}
Ok(())
}
fn key_settings(&mut self, mut form: SettingsForm, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => {
self.screen = Screen::List;
return Ok(());
}
KeyCode::Tab | KeyCode::Down => form.focus = (form.focus + 1) % SettingsForm::FIELDS,
KeyCode::BackTab | KeyCode::Up => {
form.focus = (form.focus + SettingsForm::FIELDS - 1) % SettingsForm::FIELDS
}
KeyCode::Enter => {
if form.focus == SettingsForm::FIELDS - 1 {
return self.submit_settings(form);
}
form.focus += 1;
}
KeyCode::Left => settings_adjust(&mut form, false),
KeyCode::Right => settings_adjust(&mut form, true),
KeyCode::Backspace => {
if let Some(s) = form.text_field() {
s.pop();
}
}
KeyCode::Char(c) => {
if form.focus == 4 && c == ' ' {
form.autolock = !form.autolock;
} else if let Some(s) = form.text_field() {
s.push(c);
form.error = None;
}
}
_ => {}
}
self.screen = Screen::Settings(form);
Ok(())
}
fn submit_settings(&mut self, mut form: SettingsForm) -> Result<()> {
let Some(pass) = session::get_passphrase(&form.vault_dir) else {
self.set_status(
MsgKind::Error,
"Vault is locked — unlock before editing settings",
);
self.screen = Screen::List;
return Ok(());
};
let vault = match Vault::open(&form.vault_dir, &pass) {
Ok(v) => v,
Err(e) => {
form.error = Some(format!("{e}"));
self.screen = Screen::Settings(form);
return Ok(());
}
};
let allow_agent = match form.allow_mode {
0 => AllowAgent::Bool(true),
1 => AllowAgent::Bool(false),
_ => AllowAgent::List(parse_agents(&form.allow_list)),
};
let login_note = form.login_method != 0;
let mut meta = vault.meta.clone();
meta.description = form.description.clone();
meta.access.allow_agent = allow_agent;
meta.access.rate_limit = form.rate_limit.clone();
meta.settings.autolock = form.autolock;
meta.settings.autolock_timer = form.autolock_timer.clone();
meta.settings.login_method = LoginMethod::Passphrase;
match vault.save_meta(&meta) {
Ok(_) => {
self.refresh_vaults();
if login_note {
self.set_status(
MsgKind::Warn,
format!(
"Settings for '{}' saved (only passphrase is wired today)",
form.name
),
);
} else {
self.set_status(MsgKind::Ok, format!("Settings for '{}' saved", form.name));
}
self.screen = Screen::List;
}
Err(e) => {
form.error = Some(format!("{e}"));
self.screen = Screen::Settings(form);
}
}
Ok(())
}
fn key_unlock(&mut self, mut form: UnlockForm, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => {
self.screen = Screen::List;
}
KeyCode::Backspace => {
form.passphrase.pop();
self.screen = Screen::Unlock(form);
}
KeyCode::Enter => match Vault::open(&form.vault_dir, &form.passphrase) {
Ok(_) => {
session::unlock(&form.vault_dir, &form.passphrase)?;
self.refresh_vaults();
self.set_status(MsgKind::Ok, format!("Vault '{}' unlocked", form.name));
match form.pending {
Pending::List => self.screen = Screen::List,
Pending::Secrets => self.enter_secrets(&form.vault_dir, &form.name)?,
Pending::Settings => {
let meta = VaultMeta::load_unverified(&form.vault_dir)?;
self.screen =
Screen::Settings(SettingsForm::from_meta(form.vault_dir, meta));
}
}
}
Err(_) => {
form.error = Some("Wrong passphrase".into());
form.passphrase.clear();
self.screen = Screen::Unlock(form);
}
},
KeyCode::Char(c) => {
form.passphrase.push(c);
form.error = None;
self.screen = Screen::Unlock(form);
}
_ => self.screen = Screen::Unlock(form),
}
Ok(())
}
fn key_secrets(&mut self, mut scr: SecretScreen, key: KeyEvent) -> Result<()> {
if scr.reveal.is_some() {
match key.code {
KeyCode::Char(' ') => {
if let Some(r) = scr.reveal.as_mut() {
r.masked = !r.masked;
}
}
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => scr.reveal = None,
_ => {}
}
self.screen = Screen::Secrets(scr);
return Ok(());
}
if let Some(target) = scr.pending_delete.clone() {
match key.code {
KeyCode::Char('y') => {
self.delete_secret(&mut scr, &target);
scr.pending_delete = None;
}
_ => scr.pending_delete = None,
}
self.screen = Screen::Secrets(scr);
return Ok(());
}
match key.code {
KeyCode::Esc | KeyCode::Char('b') => {
self.screen = Screen::List;
return Ok(());
}
KeyCode::Char('q') => {
self.should_quit = true;
}
KeyCode::Down | KeyCode::Char('j') => secrets_next(&mut scr),
KeyCode::Up | KeyCode::Char('k') => secrets_prev(&mut scr),
KeyCode::Char('a') => {
self.screen = Screen::SecretAdd(SecretAddForm {
vault_dir: scr.vault_dir.clone(),
vault_name: scr.name.clone(),
name: String::new(),
value: String::new(),
focus: 0,
error: None,
});
return Ok(());
}
KeyCode::Enter | KeyCode::Char('g') => self.reveal_secret(&mut scr),
KeyCode::Char('d') => {
if let Some(name) = scr.selected_name() {
scr.pending_delete = Some(name);
}
}
KeyCode::Char('l') => {
session::lock(&scr.vault_dir)?;
self.set_status(MsgKind::Ok, format!("Vault '{}' locked", scr.name));
self.refresh_vaults();
self.screen = Screen::List;
return Ok(());
}
_ => {}
}
self.screen = Screen::Secrets(scr);
Ok(())
}
fn reveal_secret(&mut self, scr: &mut SecretScreen) {
let Some(name) = scr.selected_name() else {
return;
};
let Some(pass) = session::get_passphrase(&scr.vault_dir) else {
self.set_status(MsgKind::Error, "Vault is locked");
return;
};
match Vault::open(&scr.vault_dir, &pass).and_then(|v| v.get_secret(&name)) {
Ok(Some(value)) => {
scr.reveal = Some(Reveal {
name,
value,
masked: true,
})
}
Ok(None) => self.set_status(MsgKind::Error, format!("Secret '{name}' not found")),
Err(e) => self.set_status(MsgKind::Error, format!("{e}")),
}
}
fn delete_secret(&mut self, scr: &mut SecretScreen, name: &str) {
let Some(pass) = session::get_passphrase(&scr.vault_dir) else {
self.set_status(MsgKind::Error, "Vault is locked");
return;
};
match Vault::open(&scr.vault_dir, &pass) {
Ok(vault) => match vault.remove_secret(name) {
Ok(true) => {
scr.secrets = vault.list_secret_names().unwrap_or_default();
let sel = if scr.secrets.is_empty() {
None
} else {
Some(
scr.list_state
.selected()
.unwrap_or(0)
.min(scr.secrets.len() - 1),
)
};
scr.list_state.select(sel);
self.set_status(MsgKind::Ok, format!("Secret '{name}' removed"));
}
Ok(false) => self.set_status(MsgKind::Error, format!("Secret '{name}' not found")),
Err(e) => self.set_status(MsgKind::Error, format!("{e}")),
},
Err(e) => self.set_status(MsgKind::Error, format!("{e}")),
}
}
fn key_secret_add(&mut self, mut form: SecretAddForm, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => {
let (dir, name) = (form.vault_dir.clone(), form.vault_name.clone());
self.enter_secrets(&dir, &name)?;
return Ok(());
}
KeyCode::Tab | KeyCode::Down => form.focus = (form.focus + 1) % 2,
KeyCode::BackTab | KeyCode::Up => form.focus = (form.focus + 1) % 2,
KeyCode::Enter => {
if form.focus == 0 {
form.focus = 1;
} else {
return self.submit_secret_add(form);
}
}
KeyCode::Backspace => {
if form.focus == 0 {
form.name.pop();
} else {
form.value.pop();
}
}
KeyCode::Char(c) => {
if form.focus == 0 {
form.name.push(c);
} else {
form.value.push(c);
}
form.error = None;
}
_ => {}
}
self.screen = Screen::SecretAdd(form);
Ok(())
}
fn submit_secret_add(&mut self, mut form: SecretAddForm) -> Result<()> {
if form.name.trim().is_empty() {
form.error = Some("Secret name is required".into());
self.screen = Screen::SecretAdd(form);
return Ok(());
}
let Some(pass) = session::get_passphrase(&form.vault_dir) else {
self.set_status(MsgKind::Error, "Vault is locked");
self.screen = Screen::List;
return Ok(());
};
match Vault::open(&form.vault_dir, &pass) {
Ok(vault) => match vault.add_secret(form.name.trim(), &form.value) {
Ok(_) => {
self.set_status(MsgKind::Ok, format!("Secret '{}' added", form.name.trim()));
let (dir, name) = (form.vault_dir.clone(), form.vault_name.clone());
self.enter_secrets(&dir, &name)?;
}
Err(e) => {
form.error = Some(format!("{e}"));
self.screen = Screen::SecretAdd(form);
}
},
Err(e) => {
form.error = Some(format!("{e}"));
self.screen = Screen::SecretAdd(form);
}
}
Ok(())
}
}
fn parse_agents(raw: &str) -> Vec<String> {
raw.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
fn create_adjust(form: &mut CreateForm, forward: bool) {
match form.focus {
2 => form.allow_mode = cycle(form.allow_mode, 3, forward),
5 => form.autolock = !form.autolock,
7 => form.login_method = cycle(form.login_method, 3, forward),
_ => {}
}
}
fn settings_adjust(form: &mut SettingsForm, forward: bool) {
match form.focus {
1 => form.allow_mode = cycle(form.allow_mode, 3, forward),
4 => form.autolock = !form.autolock,
6 => form.login_method = cycle(form.login_method, 3, forward),
_ => {}
}
}
fn cycle(current: usize, len: usize, forward: bool) -> usize {
if forward {
(current + 1) % len
} else {
(current + len - 1) % len
}
}
fn secrets_next(scr: &mut SecretScreen) {
if scr.secrets.is_empty() {
return;
}
let i = scr
.list_state
.selected()
.map_or(0, |i| (i + 1) % scr.secrets.len());
scr.list_state.select(Some(i));
}
fn secrets_prev(scr: &mut SecretScreen) {
if scr.secrets.is_empty() {
return;
}
let len = scr.secrets.len();
let i = scr.list_state.selected().map_or(0, |i| (i + len - 1) % len);
scr.list_state.select(Some(i));
}