use tirith_core::aliases::{self, AliasEntry, AliasFinding, AliasScan};
use tirith_core::redact::redact;
use tirith_core::verdict::Severity;
use super::write_json_stdout;
pub fn scan(include_runtime: bool, json: bool) -> i32 {
let scan = aliases::scan(include_runtime);
let any_high = scan.findings.iter().any(AliasFinding::is_high);
if json {
let body = scan_json_body(&scan, include_runtime);
if !write_json_stdout(&body, "tirith aliases scan: failed to write JSON output") {
return 1;
}
} else {
print_human_scan(&scan, include_runtime);
}
if any_high {
1
} else {
0
}
}
pub fn explain(name: &str, include_runtime: bool, json: bool) -> i32 {
let ex = aliases::explain(name, include_runtime);
if json {
let body = explain_json_body(name, &ex);
if !write_json_stdout(&body, "tirith aliases explain: failed to write JSON output") {
return 1;
}
} else {
print_human_explain(name, &ex);
}
if ex.matches.is_empty() {
2
} else {
0
}
}
fn print_human_scan(scan: &AliasScan, include_runtime: bool) {
let aliases_n = scan
.entries
.iter()
.filter(|e| e.kind == tirith_core::aliases::AliasKind::Alias)
.count();
let functions_n = scan.entries.len() - aliases_n;
eprintln!(
"tirith aliases: {} definition(s) found ({aliases_n} alias, {functions_n} function).",
scan.entries.len()
);
if include_runtime {
eprintln!(
" (static parse + runtime no-rc introspection; runtime spawns shells with \
--norc / -f / --no-config so your rc files are NOT sourced.)"
);
eprintln!(
" note: runtime introspection cannot see aliases/functions defined only in an \
already-running interactive shell; a clean result does not prove their absence."
);
if !scan.runtime_skipped.is_empty() {
eprintln!(
" runtime-skipped shells (unsupported / not installed): {}",
scan.runtime_skipped.join(", ")
);
}
} else {
eprintln!(
" (static parse only — pass --include-runtime to also introspect live shells \
with no-rc flags.)"
);
}
eprintln!();
for e in &scan.entries {
let parsed = if e.body_parsed {
""
} else {
" [body unparsed — review manually]"
};
eprintln!(
" {:<9} {:<20} [{}] {}{}",
e.kind.as_str(),
e.name,
e.shell.as_str(),
aliases::short_location(e),
parsed,
);
}
if scan.findings.is_empty() {
eprintln!("\ntirith aliases: no risky aliases / functions detected.");
return;
}
let high = scan.findings.iter().filter(|f| f.is_high()).count();
eprintln!(
"\ntirith aliases: {} finding(s) ({high} high).\n",
scan.findings.len()
);
for f in &scan.findings {
print_one_finding(f);
}
eprintln!("Run `tirith aliases explain <name>` to see a definition's body + full analysis.");
}
fn print_human_explain(name: &str, ex: &aliases::AliasExplain) {
if ex.matches.is_empty() {
eprintln!(
"tirith aliases: no alias or function named `{}` found.",
super::sanitize_for_human_output(name, false)
);
eprintln!(
" (static parse of your rc/profile files; pass --include-runtime to also check \
live shells.)"
);
return;
}
eprintln!(
"tirith aliases explain `{}`: {} definition(s).\n",
super::sanitize_for_human_output(name, false),
ex.matches.len()
);
for e in &ex.matches {
eprintln!(
" {} ({}) — {}",
super::sanitize_for_human_output(&e.name, false),
e.kind.as_str(),
super::sanitize_for_human_output(&aliases::short_location(e), false),
);
if e.body.is_empty() {
eprintln!(" body: (empty / not captured)");
} else if e.body_parsed {
eprintln!(
" body: {}",
super::sanitize_for_human_output(&redact(&e.body), true)
);
} else {
eprintln!(
" body (UNPARSED — review manually): {}",
super::sanitize_for_human_output(&redact(&e.body), true)
);
}
eprintln!();
}
if ex.findings.is_empty() {
eprintln!(
"Analysis: no risk rules fired for `{}`.",
super::sanitize_for_human_output(name, false)
);
return;
}
eprintln!("Analysis — {} finding(s):", ex.findings.len());
for f in &ex.findings {
print_one_finding(f);
}
}
fn print_one_finding(f: &AliasFinding) {
eprintln!(
" [{}] {}\n name: {} ({})\n location: {}\n detail: {}\n",
severity_label(f.severity),
f.rule_id,
super::sanitize_for_human_output(&f.name, false),
f.kind.as_str(),
super::sanitize_for_human_output(&f.location, false),
super::sanitize_for_human_output(&f.detail, false),
);
}
fn severity_label(sev: Severity) -> &'static str {
match sev {
Severity::Info => "INFO",
Severity::Low => "LOW",
Severity::Medium => "MEDIUM",
Severity::High => "HIGH",
Severity::Critical => "CRITICAL",
}
}
fn scan_json_body(scan: &AliasScan, include_runtime: bool) -> serde_json::Value {
let high = scan.findings.iter().filter(|f| f.is_high()).count();
serde_json::json!({
"schema_version": 1,
"include_runtime": include_runtime,
"total_definitions": scan.entries.len(),
"total_findings": scan.findings.len(),
"high_or_critical": high,
"runtime_skipped": scan.runtime_skipped,
"definitions": scan
.entries
.iter()
.map(alias_entry_json)
.collect::<Vec<_>>(),
"findings": scan.findings,
})
}
fn explain_json_body(name: &str, ex: &aliases::AliasExplain) -> serde_json::Value {
serde_json::json!({
"schema_version": 1,
"name": name,
"found": !ex.matches.is_empty(),
"definitions": ex.matches.iter().map(alias_entry_json).collect::<Vec<_>>(),
"findings": ex.findings,
})
}
fn alias_entry_json(e: &AliasEntry) -> serde_json::Value {
serde_json::json!({
"name": e.name,
"kind": e.kind.as_str(),
"shell": e.shell.as_str(),
"source": e.source.as_str(),
"source_path": e.source_path.as_ref().map(|p| p.display().to_string()),
"line": e.line,
"body_parsed": e.body_parsed,
"body": redact(&e.body),
})
}
#[cfg(test)]
mod tests {
use super::*;
use tirith_core::aliases::{AliasKind, AliasScan, AliasShell, AliasSource};
fn sample_entry(name: &str, body: &str) -> AliasEntry {
AliasEntry {
name: name.to_string(),
body: body.to_string(),
kind: AliasKind::Alias,
shell: AliasShell::Bash,
source: AliasSource::StaticFile,
source_path: Some(std::path::PathBuf::from("/home/u/.bashrc")),
line: Some(7),
body_parsed: true,
}
}
#[test]
fn scan_json_body_redacts_body_and_counts() {
let scan = AliasScan {
entries: vec![sample_entry(
"getkey",
"cat ~/.aws/credentials AKIAIOSFODNN7EXAMPLE",
)],
findings: vec![],
runtime_skipped: vec![],
};
let body = scan_json_body(&scan, false);
assert_eq!(body["total_definitions"], 1);
assert_eq!(body["include_runtime"], false);
let serialized = serde_json::to_string(&body).unwrap();
assert!(
!serialized.contains("AKIAIOSFODNN7EXAMPLE"),
"alias body must be credential-redacted in JSON, got {serialized}"
);
}
#[test]
fn explain_json_body_marks_found() {
let ex = aliases::AliasExplain {
matches: vec![sample_entry("git", "git --no-pager")],
findings: vec![],
};
let body = explain_json_body("git", &ex);
assert_eq!(body["found"], true);
assert_eq!(body["name"], "git");
let empty = aliases::AliasExplain::default();
let body2 = explain_json_body("nope", &empty);
assert_eq!(body2["found"], false);
}
#[test]
fn explain_body_display_strips_terminal_controls() {
let body = "cat ~/.aws/credentials \u{1b}]52;c;SGFja2Vk\u{7}\u{202e}\u{200b}evil";
let rendered = super::super::sanitize_for_human_output(&redact(body), true);
assert!(
!rendered.contains('\u{1b}'),
"ESC must be stripped: {rendered:?}"
);
assert!(!rendered.contains('\u{202e}'));
assert!(!rendered.contains('\u{200b}'));
let ml = super::super::sanitize_for_human_output(&redact("line1\nline2"), true);
assert!(ml.contains("line1\n line2"), "continuation indent: {ml:?}");
}
}