#[cfg(unix)]
use libc;
use std::path::{Path, PathBuf};
use crate::assets;
fn posix_single_quote(path: &str) -> String {
format!("'{}'", path.replace('\'', "'\\''"))
}
fn powershell_single_quote(path: &str) -> String {
format!("'{}'", path.replace('\'', "''"))
}
pub(crate) fn nushell_string_literal(value: &str) -> String {
let mut quoted = String::with_capacity(value.len() + 2);
quoted.push('"');
for character in value.chars() {
match character {
'\\' => quoted.push_str("\\\\"),
'"' => quoted.push_str("\\\""),
'\n' => quoted.push_str("\\n"),
'\r' => quoted.push_str("\\r"),
'\t' => quoted.push_str("\\t"),
control if control.is_control() => {
use std::fmt::Write as _;
write!(quoted, "\\u{{{:x}}}", control as u32)
.expect("writing to a String cannot fail");
}
ordinary => quoted.push(ordinary),
}
}
quoted.push('"');
quoted
}
fn check_path_shadow() -> Option<String> {
let shadows = super::find_shadow_binaries();
if shadows.is_empty() {
return None;
}
let our_exe = std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "unknown".into());
Some(format!(
"tirith: WARNING: '{}' shadows this binary ({})\n\
tirith: This may be a different package (e.g. pip-installed).\n\
tirith: Run '{}' to inspect, and remove the conflicting binary.",
shadows[0],
our_exe,
super::tirith_path_lookup_command(),
))
}
const SHADOW_WARN_INTERVAL_SECS: u64 = 24 * 60 * 60;
fn shadow_warn_due() -> bool {
let Some(marker) = tirith_core::policy::state_dir().map(|d| d.join("shadow-warned")) else {
return true;
};
match std::fs::metadata(&marker).and_then(|m| m.modified()) {
Ok(modified) => modified
.elapsed()
.map(|age| age.as_secs() >= SHADOW_WARN_INTERVAL_SECS)
.unwrap_or(true),
Err(_) => true,
}
}
fn mark_shadow_warned() {
if let Some(dir) = tirith_core::policy::state_dir() {
let _ = std::fs::create_dir_all(&dir);
let _ = std::fs::write(dir.join("shadow-warned"), b"");
}
}
pub fn run(shell: Option<&str>, prompt_status: bool) -> i32 {
if !crate::cli::is_quiet() && shadow_warn_due() {
if let Some(warning) = check_path_shadow() {
eprintln!("{warning}");
mark_shadow_warned();
}
}
let shell = shell.unwrap_or_else(|| detect_shell());
let prompt_executable = if prompt_status {
match tirith_core::trusted_child::TrustedExecutable::current() {
Ok(executable) => Some(executable),
Err(error) => {
eprintln!("tirith: cannot bind prompt status to the running executable: {error}");
return 1;
}
}
} else {
None
};
let hook_dir = find_hook_dir();
match shell {
"zsh" => {
if let Some(dir) = &hook_dir {
println!(
"source {}",
posix_single_quote(&dir.join("lib/zsh-hook.zsh").display().to_string())
);
} else {
eprintln!("tirith: could not locate or materialize shell hooks.");
return 1;
}
if prompt_status {
println!(
"{}",
prompt_status_snippet_for("zsh", prompt_executable.as_ref().unwrap().path())
);
}
0
}
"bash" => {
if let Some(dir) = &hook_dir {
println!(
"source {}",
posix_single_quote(&dir.join("lib/bash-hook.bash").display().to_string())
);
} else {
eprintln!("tirith: could not locate or materialize shell hooks.");
return 1;
}
if prompt_status {
println!(
"{}",
prompt_status_snippet_for("bash", prompt_executable.as_ref().unwrap().path())
);
}
0
}
"fish" => {
if let Some(dir) = &hook_dir {
println!(
"source {}",
posix_single_quote(&dir.join("lib/fish-hook.fish").display().to_string())
);
} else {
eprintln!("tirith: could not locate or materialize shell hooks.");
return 1;
}
if prompt_status {
println!(
"{}",
prompt_status_snippet_for("fish", prompt_executable.as_ref().unwrap().path())
);
}
0
}
"powershell" | "pwsh" => {
if let Some(dir) = &hook_dir {
println!(
". {}",
powershell_single_quote(
&dir.join("lib/powershell-hook.ps1").display().to_string()
)
);
} else {
eprintln!("tirith: could not locate or materialize shell hooks.");
return 1;
}
if prompt_status {
println!(
"{}",
prompt_status_snippet_for(
"powershell",
prompt_executable.as_ref().unwrap().path(),
)
);
}
0
}
"nushell" | "nu" => {
if let Some(dir) = &hook_dir {
let hook_path = dir.join("lib/nushell-hook.nu");
if !hook_path.is_file() {
eprintln!(
"tirith: resolved nushell hook is missing or not a file: {}",
hook_path.display()
);
return 1;
}
let Some(hook_path) = hook_path.to_str() else {
eprintln!(
"tirith: resolved nushell hook path is not valid UTF-8 and cannot be emitted without changing its identity"
);
return 1;
};
println!("source {}", nushell_string_literal(hook_path));
} else {
eprintln!("tirith: could not locate or materialize shell hooks.");
return 1;
}
if prompt_status {
println!(
"{}",
prompt_status_snippet_for(
"nushell",
prompt_executable.as_ref().unwrap().path(),
)
);
}
0
}
_ => {
eprintln!("tirith: unsupported shell '{shell}'");
eprintln!("Supported: zsh, bash, fish, powershell, nushell");
eprintln!(" try: tirith init --shell zsh");
1
}
}
}
pub(crate) fn prompt_status_snippet_for(shell: &str, executable: &std::path::Path) -> String {
let executable = executable.display().to_string();
let posix_executable = posix_single_quote(&executable);
let powershell_executable = powershell_single_quote(&executable);
match shell {
"zsh" => {
let substitution = posix_single_quote(&format!(
"$(TIRITH_STATUS=\"${{TIRITH_STATUS:-}}\" {posix_executable} prompt-status --short) "
));
[
"# >>> tirith prompt-status (M8 ch6) >>>".to_string(),
"if [[ -z \"${_TIRITH_PROMPT_STATUS_LOADED:-}\" ]]; then".to_string(),
" _TIRITH_PROMPT_STATUS_LOADED=1".to_string(),
" setopt PROMPT_SUBST".to_string(),
format!(" PROMPT={substitution}\"$PROMPT\""),
"fi".to_string(),
"# <<< tirith prompt-status (M8 ch6) <<<".to_string(),
]
.join("\n")
}
"bash" => {
let substitution = posix_single_quote(&format!(
"$(TIRITH_STATUS=\"${{TIRITH_STATUS:-}}\" {posix_executable} prompt-status --short) "
));
[
"# >>> tirith prompt-status (M8 ch6) >>>".to_string(),
"if [ -z \"${_TIRITH_PROMPT_STATUS_LOADED:-}\" ]; then".to_string(),
" _TIRITH_PROMPT_STATUS_LOADED=1".to_string(),
format!(" PS1={substitution}\"$PS1\""),
"fi".to_string(),
"# <<< tirith prompt-status (M8 ch6) <<<".to_string(),
]
.join("\n")
}
"fish" => [
"# >>> tirith prompt-status (M8 ch6) >>>".to_string(),
"if not set -q _TIRITH_PROMPT_STATUS_LOADED".to_string(),
" set -g _TIRITH_PROMPT_STATUS_LOADED 1".to_string(),
" functions -q fish_right_prompt; and functions -e _tirith_orig_fish_right_prompt".to_string(),
" if functions -q fish_right_prompt".to_string(),
" functions -c fish_right_prompt _tirith_orig_fish_right_prompt".to_string(),
" end".to_string(),
" function fish_right_prompt".to_string(),
format!(" env TIRITH_STATUS=\"$TIRITH_STATUS\" {posix_executable} prompt-status --short"),
" if functions -q _tirith_orig_fish_right_prompt".to_string(),
" _tirith_orig_fish_right_prompt".to_string(),
" end".to_string(),
" end".to_string(),
"end".to_string(),
"# <<< tirith prompt-status (M8 ch6) <<<".to_string(),
]
.join("\n"),
"powershell" | "pwsh" => [
"# >>> tirith prompt-status (M8 ch6) >>>".to_string(),
"if (-not $global:_TIRITH_PROMPT_STATUS_LOADED) {".to_string(),
" $global:_TIRITH_PROMPT_STATUS_LOADED = $true".to_string(),
" if (Test-Path Function:prompt) {".to_string(),
" Copy-Item Function:prompt Function:_tirith_orig_prompt -Force".to_string(),
" }".to_string(),
" function global:prompt {".to_string(),
" $_tps = $env:TIRITH_STATUS; $env:TIRITH_STATUS = $global:TIRITH_STATUS".to_string(),
format!(" try {{ $line = (& {powershell_executable} prompt-status --short) 2>$null }} finally {{ if ($null -eq $_tps) {{ Remove-Item Env:\\TIRITH_STATUS -ErrorAction SilentlyContinue }} else {{ $env:TIRITH_STATUS = $_tps }} }}"),
" if (Get-Command _tirith_orig_prompt -ErrorAction SilentlyContinue) {".to_string(),
" \"$line $(_tirith_orig_prompt)\"".to_string(),
" } else {".to_string(),
" \"$line PS $($executionContext.SessionState.Path.CurrentLocation)> \"".to_string(),
" }".to_string(),
" }".to_string(),
"}".to_string(),
"# <<< tirith prompt-status (M8 ch6) <<<".to_string(),
]
.join("\n"),
"nushell" | "nu" => [
"# >>> tirith prompt-status (M8 ch6) >>>",
"# Nushell exposes no non-exported TIRITH_STATUS variable, so a live",
"# status segment would always read `off`. nushell protection is",
"# warn-only regardless — there is no live prompt status to wire up",
"# here. See docs/prompt-status.md.",
"# <<< tirith prompt-status (M8 ch6) <<<",
]
.join("\n"),
_ => String::new(),
}
}
pub(crate) fn detect_shell() -> &'static str {
if let Some(shell) = detect_shell_from_parent() {
return shell;
}
if let Ok(shell) = std::env::var("SHELL") {
if let Some(shell) = normalize_shell_name(&shell) {
return shell;
}
}
#[cfg(windows)]
return "powershell";
#[cfg(not(windows))]
"bash"
}
fn normalize_shell_name(name: &str) -> Option<&'static str> {
let name = name.trim();
if name.is_empty() {
return None;
}
let base = name
.rsplit(['/', '\\'])
.next()
.unwrap_or(name)
.trim_start_matches('-')
.to_ascii_lowercase();
if base.contains("zsh") {
Some("zsh")
} else if base.contains("bash") {
Some("bash")
} else if base.contains("fish") {
Some("fish")
} else if base.contains("pwsh") {
Some("pwsh")
} else if base.contains("powershell") {
Some("powershell")
} else if base == "nu" || base == "nu.exe" || base.contains("nushell") {
Some("nushell")
} else {
None
}
}
#[cfg(unix)]
fn detect_shell_from_parent() -> Option<&'static str> {
let mut pid = unsafe { libc::getppid() };
for _ in 0..8 {
if pid <= 1 {
return None;
}
let (name, parent_pid) = read_process(pid)?;
if let Some(shell) = normalize_shell_name(&name) {
return Some(shell);
}
if parent_pid == pid {
break;
}
pid = parent_pid;
}
None
}
#[cfg(unix)]
fn read_process(pid: libc::pid_t) -> Option<(String, libc::pid_t)> {
use tirith_core::trusted_child::{ChildLimits, ChildOutcome, ChildSpec, TrustedExecutable};
let program = TrustedExecutable::from_system_candidates(&[
std::path::Path::new("/bin/ps"),
std::path::Path::new("/usr/bin/ps"),
])
.ok()?;
let pid = pid.to_string();
let spec = ChildSpec::new(
["-p", pid.as_str(), "-o", "comm=", "-o", "ppid="],
ChildLimits::new(std::time::Duration::from_secs(1), 16 * 1024, 16 * 1024),
);
let ChildOutcome::Completed { status, stdout, .. } =
tirith_core::trusted_child::run(&program, &spec)
else {
return None;
};
if !status.success() {
return None;
}
let line = String::from_utf8_lossy(&stdout);
let mut parts = line.split_whitespace();
let name = parts.next()?.to_string();
let ppid = parts.next()?.parse::<libc::pid_t>().ok()?;
Some((name, ppid))
}
#[cfg(not(unix))]
fn detect_shell_from_parent() -> Option<&'static str> {
None
}
pub fn find_hook_dir() -> Option<PathBuf> {
if let Ok(dir) = std::env::var("TIRITH_SHELL_DIR") {
let p = PathBuf::from(&dir);
if p.join("lib").exists() {
return Some(p);
}
}
if let Ok(exe) = std::env::current_exe() {
if let Some(bin_dir) = exe.parent() {
let brew_dir = bin_dir.join("../share/tirith/shell");
if brew_dir.join("lib").exists() {
return Some(brew_dir.canonicalize().unwrap_or(brew_dir));
}
#[cfg(unix)]
{
let sys_dir = PathBuf::from("/usr/share/tirith/shell");
if sys_dir.join("lib").exists() {
return Some(sys_dir);
}
}
let cargo_dir = bin_dir.join("../shell");
if cargo_dir.join("lib").exists() {
return Some(cargo_dir.canonicalize().unwrap_or(cargo_dir));
}
let dev_dir = bin_dir.join("../../shell");
if dev_dir.join("lib").exists() {
return Some(dev_dir.canonicalize().unwrap_or(dev_dir));
}
}
}
materialize_hooks()
}
pub fn find_hook_dir_readonly() -> Option<PathBuf> {
if let Ok(dir) = std::env::var("TIRITH_SHELL_DIR") {
let p = PathBuf::from(&dir);
if p.join("lib").exists() {
return Some(p);
}
}
if let Ok(exe) = std::env::current_exe() {
if let Some(bin_dir) = exe.parent() {
let brew_dir = bin_dir.join("../share/tirith/shell");
if brew_dir.join("lib").exists() {
return Some(brew_dir.canonicalize().unwrap_or(brew_dir));
}
#[cfg(unix)]
{
let sys_dir = PathBuf::from("/usr/share/tirith/shell");
if sys_dir.join("lib").exists() {
return Some(sys_dir);
}
}
let cargo_dir = bin_dir.join("../shell");
if cargo_dir.join("lib").exists() {
return Some(cargo_dir.canonicalize().unwrap_or(cargo_dir));
}
let dev_dir = bin_dir.join("../../shell");
if dev_dir.join("lib").exists() {
return Some(dev_dir.canonicalize().unwrap_or(dev_dir));
}
}
}
if let Some(data_dir) = tirith_core::policy::data_dir() {
let shell_dir = data_dir.join("shell");
if materialized_hooks_match_at(&data_dir).unwrap_or(false) {
return Some(shell_dir);
}
}
None
}
fn materialize_hooks() -> Option<PathBuf> {
let data_dir = tirith_core::policy::data_dir()?;
match materialize_hooks_at(&data_dir) {
Ok((shell_dir, wrote)) => {
if wrote {
eprintln!(
"tirith: materialized shell hooks to {}",
shell_dir.display()
);
}
Some(shell_dir)
}
Err(error) => {
eprintln!("tirith: failed to materialize shell hooks: {error}");
None
}
}
}
fn expected_materialized_files<'a>(
shell_dir: &Path,
version: &'a [u8],
) -> Vec<(PathBuf, &'a [u8])> {
let lib_dir = shell_dir.join("lib");
vec![
(shell_dir.join("tirith.sh"), assets::TIRITH_SH.as_bytes()),
(lib_dir.join("zsh-hook.zsh"), assets::ZSH_HOOK.as_bytes()),
(lib_dir.join("bash-hook.bash"), assets::BASH_HOOK.as_bytes()),
(lib_dir.join("fish-hook.fish"), assets::FISH_HOOK.as_bytes()),
(
lib_dir.join("powershell-hook.ps1"),
assets::POWERSHELL_HOOK.as_bytes(),
),
(
lib_dir.join("nushell-hook.nu"),
assets::NUSHELL_HOOK.as_bytes(),
),
(shell_dir.join(".hooks-version"), version),
]
}
fn prepared_file_matches(
destination: &tirith_core::util::ContainedAtomicFile,
path: &Path,
expected: &[u8],
) -> Result<bool, String> {
let cap = u64::try_from(expected.len())
.unwrap_or(u64::MAX)
.saturating_add(1);
match destination.read_capped(cap) {
Ok(observed) => Ok(observed == expected),
Err(tirith_core::util::OpenRegularError::NotFound)
| Err(tirith_core::util::OpenRegularError::TooLarge) => Ok(false),
Err(tirith_core::util::OpenRegularError::NotRegularFile) => Err(format!(
"refusing non-regular materialized hook destination {}",
path.display()
)),
Err(tirith_core::util::OpenRegularError::Io(error)) => Err(format!(
"could not read materialized hook {}: {error}",
path.display()
)),
}
}
fn materialized_hooks_match_at(data_dir: &Path) -> Result<bool, String> {
let shell_dir = data_dir.join("shell");
let version = format!("{}\n", env!("CARGO_PKG_VERSION"));
for (path, expected) in expected_materialized_files(&shell_dir, version.as_bytes()) {
let destination =
match tirith_core::util::ContainedAtomicFile::prepare(data_dir, &path, false) {
Ok(destination) => destination,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => {
return Err(format!(
"could not bind materialized hook {}: {error}",
path.display()
))
}
};
if !prepared_file_matches(&destination, &path, expected)? {
return Ok(false);
}
}
Ok(true)
}
fn materialize_hooks_at(data_dir: &Path) -> Result<(PathBuf, bool), String> {
let shell_dir = data_dir.join("shell");
let version = format!("{}\n", env!("CARGO_PKG_VERSION"));
let expected_files = expected_materialized_files(&shell_dir, version.as_bytes());
let mut prepared = Vec::with_capacity(expected_files.len());
for (path, expected) in expected_files {
let destination = tirith_core::util::ContainedAtomicFile::prepare(data_dir, &path, true)
.map_err(|error| {
format!(
"could not bind materialized hook destination {}: {error}",
path.display()
)
})?;
prepared.push((path, expected, destination));
}
let mut needs_write = false;
for (path, expected, destination) in &prepared {
if !prepared_file_matches(destination, path, expected)? {
needs_write = true;
}
}
if !needs_write {
return Ok((shell_dir, false));
}
for (path, expected, destination) in &prepared {
destination.write_atomic(expected, true).map_err(|error| {
format!(
"failed to write materialized hook {}: {error}",
path.display()
)
})?;
}
for (path, expected, destination) in &prepared {
if !prepared_file_matches(destination, path, expected)? {
return Err(format!(
"materialized hook did not verify after publication: {}",
path.display()
));
}
}
Ok((shell_dir, true))
}
#[cfg(test)]
mod tests {
use super::{
materialize_hooks_at, materialized_hooks_match_at, normalize_shell_name,
nushell_string_literal, posix_single_quote, powershell_single_quote,
prompt_status_snippet_for,
};
use std::path::Path;
fn prompt_status_snippet(shell: &str) -> String {
prompt_status_snippet_for(shell, Path::new("/opt/Tirith Bin/tirith"))
}
#[test]
fn nushell_literal_quotes_apostrophe_hash_backslash_quote_and_controls() {
assert_eq!(
nushell_string_literal("/tmp/it's \\quoted\" #hook\n\t\u{001f}"),
"\"/tmp/it's \\\\quoted\\\" #hook\\n\\t\\u{1f}\""
);
}
#[test]
fn normalize_shell_name_from_paths_and_login_shells() {
assert_eq!(normalize_shell_name("/bin/bash"), Some("bash"));
assert_eq!(normalize_shell_name("/opt/homebrew/bin/fish"), Some("fish"));
assert_eq!(normalize_shell_name("-zsh"), Some("zsh"));
}
#[test]
fn normalize_shell_name_supports_case_insensitive_names() {
assert_eq!(normalize_shell_name("BASH"), Some("bash"));
assert_eq!(normalize_shell_name("PwSh"), Some("pwsh"));
assert_eq!(normalize_shell_name("PowerShell"), Some("powershell"));
}
#[test]
fn normalize_shell_name_distinguishes_pwsh_and_windows_powershell() {
assert_eq!(normalize_shell_name("/usr/local/bin/pwsh"), Some("pwsh"));
assert_eq!(
normalize_shell_name("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"),
Some("powershell")
);
assert_eq!(normalize_shell_name("/bin/bash"), Some("bash"));
}
#[test]
fn normalize_shell_name_supports_nushell() {
assert_eq!(normalize_shell_name("nu"), Some("nushell"));
assert_eq!(normalize_shell_name("nu.exe"), Some("nushell"));
assert_eq!(normalize_shell_name("nushell"), Some("nushell"));
assert_eq!(normalize_shell_name("nushell.exe"), Some("nushell"));
assert_eq!(normalize_shell_name("/usr/bin/nu"), Some("nushell"));
assert_eq!(
normalize_shell_name("C:\\Program Files\\nu.exe"),
Some("nushell")
);
}
#[test]
fn normalize_shell_name_no_false_positive_on_gnu() {
assert_eq!(normalize_shell_name("gnu"), None);
}
#[test]
fn normalize_shell_name_rejects_unknown_values() {
assert_eq!(normalize_shell_name(""), None);
assert_eq!(normalize_shell_name("python"), None);
}
#[test]
fn quote_helpers_escape_shell_metacharacters() {
assert_eq!(
posix_single_quote("/tmp/hook' > file"),
"'/tmp/hook'\\'' > file'"
);
assert_eq!(
powershell_single_quote("C:\\temp\\it's.ps1"),
"'C:\\temp\\it''s.ps1'"
);
}
#[test]
fn prompt_status_snippet_zsh_is_marker_wrapped_and_deferred() {
let s = prompt_status_snippet("zsh");
assert!(s.contains("# >>> tirith prompt-status (M8 ch6) >>>"));
assert!(s.contains("# <<< tirith prompt-status (M8 ch6) <<<"));
assert!(s.contains("setopt PROMPT_SUBST"));
assert!(s.contains("_TIRITH_PROMPT_STATUS_LOADED"));
assert!(s.contains("/opt/Tirith Bin/tirith"));
assert!(!s.contains(" tirith prompt-status --short"));
}
#[test]
fn prompt_status_snippet_bash_uses_ps1_with_single_quoted_subst() {
let s = prompt_status_snippet("bash");
assert!(s.contains("/opt/Tirith Bin/tirith"));
assert!(!s.contains(" tirith prompt-status --short"));
assert!(s.contains("_TIRITH_PROMPT_STATUS_LOADED"));
assert!(s.contains("# >>> tirith prompt-status (M8 ch6) >>>"));
assert!(s.contains("# <<< tirith prompt-status (M8 ch6) <<<"));
}
#[test]
fn prompt_status_snippet_fish_wraps_right_prompt() {
let s = prompt_status_snippet("fish");
assert!(s.contains("function fish_right_prompt"));
assert!(s.contains("'/opt/Tirith Bin/tirith' prompt-status --short"));
assert!(s.contains("_TIRITH_PROMPT_STATUS_LOADED"));
}
#[test]
fn prompt_status_snippet_powershell_forwards_status_env() {
let s = prompt_status_snippet("powershell");
assert!(s.contains("$env:TIRITH_STATUS = $global:TIRITH_STATUS"));
assert!(s.contains("finally"));
assert!(s.contains("_TIRITH_PROMPT_STATUS_LOADED"));
}
#[test]
fn prompt_status_snippet_powershell_wraps_prompt_function() {
for shell in ["powershell", "pwsh"] {
let s = prompt_status_snippet(shell);
assert!(s.contains("function global:prompt"), "shell={shell}");
assert!(
s.contains("'/opt/Tirith Bin/tirith' prompt-status --short"),
"shell={shell}"
);
assert!(s.contains("$global:_TIRITH_PROMPT_STATUS_LOADED"));
}
}
#[test]
fn prompt_status_snippet_quotes_an_executable_with_a_single_quote() {
let executable = Path::new("/opt/Tirith's Bin/tirith");
for shell in ["zsh", "bash", "fish", "powershell"] {
let snippet = prompt_status_snippet_for(shell, executable);
assert!(snippet.contains("Tirith"), "shell={shell}: {snippet}");
assert!(!snippet.contains(" tirith prompt-status --short"));
}
}
#[test]
fn prompt_status_snippet_nushell_explains_no_live_status() {
let s = prompt_status_snippet("nushell");
assert!(s.contains("warn-only"));
assert!(s.contains("docs/prompt-status.md"));
assert!(
!s.contains("PROMPT_COMMAND"),
"nushell snippet must not suggest a runnable prompt closure (always reads off); got: {s}"
);
}
#[test]
fn materialized_hooks_require_and_repair_exact_asset_and_version_bytes() {
let data_dir = tempfile::tempdir().unwrap();
let (shell_dir, wrote) = materialize_hooks_at(data_dir.path()).unwrap();
assert!(wrote);
assert_eq!(
std::fs::read(shell_dir.join("tirith.sh")).unwrap(),
crate::assets::TIRITH_SH.as_bytes()
);
assert_eq!(
std::fs::read(shell_dir.join("lib/zsh-hook.zsh")).unwrap(),
crate::assets::ZSH_HOOK.as_bytes()
);
assert_eq!(
std::fs::read(shell_dir.join("lib/bash-hook.bash")).unwrap(),
crate::assets::BASH_HOOK.as_bytes()
);
assert_eq!(
std::fs::read(shell_dir.join("lib/fish-hook.fish")).unwrap(),
crate::assets::FISH_HOOK.as_bytes()
);
assert_eq!(
std::fs::read(shell_dir.join("lib/powershell-hook.ps1")).unwrap(),
crate::assets::POWERSHELL_HOOK.as_bytes()
);
assert_eq!(
std::fs::read(shell_dir.join("lib/nushell-hook.nu")).unwrap(),
crate::assets::NUSHELL_HOOK.as_bytes()
);
assert_eq!(
std::fs::read(shell_dir.join(".hooks-version")).unwrap(),
format!("{}\n", env!("CARGO_PKG_VERSION")).as_bytes()
);
assert!(materialized_hooks_match_at(data_dir.path()).unwrap());
std::fs::write(shell_dir.join("lib/zsh-hook.zsh"), b"tampered\n").unwrap();
std::fs::write(
shell_dir.join(".hooks-version"),
format!("{}\n", env!("CARGO_PKG_VERSION")),
)
.unwrap();
assert!(!materialized_hooks_match_at(data_dir.path()).unwrap());
let (_, repaired) = materialize_hooks_at(data_dir.path()).unwrap();
assert!(repaired);
assert_eq!(
std::fs::read(shell_dir.join("lib/zsh-hook.zsh")).unwrap(),
crate::assets::ZSH_HOOK.as_bytes()
);
std::fs::write(shell_dir.join(".hooks-version"), env!("CARGO_PKG_VERSION")).unwrap();
assert!(!materialized_hooks_match_at(data_dir.path()).unwrap());
let (_, repaired_version) = materialize_hooks_at(data_dir.path()).unwrap();
assert!(repaired_version);
assert_eq!(
std::fs::read(shell_dir.join(".hooks-version")).unwrap(),
format!("{}\n", env!("CARGO_PKG_VERSION")).as_bytes()
);
let (_, rewrote_current_bundle) = materialize_hooks_at(data_dir.path()).unwrap();
assert!(!rewrote_current_bundle);
}
#[cfg(unix)]
#[test]
fn materialized_hooks_reject_symlinked_lib_directory_without_external_write() {
use std::os::unix::fs::symlink;
let data_dir = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let shell_dir = data_dir.path().join("shell");
std::fs::create_dir(&shell_dir).unwrap();
symlink(outside.path(), shell_dir.join("lib")).unwrap();
let error = materialize_hooks_at(data_dir.path()).unwrap_err();
assert!(error.contains("could not bind materialized hook destination"));
assert!(std::fs::read_dir(outside.path()).unwrap().next().is_none());
assert!(!shell_dir.join("tirith.sh").exists());
}
#[cfg(unix)]
#[test]
fn materialized_hooks_reject_symlinked_leaf_without_touching_target() {
use std::os::unix::fs::symlink;
let data_dir = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let sentinel = outside.path().join("sentinel");
std::fs::write(&sentinel, b"unchanged").unwrap();
let lib_dir = data_dir.path().join("shell/lib");
std::fs::create_dir_all(&lib_dir).unwrap();
symlink(&sentinel, lib_dir.join("zsh-hook.zsh")).unwrap();
let error = materialize_hooks_at(data_dir.path()).unwrap_err();
assert!(error.contains("could not bind materialized hook destination"));
assert_eq!(std::fs::read(&sentinel).unwrap(), b"unchanged");
assert!(!data_dir.path().join("shell/tirith.sh").exists());
}
#[test]
fn materialized_hooks_reject_non_directory_parents_and_non_regular_leaves() {
let file_shell_root = tempfile::tempdir().unwrap();
std::fs::write(file_shell_root.path().join("shell"), b"not a directory").unwrap();
let shell_error = materialize_hooks_at(file_shell_root.path()).unwrap_err();
assert!(shell_error.contains("could not bind materialized hook destination"));
let file_lib_root = tempfile::tempdir().unwrap();
let shell_dir = file_lib_root.path().join("shell");
std::fs::create_dir(&shell_dir).unwrap();
std::fs::write(shell_dir.join("lib"), b"not a directory").unwrap();
let lib_error = materialize_hooks_at(file_lib_root.path()).unwrap_err();
assert!(lib_error.contains("could not bind materialized hook destination"));
assert!(!shell_dir.join("tirith.sh").exists());
let directory_leaf_root = tempfile::tempdir().unwrap();
let directory_leaf = directory_leaf_root.path().join("shell/lib/zsh-hook.zsh");
std::fs::create_dir_all(&directory_leaf).unwrap();
let leaf_error = materialize_hooks_at(directory_leaf_root.path()).unwrap_err();
assert!(leaf_error.contains("refusing non-regular materialized hook destination"));
assert!(!directory_leaf_root.path().join("shell/tirith.sh").exists());
}
}