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, EvidenceVersion, HeartbeatState, StampState,
INIT_GEN_ENV, INIT_VERSION_ENV,
};
use crate::shell::rc::{self, HookPresence, ShellEnv};
pub const PROBE_MARKER: &str = "dodot-probe-stamp:";
pub const PROBE_FIELD_SEP: char = '|';
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 struct ProbeStamp {
pub generation: u64,
pub version: EvidenceVersion,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProbeOutcome {
Stamp(ProbeStamp),
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{PROBE_FIELD_SEP}%s\\n' \
\"${{{INIT_GEN_ENV}-}}\" \"${{{INIT_VERSION_ENV}-}}\""
)
}
pub fn parse_probe_output(stdout: &str) -> Option<ProbeStamp> {
stdout
.lines()
.filter_map(|line| line.trim().strip_prefix(PROBE_MARKER))
.filter_map(|record| record.split_once(PROBE_FIELD_SEP))
.filter_map(|(generation, version)| {
Some(ProbeStamp {
generation: activation::parse_generation(generation)?,
version: EvidenceVersion::from_field(Some(version)),
})
})
.next_back()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpawnCapture {
pub stdout: String,
pub stderr: String,
pub status: Option<i32>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SpawnOutcome {
Finished(SpawnCapture),
TimedOut,
SpawnFailed(String),
}
pub fn spawn_captured(mut command: Command, timeout: Duration) -> SpawnOutcome {
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 SpawnOutcome::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 status = loop {
match child.try_wait() {
Ok(Some(status)) => break Some(status),
Ok(None) => {}
Err(e) => return SpawnOutcome::SpawnFailed(format!("{e}")),
}
if Instant::now() >= deadline {
kill_process_group(pid);
let _ = child.wait();
break None;
}
std::thread::sleep(POLL_INTERVAL);
};
let stdout = stdout.and_then(|h| h.join().ok()).unwrap_or_default();
let stderr = stderr.and_then(|h| h.join().ok()).unwrap_or_default();
let Some(status) = status else {
return SpawnOutcome::TimedOut;
};
SpawnOutcome::Finished(SpawnCapture {
stdout,
stderr,
status: status.code(),
})
}
pub fn run(shell: &Path, timeout: Duration) -> ProbeOutcome {
let mut command = Command::new(shell);
command.arg("-ic").arg(probe_command());
match spawn_captured(command, timeout) {
SpawnOutcome::SpawnFailed(e) => ProbeOutcome::SpawnFailed(e),
SpawnOutcome::TimedOut => ProbeOutcome::TimedOut,
SpawnOutcome::Finished(capture) => match parse_probe_output(&capture.stdout) {
Some(stamp) => ProbeOutcome::Stamp(stamp),
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 },
VersionSkew {
generation: u64,
loaded: EvidenceVersion,
},
Broken { diagnosis: Diagnosis },
Unverified { reason: String },
}
impl Verdict {
pub fn from_outcome(
outcome: ProbeOutcome,
reference: Option<u64>,
running: &str,
hook: Option<(HookPresence, String)>,
) -> Verdict {
match outcome {
ProbeOutcome::Stamp(stamp) if activation::is_skewed(Some(&stamp.version), running) => {
Verdict::VersionSkew {
generation: stamp.generation,
loaded: stamp.version,
}
}
ProbeOutcome::Stamp(stamp) => {
let found = stamp.generation;
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>,
evidence_line: &str,
hook_line: &str,
script_has_contributions: bool,
) -> Option<ActivationNotice> {
match self {
Verdict::Verified { .. } | Verdict::VersionSkew { .. } => {
let state = activation::refine(
ActivationState::Healthy,
matches!(self, Verdict::VersionSkew { .. }),
script_has_contributions,
);
Some(ActivationNotice::for_state(
state,
hook_line,
None,
evidence_line.into(),
))
}
Verdict::Broken { diagnosis } => Some(ActivationNotice {
state: ActivationState::VerifiedBroken.as_str().into(),
severity: "error".into(),
message: activation::VERIFIED_BROKEN_MESSAGE.into(),
evidence: evidence_line.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 stale_line = evidence
.as_ref()
.map(|n| n.evidence.clone())
.unwrap_or_else(|| "Never loaded.".into());
let has_contributions = activation::read_script(fs, paths)
.is_some_and(|script| crate::shell::script_has_contributions(&script));
let Some(shell) = shell_env.shell.as_deref().map(Path::new) else {
return Verdict::Unverified {
reason: "$SHELL is not set".into(),
}
.notice(evidence, &stale_line, &hook_line, has_contributions);
};
eprintln!("{}", announcement(shell));
let outcome = run(shell, timeout);
let hook = rc::scan_expected_rc(fs, paths.home_dir(), shell_env, rc_override);
let evidence_line =
activation::Evidence::collect(fs, paths, activation::EnvStamp::default(), reference, false)
.map(|e| e.evidence_line())
.unwrap_or(stale_line);
Verdict::from_outcome(outcome, reference, activation::running_version(), hook).notice(
evidence,
&evidence_line,
&hook_line,
has_contributions,
)
}
pub fn notice_with_probe(
fs: &dyn Fs,
paths: &dyn Pather,
policy: &ProbePolicy,
shell_env: &ShellEnv,
env_stamp: &activation::EnvStamp,
reference_for_gate: Option<u64>,
tty: bool,
) -> Option<ActivationNotice> {
let timeout = policy
.timeout()
.filter(|_| fs.exists(&paths.init_script_path()))
.filter(|_| {
gate_says_probe(
activation::classify_stamp(env_stamp.generation, reference_for_gate),
activation::classify_heartbeat(
activation::read_heartbeat(fs, paths).map(|h| h.generation),
reference_for_gate,
),
)
});
let evidence = activation::notice_for(
fs,
paths,
env_stamp.clone(),
reference_for_gate,
tty && timeout.is_none(),
shell_env,
);
let Some(timeout) = timeout else {
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:?}"
);
}
}
fn stamp(generation: u64, version: &str) -> ProbeStamp {
ProbeStamp {
generation,
version: EvidenceVersion::Known(version.into()),
}
}
#[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|5.6.0\n"
);
assert_eq!(
parse_probe_output(&noisy),
Some(stamp(1_755_200_000, "5.6.0"))
);
assert_eq!(
parse_probe_output(&format!("{PROBE_MARKER}1755200000|\n")),
Some(ProbeStamp {
generation: 1_755_200_000,
version: EvidenceVersion::PreVersion,
})
);
assert_eq!(parse_probe_output(&format!("{PROBE_MARKER}|\n")), None);
assert_eq!(parse_probe_output(&format!("{PROBE_MARKER}|5.6.0\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()))
}
const RUNNING: &str = "5.6.0";
const DEPLOYED: bool = true;
#[test]
fn a_current_stamp_is_a_measured_verification() {
let v = Verdict::from_outcome(
ProbeOutcome::Stamp(stamp(100, RUNNING)),
Some(100),
RUNNING,
hook(HookPresence::ManagedBlock),
);
assert_eq!(v, Verdict::Verified { generation: 100 });
let notice = v
.notice(
None,
"Last loaded just now by dodot 5.6.0.",
"HOOK",
DEPLOYED,
)
.unwrap();
assert_eq!(notice.state, "healthy");
assert_eq!(notice.severity, "ok");
assert_eq!(notice.message, activation::HEALTHY_MESSAGE);
assert_eq!(notice.evidence, "Last loaded just now by dodot 5.6.0.");
}
#[test]
fn a_current_generation_from_another_dodot_is_skew_not_health() {
for loaded in [
EvidenceVersion::Known("5.0.0".into()),
EvidenceVersion::PreVersion,
] {
let v = Verdict::from_outcome(
ProbeOutcome::Stamp(ProbeStamp {
generation: 100,
version: loaded.clone(),
}),
Some(100),
RUNNING,
hook(HookPresence::Manual),
);
assert_eq!(
v,
Verdict::VersionSkew {
generation: 100,
loaded: loaded.clone()
},
"a current generation from {loaded} is not a verification"
);
let notice = v
.notice(
None,
"Last loaded just now by dodot 5.0.0.",
"HOOK",
DEPLOYED,
)
.unwrap();
assert_eq!(notice.state, "version-skew");
assert_eq!(notice.severity, "warning");
assert_eq!(
notice.message,
"Shell hookup: your shells load a different dodot."
);
assert!(notice.hint.unwrap().contains("PATH finds first"));
}
}
#[test]
fn a_measured_activation_of_an_empty_script_is_not_reported_healthy() {
let v = Verdict::from_outcome(
ProbeOutcome::Stamp(stamp(100, RUNNING)),
Some(100),
RUNNING,
hook(HookPresence::ManagedBlock),
);
assert_eq!(v, Verdict::Verified { generation: 100 });
let deployed = v
.notice(None, "Last loaded just now.", "HOOK", DEPLOYED)
.unwrap();
assert_eq!(deployed.state, "healthy");
let empty = v
.notice(None, "Last loaded just now.", "HOOK", false)
.unwrap();
assert_eq!(
empty.state, "empty-script",
"the spawn proved the hookup fires; it proved nothing about what it deploys"
);
assert_eq!(
empty.message,
"Shell hookup: wired, but no packs are deployed."
);
}
#[test]
fn a_measured_skew_outranks_an_empty_script() {
let v = Verdict::VersionSkew {
generation: 100,
loaded: EvidenceVersion::Known("5.0.0".into()),
};
let notice = v
.notice(None, "Last loaded just now.", "HOOK", false)
.unwrap();
assert_eq!(notice.state, "version-skew");
}
#[test]
fn skew_outranks_a_stale_generation_the_way_the_evidence_path_does() {
let v = Verdict::from_outcome(
ProbeOutcome::Stamp(stamp(90, "5.0.0")),
Some(100),
RUNNING,
hook(HookPresence::ManagedBlock),
);
assert_eq!(
v,
Verdict::VersionSkew {
generation: 90,
loaded: EvidenceVersion::Known("5.0.0".into())
}
);
}
#[test]
fn the_bound_release_does_not_claim_skew_on_a_version_less_stamp() {
let v = Verdict::from_outcome(
ProbeOutcome::Stamp(ProbeStamp {
generation: 100,
version: EvidenceVersion::PreVersion,
}),
Some(100),
activation::PRE_VERSION_RELEASE,
hook(HookPresence::ManagedBlock),
);
assert_eq!(v, Verdict::Verified { generation: 100 });
}
#[test]
fn no_stamp_plus_no_hook_names_the_file_and_the_command() {
let v = Verdict::from_outcome(
ProbeOutcome::NoStamp,
Some(100),
RUNNING,
hook(HookPresence::Absent),
);
assert_eq!(
v,
Verdict::Broken {
diagnosis: Diagnosis::HookAbsent {
rc: "~/.zshrc".into()
}
}
);
let notice = v.notice(None, "Never loaded.", "HOOK", DEPLOYED).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), RUNNING, hook(presence));
let hint = v
.notice(None, "Never loaded.", "HOOK", DEPLOYED)
.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), RUNNING, None);
assert_eq!(
v,
Verdict::Broken {
diagnosis: Diagnosis::Unknown
}
);
let hint = v
.notice(None, "Never loaded.", "THE-HOOK-LINE", DEPLOYED)
.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(stamp(90, RUNNING)),
Some(100),
RUNNING,
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: "Shell hookup: no shell has loaded dodot yet.".into(),
hint: Some("Add this to your rc file: HOOK".into()),
evidence: "Never loaded.".into(),
};
for outcome in [
ProbeOutcome::TimedOut,
ProbeOutcome::SpawnFailed("no such file".into()),
] {
let v = Verdict::from_outcome(outcome, Some(100), RUNNING, hook(HookPresence::Absent));
let notice = v
.notice(Some(evidence.clone()), "Never loaded.", "HOOK", DEPLOYED)
.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, "Never loaded.", "HOOK", DEPLOYED), 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_both_halves_of_the_stamp() {
let env = TempEnvironment::builder().build();
let shell = fake_shell(
&env,
"activating-shell",
&format!("export {INIT_GEN_ENV}=1755200000\nexport {INIT_VERSION_ENV}=5.6.0"),
);
assert_eq!(
run(&shell, Duration::from_secs(10)),
ProbeOutcome::Stamp(stamp(1_755_200_000, "5.6.0"))
);
}
#[test]
fn a_shell_activating_another_dodot_measures_as_skew() {
for (name, exports, loaded) in [
(
"older-dodot-shell",
format!("export {INIT_GEN_ENV}=1755200000\nexport {INIT_VERSION_ENV}=5.0.0"),
EvidenceVersion::Known("5.0.0".into()),
),
(
"pre-version-dodot-shell",
format!("export {INIT_GEN_ENV}=1755200000"),
EvidenceVersion::PreVersion,
),
] {
let env = TempEnvironment::builder().build();
let shell = fake_shell(&env, name, &exports);
let outcome = run(&shell, Duration::from_secs(10));
assert_eq!(
outcome,
ProbeOutcome::Stamp(ProbeStamp {
generation: 1_755_200_000,
version: loaded.clone(),
}),
"{name}"
);
let verdict = Verdict::from_outcome(
outcome,
Some(1_755_200_000),
RUNNING,
hook(HookPresence::Manual),
);
assert_eq!(
verdict,
Verdict::VersionSkew {
generation: 1_755_200_000,
loaded
},
"{name}: a fresh generation from the wrong dodot is not a verification"
);
}
}
#[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\
export {INIT_VERSION_ENV}=5.6.0\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(stamp(42, "5.6.0"))
);
}
#[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(_)
));
}
}