1use 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
63fn 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
70fn 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
77fn 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
88pub 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
99pub 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
110pub 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
118pub fn audit_dir() -> PathBuf {
120 app_state_dir().join("audit")
121}
122
123pub 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
134pub 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
207pub 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
217pub 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
228pub 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
246pub 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
260pub 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
271pub 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
287pub 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
298pub 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
307pub 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
318pub 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
333pub 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
344pub 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
357pub 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
368pub 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
377pub 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
388pub 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
412pub 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
428pub 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
458pub 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
466pub 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
474pub fn vault_path(profile: &str) -> PathBuf {
476 vault_dir().join(format!("{profile}.vault"))
477}
478
479pub fn audit_log_path(profile: &str) -> PathBuf {
481 audit_dir().join(format!("{profile}.audit.jsonl"))
482}
483
484pub 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
533pub fn browser_profiles_path() -> PathBuf {
535 vault_dir().join("browser-profiles.json")
536}
537
538pub 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 if label.starts_with("xn--") {
580 return Err("punycode/IDN labels not supported (post-v1)");
581 }
582 }
583 Ok(())
584}
585
586pub 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
611pub 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
649pub 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
666pub fn profile_exists(profile: &str) -> bool {
668 vault_path(profile).exists()
669}
670
671pub 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 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
691pub 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
714pub struct ProfileMeta {
715 #[serde(default, skip_serializing_if = "Option::is_none")]
717 pub description: Option<String>,
718 pub created_at: chrono::DateTime<chrono::Utc>,
720 pub last_modified: chrono::DateTime<chrono::Utc>,
722 #[serde(default)]
724 pub is_protected: bool,
725}
726
727impl ProfileMeta {
728 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 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
751pub fn profile_meta_path(profile: &str) -> PathBuf {
753 vault_dir().join(format!("{profile}.meta.json"))
754}
755
756pub 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
767pub 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
780pub 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
790pub 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
798pub 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#[derive(Debug, Serialize, Deserialize)]
817pub struct ProfileBundle {
818 pub version: u8,
820 pub profile: String,
822 pub exported_at: chrono::DateTime<chrono::Utc>,
824 #[serde(default, skip_serializing_if = "Option::is_none")]
826 pub meta: Option<ProfileMeta>,
827 pub salt: String,
829 pub nonce: String,
831 pub ciphertext: String,
833}
834
835impl ProfileBundle {
836 const NONCE_LEN: usize = 24;
837 const SALT_LEN: usize = 32;
838 const KDF_M_COST: u32 = 32_768; const KDF_T_COST: u32 = 2;
842 const KDF_P_COST: u32 = 1;
843
844 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 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 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
889pub 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
940pub 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 if let Some(meta) = &bundle.meta {
1010 write_profile_meta(profile_name, meta)?;
1011 }
1012
1013 Ok(profile_name.to_string())
1014}
1015
1016fn 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
1046fn strip_www_prefix(host: &str) -> &str {
1048 host.strip_prefix("www.").unwrap_or(host)
1049}
1050
1051pub struct LookalikeMatch {
1053 pub registered: String,
1055 pub edit_distance: usize,
1057}
1058
1059pub 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 if registered_pattern.starts_with("*.") {
1078 continue;
1079 }
1080 let registered_lower = registered_pattern.to_ascii_lowercase();
1081 let registered = strip_www_prefix(®istered_lower);
1082
1083 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 static PROFILE_TEST_ENV_LOCK: Mutex<()> = Mutex::new(());
1108
1109 #[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 assert!(!is_profile_protected("prod"));
1163
1164 set_profile_protected("prod", true).unwrap();
1165 assert!(is_profile_protected("prod"));
1166
1167 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 let meta2 = ensure_profile_meta("newprofile").unwrap();
1189 assert_eq!(meta.created_at, meta2.created_at);
1190 },
1191 );
1192 }
1193
1194 #[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 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 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 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 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_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 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()); 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()); },
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 #[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 assert_eq!(edit_distance("paypa1.com", "paypal.com"), 1);
1663 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 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 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 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 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 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 assert_eq!(
1774 browser_hostname_fill_guard("XN--PYAL-9JA.COM"),
1775 Err("punycode/IDN labels not supported (post-v1)")
1776 );
1777 assert!(browser_hostname_fill_guard("paypa1.com").is_ok());
1780 }
1781}