Skip to main content

tsafe_core/
profile.rs

1//! Profile management — path resolution, validation, and global config.
2//!
3//! A "profile" is a named vault file. Default paths split durable vault data,
4//! mutable app state, and config across the platform directories exposed by
5//! `ProjectDirs`. All path helpers also respect `TSAFE_VAULT_DIR` so tests can
6//! redirect I/O to a temporary directory without touching the real user paths.
7
8use std::path::PathBuf;
9
10use directories::{ProjectDirs, UserDirs};
11use serde::{Deserialize, Serialize};
12
13use crate::errors::{SafeError, SafeResult};
14
15const EXEC_CONFIG_KEY: &str = "exec";
16const EXEC_MODE_KEY: &str = "mode";
17const EXEC_CUSTOM_INHERIT_KEY: &str = "custom_inherit";
18const EXEC_CUSTOM_DENY_DANGEROUS_ENV_KEY: &str = "custom_deny_dangerous_env";
19const EXEC_AUTO_REDACT_OUTPUT_KEY: &str = "auto_redact_output";
20const EXEC_EXTRA_SENSITIVE_PARENT_VARS_KEY: &str = "extra_sensitive_parent_vars";
21const QUICK_UNLOCK_CONFIG_KEY: &str = "quick_unlock";
22const QUICK_UNLOCK_AUTO_RETRIEVE_KEY: &str = "auto_retrieve";
23const QUICK_UNLOCK_RETRY_COOLDOWN_SECS_KEY: &str = "retry_cooldown_secs";
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub enum ExecMode {
27    Standard,
28    Hardened,
29    Custom,
30}
31
32impl ExecMode {
33    pub fn as_str(self) -> &'static str {
34        match self {
35            Self::Standard => "standard",
36            Self::Hardened => "hardened",
37            Self::Custom => "custom",
38        }
39    }
40}
41
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum ExecCustomInheritMode {
44    Full,
45    Minimal,
46    Clean,
47}
48
49impl ExecCustomInheritMode {
50    pub fn as_str(self) -> &'static str {
51        match self {
52            Self::Full => "full",
53            Self::Minimal => "minimal",
54            Self::Clean => "clean",
55        }
56    }
57}
58
59fn project_dirs() -> Option<ProjectDirs> {
60    ProjectDirs::from("", "", "tsafe")
61}
62
63/// Platform data root for durable vault data and snapshots (app id: `tsafe`).
64fn platform_data_root() -> PathBuf {
65    project_dirs()
66        .map(|d| d.data_dir().to_path_buf())
67        .unwrap_or_else(|| PathBuf::from(".tsafe"))
68}
69
70/// Platform config root for persisted settings such as `config.json`.
71fn platform_config_root() -> PathBuf {
72    project_dirs()
73        .map(|d| d.config_dir().to_path_buf())
74        .unwrap_or_else(platform_data_root)
75}
76
77/// Platform state root for receipts and mutable runtime state.
78fn platform_state_root() -> PathBuf {
79    project_dirs()
80        .and_then(|d| d.state_dir().map(|p| p.to_path_buf()))
81        .unwrap_or_else(platform_data_root)
82}
83
84fn vault_location_from_env() -> Option<PathBuf> {
85    std::env::var("TSAFE_VAULT_DIR").ok().map(PathBuf::from)
86}
87
88/// Parent directory of `vaults/` — durable vault data, snapshots, browser mappings.
89pub fn app_data_dir() -> PathBuf {
90    if let Some(v) = vault_location_from_env() {
91        v.parent()
92            .map(|p| p.to_path_buf())
93            .unwrap_or_else(|| PathBuf::from("."))
94    } else {
95        platform_data_root()
96    }
97}
98
99/// Parent directory for audit receipts and mutable runtime state.
100pub fn app_state_dir() -> PathBuf {
101    if let Some(v) = vault_location_from_env() {
102        v.parent()
103            .map(|p| p.join("state"))
104            .unwrap_or_else(|| PathBuf::from(".tsafe-state"))
105    } else {
106        platform_state_root()
107    }
108}
109
110/// Base dir for all vault files. Override with `TSAFE_VAULT_DIR`.
111pub fn vault_dir() -> PathBuf {
112    if let Some(v) = vault_location_from_env() {
113        return v;
114    }
115    platform_data_root().join("vaults")
116}
117
118/// Base dir for audit log files. Follows `TSAFE_VAULT_DIR` or the platform state root.
119pub fn audit_dir() -> PathBuf {
120    app_state_dir().join("audit")
121}
122
123/// Path to the global config file (`~/.config/tsafe/config.json` or similar).
124pub fn config_path() -> PathBuf {
125    if let Some(v) = vault_location_from_env() {
126        return v
127            .parent()
128            .map(|p| p.join("config.json"))
129            .unwrap_or_else(|| PathBuf::from(".tsafe/config.json"));
130    }
131    platform_config_root().join("config.json")
132}
133
134/// Return the persisted default profile name. Falls back to `"default"` if no config is set.
135pub fn get_default_profile() -> String {
136    let path = config_path();
137    if let Ok(contents) = std::fs::read_to_string(&path) {
138        if let Ok(cfg) = serde_json::from_str::<serde_json::Value>(&contents) {
139            if let Some(name) = cfg.get("default_profile").and_then(|v| v.as_str()) {
140                if !name.is_empty() {
141                    return name.to_string();
142                }
143            }
144        }
145    }
146    "default".to_string()
147}
148
149fn read_config_map() -> serde_json::Map<String, serde_json::Value> {
150    let path = config_path();
151    std::fs::read_to_string(&path)
152        .ok()
153        .and_then(|s| serde_json::from_str(&s).ok())
154        .unwrap_or_default()
155}
156
157fn write_config_map(cfg: &serde_json::Map<String, serde_json::Value>) -> SafeResult<()> {
158    let path = config_path();
159    if let Some(parent) = path.parent() {
160        std::fs::create_dir_all(parent).map_err(SafeError::Io)?;
161    }
162    let json = serde_json::to_string_pretty(&serde_json::Value::Object(cfg.clone()))?;
163    let tmp = path.with_extension("json.tmp");
164    std::fs::write(&tmp, json).map_err(SafeError::Io)?;
165    std::fs::rename(&tmp, &path).map_err(SafeError::Io)?;
166    Ok(())
167}
168
169fn ensure_object_slot<'a>(
170    cfg: &'a mut serde_json::Map<String, serde_json::Value>,
171    key: &str,
172) -> &'a mut serde_json::Map<String, serde_json::Value> {
173    if !matches!(cfg.get(key), Some(serde_json::Value::Object(_))) {
174        cfg.insert(
175            key.to_string(),
176            serde_json::Value::Object(Default::default()),
177        );
178    }
179    cfg.get_mut(key)
180        .and_then(serde_json::Value::as_object_mut)
181        .expect("object slot must exist")
182}
183
184fn exec_config(
185    cfg: &serde_json::Map<String, serde_json::Value>,
186) -> Option<&serde_json::Map<String, serde_json::Value>> {
187    cfg.get(EXEC_CONFIG_KEY)
188        .and_then(serde_json::Value::as_object)
189}
190
191fn quick_unlock_config(
192    cfg: &serde_json::Map<String, serde_json::Value>,
193) -> Option<&serde_json::Map<String, serde_json::Value>> {
194    cfg.get(QUICK_UNLOCK_CONFIG_KEY)
195        .and_then(serde_json::Value::as_object)
196}
197
198fn parse_env_toggle(name: &str) -> Option<bool> {
199    let raw = std::env::var(name).ok()?;
200    match raw.trim().to_ascii_lowercase().as_str() {
201        "1" | "true" | "yes" | "on" => Some(true),
202        "0" | "false" | "no" | "off" => Some(false),
203        _ => None,
204    }
205}
206
207/// Persist `name` as the default profile in the config file.
208pub fn set_default_profile(name: &str) -> SafeResult<()> {
209    let mut cfg = read_config_map();
210    cfg.insert(
211        "default_profile".to_string(),
212        serde_json::Value::String(name.to_string()),
213    );
214    write_config_map(&cfg)
215}
216
217/// If set, after each **new** password vault is created, its master password is copied into this
218/// profile's vault under `profile-passwords/<new-profile>` (for recovery via main-vault bridging).
219pub fn get_backup_new_profile_passwords_to() -> Option<String> {
220    let cfg = serde_json::Value::Object(read_config_map());
221    cfg.get("backup_new_profile_passwords_to")
222        .and_then(|v| v.as_str())
223        .map(str::trim)
224        .filter(|s| !s.is_empty())
225        .map(|s| s.to_string())
226}
227
228/// Set or clear the backup target profile (`main` and `default` are typical). Pass `None` to disable.
229pub fn set_backup_new_profile_passwords_to(target: Option<&str>) -> SafeResult<()> {
230    let mut cfg = read_config_map();
231    match target {
232        None | Some("") => {
233            cfg.remove("backup_new_profile_passwords_to");
234        }
235        Some(t) => {
236            validate_profile_name(t)?;
237            cfg.insert(
238                "backup_new_profile_passwords_to".to_string(),
239                serde_json::Value::String(t.to_string()),
240            );
241        }
242    }
243    write_config_map(&cfg)
244}
245
246/// Return whether the CLI should automatically try the OS credential store during normal vault opens.
247///
248/// Environment override: `TSAFE_AUTO_QUICK_UNLOCK=on|off`.
249pub fn get_auto_quick_unlock() -> bool {
250    if let Some(v) = parse_env_toggle("TSAFE_AUTO_QUICK_UNLOCK") {
251        return v;
252    }
253    let cfg = read_config_map();
254    quick_unlock_config(&cfg)
255        .and_then(|quick_unlock| quick_unlock.get(QUICK_UNLOCK_AUTO_RETRIEVE_KEY))
256        .and_then(serde_json::Value::as_bool)
257        .unwrap_or(true)
258}
259
260/// Persist whether the CLI should automatically try the OS credential store during normal vault opens.
261pub fn set_auto_quick_unlock(enabled: bool) -> SafeResult<()> {
262    let mut cfg = read_config_map();
263    let quick_unlock = ensure_object_slot(&mut cfg, QUICK_UNLOCK_CONFIG_KEY);
264    quick_unlock.insert(
265        QUICK_UNLOCK_AUTO_RETRIEVE_KEY.to_string(),
266        serde_json::Value::Bool(enabled),
267    );
268    write_config_map(&cfg)
269}
270
271/// Return the cooldown, in seconds, applied after an automatic quick-unlock failure.
272///
273/// Environment override: `TSAFE_QUICK_UNLOCK_RETRY_COOLDOWN_SECS=<n>`.
274pub fn get_quick_unlock_retry_cooldown_secs() -> u64 {
275    if let Ok(raw) = std::env::var("TSAFE_QUICK_UNLOCK_RETRY_COOLDOWN_SECS") {
276        if let Ok(secs) = raw.trim().parse::<u64>() {
277            return secs;
278        }
279    }
280    let cfg = read_config_map();
281    quick_unlock_config(&cfg)
282        .and_then(|quick_unlock| quick_unlock.get(QUICK_UNLOCK_RETRY_COOLDOWN_SECS_KEY))
283        .and_then(serde_json::Value::as_u64)
284        .unwrap_or(300)
285}
286
287/// Persist the cooldown, in seconds, applied after an automatic quick-unlock failure.
288pub fn set_quick_unlock_retry_cooldown_secs(seconds: u64) -> SafeResult<()> {
289    let mut cfg = read_config_map();
290    let quick_unlock = ensure_object_slot(&mut cfg, QUICK_UNLOCK_CONFIG_KEY);
291    quick_unlock.insert(
292        QUICK_UNLOCK_RETRY_COOLDOWN_SECS_KEY.to_string(),
293        serde_json::Value::Number(seconds.into()),
294    );
295    write_config_map(&cfg)
296}
297
298/// Return true when `tsafe exec` should redact child stdout/stderr by default.
299pub fn get_exec_auto_redact_output() -> bool {
300    let cfg = read_config_map();
301    exec_config(&cfg)
302        .and_then(|exec| exec.get(EXEC_AUTO_REDACT_OUTPUT_KEY))
303        .and_then(serde_json::Value::as_bool)
304        .unwrap_or(false)
305}
306
307/// Persist whether `tsafe exec` should redact child stdout/stderr by default.
308pub fn set_exec_auto_redact_output(enabled: bool) -> SafeResult<()> {
309    let mut cfg = read_config_map();
310    let exec = ensure_object_slot(&mut cfg, EXEC_CONFIG_KEY);
311    exec.insert(
312        EXEC_AUTO_REDACT_OUTPUT_KEY.to_string(),
313        serde_json::Value::Bool(enabled),
314    );
315    write_config_map(&cfg)
316}
317
318/// Return the persisted exec trust mode. Defaults to `custom`, which preserves the
319/// current config-driven exec behavior until stricter presets are selected.
320pub fn get_exec_mode() -> ExecMode {
321    let cfg = read_config_map();
322    match exec_config(&cfg)
323        .and_then(|exec| exec.get(EXEC_MODE_KEY))
324        .and_then(serde_json::Value::as_str)
325    {
326        Some("standard") => ExecMode::Standard,
327        Some("hardened") => ExecMode::Hardened,
328        Some("custom") => ExecMode::Custom,
329        _ => ExecMode::Custom,
330    }
331}
332
333/// Persist the exec trust mode.
334pub fn set_exec_mode(mode: ExecMode) -> SafeResult<()> {
335    let mut cfg = read_config_map();
336    let exec = ensure_object_slot(&mut cfg, EXEC_CONFIG_KEY);
337    exec.insert(
338        EXEC_MODE_KEY.to_string(),
339        serde_json::Value::String(mode.as_str().to_string()),
340    );
341    write_config_map(&cfg)
342}
343
344/// Return the inherit strategy used by exec when mode=`custom`.
345pub fn get_exec_custom_inherit_mode() -> ExecCustomInheritMode {
346    let cfg = read_config_map();
347    match exec_config(&cfg)
348        .and_then(|exec| exec.get(EXEC_CUSTOM_INHERIT_KEY))
349        .and_then(serde_json::Value::as_str)
350    {
351        Some("minimal") => ExecCustomInheritMode::Minimal,
352        Some("clean") => ExecCustomInheritMode::Clean,
353        _ => ExecCustomInheritMode::Full,
354    }
355}
356
357/// Persist the inherit strategy used by exec when mode=`custom`.
358pub fn set_exec_custom_inherit_mode(mode: ExecCustomInheritMode) -> SafeResult<()> {
359    let mut cfg = read_config_map();
360    let exec = ensure_object_slot(&mut cfg, EXEC_CONFIG_KEY);
361    exec.insert(
362        EXEC_CUSTOM_INHERIT_KEY.to_string(),
363        serde_json::Value::String(mode.as_str().to_string()),
364    );
365    write_config_map(&cfg)
366}
367
368/// Return whether exec should deny dangerous injected env names when mode=`custom`.
369pub fn get_exec_custom_deny_dangerous_env() -> bool {
370    let cfg = read_config_map();
371    exec_config(&cfg)
372        .and_then(|exec| exec.get(EXEC_CUSTOM_DENY_DANGEROUS_ENV_KEY))
373        .and_then(serde_json::Value::as_bool)
374        .unwrap_or(true)
375}
376
377/// Persist whether exec should deny dangerous injected env names when mode=`custom`.
378pub fn set_exec_custom_deny_dangerous_env(enabled: bool) -> SafeResult<()> {
379    let mut cfg = read_config_map();
380    let exec = ensure_object_slot(&mut cfg, EXEC_CONFIG_KEY);
381    exec.insert(
382        EXEC_CUSTOM_DENY_DANGEROUS_ENV_KEY.to_string(),
383        serde_json::Value::Bool(enabled),
384    );
385    write_config_map(&cfg)
386}
387
388/// Return additional parent environment variable names to strip during `tsafe exec`.
389pub fn get_exec_extra_sensitive_parent_vars() -> Vec<String> {
390    let cfg = read_config_map();
391    let mut out = Vec::new();
392    if let Some(values) = exec_config(&cfg)
393        .and_then(|exec| exec.get(EXEC_EXTRA_SENSITIVE_PARENT_VARS_KEY))
394        .and_then(serde_json::Value::as_array)
395    {
396        for value in values {
397            if let Some(name) = value.as_str() {
398                let trimmed = name.trim();
399                if validate_env_var_name(trimmed).is_ok()
400                    && !out
401                        .iter()
402                        .any(|existing: &String| existing.eq_ignore_ascii_case(trimmed))
403                {
404                    out.push(trimmed.to_string());
405                }
406            }
407        }
408    }
409    out
410}
411
412/// Add a parent environment variable name to the extra strip list for `tsafe exec`.
413pub fn add_exec_extra_sensitive_parent_var(name: &str) -> SafeResult<()> {
414    let trimmed = name.trim();
415    validate_env_var_name(trimmed)?;
416
417    let mut names = get_exec_extra_sensitive_parent_vars();
418    if !names
419        .iter()
420        .any(|existing| existing.eq_ignore_ascii_case(trimmed))
421    {
422        names.push(trimmed.to_string());
423        names.sort();
424    }
425    set_exec_extra_sensitive_parent_vars(&names)
426}
427
428/// Remove a parent environment variable name from the extra strip list for `tsafe exec`.
429pub fn remove_exec_extra_sensitive_parent_var(name: &str) -> SafeResult<bool> {
430    let trimmed = name.trim();
431    validate_env_var_name(trimmed)?;
432
433    let mut names = get_exec_extra_sensitive_parent_vars();
434    let original_len = names.len();
435    names.retain(|existing| !existing.eq_ignore_ascii_case(trimmed));
436    if names.len() == original_len {
437        return Ok(false);
438    }
439    set_exec_extra_sensitive_parent_vars(&names)?;
440    Ok(true)
441}
442
443fn set_exec_extra_sensitive_parent_vars(names: &[String]) -> SafeResult<()> {
444    let mut cfg = read_config_map();
445    let exec = ensure_object_slot(&mut cfg, EXEC_CONFIG_KEY);
446    exec.insert(
447        EXEC_EXTRA_SENSITIVE_PARENT_VARS_KEY.to_string(),
448        serde_json::Value::Array(
449            names
450                .iter()
451                .map(|name| serde_json::Value::String(name.clone()))
452                .collect(),
453        ),
454    );
455    write_config_map(&cfg)
456}
457
458/// Default age identity path for a profile: `~/.age/tsafe-<profile>.txt`.
459pub fn default_age_identity_path(profile: &str) -> PathBuf {
460    let base = UserDirs::new()
461        .map(|d| d.home_dir().join(".age"))
462        .unwrap_or_else(|| PathBuf::from(".age"));
463    base.join(format!("tsafe-{profile}.txt"))
464}
465
466/// Resolve age identity: `TSAFE_AGE_IDENTITY` if set, else [`default_age_identity_path`].
467pub fn resolve_age_identity_path(profile: &str) -> PathBuf {
468    if let Ok(p) = std::env::var("TSAFE_AGE_IDENTITY") {
469        return PathBuf::from(p);
470    }
471    default_age_identity_path(profile)
472}
473
474/// Resolve the vault file path for a named profile.
475pub fn vault_path(profile: &str) -> PathBuf {
476    vault_dir().join(format!("{profile}.vault"))
477}
478
479/// Resolve the audit log path for a named profile.
480pub fn audit_log_path(profile: &str) -> PathBuf {
481    audit_dir().join(format!("{profile}.audit.jsonl"))
482}
483
484/// Move a profile's snapshot history to a new profile name, updating both the
485/// directory and the snapshot file prefixes so future snapshot commands keep
486/// working under the destination profile.
487pub fn rename_profile_snapshot_history(from: &str, to: &str) -> SafeResult<bool> {
488    let src_dir = crate::snapshot::snapshot_dir(from);
489    if !src_dir.exists() {
490        return Ok(false);
491    }
492
493    let dst_dir = crate::snapshot::snapshot_dir(to);
494    if dst_dir.exists() {
495        return Err(SafeError::InvalidVault {
496            reason: format!("snapshot history already exists for profile '{to}'"),
497        });
498    }
499
500    if let Some(parent) = dst_dir.parent() {
501        std::fs::create_dir_all(parent)?;
502    }
503    std::fs::create_dir(&dst_dir)?;
504
505    let from_prefix = format!("{from}.vault.");
506    let to_prefix = format!("{to}.vault.");
507    for entry in std::fs::read_dir(&src_dir)? {
508        let entry = entry?;
509        let path = entry.path();
510        let name = entry.file_name();
511        let name_text = name.to_string_lossy();
512        let dest_name = if name_text.starts_with(&from_prefix) && name_text.ends_with(".snap") {
513            format!("{}{}", to_prefix, &name_text[from_prefix.len()..])
514        } else {
515            name_text.into_owned()
516        };
517        let dst_path = dst_dir.join(dest_name);
518        if dst_path.exists() {
519            return Err(SafeError::InvalidVault {
520                reason: format!(
521                    "snapshot migration target already exists at '{}'",
522                    dst_path.display()
523                ),
524            });
525        }
526        std::fs::rename(path, dst_path)?;
527    }
528
529    std::fs::remove_dir(&src_dir)?;
530    Ok(true)
531}
532
533/// Path to the browser domain -> profile mapping file.
534pub fn browser_profiles_path() -> PathBuf {
535    vault_dir().join("browser-profiles.json")
536}
537
538/// Structural validation for hostnames the browser extension sends to the native host.
539///
540/// Rejects empty, oversized, non-ASCII, malformed DNS-like, or punycode-prefixed
541/// hostnames before profile mapping or vault I/O. This is a lightweight abuse
542/// / oddity guard. Punycode labels (`xn--*`) are rejected outright because they
543/// are the only IDN-attack vector that survives the "ASCII-only" check —
544/// `xn--pyal-9ja.com` IS ASCII but encodes Cyrillic Unicode confusables.
545/// Full IDN support (decode + confusables-table) is post-v1.
546pub fn browser_hostname_fill_guard(hostname: &str) -> Result<(), &'static str> {
547    let host = hostname.trim().trim_end_matches('.');
548    if host.is_empty() {
549        return Err("empty hostname");
550    }
551    if host.len() > 253 {
552        return Err("hostname too long");
553    }
554    if !host.is_ascii() {
555        return Err("hostname must be ASCII (IDN should be sent as punycode)");
556    }
557    let lower = host.to_ascii_lowercase();
558    let labels: Vec<&str> = lower.split('.').collect();
559    if labels.len() > 12 {
560        return Err("too many hostname labels");
561    }
562    for label in &labels {
563        if label.is_empty() {
564            return Err("empty hostname label");
565        }
566        if label.len() > 63 {
567            return Err("hostname label too long");
568        }
569        if label.starts_with('-') || label.ends_with('-') {
570            return Err("hostname label has invalid hyphen placement");
571        }
572        // RFC 3490 punycode labels (`xn--*`) are how IDN homoglyph attacks
573        // bypass the "ASCII-only" check above: `xn--pyal-9ja.com` IS ASCII
574        // but encodes Cyrillic characters that look identical to a registered
575        // Latin-script domain. Until tsafe ships full IDN support (decode +
576        // confusables-table check), reject all xn-- labels at the door.
577        // Mirrored in `extension/src/content/autofill.ts`'s
578        // `browserHostnameFillGuard` — keep both copies in sync.
579        if label.starts_with("xn--") {
580            return Err("punycode/IDN labels not supported (post-v1)");
581        }
582    }
583    Ok(())
584}
585
586/// Load browser domain -> profile mappings.
587pub fn load_browser_profiles() -> SafeResult<Vec<(String, String)>> {
588    let path = browser_profiles_path();
589    if !path.exists() {
590        return Ok(Vec::new());
591    }
592
593    let value: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path)?)?;
594    let map = value.as_object().ok_or_else(|| SafeError::InvalidVault {
595        reason: format!(
596            "browser profile mappings at '{}' must be a JSON object",
597            path.display()
598        ),
599    })?;
600
601    Ok(map
602        .iter()
603        .filter_map(|(domain, profile)| {
604            profile
605                .as_str()
606                .map(|target| (domain.to_string(), target.to_string()))
607        })
608        .collect())
609}
610
611/// Resolve a hostname to a configured browser profile.
612///
613/// Exact matches win. Otherwise, the longest wildcard suffix (`*.corp.example`)
614/// that matches a subdomain is returned.
615pub fn resolve_browser_profile(hostname: &str) -> SafeResult<Option<String>> {
616    let host = hostname.trim().trim_end_matches('.').to_ascii_lowercase();
617    if host.is_empty() {
618        return Ok(None);
619    }
620
621    let mappings = load_browser_profiles()?;
622    if let Some((_, profile)) = mappings
623        .iter()
624        .find(|(domain, _)| !domain.starts_with("*.") && domain.eq_ignore_ascii_case(&host))
625    {
626        return Ok(Some(profile.clone()));
627    }
628
629    let mut best: Option<(usize, &str)> = None;
630    for (pattern, profile) in &mappings {
631        let Some(suffix) = pattern.strip_prefix("*.") else {
632            continue;
633        };
634        let suffix = suffix.trim_end_matches('.').to_ascii_lowercase();
635        if suffix.is_empty() || host == suffix {
636            continue;
637        }
638        if host.ends_with(&suffix) && host.as_bytes()[host.len() - suffix.len() - 1] == b'.' {
639            match best {
640                Some((best_len, _)) if best_len >= suffix.len() => {}
641                _ => best = Some((suffix.len(), profile.as_str())),
642            }
643        }
644    }
645
646    Ok(best.map(|(_, profile)| profile.to_string()))
647}
648
649/// Return all profile names that have an existing vault file.
650pub fn list_profiles() -> SafeResult<Vec<String>> {
651    let dir = vault_dir();
652    if !dir.exists() {
653        return Ok(Vec::new());
654    }
655    let mut names: Vec<String> = std::fs::read_dir(&dir)?
656        .filter_map(|e| e.ok())
657        .filter_map(|e| {
658            let name = e.file_name().to_string_lossy().into_owned();
659            name.strip_suffix(".vault").map(|s| s.to_string())
660        })
661        .collect();
662    names.sort();
663    Ok(names)
664}
665
666/// Return `true` if a vault file exists for the given profile name.
667pub fn profile_exists(profile: &str) -> bool {
668    vault_path(profile).exists()
669}
670
671/// Validate a profile name: alphanumeric, hyphens, underscores only.
672pub fn validate_profile_name(name: &str) -> SafeResult<()> {
673    if name.is_empty() {
674        return Err(SafeError::ProfileNotFound {
675            name: name.to_string(),
676        });
677    }
678    // ASCII-only: the name becomes a vault filename, so non-ASCII characters
679    // are a portability foot-gun. This also matches the error message below.
680    if !name
681        .chars()
682        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
683    {
684        return Err(SafeError::InvalidVault {
685            reason: format!("profile '{name}': only alphanumeric, '-', '_' characters allowed"),
686        });
687    }
688    Ok(())
689}
690
691/// Validate an environment variable name for config-based strip rules.
692pub fn validate_env_var_name(name: &str) -> SafeResult<()> {
693    if name.is_empty() {
694        return Err(SafeError::InvalidVault {
695            reason: "environment variable name cannot be empty".to_string(),
696        });
697    }
698    if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
699        return Err(SafeError::InvalidVault {
700            reason: format!(
701                "environment variable '{name}': only ASCII letters, digits, and '_' are allowed"
702            ),
703        });
704    }
705    Ok(())
706}
707
708// ── Profile metadata and protection ──────────────────────────────────────────
709
710/// Metadata associated with a named profile, stored in a sidecar JSON file
711/// (`<vault_dir>/<profile>.meta.json`). This is separate from the vault file so
712/// it can be read without the vault password.
713#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
714pub struct ProfileMeta {
715    /// Human-readable description of the profile's purpose.
716    #[serde(default, skip_serializing_if = "Option::is_none")]
717    pub description: Option<String>,
718    /// When the vault file was first created (best-effort; set at meta creation time).
719    pub created_at: chrono::DateTime<chrono::Utc>,
720    /// When the metadata was last modified.
721    pub last_modified: chrono::DateTime<chrono::Utc>,
722    /// If `true`, deletion without `--force` must be blocked with a warning.
723    #[serde(default)]
724    pub is_protected: bool,
725}
726
727impl ProfileMeta {
728    /// Create new metadata with default values and the current timestamp.
729    pub fn new() -> Self {
730        let now = chrono::Utc::now();
731        Self {
732            description: None,
733            created_at: now,
734            last_modified: now,
735            is_protected: false,
736        }
737    }
738
739    /// Touch `last_modified` without changing any other fields.
740    pub fn touch(&mut self) {
741        self.last_modified = chrono::Utc::now();
742    }
743}
744
745impl Default for ProfileMeta {
746    fn default() -> Self {
747        Self::new()
748    }
749}
750
751/// Path to the metadata sidecar file for a named profile.
752pub fn profile_meta_path(profile: &str) -> PathBuf {
753    vault_dir().join(format!("{profile}.meta.json"))
754}
755
756/// Read profile metadata. Returns `None` if the sidecar file does not exist.
757pub fn read_profile_meta(profile: &str) -> SafeResult<Option<ProfileMeta>> {
758    let path = profile_meta_path(profile);
759    if !path.exists() {
760        return Ok(None);
761    }
762    let contents = std::fs::read_to_string(&path).map_err(SafeError::Io)?;
763    let meta: ProfileMeta = serde_json::from_str(&contents)?;
764    Ok(Some(meta))
765}
766
767/// Write profile metadata, creating the vault directory if necessary.
768pub fn write_profile_meta(profile: &str, meta: &ProfileMeta) -> SafeResult<()> {
769    let path = profile_meta_path(profile);
770    if let Some(parent) = path.parent() {
771        std::fs::create_dir_all(parent).map_err(SafeError::Io)?;
772    }
773    let json = serde_json::to_string_pretty(meta)?;
774    let tmp = path.with_extension("meta.json.tmp");
775    std::fs::write(&tmp, json).map_err(SafeError::Io)?;
776    std::fs::rename(&tmp, &path).map_err(SafeError::Io)?;
777    Ok(())
778}
779
780/// Ensure a profile's metadata sidecar exists, creating it with defaults if not.
781pub fn ensure_profile_meta(profile: &str) -> SafeResult<ProfileMeta> {
782    if let Some(existing) = read_profile_meta(profile)? {
783        return Ok(existing);
784    }
785    let meta = ProfileMeta::new();
786    write_profile_meta(profile, &meta)?;
787    Ok(meta)
788}
789
790/// Set the protection flag on a profile's metadata, creating the sidecar if needed.
791pub fn set_profile_protected(profile: &str, protected: bool) -> SafeResult<()> {
792    let mut meta = read_profile_meta(profile)?.unwrap_or_default();
793    meta.is_protected = protected;
794    meta.touch();
795    write_profile_meta(profile, &meta)
796}
797
798/// Returns `true` if the profile exists and has `is_protected = true`.
799pub fn is_profile_protected(profile: &str) -> bool {
800    read_profile_meta(profile)
801        .ok()
802        .flatten()
803        .map(|m| m.is_protected)
804        .unwrap_or(false)
805}
806
807// ── Profile portability (export / import) ─────────────────────────────────────
808
809/// A self-contained, re-encrypted export bundle for a single profile.
810///
811/// The vault file bytes are stored as base64. A separate re-encryption wrapper
812/// (PBKDF2 + XChaCha20-Poly1305) protects the payload so the export file is
813/// safe to copy across machines. The original vault password is **not** stored.
814///
815/// Bundle format version: `1`.
816#[derive(Debug, Serialize, Deserialize)]
817pub struct ProfileBundle {
818    /// Format version for forward compatibility.
819    pub version: u8,
820    /// Profile name at export time (advisory — import may use a different name).
821    pub profile: String,
822    /// Export timestamp.
823    pub exported_at: chrono::DateTime<chrono::Utc>,
824    /// Metadata sidecar at export time (optional).
825    #[serde(default, skip_serializing_if = "Option::is_none")]
826    pub meta: Option<ProfileMeta>,
827    /// PBKDF2-HMAC-SHA256 salt (base64) used to derive the bundle encryption key.
828    pub salt: String,
829    /// XChaCha20-Poly1305 nonce (base64) for the encrypted payload.
830    pub nonce: String,
831    /// Encrypted vault file bytes (base64).
832    pub ciphertext: String,
833}
834
835impl ProfileBundle {
836    const NONCE_LEN: usize = 24;
837    const SALT_LEN: usize = 32;
838    /// Argon2id parameters for bundle key derivation — intentionally lower than
839    /// the vault KDF to allow faster export/import on constrained machines.
840    const KDF_M_COST: u32 = 32_768; // 32 MiB
841    const KDF_T_COST: u32 = 2;
842    const KDF_P_COST: u32 = 1;
843
844    /// Derive a 256-bit key from `password` and `salt` using Argon2id.
845    fn derive_key(password: &[u8], salt: &[u8]) -> SafeResult<[u8; 32]> {
846        let vault_key = crate::crypto::derive_key(
847            password,
848            salt,
849            Self::KDF_M_COST,
850            Self::KDF_T_COST,
851            Self::KDF_P_COST,
852        )?;
853        Ok(*vault_key.as_bytes())
854    }
855
856    /// Encrypt `plaintext` with XChaCha20-Poly1305 under `key` and a random nonce.
857    fn seal(key: &[u8; 32], plaintext: &[u8]) -> SafeResult<([u8; Self::NONCE_LEN], Vec<u8>)> {
858        use chacha20poly1305::{
859            aead::{Aead, KeyInit},
860            XChaCha20Poly1305, XNonce,
861        };
862        use rand::RngCore;
863        let mut nonce_bytes = [0u8; Self::NONCE_LEN];
864        rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
865        let cipher = XChaCha20Poly1305::new(key.into());
866        let nonce = XNonce::from_slice(&nonce_bytes);
867        let ciphertext = cipher
868            .encrypt(nonce, plaintext)
869            .map_err(|_| SafeError::Crypto {
870                context: "bundle encryption failed".into(),
871            })?;
872        Ok((nonce_bytes, ciphertext))
873    }
874
875    /// Decrypt `ciphertext` with XChaCha20-Poly1305 under `key` and `nonce`.
876    fn open(key: &[u8; 32], nonce: &[u8], ciphertext: &[u8]) -> SafeResult<Vec<u8>> {
877        use chacha20poly1305::{
878            aead::{Aead, KeyInit},
879            XChaCha20Poly1305, XNonce,
880        };
881        let cipher = XChaCha20Poly1305::new(key.into());
882        let nonce = XNonce::from_slice(nonce);
883        cipher
884            .decrypt(nonce, ciphertext)
885            .map_err(|_| SafeError::DecryptionFailed)
886    }
887}
888
889/// Export a profile's vault file to a self-contained encrypted bundle.
890///
891/// `profile` — the profile name whose vault file will be read.
892/// `dest_path` — where to write the bundle JSON.
893/// `bundle_password` — the export password; independent of the vault's master password.
894///
895/// Returns `SafeError::VaultNotFound` if the profile has no vault file.
896pub fn export_profile(
897    profile: &str,
898    dest_path: &std::path::Path,
899    bundle_password: &[u8],
900) -> SafeResult<()> {
901    use base64::{engine::general_purpose::STANDARD as B64, Engine};
902    use rand::RngCore;
903
904    let vault_file_path = vault_path(profile);
905    if !vault_file_path.exists() {
906        return Err(SafeError::VaultNotFound {
907            path: vault_file_path.display().to_string(),
908        });
909    }
910
911    let vault_bytes = std::fs::read(&vault_file_path).map_err(SafeError::Io)?;
912    let meta = read_profile_meta(profile)?;
913
914    let mut salt = [0u8; ProfileBundle::SALT_LEN];
915    rand::rngs::OsRng.fill_bytes(&mut salt);
916
917    let key = ProfileBundle::derive_key(bundle_password, &salt)?;
918    let (nonce, ciphertext) = ProfileBundle::seal(&key, &vault_bytes)?;
919
920    let bundle = ProfileBundle {
921        version: 1,
922        profile: profile.to_string(),
923        exported_at: chrono::Utc::now(),
924        meta,
925        salt: B64.encode(salt),
926        nonce: B64.encode(nonce),
927        ciphertext: B64.encode(&ciphertext),
928    };
929
930    let json = serde_json::to_string_pretty(&bundle)?;
931    if let Some(parent) = dest_path.parent() {
932        if !parent.as_os_str().is_empty() {
933            std::fs::create_dir_all(parent).map_err(SafeError::Io)?;
934        }
935    }
936    std::fs::write(dest_path, json).map_err(SafeError::Io)?;
937    Ok(())
938}
939
940/// Import a profile bundle, registering it under `dest_profile`.
941///
942/// The vault file is decrypted from the bundle and written to the standard vault
943/// directory. If `dest_profile` is `None`, the name embedded in the bundle is used.
944/// Returns `SafeError::VaultAlreadyExists` if the target vault file already exists.
945pub fn import_profile(
946    src_path: &std::path::Path,
947    dest_profile: Option<&str>,
948    bundle_password: &[u8],
949) -> SafeResult<String> {
950    use base64::{engine::general_purpose::STANDARD as B64, Engine};
951
952    let json = std::fs::read_to_string(src_path).map_err(SafeError::Io)?;
953    let bundle: ProfileBundle = serde_json::from_str(&json)?;
954
955    if bundle.version != 1 {
956        return Err(SafeError::InvalidVault {
957            reason: format!(
958                "unsupported profile bundle version: {} (expected 1)",
959                bundle.version
960            ),
961        });
962    }
963
964    let salt = B64
965        .decode(&bundle.salt)
966        .map_err(|_| SafeError::InvalidVault {
967            reason: "bundle salt is not valid base64".into(),
968        })?;
969    let nonce = B64
970        .decode(&bundle.nonce)
971        .map_err(|_| SafeError::InvalidVault {
972            reason: "bundle nonce is not valid base64".into(),
973        })?;
974    let ciphertext = B64
975        .decode(&bundle.ciphertext)
976        .map_err(|_| SafeError::InvalidVault {
977            reason: "bundle ciphertext is not valid base64".into(),
978        })?;
979
980    if salt.len() != ProfileBundle::SALT_LEN {
981        return Err(SafeError::InvalidVault {
982            reason: format!(
983                "bundle salt has wrong length: {} (expected {})",
984                salt.len(),
985                ProfileBundle::SALT_LEN
986            ),
987        });
988    }
989
990    let key = ProfileBundle::derive_key(bundle_password, &salt)?;
991    let vault_bytes = ProfileBundle::open(&key, &nonce, &ciphertext)?;
992
993    let profile_name = dest_profile.unwrap_or(&bundle.profile);
994    validate_profile_name(profile_name)?;
995
996    let dest_vault = vault_path(profile_name);
997    if dest_vault.exists() {
998        return Err(SafeError::VaultAlreadyExists {
999            path: dest_vault.display().to_string(),
1000        });
1001    }
1002
1003    if let Some(parent) = dest_vault.parent() {
1004        std::fs::create_dir_all(parent).map_err(SafeError::Io)?;
1005    }
1006    std::fs::write(&dest_vault, &vault_bytes).map_err(SafeError::Io)?;
1007
1008    // Restore metadata sidecar if present in the bundle.
1009    if let Some(meta) = &bundle.meta {
1010        write_profile_meta(profile_name, meta)?;
1011    }
1012
1013    Ok(profile_name.to_string())
1014}
1015
1016// ── Phishing / lookalike guard ────────────────────────────────────────────────
1017
1018/// Space-optimised Levenshtein edit distance between two ASCII strings.
1019fn edit_distance(a: &str, b: &str) -> usize {
1020    let a: Vec<char> = a.chars().collect();
1021    let b: Vec<char> = b.chars().collect();
1022    let (m, n) = (a.len(), b.len());
1023    if m == 0 {
1024        return n;
1025    }
1026    if n == 0 {
1027        return m;
1028    }
1029    let mut row: Vec<usize> = (0..=n).collect();
1030    for i in 1..=m {
1031        let mut prev = row[0];
1032        row[0] = i;
1033        for j in 1..=n {
1034            let temp = row[j];
1035            row[j] = if a[i - 1] == b[j - 1] {
1036                prev
1037            } else {
1038                1 + prev.min(row[j]).min(row[j - 1])
1039            };
1040            prev = temp;
1041        }
1042    }
1043    row[n]
1044}
1045
1046/// Strip a leading `www.` from a hostname (ASCII, already lowercased).
1047fn strip_www_prefix(host: &str) -> &str {
1048    host.strip_prefix("www.").unwrap_or(host)
1049}
1050
1051/// A suspicious domain match returned by [`lookalike_check`].
1052pub struct LookalikeMatch {
1053    /// The registered profile domain that the candidate closely resembles.
1054    pub registered: String,
1055    /// Levenshtein edit distance between the normalised forms.
1056    pub edit_distance: usize,
1057}
1058
1059/// Check whether `hostname` is a typosquat lookalike of any registered browser-profile
1060/// domain.
1061///
1062/// Returns the closest suspicious match (edit distance ≤ 1, non-exact) or `None`.
1063///
1064/// Rules:
1065/// - Wildcard patterns (`*.`-prefixed) are skipped — they cover legitimate subdomains.
1066/// - Leading `www.` is stripped from both sides before comparison.
1067/// - Comparison is case-insensitive (ASCII lowercase).
1068/// - Exact matches return `None` (already handled by profile-mapping logic).
1069pub fn lookalike_check(hostname: &str, profiles: &[(String, String)]) -> Option<LookalikeMatch> {
1070    const THRESHOLD: usize = 1;
1071
1072    let candidate = strip_www_prefix(&hostname.to_ascii_lowercase()).to_string();
1073    let mut best: Option<LookalikeMatch> = None;
1074
1075    for (registered_pattern, _) in profiles {
1076        // Skip wildcards — they are subdomain matchers, not canonical domain names.
1077        if registered_pattern.starts_with("*.") {
1078            continue;
1079        }
1080        let registered_lower = registered_pattern.to_ascii_lowercase();
1081        let registered = strip_www_prefix(&registered_lower);
1082
1083        // Exact match → already allowed; not a phishing signal.
1084        if candidate == registered {
1085            continue;
1086        }
1087
1088        let dist = edit_distance(&candidate, registered);
1089        if dist <= THRESHOLD && best.as_ref().is_none_or(|b| dist < b.edit_distance) {
1090            best = Some(LookalikeMatch {
1091                registered: registered_pattern.clone(),
1092                edit_distance: dist,
1093            });
1094        }
1095    }
1096
1097    best
1098}
1099
1100#[cfg(test)]
1101mod tests {
1102    use std::sync::Mutex;
1103
1104    use super::*;
1105
1106    /// `TSAFE_VAULT_DIR` is process-global; serialize tests that touch it.
1107    static PROFILE_TEST_ENV_LOCK: Mutex<()> = Mutex::new(());
1108
1109    // ── Task 1.7: Profile metadata and protection ─────────────────────────────
1110
1111    #[test]
1112    fn profile_meta_roundtrip() {
1113        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
1114        let dir = tempfile::tempdir().unwrap();
1115        let vaults = dir.path().join("vaults");
1116        temp_env::with_var(
1117            "TSAFE_VAULT_DIR",
1118            Some(vaults.as_os_str().to_str().unwrap()),
1119            || {
1120                std::fs::create_dir_all(&vaults).unwrap();
1121                let meta = ProfileMeta {
1122                    description: Some("my dev vault".into()),
1123                    created_at: chrono::Utc::now(),
1124                    last_modified: chrono::Utc::now(),
1125                    is_protected: true,
1126                };
1127                write_profile_meta("dev", &meta).unwrap();
1128                let loaded = read_profile_meta("dev")
1129                    .unwrap()
1130                    .expect("meta should exist");
1131                assert_eq!(loaded.description.as_deref(), Some("my dev vault"));
1132                assert!(loaded.is_protected);
1133            },
1134        );
1135    }
1136
1137    #[test]
1138    fn read_profile_meta_missing_returns_none() {
1139        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
1140        let dir = tempfile::tempdir().unwrap();
1141        let vaults = dir.path().join("vaults");
1142        temp_env::with_var(
1143            "TSAFE_VAULT_DIR",
1144            Some(vaults.as_os_str().to_str().unwrap()),
1145            || {
1146                assert!(read_profile_meta("nonexistent").unwrap().is_none());
1147            },
1148        );
1149    }
1150
1151    #[test]
1152    fn set_profile_protected_blocks_deletion_signal() {
1153        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
1154        let dir = tempfile::tempdir().unwrap();
1155        let vaults = dir.path().join("vaults");
1156        temp_env::with_var(
1157            "TSAFE_VAULT_DIR",
1158            Some(vaults.as_os_str().to_str().unwrap()),
1159            || {
1160                std::fs::create_dir_all(&vaults).unwrap();
1161                // Not protected by default.
1162                assert!(!is_profile_protected("prod"));
1163
1164                set_profile_protected("prod", true).unwrap();
1165                assert!(is_profile_protected("prod"));
1166
1167                // Caller must check is_profile_protected; clearing protection lifts the block.
1168                set_profile_protected("prod", false).unwrap();
1169                assert!(!is_profile_protected("prod"));
1170            },
1171        );
1172    }
1173
1174    #[test]
1175    fn ensure_profile_meta_creates_defaults_when_missing() {
1176        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
1177        let dir = tempfile::tempdir().unwrap();
1178        let vaults = dir.path().join("vaults");
1179        temp_env::with_var(
1180            "TSAFE_VAULT_DIR",
1181            Some(vaults.as_os_str().to_str().unwrap()),
1182            || {
1183                std::fs::create_dir_all(&vaults).unwrap();
1184                let meta = ensure_profile_meta("newprofile").unwrap();
1185                assert!(!meta.is_protected);
1186                assert!(meta.description.is_none());
1187                // Calling again should return the persisted value.
1188                let meta2 = ensure_profile_meta("newprofile").unwrap();
1189                assert_eq!(meta.created_at, meta2.created_at);
1190            },
1191        );
1192    }
1193
1194    // ── Task 1.6: Profile portability ─────────────────────────────────────────
1195
1196    #[test]
1197    fn export_import_profile_roundtrip() {
1198        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
1199        let dir = tempfile::tempdir().unwrap();
1200        let vaults = dir.path().join("vaults");
1201        temp_env::with_var(
1202            "TSAFE_VAULT_DIR",
1203            Some(vaults.as_os_str().to_str().unwrap()),
1204            || {
1205                std::fs::create_dir_all(&vaults).unwrap();
1206
1207                // Create a minimal vault file (just needs to exist for export).
1208                let vault_content = b"fake-vault-bytes-for-testing";
1209                std::fs::write(vault_path("source"), vault_content).unwrap();
1210
1211                let bundle_path = dir.path().join("source.bundle.json");
1212                let bundle_pw = b"export-password-123";
1213
1214                export_profile("source", &bundle_path, bundle_pw).unwrap();
1215                assert!(bundle_path.exists(), "bundle file should be written");
1216
1217                let imported_name =
1218                    import_profile(&bundle_path, Some("imported"), bundle_pw).unwrap();
1219                assert_eq!(imported_name, "imported");
1220
1221                let imported_vault = vault_path("imported");
1222                assert!(imported_vault.exists(), "imported vault should exist");
1223                let imported_bytes = std::fs::read(&imported_vault).unwrap();
1224                assert_eq!(imported_bytes, vault_content);
1225            },
1226        );
1227    }
1228
1229    #[test]
1230    fn export_import_uses_bundle_profile_name_when_dest_is_none() {
1231        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
1232        let dir = tempfile::tempdir().unwrap();
1233        let vaults = dir.path().join("vaults");
1234        temp_env::with_var(
1235            "TSAFE_VAULT_DIR",
1236            Some(vaults.as_os_str().to_str().unwrap()),
1237            || {
1238                std::fs::create_dir_all(&vaults).unwrap();
1239                // Write a source vault under one name, export it,
1240                // then import to a different directory to avoid collision.
1241                std::fs::write(vault_path("srcprofile"), b"vault-data").unwrap();
1242
1243                let bundle_path = dir.path().join("srcprofile.bundle.json");
1244                export_profile("srcprofile", &bundle_path, b"pw").unwrap();
1245
1246                // Remove the source vault so the import-with-embedded-name doesn't collide.
1247                std::fs::remove_file(vault_path("srcprofile")).unwrap();
1248
1249                let name = import_profile(&bundle_path, None, b"pw").unwrap();
1250                assert_eq!(name, "srcprofile");
1251                assert!(vault_path("srcprofile").exists());
1252            },
1253        );
1254    }
1255
1256    #[test]
1257    fn import_with_wrong_password_fails() {
1258        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
1259        let dir = tempfile::tempdir().unwrap();
1260        let vaults = dir.path().join("vaults");
1261        temp_env::with_var(
1262            "TSAFE_VAULT_DIR",
1263            Some(vaults.as_os_str().to_str().unwrap()),
1264            || {
1265                std::fs::create_dir_all(&vaults).unwrap();
1266                std::fs::write(vault_path("src"), b"vault-data").unwrap();
1267                let bundle_path = dir.path().join("src.bundle.json");
1268                export_profile("src", &bundle_path, b"correct-pw").unwrap();
1269
1270                let result = import_profile(&bundle_path, Some("dst"), b"wrong-pw");
1271                assert!(
1272                    matches!(result, Err(SafeError::DecryptionFailed)),
1273                    "wrong password should fail decryption"
1274                );
1275            },
1276        );
1277    }
1278
1279    #[test]
1280    fn export_nonexistent_profile_fails() {
1281        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
1282        let dir = tempfile::tempdir().unwrap();
1283        let vaults = dir.path().join("vaults");
1284        temp_env::with_var(
1285            "TSAFE_VAULT_DIR",
1286            Some(vaults.as_os_str().to_str().unwrap()),
1287            || {
1288                let bundle_path = dir.path().join("out.json");
1289                let result = export_profile("no-such-profile", &bundle_path, b"pw");
1290                assert!(
1291                    matches!(result, Err(SafeError::VaultNotFound { .. })),
1292                    "expected VaultNotFound"
1293                );
1294            },
1295        );
1296    }
1297
1298    #[test]
1299    fn import_into_existing_profile_fails() {
1300        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
1301        let dir = tempfile::tempdir().unwrap();
1302        let vaults = dir.path().join("vaults");
1303        temp_env::with_var(
1304            "TSAFE_VAULT_DIR",
1305            Some(vaults.as_os_str().to_str().unwrap()),
1306            || {
1307                std::fs::create_dir_all(&vaults).unwrap();
1308                std::fs::write(vault_path("src"), b"v1").unwrap();
1309                let bundle_path = dir.path().join("bundle.json");
1310                export_profile("src", &bundle_path, b"pw").unwrap();
1311
1312                // Pre-create the destination.
1313                std::fs::write(vault_path("dst"), b"pre-existing").unwrap();
1314                let result = import_profile(&bundle_path, Some("dst"), b"pw");
1315                assert!(
1316                    matches!(result, Err(SafeError::VaultAlreadyExists { .. })),
1317                    "expected VaultAlreadyExists"
1318                );
1319            },
1320        );
1321    }
1322
1323    #[test]
1324    fn export_import_preserves_metadata() {
1325        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
1326        let dir = tempfile::tempdir().unwrap();
1327        let vaults = dir.path().join("vaults");
1328        temp_env::with_var(
1329            "TSAFE_VAULT_DIR",
1330            Some(vaults.as_os_str().to_str().unwrap()),
1331            || {
1332                std::fs::create_dir_all(&vaults).unwrap();
1333                std::fs::write(vault_path("prod"), b"vault-data").unwrap();
1334
1335                // Set up metadata with protection.
1336                set_profile_protected("prod", true).unwrap();
1337                let bundle_path = dir.path().join("prod.bundle.json");
1338                export_profile("prod", &bundle_path, b"pw").unwrap();
1339
1340                let imported = import_profile(&bundle_path, Some("prod-copy"), b"pw").unwrap();
1341                assert_eq!(imported, "prod-copy");
1342                // Metadata should be restored.
1343                let meta = read_profile_meta("prod-copy").unwrap();
1344                assert!(meta.is_some(), "metadata should be imported");
1345                assert!(
1346                    meta.unwrap().is_protected,
1347                    "protection flag should be preserved"
1348                );
1349            },
1350        );
1351    }
1352
1353    #[test]
1354    fn vault_dir_uses_env_override() {
1355        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
1356        temp_env::with_var("TSAFE_VAULT_DIR", Some("/tmp/tsafe-vault-test"), || {
1357            assert_eq!(vault_dir(), PathBuf::from("/tmp/tsafe-vault-test"));
1358        });
1359    }
1360
1361    #[test]
1362    fn state_and_config_paths_follow_env_override() {
1363        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
1364        temp_env::with_var("TSAFE_VAULT_DIR", Some("/tmp/tsafe/vaults"), || {
1365            assert_eq!(app_data_dir(), PathBuf::from("/tmp/tsafe"));
1366            assert_eq!(app_state_dir(), PathBuf::from("/tmp/tsafe/state"));
1367            assert_eq!(audit_dir(), PathBuf::from("/tmp/tsafe/state/audit"));
1368            assert_eq!(config_path(), PathBuf::from("/tmp/tsafe/config.json"));
1369        });
1370    }
1371
1372    #[test]
1373    fn vault_path_suffix() {
1374        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
1375        temp_env::with_var("TSAFE_VAULT_DIR", Some("/tmp/ts"), || {
1376            let p = vault_path("dev");
1377            assert!(p.to_string_lossy().ends_with("dev.vault"));
1378        });
1379    }
1380
1381    #[test]
1382    fn rename_profile_snapshot_history_moves_and_reprefixes_snapshots() {
1383        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
1384        let dir = tempfile::tempdir().unwrap();
1385        let vaults = dir.path().join("vaults");
1386        temp_env::with_var(
1387            "TSAFE_VAULT_DIR",
1388            Some(vaults.as_os_str().to_str().unwrap()),
1389            || {
1390                let src_dir = crate::snapshot::snapshot_dir("work");
1391                std::fs::create_dir_all(&src_dir).unwrap();
1392                std::fs::write(src_dir.join("work.vault.123.0000.snap"), b"one").unwrap();
1393                std::fs::write(src_dir.join("work.vault.124.0000.snap"), b"two").unwrap();
1394                std::fs::write(src_dir.join("keep.tmp"), b"tmp").unwrap();
1395
1396                let migrated = rename_profile_snapshot_history("work", "prod").unwrap();
1397
1398                assert!(migrated);
1399                assert!(!src_dir.exists());
1400                let dst_dir = crate::snapshot::snapshot_dir("prod");
1401                assert!(dst_dir.join("prod.vault.123.0000.snap").exists());
1402                assert!(dst_dir.join("prod.vault.124.0000.snap").exists());
1403                assert!(dst_dir.join("keep.tmp").exists());
1404                let listed = crate::snapshot::list("prod").unwrap();
1405                assert_eq!(listed.len(), 2);
1406                assert!(crate::snapshot::list("work").unwrap().is_empty());
1407            },
1408        );
1409    }
1410
1411    #[test]
1412    fn rename_profile_snapshot_history_is_noop_when_source_missing() {
1413        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
1414        let dir = tempfile::tempdir().unwrap();
1415        let vaults = dir.path().join("vaults");
1416        temp_env::with_var(
1417            "TSAFE_VAULT_DIR",
1418            Some(vaults.as_os_str().to_str().unwrap()),
1419            || {
1420                let migrated = rename_profile_snapshot_history("missing", "renamed").unwrap();
1421                assert!(!migrated);
1422                assert!(!crate::snapshot::snapshot_dir("renamed").exists());
1423            },
1424        );
1425    }
1426
1427    #[test]
1428    fn rename_profile_snapshot_history_rejects_existing_destination() {
1429        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
1430        let dir = tempfile::tempdir().unwrap();
1431        let vaults = dir.path().join("vaults");
1432        temp_env::with_var(
1433            "TSAFE_VAULT_DIR",
1434            Some(vaults.as_os_str().to_str().unwrap()),
1435            || {
1436                std::fs::create_dir_all(crate::snapshot::snapshot_dir("from")).unwrap();
1437                std::fs::create_dir_all(crate::snapshot::snapshot_dir("to")).unwrap();
1438
1439                let err = rename_profile_snapshot_history("from", "to").unwrap_err();
1440
1441                assert!(matches!(err, SafeError::InvalidVault { .. }));
1442            },
1443        );
1444    }
1445
1446    #[test]
1447    fn validate_valid_names() {
1448        for n in ["dev", "prod-1", "my_profile", "ABC123"] {
1449            assert!(validate_profile_name(n).is_ok(), "{n} should be valid");
1450        }
1451    }
1452
1453    #[test]
1454    fn validate_rejects_bad_names() {
1455        for n in ["", "has spaces", "has/slash", "path\\sep", "na:me"] {
1456            assert!(validate_profile_name(n).is_err(), "{n} should be invalid");
1457        }
1458    }
1459
1460    #[test]
1461    fn backup_new_profile_passwords_config_roundtrip() {
1462        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
1463        let dir = tempfile::tempdir().unwrap();
1464        let vaults = dir.path().join("vaults");
1465        temp_env::with_var(
1466            "TSAFE_VAULT_DIR",
1467            Some(vaults.as_os_str().to_str().unwrap()),
1468            || {
1469                assert!(get_backup_new_profile_passwords_to().is_none());
1470                set_backup_new_profile_passwords_to(Some("main")).unwrap();
1471                assert_eq!(
1472                    get_backup_new_profile_passwords_to().as_deref(),
1473                    Some("main")
1474                );
1475                set_backup_new_profile_passwords_to(None).unwrap();
1476                assert!(get_backup_new_profile_passwords_to().is_none());
1477            },
1478        );
1479    }
1480
1481    #[test]
1482    fn exec_auto_redact_output_config_roundtrip() {
1483        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
1484        let dir = tempfile::tempdir().unwrap();
1485        let vaults = dir.path().join("vaults");
1486        temp_env::with_var(
1487            "TSAFE_VAULT_DIR",
1488            Some(vaults.as_os_str().to_str().unwrap()),
1489            || {
1490                assert!(!get_exec_auto_redact_output());
1491                set_exec_auto_redact_output(true).unwrap();
1492                assert!(get_exec_auto_redact_output());
1493                set_exec_auto_redact_output(false).unwrap();
1494                assert!(!get_exec_auto_redact_output());
1495            },
1496        );
1497    }
1498
1499    #[test]
1500    fn exec_mode_config_roundtrip() {
1501        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
1502        let dir = tempfile::tempdir().unwrap();
1503        let vaults = dir.path().join("vaults");
1504        temp_env::with_var(
1505            "TSAFE_VAULT_DIR",
1506            Some(vaults.as_os_str().to_str().unwrap()),
1507            || {
1508                assert_eq!(get_exec_mode(), ExecMode::Custom);
1509                set_exec_mode(ExecMode::Hardened).unwrap();
1510                assert_eq!(get_exec_mode(), ExecMode::Hardened);
1511                set_exec_mode(ExecMode::Standard).unwrap();
1512                assert_eq!(get_exec_mode(), ExecMode::Standard);
1513            },
1514        );
1515    }
1516
1517    #[test]
1518    fn exec_custom_settings_roundtrip() {
1519        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
1520        let dir = tempfile::tempdir().unwrap();
1521        let vaults = dir.path().join("vaults");
1522        temp_env::with_var(
1523            "TSAFE_VAULT_DIR",
1524            Some(vaults.as_os_str().to_str().unwrap()),
1525            || {
1526                assert_eq!(get_exec_custom_inherit_mode(), ExecCustomInheritMode::Full);
1527                assert!(get_exec_custom_deny_dangerous_env()); // default is true (deny by default)
1528
1529                set_exec_custom_inherit_mode(ExecCustomInheritMode::Minimal).unwrap();
1530                set_exec_custom_deny_dangerous_env(false).unwrap();
1531
1532                assert_eq!(
1533                    get_exec_custom_inherit_mode(),
1534                    ExecCustomInheritMode::Minimal
1535                );
1536                assert!(!get_exec_custom_deny_dangerous_env()); // explicitly set to false
1537            },
1538        );
1539    }
1540
1541    #[test]
1542    fn exec_extra_sensitive_parent_vars_roundtrip() {
1543        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
1544        let dir = tempfile::tempdir().unwrap();
1545        let vaults = dir.path().join("vaults");
1546        temp_env::with_var(
1547            "TSAFE_VAULT_DIR",
1548            Some(vaults.as_os_str().to_str().unwrap()),
1549            || {
1550                assert!(get_exec_extra_sensitive_parent_vars().is_empty());
1551                add_exec_extra_sensitive_parent_var("OPENAI_API_KEY").unwrap();
1552                add_exec_extra_sensitive_parent_var("openai_api_key").unwrap();
1553                add_exec_extra_sensitive_parent_var("ANTHROPIC_API_KEY").unwrap();
1554                assert_eq!(
1555                    get_exec_extra_sensitive_parent_vars(),
1556                    vec![
1557                        "ANTHROPIC_API_KEY".to_string(),
1558                        "OPENAI_API_KEY".to_string()
1559                    ]
1560                );
1561                assert!(remove_exec_extra_sensitive_parent_var("OPENAI_API_KEY").unwrap());
1562                assert_eq!(
1563                    get_exec_extra_sensitive_parent_vars(),
1564                    vec!["ANTHROPIC_API_KEY".to_string()]
1565                );
1566                assert!(!remove_exec_extra_sensitive_parent_var("OPENAI_API_KEY").unwrap());
1567            },
1568        );
1569    }
1570
1571    #[test]
1572    fn auto_quick_unlock_config_roundtrip() {
1573        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
1574        let dir = tempfile::tempdir().unwrap();
1575        let vaults = dir.path().join("vaults");
1576        temp_env::with_var(
1577            "TSAFE_VAULT_DIR",
1578            Some(vaults.as_os_str().to_str().unwrap()),
1579            || {
1580                assert!(get_auto_quick_unlock());
1581                set_auto_quick_unlock(false).unwrap();
1582                assert!(!get_auto_quick_unlock());
1583                set_auto_quick_unlock(true).unwrap();
1584                assert!(get_auto_quick_unlock());
1585            },
1586        );
1587    }
1588
1589    #[test]
1590    fn quick_unlock_retry_cooldown_roundtrip() {
1591        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
1592        let dir = tempfile::tempdir().unwrap();
1593        let vaults = dir.path().join("vaults");
1594        temp_env::with_var(
1595            "TSAFE_VAULT_DIR",
1596            Some(vaults.as_os_str().to_str().unwrap()),
1597            || {
1598                assert_eq!(get_quick_unlock_retry_cooldown_secs(), 300);
1599                set_quick_unlock_retry_cooldown_secs(45).unwrap();
1600                assert_eq!(get_quick_unlock_retry_cooldown_secs(), 45);
1601                set_quick_unlock_retry_cooldown_secs(0).unwrap();
1602                assert_eq!(get_quick_unlock_retry_cooldown_secs(), 0);
1603            },
1604        );
1605    }
1606
1607    #[test]
1608    fn resolve_browser_profile_prefers_exact_then_longest_wildcard() {
1609        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
1610        let dir = tempfile::tempdir().unwrap();
1611        let vaults = dir.path().join("vaults");
1612        std::fs::create_dir_all(&vaults).unwrap();
1613        std::fs::write(
1614            vaults.join("browser-profiles.json"),
1615            r#"{
1616  "github.com": "work",
1617  "*.corp.example": "corp",
1618  "*.deep.corp.example": "deep"
1619}"#,
1620        )
1621        .unwrap();
1622
1623        temp_env::with_var(
1624            "TSAFE_VAULT_DIR",
1625            Some(vaults.as_os_str().to_str().unwrap()),
1626            || {
1627                assert_eq!(
1628                    resolve_browser_profile("github.com").unwrap().as_deref(),
1629                    Some("work")
1630                );
1631                assert_eq!(
1632                    resolve_browser_profile("jira.corp.example")
1633                        .unwrap()
1634                        .as_deref(),
1635                    Some("corp")
1636                );
1637                assert_eq!(
1638                    resolve_browser_profile("login.deep.corp.example")
1639                        .unwrap()
1640                        .as_deref(),
1641                    Some("deep")
1642                );
1643                assert!(resolve_browser_profile("corp.example").unwrap().is_none());
1644                assert!(resolve_browser_profile("unknown.example")
1645                    .unwrap()
1646                    .is_none());
1647            },
1648        );
1649    }
1650
1651    // ── edit_distance ──────────────────────────────────────────────────────────
1652
1653    #[test]
1654    fn edit_distance_identical_strings_is_zero() {
1655        assert_eq!(edit_distance("paypal.com", "paypal.com"), 0);
1656        assert_eq!(edit_distance("", ""), 0);
1657    }
1658
1659    #[test]
1660    fn edit_distance_single_substitution_is_one() {
1661        // '1' instead of 'l' — classic zero-to-letter swap
1662        assert_eq!(edit_distance("paypa1.com", "paypal.com"), 1);
1663        // one char off at the end
1664        assert_eq!(edit_distance("github.co", "github.com"), 1);
1665    }
1666
1667    #[test]
1668    fn edit_distance_unrelated_domains_exceeds_threshold() {
1669        assert!(edit_distance("amazon.com", "paypal.com") > 1);
1670        assert!(edit_distance("example.org", "google.com") > 1);
1671    }
1672
1673    // ── lookalike_check ────────────────────────────────────────────────────────
1674
1675    fn profiles_fixture() -> Vec<(String, String)> {
1676        vec![
1677            ("paypal.com".into(), "finance".into()),
1678            ("github.com".into(), "work".into()),
1679            ("*.corp.example".into(), "corp".into()),
1680        ]
1681    }
1682
1683    #[test]
1684    fn lookalike_check_exact_match_returns_none() {
1685        let result = lookalike_check("paypal.com", &profiles_fixture());
1686        assert!(
1687            result.is_none(),
1688            "exact match should not trigger phishing warning"
1689        );
1690    }
1691
1692    #[test]
1693    fn lookalike_check_typosquat_returns_match() {
1694        // paypa1.com is 1 edit away from paypal.com
1695        let result = lookalike_check("paypa1.com", &profiles_fixture());
1696        assert!(
1697            result.is_some(),
1698            "typosquat should trigger phishing warning"
1699        );
1700        let m = result.unwrap();
1701        assert_eq!(m.registered, "paypal.com");
1702        assert_eq!(m.edit_distance, 1);
1703    }
1704
1705    #[test]
1706    fn lookalike_check_www_prefix_stripped() {
1707        // www.paypa1.com should still match paypal.com after stripping www.
1708        let result = lookalike_check("www.paypa1.com", &profiles_fixture());
1709        assert!(result.is_some());
1710        assert_eq!(result.unwrap().registered, "paypal.com");
1711    }
1712
1713    #[test]
1714    fn lookalike_check_unrelated_domain_returns_none() {
1715        let result = lookalike_check("totally-different.io", &profiles_fixture());
1716        assert!(
1717            result.is_none(),
1718            "unrelated domain should not trigger phishing warning"
1719        );
1720    }
1721
1722    #[test]
1723    fn lookalike_check_skips_wildcard_patterns() {
1724        // A 1-edit lookalike of the wildcard suffix shouldn't be flagged
1725        // (wildcards are for subdomains, not canonical domain names)
1726        let result = lookalike_check("corp.exampl", &profiles_fixture());
1727        assert!(result.is_none(), "wildcard patterns should be skipped");
1728    }
1729
1730    #[test]
1731    fn browser_hostname_fill_guard_accepts_normal_hosts() {
1732        assert!(browser_hostname_fill_guard("github.com").is_ok());
1733        assert!(browser_hostname_fill_guard("login.deep.corp.example.").is_ok());
1734    }
1735
1736    #[test]
1737    fn browser_hostname_fill_guard_rejects_garbage() {
1738        assert_eq!(browser_hostname_fill_guard(""), Err("empty hostname"));
1739        assert_eq!(browser_hostname_fill_guard("   "), Err("empty hostname"));
1740        let long_label = format!("{}.com", "a".repeat(64));
1741        assert_eq!(
1742            browser_hostname_fill_guard(&long_label),
1743            Err("hostname label too long")
1744        );
1745        let many = (0..14)
1746            .map(|i| format!("l{i}"))
1747            .collect::<Vec<_>>()
1748            .join(".");
1749        assert_eq!(
1750            browser_hostname_fill_guard(&many),
1751            Err("too many hostname labels")
1752        );
1753        assert_eq!(
1754            browser_hostname_fill_guard("bad-.example.com"),
1755            Err("hostname label has invalid hyphen placement")
1756        );
1757    }
1758
1759    #[test]
1760    fn browser_hostname_fill_guard_rejects_punycode_labels() {
1761        // The IDN-homoglyph attack vector that survives the ASCII-only check.
1762        // `xn--pyal-9ja.com` is ASCII but encodes Cyrillic confusables of
1763        // `paypal.com`. Reject all xn-- labels until we ship full IDN support.
1764        assert_eq!(
1765            browser_hostname_fill_guard("xn--pyal-9ja.com"),
1766            Err("punycode/IDN labels not supported (post-v1)")
1767        );
1768        assert_eq!(
1769            browser_hostname_fill_guard("login.xn--anything-9ja.com"),
1770            Err("punycode/IDN labels not supported (post-v1)")
1771        );
1772        // Case-insensitive: lowercased before the prefix check.
1773        assert_eq!(
1774            browser_hostname_fill_guard("XN--PYAL-9JA.COM"),
1775            Err("punycode/IDN labels not supported (post-v1)")
1776        );
1777        // Existing ASCII edit-distance attacks (paypa1.com vs paypal.com) are
1778        // unrelated to this guard — they're caught downstream by lookalike_check.
1779        assert!(browser_hostname_fill_guard("paypa1.com").is_ok());
1780    }
1781}