use std::collections::HashMap;
use std::io::{self, BufRead, IsTerminal, Write};
use anyhow::{Context, Result};
use chrono::Utc;
use colored::Colorize;
use tsafe_core::{
audit::{AuditEntry, AuditLog},
env as tsenv,
errors::SafeError,
events::emit_event,
keyring_store, profile,
rbac::RbacProfile,
vault::Vault,
};
pub fn prompt_password(prompt: &str) -> Result<String> {
if let Ok(pw) = std::env::var("TSAFE_PASSWORD") {
return Ok(pw);
}
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() {
anyhow::bail!(
"no TTY detected and TSAFE_PASSWORD is not set.\n\
For non-interactive use set TSAFE_PASSWORD in the process environment:\n\
\n $env:TSAFE_PASSWORD = \"<vault password>\" # PowerShell\n\
export TSAFE_PASSWORD=\"<vault password>\" # bash\n\
\nTo create a new vault non-interactively:\n\
\n $env:TSAFE_PASSWORD = \"<new password>\"; tsafe init"
);
}
rpassword::prompt_password(prompt).context("failed to read password from terminal")
}
pub fn prompt_password_confirmed() -> Result<String> {
if std::env::var("TSAFE_PASSWORD").is_ok() {
return prompt_password("New vault password: ");
}
let pw1 = prompt_password("New vault password: ")?;
let pw2 = prompt_password("Confirm password: ")?;
if pw1 != pw2 {
anyhow::bail!("passwords do not match");
}
Ok(pw1)
}
fn quick_unlock_cooldown_path(profile_name: &str) -> std::path::PathBuf {
profile::app_state_dir()
.join("quick-unlock-cooldown")
.join(format!("{profile_name}.txt"))
}
#[allow(dead_code)] fn quick_unlock_retry_blocked(profile_name: &str) -> bool {
if !profile::get_auto_quick_unlock() {
return true;
}
let path = quick_unlock_cooldown_path(profile_name);
let Ok(raw) = std::fs::read_to_string(&path) else {
return false;
};
let Ok(until_epoch) = raw.trim().parse::<i64>() else {
let _ = std::fs::remove_file(path);
return false;
};
let now_epoch = Utc::now().timestamp();
if now_epoch < until_epoch {
return true;
}
let _ = std::fs::remove_file(path);
false
}
fn note_quick_unlock_retry_cooldown(profile_name: &str) {
let seconds = profile::get_quick_unlock_retry_cooldown_secs();
if seconds == 0 {
return;
}
let path = quick_unlock_cooldown_path(profile_name);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let until_epoch = Utc::now().timestamp() + seconds as i64;
let _ = std::fs::write(path, until_epoch.to_string());
}
fn clear_quick_unlock_retry_cooldown(profile_name: &str) {
let _ = std::fs::remove_file(quick_unlock_cooldown_path(profile_name));
}
#[allow(dead_code)] fn try_auto_quick_unlock_password(profile_name: &str) -> Option<String> {
if quick_unlock_retry_blocked(profile_name) {
return None;
}
match keyring_store::retrieve_password(profile_name) {
Ok(Some(pw)) => {
clear_quick_unlock_retry_cooldown(profile_name);
Some(pw.trim_end_matches(['\n', '\r']).to_string())
}
Ok(None) => {
clear_quick_unlock_retry_cooldown(profile_name);
None
}
Err(_) => {
note_quick_unlock_retry_cooldown(profile_name);
None
}
}
}
pub fn open_vault(profile: &str) -> Result<Vault> {
open_vault_with_access_profile(profile, RbacProfile::ReadWrite)
}
pub fn open_vault_with_access_profile(profile: &str, access_profile: RbacProfile) -> Result<Vault> {
profile::validate_profile_name(profile)?;
let path = profile::vault_path(profile);
if !path.exists() {
if !access_profile.allows_write() {
anyhow::bail!("vault not found: {}", path.display());
}
return first_run_create_vault(profile, &path);
}
if Vault::is_team_vault(&path) {
return open_team_vault_with_access_profile(profile, &path, access_profile);
}
if let Ok(password) = std::env::var("TSAFE_PASSWORD") {
return Vault::open_with_access_profile(&path, password.as_bytes(), access_profile)
.map_err(|e| match e {
SafeError::DecryptionFailed => {
anyhow::anyhow!("wrong password for profile '{profile}' from TSAFE_PASSWORD")
}
other => anyhow::anyhow!("{other}"),
});
}
if let Ok(Some(pw)) = tsafe_core::agent::request_password_from_agent(profile) {
return Vault::open_with_access_profile(&path, pw.as_bytes(), access_profile).map_err(
|e| match e {
SafeError::DecryptionFailed => {
anyhow::anyhow!("agent provided wrong password for '{profile}'")
}
other => anyhow::anyhow!("{other}"),
},
);
}
if let Some(pw) = get_password_from_main(profile) {
return Vault::open_with_access_profile(&path, pw.as_bytes(), access_profile).map_err(
|e| match e {
SafeError::DecryptionFailed => anyhow::anyhow!(
"main vault has a password stored for '{profile}' but it is wrong — \
update it with: tsafe -p main set profile-passwords/{profile} <new-password>"
),
other => anyhow::anyhow!("{other}"),
},
);
}
#[cfg(all(windows, feature = "biometric"))]
{
use crate::windows_hello::{request_windows_hello_verification, BiometricChallengeError};
match request_windows_hello_verification("tsafe vault unlock") {
Ok(true) | Err(BiometricChallengeError::NoWindowHandle)
| Err(BiometricChallengeError::NotEnrolled)
| Err(BiometricChallengeError::NotConfigured) => {
}
Ok(false) => {
anyhow::bail!("Windows Hello verification denied for '{profile}'")
}
Err(BiometricChallengeError::Canceled) => {
anyhow::bail!("Windows Hello verification canceled for '{profile}'")
}
Err(e) => {
eprintln!("{} Windows Hello challenge failed ({e}) — proceeding without challenge", "warn:".yellow());
}
}
}
#[cfg(feature = "biometric")]
if let Some(pw) = crate::cmd_biometric::retrieve_biometric_password(profile)
.ok()
.flatten()
{
let pw_str = pw.trim_end_matches(['\n', '\r']).to_string();
match Vault::open_with_access_profile(&path, pw_str.as_bytes(), access_profile) {
Ok(v) => {
clear_quick_unlock_retry_cooldown(profile);
return Ok(v);
}
Err(SafeError::DecryptionFailed) => {
note_quick_unlock_retry_cooldown(profile);
let stale = crate::cmd_biometric::classify_biometric_unlock_error(
SafeError::DecryptionFailed,
);
eprintln!("{} {stale}", "warn:".yellow());
eprintln!(
"{} After logging in, run: tsafe biometric re-enroll",
"hint:".cyan()
);
}
Err(other) => return Err(anyhow::anyhow!("{other}")),
}
}
#[cfg(not(feature = "biometric"))]
if let Some(pw) = try_auto_quick_unlock_password(profile) {
match Vault::open_with_access_profile(&path, pw.as_bytes(), access_profile) {
Ok(v) => {
clear_quick_unlock_retry_cooldown(profile);
return Ok(v);
}
Err(SafeError::DecryptionFailed) => {
note_quick_unlock_retry_cooldown(profile);
eprintln!(
"{} Stored keyring password for '{}' is outdated — falling back to prompt.",
"warn:".yellow(),
profile
);
}
Err(other) => return Err(anyhow::anyhow!("{other}")),
}
}
let password = prompt_password(&format!("Password for profile '{profile}': "))?;
let vault =
Vault::open_with_access_profile(&path, password.as_bytes(), access_profile).map_err(
|e| match e {
SafeError::DecryptionFailed => anyhow::anyhow!(
"wrong password for profile '{profile}'\n\
\n If you use quick unlock, check its status: tsafe biometric status\
\n To reset the stored password: tsafe biometric disable && tsafe biometric enable\
\n If you forgot the master password, see: tsafe explain vault-recovery"
),
other => anyhow::anyhow!("{other}"),
},
)?;
if std::env::var("TSAFE_PASSWORD").is_err() {
tsafe_core::keyring_store::store_password(profile, &password).ok();
}
Ok(vault)
}
#[allow(dead_code)] pub fn open_vault_noninteractive(profile: &str) -> Result<Option<Vault>> {
open_vault_noninteractive_with_access_profile(profile, RbacProfile::ReadWrite)
}
#[allow(dead_code)] pub fn open_vault_noninteractive_with_access_profile(
profile: &str,
access_profile: RbacProfile,
) -> Result<Option<Vault>> {
profile::validate_profile_name(profile)?;
let path = profile::vault_path(profile);
if !path.exists() {
return Ok(None);
}
if Vault::is_team_vault(&path) {
return open_team_vault_with_access_profile(profile, &path, access_profile).map(Some);
}
if let Ok(password) = std::env::var("TSAFE_PASSWORD") {
return match Vault::open_with_access_profile(&path, password.as_bytes(), access_profile) {
Ok(vault) => Ok(Some(vault)),
Err(SafeError::DecryptionFailed) => Ok(None),
Err(other) => Err(anyhow::anyhow!("{other}")),
};
}
if let Ok(Some(pw)) = tsafe_core::agent::request_password_from_agent(profile) {
return match Vault::open_with_access_profile(&path, pw.as_bytes(), access_profile) {
Ok(vault) => Ok(Some(vault)),
Err(SafeError::DecryptionFailed) => Ok(None),
Err(other) => Err(anyhow::anyhow!("{other}")),
};
}
if let Some(pw) = get_password_from_main(profile) {
return match Vault::open_with_access_profile(&path, pw.as_bytes(), access_profile) {
Ok(vault) => Ok(Some(vault)),
Err(SafeError::DecryptionFailed) => Ok(None),
Err(other) => Err(anyhow::anyhow!("{other}")),
};
}
Ok(None)
}
pub fn get_password_from_main(profile: &str) -> Option<String> {
if profile == "main" {
return None;
}
let main_path = profile::vault_path("main");
if !main_path.exists() {
return None;
}
let main_pw: String =
if let Ok(Some(pw)) = tsafe_core::agent::request_password_from_agent("main") {
pw.to_string()
} else if let Ok(pw) = std::env::var("TSAFE_PASSWORD") {
pw
} else {
return None;
};
let main_vault = Vault::open(&main_path, main_pw.as_bytes()).ok()?;
let key = format!("profile-passwords/{profile}");
main_vault.get(&key).ok().map(|z| z.to_string())
}
pub fn open_vault_for_backup(target_profile: &str) -> Result<Vault> {
profile::validate_profile_name(target_profile)?;
let path = profile::vault_path(target_profile);
if !path.exists() {
anyhow::bail!("vault for profile '{target_profile}' does not exist");
}
if Vault::is_team_vault(&path) {
return open_team_vault_with_access_profile(target_profile, &path, RbacProfile::ReadWrite);
}
if let Ok(password) = std::env::var("TSAFE_PASSWORD") {
return Vault::open_with_access_profile(&path, password.as_bytes(), RbacProfile::ReadWrite)
.map_err(|e| anyhow::anyhow!("{e}"));
}
if let Ok(Some(pw)) = tsafe_core::agent::request_password_from_agent(target_profile) {
return Vault::open_with_access_profile(&path, pw.as_bytes(), RbacProfile::ReadWrite)
.map_err(|e| anyhow::anyhow!("{e}"));
}
if let Some(pw) = get_password_from_main(target_profile) {
return Vault::open_with_access_profile(&path, pw.as_bytes(), RbacProfile::ReadWrite)
.map_err(|e| anyhow::anyhow!("{e}"));
}
use std::io::IsTerminal;
if std::io::stdin().is_terminal() && std::env::var("TSAFE_PASSWORD").is_err() {
let password = prompt_password(&format!(
"Password for '{target_profile}' vault (to store backup of the new profile's master password): "
))?;
return Vault::open_with_access_profile(&path, password.as_bytes(), RbacProfile::ReadWrite)
.map_err(|e| match e {
SafeError::DecryptionFailed => anyhow::anyhow!("wrong password"),
other => anyhow::anyhow!("{other}"),
});
}
anyhow::bail!(
"could not open backup vault '{target_profile}' non-interactively — unlock it first \
(e.g. `tsafe agent unlock --profile {target_profile}`)"
)
}
pub fn backup_new_profile_password_if_configured(
created_profile: &str,
new_password: &str,
) -> Result<()> {
let Some(target) = profile::get_backup_new_profile_passwords_to() else {
return Ok(());
};
if target == created_profile {
return Ok(());
}
profile::validate_profile_name(&target).map_err(|e| anyhow::anyhow!("{e}"))?;
if !profile::vault_path(&target).exists() {
eprintln!(
"{} Config expects master-password backups in '{}' but that vault does not exist yet — skipping backup.",
"warn:".yellow().bold(),
target
);
eprintln!(" Create it with: tsafe --profile {target} init");
return Ok(());
}
let mut v = match open_vault_for_backup(&target) {
Ok(v) => v,
Err(e) => {
eprintln!(
"{} Could not open backup vault '{}' to store the new profile password: {:#}",
"warn:".yellow().bold(),
target,
e
);
eprintln!(
" After '{target}' is unlocked, add the password with: tsafe --profile {target} set profile-passwords/{created_profile}"
);
return Ok(());
}
};
let key = format!("profile-passwords/{created_profile}");
v.set(&key, new_password, HashMap::new())
.map_err(|e| anyhow::anyhow!("{e}"))?;
v.save().map_err(|e| anyhow::anyhow!("{e}"))?;
audit(&target)
.append(&AuditEntry::success(&target, "set", Some(&key)))
.ok();
println!(
"{} Also saved master password for '{}' under {} in the '{}' vault \
(config: backup_new_profile_passwords_to).",
"✓".green(),
created_profile,
key,
target
);
println!(" Keep that vault strictly protected — it can unlock other profiles via bridging.");
Ok(())
}
pub fn open_team_vault_with_access_profile(
profile: &str,
path: &std::path::Path,
access_profile: RbacProfile,
) -> Result<Vault> {
use tsafe_core::{age_crypto, team};
let identity_path = profile::resolve_age_identity_path(profile);
if !identity_path.exists() {
anyhow::bail!(
"Team vault detected but no age identity found at {}\n\
Generate one with: tsafe --profile {profile} team keygen\n\
Or set TSAFE_AGE_IDENTITY to your identity file.",
identity_path.display()
);
}
let identities = age_crypto::load_identities(&identity_path)
.map_err(|e| anyhow::anyhow!("failed to load age identity: {e}"))?;
let json = std::fs::read_to_string(path)?;
let file: tsafe_core::vault::VaultFile = serde_json::from_str(&json)?;
let dek = team::unwrap_dek(&file, &identities).map_err(|e| {
anyhow::anyhow!("cannot decrypt team vault — your identity may not be a member: {e}")
})?;
Vault::open_with_key_with_access_profile(path, dek, access_profile)
.map_err(|e| anyhow::anyhow!("{e}"))
}
pub fn first_run_create_vault(profile: &str, path: &std::path::Path) -> Result<Vault> {
if std::env::var("TSAFE_PASSWORD").is_ok() {
let password = prompt_password("")?; let vault = Vault::create(path, password.as_bytes()).context("failed to create vault")?;
audit(profile)
.append(&AuditEntry::success(profile, "init", None))
.ok();
emit_event(profile, "init", None);
eprintln!("{} Vault for profile '{profile}' created.", "✓".green());
backup_new_profile_password_if_configured(profile, &password)?;
return Ok(vault);
}
eprintln!("{} No vault found for profile '{profile}'.", "i".blue());
if !io::stdin().is_terminal() {
anyhow::bail!("Vault not created. Run `tsafe --profile {profile} init` to set up.");
}
use inquire::Confirm;
let confirmed = Confirm::new("Create it now?")
.with_default(true)
.prompt()
.unwrap_or(false);
if !confirmed {
anyhow::bail!("Vault not created. Run `tsafe --profile {profile} init` to set up.");
}
let password = prompt_password_confirmed()?;
let vault = Vault::create(path, password.as_bytes()).context("failed to create vault")?;
audit(profile)
.append(&AuditEntry::success(profile, "init", None))
.ok();
emit_event(profile, "init", None);
eprintln!("{} Vault for profile '{profile}' created.", "✓".green());
backup_new_profile_password_if_configured(profile, &password)?;
onboarding_biometric_unlock(profile, &password)?;
Ok(vault)
}
pub fn audit(profile: &str) -> AuditLog {
AuditLog::new(&profile::audit_log_path(profile))
}
pub fn parse_tag_filters(filters: &[String]) -> Vec<(&str, &str)> {
filters
.iter()
.filter_map(|f| {
let mut parts = f.splitn(2, '=');
Some((parts.next()?, parts.next()?))
})
.collect()
}
pub fn parse_tags_map(tags: &[String]) -> HashMap<String, String> {
tags.iter()
.filter_map(|t| {
let mut p = t.splitn(2, '=');
Some((p.next()?.to_string(), p.next()?.to_string()))
})
.collect()
}
pub fn warn_if_expired(key: &str, tags: &HashMap<String, String>) {
if let Some(exp) = tags.get("expires") {
if let Ok(date) = chrono::NaiveDate::parse_from_str(exp, "%Y-%m-%d") {
let today = Utc::now().date_naive();
if date < today {
eprintln!(
"{} '{}' expired on {} (tag expires={})",
"warn:".yellow().bold(),
key,
exp,
exp
);
} else {
let days = (date - today).num_days();
if days <= 30 {
eprintln!(
"{} '{}' expires in {} day(s) on {}",
"warn:".yellow().bold(),
key,
days,
exp
);
}
}
}
}
}
pub fn set_clipboard(value: &str) -> Result<()> {
#[cfg(target_os = "windows")]
{
let mut child = std::process::Command::new("clip")
.stdin(std::process::Stdio::piped())
.spawn()
.context("could not launch clip.exe — clipboard unavailable")?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(value.as_bytes()).ok();
}
child.wait().ok();
}
#[cfg(target_os = "macos")]
{
let mut child = std::process::Command::new("pbcopy")
.stdin(std::process::Stdio::piped())
.spawn()
.context("could not launch pbcopy — clipboard unavailable")?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(value.as_bytes()).ok();
}
child.wait().ok();
}
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
{
let mut child = std::process::Command::new("xclip")
.args(["-selection", "clipboard"])
.stdin(std::process::Stdio::piped())
.spawn()
.context("could not launch xclip — install xclip or copy manually")?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(value.as_bytes()).ok();
}
child.wait().ok();
}
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(30));
#[cfg(target_os = "windows")]
{
let _ = std::process::Command::new("cmd")
.args(["/c", "echo off | clip"])
.status();
}
#[cfg(target_os = "macos")]
{
if let Ok(mut child) = std::process::Command::new("pbcopy")
.stdin(std::process::Stdio::piped())
.spawn()
{
drop(child.stdin.take()); let _ = child.wait();
}
}
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
{
if let Ok(mut child) = std::process::Command::new("xclip")
.args(["-selection", "clipboard"])
.stdin(std::process::Stdio::piped())
.spawn()
{
drop(child.stdin.take());
let _ = child.wait();
}
}
});
Ok(())
}
#[derive(Clone, Copy)]
pub enum MasterPasswordKeychainPrompt {
NewVault,
AfterRotate,
}
pub fn offer_os_keychain_for_master_password(
profile: &str,
password: &str,
ctx: MasterPasswordKeychainPrompt,
) -> Result<()> {
use std::io::IsTerminal;
if std::env::var("TSAFE_PASSWORD").is_ok() || !std::io::stdin().is_terminal() {
return Ok(());
}
match ctx {
MasterPasswordKeychainPrompt::NewVault => {
if keyring_store::has_password(profile) {
return Ok(());
}
}
MasterPasswordKeychainPrompt::AfterRotate => {
if std::env::var("TSAFE_NEW_MASTER_PASSWORD").is_ok() {
return Ok(());
}
}
}
let store_ok = keyring_store::probe_credential_store().is_ok();
println!();
if store_ok {
match ctx {
MasterPasswordKeychainPrompt::NewVault => {
println!("{}", "Quick unlock — recommended".cyan().bold());
println!(
" Store your master password in this device's OS credential store so you can unlock \
with Touch ID, Face ID, Windows Hello, or your device PIN — without typing it every time."
);
}
MasterPasswordKeychainPrompt::AfterRotate => {
println!("{}", "Update OS keychain for quick unlock".cyan().bold());
println!(
" Your vault master password just changed. Save the new password to the OS credential store \
so Touch ID / empty-password unlock keeps working? (Any old stored password no longer matches.)"
);
}
}
println!(
" You can change this later with `tsafe biometric disable` or `tsafe biometric enable`."
);
println!();
for attempt in 0..4 {
if attempt > 0 {
eprint!(" Please answer Y, n, or l. ");
}
eprint!(
"{} [Y] Set up now [n] Not now [l] Later: ",
"→".cyan().bold()
);
io::stderr().flush().ok();
let mut line = String::new();
let n = io::stdin().lock().read_line(&mut line)?;
if n == 0 {
println!();
println!(" When you're ready: tsafe --profile {profile} biometric enable");
return Ok(());
}
match parse_onboarding_biometric_choice(line.as_str()) {
Some('y') => {
keyring_store::store_password(profile, password)
.map_err(|e| anyhow::anyhow!("{e}"))?;
println!();
println!(
"{} Master password saved for quick unlock (profile '{}').",
"✓".green(),
profile
);
println!(
" Leave the password empty when prompted (CLI or TUI) to use OS unlock, \
or run `tsafe biometric status`."
);
return Ok(());
}
Some('n') => {
println!();
println!(
" Understood — you'll enter your master password when unlocking the vault."
);
println!(
" To enable quick unlock later: tsafe --profile {profile} biometric enable"
);
return Ok(());
}
Some('l') => {
println!();
println!(" No problem — enable it when you're ready.");
println!(" tsafe --profile {profile} biometric enable");
return Ok(());
}
Some(_) | None => continue,
}
}
println!();
println!(" To enable quick unlock later: tsafe --profile {profile} biometric enable");
return Ok(());
}
println!("{}", "Quick unlock".cyan().bold());
println!(
" This environment doesn't appear to have a working OS credential store \
(common over SSH or in CI)."
);
println!(
" On this machine with a desktop session, run: {}",
format!("tsafe --profile {profile} biometric enable").cyan()
);
println!();
Ok(())
}
pub fn onboarding_biometric_unlock(profile: &str, password: &str) -> Result<()> {
offer_os_keychain_for_master_password(profile, password, MasterPasswordKeychainPrompt::NewVault)
}
pub fn parse_onboarding_biometric_choice(line: &str) -> Option<char> {
let t = line.trim().to_lowercase();
if t.is_empty() {
return Some('y');
}
match t.chars().next()? {
'y' => Some('y'),
'n' => Some('n'),
'l' => Some('l'),
_ => None,
}
}
#[cfg(test)]
#[allow(clippy::items_after_test_module)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn parse_tag_filters_splits_key_value_pairs() {
let filters = vec!["env=prod".to_string(), "team=backend".to_string()];
let result = parse_tag_filters(&filters);
assert_eq!(result, vec![("env", "prod"), ("team", "backend")]);
}
#[test]
fn parse_tag_filters_skips_entries_without_equals() {
let filters = vec!["no-equals".to_string(), "key=val".to_string()];
let result = parse_tag_filters(&filters);
assert_eq!(result, vec![("key", "val")]);
}
#[test]
fn parse_tag_filters_empty_input_returns_empty() {
assert!(parse_tag_filters(&[]).is_empty());
}
#[test]
fn parse_tag_filters_value_may_contain_equals() {
let filters = vec!["url=http://example.com?a=b&c=d".to_string()];
let result = parse_tag_filters(&filters);
assert_eq!(result, vec![("url", "http://example.com?a=b&c=d")]);
}
#[test]
fn parse_tags_map_produces_correct_map() {
let tags = vec!["env=prod".to_string(), "team=backend".to_string()];
let map = parse_tags_map(&tags);
assert_eq!(map.get("env").map(String::as_str), Some("prod"));
assert_eq!(map.get("team").map(String::as_str), Some("backend"));
assert_eq!(map.len(), 2);
}
#[test]
fn parse_tags_map_skips_entries_without_equals() {
let tags = vec!["not-a-tag".to_string()];
assert!(parse_tags_map(&tags).is_empty());
}
#[test]
fn parse_tags_map_empty_input_is_empty() {
assert!(parse_tags_map(&[]).is_empty());
}
#[test]
fn parse_tags_map_value_may_contain_equals() {
let tags = vec!["url=https://example.com?foo=bar".to_string()];
let map = parse_tags_map(&tags);
assert_eq!(
map.get("url").map(String::as_str),
Some("https://example.com?foo=bar")
);
}
#[test]
fn biometric_choice_empty_or_whitespace_means_yes() {
assert_eq!(parse_onboarding_biometric_choice(""), Some('y'));
assert_eq!(parse_onboarding_biometric_choice("\n"), Some('y'));
assert_eq!(parse_onboarding_biometric_choice(" "), Some('y'));
}
#[test]
fn biometric_choice_y_variants_return_yes() {
assert_eq!(parse_onboarding_biometric_choice("y"), Some('y'));
assert_eq!(parse_onboarding_biometric_choice("Y"), Some('y'));
assert_eq!(parse_onboarding_biometric_choice("Y\n"), Some('y'));
}
#[test]
fn biometric_choice_n_variants_return_no() {
assert_eq!(parse_onboarding_biometric_choice("n"), Some('n'));
assert_eq!(parse_onboarding_biometric_choice("N"), Some('n'));
assert_eq!(parse_onboarding_biometric_choice("N\n"), Some('n'));
}
#[test]
fn biometric_choice_l_variants_return_later() {
assert_eq!(parse_onboarding_biometric_choice("l"), Some('l'));
assert_eq!(parse_onboarding_biometric_choice("L"), Some('l'));
}
#[test]
fn biometric_choice_unknown_char_returns_none() {
assert_eq!(parse_onboarding_biometric_choice("x"), None);
assert_eq!(parse_onboarding_biometric_choice("q"), None);
assert_eq!(parse_onboarding_biometric_choice("?"), None);
}
#[test]
fn prompt_password_returns_env_var_value() {
temp_env::with_var("TSAFE_PASSWORD", Some("my-secret-pw"), || {
let pw = prompt_password("ignored prompt").unwrap();
assert_eq!(pw, "my-secret-pw");
});
}
#[test]
fn prompt_password_confirmed_skips_confirm_when_env_var_set() {
temp_env::with_var("TSAFE_PASSWORD", Some("env-pw"), || {
let pw = prompt_password_confirmed().unwrap();
assert_eq!(pw, "env-pw");
});
}
#[test]
fn get_password_from_main_returns_none_for_main_profile() {
assert!(get_password_from_main("main").is_none());
}
#[test]
fn open_vault_with_access_profile_returns_read_only_handle() {
let dir = tempdir().unwrap();
let vault_dir = dir.path().join("vaults");
std::fs::create_dir_all(&vault_dir).unwrap();
temp_env::with_vars(
[
("TSAFE_VAULT_DIR", Some(vault_dir.as_os_str())),
("TSAFE_PASSWORD", Some(std::ffi::OsStr::new("test-pw"))),
],
|| {
let vault_path = profile::vault_path("default");
{
let mut vault = Vault::create(&vault_path, b"test-pw").unwrap();
vault.set("API_KEY", "secret", HashMap::new()).unwrap();
vault.save().unwrap();
}
let opened =
open_vault_with_access_profile("default", RbacProfile::ReadOnly).unwrap();
assert_eq!(opened.access_profile(), RbacProfile::ReadOnly);
assert_eq!(opened.get("API_KEY").unwrap().as_str(), "secret");
},
);
}
#[test]
fn open_vault_with_access_profile_read_only_missing_vault_does_not_create() {
let dir = tempdir().unwrap();
let vault_dir = dir.path().join("vaults");
std::fs::create_dir_all(&vault_dir).unwrap();
temp_env::with_vars(
[
("TSAFE_VAULT_DIR", Some(vault_dir.as_os_str())),
("TSAFE_PASSWORD", Some(std::ffi::OsStr::new("test-pw"))),
],
|| {
let missing_path = profile::vault_path("default");
let err = match open_vault_with_access_profile("default", RbacProfile::ReadOnly) {
Ok(_) => panic!("expected missing read-only vault open to fail"),
Err(err) => err,
};
assert!(err.to_string().contains("vault not found"));
assert!(!missing_path.exists());
},
);
}
}
pub fn onboarding_import_dotenv(profile: &str, password: &str) -> Result<()> {
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let mut dotenv_files: Vec<std::path::PathBuf> = std::fs::read_dir(&cwd)
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let name = e.file_name();
let s = name.to_string_lossy().into_owned();
if s == ".env" || s.starts_with(".env.") {
Some(e.path())
} else {
None
}
})
.collect();
dotenv_files.sort();
if dotenv_files.is_empty() {
return Ok(());
}
println!(
"\n{} Found .env file(s) in the current directory:",
"→".cyan().bold()
);
for f in &dotenv_files {
println!(" {}", f.display());
}
use std::io::IsTerminal as _;
let non_interactive =
std::env::var("TSAFE_PASSWORD").is_ok() || !std::io::stdin().is_terminal();
if non_interactive {
return Ok(());
}
eprint!("Import them into the new vault? [y/N]: ");
io::stderr().flush().ok();
let mut resp = String::new();
io::stdin().lock().read_line(&mut resp)?;
if !resp.trim().eq_ignore_ascii_case("y") {
return Ok(());
}
let vault_path = profile::vault_path(profile);
let mut vault = Vault::open(&vault_path, password.as_bytes())
.context("could not reopen vault for onboarding import")?;
let mut total = 0usize;
for path in &dotenv_files {
match tsenv::parse_dotenv(path) {
Err(e) => eprintln!(
"{} skipping {}: {e}",
"warn:".yellow().bold(),
path.display()
),
Ok(pairs) => {
let mut n = 0usize;
for (k, v) in &pairs {
if vault.set(k, v, HashMap::new()).is_ok() {
audit(profile)
.append(&AuditEntry::success(profile, "import", Some(k)))
.ok();
n += 1;
}
}
println!(" {} {} secret(s) from {}", "✓".green(), n, path.display());
total += n;
}
}
}
println!(
"{} Onboarding complete — {total} secret(s) imported",
"✓".green().bold()
);
Ok(())
}
pub fn build_http_agent() -> ureq::Agent {
ureq::AgentBuilder::new()
.timeout_connect(std::time::Duration::from_secs(10))
.timeout_read(std::time::Duration::from_secs(30))
.timeout_write(std::time::Duration::from_secs(30))
.build()
}