use chrono::{DateTime, Utc};
use std::collections::HashMap;
use std::path::PathBuf;
use std::str::FromStr;
use std::time::{Duration, Instant};
use tsafe_core::errors::SafeError;
use tsafe_core::profile::{get_default_profile, list_profiles, vault_path};
use tsafe_core::vault::Vault;
use zeroize::Zeroizing;
use crate::clipboard::{ClipboardBackend, ClipboardError, ClipboardOutcome, SystemClipboard};
use tsafe_core::audit::{AuditClipboardContext, AuditContext, AuditEntry, AuditLog};
use tsafe_core::profile::audit_log_path;
pub type SensitiveString = Zeroizing<String>;
pub(crate) fn empty_sensitive() -> SensitiveString {
Zeroizing::new(String::new())
}
pub(crate) fn sensitive_string(value: impl Into<String>) -> SensitiveString {
Zeroizing::new(value.into())
}
pub fn sensitive_string_for_test(value: impl Into<String>) -> SensitiveString {
sensitive_string(value)
}
pub const CLIPBOARD_TTL: Duration = Duration::from_secs(30);
pub const REVEAL_TTL: Duration = Duration::from_secs(60);
pub struct Reveal {
pub value: SensitiveString,
pub revealed_at: Instant,
}
pub const UNDO_TTL: Duration = Duration::from_secs(5);
pub struct PendingUndo {
pub key: String,
pub value: SensitiveString,
pub tags: HashMap<String, String>,
pub deleted_at: Instant,
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum Theme {
#[default]
Dark,
Light,
HighContrast,
}
impl FromStr for Theme {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s {
"light" => Theme::Light,
"high-contrast" => Theme::HighContrast,
_ => Theme::Dark,
})
}
}
impl Theme {
pub fn as_str(self) -> &'static str {
match self {
Theme::Dark => "dark",
Theme::Light => "light",
Theme::HighContrast => "high-contrast",
}
}
pub fn cycle(self) -> Self {
match self {
Theme::Dark => Theme::Light,
Theme::Light => Theme::HighContrast,
Theme::HighContrast => Theme::Dark,
}
}
}
#[derive(Debug, Clone)]
pub enum ListEntry {
Namespace {
name: String,
count: usize,
collapsed: bool,
},
Key(String),
}
#[derive(Debug, Clone, PartialEq)]
pub enum Screen {
Login,
Dashboard,
EditSecret { is_new: bool },
ConfirmDelete,
RotatePassword,
SnapshotRestore,
AuditLog,
NewProfile { step: u8 },
Quitting,
History { key: String },
MoveSecret { source: String },
NsBulk { copy: bool, from: String },
}
impl Screen {
pub fn footer_hints(&self) -> &'static [(&'static str, &'static str)] {
match self {
Screen::Login => &[
("Tab", "profile ⇄ password"),
("\u{2191}\u{2193}", "profile row"),
("Enter", "unlock"),
("Ctrl-C", "quit"),
],
Screen::Dashboard => &[
("n", "new"),
("e/Enter", "edit"),
("d", "del"),
("r", "reveal"),
("y", "copy"),
("m", "move"),
("h", "history"),
("R", "rotate pw"),
("S", "snapshot"),
("/", "search"),
("a", "audit"),
("T", "theme"),
("p", "switch"),
("q", "quit"),
("?", "help"),
],
Screen::EditSecret { .. } => &[
("Tab", "switch field"),
("Enter", "save"),
("Esc", "cancel"),
],
Screen::ConfirmDelete => &[("y", "confirm"), ("n/Esc", "cancel")],
Screen::RotatePassword => &[("Enter", "confirm"), ("Esc", "cancel")],
Screen::SnapshotRestore => &[
("\u{2191}\u{2193}", "navigate"),
("Enter", "restore"),
("Esc", "cancel"),
],
Screen::AuditLog => &[("\u{2191}\u{2193}/j/k", "scroll"), ("q/Esc", "back")],
Screen::NewProfile { .. } => &[("Enter", "next"), ("Esc", "back")],
Screen::History { .. } => &[("\u{2191}\u{2193}/j/k", "scroll"), ("q/Esc", "back")],
Screen::MoveSecret { .. } => &[("Enter", "confirm"), ("Esc", "cancel")],
Screen::NsBulk { .. } => &[("Enter", "confirm"), ("Esc", "cancel")],
Screen::Quitting => &[],
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum EditFocus {
Key,
Value,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LoginFocus {
Profile,
#[default]
Password,
}
pub struct App {
pub screen: Screen,
pub profiles: Vec<String>,
pub profile_cursor: usize,
pub active_profile: Option<String>,
pub(crate) session_vault: Option<Vault>,
pub secret_keys: Vec<String>,
pub secret_cursor: usize,
pub search_mode: bool,
pub search_query: String,
pub password_buf: SensitiveString,
pub login_error: Option<String>,
pub(crate) login_session_password: Option<SensitiveString>,
pub(crate) pending_keyring_master_password: Option<SensitiveString>,
pub login_focus: LoginFocus,
pub edit_key: String,
pub edit_value: String,
pub edit_focus: EditFocus,
pub edit_error: Option<String>,
pub revealed: HashMap<String, Reveal>,
pub rotate_step: u8,
pub rotate_new1: SensitiveString,
pub rotate_new2: SensitiveString,
pub rotate_error: Option<String>,
pub snapshot_paths: Vec<PathBuf>,
pub snapshot_cursor: usize,
pub audit_lines: Vec<String>,
pub audit_scroll: usize,
pub new_profile_name: String,
pub new_profile_pw1: SensitiveString,
pub new_profile_pw2: SensitiveString,
pub new_profile_error: Option<String>,
pub status_message: Option<String>,
pub help_visible: bool,
pub pinned_keys: std::collections::HashSet<String>,
pub collapsed_namespaces: std::collections::HashSet<String>,
pub update_available: Option<String>,
pub update_rx: Option<std::sync::mpsc::Receiver<Option<String>>>,
pub theme: Theme,
pub history_entries: Vec<(usize, DateTime<Utc>)>,
pub history_scroll: usize,
pub mv_dest_buf: String,
pub mv_error: Option<String>,
pub(crate) clipboard: Box<dyn ClipboardBackend + Send>,
pub copied_at: Option<Instant>,
pub copied_value: Option<SensitiveString>,
pub pending_undo: Option<PendingUndo>,
pub(crate) last_undo_status_secs: Option<u64>,
}
impl App {
fn initial_profile_cursor(profiles: &[String]) -> usize {
if profiles.is_empty() {
return 0;
}
let default_profile = get_default_profile();
profiles
.iter()
.position(|p| p.eq_ignore_ascii_case(&default_profile))
.unwrap_or(0)
}
pub fn new_for_test(profiles: Vec<String>, screen: Screen) -> Self {
let profile_cursor = Self::initial_profile_cursor(&profiles);
let login_focus = if profiles.is_empty() {
LoginFocus::Profile
} else {
LoginFocus::Password
};
App {
screen,
profiles,
profile_cursor,
active_profile: None,
session_vault: None,
secret_keys: Vec::new(),
secret_cursor: 0,
search_mode: false,
search_query: String::new(),
password_buf: empty_sensitive(),
login_error: None,
login_session_password: None,
pending_keyring_master_password: None,
login_focus,
edit_key: String::new(),
edit_value: String::new(),
edit_focus: EditFocus::Key,
edit_error: None,
revealed: HashMap::new(),
rotate_step: 0,
rotate_new1: empty_sensitive(),
rotate_new2: empty_sensitive(),
rotate_error: None,
snapshot_paths: Vec::new(),
snapshot_cursor: 0,
audit_lines: Vec::new(),
audit_scroll: 0,
new_profile_name: String::new(),
new_profile_pw1: empty_sensitive(),
new_profile_pw2: empty_sensitive(),
new_profile_error: None,
status_message: None,
help_visible: false,
pinned_keys: std::collections::HashSet::new(),
collapsed_namespaces: std::collections::HashSet::new(),
update_available: None,
update_rx: None,
theme: Theme::Dark,
history_entries: Vec::new(),
history_scroll: 0,
mv_dest_buf: String::new(),
mv_error: None,
clipboard: Box::new(SystemClipboard::new()),
copied_at: None,
copied_value: None,
pending_undo: None,
last_undo_status_secs: None,
}
}
pub fn new() -> Self {
let profiles = list_profiles().unwrap_or_default();
let profile_cursor = Self::initial_profile_cursor(&profiles);
let login_focus = if profiles.is_empty() {
LoginFocus::Profile
} else {
LoginFocus::Password
};
App {
screen: Screen::Login,
profiles,
profile_cursor,
active_profile: None,
session_vault: None,
secret_keys: Vec::new(),
secret_cursor: 0,
search_mode: false,
search_query: String::new(),
password_buf: empty_sensitive(),
login_error: None,
login_session_password: None,
pending_keyring_master_password: None,
login_focus,
edit_key: String::new(),
edit_value: String::new(),
edit_focus: EditFocus::Key,
edit_error: None,
revealed: HashMap::new(),
rotate_step: 0,
rotate_new1: empty_sensitive(),
rotate_new2: empty_sensitive(),
rotate_error: None,
snapshot_paths: Vec::new(),
snapshot_cursor: 0,
audit_lines: Vec::new(),
audit_scroll: 0,
new_profile_name: String::new(),
new_profile_pw1: empty_sensitive(),
new_profile_pw2: empty_sensitive(),
new_profile_error: None,
status_message: None,
help_visible: false,
pinned_keys: std::collections::HashSet::new(),
collapsed_namespaces: std::collections::HashSet::new(),
update_available: None,
update_rx: {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(tsafe_core::update::check_for_update());
});
Some(rx)
},
theme: App::load_theme(),
history_entries: Vec::new(),
history_scroll: 0,
mv_dest_buf: String::new(),
mv_error: None,
clipboard: Box::new(SystemClipboard::new()),
copied_at: None,
copied_value: None,
pending_undo: None,
last_undo_status_secs: None,
}
}
pub fn poll_update_check(&mut self) {
if let Some(rx) = &self.update_rx {
if let Ok(result) = rx.try_recv() {
self.update_available = result;
self.update_rx = None;
}
}
}
pub fn current_profile_name(&self) -> Option<&str> {
self.profiles.get(self.profile_cursor).map(|s| s.as_str())
}
pub fn try_login(&mut self) {
let profile = match self.current_profile_name() {
Some(p) => p.to_owned(),
None => {
crate::tui_debug::log("try_login: no profile (empty list)");
self.login_error = Some("No profiles found — press 'n' to create one.".into());
let _ = std::mem::take(&mut self.password_buf);
return;
}
};
crate::tui_debug::log(format!("try_login: profile={profile:?}"));
let had_typed_password = !self.password_buf.is_empty();
let mut password = std::mem::take(&mut self.password_buf);
let mut unlock_via_os_store = false;
if password.is_empty() {
if let Some(p) = self.pending_keyring_master_password.take() {
if p.is_empty() {
crate::tui_debug::log("try_login: OS store returned empty secret after trim");
self.login_error = Some(
"OS credential store returned an empty password. \
Run: tsafe biometric disable && tsafe biometric enable"
.into(),
);
return;
}
password = p;
unlock_via_os_store = true;
} else {
crate::tui_debug::log(
"try_login: empty field but pending_keyring is None (did run_loop skip OS retrieve?)",
);
self.login_error = Some(
"Enter your master password. \
Tip: run `tsafe biometric enable`, leave the field empty, press Enter for Touch ID / OS unlock."
.into(),
);
return;
}
}
if unlock_via_os_store {
password = sensitive_string(password.trim_end_matches(['\n', '\r']).to_string());
}
let vault_file_path = vault_path(&profile);
if Vault::is_team_vault(&vault_file_path) {
crate::tui_debug::log(format!(
"try_login: team vault at {} — TUI password path not used",
vault_file_path.display()
));
self.login_error = Some(
"This profile is a team (age) vault. The TUI only supports password-based vaults — use the CLI (e.g. tsafe list) or set TSAFE_PASSWORD."
.into(),
);
if had_typed_password {
self.password_buf = password;
}
return;
}
crate::tui_debug::log(format!(
"try_login: Vault::open path={} unlock_via_os_store={unlock_via_os_store} master_password_byte_len={}",
vault_file_path.display(),
password.len()
));
match Vault::open(&vault_file_path, password.as_bytes()) {
Ok(vault) => {
crate::tui_debug::log("try_login: Vault::open OK -> Dashboard");
self.login_session_password = Some(password);
self.activate_session(profile, vault);
}
Err(e) => {
crate::tui_debug::log(format!("try_login: Vault::open ERR {e}"));
self.login_session_password = None;
self.session_vault = None;
let msg = if unlock_via_os_store && matches!(e, SafeError::DecryptionFailed) {
"Login failed: decryption failed — the password in OS unlock does not match this vault \
(common after `tsafe rotate`). Refresh stored credential: \
tsafe biometric disable && tsafe biometric enable"
.to_string()
} else {
format!("Login failed: {e}")
};
self.login_error = Some(msg);
if had_typed_password {
self.password_buf = password;
}
}
}
}
pub fn activate_session(&mut self, profile: String, vault: Vault) {
self.reload_from_vault(&vault);
self.active_profile = Some(profile);
self.session_vault = Some(vault);
self.secret_cursor = 0;
self.revealed.clear();
self.search_mode = false;
self.search_query.clear();
self.login_error = None;
self.screen = Screen::Dashboard;
}
pub fn clear_active_session(&mut self) {
self.active_profile = None;
self.session_vault = None;
self.secret_keys.clear();
self.secret_cursor = 0;
self.revealed.clear();
self.pending_undo = None;
self.last_undo_status_secs = None;
self.search_mode = false;
self.search_query.clear();
self.snapshot_paths.clear();
self.snapshot_cursor = 0;
self.history_entries.clear();
self.history_scroll = 0;
self.pinned_keys.clear();
}
pub fn reload_from_vault(&mut self, vault: &Vault) {
self.pinned_keys = vault
.file()
.secrets
.iter()
.filter(|(_, e)| e.tags.get("pinned").map(|v| v == "true").unwrap_or(false))
.map(|(k, _)| k.clone())
.collect();
let mut keys: Vec<String> = vault.list().iter().map(|s| s.to_string()).collect();
keys.sort_by(|a, b| {
let a_pin = self.pinned_keys.contains(a);
let b_pin = self.pinned_keys.contains(b);
b_pin.cmp(&a_pin).then(a.cmp(b))
});
self.revealed.retain(|k, _| keys.contains(k));
self.secret_keys = keys;
self.collapse_all_namespaces();
if self.secret_cursor >= self.secret_keys.len() {
self.secret_cursor = self.secret_keys.len().saturating_sub(1);
}
}
pub fn filtered_keys(&self) -> Vec<&str> {
if self.search_query.is_empty() {
return self.secret_keys.iter().map(|s| s.as_str()).collect();
}
use nucleo::pattern::{CaseMatching, Normalization, Pattern};
use nucleo::{Config, Matcher, Utf32Str};
let mut matcher = Matcher::new(Config::DEFAULT);
let pattern = Pattern::parse(
&self.search_query,
CaseMatching::Ignore,
Normalization::Smart,
);
let mut scored: Vec<(usize, u32)> = self
.secret_keys
.iter()
.enumerate()
.filter_map(|(i, k)| {
let mut buf: Vec<char> = Vec::new();
let haystack = Utf32Str::new(k.as_str(), &mut buf);
pattern.score(haystack, &mut matcher).map(|s| (i, s))
})
.collect();
scored.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
scored
.iter()
.map(|(i, _)| self.secret_keys[*i].as_str())
.collect()
}
pub fn visible_entries(&self) -> Vec<ListEntry> {
let filtered = self.filtered_keys();
let searching = !self.search_query.is_empty();
let mut entries: Vec<ListEntry> = Vec::new();
for k in filtered.iter().filter(|k| !k.contains('/')) {
entries.push(ListEntry::Key(k.to_string()));
}
let mut ns_groups: Vec<(String, Vec<String>)> = Vec::new();
for k in filtered.iter().filter(|k| k.contains('/')) {
let ns = k.split('/').next().unwrap_or("").to_string();
if let Some(group) = ns_groups.iter_mut().find(|(n, _)| n == &ns) {
group.1.push(k.to_string());
} else {
ns_groups.push((ns, vec![k.to_string()]));
}
}
for (ns, keys) in ns_groups {
let count = keys.len();
let collapsed = !searching && self.collapsed_namespaces.contains(&ns);
entries.push(ListEntry::Namespace {
name: ns,
count,
collapsed,
});
if !collapsed {
for k in keys {
entries.push(ListEntry::Key(k));
}
}
}
entries
}
pub fn selected_key(&self) -> Option<String> {
match self.visible_entries().get(self.secret_cursor) {
Some(ListEntry::Key(k)) => Some(k.clone()),
_ => None,
}
}
pub fn selected_namespace(&self) -> Option<String> {
match self.visible_entries().get(self.secret_cursor) {
Some(ListEntry::Namespace { name, .. }) => Some(name.clone()),
_ => None,
}
}
pub fn collapse_all_namespaces(&mut self) {
self.collapsed_namespaces = self
.secret_keys
.iter()
.filter_map(|k| k.split('/').next().filter(|_| k.contains('/')))
.map(|ns| ns.to_string())
.collect();
}
pub fn set_status(&mut self, msg: impl Into<String>) {
self.status_message = Some(msg.into());
}
pub fn clear_status(&mut self) {
self.status_message = None;
}
pub fn load_theme() -> Theme {
let path = tsafe_core::profile::config_path();
if let Ok(contents) = std::fs::read_to_string(&path) {
if let Ok(cfg) = serde_json::from_str::<serde_json::Value>(&contents) {
if let Some(s) = cfg.get("tui.theme").and_then(|v| v.as_str()) {
return s.parse().unwrap_or_default();
}
}
}
Theme::default()
}
pub fn save_theme(&self) {
let path = tsafe_core::profile::config_path();
let mut cfg: serde_json::Map<String, serde_json::Value> = std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
cfg.insert(
"tui.theme".to_string(),
serde_json::Value::String(self.theme.as_str().to_string()),
);
if let Ok(json) = serde_json::to_string_pretty(&serde_json::Value::Object(cfg)) {
let tmp = path.with_extension("json.tmp");
let _ = std::fs::write(&tmp, &json);
let _ = std::fs::rename(&tmp, &path);
}
}
pub fn set_clipboard_backend(&mut self, backend: Box<dyn ClipboardBackend + Send>) {
self.clipboard = backend;
}
pub fn copy_to_clipboard(
&mut self,
key: &str,
value: SensitiveString,
) -> Result<ClipboardOutcome, ClipboardError> {
let outcome = self.clipboard.set_secret_text(value.as_str())?;
self.copied_at = Some(Instant::now());
self.copied_value = Some(value);
self.set_status(format!(
"Copied '{key}' (clears in {}s)",
CLIPBOARD_TTL.as_secs()
));
Ok(outcome)
}
pub fn maybe_expire_clipboard(&mut self) {
let Some(at) = self.copied_at else {
return;
};
if at.elapsed() < CLIPBOARD_TTL {
return;
}
let original = self.copied_value.take();
self.copied_at = None;
let Some(original) = original else {
return;
};
let profile = self.active_profile.clone();
match self.clipboard.get_text() {
Ok(current) if current == *original => {
let clear_result = self.clipboard.set_text("");
if matches!(self.status_message.as_deref(), Some(m) if m.starts_with("Copied ")) {
self.set_status("Clipboard cleared.");
}
emit_clipboard_audit(
profile.as_deref(),
"clipboard.cleared",
None,
clear_result.as_ref().err().map(|e| e.to_string()),
Some(true),
);
}
Ok(_) => {
emit_clipboard_audit(
profile.as_deref(),
"clipboard.preserved",
None,
None,
Some(false),
);
}
Err(e) => {
emit_clipboard_audit(
profile.as_deref(),
"clipboard.preserved",
None,
Some(e.to_string()),
None,
);
}
}
}
pub fn maybe_expire_reveals(&mut self) {
if self.revealed.is_empty() {
return;
}
let now = Instant::now();
if !self
.revealed
.values()
.any(|r| now.duration_since(r.revealed_at) >= REVEAL_TTL)
{
return;
}
let profile = self.active_profile.clone();
let mut count: usize = 0;
self.revealed.retain(|key, reveal| {
if now.duration_since(reveal.revealed_at) >= REVEAL_TTL {
emit_reveal_audit(profile.as_deref(), "secret.reveal_expired", Some(key));
count += 1;
false
} else {
true
}
});
if count > 0 {
self.set_status(format!("{count} reveal(s) auto-concealed"));
}
}
pub fn maybe_expire_undo(&mut self) {
let Some(undo) = self.pending_undo.as_ref() else {
return;
};
let elapsed = undo.deleted_at.elapsed();
let key = undo.key.clone();
if elapsed >= UNDO_TTL {
let profile = self.active_profile.clone();
self.pending_undo = None;
self.last_undo_status_secs = None;
emit_delete_audit(
profile.as_deref(),
"secret.delete_finalized",
Some(&key),
None,
);
self.set_status(format!("Delete of '{key}' is now permanent."));
return;
}
let remaining = UNDO_TTL.saturating_sub(elapsed).as_secs() + 1;
if self.last_undo_status_secs == Some(remaining) {
return;
}
self.last_undo_status_secs = Some(remaining);
self.set_status(format!("Deleted '{key}' — press u to undo ({remaining}s)"));
}
pub fn finalize_pending_undo_for_key(&mut self, key: &str) {
if matches!(&self.pending_undo, Some(u) if u.key == key) {
let profile = self.active_profile.clone();
self.pending_undo = None;
self.last_undo_status_secs = None;
emit_delete_audit(
profile.as_deref(),
"secret.delete_finalized",
Some(key),
None,
);
}
}
}
fn emit_clipboard_audit(
profile: Option<&str>,
op: &'static str,
key: Option<&str>,
reason: Option<String>,
cleared_verified: Option<bool>,
) {
let Some(profile) = profile else {
return;
};
let entry = AuditEntry::success(profile, op, key).with_context(AuditContext::from_clipboard(
AuditClipboardContext {
ttl_secs: CLIPBOARD_TTL.as_secs(),
reason,
excluded_from_history: None,
cleared_verified,
},
));
AuditLog::new(&audit_log_path(profile)).append(&entry).ok();
}
pub(crate) fn emit_reveal_audit(profile: Option<&str>, op: &'static str, key: Option<&str>) {
let Some(profile) = profile else {
return;
};
let entry = AuditEntry::success(profile, op, key).with_context(AuditContext::from_reveal(
tsafe_core::audit::AuditRevealContext {
ttl_secs: REVEAL_TTL.as_secs(),
},
));
AuditLog::new(&audit_log_path(profile)).append(&entry).ok();
}
pub(crate) fn emit_delete_audit(
profile: Option<&str>,
op: &'static str,
key: Option<&str>,
reason: Option<String>,
) {
let Some(profile) = profile else {
return;
};
let entry = match reason {
None => AuditEntry::success(profile, op, key),
Some(msg) => AuditEntry::failure(profile, op, key, &msg),
};
AuditLog::new(&audit_log_path(profile)).append(&entry).ok();
}
impl Drop for App {
fn drop(&mut self) {
let Some(original) = self.copied_value.take() else {
return;
};
if let Ok(current) = self.clipboard.get_text() {
if current == *original {
let _ = self.clipboard.set_text("");
}
}
}
}
impl std::fmt::Debug for App {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("App")
.field("screen", &self.screen)
.field("active_profile", &self.active_profile)
.field("secret_cursor", &self.secret_cursor)
.field("status_message", &self.status_message)
.field("copied_at", &self.copied_at)
.field("copied_value", &"<redacted>")
.field(
"revealed",
&format_args!("<{} entries redacted>", self.revealed.len()),
)
.field(
"pending_undo",
match &self.pending_undo {
Some(_) => &"<pending — value redacted>",
None => &"None",
},
)
.finish_non_exhaustive()
}
}
impl Default for App {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use super::*;
use serial_test::serial;
use tsafe_core::vault::Vault;
static VAULT_ENV_LOCK: Mutex<()> = Mutex::new(());
fn app_with_keys(keys: &[&str]) -> App {
let mut app = App {
screen: Screen::Dashboard,
profiles: vec!["test".into()],
profile_cursor: 0,
active_profile: Some("test".into()),
session_vault: None,
secret_keys: keys.iter().map(|s| s.to_string()).collect(),
secret_cursor: 0,
search_mode: false,
search_query: String::new(),
password_buf: empty_sensitive(),
login_error: None,
login_session_password: None,
pending_keyring_master_password: None,
login_focus: LoginFocus::Password,
edit_key: String::new(),
edit_value: String::new(),
edit_focus: EditFocus::Key,
edit_error: None,
revealed: HashMap::new(),
rotate_step: 0,
rotate_new1: empty_sensitive(),
rotate_new2: empty_sensitive(),
rotate_error: None,
snapshot_paths: Vec::new(),
snapshot_cursor: 0,
audit_lines: Vec::new(),
audit_scroll: 0,
new_profile_name: String::new(),
new_profile_pw1: empty_sensitive(),
new_profile_pw2: empty_sensitive(),
new_profile_error: None,
status_message: None,
help_visible: false,
pinned_keys: std::collections::HashSet::new(),
collapsed_namespaces: std::collections::HashSet::new(),
update_available: None,
update_rx: None,
theme: Theme::Dark,
history_entries: Vec::new(),
history_scroll: 0,
mv_dest_buf: String::new(),
mv_error: None,
clipboard: Box::new(SystemClipboard::new()),
copied_at: None,
copied_value: None,
pending_undo: None,
last_undo_status_secs: None,
};
app.secret_cursor = 0;
app
}
#[test]
fn filtered_keys_returns_all_when_no_query() {
let app = app_with_keys(&["AAA", "BBB", "CCC"]);
let keys = app.filtered_keys();
assert_eq!(keys, vec!["AAA", "BBB", "CCC"]);
}
#[test]
fn filtered_keys_fuzzy_matches() {
let mut app = app_with_keys(&["DB_PASSWORD", "API_KEY", "DB_HOST"]);
app.search_query = "db".into();
let keys = app.filtered_keys();
assert!(
keys.contains(&"DB_PASSWORD"),
"expected DB_PASSWORD in results"
);
assert!(keys.contains(&"DB_HOST"), "expected DB_HOST in results");
assert!(!keys.contains(&"API_KEY"), "API_KEY should not match 'db'");
assert_eq!(keys.len(), 2);
}
#[test]
fn filtered_keys_returns_empty_on_no_match() {
let mut app = app_with_keys(&["DB_PASSWORD", "API_KEY"]);
app.search_query = "zzz".into();
assert!(app.filtered_keys().is_empty());
}
#[test]
fn selected_key_in_bounds() {
let mut app = app_with_keys(&["A", "B", "C"]);
app.secret_cursor = 1;
assert_eq!(app.selected_key(), Some("B".into()));
}
#[test]
fn selected_key_empty_list() {
let app = app_with_keys(&[]);
assert_eq!(app.selected_key(), None);
}
#[test]
fn selected_key_out_of_bounds_returns_none() {
let mut app = app_with_keys(&["A", "B"]);
app.secret_cursor = 99;
assert_eq!(app.selected_key(), None);
}
#[test]
fn set_and_clear_status() {
let mut app = app_with_keys(&[]);
app.set_status("hello");
assert_eq!(app.status_message.as_deref(), Some("hello"));
app.clear_status();
assert!(app.status_message.is_none());
}
#[test]
#[serial]
fn stale_os_unlock_password_mentions_biometric_refresh() {
let _g = VAULT_ENV_LOCK.lock().unwrap();
let dir = tempfile::TempDir::new().unwrap();
let vaults = dir.path().join("vaults");
std::fs::create_dir_all(&vaults).unwrap();
let vault_file = vaults.join("ptest.vault");
Vault::create(&vault_file, b"actual-master-secret").unwrap();
let mut app = App {
screen: Screen::Login,
profiles: vec!["ptest".into()],
profile_cursor: 0,
active_profile: None,
session_vault: None,
secret_keys: Vec::new(),
secret_cursor: 0,
search_mode: false,
search_query: String::new(),
password_buf: empty_sensitive(),
login_error: None,
login_session_password: None,
pending_keyring_master_password: Some(sensitive_string("wrong-keychain-password")),
login_focus: LoginFocus::Password,
edit_key: String::new(),
edit_value: String::new(),
edit_focus: EditFocus::Key,
edit_error: None,
revealed: HashMap::new(),
rotate_step: 0,
rotate_new1: empty_sensitive(),
rotate_new2: empty_sensitive(),
rotate_error: None,
snapshot_paths: Vec::new(),
snapshot_cursor: 0,
audit_lines: Vec::new(),
audit_scroll: 0,
new_profile_name: String::new(),
new_profile_pw1: empty_sensitive(),
new_profile_pw2: empty_sensitive(),
new_profile_error: None,
status_message: None,
help_visible: false,
pinned_keys: std::collections::HashSet::new(),
collapsed_namespaces: std::collections::HashSet::new(),
update_available: None,
update_rx: None,
theme: Theme::Dark,
history_entries: Vec::new(),
history_scroll: 0,
mv_dest_buf: String::new(),
mv_error: None,
clipboard: Box::new(SystemClipboard::new()),
copied_at: None,
copied_value: None,
pending_undo: None,
last_undo_status_secs: None,
};
temp_env::with_var("TSAFE_VAULT_DIR", Some(vaults.as_os_str()), || {
app.try_login();
});
let err = app
.login_error
.as_ref()
.expect("expected login error when OS-stored password mismatches vault");
assert!(
err.contains("biometric disable") && err.contains("biometric enable"),
"unexpected message: {err}"
);
assert_eq!(app.screen, Screen::Login);
}
#[test]
#[serial]
fn os_store_password_trims_trailing_newline() {
let _g = VAULT_ENV_LOCK.lock().unwrap();
let dir = tempfile::TempDir::new().unwrap();
let vaults = dir.path().join("vaults");
std::fs::create_dir_all(&vaults).unwrap();
let vault_file = vaults.join("ntrim.vault");
Vault::create(&vault_file, b"correct-pw").unwrap();
let mut app = App {
screen: Screen::Login,
profiles: vec!["ntrim".into()],
profile_cursor: 0,
active_profile: None,
session_vault: None,
secret_keys: Vec::new(),
secret_cursor: 0,
search_mode: false,
search_query: String::new(),
password_buf: empty_sensitive(),
login_error: None,
login_session_password: None,
pending_keyring_master_password: Some(sensitive_string("correct-pw\n")),
login_focus: LoginFocus::Password,
edit_key: String::new(),
edit_value: String::new(),
edit_focus: EditFocus::Key,
edit_error: None,
revealed: HashMap::new(),
rotate_step: 0,
rotate_new1: empty_sensitive(),
rotate_new2: empty_sensitive(),
rotate_error: None,
snapshot_paths: Vec::new(),
snapshot_cursor: 0,
audit_lines: Vec::new(),
audit_scroll: 0,
new_profile_name: String::new(),
new_profile_pw1: empty_sensitive(),
new_profile_pw2: empty_sensitive(),
new_profile_error: None,
status_message: None,
help_visible: false,
pinned_keys: std::collections::HashSet::new(),
collapsed_namespaces: std::collections::HashSet::new(),
update_available: None,
update_rx: None,
theme: Theme::Dark,
history_entries: Vec::new(),
history_scroll: 0,
mv_dest_buf: String::new(),
mv_error: None,
clipboard: Box::new(SystemClipboard::new()),
copied_at: None,
copied_value: None,
pending_undo: None,
last_undo_status_secs: None,
};
temp_env::with_var("TSAFE_VAULT_DIR", Some(vaults.as_os_str()), || {
app.try_login();
});
assert_eq!(app.screen, Screen::Dashboard, "{:?}", app.login_error);
assert!(app.session_vault.is_some());
}
#[test]
#[serial]
fn initial_profile_cursor_prefers_configured_default_profile() {
let _g = VAULT_ENV_LOCK.lock().unwrap();
let dir = tempfile::TempDir::new().unwrap();
let profiles = vec!["alpha".to_string(), "work".to_string()];
temp_env::with_var("TSAFE_VAULT_DIR", Some(dir.path()), || {
tsafe_core::profile::set_default_profile("work").unwrap();
assert_eq!(App::initial_profile_cursor(&profiles), 1);
});
}
#[test]
#[serial]
fn initial_profile_cursor_falls_back_to_first_when_default_missing() {
let _g = VAULT_ENV_LOCK.lock().unwrap();
let dir = tempfile::TempDir::new().unwrap();
let profiles = vec!["alpha".to_string(), "work".to_string()];
temp_env::with_var("TSAFE_VAULT_DIR", Some(dir.path()), || {
tsafe_core::profile::set_default_profile("prod").unwrap();
assert_eq!(App::initial_profile_cursor(&profiles), 0);
});
}
}