use std::path::PathBuf;
pub const SESSION_ID_ENV: &str = "JARVY_WIZARD_SESSION_ID";
const MAX_TOKEN_AGE_SECS: u64 = 10 * 60;
pub struct WizardSessionGuard {
token_path: Option<PathBuf>,
}
impl WizardSessionGuard {
pub fn activate(session_id: &str) -> Self {
match token_path_for(session_id) {
Ok(path) => {
if let Some(parent) = path.parent() {
let _ = crate::paths::ensure_dir_0700(parent);
}
match std::fs::write(&path, session_id) {
Ok(()) => {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) = std::fs::set_permissions(
&path,
std::fs::Permissions::from_mode(0o600),
) {
if crate::observability::telemetry_gate::is_enabled() {
tracing::warn!(
event = "wizard.session_token_perms_unsafe",
path = %path.display(),
error = %e,
fs_hint = "chmod_failed",
);
}
} else if let Ok(meta) = std::fs::metadata(&path) {
let mode = meta.permissions().mode() & 0o777;
if mode != 0o600
&& crate::observability::telemetry_gate::is_enabled()
{
tracing::warn!(
event = "wizard.session_token_perms_unsafe",
path = %path.display(),
mode = format!("{mode:o}"),
fs_hint = "chmod_ignored",
);
}
}
}
Self {
token_path: Some(path),
}
}
Err(e) => {
if crate::observability::telemetry_gate::is_enabled() {
tracing::warn!(
event = "wizard.session_token_activate_failed",
error = %e,
path = %path.display(),
);
}
Self { token_path: None }
}
}
}
Err(_) => Self { token_path: None },
}
}
}
impl Drop for WizardSessionGuard {
fn drop(&mut self) {
if let Some(path) = self.token_path.take() {
let _ = std::fs::remove_file(&path);
}
}
}
pub fn token_path_for(session_id: &str) -> Result<PathBuf, crate::paths::NoHomeDir> {
Ok(crate::paths::state_dir()?.join(format!("wizard-session-{session_id}.active")))
}
pub fn is_active() -> bool {
let _guard = tracing::debug_span!("wizard.session.check").entered();
let Ok(session_id) = std::env::var(SESSION_ID_ENV) else {
emit_refused("env_missing", "");
return false;
};
if session_id.is_empty() {
emit_refused("env_empty", "");
return false;
}
let Ok(path) = token_path_for(&session_id) else {
emit_refused("no_home", &session_id);
return false;
};
let Ok(meta) = std::fs::metadata(&path) else {
emit_refused("marker_missing", &session_id);
return false;
};
let Ok(mtime) = meta.modified() else {
emit_refused("mtime_unavailable", &session_id);
return false;
};
let session_id_owned = std::env::var(SESSION_ID_ENV).unwrap_or_default();
match mtime.elapsed() {
Ok(age) => {
if age.as_secs() <= MAX_TOKEN_AGE_SECS {
true
} else {
emit_refused("marker_stale", &session_id_owned);
false
}
}
Err(_) => match std::time::SystemTime::now().duration_since(mtime) {
Err(future_delta) => {
let allowed = future_delta.duration().as_secs() <= MAX_FORWARD_SKEW_SECS;
if !allowed {
emit_refused("marker_future_mtime", &session_id_owned);
}
allowed
}
Ok(_) => true,
},
}
}
const MAX_FORWARD_SKEW_SECS: u64 = 5;
fn emit_refused(reason: &'static str, session_id: &str) {
if crate::observability::telemetry_gate::is_enabled() {
tracing::debug!(
event = "wizard.session.bypass_refused",
reason = reason,
session_id = %session_id,
);
}
}
#[cfg(test)]
#[allow(unsafe_code)] mod tests {
use super::*;
#[test]
#[serial_test::serial(jarvy_home_env)]
fn guard_drop_removes_the_marker_file() {
let tmp = tempfile::TempDir::new().unwrap();
unsafe { std::env::set_var("JARVY_HOME", tmp.path()) };
let session_id = uuid::Uuid::now_v7().to_string();
let path = token_path_for(&session_id).unwrap();
assert!(!path.exists(), "precondition: token doesn't yet exist");
{
let _guard = WizardSessionGuard::activate(&session_id);
assert!(path.exists(), "activate must create the marker file");
}
assert!(
!path.exists(),
"guard Drop must remove the marker file (RAII contract) — \
found stale token at {path:?}"
);
unsafe { std::env::remove_var("JARVY_HOME") };
}
#[test]
#[serial_test::serial(jarvy_home_env)]
fn is_active_returns_true_when_marker_exists_and_env_set() {
let tmp = tempfile::TempDir::new().unwrap();
unsafe { std::env::set_var("JARVY_HOME", tmp.path()) };
let session_id = uuid::Uuid::now_v7().to_string();
unsafe { std::env::set_var(SESSION_ID_ENV, &session_id) };
let _guard = WizardSessionGuard::activate(&session_id);
assert!(
is_active(),
"is_active must return true when env var + marker file both present"
);
unsafe { std::env::remove_var(SESSION_ID_ENV) };
unsafe { std::env::remove_var("JARVY_HOME") };
}
#[test]
#[serial_test::serial(jarvy_home_env)]
fn is_active_returns_false_when_env_set_but_marker_missing() {
let tmp = tempfile::TempDir::new().unwrap();
unsafe { std::env::set_var("JARVY_HOME", tmp.path()) };
unsafe { std::env::set_var(SESSION_ID_ENV, "orphaned-session") };
assert!(
!is_active(),
"orphaned env var without a marker file must NOT authorise \
the bypass — that's the whole point of the marker check"
);
unsafe { std::env::remove_var(SESSION_ID_ENV) };
unsafe { std::env::remove_var("JARVY_HOME") };
}
#[test]
#[serial_test::serial(jarvy_home_env)]
fn is_active_returns_false_when_env_missing() {
unsafe { std::env::remove_var(SESSION_ID_ENV) };
assert!(
!is_active(),
"no SESSION_ID_ENV → no bypass, regardless of any marker \
files on disk"
);
}
#[test]
#[serial_test::serial(jarvy_home_env)]
fn is_active_returns_false_when_env_empty_string() {
unsafe { std::env::set_var(SESSION_ID_ENV, "") };
assert!(
!is_active(),
"empty session id is not a valid identifier — must refuse"
);
unsafe { std::env::remove_var(SESSION_ID_ENV) };
}
#[test]
#[serial_test::serial(jarvy_home_env)]
fn is_active_returns_false_for_marker_older_than_max_age() {
let tmp = tempfile::TempDir::new().unwrap();
unsafe { std::env::set_var("JARVY_HOME", tmp.path()) };
let session_id = uuid::Uuid::now_v7().to_string();
let path = token_path_for(&session_id).unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, &session_id).unwrap();
let old =
std::time::SystemTime::now() - std::time::Duration::from_secs(MAX_TOKEN_AGE_SECS + 60);
filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(old))
.expect("set_file_mtime must succeed on the temp file");
unsafe { std::env::set_var(SESSION_ID_ENV, &session_id) };
assert!(
!is_active(),
"marker older than MAX_TOKEN_AGE_SECS ({}s) MUST refuse — \
the whole point of the age check is to catch stale tokens \
from crashed / killed wizards whose Drop didn't run",
MAX_TOKEN_AGE_SECS
);
let _ = std::fs::remove_file(&path);
unsafe { std::env::remove_var(SESSION_ID_ENV) };
unsafe { std::env::remove_var("JARVY_HOME") };
}
#[test]
#[serial_test::serial(jarvy_home_env)]
fn is_active_refuses_future_mtime_beyond_clock_skew() {
let tmp = tempfile::TempDir::new().unwrap();
unsafe { std::env::set_var("JARVY_HOME", tmp.path()) };
let session_id = uuid::Uuid::now_v7().to_string();
let path = token_path_for(&session_id).unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, &session_id).unwrap();
let far_future =
std::time::SystemTime::now() + std::time::Duration::from_secs(24 * 60 * 60);
filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(far_future)).unwrap();
unsafe { std::env::set_var(SESSION_ID_ENV, &session_id) };
assert!(
!is_active(),
"24-hour-future mtime must be treated as forged, not as \
clock skew — a wizard that just started never has an mtime \
hours ahead of now"
);
let _ = std::fs::remove_file(&path);
unsafe { std::env::remove_var(SESSION_ID_ENV) };
unsafe { std::env::remove_var("JARVY_HOME") };
}
#[test]
#[serial_test::serial(jarvy_home_env)]
fn activate_degrades_gracefully_when_state_dir_write_fails() {
let tmp = tempfile::TempDir::new().unwrap();
let bogus = tmp.path().join("home-is-a-file");
std::fs::write(&bogus, "").unwrap();
unsafe { std::env::set_var("JARVY_HOME", &bogus) };
let guard = WizardSessionGuard::activate("test-uuid");
drop(guard);
unsafe { std::env::remove_var("JARVY_HOME") };
}
}