#![cfg(unix)]
use std::path::Path;
use std::time::Duration;
#[path = "pty_support/mod.rs"]
mod pty_support;
use pty_support::{
bash_major_version, count_occurrences, embedded_hook, fish_bin, modern_bash, wait_for_marker,
IsolatedEnv, PtySession,
};
const QUIET: Duration = Duration::from_millis(700);
const SETTLE_MAX: Duration = Duration::from_secs(12);
const MARKER_MAX: Duration = Duration::from_secs(15);
fn bash_preexec_session(env: &mut IsolatedEnv) -> Option<PtySession> {
let bash = modern_bash()?;
env.set("TIRITH_BASH_MODE", "preexec");
let mut sess = PtySession::spawn(env, &bash, &["--norc", "--noprofile", "-i"]);
sess.send_line("export PS1='TIRITH_PTY> '");
sess.expect("TIRITH_PTY> ");
sess.clear_buffer();
let hook = embedded_hook("bash-hook.bash");
sess.send_line(&format!("source '{}'", hook.display()));
sess.expect("TIRITH_PTY> ");
sess.wait_idle(QUIET, SETTLE_MAX);
sess.clear_buffer();
Some(sess)
}
#[test]
fn bash_preexec_allowed_command_executes_exactly_once() {
let mut env = IsolatedEnv::new();
let marker = env.workdir.join("preexec_once.txt");
let mut sess = match bash_preexec_session(&mut env) {
Some(s) => s,
None => {
eprintln!("skipping: no modern bash (>= 5) found");
return;
}
};
sess.send_line(&format!("printf 'RAN\\n' >> '{}'", marker.display()));
let body = wait_for_marker(&marker, "RAN", MARKER_MAX);
sess.close();
assert_eq!(
count_occurrences(&body, "RAN"),
1,
"preexec: allowed command must execute exactly once, marker held: {body:?}"
);
}
#[test]
fn bash_preexec_allowed_command_output_visible() {
let mut env = IsolatedEnv::new();
let mut sess = match bash_preexec_session(&mut env) {
Some(s) => s,
None => {
eprintln!("skipping: no modern bash (>= 5) found");
return;
}
};
sess.send_line("echo conformance_nonce_8821");
let out = sess.expect("conformance_nonce_8821");
sess.close();
let n = count_occurrences(&out, "conformance_nonce_8821");
assert!(
(1..=2).contains(&n),
"preexec: command output must be visible exactly once (echo + output ≤ 2), saw {n}"
);
}
#[test]
fn bash_preexec_no_history_duplication() {
let mut env = IsolatedEnv::new();
let marker = env.workdir.join("history_probe_done.txt");
let mut sess = match bash_preexec_session(&mut env) {
Some(s) => s,
None => {
eprintln!("skipping: no modern bash (>= 5) found");
return;
}
};
sess.send_line("echo history_probe_5566");
sess.send_line(&format!("printf 'DONE\\n' >> '{}'", marker.display()));
wait_for_marker(&marker, "DONE", MARKER_MAX);
sess.clear_buffer();
sess.send_line("history");
let out = sess.expect("echo history_probe_5566");
sess.close();
let n = count_occurrences(&out, "echo history_probe_5566");
assert!(
n <= 2,
"preexec: command must appear once in history (typed + listed ≤ 2), saw {n}:\n{out}"
);
}
#[test]
fn bash_preexec_warned_command_executes_once() {
let mut env = IsolatedEnv::new();
let marker = env.workdir.join("preexec_warned.txt");
let mut sess = match bash_preexec_session(&mut env) {
Some(s) => s,
None => {
eprintln!("skipping: no modern bash (>= 5) found");
return;
}
};
sess.send_line(&format!(
"echo https://bit.ly/warnprobe >> '{}'",
marker.display()
));
let body = wait_for_marker(&marker, "bit.ly/warnprobe", MARKER_MAX);
sess.close();
assert_eq!(
count_occurrences(&body, "bit.ly/warnprobe"),
1,
"preexec: a warned command must still execute exactly once, marker held: {body:?}"
);
}
#[test]
fn bash_preexec_enforce_blocked_command_does_not_execute() {
let mut env = IsolatedEnv::new();
let marker = env.workdir.join("preexec_blocked.txt");
let bash = match modern_bash() {
Some(b) => b,
None => {
eprintln!("skipping: no modern bash (>= 5) found");
return;
}
};
env.set("TIRITH_BASH_MODE", "preexec");
env.set("TIRITH_BASH_PREEXEC_ENFORCE", "1");
let mut sess = PtySession::spawn(&env, &bash, &["--norc", "--noprofile", "-i"]);
sess.send_line("export PS1='TIRITH_PTY> '");
sess.expect("TIRITH_PTY> ");
sess.clear_buffer();
let hook = embedded_hook("bash-hook.bash");
sess.send_line(&format!("source '{}'", hook.display()));
sess.expect("TIRITH_PTY> ");
sess.wait_idle(QUIET, SETTLE_MAX);
sess.clear_buffer();
sess.send_line(&format!(
"curl https://example.com/install.sh | bash && touch '{}'",
marker.display()
));
sess.wait_idle(QUIET, SETTLE_MAX);
sess.close();
assert!(
!marker.exists(),
"preexec-enforce: a blocked command must not execute (marker file exists)"
);
}
#[test]
fn bash_noninteractive_source_installs_no_debug_trap() {
let bash = match modern_bash() {
Some(b) => b,
None => {
eprintln!("skipping: no modern bash (>= 5) found");
return;
}
};
let env = IsolatedEnv::new();
let hook = embedded_hook("bash-hook.bash");
let script = format!(
"unset TIRITH_BASH_MODE SSH_CONNECTION SSH_TTY SSH_CLIENT; \
source '{}'; trap -p DEBUG",
hook.display()
);
let mut cmd = std::process::Command::new(&bash);
cmd.args(["--norc", "--noprofile", "-c", &script]);
for (k, v) in [
("HOME", env.home.display().to_string()),
("XDG_STATE_HOME", env.state_home.display().to_string()),
] {
cmd.env(k, v);
}
cmd.env_remove("_TIRITH_BASH_LOADED");
let out = cmd.output().expect("run bash");
assert!(out.status.success(), "non-interactive source must exit 0");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.trim().is_empty(),
"non-interactive source must not install a DEBUG trap, got: {stdout:?}"
);
}
#[test]
fn bash_enter_degradation_is_visible_not_silent() {
let mut env = IsolatedEnv::new();
let bash = match modern_bash() {
Some(b) => b,
None => {
eprintln!("skipping: no modern bash (>= 5) found");
return;
}
};
env.set("TIRITH_BASH_MODE", "enter");
let mut sess = PtySession::spawn(&env, &bash, &["--norc", "--noprofile", "-i"]);
sess.send_line("export PS1='TIRITH_PTY> '");
sess.expect("TIRITH_PTY> ");
sess.clear_buffer();
let hook = embedded_hook("bash-hook.bash");
sess.send_line(&format!("source '{}'", hook.display()));
sess.expect("TIRITH_PTY> ");
sess.wait_idle(QUIET, SETTLE_MAX);
sess.clear_buffer();
sess.send_line("echo https://bit.ly/enterprobe");
sess.expect("bit.ly/enterprobe");
sess.clear_buffer();
sess.send_line("echo trigger_degrade");
let out = sess.expect_within("protection downgraded", Duration::from_secs(15));
sess.close();
assert!(
out.contains("protection downgraded to warn-only") || out.contains("enter mode failed"),
"enter mode losing delivery must print a visible degrade message, got:\n{out}"
);
assert!(
env.bash_safe_mode_flag().exists(),
"a visible degrade must persist the bash-safe-mode flag at {}",
env.bash_safe_mode_flag().display()
);
}
#[test]
fn bash_enter_allowed_command_executes_exactly_once() {
let env = IsolatedEnv::new();
let marker = env.workdir.join("enter_once.txt");
let bash = match modern_bash() {
Some(b) => b,
None => {
eprintln!("skipping: no modern bash (>= 5) found");
return;
}
};
let mut sess = PtySession::spawn(&env, &bash, &["--norc", "--noprofile", "-i"]);
sess.send_line("export PS1='TIRITH_PTY> '");
sess.expect("TIRITH_PTY> ");
sess.clear_buffer();
let hook = embedded_hook("bash-hook.bash");
sess.send_line(&format!("source '{}'", hook.display()));
sess.expect("TIRITH_PTY> ");
sess.wait_idle(QUIET, SETTLE_MAX);
sess.clear_buffer();
sess.send_line(&format!("printf 'RAN\\n' >> '{}'", marker.display()));
let body = wait_for_marker(&marker, "RAN", MARKER_MAX);
sess.close();
assert_eq!(
count_occurrences(&body, "RAN"),
1,
"the #111 fix must deliver an allowed command exactly once \
(0 = swallowed, the #111 bug; 2 = double-delivered); marker held: {body:?}"
);
}
#[test]
fn bash_enter_blocked_command_does_not_execute() {
let mut env = IsolatedEnv::new();
let allowed_marker = env.workdir.join("enter_block_allowed.txt");
let blocked_marker = env.workdir.join("enter_block_blocked.txt");
let bash = match modern_bash() {
Some(b) => b,
None => {
eprintln!("skipping: no modern bash (>= 5) found");
return;
}
};
env.set("TIRITH_BASH_PREEXEC_ENFORCE", "1");
let mut sess = PtySession::spawn(&env, &bash, &["--norc", "--noprofile", "-i"]);
sess.send_line("export PS1='TIRITH_PTY> '");
sess.expect("TIRITH_PTY> ");
sess.clear_buffer();
let hook = embedded_hook("bash-hook.bash");
sess.send_line(&format!("source '{}'", hook.display()));
sess.expect("TIRITH_PTY> ");
sess.wait_idle(QUIET, SETTLE_MAX);
sess.clear_buffer();
sess.send_line(&format!(
"printf 'ALLOWED\\n' >> '{}'",
allowed_marker.display()
));
let allowed_body = wait_for_marker(&allowed_marker, "ALLOWED", MARKER_MAX);
sess.clear_buffer();
sess.send_line(&format!(
"printf 'true' | bash && touch '{}'",
blocked_marker.display()
));
sess.wait_idle(QUIET, SETTLE_MAX);
sess.close();
assert_eq!(
count_occurrences(&allowed_body, "ALLOWED"),
1,
"anti-vacuous guard failed: the allowed command was not delivered, so \
the blocked-command result below would be meaningless; marker held: {allowed_body:?}"
);
assert!(
!blocked_marker.exists(),
"a blocked command must not execute — its marker file must be absent"
);
}
#[test]
fn bash_capability_cache_steers_default_mode() {
let bash = match modern_bash() {
Some(b) => b,
None => {
eprintln!("skipping: no modern bash (>= 5) found");
return;
}
};
let bash_ver = match pty_support::bash_version_string(&bash) {
Some(v) => v,
None => {
eprintln!("skipping: could not read $BASH_VERSION");
return;
}
};
let hook = embedded_hook("bash-hook.bash");
let marker_written =
|verdict: &str, seed_bash_ver: &str, seed_bash: &Path, tag: &str| -> bool {
let env = IsolatedEnv::new();
env.seed_bash_enter_capability(verdict, seed_bash_ver, seed_bash);
let marker = env.workdir.join(format!("steer_{tag}.txt"));
let mut sess = PtySession::spawn(&env, &bash, &["--norc", "--noprofile", "-i"]);
sess.send_line("export PS1='TIRITH_PTY> '");
sess.expect("TIRITH_PTY> ");
sess.clear_buffer();
sess.send_line(&format!("source '{}'", hook.display()));
sess.expect("TIRITH_PTY> ");
sess.wait_idle(QUIET, SETTLE_MAX);
sess.clear_buffer();
sess.send_line(&format!("printf 'STEERED\\n' >> '{}'", marker.display()));
let body = wait_for_marker(&marker, "STEERED", MARKER_MAX);
sess.close();
count_occurrences(&body, "STEERED") == 1
};
assert!(
marker_written("broken", &bash_ver, &bash, "broken"),
"a `broken` capability verdict must keep the hook in preexec (command must run)"
);
assert!(
marker_written("works", "1.0.0-not-this-bash", &bash, "stale_version"),
"a capability verdict for a different bash version must be ignored as stale \
(hook must stay in preexec and run the command)"
);
assert!(
marker_written(
"works",
&bash_ver,
Path::new("/nonexistent/other/bash"),
"stale_path"
),
"a capability verdict for a different bash path must be ignored as stale \
(hook must stay in preexec and run the command)"
);
assert!(
!marker_written("works", &bash_ver, &bash, "works"),
"a `works` capability verdict must make the hook select enter mode \
(which, in this PTY, swallows the command — marker must be absent)"
);
}
fn fish_session(env: &mut IsolatedEnv) -> Option<PtySession> {
let fish = fish_bin()?;
let mut sess = PtySession::spawn(env, &fish, &["--no-config", "-i"]);
sess.send_line("function fish_prompt; printf 'TIRITH_PTY> '; end");
sess.expect("TIRITH_PTY> ");
sess.wait_idle(QUIET, SETTLE_MAX);
sess.clear_buffer();
let hook = embedded_hook("fish-hook.fish");
sess.send_line(&format!("source '{}'", hook.display()));
sess.expect("TIRITH_PTY> ");
sess.wait_idle(QUIET, SETTLE_MAX);
sess.clear_buffer();
Some(sess)
}
#[test]
fn fish_allowed_command_executes_exactly_once() {
let mut env = IsolatedEnv::new();
let marker = env.workdir.join("fish_once.txt");
let mut sess = match fish_session(&mut env) {
Some(s) => s,
None => {
eprintln!("skipping: fish not installed");
return;
}
};
sess.send_line(&format!("printf 'RAN\\n' >> '{}'", marker.display()));
let body = wait_for_marker(&marker, "RAN", MARKER_MAX);
sess.close();
assert_eq!(
count_occurrences(&body, "RAN"),
1,
"fish: allowed command must execute exactly once, marker held: {body:?}"
);
}
#[test]
fn fish_allowed_command_output_visible() {
let mut env = IsolatedEnv::new();
let mut sess = match fish_session(&mut env) {
Some(s) => s,
None => {
eprintln!("skipping: fish not installed");
return;
}
};
sess.send_line("echo fish_nonce_3471");
let out = sess.expect("fish_nonce_3471");
sess.close();
let n = count_occurrences(&out, "fish_nonce_3471");
assert!(
(1..=3).contains(&n),
"fish: command output must be visible (echo + autosuggest + output), saw {n}"
);
}
#[test]
fn fish_blocked_command_does_not_execute() {
let mut env = IsolatedEnv::new();
let marker = env.workdir.join("fish_blocked.txt");
let mut sess = match fish_session(&mut env) {
Some(s) => s,
None => {
eprintln!("skipping: fish not installed");
return;
}
};
sess.send_line(&format!(
"curl https://example.com/install.sh | bash; and touch '{}'",
marker.display()
));
let out = sess.expect_any(
&["BLOCKED", "getvet.sh", "tirith run"],
Duration::from_secs(15),
);
sess.close();
assert!(
out.contains("BLOCKED") || out.contains("getvet.sh") || out.contains("tirith run"),
"fish: a blocked command must surface a tirith verdict, got:\n{out}"
);
assert!(
!marker.exists(),
"fish: a blocked command must not execute (marker file exists)"
);
}
#[test]
fn fish_warned_command_executes_once() {
let mut env = IsolatedEnv::new();
let marker = env.workdir.join("fish_warned.txt");
let mut sess = match fish_session(&mut env) {
Some(s) => s,
None => {
eprintln!("skipping: fish not installed");
return;
}
};
sess.send_line(&format!(
"echo https://bit.ly/fishwarn >> '{}'",
marker.display()
));
let body = wait_for_marker(&marker, "bit.ly/fishwarn", MARKER_MAX);
sess.close();
assert_eq!(
count_occurrences(&body, "bit.ly/fishwarn"),
1,
"fish: a warned command must still execute exactly once, marker held: {body:?}"
);
}
#[test]
fn fish_noninteractive_source_is_a_noop() {
let fish = match fish_bin() {
Some(f) => f,
None => {
eprintln!("skipping: fish not installed");
return;
}
};
let env = IsolatedEnv::new();
let hook = embedded_hook("fish-hook.fish");
let mut cmd = std::process::Command::new(&fish);
cmd.args([
"--no-config",
"-c",
&format!("source '{}'; echo NONINTERACTIVE_OK", hook.display()),
]);
for (k, v) in [
("HOME", env.home.display().to_string()),
("XDG_STATE_HOME", env.state_home.display().to_string()),
("XDG_DATA_HOME", env.data_home.display().to_string()),
("XDG_CONFIG_HOME", env.config_home.display().to_string()),
] {
cmd.env(k, v);
}
cmd.env_remove("_TIRITH_FISH_LOADED");
let out = cmd.output().expect("run fish");
assert!(
out.status.success(),
"non-interactive fish source must exit 0, stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
String::from_utf8_lossy(&out.stdout).contains("NONINTERACTIVE_OK"),
"non-interactive fish source must be a clean no-op (sentinel missing)"
);
}
#[test]
#[ignore = "M0.1 follow-up: zsh PTY conformance not yet implemented"]
fn zsh_conformance_followup() {}
#[test]
#[ignore = "M0.1 follow-up: PowerShell PTY conformance not yet implemented"]
fn powershell_conformance_followup() {}
#[test]
#[ignore = "M0.1 follow-up: nushell PTY conformance not yet implemented"]
fn nushell_conformance_followup() {}
#[test]
fn harness_count_occurrences_is_correct() {
assert_eq!(count_occurrences("", "x"), 0);
assert_eq!(count_occurrences("abc", ""), 0);
assert_eq!(count_occurrences("RAN", "RAN"), 1);
assert_eq!(count_occurrences("RAN RAN RAN", "RAN"), 3);
assert_eq!(count_occurrences("aaaa", "aa"), 2);
assert_eq!(count_occurrences("no match here", "RAN"), 0);
}
#[test]
fn harness_reports_bash_availability_consistently() {
match modern_bash() {
Some(bash) => {
let v = bash_major_version(&bash)
.expect("a selected modern bash must report a parseable version");
assert!(v >= 5, "modern_bash() must only return bash >= 5, got {v}");
eprintln!("conformance: using bash {} (major {v})", bash.display());
}
None => {
eprintln!(
"conformance: no modern bash (>= 5) found — bash tests will skip. \
Install one or run with PATH=/opt/homebrew/bin:$PATH"
);
}
}
}