use std::path::{Path, PathBuf};
pub const ALLOW_HOST_STATE_ENV: &str = "TRUSTY_MPM_ALLOW_HOST_STATE";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HostStateAccess {
RealEnvironment,
OptedIn,
ScratchEnvironment {
effective_home: PathBuf,
passwd_home: PathBuf,
},
Indeterminate {
detail: String,
},
}
impl HostStateAccess {
pub fn is_allowed(&self) -> bool {
matches!(self, Self::RealEnvironment | Self::OptedIn)
}
pub fn skip_reason(&self) -> Option<String> {
match self {
Self::RealEnvironment | Self::OptedIn => None,
Self::ScratchEnvironment {
effective_home,
passwd_home,
} => Some(format!(
"$HOME is {} but this user's real home is {} — tmux and the host process \
table are shared system state that $HOME does not isolate, so reaching them \
would touch the operator's real sessions and project directories (#5784). \
Set {ALLOW_HOST_STATE_ENV}=1 to allow it.",
effective_home.display(),
passwd_home.display()
)),
Self::Indeterminate { detail } => Some(format!(
"cannot tell whether this is the operator's real environment ({detail}), so \
shared host state is left alone rather than risk touching real sessions and \
project directories (#5784). Set {ALLOW_HOST_STATE_ENV}=1 to allow it."
)),
}
}
}
pub fn classify(
effective_home: Option<&Path>,
passwd_home: Option<&Path>,
opt_in: bool,
) -> HostStateAccess {
if opt_in {
return HostStateAccess::OptedIn;
}
match (effective_home, passwd_home) {
(Some(effective), Some(passwd)) if same_home(effective, passwd) => {
HostStateAccess::RealEnvironment
}
(Some(effective), Some(passwd)) => HostStateAccess::ScratchEnvironment {
effective_home: effective.to_path_buf(),
passwd_home: passwd.to_path_buf(),
},
(None, _) => HostStateAccess::Indeterminate {
detail: "$HOME is unset or empty".to_string(),
},
(_, None) => HostStateAccess::Indeterminate {
detail: "the OS password database has no home for this uid".to_string(),
},
}
}
fn same_home(a: &Path, b: &Path) -> bool {
if let (Ok(a), Ok(b)) = (a.canonicalize(), b.canonicalize()) {
return a == b;
}
trim_trailing_separators(a) == trim_trailing_separators(b)
}
fn trim_trailing_separators(path: &Path) -> PathBuf {
let raw = path.to_string_lossy();
let trimmed = raw.trim_end_matches(std::path::MAIN_SEPARATOR);
if trimmed.is_empty() {
path.to_path_buf()
} else {
PathBuf::from(trimmed)
}
}
pub fn host_state_access() -> HostStateAccess {
#[cfg(not(unix))]
{
return host_state_access_non_unix();
}
#[cfg(unix)]
classify(
effective_home().as_deref(),
passwd_home().as_deref(),
opt_in_enabled(),
)
}
fn effective_home() -> Option<PathBuf> {
std::env::var_os("HOME")
.filter(|v| !v.is_empty())
.map(PathBuf::from)
}
#[cfg(unix)]
fn passwd_home() -> Option<PathBuf> {
match nix::unistd::User::from_uid(nix::unistd::Uid::current()) {
Ok(Some(user)) if !user.dir.as_os_str().is_empty() => Some(user.dir),
Ok(_) => None,
Err(e) => {
tracing::debug!("#5784: password-database lookup for the current uid failed: {e}");
None
}
}
}
#[cfg(not(unix))]
fn host_state_access_non_unix() -> HostStateAccess {
HostStateAccess::RealEnvironment
}
fn opt_in_enabled() -> bool {
std::env::var(ALLOW_HOST_STATE_ENV)
.ok()
.map(|v| crate::core::auto_resume::parse_truthy(v.trim()))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_opt_in_allows() {
let access = classify(
Some(Path::new("/tmp/scratch-home")),
Some(Path::new("/Users/real")),
true,
);
assert_eq!(access, HostStateAccess::OptedIn);
assert!(access.is_allowed());
assert!(access.skip_reason().is_none());
}
#[test]
fn classify_matching_homes_allow() {
let access = classify(
Some(Path::new("/Users/real")),
Some(Path::new("/Users/real")),
false,
);
assert_eq!(access, HostStateAccess::RealEnvironment);
assert!(access.is_allowed());
}
#[test]
fn classify_mismatched_homes_block() {
let access = classify(
Some(Path::new("/tmp/scratch-home")),
Some(Path::new("/Users/real")),
false,
);
assert!(!access.is_allowed(), "a reassigned $HOME must block");
assert!(matches!(access, HostStateAccess::ScratchEnvironment { .. }));
}
#[test]
fn classify_missing_home_blocks() {
let access = classify(None, Some(Path::new("/Users/real")), false);
assert!(!access.is_allowed());
assert!(matches!(access, HostStateAccess::Indeterminate { .. }));
}
#[test]
fn classify_missing_passwd_home_blocks() {
let access = classify(Some(Path::new("/Users/real")), None, false);
assert!(!access.is_allowed());
assert!(matches!(access, HostStateAccess::Indeterminate { .. }));
}
#[test]
fn classify_trailing_separator_still_matches() {
let access = classify(
Some(Path::new("/Users/real/")),
Some(Path::new("/Users/real")),
false,
);
assert_eq!(access, HostStateAccess::RealEnvironment);
}
#[test]
fn same_home_rejects_siblings() {
assert!(!same_home(
Path::new("/Users/real"),
Path::new("/Users/rea")
));
assert!(!same_home(
Path::new("/Users/real"),
Path::new("/Users/real2")
));
}
#[test]
fn skip_reason_names_the_hatch() {
for access in [
classify(
Some(Path::new("/tmp/scratch-home")),
Some(Path::new("/Users/real")),
false,
),
classify(None, None, false),
] {
let reason = access
.skip_reason()
.expect("a block arm must explain itself");
assert!(
reason.contains(ALLOW_HOST_STATE_ENV),
"skip reason must name the opt-in: {reason}"
);
}
}
}