use std::io::Read;
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use crate::fs::Fs;
use crate::paths::Pather;
use crate::shell::activation::{
self, ActivationNotice, ActivationState, HeartbeatState, StampState, INIT_GEN_ENV,
};
use crate::shell::rc::{self, HookPresence, ShellEnv};
pub const PROBE_MARKER: &str = "dodot-probe-gen:";
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
const SCRUB_PREFIX: &str = "DODOT_INIT_";
const POLL_INTERVAL: Duration = Duration::from_millis(20);
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ProbePolicy {
#[default]
Never,
Gated { timeout: Duration },
}
impl ProbePolicy {
pub fn production() -> Self {
ProbePolicy::Gated {
timeout: DEFAULT_TIMEOUT,
}
}
pub fn timeout(&self) -> Option<Duration> {
match self {
ProbePolicy::Never => None,
ProbePolicy::Gated { timeout } => Some(*timeout),
}
}
}
pub fn gate_says_probe(stamp: StampState, heartbeat: HeartbeatState) -> bool {
!matches!(stamp, StampState::Current) && !matches!(heartbeat, HeartbeatState::Fresh)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProbeOutcome {
Stamp(u64),
NoStamp,
TimedOut,
SpawnFailed(String),
}
pub fn announcement(shell: &Path) -> String {
let name = shell
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("your shell");
format!("verifying shell integration ({name})…")
}
fn probe_command() -> String {
format!("printf '{PROBE_MARKER}%s\\n' \"${{{INIT_GEN_ENV}-}}\"")
}
pub fn parse_probe_output(stdout: &str) -> Option<u64> {
stdout
.lines()
.filter_map(|line| line.trim().strip_prefix(PROBE_MARKER))
.filter_map(activation::parse_generation)
.next_back()
}
pub fn run(shell: &Path, timeout: Duration) -> ProbeOutcome {
let mut command = Command::new(shell);
command
.arg("-ic")
.arg(probe_command())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for key in scrubbed_keys(std::env::vars().map(|(k, _)| k)) {
command.env_remove(key);
}
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.process_group(0);
}
let mut child = match command.spawn() {
Ok(c) => c,
Err(e) => return ProbeOutcome::SpawnFailed(format!("{e}")),
};
let pid = child.id();
let stdout = child.stdout.take().map(drain);
let stderr = child.stderr.take().map(drain);
let deadline = Instant::now() + timeout;
let timed_out = loop {
match child.try_wait() {
Ok(Some(_)) => break false,
Ok(None) => {}
Err(e) => return ProbeOutcome::SpawnFailed(format!("{e}")),
}
if Instant::now() >= deadline {
kill_process_group(pid);
let _ = child.wait();
break true;
}
std::thread::sleep(POLL_INTERVAL);
};
let captured = stdout.and_then(|h| h.join().ok()).unwrap_or_default();
drop(stderr.map(|h| h.join()));
if timed_out {
return ProbeOutcome::TimedOut;
}
match parse_probe_output(&captured) {
Some(generation) => ProbeOutcome::Stamp(generation),
None => ProbeOutcome::NoStamp,
}
}
fn drain<R: Read + Send + 'static>(mut pipe: R) -> std::thread::JoinHandle<String> {
std::thread::spawn(move || {
let mut buf = Vec::new();
let _ = pipe.read_to_end(&mut buf);
String::from_utf8_lossy(&buf).into_owned()
})
}
pub fn scrubbed_keys(keys: impl Iterator<Item = String>) -> Vec<String> {
keys.filter(|k| k.starts_with(SCRUB_PREFIX)).collect()
}
#[cfg(unix)]
fn kill_process_group(pid: u32) {
unsafe {
libc::kill(-(pid as libc::pid_t), libc::SIGKILL);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Diagnosis {
HookAbsent { rc: String },
HookNotReached { rc: String },
StaleScript { found: u64, expected: u64 },
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Verdict {
Verified { generation: u64 },
Broken { diagnosis: Diagnosis },
Unverified { reason: String },
}
impl Verdict {
pub fn from_outcome(
outcome: ProbeOutcome,
reference: Option<u64>,
hook: Option<(HookPresence, String)>,
) -> Verdict {
match outcome {
ProbeOutcome::Stamp(found) => {
match activation::classify_stamp(Some(found), reference) {
StampState::Current => Verdict::Verified { generation: found },
_ => Verdict::Broken {
diagnosis: Diagnosis::StaleScript {
found,
expected: reference.unwrap_or(found),
},
},
}
}
ProbeOutcome::NoStamp => Verdict::Broken {
diagnosis: match hook {
Some((presence, rc)) if presence.is_present() => {
Diagnosis::HookNotReached { rc }
}
Some((_, rc)) => Diagnosis::HookAbsent { rc },
None => Diagnosis::Unknown,
},
},
ProbeOutcome::TimedOut => Verdict::Unverified {
reason: "your shell did not finish starting up in time".into(),
},
ProbeOutcome::SpawnFailed(e) => Verdict::Unverified {
reason: format!("could not run your shell ({e})"),
},
}
}
pub fn notice(
&self,
evidence: Option<ActivationNotice>,
hook_line: &str,
) -> Option<ActivationNotice> {
match self {
Verdict::Verified { .. } => Some(ActivationNotice {
state: ActivationState::Healthy.as_str().into(),
severity: "ok".into(),
message: "Shell hookup verified: a new shell loads dodot.".into(),
hint: None,
}),
Verdict::Broken { diagnosis } => Some(ActivationNotice {
state: ActivationState::VerifiedBroken.as_str().into(),
severity: "error".into(),
message: activation::VERIFIED_BROKEN_MESSAGE.into(),
hint: Some(match diagnosis {
Diagnosis::HookAbsent { rc } => format!(
"The dodot hook is missing from {rc} — run `dodot install --write` to add it."
),
Diagnosis::HookNotReached { rc } => format!(
"The dodot hook is in {rc} but was never reached — something earlier in \
that file is failing before it."
),
Diagnosis::StaleScript { found, expected } => format!(
"Your shell sourced an older init script (generation {found}, current is \
{expected}) — check for a second dodot hook or a stale copy."
),
Diagnosis::Unknown => format!(
"dodot could not tell which rc file your shell reads. Add this line to it: \
{hook_line}"
),
}),
}),
Verdict::Unverified { reason } => {
let mut notice = evidence?;
notice.hint = Some(match notice.hint.take() {
Some(hint) => format!(
"{hint} (dodot could not verify by running your shell — {reason} — so this \
reports your configuration, not measured activation.)"
),
None => format!(
"dodot could not verify by running your shell — {reason} — so this reports \
your configuration, not measured activation."
),
});
Some(notice)
}
}
}
}
pub fn measure(
fs: &dyn Fs,
paths: &dyn Pather,
timeout: Duration,
shell_env: &ShellEnv,
rc_override: Option<&Path>,
reference: Option<u64>,
evidence: Option<ActivationNotice>,
) -> Option<ActivationNotice> {
let hook_line = activation::hook_line(&paths.init_script_path(), paths.home_dir());
let Some(shell) = shell_env.shell.as_deref().map(Path::new) else {
return Verdict::Unverified {
reason: "$SHELL is not set".into(),
}
.notice(evidence, &hook_line);
};
eprintln!("{}", announcement(shell));
let outcome = run(shell, timeout);
let hook = rc::scan_expected_rc(fs, paths.home_dir(), shell_env, rc_override);
Verdict::from_outcome(outcome, reference, hook).notice(evidence, &hook_line)
}
pub fn notice_with_probe(
fs: &dyn Fs,
paths: &dyn Pather,
policy: &ProbePolicy,
shell_env: &ShellEnv,
env_stamp: Option<u64>,
reference_for_gate: Option<u64>,
quiet_ok: bool,
) -> Option<ActivationNotice> {
let evidence = activation::notice_for(
fs,
paths,
env_stamp,
reference_for_gate,
quiet_ok,
false,
shell_env,
);
let Some(timeout) = policy.timeout() else {
return evidence;
};
if !fs.exists(&paths.init_script_path()) {
return evidence;
}
let stamp = activation::classify_stamp(env_stamp, reference_for_gate);
let heartbeat =
activation::classify_heartbeat(activation::read_heartbeat(fs, paths), reference_for_gate);
if !gate_says_probe(stamp, heartbeat) {
return evidence;
}
let reference = activation::read_script_generation(fs, paths);
measure(fs, paths, timeout, shell_env, None, reference, evidence)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_gate_fires_only_when_both_cheap_signals_are_inconclusive() {
use HeartbeatState as H;
use StampState as S;
let matrix = [
(S::Current, H::Absent, false),
(S::Current, H::Fresh, false),
(S::Current, H::Old, false),
(S::Absent, H::Fresh, false),
(S::Stale, H::Fresh, false),
(S::Absent, H::Absent, true),
(S::Absent, H::Old, true),
(S::Stale, H::Old, true),
(S::Stale, H::Absent, true),
];
for (stamp, heartbeat, expected) in matrix {
assert_eq!(
gate_says_probe(stamp, heartbeat),
expected,
"stamp={stamp:?} heartbeat={heartbeat:?}"
);
}
}
#[test]
fn the_stamp_is_read_out_of_arbitrary_rc_noise() {
let noisy = format!(
"Welcome to your shell!\n[oh-my-zsh] update available\n{PROBE_MARKER}1755200000\n"
);
assert_eq!(parse_probe_output(&noisy), Some(1_755_200_000));
assert_eq!(parse_probe_output(&format!("{PROBE_MARKER}\n")), None);
assert_eq!(parse_probe_output("nothing at all\n"), None);
}
#[test]
fn only_the_init_stamp_family_is_scrubbed() {
let keys = [
"DODOT_INIT_GEN",
"DODOT_INIT_ANYTHING",
"DODOT_DATA_DIR",
"PATH",
"HOME",
]
.into_iter()
.map(String::from);
assert_eq!(
scrubbed_keys(keys),
vec!["DODOT_INIT_GEN".to_string(), "DODOT_INIT_ANYTHING".into()]
);
}
#[test]
fn the_announcement_names_the_shell_being_run() {
assert_eq!(
announcement(Path::new("/bin/zsh")),
"verifying shell integration (zsh)…"
);
}
fn hook(presence: HookPresence) -> Option<(HookPresence, String)> {
Some((presence, "~/.zshrc".to_string()))
}
#[test]
fn a_current_stamp_is_a_measured_verification() {
let v = Verdict::from_outcome(
ProbeOutcome::Stamp(100),
Some(100),
hook(HookPresence::ManagedBlock),
);
assert_eq!(v, Verdict::Verified { generation: 100 });
let notice = v.notice(None, "HOOK").unwrap();
assert_eq!(notice.state, "healthy");
assert_eq!(notice.severity, "ok");
assert!(notice.message.contains("verified"), "{}", notice.message);
}
#[test]
fn no_stamp_plus_no_hook_names_the_file_and_the_command() {
let v = Verdict::from_outcome(ProbeOutcome::NoStamp, Some(100), hook(HookPresence::Absent));
assert_eq!(
v,
Verdict::Broken {
diagnosis: Diagnosis::HookAbsent {
rc: "~/.zshrc".into()
}
}
);
let notice = v.notice(None, "HOOK").unwrap();
assert_eq!(notice.state, "verified-broken");
assert_eq!(notice.severity, "error");
let hint = notice.hint.unwrap();
assert!(hint.contains("~/.zshrc"), "{hint}");
assert!(hint.contains("dodot install --write"), "{hint}");
}
#[test]
fn no_stamp_with_the_hook_present_blames_the_rc_file_instead() {
for presence in [HookPresence::ManagedBlock, HookPresence::Manual] {
let v = Verdict::from_outcome(ProbeOutcome::NoStamp, Some(100), hook(presence));
let hint = v.notice(None, "HOOK").unwrap().hint.unwrap();
assert!(
hint.contains("never reached"),
"{presence:?} should diagnose a broken rc, not a missing hook: {hint}"
);
assert!(
!hint.contains("dodot install --write"),
"adding the hook again fixes nothing here: {hint}"
);
}
}
#[test]
fn an_unknown_shell_falls_back_to_the_hook_line() {
let v = Verdict::from_outcome(ProbeOutcome::NoStamp, Some(100), None);
assert_eq!(
v,
Verdict::Broken {
diagnosis: Diagnosis::Unknown
}
);
let hint = v.notice(None, "THE-HOOK-LINE").unwrap().hint.unwrap();
assert!(hint.contains("THE-HOOK-LINE"), "{hint}");
}
#[test]
fn a_stale_sourced_script_is_reported_as_such() {
let v = Verdict::from_outcome(
ProbeOutcome::Stamp(90),
Some(100),
hook(HookPresence::ManagedBlock),
);
assert_eq!(
v,
Verdict::Broken {
diagnosis: Diagnosis::StaleScript {
found: 90,
expected: 100
}
}
);
}
#[test]
fn a_failed_measurement_degrades_to_the_evidence_notice() {
let evidence = ActivationNotice {
state: "never-activated".into(),
severity: "warning".into(),
message: "Deployed, but no shell has loaded dodot yet.".into(),
hint: Some("Add this to your rc file: HOOK".into()),
};
for outcome in [
ProbeOutcome::TimedOut,
ProbeOutcome::SpawnFailed("no such file".into()),
] {
let v = Verdict::from_outcome(outcome, Some(100), hook(HookPresence::Absent));
let notice = v.notice(Some(evidence.clone()), "HOOK").unwrap();
assert_eq!(notice.state, "never-activated");
assert_eq!(notice.severity, "warning");
let hint = notice.hint.unwrap();
assert!(hint.starts_with("Add this to your rc file: HOOK"), "{hint}");
assert!(hint.contains("could not verify"), "{hint}");
assert!(hint.contains("not measured activation"), "{hint}");
}
}
#[test]
fn a_failed_measurement_with_nothing_to_degrade_to_stays_silent() {
let v = Verdict::Unverified {
reason: "boom".into(),
};
assert_eq!(v.notice(None, "HOOK"), None);
}
use crate::testing::TempEnvironment;
use std::path::PathBuf;
fn fake_shell(env: &TempEnvironment, name: &str, rc_behaviour: &str) -> PathBuf {
let path = env.home.join(name);
let script = format!("#!/bin/sh\n{rc_behaviour}\neval \"$2\"\n");
env.fs
.write_file_with_mode(&path, script.as_bytes(), 0o755)
.unwrap();
path
}
#[test]
fn a_shell_that_activates_reports_the_stamp() {
let env = TempEnvironment::builder().build();
let shell = fake_shell(
&env,
"activating-shell",
&format!("export {INIT_GEN_ENV}=1755200000"),
);
assert_eq!(
run(&shell, Duration::from_secs(10)),
ProbeOutcome::Stamp(1_755_200_000)
);
}
#[test]
fn a_shell_with_no_hook_reports_no_stamp() {
let env = TempEnvironment::builder().build();
let shell = fake_shell(&env, "bare-shell", "echo 'welcome to your shell'");
assert_eq!(run(&shell, Duration::from_secs(10)), ProbeOutcome::NoStamp);
}
#[test]
fn rc_noise_and_a_nonzero_exit_do_not_fail_a_successful_probe() {
let env = TempEnvironment::builder().build();
let path = env.home.join("noisy-shell");
let script = format!(
"#!/bin/sh\n\
echo 'error: some unrelated rc line failed' >&2\n\
echo 'p10k wants your attention'\n\
export {INIT_GEN_ENV}=42\n\
eval \"$2\"\n\
exit 3\n"
);
env.fs
.write_file_with_mode(&path, script.as_bytes(), 0o755)
.unwrap();
assert_eq!(run(&path, Duration::from_secs(10)), ProbeOutcome::Stamp(42));
}
#[test]
fn a_hanging_rc_times_out_and_takes_its_children_with_it() {
let env = TempEnvironment::builder().build();
let pidfile = env.home.join("grandchild.pid");
let path = env.home.join("hanging-shell");
let script = format!(
"#!/bin/sh\nsh -c 'echo $$ > {pid}; sleep 300' &\nsleep 300\n",
pid = pidfile.display()
);
env.fs
.write_file_with_mode(&path, script.as_bytes(), 0o755)
.unwrap();
let start = Instant::now();
let outcome = run(&path, Duration::from_millis(500));
assert_eq!(outcome, ProbeOutcome::TimedOut);
assert!(
start.elapsed() < Duration::from_secs(30),
"the timeout must not wait out the rc file: {:?}",
start.elapsed()
);
let pid: i32 = wait_for_pidfile(&env, &pidfile);
assert!(
wait_until_dead(pid),
"pid {pid} survived the timeout: the process *group* was not killed"
);
}
fn wait_for_pidfile(env: &TempEnvironment, pidfile: &Path) -> i32 {
for _ in 0..100 {
if let Ok(text) = env.fs.read_to_string(pidfile) {
if let Ok(pid) = text.trim().parse() {
return pid;
}
}
std::thread::sleep(Duration::from_millis(20));
}
panic!("the fake shell never recorded its background child's pid");
}
fn wait_until_dead(pid: i32) -> bool {
for _ in 0..100 {
let alive = unsafe { libc::kill(pid, 0) } == 0;
if !alive {
return true;
}
std::thread::sleep(Duration::from_millis(20));
}
false
}
#[test]
fn an_inherited_stamp_is_scrubbed_before_the_child_sees_it() {
let env = TempEnvironment::builder().build();
let _guard = crate::testing::EnvVarGuard::set(INIT_GEN_ENV, "999999");
let shell = fake_shell(&env, "inheriting-shell", "# sources nothing");
assert_eq!(
run(&shell, Duration::from_secs(10)),
ProbeOutcome::NoStamp,
"an inherited stamp must not count as this shell's activation"
);
}
#[test]
fn a_shell_that_cannot_be_run_is_a_couldnt_verify_not_a_panic() {
let env = TempEnvironment::builder().build();
let missing = env.home.join("no-such-shell");
assert!(matches!(
run(&missing, Duration::from_secs(5)),
ProbeOutcome::SpawnFailed(_)
));
}
}