use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, RecvTimeoutError};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
pub const CACHE_SCHEMA: u32 = 2;
pub const CACHE_FILENAME: &str = "bash-enter-capability";
const PHASE_TIMEOUT: Duration = Duration::from_secs(6);
const MARKER_TIMEOUT: Duration = Duration::from_secs(8);
const MARKER_POLL: Duration = Duration::from_millis(50);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnterCapability {
Works,
Broken,
Inconclusive,
}
impl EnterCapability {
fn as_token(self) -> &'static str {
match self {
EnterCapability::Works => "works",
EnterCapability::Broken => "broken",
EnterCapability::Inconclusive => "inconclusive",
}
}
pub fn describe(self) -> &'static str {
match self {
EnterCapability::Works => "enter mode delivers and blocks correctly",
EnterCapability::Broken => "enter mode cannot deliver/block — preexec will be used",
EnterCapability::Inconclusive => {
"enter-mode self-test inconclusive — preexec will be used"
}
}
}
pub fn enables_enter(self) -> bool {
matches!(self, EnterCapability::Works)
}
}
#[derive(Debug, Clone)]
pub struct ProbeOutcome {
pub capability: EnterCapability,
pub bash_version: Option<String>,
pub bash_path: Option<PathBuf>,
pub reason: String,
pub cache_path: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct CachedDecision {
pub capability: EnterCapability,
pub tirith_version: String,
pub bash_version: String,
pub bash_path: String,
pub bash_fingerprint: String,
pub reason: String,
}
pub fn discover_bash() -> Option<PathBuf> {
let out = std::process::Command::new("sh")
.args(["-c", "command -v bash"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let p = String::from_utf8_lossy(&out.stdout).trim().to_string();
if p.is_empty() {
None
} else {
Some(PathBuf::from(p))
}
}
pub fn bash_version_of(path: &Path) -> Option<String> {
let out = std::process::Command::new(path)
.args(["-c", "printf '%s' \"$BASH_VERSION\""])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let v = String::from_utf8_lossy(&out.stdout).trim().to_string();
if v.is_empty() {
None
} else {
Some(v)
}
}
fn posix_quote(path: &Path) -> String {
format!("'{}'", path.display().to_string().replace('\'', "'\\''"))
}
fn write_probe_hook(dir: &Path) -> std::io::Result<PathBuf> {
let path = dir.join("bash-hook.bash");
std::fs::write(&path, crate::assets::BASH_HOOK)?;
Ok(path)
}
fn probe_path() -> String {
let ambient = std::env::var("PATH").unwrap_or_default();
let exe_dir = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(Path::to_path_buf));
match exe_dir {
Some(dir) if ambient.is_empty() => dir.display().to_string(),
Some(dir) => format!("{}:{}", dir.display(), ambient),
None => ambient,
}
}
struct ProbeSession {
writer: Arc<Mutex<Box<dyn Write + Send>>>,
child: Box<dyn portable_pty::Child + Send + Sync>,
rx: mpsc::Receiver<Vec<u8>>,
buf: String,
}
impl ProbeSession {
fn spawn(
program: &Path,
args: &[&str],
envs: &[(String, String)],
cwd: &Path,
) -> std::io::Result<Self> {
let pair = native_pty_system()
.openpty(PtySize {
rows: 40,
cols: 100,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| std::io::Error::other(format!("openpty: {e}")))?;
let mut cmd = CommandBuilder::new(program);
for a in args {
cmd.arg(a);
}
cmd.env_clear();
cmd.env("PATH", probe_path());
for (k, v) in envs {
cmd.env(k, v);
}
cmd.cwd(cwd);
let child = pair
.slave
.spawn_command(cmd)
.map_err(|e| std::io::Error::other(format!("spawn: {e}")))?;
drop(pair.slave);
let writer: Arc<Mutex<Box<dyn Write + Send>>> =
Arc::new(Mutex::new(pair.master.take_writer().map_err(|e| {
std::io::Error::other(format!("take_writer: {e}"))
})?));
let mut reader = pair
.master
.try_clone_reader()
.map_err(|e| std::io::Error::other(format!("clone_reader: {e}")))?;
drop(pair.master);
let (tx, rx) = mpsc::channel::<Vec<u8>>();
std::thread::spawn(move || {
let mut chunk = [0u8; 4096];
loop {
match reader.read(&mut chunk) {
Ok(0) => break,
Ok(n) => {
if tx.send(chunk[..n].to_vec()).is_err() {
break;
}
}
Err(_) => break,
}
}
});
Ok(Self {
writer,
child,
rx,
buf: String::new(),
})
}
fn pump(&mut self, slice: Duration) {
match self.rx.recv_timeout(slice) {
Ok(bytes) => self.buf.push_str(&String::from_utf8_lossy(&bytes)),
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => {}
}
while let Ok(bytes) = self.rx.try_recv() {
self.buf.push_str(&String::from_utf8_lossy(&bytes));
}
}
fn send_line(&mut self, line: &str) {
let mut s = line.to_string();
s.push('\r');
if let Ok(mut w) = self.writer.lock() {
let _ = w.write_all(s.as_bytes());
let _ = w.flush();
}
}
fn wait_for(&mut self, needle: &str, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
loop {
if self.buf.contains(needle) {
return true;
}
if Instant::now() >= deadline {
return false;
}
self.pump(Duration::from_millis(80));
}
}
fn drain(&mut self, slice: Duration) {
self.pump(slice);
}
fn kill(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Drop for ProbeSession {
fn drop(&mut self) {
let _ = self.child.kill();
}
}
fn count_occurrences(haystack: &str, needle: &str) -> usize {
if needle.is_empty() {
return 0;
}
let mut count = 0;
let mut rest = haystack;
while let Some(idx) = rest.find(needle) {
count += 1;
rest = &rest[idx + needle.len()..];
}
count
}
fn wait_for_marker(
sess: &mut ProbeSession,
marker: &Path,
needle: &str,
timeout: Duration,
) -> bool {
let deadline = Instant::now() + timeout;
loop {
if std::fs::read_to_string(marker)
.unwrap_or_default()
.contains(needle)
{
return true;
}
if Instant::now() >= deadline {
return false;
}
sess.drain(MARKER_POLL);
}
}
fn marker_stays_absent(sess: &mut ProbeSession, marker: &Path, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
loop {
if marker.exists() {
return false;
}
if Instant::now() >= deadline {
return true;
}
sess.drain(MARKER_POLL);
}
}
struct ProbeEnv {
_root: tempfile::TempDir,
work: PathBuf,
envs: Vec<(String, String)>,
hook: PathBuf,
}
impl ProbeEnv {
fn new() -> std::io::Result<Self> {
let root = tempfile::tempdir()?;
let base = root.path().to_path_buf();
let home = base.join("home");
let state = base.join("state");
let data = base.join("data");
let config = base.join("config");
let work = base.join("work");
for d in [&home, &state, &data, &config, &work] {
std::fs::create_dir_all(d)?;
}
let hook = write_probe_hook(&base)?;
let envs = vec![
("HOME".to_string(), home.display().to_string()),
("XDG_STATE_HOME".to_string(), state.display().to_string()),
("XDG_DATA_HOME".to_string(), data.display().to_string()),
("XDG_CONFIG_HOME".to_string(), config.display().to_string()),
("TERM".to_string(), "xterm-256color".to_string()),
("TIRITH_BASH_MODE".to_string(), "enter".to_string()),
("TIRITH_LOG".to_string(), "0".to_string()),
];
Ok(Self {
_root: root,
work,
envs,
hook,
})
}
}
fn probe_delivery(bash: &Path, env: &ProbeEnv) -> Result<bool, String> {
let marker = env.work.join("deliver_marker");
let nonce = "TIRITH_PROBE_DELIVER_NONCE";
let mut sess =
ProbeSession::spawn(bash, &["--norc", "--noprofile", "-i"], &env.envs, &env.work)
.map_err(|e| format!("PTY spawn failed: {e}"))?;
sess.send_line("export PS1='TIRITH_PROBE> '");
if !sess.wait_for("TIRITH_PROBE> ", PHASE_TIMEOUT) {
sess.kill();
return Err("bash never reached the probe prompt".into());
}
sess.send_line(&format!("source {}", posix_quote(&env.hook)));
if !sess.wait_for("TIRITH_PROBE> ", PHASE_TIMEOUT) {
sess.kill();
return Err("sourcing the hook did not return a prompt".into());
}
sess.send_line(&format!("printf '{nonce}\\n' >> {}", posix_quote(&marker)));
let delivered = wait_for_marker(&mut sess, &marker, nonce, MARKER_TIMEOUT);
sess.kill();
if !delivered {
return Ok(false);
}
let body = std::fs::read_to_string(&marker).unwrap_or_default();
Ok(count_occurrences(&body, nonce) == 1)
}
fn probe_blocking(bash: &Path, env: &ProbeEnv) -> Result<bool, String> {
let allowed_marker = env.work.join("block_allowed_marker");
let blocked_marker = env.work.join("block_marker");
let allowed_nonce = "TIRITH_PROBE_BLOCK_ALLOWED_NONCE";
let mut sess =
ProbeSession::spawn(bash, &["--norc", "--noprofile", "-i"], &env.envs, &env.work)
.map_err(|e| format!("PTY spawn failed: {e}"))?;
sess.send_line("export PS1='TIRITH_PROBE> '");
if !sess.wait_for("TIRITH_PROBE> ", PHASE_TIMEOUT) {
sess.kill();
return Err("bash never reached the probe prompt".into());
}
sess.send_line(&format!("source {}", posix_quote(&env.hook)));
if !sess.wait_for("TIRITH_PROBE> ", PHASE_TIMEOUT) {
sess.kill();
return Err("sourcing the hook did not return a prompt".into());
}
sess.send_line(&format!(
"printf '{allowed_nonce}\\n' >> {}",
posix_quote(&allowed_marker)
));
if !wait_for_marker(&mut sess, &allowed_marker, allowed_nonce, MARKER_TIMEOUT) {
sess.kill();
return Err(
"anti-vacuous guard failed: the probe shell did not deliver an allowed \
command, so a blocked-command verdict cannot be trusted"
.into(),
);
}
sess.send_line(&format!(
"printf 'true' | bash && touch {}",
posix_quote(&blocked_marker)
));
let blocked = !marker_stays_absent(&mut sess, &blocked_marker, MARKER_TIMEOUT);
sess.kill();
Ok(!blocked)
}
fn classify_probe_results(
delivery: &Result<bool, String>,
blocking: Option<&Result<bool, String>>,
) -> (EnterCapability, String) {
match delivery {
Ok(false) => (
EnterCapability::Broken,
"enter mode did not deliver an allowed command (issue #111)".into(),
),
Err(e) => (
EnterCapability::Inconclusive,
format!("delivery probe could not run: {e}"),
),
Ok(true) => match blocking {
Some(Ok(true)) => (
EnterCapability::Works,
"enter mode delivers an allowed command and blocks a blocked one".into(),
),
Some(Ok(false)) => (
EnterCapability::Broken,
"enter mode delivered but failed to block a blocked command".into(),
),
Some(Err(e)) => (
EnterCapability::Inconclusive,
format!("blocking probe could not run: {e}"),
),
None => (
EnterCapability::Inconclusive,
"delivery succeeded but the blocking phase was not run".into(),
),
},
}
}
pub fn probe() -> ProbeOutcome {
let Some(bash) = discover_bash() else {
return ProbeOutcome {
capability: EnterCapability::Inconclusive,
bash_version: None,
bash_path: None,
reason: "no bash found on PATH".into(),
cache_path: None,
};
};
let bash_version = bash_version_of(&bash);
let env = match ProbeEnv::new() {
Ok(e) => e,
Err(e) => {
return ProbeOutcome {
capability: EnterCapability::Inconclusive,
bash_version,
bash_path: Some(bash),
reason: format!("could not stage probe environment: {e}"),
cache_path: None,
};
}
};
let delivery = probe_delivery(&bash, &env);
let blocking = matches!(delivery, Ok(true)).then(|| probe_blocking(&bash, &env));
let (capability, reason) = classify_probe_results(&delivery, blocking.as_ref());
ProbeOutcome {
capability,
bash_version,
bash_path: Some(bash),
reason,
cache_path: None,
}
}
pub fn cache_path() -> Option<PathBuf> {
tirith_core::policy::state_dir().map(|d| d.join(CACHE_FILENAME))
}
fn render_cache(outcome: &ProbeOutcome) -> String {
let bash_version = outcome.bash_version.as_deref().unwrap_or("");
let bash_path = outcome
.bash_path
.as_deref()
.map(|p| p.display().to_string())
.unwrap_or_default();
let mut body = String::new();
body.push_str(&format!("schema={CACHE_SCHEMA}\n"));
body.push_str(&format!("tirith_version={}\n", env!("CARGO_PKG_VERSION")));
body.push_str("shell=bash\n");
body.push_str(&format!("bash_version={bash_version}\n"));
body.push_str(&format!("bash_path={bash_path}\n"));
let bash_fingerprint = outcome
.bash_path
.as_deref()
.and_then(|p| std::fs::metadata(p).ok())
.and_then(|m| {
let mtime = m
.modified()
.ok()?
.duration_since(std::time::UNIX_EPOCH)
.ok()?;
Some(format!("{}:{}", mtime.as_secs(), m.len()))
})
.unwrap_or_default();
body.push_str(&format!("bash_fingerprint={bash_fingerprint}\n"));
body.push_str(&format!(
"enter_capability={}\n",
outcome.capability.as_token()
));
let reason = outcome.reason.replace(['\n', '\r'], " ");
body.push_str(&format!("reason={reason}\n"));
body
}
pub fn write_cache(outcome: &ProbeOutcome) -> Result<PathBuf, String> {
use std::os::unix::fs::PermissionsExt;
let path = cache_path().ok_or("could not determine tirith state directory")?;
let dir = path
.parent()
.ok_or("capability cache path has no parent directory")?;
std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
let body = render_cache(outcome);
let mut tmp = tempfile::NamedTempFile::new_in(dir)
.map_err(|e| format!("create temp file in {}: {e}", dir.display()))?;
tmp.as_file()
.set_permissions(std::fs::Permissions::from_mode(0o600))
.map_err(|e| format!("chmod temp cache file: {e}"))?;
tmp.write_all(body.as_bytes())
.map_err(|e| format!("write temp cache file: {e}"))?;
tmp.flush()
.map_err(|e| format!("flush temp cache file: {e}"))?;
tmp.persist(&path)
.map_err(|e| format!("rename into {}: {}", path.display(), e.error))?;
Ok(path)
}
fn parse_capability(token: &str) -> Option<EnterCapability> {
match token.trim() {
"works" => Some(EnterCapability::Works),
"broken" => Some(EnterCapability::Broken),
"inconclusive" => Some(EnterCapability::Inconclusive),
_ => None,
}
}
pub fn read_cache() -> Option<CachedDecision> {
let path = cache_path()?;
let body = std::fs::read_to_string(&path).ok()?;
if body.len() > 4096 {
return None;
}
let mut schema: Option<u32> = None;
let mut tirith_version: Option<String> = None;
let mut bash_version: Option<String> = None;
let mut bash_path: Option<String> = None;
let mut bash_fingerprint: Option<String> = None;
let mut capability: Option<EnterCapability> = None;
let mut reason = String::new();
for line in body.lines() {
let Some((key, value)) = line.split_once('=') else {
continue;
};
match key.trim() {
"schema" => schema = value.trim().parse().ok(),
"tirith_version" => tirith_version = Some(value.trim().to_string()),
"bash_version" => bash_version = Some(value.trim().to_string()),
"bash_path" => bash_path = Some(value.trim().to_string()),
"bash_fingerprint" => bash_fingerprint = Some(value.trim().to_string()),
"enter_capability" => capability = parse_capability(value),
"reason" => reason = value.trim().to_string(),
_ => {}
}
}
if schema != Some(CACHE_SCHEMA) {
return None;
}
Some(CachedDecision {
capability: capability?,
tirith_version: tirith_version?,
bash_version: bash_version?,
bash_path: bash_path?,
bash_fingerprint: bash_fingerprint?,
reason,
})
}
pub fn decision_is_fresh(decision: &CachedDecision) -> bool {
let Some(bash) = discover_bash() else {
return false;
};
if bash.display().to_string() != decision.bash_path {
return false;
}
let fingerprint_matches = std::fs::metadata(&bash)
.ok()
.and_then(|m| {
let mtime = m
.modified()
.ok()?
.duration_since(std::time::UNIX_EPOCH)
.ok()?;
Some(format!("{}:{}", mtime.as_secs(), m.len()))
})
.map(|fp| fp == decision.bash_fingerprint)
.unwrap_or(false);
if !fingerprint_matches {
return false;
}
match bash_version_of(&bash) {
Some(running) => running == decision.bash_version,
None => false,
}
}
pub fn run_and_cache() -> ProbeOutcome {
let mut outcome = probe();
match write_cache(&outcome) {
Ok(path) => outcome.cache_path = Some(path),
Err(e) => {
eprintln!("tirith: could not write bash enter-mode capability cache: {e}");
}
}
outcome
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn posix_quote_escapes_embedded_single_quotes() {
assert_eq!(posix_quote(Path::new("/tmp/plain")), "'/tmp/plain'");
assert_eq!(
posix_quote(Path::new("/tmp/it's here/bash")),
"'/tmp/it'\\''s here/bash'"
);
assert_eq!(posix_quote(Path::new("/a b/c")), "'/a b/c'");
}
#[test]
fn probe_path_prepends_running_exe_directory() {
let path = probe_path();
let exe_dir = std::env::current_exe()
.expect("current_exe must resolve in the test runner")
.parent()
.expect("the test binary has a parent directory")
.display()
.to_string();
assert!(
path.starts_with(&exe_dir),
"probe PATH must START with the running exe's directory \
(so it wins over any stale installed tirith); exe_dir={exe_dir:?}, path={path:?}"
);
assert!(
path.split(':').any(|seg| seg == exe_dir),
"the running exe's directory must be a discrete PATH entry; path={path:?}"
);
if let Ok(ambient) = std::env::var("PATH") {
if !ambient.is_empty() {
assert!(
path.ends_with(&ambient),
"the ambient PATH must be preserved after the prepended dir; \
ambient={ambient:?}, path={path:?}"
);
}
}
}
#[test]
fn count_occurrences_is_non_overlapping() {
assert_eq!(count_occurrences("", "x"), 0);
assert_eq!(count_occurrences("abc", ""), 0);
assert_eq!(count_occurrences("NONCE", "NONCE"), 1);
assert_eq!(count_occurrences("NONCE NONCE", "NONCE"), 2);
assert_eq!(count_occurrences("aaaa", "aa"), 2);
}
#[test]
fn capability_tokens_round_trip() {
for cap in [
EnterCapability::Works,
EnterCapability::Broken,
EnterCapability::Inconclusive,
] {
assert_eq!(parse_capability(cap.as_token()), Some(cap));
}
assert_eq!(parse_capability("garbage"), None);
assert_eq!(parse_capability(""), None);
}
#[test]
fn only_works_enables_enter() {
assert!(EnterCapability::Works.enables_enter());
assert!(!EnterCapability::Broken.enables_enter());
assert!(!EnterCapability::Inconclusive.enables_enter());
}
#[test]
fn rendered_cache_has_strict_key_value_grammar() {
let outcome = ProbeOutcome {
capability: EnterCapability::Works,
bash_version: Some("5.3.9(1)-release".to_string()),
bash_path: Some(PathBuf::from("/opt/homebrew/bin/bash")),
reason: "line one\nline two\rline three".to_string(),
cache_path: None,
};
let body = render_cache(&outcome);
for line in body.lines() {
assert!(
line.split_once('=').is_some(),
"cache line is not key=value: {line:?}"
);
}
assert!(body.contains(&format!("schema={CACHE_SCHEMA}\n")));
assert!(body.contains("enter_capability=works\n"));
assert!(body.contains("bash_version=5.3.9(1)-release\n"));
assert!(body.contains("bash_path=/opt/homebrew/bin/bash\n"));
let reason_lines = body.lines().filter(|l| l.starts_with("reason=")).count();
assert_eq!(reason_lines, 1, "reason must be a single line");
}
#[test]
fn render_then_parse_round_trip() {
let outcome = ProbeOutcome {
capability: EnterCapability::Broken,
bash_version: Some("5.2.0".to_string()),
bash_path: Some(PathBuf::from("/usr/bin/bash")),
reason: "issue #111 reproduced".to_string(),
cache_path: None,
};
let body = render_cache(&outcome);
let mut schema = None;
let mut cap = None;
let mut bv = None;
let mut bp = None;
let mut tv = None;
for line in body.lines() {
if let Some((k, v)) = line.split_once('=') {
match k.trim() {
"schema" => schema = v.trim().parse::<u32>().ok(),
"enter_capability" => cap = parse_capability(v),
"bash_version" => bv = Some(v.trim().to_string()),
"bash_path" => bp = Some(v.trim().to_string()),
"tirith_version" => tv = Some(v.trim().to_string()),
_ => {}
}
}
}
assert_eq!(schema, Some(CACHE_SCHEMA));
assert_eq!(cap, Some(EnterCapability::Broken));
assert_eq!(bv.as_deref(), Some("5.2.0"));
assert_eq!(bp.as_deref(), Some("/usr/bin/bash"));
assert_eq!(tv.as_deref(), Some(env!("CARGO_PKG_VERSION")));
}
use crate::cli::test_harness::ENV_LOCK;
fn isolated_state_home(dir: &Path) -> tirith_test_support::GlobalStateGuard {
let mut environment = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
environment.set_env("XDG_STATE_HOME", dir);
environment
}
#[test]
fn write_cache_is_atomic_and_readable() {
let dir = tempfile::tempdir().unwrap();
let _guard = isolated_state_home(dir.path());
let outcome = ProbeOutcome {
capability: EnterCapability::Works,
bash_version: Some("5.3.0".to_string()),
bash_path: Some(PathBuf::from("/opt/homebrew/bin/bash")),
reason: "test".to_string(),
cache_path: None,
};
let written = write_cache(&outcome).expect("cache write");
assert!(written.exists(), "cache file must exist after write");
let decision = read_cache().expect("cache must read back");
assert_eq!(decision.capability, EnterCapability::Works);
assert_eq!(decision.bash_version, "5.3.0");
assert_eq!(decision.bash_path, "/opt/homebrew/bin/bash");
assert_eq!(decision.tirith_version, env!("CARGO_PKG_VERSION"));
let entries: Vec<String> = std::fs::read_dir(dir.path().join("tirith"))
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(
entries,
vec![CACHE_FILENAME.to_string()],
"atomic write must leave only the cache file, found: {entries:?}"
);
}
#[test]
fn read_cache_rejects_wrong_schema() {
let dir = tempfile::tempdir().unwrap();
let _guard = isolated_state_home(dir.path());
let state = dir.path().join("tirith");
std::fs::create_dir_all(&state).unwrap();
std::fs::write(
state.join(CACHE_FILENAME),
"schema=999\ntirith_version=0.0.0\nshell=bash\nbash_version=5.3.0\nenter_capability=works\n",
)
.unwrap();
assert!(
read_cache().is_none(),
"a cache with an unknown schema must be rejected"
);
}
#[test]
fn read_cache_rejects_oversized_file() {
let dir = tempfile::tempdir().unwrap();
let _guard = isolated_state_home(dir.path());
let state = dir.path().join("tirith");
std::fs::create_dir_all(&state).unwrap();
let mut junk = String::from("schema=1\n");
junk.push_str(&"x".repeat(5000));
std::fs::write(state.join(CACHE_FILENAME), junk).unwrap();
assert!(
read_cache().is_none(),
"an oversized cache file must be rejected"
);
}
#[test]
fn read_cache_rejects_cache_missing_bash_path() {
let dir = tempfile::tempdir().unwrap();
let _guard = isolated_state_home(dir.path());
let state = dir.path().join("tirith");
std::fs::create_dir_all(&state).unwrap();
std::fs::write(
state.join(CACHE_FILENAME),
format!(
"schema={CACHE_SCHEMA}\ntirith_version=0.0.0\nshell=bash\n\
bash_version=5.3.0\nenter_capability=works\n"
),
)
.unwrap();
assert!(
read_cache().is_none(),
"a schema-1 cache missing bash_path must be rejected"
);
}
fn bash_major(path: &Path) -> Option<u32> {
let out = std::process::Command::new(path)
.arg("--version")
.output()
.ok()?;
if !out.status.success() {
return None;
}
let first = String::from_utf8_lossy(&out.stdout)
.lines()
.next()
.unwrap_or_default()
.to_string();
let rest = &first[first.find("version ")? + "version ".len()..];
rest.split('.').next()?.trim().parse::<u32>().ok()
}
fn probe_target_if_modern() -> Option<PathBuf> {
let bash = discover_bash()?;
match bash_major(&bash) {
Some(major) if major >= 5 => Some(bash),
_ => None,
}
}
#[test]
fn classify_probe_results_maps_phases_to_verdicts() {
let ok_true: Result<bool, String> = Ok(true);
let ok_false: Result<bool, String> = Ok(false);
let err: Result<bool, String> = Err("boom".into());
let (cap, _) = classify_probe_results(&ok_true, Some(&ok_true));
assert_eq!(cap, EnterCapability::Works, "delivered + blocked ⇒ Works");
assert!(
cap.enables_enter(),
"only a Works verdict enables enter mode"
);
let (cap, reason) = classify_probe_results(&ok_false, None);
assert_eq!(cap, EnterCapability::Broken, "no delivery ⇒ Broken");
assert!(
reason.contains("#111"),
"reason should cite the #111 swallow"
);
let (cap, _) = classify_probe_results(&ok_true, Some(&ok_false));
assert_eq!(
cap,
EnterCapability::Broken,
"delivered-but-not-blocked ⇒ Broken"
);
let (cap, _) = classify_probe_results(&err, None);
assert_eq!(
cap,
EnterCapability::Inconclusive,
"delivery Err ⇒ Inconclusive"
);
let (cap, _) = classify_probe_results(&ok_true, Some(&err));
assert_eq!(
cap,
EnterCapability::Inconclusive,
"blocking Err (e.g. anti-vacuous guard failed) ⇒ Inconclusive, not Works"
);
assert!(
!cap.enables_enter(),
"an Inconclusive verdict must not enable enter mode — fail closed"
);
}
#[test]
fn probe_reaches_definite_verdict_on_modern_bash() {
let Some(bash) = probe_target_if_modern() else {
eprintln!("skipping: no modern bash (>= 5) on PATH for the probe to target");
return;
};
let outcome = probe();
match outcome.capability {
EnterCapability::Works => assert!(
outcome.capability.enables_enter(),
"a Works verdict must enable enter mode"
),
EnterCapability::Broken => {}
EnterCapability::Inconclusive => panic!(
"the PR #116 fix must let the probe reach a definite verdict for a \
healthy modern bash ({}) — an Inconclusive here means the probe \
could not run, not a real delivery failure; reason was {:?}",
bash.display(),
outcome.reason,
),
}
assert!(
outcome.bash_version.is_some(),
"a completed probe must record the probed $BASH_VERSION"
);
assert_eq!(
outcome.bash_path.as_deref(),
Some(bash.as_path()),
"the probe must record the bash path it targeted"
);
eprintln!(
"probe verdict for {}: {:?} ({})",
bash.display(),
outcome.capability,
outcome.reason
);
}
#[test]
fn probe_delivery_reaches_definite_verdict_on_modern_bash() {
let Some(bash) = probe_target_if_modern() else {
eprintln!("skipping: no modern bash (>= 5) on PATH for the probe to target");
return;
};
let env = ProbeEnv::new().expect("stage probe environment");
match probe_delivery(&bash, &env) {
Ok(delivered) => eprintln!("probe_delivery for {}: Ok({delivered})", bash.display()),
Err(e) => panic!(
"probe_delivery on a healthy modern bash ({}) must reach a definite \
Ok(_) verdict, not Err — Err means the probe could not run: {e}",
bash.display()
),
}
}
#[test]
fn probe_blocking_anti_vacuous_guard_holds_on_modern_bash() {
let Some(bash) = probe_target_if_modern() else {
eprintln!("skipping: no modern bash (>= 5) on PATH for the probe to target");
return;
};
let env = ProbeEnv::new().expect("stage probe environment");
match probe_blocking(&bash, &env) {
Ok(true) => eprintln!(
"probe_blocking for {}: Ok(true) — delivery works and the blocked \
command was stopped",
bash.display()
),
Ok(false) => eprintln!(
"probe_blocking for {}: Ok(false) — delivered but did not block",
bash.display()
),
Err(e) => {
assert!(
e.contains("anti-vacuous") || e.contains("probe"),
"an Err from probe_blocking must be a probe/guard failure, got: {e}"
);
eprintln!(
"probe_blocking for {}: Err (anti-vacuous guard refused to vouch \
for a non-delivering shell) — {e}",
bash.display()
);
}
}
}
#[test]
fn marker_stays_absent_detects_a_created_marker() {
let dir = tempfile::tempdir().unwrap();
let absent = dir.path().join("never");
let present = dir.path().join("created");
let Some(bash) = probe_target_if_modern() else {
eprintln!("skipping: no modern bash (>= 5) to host a drain-only session");
return;
};
let env = ProbeEnv::new().expect("stage probe environment");
let mut sess = ProbeSession::spawn(
&bash,
&["--norc", "--noprofile", "-i"],
&env.envs,
&env.work,
)
.expect("spawn probe session");
assert!(
marker_stays_absent(&mut sess, &absent, Duration::from_millis(200)),
"a file that is never created must be reported absent"
);
std::fs::write(&present, "x").unwrap();
assert!(
!marker_stays_absent(&mut sess, &present, Duration::from_millis(200)),
"an existing file must be reported present"
);
sess.kill();
}
}