diff --git a/src/config/app_config.rs b/src/config/app_config.rs index a363ca8..61646fe 100644 --- a/src/config/app_config.rs +++ b/src/config/app_config.rs @@ -274,10 +274,25 @@ impl AppConfig { pub fn vault_password_file(&self) -> PathBuf { match &self.vault_password_file { - Some(path) => match path.exists() { - true => path.clone(), - false => gman::config::Config::local_provider_password_file(), - }, + Some(path) => { + if path.exists() { + return path.clone(); + } + + if let Some(translated) = paths::translate_sandboxed_home_path(path) + && translated.exists() + { + info!( + "vault_password_file '{}' not found; resolved to sandboxed path '{}'", + path.display(), + translated.display() + ); + + return translated; + } + + gman::config::Config::local_provider_password_file() + } None => gman::config::Config::local_provider_password_file(), } } diff --git a/src/config/paths.rs b/src/config/paths.rs index 1578225..d694870 100644 --- a/src/config/paths.rs +++ b/src/config/paths.rs @@ -41,6 +41,70 @@ pub fn sandbox_kit_override() -> Option { env::var_os(get_env_name("sandbox_kit")).map(PathBuf::from) } +pub fn translate_sandboxed_home_path(path: &Path) -> Option { + if env::var_os("IS_SANDBOX").is_none() { + return None; + } + + let s = path.to_str()?; + + if let Some(translated) = translate_unix_home_style(s, "/home/") { + return Some(translated); + } + + if let Some(translated) = translate_unix_home_style(s, "/Users/") { + return Some(translated); + } + + translate_windows_users_path(s) +} + +fn translate_unix_home_style(s: &str, prefix: &str) -> Option { + let rest = s.strip_prefix(prefix)?; + let (user, tail) = match rest.split_once('/') { + Some((u, t)) => (u, t), + None => (rest, ""), + }; + + if user.is_empty() || user == "agent" { + return None; + } + + Some(if tail.is_empty() { + PathBuf::from("/home/agent") + } else { + PathBuf::from(format!("/home/agent/{tail}")) + }) +} + +fn translate_windows_users_path(s: &str) -> Option { + let bytes = s.as_bytes(); + if bytes.len() < 4 + || !bytes[0].is_ascii_alphabetic() + || bytes[1] != b':' + || bytes[2] != b'\\' + { + return None; + } + + let after_drive = &s[3..]; + let rest = after_drive.strip_prefix("Users\\")?; + let (user, tail) = match rest.split_once('\\') { + Some((u, t)) => (u, t.replace('\\', "/")), + None => (rest, String::new()), + }; + + if user.is_empty() || user == "agent" { + return None; + } + + Some(if tail.is_empty() { + PathBuf::from("/home/agent") + } else { + PathBuf::from(format!("/home/agent/{tail}")) + }) +} + pub fn sbx_mixin_file() -> PathBuf { config_dir().join(SBX_MIXIN_FILE_NAME) } @@ -407,6 +471,173 @@ mod tests { } } + mod sandbox_home_translation { + use super::*; + use serial_test::serial; + + fn with_sandbox(f: F) { + let prev = env::var_os("IS_SANDBOX"); + unsafe { + env::set_var("IS_SANDBOX", "1"); + } + f(); + unsafe { + match prev { + Some(v) => env::set_var("IS_SANDBOX", v), + None => env::remove_var("IS_SANDBOX"), + } + } + } + + fn without_sandbox(f: F) { + let prev = env::var_os("IS_SANDBOX"); + unsafe { + env::remove_var("IS_SANDBOX"); + } + f(); + unsafe { + if let Some(v) = prev { + env::set_var("IS_SANDBOX", v); + } + } + } + + #[test] + #[serial] + fn returns_none_when_not_in_sandbox() { + without_sandbox(|| { + let p = Path::new("/home/atusa/.coyote_password"); + assert_eq!(translate_sandboxed_home_path(p), None); + }); + } + + #[test] + #[serial] + fn translates_host_home_to_agent_home() { + with_sandbox(|| { + let p = Path::new("/home/atusa/.coyote_password"); + assert_eq!( + translate_sandboxed_home_path(p), + Some(PathBuf::from("/home/agent/.coyote_password")) + ); + }); + } + + #[test] + #[serial] + fn translates_nested_host_home_path() { + with_sandbox(|| { + let p = Path::new("/home/atusa/.config/coyote/.password"); + assert_eq!( + translate_sandboxed_home_path(p), + Some(PathBuf::from("/home/agent/.config/coyote/.password")) + ); + }); + } + + #[test] + #[serial] + fn returns_none_when_path_already_targets_agent_home() { + with_sandbox(|| { + let p = Path::new("/home/agent/.coyote_password"); + assert_eq!(translate_sandboxed_home_path(p), None); + }); + } + + #[test] + #[serial] + fn returns_none_when_path_is_outside_home() { + with_sandbox(|| { + let p = Path::new("/etc/coyote/.coyote_password"); + assert_eq!(translate_sandboxed_home_path(p), None); + }); + } + + #[test] + #[serial] + fn returns_none_for_relative_path() { + with_sandbox(|| { + let p = Path::new(".coyote_password"); + assert_eq!(translate_sandboxed_home_path(p), None); + }); + } + + #[test] + #[serial] + fn returns_none_for_first_segment_not_home() { + with_sandbox(|| { + let p = Path::new("/opt/atusa/.coyote_password"); + assert_eq!(translate_sandboxed_home_path(p), None); + }); + } + + #[test] + #[serial] + fn translates_macos_users_path() { + with_sandbox(|| { + let p = Path::new("/Users/atusa/.coyote_password"); + assert_eq!( + translate_sandboxed_home_path(p), + Some(PathBuf::from("/home/agent/.coyote_password")) + ); + }); + } + + #[test] + #[serial] + fn translates_macos_nested_path() { + with_sandbox(|| { + let p = Path::new("/Users/atusa/.config/coyote/.password"); + assert_eq!( + translate_sandboxed_home_path(p), + Some(PathBuf::from("/home/agent/.config/coyote/.password")) + ); + }); + } + + #[test] + #[serial] + fn returns_none_when_macos_path_already_targets_agent() { + with_sandbox(|| { + let p = Path::new("/Users/agent/.coyote_password"); + assert_eq!(translate_sandboxed_home_path(p), None); + }); + } + + #[test] + #[serial] + fn translates_windows_drive_letter_path() { + with_sandbox(|| { + let p = Path::new("C:\\Users\\atusa\\.coyote_password"); + assert_eq!( + translate_sandboxed_home_path(p), + Some(PathBuf::from("/home/agent/.coyote_password")) + ); + }); + } + + #[test] + #[serial] + fn translates_windows_nested_path() { + with_sandbox(|| { + let p = Path::new("D:\\Users\\atusa\\.config\\coyote\\.password"); + assert_eq!( + translate_sandboxed_home_path(p), + Some(PathBuf::from("/home/agent/.config/coyote/.password")) + ); + }); + } + + #[test] + #[serial] + fn returns_none_when_windows_path_already_targets_agent() { + with_sandbox(|| { + let p = Path::new("C:\\Users\\agent\\.coyote_password"); + assert_eq!(translate_sandboxed_home_path(p), None); + }); + } + } + #[test] fn sandbox_kit_override_reflects_env_var_state() { let env_name = get_env_name("sandbox_kit"); diff --git a/src/vault/mod.rs b/src/vault/mod.rs index d2e2c81..a8e9ca7 100644 --- a/src/vault/mod.rs +++ b/src/vault/mod.rs @@ -17,7 +17,7 @@ use gman::providers::SecretProvider; use gman::providers::SupportedProvider; use gman::providers::local::LocalProvider; use inquire::{Password, PasswordDisplayMode, required}; -use log::warn; +use log::{info, warn}; use serde_yaml::Value; use std::sync::{Arc, LazyLock}; use tokio::runtime::Handle; @@ -25,6 +25,31 @@ use uuid::Uuid; pub static SECRET_RE: LazyLock = LazyLock::new(|| Regex::new(r"\{\{([^{}]+)}}").unwrap()); +fn apply_sandboxed_home_translation(provider_def: &mut LocalProvider) { + let Some(ref pf) = provider_def.password_file else { + return; + }; + + if pf.exists() { + return; + } + + let Some(translated) = paths::translate_sandboxed_home_path(pf) else { + return; + }; + + if !translated.exists() { + return; + } + + info!( + "vault password file '{}' not found; resolved to sandboxed path '{}'", + pf.display(), + translated.display() + ); + provider_def.password_file = Some(translated); +} + #[derive(Debug, Default, Clone)] pub struct Vault { pub(crate) provider: SupportedProvider, @@ -92,6 +117,7 @@ impl Vault { }; if let SupportedProvider::Local { provider_def } = &mut provider { + apply_sandboxed_home_translation(provider_def); ensure_password_file_initialized(provider_def)?; }