#![cfg(unix)]
use std::process::Command;
fn hook_path() -> String {
format!(
"{}/assets/shell/lib/bash-hook.bash",
env!("CARGO_MANIFEST_DIR")
)
}
fn split_test_env(extra_env: &[(&str, &str)]) -> (String, Vec<(String, String)>) {
let mut prelude = String::new();
let mut env_vars = Vec::new();
for (k, v) in extra_env {
if k.starts_with("_TIRITH_TEST_") {
prelude.push_str(&format!("{k}='{v}'; "));
} else {
env_vars.push((k.to_string(), v.to_string()));
}
}
(prelude, env_vars)
}
fn seed_capability_cache(state_dir: &std::path::Path, verdict: &str) {
let (bash_version, bash_path) = {
let out = Command::new("bash")
.args(["-c", "printf '%s\\n%s' \"$BASH_VERSION\" \"$BASH\""])
.output()
.expect("query bash identity");
let text = String::from_utf8_lossy(&out.stdout);
let mut lines = text.lines();
(
lines.next().unwrap_or_default().trim().to_string(),
lines.next().unwrap_or_default().trim().to_string(),
)
};
let cache_dir = state_dir.join("tirith");
std::fs::create_dir_all(&cache_dir).unwrap();
std::fs::write(
cache_dir.join("bash-enter-capability"),
format!(
"schema=1\ntirith_version=\nshell=bash\nbash_version={bash_version}\n\
bash_path={bash_path}\nenter_capability={verdict}\nreason=seeded by test\n"
),
)
.unwrap();
}
fn source_hook_and_dump_exports(capability: Option<&str>, extra_env: &[(&str, &str)]) -> String {
let tmpdir = tempfile::tempdir().expect("failed to create tmpdir");
if let Some(v) = capability {
seed_capability_cache(tmpdir.path(), v);
}
let hook = hook_path();
let (prelude, env_vars) = split_test_env(extra_env);
let script = format!(
"{prelude}source '{hook}' 2>/dev/null; \
printf 'MODE=%s\\nPROT=%s\\nSTATUS=%s\\n' \
\"${{TIRITH_BASH_EFFECTIVE_MODE:-}}\" \
\"${{TIRITH_BASH_EFFECTIVE_PROTECTION:-}}\" \
\"${{TIRITH_STATUS:-}}\""
);
let mut cmd = Command::new("bash");
cmd.args(["--norc", "--noprofile", "-i", "-c", &script])
.env_clear()
.env("HOME", std::env::var("HOME").unwrap_or_default())
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("XDG_STATE_HOME", tmpdir.path());
for (k, v) in &env_vars {
cmd.env(k, v);
}
let out = cmd.output().expect("failed to run bash");
assert!(
out.status.success(),
"bash exited non-zero: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).to_string()
}
#[test]
fn hook_exports_enter_when_capability_cache_proves_it() {
let out = source_hook_and_dump_exports(Some("works"), &[("_TIRITH_TEST_SKIP_HEALTH", "1")]);
assert!(
out.contains("MODE=enter"),
"a `works` capability cache must resolve to enter mode, got:\n{out}"
);
assert!(
out.contains("PROT=blocks"),
"enter mode must report blocks protection, got:\n{out}"
);
}
#[test]
fn hook_exports_preexec_by_default_without_capability_proof() {
let out = source_hook_and_dump_exports(None, &[]);
assert!(
out.contains("MODE=preexec"),
"with no capability cache the hook must default to preexec, got:\n{out}"
);
assert!(
out.contains("PROT=warn-only"),
"the preexec fallback must report warn-only protection, got:\n{out}"
);
}
#[test]
fn hook_exports_preexec_when_capability_cache_says_broken() {
let out = source_hook_and_dump_exports(Some("broken"), &[]);
assert!(
out.contains("MODE=preexec"),
"a `broken` capability cache must resolve to preexec, got:\n{out}"
);
}
#[test]
fn hook_exports_preexec_warn_only_when_mode_requested() {
let out = source_hook_and_dump_exports(None, &[("TIRITH_BASH_MODE", "preexec")]);
assert!(
out.contains("MODE=preexec"),
"expected MODE=preexec, got:\n{out}"
);
assert!(
out.contains("PROT=warn-only"),
"expected PROT=warn-only, got:\n{out}"
);
}
#[test]
fn hook_exports_preexec_in_ssh_sessions() {
let out =
source_hook_and_dump_exports(Some("works"), &[("SSH_CONNECTION", "1.2.3.4 1 5.6.7.8 22")]);
assert!(
out.contains("MODE=preexec"),
"SSH sessions should resolve to preexec, got:\n{out}"
);
assert!(
out.contains("PROT=warn-only"),
"SSH preexec should report warn-only protection, got:\n{out}"
);
}
#[test]
fn hook_does_not_export_in_noninteractive_shell() {
let tmpdir = tempfile::tempdir().expect("failed to create tmpdir");
let hook = hook_path();
let script = format!(
"source '{hook}' 2>/dev/null; \
printf 'MODE=%s\\nPROT=%s\\nSTATUS=%s\\n' \
\"${{TIRITH_BASH_EFFECTIVE_MODE:-unset}}\" \
\"${{TIRITH_BASH_EFFECTIVE_PROTECTION:-unset}}\" \
\"${{TIRITH_STATUS:-unset}}\""
);
let out = Command::new("bash")
.args(["--norc", "--noprofile", "-c", &script])
.env_clear()
.env("HOME", std::env::var("HOME").unwrap_or_default())
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("XDG_STATE_HOME", tmpdir.path())
.output()
.expect("failed to run bash");
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("MODE=unset"),
"non-interactive hook must not export MODE, got:\n{stdout}"
);
assert!(
stdout.contains("PROT=unset"),
"non-interactive hook must not export PROT, got:\n{stdout}"
);
assert!(
stdout.contains("STATUS=unset"),
"non-interactive hook must not set TIRITH_STATUS — invariant (g), got:\n{stdout}"
);
}
#[test]
fn hook_exports_status_blocks_in_enter_mode() {
let out = source_hook_and_dump_exports(Some("works"), &[("_TIRITH_TEST_SKIP_HEALTH", "1")]);
assert!(
out.contains("STATUS=blocks"),
"enter mode must set TIRITH_STATUS=blocks, got:\n{out}"
);
}
#[test]
fn hook_exports_status_warn_only_in_plain_preexec() {
let out = source_hook_and_dump_exports(None, &[]);
assert!(
out.contains("STATUS=warn-only"),
"plain preexec must set TIRITH_STATUS=warn-only, got:\n{out}"
);
assert!(
!out.contains("STATUS=degraded"),
"a shell that starts in preexec is warn-only, not degraded, got:\n{out}"
);
}
#[test]
fn status_is_not_exported_to_child_processes() {
let tmpdir = tempfile::tempdir().expect("failed to create tmpdir");
let hook = hook_path();
let script = format!(
"source '{hook}' 2>/dev/null; \
printf 'PARENT=%s\\n' \"${{TIRITH_STATUS:-unset}}\"; \
bash --norc --noprofile -c 'printf \"CHILD=[%s]\\n\" \"${{TIRITH_STATUS:-}}\"'"
);
let out = Command::new("bash")
.args(["--norc", "--noprofile", "-i", "-c", &script])
.env_clear()
.env("HOME", std::env::var("HOME").unwrap_or_default())
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("XDG_STATE_HOME", tmpdir.path())
.output()
.expect("failed to run bash");
assert!(
out.status.success(),
"bash exited non-zero: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("PARENT=warn-only") || stdout.contains("PARENT=blocks"),
"anti-vacuous: the parent interactive shell must have TIRITH_STATUS set, got:\n{stdout}"
);
assert!(
stdout.contains("CHILD=[]"),
"TIRITH_STATUS must not be exported — a non-interactive child must not \
inherit it, got:\n{stdout}"
);
}
#[test]
fn degrade_to_preexec_exports_status_degraded() {
let out = source_hook_run_and_dump(
&[("_TIRITH_TEST_SKIP_HEALTH", "1")],
"_tirith_degrade_to_preexec degrade-test",
);
assert!(
out.contains("STATUS=degraded"),
"a runtime degrade must set TIRITH_STATUS=degraded, got:\n{out}"
);
}
fn doctor_stdout(env: &[(&str, &str)]) -> String {
let tmpdir = tempfile::tempdir().expect("failed to create tmpdir");
let bin = env!("CARGO_BIN_EXE_tirith");
let export_lines: Vec<String> = env
.iter()
.map(|(k, v)| format!("export {k}={}", shell_quote(v)))
.collect();
let bash_script = format!("{}\n'{bin}' doctor", export_lines.join("\n"));
let out = Command::new("bash")
.args(["--norc", "--noprofile", "-c", &bash_script])
.env_clear()
.env("HOME", std::env::var("HOME").unwrap_or_default())
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("XDG_STATE_HOME", tmpdir.path())
.output()
.expect("failed to run doctor");
String::from_utf8_lossy(&out.stdout).to_string()
}
fn shell_quote(s: &str) -> String {
format!("'{s}'")
}
#[test]
fn doctor_shows_effective_state_when_hook_loaded() {
let stdout = doctor_stdout(&[
("TIRITH_BASH_MODE", "preexec"),
("TIRITH_BASH_PREEXEC_ENFORCE", "1"),
("TIRITH_BASH_EFFECTIVE_MODE", "preexec"),
("TIRITH_BASH_EFFECTIVE_PROTECTION", "blocks"),
]);
assert!(
stdout.contains("requested mode: preexec"),
"requested mode missing, got:\n{stdout}"
);
assert!(
stdout.contains("requested enforce: on"),
"requested enforce should be on, got:\n{stdout}"
);
assert!(
stdout.contains("bash mode: preexec"),
"effective bash mode missing, got:\n{stdout}"
);
assert!(
stdout.contains("effective protection: blocks"),
"effective protection missing, got:\n{stdout}"
);
}
#[test]
fn doctor_distinguishes_requested_from_effective_on_degrade() {
let stdout = doctor_stdout(&[
("TIRITH_BASH_MODE", "preexec"),
("TIRITH_BASH_PREEXEC_ENFORCE", "1"),
("TIRITH_BASH_EFFECTIVE_MODE", "preexec"),
("TIRITH_BASH_EFFECTIVE_PROTECTION", "warn-only"),
]);
assert!(
stdout.contains("requested enforce: on"),
"requested enforce should still read on, got:\n{stdout}"
);
assert!(
stdout.contains("effective protection: warn-only"),
"effective protection should report the live degraded value, got:\n{stdout}"
);
}
#[test]
fn doctor_reports_not_loaded_when_exports_absent() {
let stdout = doctor_stdout(&[("TIRITH_BASH_MODE", "enter")]);
assert!(
stdout.contains("bash hook: not loaded in this process"),
"expected not-loaded marker, got:\n{stdout}"
);
assert!(
!stdout.contains("effective protection:"),
"should not print effective protection when hook not loaded, got:\n{stdout}"
);
assert!(
stdout.contains("requested mode: enter"),
"requested mode should echo the user's env var, got:\n{stdout}"
);
}
#[test]
fn doctor_default_requested_mode_shown_when_env_unset() {
let stdout = doctor_stdout(&[
("TIRITH_BASH_EFFECTIVE_MODE", "enter"),
("TIRITH_BASH_EFFECTIVE_PROTECTION", "blocks"),
]);
assert!(
stdout.contains("requested mode: (default)"),
"unset mode should render as (default), got:\n{stdout}"
);
assert!(
stdout.contains("bash mode: enter"),
"effective mode missing, got:\n{stdout}"
);
}
fn source_hook_run_and_dump(extra_env: &[(&str, &str)], body: &str) -> String {
let tmpdir = tempfile::tempdir().expect("failed to create tmpdir");
let hook = hook_path();
let (prelude, env_vars) = split_test_env(extra_env);
let script = format!(
"{prelude}source '{hook}' 2>/dev/null; {body}; \
printf 'MODE=%s\\nPROT=%s\\nSTATUS=%s\\n' \
\"${{TIRITH_BASH_EFFECTIVE_MODE:-}}\" \
\"${{TIRITH_BASH_EFFECTIVE_PROTECTION:-}}\" \
\"${{TIRITH_STATUS:-}}\""
);
let mut cmd = Command::new("bash");
cmd.args(["--norc", "--noprofile", "-i", "-c", &script])
.env_clear()
.env("HOME", std::env::var("HOME").unwrap_or_default())
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("XDG_STATE_HOME", tmpdir.path());
for (k, v) in &env_vars {
cmd.env(k, v);
}
let out = cmd.output().expect("failed to run bash");
assert!(
out.status.success(),
"bash exited non-zero: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).to_string()
}
#[test]
fn degrade_to_preexec_reexports_effective_state() {
let out = source_hook_run_and_dump(
&[("_TIRITH_TEST_SKIP_HEALTH", "1")],
"_tirith_degrade_to_preexec degrade-test",
);
assert!(
out.contains("MODE=preexec"),
"degrade must re-export TIRITH_BASH_EFFECTIVE_MODE=preexec, got:\n{out}"
);
assert!(
out.contains("PROT=warn-only"),
"degrade must re-export TIRITH_BASH_EFFECTIVE_PROTECTION=warn-only, got:\n{out}"
);
}