use std::path::PathBuf;
use directories::{ProjectDirs, UserDirs};
use serde::{Deserialize, Serialize};
use crate::errors::{SafeError, SafeResult};
const EXEC_CONFIG_KEY: &str = "exec";
const EXEC_MODE_KEY: &str = "mode";
const EXEC_CUSTOM_INHERIT_KEY: &str = "custom_inherit";
const EXEC_CUSTOM_DENY_DANGEROUS_ENV_KEY: &str = "custom_deny_dangerous_env";
const EXEC_AUTO_REDACT_OUTPUT_KEY: &str = "auto_redact_output";
const EXEC_EXTRA_SENSITIVE_PARENT_VARS_KEY: &str = "extra_sensitive_parent_vars";
const QUICK_UNLOCK_CONFIG_KEY: &str = "quick_unlock";
const QUICK_UNLOCK_AUTO_RETRIEVE_KEY: &str = "auto_retrieve";
const QUICK_UNLOCK_RETRY_COOLDOWN_SECS_KEY: &str = "retry_cooldown_secs";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ExecMode {
Standard,
Hardened,
Custom,
}
impl ExecMode {
pub fn as_str(self) -> &'static str {
match self {
Self::Standard => "standard",
Self::Hardened => "hardened",
Self::Custom => "custom",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ExecCustomInheritMode {
Full,
Minimal,
Clean,
}
impl ExecCustomInheritMode {
pub fn as_str(self) -> &'static str {
match self {
Self::Full => "full",
Self::Minimal => "minimal",
Self::Clean => "clean",
}
}
}
fn project_dirs() -> Option<ProjectDirs> {
ProjectDirs::from("", "", "tsafe")
}
fn platform_data_root() -> PathBuf {
project_dirs()
.map(|d| d.data_dir().to_path_buf())
.unwrap_or_else(|| PathBuf::from(".tsafe"))
}
fn platform_config_root() -> PathBuf {
project_dirs()
.map(|d| d.config_dir().to_path_buf())
.unwrap_or_else(platform_data_root)
}
fn platform_state_root() -> PathBuf {
project_dirs()
.and_then(|d| d.state_dir().map(|p| p.to_path_buf()))
.unwrap_or_else(platform_data_root)
}
fn vault_location_from_env() -> Option<PathBuf> {
std::env::var("TSAFE_VAULT_DIR").ok().map(PathBuf::from)
}
pub fn app_data_dir() -> PathBuf {
if let Some(v) = vault_location_from_env() {
v.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."))
} else {
platform_data_root()
}
}
pub fn app_state_dir() -> PathBuf {
if let Some(v) = vault_location_from_env() {
v.parent()
.map(|p| p.join("state"))
.unwrap_or_else(|| PathBuf::from(".tsafe-state"))
} else {
platform_state_root()
}
}
pub fn vault_dir() -> PathBuf {
if let Some(v) = vault_location_from_env() {
return v;
}
platform_data_root().join("vaults")
}
pub fn audit_dir() -> PathBuf {
app_state_dir().join("audit")
}
pub fn config_path() -> PathBuf {
if let Some(v) = vault_location_from_env() {
return v
.parent()
.map(|p| p.join("config.json"))
.unwrap_or_else(|| PathBuf::from(".tsafe/config.json"));
}
platform_config_root().join("config.json")
}
pub fn get_default_profile() -> String {
let path = 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(name) = cfg.get("default_profile").and_then(|v| v.as_str()) {
if !name.is_empty() {
return name.to_string();
}
}
}
}
"default".to_string()
}
fn read_config_map() -> serde_json::Map<String, serde_json::Value> {
let path = config_path();
std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn write_config_map(cfg: &serde_json::Map<String, serde_json::Value>) -> SafeResult<()> {
let path = config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(SafeError::Io)?;
}
let json = serde_json::to_string_pretty(&serde_json::Value::Object(cfg.clone()))?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, json).map_err(SafeError::Io)?;
std::fs::rename(&tmp, &path).map_err(SafeError::Io)?;
Ok(())
}
fn ensure_object_slot<'a>(
cfg: &'a mut serde_json::Map<String, serde_json::Value>,
key: &str,
) -> &'a mut serde_json::Map<String, serde_json::Value> {
if !matches!(cfg.get(key), Some(serde_json::Value::Object(_))) {
cfg.insert(
key.to_string(),
serde_json::Value::Object(Default::default()),
);
}
cfg.get_mut(key)
.and_then(serde_json::Value::as_object_mut)
.expect("object slot must exist")
}
fn exec_config(
cfg: &serde_json::Map<String, serde_json::Value>,
) -> Option<&serde_json::Map<String, serde_json::Value>> {
cfg.get(EXEC_CONFIG_KEY)
.and_then(serde_json::Value::as_object)
}
fn quick_unlock_config(
cfg: &serde_json::Map<String, serde_json::Value>,
) -> Option<&serde_json::Map<String, serde_json::Value>> {
cfg.get(QUICK_UNLOCK_CONFIG_KEY)
.and_then(serde_json::Value::as_object)
}
fn parse_env_toggle(name: &str) -> Option<bool> {
let raw = std::env::var(name).ok()?;
match raw.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
pub fn set_default_profile(name: &str) -> SafeResult<()> {
let mut cfg = read_config_map();
cfg.insert(
"default_profile".to_string(),
serde_json::Value::String(name.to_string()),
);
write_config_map(&cfg)
}
pub fn get_backup_new_profile_passwords_to() -> Option<String> {
let cfg = serde_json::Value::Object(read_config_map());
cfg.get("backup_new_profile_passwords_to")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
}
pub fn set_backup_new_profile_passwords_to(target: Option<&str>) -> SafeResult<()> {
let mut cfg = read_config_map();
match target {
None | Some("") => {
cfg.remove("backup_new_profile_passwords_to");
}
Some(t) => {
validate_profile_name(t)?;
cfg.insert(
"backup_new_profile_passwords_to".to_string(),
serde_json::Value::String(t.to_string()),
);
}
}
write_config_map(&cfg)
}
pub fn get_auto_quick_unlock() -> bool {
if let Some(v) = parse_env_toggle("TSAFE_AUTO_QUICK_UNLOCK") {
return v;
}
let cfg = read_config_map();
quick_unlock_config(&cfg)
.and_then(|quick_unlock| quick_unlock.get(QUICK_UNLOCK_AUTO_RETRIEVE_KEY))
.and_then(serde_json::Value::as_bool)
.unwrap_or(true)
}
pub fn set_auto_quick_unlock(enabled: bool) -> SafeResult<()> {
let mut cfg = read_config_map();
let quick_unlock = ensure_object_slot(&mut cfg, QUICK_UNLOCK_CONFIG_KEY);
quick_unlock.insert(
QUICK_UNLOCK_AUTO_RETRIEVE_KEY.to_string(),
serde_json::Value::Bool(enabled),
);
write_config_map(&cfg)
}
pub fn get_quick_unlock_retry_cooldown_secs() -> u64 {
if let Ok(raw) = std::env::var("TSAFE_QUICK_UNLOCK_RETRY_COOLDOWN_SECS") {
if let Ok(secs) = raw.trim().parse::<u64>() {
return secs;
}
}
let cfg = read_config_map();
quick_unlock_config(&cfg)
.and_then(|quick_unlock| quick_unlock.get(QUICK_UNLOCK_RETRY_COOLDOWN_SECS_KEY))
.and_then(serde_json::Value::as_u64)
.unwrap_or(300)
}
pub fn set_quick_unlock_retry_cooldown_secs(seconds: u64) -> SafeResult<()> {
let mut cfg = read_config_map();
let quick_unlock = ensure_object_slot(&mut cfg, QUICK_UNLOCK_CONFIG_KEY);
quick_unlock.insert(
QUICK_UNLOCK_RETRY_COOLDOWN_SECS_KEY.to_string(),
serde_json::Value::Number(seconds.into()),
);
write_config_map(&cfg)
}
pub fn get_exec_auto_redact_output() -> bool {
let cfg = read_config_map();
exec_config(&cfg)
.and_then(|exec| exec.get(EXEC_AUTO_REDACT_OUTPUT_KEY))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
}
pub fn set_exec_auto_redact_output(enabled: bool) -> SafeResult<()> {
let mut cfg = read_config_map();
let exec = ensure_object_slot(&mut cfg, EXEC_CONFIG_KEY);
exec.insert(
EXEC_AUTO_REDACT_OUTPUT_KEY.to_string(),
serde_json::Value::Bool(enabled),
);
write_config_map(&cfg)
}
pub fn get_exec_mode() -> ExecMode {
let cfg = read_config_map();
match exec_config(&cfg)
.and_then(|exec| exec.get(EXEC_MODE_KEY))
.and_then(serde_json::Value::as_str)
{
Some("standard") => ExecMode::Standard,
Some("hardened") => ExecMode::Hardened,
Some("custom") => ExecMode::Custom,
_ => ExecMode::Custom,
}
}
pub fn set_exec_mode(mode: ExecMode) -> SafeResult<()> {
let mut cfg = read_config_map();
let exec = ensure_object_slot(&mut cfg, EXEC_CONFIG_KEY);
exec.insert(
EXEC_MODE_KEY.to_string(),
serde_json::Value::String(mode.as_str().to_string()),
);
write_config_map(&cfg)
}
pub fn get_exec_custom_inherit_mode() -> ExecCustomInheritMode {
let cfg = read_config_map();
match exec_config(&cfg)
.and_then(|exec| exec.get(EXEC_CUSTOM_INHERIT_KEY))
.and_then(serde_json::Value::as_str)
{
Some("minimal") => ExecCustomInheritMode::Minimal,
Some("clean") => ExecCustomInheritMode::Clean,
_ => ExecCustomInheritMode::Full,
}
}
pub fn set_exec_custom_inherit_mode(mode: ExecCustomInheritMode) -> SafeResult<()> {
let mut cfg = read_config_map();
let exec = ensure_object_slot(&mut cfg, EXEC_CONFIG_KEY);
exec.insert(
EXEC_CUSTOM_INHERIT_KEY.to_string(),
serde_json::Value::String(mode.as_str().to_string()),
);
write_config_map(&cfg)
}
pub fn get_exec_custom_deny_dangerous_env() -> bool {
let cfg = read_config_map();
exec_config(&cfg)
.and_then(|exec| exec.get(EXEC_CUSTOM_DENY_DANGEROUS_ENV_KEY))
.and_then(serde_json::Value::as_bool)
.unwrap_or(true)
}
pub fn set_exec_custom_deny_dangerous_env(enabled: bool) -> SafeResult<()> {
let mut cfg = read_config_map();
let exec = ensure_object_slot(&mut cfg, EXEC_CONFIG_KEY);
exec.insert(
EXEC_CUSTOM_DENY_DANGEROUS_ENV_KEY.to_string(),
serde_json::Value::Bool(enabled),
);
write_config_map(&cfg)
}
pub fn get_exec_extra_sensitive_parent_vars() -> Vec<String> {
let cfg = read_config_map();
let mut out = Vec::new();
if let Some(values) = exec_config(&cfg)
.and_then(|exec| exec.get(EXEC_EXTRA_SENSITIVE_PARENT_VARS_KEY))
.and_then(serde_json::Value::as_array)
{
for value in values {
if let Some(name) = value.as_str() {
let trimmed = name.trim();
if validate_env_var_name(trimmed).is_ok()
&& !out
.iter()
.any(|existing: &String| existing.eq_ignore_ascii_case(trimmed))
{
out.push(trimmed.to_string());
}
}
}
}
out
}
pub fn add_exec_extra_sensitive_parent_var(name: &str) -> SafeResult<()> {
let trimmed = name.trim();
validate_env_var_name(trimmed)?;
let mut names = get_exec_extra_sensitive_parent_vars();
if !names
.iter()
.any(|existing| existing.eq_ignore_ascii_case(trimmed))
{
names.push(trimmed.to_string());
names.sort();
}
set_exec_extra_sensitive_parent_vars(&names)
}
pub fn remove_exec_extra_sensitive_parent_var(name: &str) -> SafeResult<bool> {
let trimmed = name.trim();
validate_env_var_name(trimmed)?;
let mut names = get_exec_extra_sensitive_parent_vars();
let original_len = names.len();
names.retain(|existing| !existing.eq_ignore_ascii_case(trimmed));
if names.len() == original_len {
return Ok(false);
}
set_exec_extra_sensitive_parent_vars(&names)?;
Ok(true)
}
fn set_exec_extra_sensitive_parent_vars(names: &[String]) -> SafeResult<()> {
let mut cfg = read_config_map();
let exec = ensure_object_slot(&mut cfg, EXEC_CONFIG_KEY);
exec.insert(
EXEC_EXTRA_SENSITIVE_PARENT_VARS_KEY.to_string(),
serde_json::Value::Array(
names
.iter()
.map(|name| serde_json::Value::String(name.clone()))
.collect(),
),
);
write_config_map(&cfg)
}
pub fn default_age_identity_path(profile: &str) -> PathBuf {
let base = UserDirs::new()
.map(|d| d.home_dir().join(".age"))
.unwrap_or_else(|| PathBuf::from(".age"));
base.join(format!("tsafe-{profile}.txt"))
}
pub fn resolve_age_identity_path(profile: &str) -> PathBuf {
if let Ok(p) = std::env::var("TSAFE_AGE_IDENTITY") {
return PathBuf::from(p);
}
default_age_identity_path(profile)
}
pub fn vault_path(profile: &str) -> PathBuf {
vault_dir().join(format!("{profile}.vault"))
}
pub fn audit_log_path(profile: &str) -> PathBuf {
audit_dir().join(format!("{profile}.audit.jsonl"))
}
pub fn rename_profile_snapshot_history(from: &str, to: &str) -> SafeResult<bool> {
let src_dir = crate::snapshot::snapshot_dir(from);
if !src_dir.exists() {
return Ok(false);
}
let dst_dir = crate::snapshot::snapshot_dir(to);
if dst_dir.exists() {
return Err(SafeError::InvalidVault {
reason: format!("snapshot history already exists for profile '{to}'"),
});
}
if let Some(parent) = dst_dir.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::create_dir(&dst_dir)?;
let from_prefix = format!("{from}.vault.");
let to_prefix = format!("{to}.vault.");
for entry in std::fs::read_dir(&src_dir)? {
let entry = entry?;
let path = entry.path();
let name = entry.file_name();
let name_text = name.to_string_lossy();
let dest_name = if name_text.starts_with(&from_prefix) && name_text.ends_with(".snap") {
format!("{}{}", to_prefix, &name_text[from_prefix.len()..])
} else {
name_text.into_owned()
};
let dst_path = dst_dir.join(dest_name);
if dst_path.exists() {
return Err(SafeError::InvalidVault {
reason: format!(
"snapshot migration target already exists at '{}'",
dst_path.display()
),
});
}
std::fs::rename(path, dst_path)?;
}
std::fs::remove_dir(&src_dir)?;
Ok(true)
}
pub fn browser_profiles_path() -> PathBuf {
vault_dir().join("browser-profiles.json")
}
pub fn browser_hostname_fill_guard(hostname: &str) -> Result<(), &'static str> {
let host = hostname.trim().trim_end_matches('.');
if host.is_empty() {
return Err("empty hostname");
}
if host.len() > 253 {
return Err("hostname too long");
}
if !host.is_ascii() {
return Err("hostname must be ASCII (IDN should be sent as punycode)");
}
let lower = host.to_ascii_lowercase();
let labels: Vec<&str> = lower.split('.').collect();
if labels.len() > 12 {
return Err("too many hostname labels");
}
for label in &labels {
if label.is_empty() {
return Err("empty hostname label");
}
if label.len() > 63 {
return Err("hostname label too long");
}
if label.starts_with('-') || label.ends_with('-') {
return Err("hostname label has invalid hyphen placement");
}
if label.starts_with("xn--") {
return Err("punycode/IDN labels not supported (post-v1)");
}
}
Ok(())
}
pub fn load_browser_profiles() -> SafeResult<Vec<(String, String)>> {
let path = browser_profiles_path();
if !path.exists() {
return Ok(Vec::new());
}
let value: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path)?)?;
let map = value.as_object().ok_or_else(|| SafeError::InvalidVault {
reason: format!(
"browser profile mappings at '{}' must be a JSON object",
path.display()
),
})?;
Ok(map
.iter()
.filter_map(|(domain, profile)| {
profile
.as_str()
.map(|target| (domain.to_string(), target.to_string()))
})
.collect())
}
pub fn resolve_browser_profile(hostname: &str) -> SafeResult<Option<String>> {
let host = hostname.trim().trim_end_matches('.').to_ascii_lowercase();
if host.is_empty() {
return Ok(None);
}
let mappings = load_browser_profiles()?;
if let Some((_, profile)) = mappings
.iter()
.find(|(domain, _)| !domain.starts_with("*.") && domain.eq_ignore_ascii_case(&host))
{
return Ok(Some(profile.clone()));
}
let mut best: Option<(usize, &str)> = None;
for (pattern, profile) in &mappings {
let Some(suffix) = pattern.strip_prefix("*.") else {
continue;
};
let suffix = suffix.trim_end_matches('.').to_ascii_lowercase();
if suffix.is_empty() || host == suffix {
continue;
}
if host.ends_with(&suffix) && host.as_bytes()[host.len() - suffix.len() - 1] == b'.' {
match best {
Some((best_len, _)) if best_len >= suffix.len() => {}
_ => best = Some((suffix.len(), profile.as_str())),
}
}
}
Ok(best.map(|(_, profile)| profile.to_string()))
}
pub fn list_profiles() -> SafeResult<Vec<String>> {
let dir = vault_dir();
if !dir.exists() {
return Ok(Vec::new());
}
let mut names: Vec<String> = std::fs::read_dir(&dir)?
.filter_map(|e| e.ok())
.filter_map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
name.strip_suffix(".vault").map(|s| s.to_string())
})
.collect();
names.sort();
Ok(names)
}
pub fn profile_exists(profile: &str) -> bool {
vault_path(profile).exists()
}
pub fn validate_profile_name(name: &str) -> SafeResult<()> {
if name.is_empty() {
return Err(SafeError::ProfileNotFound {
name: name.to_string(),
});
}
if !name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Err(SafeError::InvalidVault {
reason: format!("profile '{name}': only alphanumeric, '-', '_' characters allowed"),
});
}
Ok(())
}
pub fn validate_env_var_name(name: &str) -> SafeResult<()> {
if name.is_empty() {
return Err(SafeError::InvalidVault {
reason: "environment variable name cannot be empty".to_string(),
});
}
if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(SafeError::InvalidVault {
reason: format!(
"environment variable '{name}': only ASCII letters, digits, and '_' are allowed"
),
});
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProfileMeta {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub last_modified: chrono::DateTime<chrono::Utc>,
#[serde(default)]
pub is_protected: bool,
}
impl ProfileMeta {
pub fn new() -> Self {
let now = chrono::Utc::now();
Self {
description: None,
created_at: now,
last_modified: now,
is_protected: false,
}
}
pub fn touch(&mut self) {
self.last_modified = chrono::Utc::now();
}
}
impl Default for ProfileMeta {
fn default() -> Self {
Self::new()
}
}
pub fn profile_meta_path(profile: &str) -> PathBuf {
vault_dir().join(format!("{profile}.meta.json"))
}
pub fn read_profile_meta(profile: &str) -> SafeResult<Option<ProfileMeta>> {
let path = profile_meta_path(profile);
if !path.exists() {
return Ok(None);
}
let contents = std::fs::read_to_string(&path).map_err(SafeError::Io)?;
let meta: ProfileMeta = serde_json::from_str(&contents)?;
Ok(Some(meta))
}
pub fn write_profile_meta(profile: &str, meta: &ProfileMeta) -> SafeResult<()> {
let path = profile_meta_path(profile);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(SafeError::Io)?;
}
let json = serde_json::to_string_pretty(meta)?;
let tmp = path.with_extension("meta.json.tmp");
std::fs::write(&tmp, json).map_err(SafeError::Io)?;
std::fs::rename(&tmp, &path).map_err(SafeError::Io)?;
Ok(())
}
pub fn ensure_profile_meta(profile: &str) -> SafeResult<ProfileMeta> {
if let Some(existing) = read_profile_meta(profile)? {
return Ok(existing);
}
let meta = ProfileMeta::new();
write_profile_meta(profile, &meta)?;
Ok(meta)
}
pub fn set_profile_protected(profile: &str, protected: bool) -> SafeResult<()> {
let mut meta = read_profile_meta(profile)?.unwrap_or_default();
meta.is_protected = protected;
meta.touch();
write_profile_meta(profile, &meta)
}
pub fn is_profile_protected(profile: &str) -> bool {
read_profile_meta(profile)
.ok()
.flatten()
.map(|m| m.is_protected)
.unwrap_or(false)
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ProfileBundle {
pub version: u8,
pub profile: String,
pub exported_at: chrono::DateTime<chrono::Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub meta: Option<ProfileMeta>,
pub salt: String,
pub nonce: String,
pub ciphertext: String,
}
impl ProfileBundle {
const NONCE_LEN: usize = 24;
const SALT_LEN: usize = 32;
const KDF_M_COST: u32 = 32_768; const KDF_T_COST: u32 = 2;
const KDF_P_COST: u32 = 1;
fn derive_key(password: &[u8], salt: &[u8]) -> SafeResult<[u8; 32]> {
let vault_key = crate::crypto::derive_key(
password,
salt,
Self::KDF_M_COST,
Self::KDF_T_COST,
Self::KDF_P_COST,
)?;
Ok(*vault_key.as_bytes())
}
fn seal(key: &[u8; 32], plaintext: &[u8]) -> SafeResult<([u8; Self::NONCE_LEN], Vec<u8>)> {
use chacha20poly1305::{
aead::{Aead, KeyInit},
XChaCha20Poly1305, XNonce,
};
use rand::RngCore;
let mut nonce_bytes = [0u8; Self::NONCE_LEN];
rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
let cipher = XChaCha20Poly1305::new(key.into());
let nonce = XNonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext)
.map_err(|_| SafeError::Crypto {
context: "bundle encryption failed".into(),
})?;
Ok((nonce_bytes, ciphertext))
}
fn open(key: &[u8; 32], nonce: &[u8], ciphertext: &[u8]) -> SafeResult<Vec<u8>> {
use chacha20poly1305::{
aead::{Aead, KeyInit},
XChaCha20Poly1305, XNonce,
};
let cipher = XChaCha20Poly1305::new(key.into());
let nonce = XNonce::from_slice(nonce);
cipher
.decrypt(nonce, ciphertext)
.map_err(|_| SafeError::DecryptionFailed)
}
}
pub fn export_profile(
profile: &str,
dest_path: &std::path::Path,
bundle_password: &[u8],
) -> SafeResult<()> {
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use rand::RngCore;
let vault_file_path = vault_path(profile);
if !vault_file_path.exists() {
return Err(SafeError::VaultNotFound {
path: vault_file_path.display().to_string(),
});
}
let vault_bytes = std::fs::read(&vault_file_path).map_err(SafeError::Io)?;
let meta = read_profile_meta(profile)?;
let mut salt = [0u8; ProfileBundle::SALT_LEN];
rand::rngs::OsRng.fill_bytes(&mut salt);
let key = ProfileBundle::derive_key(bundle_password, &salt)?;
let (nonce, ciphertext) = ProfileBundle::seal(&key, &vault_bytes)?;
let bundle = ProfileBundle {
version: 1,
profile: profile.to_string(),
exported_at: chrono::Utc::now(),
meta,
salt: B64.encode(salt),
nonce: B64.encode(nonce),
ciphertext: B64.encode(&ciphertext),
};
let json = serde_json::to_string_pretty(&bundle)?;
if let Some(parent) = dest_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(SafeError::Io)?;
}
}
std::fs::write(dest_path, json).map_err(SafeError::Io)?;
Ok(())
}
pub fn import_profile(
src_path: &std::path::Path,
dest_profile: Option<&str>,
bundle_password: &[u8],
) -> SafeResult<String> {
use base64::{engine::general_purpose::STANDARD as B64, Engine};
let json = std::fs::read_to_string(src_path).map_err(SafeError::Io)?;
let bundle: ProfileBundle = serde_json::from_str(&json)?;
if bundle.version != 1 {
return Err(SafeError::InvalidVault {
reason: format!(
"unsupported profile bundle version: {} (expected 1)",
bundle.version
),
});
}
let salt = B64
.decode(&bundle.salt)
.map_err(|_| SafeError::InvalidVault {
reason: "bundle salt is not valid base64".into(),
})?;
let nonce = B64
.decode(&bundle.nonce)
.map_err(|_| SafeError::InvalidVault {
reason: "bundle nonce is not valid base64".into(),
})?;
let ciphertext = B64
.decode(&bundle.ciphertext)
.map_err(|_| SafeError::InvalidVault {
reason: "bundle ciphertext is not valid base64".into(),
})?;
if salt.len() != ProfileBundle::SALT_LEN {
return Err(SafeError::InvalidVault {
reason: format!(
"bundle salt has wrong length: {} (expected {})",
salt.len(),
ProfileBundle::SALT_LEN
),
});
}
let key = ProfileBundle::derive_key(bundle_password, &salt)?;
let vault_bytes = ProfileBundle::open(&key, &nonce, &ciphertext)?;
let profile_name = dest_profile.unwrap_or(&bundle.profile);
validate_profile_name(profile_name)?;
let dest_vault = vault_path(profile_name);
if dest_vault.exists() {
return Err(SafeError::VaultAlreadyExists {
path: dest_vault.display().to_string(),
});
}
if let Some(parent) = dest_vault.parent() {
std::fs::create_dir_all(parent).map_err(SafeError::Io)?;
}
std::fs::write(&dest_vault, &vault_bytes).map_err(SafeError::Io)?;
if let Some(meta) = &bundle.meta {
write_profile_meta(profile_name, meta)?;
}
Ok(profile_name.to_string())
}
fn edit_distance(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let (m, n) = (a.len(), b.len());
if m == 0 {
return n;
}
if n == 0 {
return m;
}
let mut row: Vec<usize> = (0..=n).collect();
for i in 1..=m {
let mut prev = row[0];
row[0] = i;
for j in 1..=n {
let temp = row[j];
row[j] = if a[i - 1] == b[j - 1] {
prev
} else {
1 + prev.min(row[j]).min(row[j - 1])
};
prev = temp;
}
}
row[n]
}
fn strip_www_prefix(host: &str) -> &str {
host.strip_prefix("www.").unwrap_or(host)
}
pub struct LookalikeMatch {
pub registered: String,
pub edit_distance: usize,
}
pub fn lookalike_check(hostname: &str, profiles: &[(String, String)]) -> Option<LookalikeMatch> {
const THRESHOLD: usize = 1;
let candidate = strip_www_prefix(&hostname.to_ascii_lowercase()).to_string();
let mut best: Option<LookalikeMatch> = None;
for (registered_pattern, _) in profiles {
if registered_pattern.starts_with("*.") {
continue;
}
let registered_lower = registered_pattern.to_ascii_lowercase();
let registered = strip_www_prefix(®istered_lower);
if candidate == registered {
continue;
}
let dist = edit_distance(&candidate, registered);
if dist <= THRESHOLD && best.as_ref().is_none_or(|b| dist < b.edit_distance) {
best = Some(LookalikeMatch {
registered: registered_pattern.clone(),
edit_distance: dist,
});
}
}
best
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use super::*;
static PROFILE_TEST_ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn profile_meta_roundtrip() {
let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let vaults = dir.path().join("vaults");
temp_env::with_var(
"TSAFE_VAULT_DIR",
Some(vaults.as_os_str().to_str().unwrap()),
|| {
std::fs::create_dir_all(&vaults).unwrap();
let meta = ProfileMeta {
description: Some("my dev vault".into()),
created_at: chrono::Utc::now(),
last_modified: chrono::Utc::now(),
is_protected: true,
};
write_profile_meta("dev", &meta).unwrap();
let loaded = read_profile_meta("dev")
.unwrap()
.expect("meta should exist");
assert_eq!(loaded.description.as_deref(), Some("my dev vault"));
assert!(loaded.is_protected);
},
);
}
#[test]
fn read_profile_meta_missing_returns_none() {
let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let vaults = dir.path().join("vaults");
temp_env::with_var(
"TSAFE_VAULT_DIR",
Some(vaults.as_os_str().to_str().unwrap()),
|| {
assert!(read_profile_meta("nonexistent").unwrap().is_none());
},
);
}
#[test]
fn set_profile_protected_blocks_deletion_signal() {
let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let vaults = dir.path().join("vaults");
temp_env::with_var(
"TSAFE_VAULT_DIR",
Some(vaults.as_os_str().to_str().unwrap()),
|| {
std::fs::create_dir_all(&vaults).unwrap();
assert!(!is_profile_protected("prod"));
set_profile_protected("prod", true).unwrap();
assert!(is_profile_protected("prod"));
set_profile_protected("prod", false).unwrap();
assert!(!is_profile_protected("prod"));
},
);
}
#[test]
fn ensure_profile_meta_creates_defaults_when_missing() {
let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let vaults = dir.path().join("vaults");
temp_env::with_var(
"TSAFE_VAULT_DIR",
Some(vaults.as_os_str().to_str().unwrap()),
|| {
std::fs::create_dir_all(&vaults).unwrap();
let meta = ensure_profile_meta("newprofile").unwrap();
assert!(!meta.is_protected);
assert!(meta.description.is_none());
let meta2 = ensure_profile_meta("newprofile").unwrap();
assert_eq!(meta.created_at, meta2.created_at);
},
);
}
#[test]
fn export_import_profile_roundtrip() {
let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let vaults = dir.path().join("vaults");
temp_env::with_var(
"TSAFE_VAULT_DIR",
Some(vaults.as_os_str().to_str().unwrap()),
|| {
std::fs::create_dir_all(&vaults).unwrap();
let vault_content = b"fake-vault-bytes-for-testing";
std::fs::write(vault_path("source"), vault_content).unwrap();
let bundle_path = dir.path().join("source.bundle.json");
let bundle_pw = b"export-password-123";
export_profile("source", &bundle_path, bundle_pw).unwrap();
assert!(bundle_path.exists(), "bundle file should be written");
let imported_name =
import_profile(&bundle_path, Some("imported"), bundle_pw).unwrap();
assert_eq!(imported_name, "imported");
let imported_vault = vault_path("imported");
assert!(imported_vault.exists(), "imported vault should exist");
let imported_bytes = std::fs::read(&imported_vault).unwrap();
assert_eq!(imported_bytes, vault_content);
},
);
}
#[test]
fn export_import_uses_bundle_profile_name_when_dest_is_none() {
let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let vaults = dir.path().join("vaults");
temp_env::with_var(
"TSAFE_VAULT_DIR",
Some(vaults.as_os_str().to_str().unwrap()),
|| {
std::fs::create_dir_all(&vaults).unwrap();
std::fs::write(vault_path("srcprofile"), b"vault-data").unwrap();
let bundle_path = dir.path().join("srcprofile.bundle.json");
export_profile("srcprofile", &bundle_path, b"pw").unwrap();
std::fs::remove_file(vault_path("srcprofile")).unwrap();
let name = import_profile(&bundle_path, None, b"pw").unwrap();
assert_eq!(name, "srcprofile");
assert!(vault_path("srcprofile").exists());
},
);
}
#[test]
fn import_with_wrong_password_fails() {
let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let vaults = dir.path().join("vaults");
temp_env::with_var(
"TSAFE_VAULT_DIR",
Some(vaults.as_os_str().to_str().unwrap()),
|| {
std::fs::create_dir_all(&vaults).unwrap();
std::fs::write(vault_path("src"), b"vault-data").unwrap();
let bundle_path = dir.path().join("src.bundle.json");
export_profile("src", &bundle_path, b"correct-pw").unwrap();
let result = import_profile(&bundle_path, Some("dst"), b"wrong-pw");
assert!(
matches!(result, Err(SafeError::DecryptionFailed)),
"wrong password should fail decryption"
);
},
);
}
#[test]
fn export_nonexistent_profile_fails() {
let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let vaults = dir.path().join("vaults");
temp_env::with_var(
"TSAFE_VAULT_DIR",
Some(vaults.as_os_str().to_str().unwrap()),
|| {
let bundle_path = dir.path().join("out.json");
let result = export_profile("no-such-profile", &bundle_path, b"pw");
assert!(
matches!(result, Err(SafeError::VaultNotFound { .. })),
"expected VaultNotFound"
);
},
);
}
#[test]
fn import_into_existing_profile_fails() {
let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let vaults = dir.path().join("vaults");
temp_env::with_var(
"TSAFE_VAULT_DIR",
Some(vaults.as_os_str().to_str().unwrap()),
|| {
std::fs::create_dir_all(&vaults).unwrap();
std::fs::write(vault_path("src"), b"v1").unwrap();
let bundle_path = dir.path().join("bundle.json");
export_profile("src", &bundle_path, b"pw").unwrap();
std::fs::write(vault_path("dst"), b"pre-existing").unwrap();
let result = import_profile(&bundle_path, Some("dst"), b"pw");
assert!(
matches!(result, Err(SafeError::VaultAlreadyExists { .. })),
"expected VaultAlreadyExists"
);
},
);
}
#[test]
fn export_import_preserves_metadata() {
let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let vaults = dir.path().join("vaults");
temp_env::with_var(
"TSAFE_VAULT_DIR",
Some(vaults.as_os_str().to_str().unwrap()),
|| {
std::fs::create_dir_all(&vaults).unwrap();
std::fs::write(vault_path("prod"), b"vault-data").unwrap();
set_profile_protected("prod", true).unwrap();
let bundle_path = dir.path().join("prod.bundle.json");
export_profile("prod", &bundle_path, b"pw").unwrap();
let imported = import_profile(&bundle_path, Some("prod-copy"), b"pw").unwrap();
assert_eq!(imported, "prod-copy");
let meta = read_profile_meta("prod-copy").unwrap();
assert!(meta.is_some(), "metadata should be imported");
assert!(
meta.unwrap().is_protected,
"protection flag should be preserved"
);
},
);
}
#[test]
fn vault_dir_uses_env_override() {
let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
temp_env::with_var("TSAFE_VAULT_DIR", Some("/tmp/tsafe-vault-test"), || {
assert_eq!(vault_dir(), PathBuf::from("/tmp/tsafe-vault-test"));
});
}
#[test]
fn state_and_config_paths_follow_env_override() {
let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
temp_env::with_var("TSAFE_VAULT_DIR", Some("/tmp/tsafe/vaults"), || {
assert_eq!(app_data_dir(), PathBuf::from("/tmp/tsafe"));
assert_eq!(app_state_dir(), PathBuf::from("/tmp/tsafe/state"));
assert_eq!(audit_dir(), PathBuf::from("/tmp/tsafe/state/audit"));
assert_eq!(config_path(), PathBuf::from("/tmp/tsafe/config.json"));
});
}
#[test]
fn vault_path_suffix() {
let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
temp_env::with_var("TSAFE_VAULT_DIR", Some("/tmp/ts"), || {
let p = vault_path("dev");
assert!(p.to_string_lossy().ends_with("dev.vault"));
});
}
#[test]
fn rename_profile_snapshot_history_moves_and_reprefixes_snapshots() {
let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let vaults = dir.path().join("vaults");
temp_env::with_var(
"TSAFE_VAULT_DIR",
Some(vaults.as_os_str().to_str().unwrap()),
|| {
let src_dir = crate::snapshot::snapshot_dir("work");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::write(src_dir.join("work.vault.123.0000.snap"), b"one").unwrap();
std::fs::write(src_dir.join("work.vault.124.0000.snap"), b"two").unwrap();
std::fs::write(src_dir.join("keep.tmp"), b"tmp").unwrap();
let migrated = rename_profile_snapshot_history("work", "prod").unwrap();
assert!(migrated);
assert!(!src_dir.exists());
let dst_dir = crate::snapshot::snapshot_dir("prod");
assert!(dst_dir.join("prod.vault.123.0000.snap").exists());
assert!(dst_dir.join("prod.vault.124.0000.snap").exists());
assert!(dst_dir.join("keep.tmp").exists());
let listed = crate::snapshot::list("prod").unwrap();
assert_eq!(listed.len(), 2);
assert!(crate::snapshot::list("work").unwrap().is_empty());
},
);
}
#[test]
fn rename_profile_snapshot_history_is_noop_when_source_missing() {
let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let vaults = dir.path().join("vaults");
temp_env::with_var(
"TSAFE_VAULT_DIR",
Some(vaults.as_os_str().to_str().unwrap()),
|| {
let migrated = rename_profile_snapshot_history("missing", "renamed").unwrap();
assert!(!migrated);
assert!(!crate::snapshot::snapshot_dir("renamed").exists());
},
);
}
#[test]
fn rename_profile_snapshot_history_rejects_existing_destination() {
let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let vaults = dir.path().join("vaults");
temp_env::with_var(
"TSAFE_VAULT_DIR",
Some(vaults.as_os_str().to_str().unwrap()),
|| {
std::fs::create_dir_all(crate::snapshot::snapshot_dir("from")).unwrap();
std::fs::create_dir_all(crate::snapshot::snapshot_dir("to")).unwrap();
let err = rename_profile_snapshot_history("from", "to").unwrap_err();
assert!(matches!(err, SafeError::InvalidVault { .. }));
},
);
}
#[test]
fn validate_valid_names() {
for n in ["dev", "prod-1", "my_profile", "ABC123"] {
assert!(validate_profile_name(n).is_ok(), "{n} should be valid");
}
}
#[test]
fn validate_rejects_bad_names() {
for n in ["", "has spaces", "has/slash", "path\\sep", "na:me"] {
assert!(validate_profile_name(n).is_err(), "{n} should be invalid");
}
}
#[test]
fn backup_new_profile_passwords_config_roundtrip() {
let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let vaults = dir.path().join("vaults");
temp_env::with_var(
"TSAFE_VAULT_DIR",
Some(vaults.as_os_str().to_str().unwrap()),
|| {
assert!(get_backup_new_profile_passwords_to().is_none());
set_backup_new_profile_passwords_to(Some("main")).unwrap();
assert_eq!(
get_backup_new_profile_passwords_to().as_deref(),
Some("main")
);
set_backup_new_profile_passwords_to(None).unwrap();
assert!(get_backup_new_profile_passwords_to().is_none());
},
);
}
#[test]
fn exec_auto_redact_output_config_roundtrip() {
let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let vaults = dir.path().join("vaults");
temp_env::with_var(
"TSAFE_VAULT_DIR",
Some(vaults.as_os_str().to_str().unwrap()),
|| {
assert!(!get_exec_auto_redact_output());
set_exec_auto_redact_output(true).unwrap();
assert!(get_exec_auto_redact_output());
set_exec_auto_redact_output(false).unwrap();
assert!(!get_exec_auto_redact_output());
},
);
}
#[test]
fn exec_mode_config_roundtrip() {
let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let vaults = dir.path().join("vaults");
temp_env::with_var(
"TSAFE_VAULT_DIR",
Some(vaults.as_os_str().to_str().unwrap()),
|| {
assert_eq!(get_exec_mode(), ExecMode::Custom);
set_exec_mode(ExecMode::Hardened).unwrap();
assert_eq!(get_exec_mode(), ExecMode::Hardened);
set_exec_mode(ExecMode::Standard).unwrap();
assert_eq!(get_exec_mode(), ExecMode::Standard);
},
);
}
#[test]
fn exec_custom_settings_roundtrip() {
let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let vaults = dir.path().join("vaults");
temp_env::with_var(
"TSAFE_VAULT_DIR",
Some(vaults.as_os_str().to_str().unwrap()),
|| {
assert_eq!(get_exec_custom_inherit_mode(), ExecCustomInheritMode::Full);
assert!(get_exec_custom_deny_dangerous_env());
set_exec_custom_inherit_mode(ExecCustomInheritMode::Minimal).unwrap();
set_exec_custom_deny_dangerous_env(false).unwrap();
assert_eq!(
get_exec_custom_inherit_mode(),
ExecCustomInheritMode::Minimal
);
assert!(!get_exec_custom_deny_dangerous_env()); },
);
}
#[test]
fn exec_extra_sensitive_parent_vars_roundtrip() {
let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let vaults = dir.path().join("vaults");
temp_env::with_var(
"TSAFE_VAULT_DIR",
Some(vaults.as_os_str().to_str().unwrap()),
|| {
assert!(get_exec_extra_sensitive_parent_vars().is_empty());
add_exec_extra_sensitive_parent_var("OPENAI_API_KEY").unwrap();
add_exec_extra_sensitive_parent_var("openai_api_key").unwrap();
add_exec_extra_sensitive_parent_var("ANTHROPIC_API_KEY").unwrap();
assert_eq!(
get_exec_extra_sensitive_parent_vars(),
vec![
"ANTHROPIC_API_KEY".to_string(),
"OPENAI_API_KEY".to_string()
]
);
assert!(remove_exec_extra_sensitive_parent_var("OPENAI_API_KEY").unwrap());
assert_eq!(
get_exec_extra_sensitive_parent_vars(),
vec!["ANTHROPIC_API_KEY".to_string()]
);
assert!(!remove_exec_extra_sensitive_parent_var("OPENAI_API_KEY").unwrap());
},
);
}
#[test]
fn auto_quick_unlock_config_roundtrip() {
let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let vaults = dir.path().join("vaults");
temp_env::with_var(
"TSAFE_VAULT_DIR",
Some(vaults.as_os_str().to_str().unwrap()),
|| {
assert!(get_auto_quick_unlock());
set_auto_quick_unlock(false).unwrap();
assert!(!get_auto_quick_unlock());
set_auto_quick_unlock(true).unwrap();
assert!(get_auto_quick_unlock());
},
);
}
#[test]
fn quick_unlock_retry_cooldown_roundtrip() {
let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let vaults = dir.path().join("vaults");
temp_env::with_var(
"TSAFE_VAULT_DIR",
Some(vaults.as_os_str().to_str().unwrap()),
|| {
assert_eq!(get_quick_unlock_retry_cooldown_secs(), 300);
set_quick_unlock_retry_cooldown_secs(45).unwrap();
assert_eq!(get_quick_unlock_retry_cooldown_secs(), 45);
set_quick_unlock_retry_cooldown_secs(0).unwrap();
assert_eq!(get_quick_unlock_retry_cooldown_secs(), 0);
},
);
}
#[test]
fn resolve_browser_profile_prefers_exact_then_longest_wildcard() {
let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let vaults = dir.path().join("vaults");
std::fs::create_dir_all(&vaults).unwrap();
std::fs::write(
vaults.join("browser-profiles.json"),
r#"{
"github.com": "work",
"*.corp.example": "corp",
"*.deep.corp.example": "deep"
}"#,
)
.unwrap();
temp_env::with_var(
"TSAFE_VAULT_DIR",
Some(vaults.as_os_str().to_str().unwrap()),
|| {
assert_eq!(
resolve_browser_profile("github.com").unwrap().as_deref(),
Some("work")
);
assert_eq!(
resolve_browser_profile("jira.corp.example")
.unwrap()
.as_deref(),
Some("corp")
);
assert_eq!(
resolve_browser_profile("login.deep.corp.example")
.unwrap()
.as_deref(),
Some("deep")
);
assert!(resolve_browser_profile("corp.example").unwrap().is_none());
assert!(resolve_browser_profile("unknown.example")
.unwrap()
.is_none());
},
);
}
#[test]
fn edit_distance_identical_strings_is_zero() {
assert_eq!(edit_distance("paypal.com", "paypal.com"), 0);
assert_eq!(edit_distance("", ""), 0);
}
#[test]
fn edit_distance_single_substitution_is_one() {
assert_eq!(edit_distance("paypa1.com", "paypal.com"), 1);
assert_eq!(edit_distance("github.co", "github.com"), 1);
}
#[test]
fn edit_distance_unrelated_domains_exceeds_threshold() {
assert!(edit_distance("amazon.com", "paypal.com") > 1);
assert!(edit_distance("example.org", "google.com") > 1);
}
fn profiles_fixture() -> Vec<(String, String)> {
vec![
("paypal.com".into(), "finance".into()),
("github.com".into(), "work".into()),
("*.corp.example".into(), "corp".into()),
]
}
#[test]
fn lookalike_check_exact_match_returns_none() {
let result = lookalike_check("paypal.com", &profiles_fixture());
assert!(
result.is_none(),
"exact match should not trigger phishing warning"
);
}
#[test]
fn lookalike_check_typosquat_returns_match() {
let result = lookalike_check("paypa1.com", &profiles_fixture());
assert!(
result.is_some(),
"typosquat should trigger phishing warning"
);
let m = result.unwrap();
assert_eq!(m.registered, "paypal.com");
assert_eq!(m.edit_distance, 1);
}
#[test]
fn lookalike_check_www_prefix_stripped() {
let result = lookalike_check("www.paypa1.com", &profiles_fixture());
assert!(result.is_some());
assert_eq!(result.unwrap().registered, "paypal.com");
}
#[test]
fn lookalike_check_unrelated_domain_returns_none() {
let result = lookalike_check("totally-different.io", &profiles_fixture());
assert!(
result.is_none(),
"unrelated domain should not trigger phishing warning"
);
}
#[test]
fn lookalike_check_skips_wildcard_patterns() {
let result = lookalike_check("corp.exampl", &profiles_fixture());
assert!(result.is_none(), "wildcard patterns should be skipped");
}
#[test]
fn browser_hostname_fill_guard_accepts_normal_hosts() {
assert!(browser_hostname_fill_guard("github.com").is_ok());
assert!(browser_hostname_fill_guard("login.deep.corp.example.").is_ok());
}
#[test]
fn browser_hostname_fill_guard_rejects_garbage() {
assert_eq!(browser_hostname_fill_guard(""), Err("empty hostname"));
assert_eq!(browser_hostname_fill_guard(" "), Err("empty hostname"));
let long_label = format!("{}.com", "a".repeat(64));
assert_eq!(
browser_hostname_fill_guard(&long_label),
Err("hostname label too long")
);
let many = (0..14)
.map(|i| format!("l{i}"))
.collect::<Vec<_>>()
.join(".");
assert_eq!(
browser_hostname_fill_guard(&many),
Err("too many hostname labels")
);
assert_eq!(
browser_hostname_fill_guard("bad-.example.com"),
Err("hostname label has invalid hyphen placement")
);
}
#[test]
fn browser_hostname_fill_guard_rejects_punycode_labels() {
assert_eq!(
browser_hostname_fill_guard("xn--pyal-9ja.com"),
Err("punycode/IDN labels not supported (post-v1)")
);
assert_eq!(
browser_hostname_fill_guard("login.xn--anything-9ja.com"),
Err("punycode/IDN labels not supported (post-v1)")
);
assert_eq!(
browser_hostname_fill_guard("XN--PYAL-9JA.COM"),
Err("punycode/IDN labels not supported (post-v1)")
);
assert!(browser_hostname_fill_guard("paypa1.com").is_ok());
}
}