use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use crate::keychain_guard::{KeychainIsolation, TestKeychain};
static HOME_TEST_LOCK: Mutex<()> = Mutex::new(());
const COMMON_ENVS: &[&str] = &[
"WIREMUX_NO_SHIPPED_PRESETS",
"WIREMUX_PROFILE_DIR",
"XDG_CONFIG_HOME",
"CLAUDE_CODE_OAUTH_TOKEN",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"OPENROUTER_API_KEY",
"XAI_API_KEY",
"GROK_API_KEY",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
];
pub struct IsolatedHome {
dir: tempfile::TempDir,
saved: Vec<(OsString, Option<OsString>)>,
_keychain: KeychainIsolation,
_test_keychain: TestKeychain,
_lock: std::sync::MutexGuard<'static, ()>,
}
#[derive(Debug, Clone)]
pub enum PlantCredentials<'a> {
Claude {
access: &'a str,
refresh: Option<&'a str>,
expires_at_ms: Option<u64>,
},
JsonPointer {
relative_path: &'a str,
document: serde_json::Value,
},
}
impl IsolatedHome {
pub fn new() -> Self {
Self::with_extra_envs(&[])
}
pub fn with_extra_envs(extra: &[&str]) -> Self {
let lock = HOME_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().expect("IsolatedHome tempdir");
let mut names: Vec<&str> = COMMON_ENVS.to_vec();
names.extend_from_slice(extra);
names.push("HOME");
names.push("USERPROFILE");
let mut saved = Vec::new();
let mut seen = std::collections::BTreeSet::new();
for name in names {
if !seen.insert(name) {
continue;
}
let key = OsString::from(name);
saved.push((key.clone(), std::env::var_os(&key)));
}
unsafe {
std::env::set_var("HOME", dir.path());
std::env::set_var("USERPROFILE", dir.path());
for name in COMMON_ENVS.iter().chain(extra.iter()) {
std::env::remove_var(name);
}
}
Self {
dir,
saved,
_keychain: KeychainIsolation::hold(),
_test_keychain: TestKeychain::hold(),
_lock: lock,
}
}
#[must_use]
pub fn path(&self) -> &Path {
self.dir.path()
}
pub fn set_env(&self, key: &str, value: &str) {
unsafe {
std::env::set_var(key, value);
}
}
pub fn plant_credentials(&self, spec: PlantCredentials<'_>) -> PathBuf {
match spec {
PlantCredentials::Claude {
access,
refresh,
expires_at_ms,
} => {
let path = PathBuf::from(".claude/.credentials.json");
let abs = self.path().join(&path);
if let Some(parent) = abs.parent() {
std::fs::create_dir_all(parent).expect("mkdir .claude");
}
let refresh = refresh.unwrap_or("");
let expires = expires_at_ms.unwrap_or(4_000_000_000_000);
let doc = serde_json::json!({
"claudeAiOauth": {
"accessToken": access,
"refreshToken": refresh,
"expiresAt": expires
},
"otherField": "keep-me"
});
std::fs::write(&abs, serde_json::to_vec_pretty(&doc).expect("json"))
.expect("plant claude credentials");
abs
}
PlantCredentials::JsonPointer {
relative_path,
document,
} => {
let abs = self.path().join(relative_path);
if let Some(parent) = abs.parent() {
std::fs::create_dir_all(parent).expect("mkdir plant parents");
}
std::fs::write(&abs, serde_json::to_vec_pretty(&document).expect("json"))
.expect("plant json-pointer store");
abs
}
}
}
pub fn plant_keychain(&self, service: &str, account: &str, secret: &serde_json::Value) {
crate::keychain_guard::test_keychain_set(
service,
account,
&serde_json::to_string(secret).expect("keychain json"),
);
}
}
impl Default for IsolatedHome {
fn default() -> Self {
Self::new()
}
}
impl Drop for IsolatedHome {
fn drop(&mut self) {
unsafe {
for (key, prev) in self.saved.drain(..) {
match prev {
Some(v) => std::env::set_var(&key, v),
None => std::env::remove_var(&key),
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn isolated_home_sets_and_restores_home() {
let before = std::env::var_os("HOME");
{
let home = IsolatedHome::new();
assert_eq!(
std::env::var_os("HOME").as_deref(),
Some(home.path().as_os_str())
);
assert!(crate::keychain_guard::keychain_disabled());
}
assert_eq!(std::env::var_os("HOME"), before);
}
#[test]
fn plant_claude_and_json_pointer() {
let home = IsolatedHome::new();
let claude = home.plant_credentials(PlantCredentials::Claude {
access: "sk-ant-oat01-plant",
refresh: Some("rt-plant"),
expires_at_ms: None,
});
assert!(claude.starts_with(home.path()));
let text = std::fs::read_to_string(&claude).unwrap();
assert!(text.contains("sk-ant-oat01-plant"));
assert!(text.contains("keep-me"));
let json = home.plant_credentials(PlantCredentials::JsonPointer {
relative_path: ".config/wiremux/other-vendor.json",
document: serde_json::json!({"tokens":{"access":"a","refresh":"r"}}),
});
assert!(json.ends_with("other-vendor.json"));
let doc: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&json).unwrap()).unwrap();
assert_eq!(doc["tokens"]["access"].as_str(), Some("a"));
}
}