use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use super::redact_secret;
const SECRET_MARKERS: &[&str] = &[
"TOKEN",
"SECRET",
"PASS",
"_KEY",
"CREDENTIAL",
"AUTH",
"COOKIE",
"SESSION",
];
const CONFIG_REDIRECTS: &[&str] = &[
"CLAUDE_CONFIG_DIR",
"CODEX_HOME",
"DOCKER_CONFIG",
"GIT_CONFIG_GLOBAL",
"NETRC",
];
fn looks_secret(name: &str, value: &OsStr) -> bool {
let upper = name.to_ascii_uppercase();
SECRET_MARKERS.iter().any(|m| upper.contains(m))
|| upper.ends_with("_PAT")
|| CONFIG_REDIRECTS.contains(&upper.as_str())
|| crate::credential_registry::is_registered_credential_env_var(name)
|| value.to_str().is_some_and(has_url_userinfo)
}
fn has_url_userinfo(value: &str) -> bool {
value.match_indices("://").any(|(at, _)| {
let rest = &value[at + 3..];
let authority = rest.split(['/', '?', '#', ' ']).next().unwrap_or("");
authority.contains('@')
})
}
static SEQ: AtomicU64 = AtomicU64::new(0);
static ACTIVE: AtomicUsize = AtomicUsize::new(0);
pub(crate) fn is_active() -> bool {
ACTIVE.load(Ordering::SeqCst) > 0
}
#[must_use = "the sandbox is torn down when this value drops"]
pub struct CredentialSandbox {
root: PathBuf,
saved: Vec<(OsString, Option<OsString>)>,
}
impl CredentialSandbox {
#[track_caller]
pub fn enter() -> Self {
let root = std::env::temp_dir().join(format!(
"trusty-credential-sandbox-{}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos()),
SEQ.fetch_add(1, Ordering::Relaxed)
));
for sub in ["home", "config/gh", "data", "cache"] {
std::fs::create_dir_all(root.join(sub)).unwrap_or_else(|e| {
panic!("credential sandbox: cannot create {}: {e}", root.display())
});
}
ACTIVE.fetch_add(1, Ordering::SeqCst);
let mut sandbox = Self {
root,
saved: Vec::new(),
};
let ambient: Vec<OsString> = std::env::vars_os()
.filter(|(k, v)| k.to_str().is_none_or(|k| looks_secret(k, v)))
.map(|(k, _)| k)
.collect();
for name in ambient {
sandbox.remove_os(&name);
}
let root = sandbox.root.clone();
sandbox.set("HOME", root.join("home"));
sandbox.set("XDG_CONFIG_HOME", root.join("config"));
sandbox.set("XDG_DATA_HOME", root.join("data"));
sandbox.set("XDG_CACHE_HOME", root.join("cache"));
sandbox.set("GH_CONFIG_DIR", root.join("config/gh"));
sandbox.set("TRUSTY_DATA_DIR_OVERRIDE", root.join("data"));
sandbox.set("GIT_CONFIG_NOSYSTEM", "1");
sandbox.set("GIT_TERMINAL_PROMPT", "0");
let _ = super::dotenv::skip_env_local_load();
sandbox.verify();
sandbox
}
#[track_caller]
fn verify(&self) {
let home = self.home();
assert!(
dirs::home_dir().as_deref() == Some(home.as_path()),
"credential sandbox: $HOME did not move to {}",
home.display()
);
let leaked: Vec<String> = std::env::vars_os()
.filter(|(k, v)| k.to_str().is_none_or(|k| looks_secret(k, v)))
.map(|(k, _)| k.to_string_lossy().into_owned())
.collect();
assert!(
leaked.is_empty(),
"credential sandbox: credential variables survived (names only): {leaked:?}"
);
}
pub fn home(&self) -> PathBuf {
self.root.join("home")
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn set(&mut self, key: &str, value: impl AsRef<OsStr>) {
self.remember(OsStr::new(key));
unsafe { std::env::set_var(key, value) };
}
pub fn remove(&mut self, key: &str) {
self.remove_os(OsStr::new(key));
}
fn remove_os(&mut self, key: &OsStr) {
self.remember(key);
unsafe { std::env::remove_var(key) };
}
fn remember(&mut self, key: &OsStr) {
if !self.saved.iter().any(|(k, _)| k == key) {
self.saved.push((key.to_os_string(), std::env::var_os(key)));
}
}
}
impl Drop for CredentialSandbox {
fn drop(&mut self) {
for (key, prior) in self.saved.drain(..).rev() {
unsafe {
match prior {
Some(v) => std::env::set_var(&key, v),
None => std::env::remove_var(&key),
}
}
}
let _ = std::fs::remove_dir_all(&self.root);
ACTIVE.fetch_sub(1, Ordering::SeqCst);
}
}
#[track_caller]
pub fn assert_secret_eq(actual: Option<&str>, expected: Option<&str>, what: &str) {
if actual != expected {
let shown = actual.map_or_else(
|| "None".to_string(),
|v| format!("Some({})", redact_secret(v)),
);
panic!("{what}: expected {expected:?}, got {shown} (actual value redacted)");
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
#[test]
#[serial]
fn the_sandbox_clears_credentials_and_restores_them() {
clears_and_restores();
}
#[serial(dotenv_credential_env, inference_env)]
fn clears_and_restores() {
let _env = crate::data_dir::ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
const VAR: &str = "TRUSTY_9123_PROBE_API_KEY";
const URL_VAR: &str = "TRUSTY_9123_PROBE_REMOTE";
let real_home = std::env::var_os("HOME");
unsafe {
std::env::set_var(VAR, "outside-value");
std::env::set_var(URL_VAR, "https://x-access-token:probe@example.test/o/r.git");
}
{
let mut sandbox = CredentialSandbox::enter();
assert!(std::env::var_os(VAR).is_none(), "the credential leaked in");
assert!(
std::env::var_os(URL_VAR).is_none(),
"the URL credential leaked in"
);
assert_eq!(std::env::var_os("HOME"), Some(sandbox.home().into()));
assert!(std::env::var_os("PATH").is_some(), "PATH must survive");
sandbox.set(VAR, "inside-value");
assert_eq!(std::env::var(VAR).as_deref(), Ok("inside-value"));
}
assert_eq!(std::env::var(VAR).as_deref(), Ok("outside-value"));
assert!(
std::env::var_os(URL_VAR).is_some(),
"the URL variable was not restored"
);
assert_eq!(std::env::var_os("HOME"), real_home);
unsafe {
std::env::remove_var(VAR);
std::env::remove_var(URL_VAR);
}
}
#[test]
#[serial]
fn the_sandbox_reads_no_env_local() {
reads_no_env_local();
}
#[serial(dotenv_credential_env, inference_env)]
fn reads_no_env_local() {
let _env = crate::data_dir::ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let sandbox = CredentialSandbox::enter();
std::fs::write(
sandbox.home().join(".env.local"),
"TRUSTY_9123_DOTENV_TOKEN=from-env-local\n",
)
.expect("write a user-tier .env.local");
super::super::load_env_local_once();
assert!(
std::env::var_os("TRUSTY_9123_DOTENV_TOKEN").is_none(),
"the loader read a .env.local inside the sandbox"
);
assert!(
!super::super::dotenv::skip_env_local_load(),
"the sandbox must leave the loader latched"
);
}
#[test]
#[serial]
fn the_sandbox_hides_env_local_and_the_keychain() {
hides_env_local_and_the_keychain();
}
#[serial(dotenv_credential_env, inference_env)]
fn hides_env_local_and_the_keychain() {
let _env = crate::data_dir::ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let repo = tempfile::TempDir::new().expect("repo");
std::fs::create_dir(repo.path().join(".git")).expect(".git");
std::fs::write(repo.path().join(".env.local"), "TRUSTY_9123_PROBE=v\n").expect("write");
let read = || super::super::dotenv::env_local_value_from(repo.path(), "TRUSTY_9123_PROBE");
assert_eq!(read().as_deref(), Some("v"), "the probe must be readable");
assert!(super::super::resolver::keychain_allowed());
{
let _sandbox = CredentialSandbox::enter();
assert!(is_active());
assert_eq!(read(), None, "a sandboxed read reached .env.local");
assert!(!super::super::resolver::keychain_allowed());
assert_eq!(std::env::var("GIT_CONFIG_NOSYSTEM").as_deref(), Ok("1"));
assert_eq!(std::env::var("GIT_TERMINAL_PROMPT").as_deref(), Ok("0"));
}
assert!(!is_active(), "the flag outlived the sandbox");
assert_eq!(read().as_deref(), Some("v"));
}
#[test]
fn a_failed_secret_assert_never_prints_the_value() {
let secret = "sk-9123-0123456789abcdef0123456789";
let panic = std::panic::catch_unwind(|| {
assert_secret_eq(Some(secret), Some("expected"), "probe");
})
.expect_err("a mismatch must panic");
let msg = panic
.downcast_ref::<String>()
.expect("panic! with format args carries a String");
assert!(!msg.contains(secret), "the message echoed the value");
assert!(!msg.contains("0123456789"), "the message echoed its tail");
assert!(msg.contains("expected"), "the message lost the expectation");
}
}