use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tirith_core::path_audit::{self, PathAuditReport, PathDirRisk};
use tirith_core::policy;
use super::write_json_stdout;
fn env_path() -> String {
std::env::var("PATH").unwrap_or_default()
}
fn tmp_roots() -> Vec<std::path::PathBuf> {
let mut roots = vec![std::path::PathBuf::from("/tmp")];
if let Some(tmp) = std::env::var_os("TMPDIR") {
let p = std::path::PathBuf::from(tmp);
if !p.as_os_str().is_empty() {
roots.push(p);
}
}
roots
}
pub fn audit(json: bool) -> i32 {
let path_value = env_path();
let repo_root = policy::find_repo_root(None);
let report = path_audit::audit_path_str(&path_value, repo_root.as_deref(), &tmp_roots());
if json {
let mut body = serde_json::json!({
"schema_version": 1,
"path_dirs": report.path_dirs,
"findings": report.findings,
"has_high": report.has_high(),
});
if let Some(note) = platform_note() {
body["platform_note"] = serde_json::Value::String(note.to_string());
}
if !write_json_stdout(&body, "tirith path audit: failed to write JSON output") {
return 1;
}
} else {
print_human_audit(&report);
}
if report.has_high() {
1
} else {
0
}
}
pub fn watch(interval: u64, json: bool) -> i32 {
let interval = interval.max(1);
install_sigint_handler();
if !json {
eprintln!("tirith path watch: auditing $PATH every {interval}s (Ctrl-C to stop).");
}
let repo_root = policy::find_repo_root(None);
let tmp = tmp_roots();
let mut last_signature: Option<String> = None;
let mut polls: u64 = 0;
while !STOP.load(Ordering::Relaxed) {
let step = Duration::from_millis(200);
let target = Duration::from_secs(interval);
let mut slept = Duration::ZERO;
while slept < target && !STOP.load(Ordering::Relaxed) {
std::thread::sleep(step);
slept += step;
}
if STOP.load(Ordering::Relaxed) {
break;
}
polls += 1;
let report = path_audit::audit_path_str(&env_path(), repo_root.as_deref(), &tmp);
let signature = signature_of(&report);
if last_signature.as_deref() != Some(signature.as_str()) {
if json {
let body = serde_json::json!({
"schema_version": 1,
"poll": polls,
"findings": report.findings,
"has_high": report.has_high(),
});
let _ = write_json_stdout(&body, "tirith path watch: failed to write JSON");
} else {
eprintln!("\n[poll {polls}] $PATH audit changed:");
print_human_audit(&report);
}
last_signature = Some(signature);
}
}
if !json {
eprintln!("\ntirith path watch: stopped after {polls} poll(s).");
}
0
}
pub fn which(cmd: &str, secure: bool, json: bool) -> i32 {
let path_value = env_path();
let hits = path_audit::which_all(cmd, &path_value);
let first_is_system = hits.first().map(|p| path_audit::is_system_path(p));
let insecure = secure && matches!(first_is_system, Some(false));
if json {
let body = serde_json::json!({
"schema_version": 1,
"command": cmd,
"secure": secure,
"resolved": !hits.is_empty(),
"hits": hits.iter().map(|p| p.display().to_string()).collect::<Vec<_>>(),
"first_is_system": first_is_system,
"insecure": insecure,
});
if !write_json_stdout(&body, "tirith path which: failed to write JSON output") {
return 1;
}
} else {
print_human_which(cmd, &hits, secure, insecure);
}
if hits.is_empty() {
return 2;
}
if insecure {
1
} else {
0
}
}
fn signature_of(report: &PathAuditReport) -> String {
report
.findings
.iter()
.map(|e| format!("{}|{}|{}", e.dir, risk_str(e.risk), e.command))
.collect::<Vec<_>>()
.join("\n")
}
fn risk_str(risk: PathDirRisk) -> &'static str {
match risk {
PathDirRisk::InRepo => "in_repo",
PathDirRisk::InTmp => "in_tmp",
PathDirRisk::WritableBeforeSystem => "writable_before_system",
PathDirRisk::DuplicateCommand => "duplicate_command",
}
}
fn risk_label(risk: PathDirRisk) -> &'static str {
match risk {
PathDirRisk::InRepo => "repo-local PATH dir",
PathDirRisk::InTmp => "/tmp PATH dir (HIGH)",
PathDirRisk::WritableBeforeSystem => "user-writable, precedes system path (HIGH)",
PathDirRisk::DuplicateCommand => "duplicate command",
}
}
fn platform_note() -> Option<&'static str> {
#[cfg(windows)]
{
Some(
"PATH audit on Windows covers repo-local / temp / duplicate-command risks; the \
user-writable-before-system rule is not yet implemented on this platform (it \
relies on a Unix writability probe), so a 'clean' result does not rule that out.",
)
}
#[cfg(not(windows))]
{
None
}
}
fn print_human_audit(report: &PathAuditReport) {
eprintln!(
"tirith path audit: {} PATH dir(s), {} finding(s).",
report.path_dirs.len(),
report.findings.len()
);
if let Some(note) = platform_note() {
eprintln!(" note: {note}");
}
if report.findings.is_empty() {
eprintln!(" $PATH is clean (no repo-local / /tmp / writable-before-system dirs, no duplicate commands).");
return;
}
for e in &report.findings {
match e.risk {
PathDirRisk::DuplicateCommand => {
eprintln!(
" [{}] {} (shadowed copy in {})",
risk_label(e.risk),
super::sanitize_for_human_output(&e.command, false),
super::sanitize_for_human_output(&e.dir, false)
);
}
_ => {
eprintln!(
" [{}] {}",
risk_label(e.risk),
super::sanitize_for_human_output(&e.dir, false)
);
}
}
}
eprintln!("\nReorder $PATH so system dirs precede user-writable ones; run `tirith path which <cmd>` to see what wins.");
}
fn print_human_which(cmd: &str, hits: &[std::path::PathBuf], secure: bool, insecure: bool) {
let cmd_display = super::sanitize_for_human_output(cmd, false);
if hits.is_empty() {
eprintln!("tirith path which: `{cmd_display}` not found on $PATH.");
return;
}
eprintln!("tirith path which `{cmd_display}`:");
for (i, h) in hits.iter().enumerate() {
let sys = if path_audit::is_system_path(h) {
" [system]"
} else {
""
};
let marker = if i == 0 { "→" } else { " " };
eprintln!(
" {marker} {}{sys}",
super::sanitize_for_human_output(&h.display().to_string(), false)
);
}
if secure {
if insecure {
eprintln!(
"\n--secure: FAIL — `{cmd_display}` resolves to a non-system binary ({}).",
super::sanitize_for_human_output(&hits[0].display().to_string(), false)
);
} else {
eprintln!("\n--secure: ok — `{cmd_display}` resolves to a system binary.");
}
}
}
static STOP: AtomicBool = AtomicBool::new(false);
#[cfg(unix)]
fn install_sigint_handler() {
extern "C" fn handle(_sig: libc::c_int) {
STOP.store(true, Ordering::Relaxed);
}
unsafe {
libc::signal(libc::SIGINT, handle as *const () as libc::sighandler_t);
}
}
#[cfg(not(unix))]
fn install_sigint_handler() {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn which_unresolved_exits_2() {
assert_eq!(which("tirith-no-such-cmd-xyz-9999", false, true), 2);
}
#[test]
fn risk_str_round_trip() {
assert_eq!(risk_str(PathDirRisk::InTmp), "in_tmp");
assert_eq!(
risk_str(PathDirRisk::WritableBeforeSystem),
"writable_before_system"
);
}
#[test]
fn signature_changes_with_findings() {
let empty = PathAuditReport::default();
let mut one = PathAuditReport::default();
one.findings.push(tirith_core::path_audit::PathAuditEntry {
dir: "/tmp/x".into(),
risk: PathDirRisk::InTmp,
command: String::new(),
});
assert_ne!(signature_of(&empty), signature_of(&one));
}
}