use std::fs;
use std::io::{self, BufRead, Write};
use serde::{Deserialize, Serialize};
const DEFAULT_TTL: &str = "30d";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrustEntry {
pub pattern: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rule_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ttl_expires: Option<String>,
pub added: String,
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrustStore {
pub version: u32,
pub entries: Vec<TrustEntry>,
}
impl Default for TrustStore {
fn default() -> Self {
Self {
version: 1,
entries: Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ScopeKind {
Exact,
Substring,
Domain,
Wildcard,
BareTld,
}
impl ScopeKind {
fn label(self) -> &'static str {
match self {
ScopeKind::Exact => "exact",
ScopeKind::Substring => "substring",
ScopeKind::Domain => "domain",
ScopeKind::Wildcard => "wildcard",
ScopeKind::BareTld => "bare-TLD",
}
}
fn coverage(self) -> &'static str {
match self {
ScopeKind::Exact => "matches this exact string only",
ScopeKind::Substring => "matches any URL or command containing this substring",
ScopeKind::Domain => "matches this domain and every subdomain under it",
ScopeKind::Wildcard => "matches every subdomain of this domain",
ScopeKind::BareTld => "matches every host under this entire top-level domain",
}
}
fn is_broad(self) -> bool {
matches!(
self,
ScopeKind::Domain | ScopeKind::Wildcard | ScopeKind::BareTld
)
}
fn is_dangerous(self) -> bool {
matches!(self, ScopeKind::Wildcard | ScopeKind::BareTld)
}
}
const KNOWN_TLDS: &[&str] = &[
"com", "net", "org", "io", "dev", "sh", "co", "ai", "app", "xyz", "info", "biz", "me", "us",
"uk", "de", "fr", "ru", "cn", "jp", "in", "br", "ca", "au", "eu", "gov", "edu", "mil", "tv",
"cc", "ws", "to", "gg", "fm", "site", "online", "tech", "cloud", "store", "live", "run", "id",
];
pub fn classify_scope(pattern: &str) -> ScopeKind {
let p = pattern.trim().to_lowercase();
if let Some(rest) = p.strip_prefix("*.") {
if !rest.contains('.') && KNOWN_TLDS.contains(&rest) {
return ScopeKind::BareTld;
}
return ScopeKind::Wildcard;
}
if p.contains("://") || p.contains('/') || p.contains('?') || p.contains('#') {
return ScopeKind::Exact;
}
if !p.contains('.') {
if KNOWN_TLDS.contains(&p.as_str()) {
return ScopeKind::BareTld;
}
return ScopeKind::Substring;
}
let labels: Vec<&str> = p.split('.').filter(|l| !l.is_empty()).collect();
if labels.len() >= 2 {
ScopeKind::Domain
} else {
ScopeKind::Substring
}
}
#[derive(Debug, Clone, Serialize)]
struct TrustListRow {
pattern: String,
rule_id: Option<String>,
source: String,
expires: Option<String>,
expired: bool,
scope_kind: ScopeKind,
scope_coverage: String,
broad_warning: bool,
}
fn print_trust_error(subcmd: &str, err: &str, hint_pattern: Option<&str>) {
eprintln!("tirith: trust {subcmd}: {err}");
if err.contains("git repository") {
if let Some(pattern) = hint_pattern {
eprintln!(" try: tirith trust {subcmd} {pattern} --scope user");
} else {
eprintln!(" try: tirith trust {subcmd} --scope user");
}
}
}
#[must_use]
fn print_json(value: &impl Serialize) -> i32 {
match serde_json::to_string_pretty(value) {
Ok(s) => {
println!("{s}");
0
}
Err(e) => {
eprintln!("tirith: JSON serialization failed: {e}");
1
}
}
}
fn trust_store_path(scope: &str) -> Result<std::path::PathBuf, String> {
match scope {
"user" => {
let config = tirith_core::policy::config_dir()
.ok_or_else(|| "cannot determine config directory".to_string())?;
Ok(config.join("trust.json"))
}
"repo" => {
let repo_root = tirith_core::policy::find_repo_root(None)
.ok_or_else(|| "not inside a git repository".to_string())?;
Ok(repo_root.join(".tirith").join("trust.json"))
}
other => Err(format!("unknown scope: {other} (use 'user' or 'repo')")),
}
}
fn load_store(path: &std::path::Path) -> Result<TrustStore, String> {
match fs::read_to_string(path) {
Ok(content) => serde_json::from_str(&content)
.map_err(|e| format!("corrupt trust store at {}: {e}", path.display())),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(TrustStore::default()),
Err(e) => Err(format!("cannot read {}: {e}", path.display())),
}
}
fn write_store(path: &std::path::Path, store: &TrustStore) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("cannot create directory {}: {e}", parent.display()))?;
}
let json = serde_json::to_string_pretty(store)
.map_err(|e| format!("failed to serialize trust store: {e}"))?;
fs::write(path, json).map_err(|e| format!("failed to write {}: {e}", path.display()))?;
Ok(())
}
fn parse_ttl(ttl: &str) -> Result<String, String> {
let ttl = ttl.trim();
if ttl.is_empty() {
return Err("empty TTL".to_string());
}
let (num_str, unit) = if let Some(n) = ttl.strip_suffix('d') {
(n, "d")
} else if let Some(n) = ttl.strip_suffix('h') {
(n, "h")
} else if let Some(n) = ttl.strip_suffix('m') {
(n, "m")
} else {
return Err(format!(
"unsupported TTL format: {ttl} (use e.g. 1h, 7d, 30d)"
));
};
let num: u64 = num_str
.parse()
.map_err(|_| format!("invalid TTL number: {num_str}"))?;
if num == 0 {
return Err("TTL must be > 0".to_string());
}
let multiplier: u64 = match unit {
"m" => 60,
"h" => 3600,
"d" => 86400,
_ => unreachable!(),
};
let seconds = num
.checked_mul(multiplier)
.ok_or_else(|| format!("TTL value too large: {num}{unit}"))?;
let seconds_i64 =
i64::try_from(seconds).map_err(|_| format!("TTL value too large: {num}{unit}"))?;
let expires = chrono::Utc::now() + chrono::Duration::seconds(seconds_i64);
Ok(expires.to_rfc3339())
}
fn is_expired(entry: &TrustEntry) -> bool {
if let Some(ref exp) = entry.ttl_expires {
if let Ok(expiry) = chrono::DateTime::parse_from_rfc3339(exp) {
return expiry < chrono::Utc::now();
}
}
false
}
fn humanize_expiry(ttl_expires: Option<&str>) -> Option<String> {
let exp = ttl_expires?;
let expiry = chrono::DateTime::parse_from_rfc3339(exp).ok()?;
let now = chrono::Utc::now();
let delta = expiry.signed_duration_since(now);
if delta.num_seconds() <= 0 {
return Some("expired".to_string());
}
let secs = delta.num_seconds();
let human = if secs >= 86400 {
format!("in {}d", secs / 86400)
} else if secs >= 3600 {
format!("in {}h", secs / 3600)
} else if secs >= 60 {
format!("in {}m", secs / 60)
} else {
format!("in {secs}s")
};
Some(human)
}
fn validate_pattern(pattern: &str, policy: &tirith_core::policy::Policy) -> Result<(), String> {
if pattern.is_empty() {
return Err("pattern must not be empty".to_string());
}
for (i, b) in pattern.bytes().enumerate() {
if b < 0x20 && b != b'\t' {
return Err(format!(
"pattern contains control character at byte offset {i} (0x{b:02x})"
));
}
}
if policy.is_blocklisted(pattern) {
return Err(format!(
"pattern '{pattern}' is in the blocklist and cannot be trusted"
));
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn add(
pattern: &str,
rule_id: Option<&str>,
ttl: Option<&str>,
permanent: bool,
broad: bool,
reason: Option<&str>,
scope: &str,
json: bool,
) -> i32 {
let mut policy = tirith_core::policy::Policy::discover(None);
policy.load_user_lists();
policy.load_org_lists(None);
if let Err(e) = validate_pattern(pattern, &policy) {
eprintln!("tirith: trust add: {e}");
return 1;
}
if permanent && ttl.is_some() {
eprintln!("tirith: trust add: --permanent cannot be combined with --ttl");
return 1;
}
let scope_kind = classify_scope(pattern);
if scope_kind.is_broad() && !broad {
eprintln!(
"tirith: trust add: '{pattern}' is a {} pattern — {}.",
scope_kind.label(),
scope_kind.coverage()
);
eprintln!(
" Trust the narrowest thing that works (a specific URL or path), \
or pass --broad to accept this scope."
);
if scope_kind == ScopeKind::BareTld {
eprintln!(
" Note: trusting a bare TLD allows EVERY host under '.{pattern}' — \
this is almost never what you want."
);
}
return 1;
}
let path = match trust_store_path(scope) {
Ok(p) => p,
Err(e) => {
print_trust_error("add", &e, Some(pattern));
return 1;
}
};
let mut store = match load_store(&path) {
Ok(s) => s,
Err(e) => {
eprintln!("tirith: trust add: {e}");
return 1;
}
};
let (ttl_expires, ttl_label): (Option<String>, Option<String>) = if permanent {
(None, None)
} else {
let effective = ttl.unwrap_or(DEFAULT_TTL);
match parse_ttl(effective) {
Ok(exp) => (Some(exp), Some(effective.to_string())),
Err(e) => {
eprintln!("tirith: trust add: {e}");
return 1;
}
}
};
let entry = TrustEntry {
pattern: pattern.to_string(),
rule_id: rule_id.map(String::from),
ttl_expires: ttl_expires.clone(),
added: chrono::Utc::now().to_rfc3339(),
source: "cli".to_string(),
reason: reason.map(str::to_string),
};
store.entries.push(entry);
if let Err(e) = write_store(&path, &store) {
eprintln!("tirith: trust add: {e}");
return 1;
}
tirith_core::audit::log_trust_change(pattern, rule_id, "add", ttl_expires.as_deref(), scope);
if json {
let out = serde_json::json!({
"added": pattern,
"scope": scope,
"rule_id": rule_id,
"scope_kind": scope_kind,
"scope_coverage": scope_kind.coverage(),
"ttl": ttl_label,
"ttl_expires": ttl_expires,
"permanent": permanent,
"reason": reason,
});
return print_json(&out);
}
let ttl_note = match &ttl_label {
Some(t) => format!(", ttl: {t}"),
None => ", permanent (no expiry)".to_string(),
};
eprintln!(
"tirith: trusted '{pattern}' (scope: {scope}, {} pattern{ttl_note})",
scope_kind.label()
);
if scope_kind.is_dangerous() {
eprintln!(
" warning: this is a {} entry — {}.",
scope_kind.label(),
scope_kind.coverage()
);
}
0
}
pub fn list(rule_filter: Option<&str>, json: bool, show_expired: bool, scope: &str) -> i32 {
if !matches!(scope, "user" | "repo" | "all") {
eprintln!("tirith: trust list: unknown scope '{scope}' (use 'user', 'repo', or 'all')");
return 1;
}
let mut rows: Vec<TrustListRow> = match collect_rows(scope, show_expired) {
Ok(r) => r,
Err(e) => {
eprintln!("tirith: trust list: {e}");
return 1;
}
};
if let Some(filter) = rule_filter {
rows.retain(|r| {
r.rule_id
.as_ref()
.map(|id| id.eq_ignore_ascii_case(filter))
.unwrap_or(false)
});
}
if json {
return print_json(&rows);
}
if rows.is_empty() {
eprintln!("tirith: no trust entries found");
} else {
let max_pat = rows
.iter()
.map(|r| r.pattern.len())
.max()
.unwrap_or(7)
.max(7);
let max_src = rows
.iter()
.map(|r| r.source.len())
.max()
.unwrap_or(6)
.max(6);
let max_rule = rows
.iter()
.map(|r| r.rule_id.as_ref().map(|s| s.len()).unwrap_or(1))
.max()
.unwrap_or(4)
.max(4);
let scope_render = |row: &TrustListRow| -> String {
if row.broad_warning {
format!("{}!", row.scope_kind.label())
} else {
row.scope_kind.label().to_string()
}
};
let max_scope = rows
.iter()
.map(|r| scope_render(r).len())
.max()
.unwrap_or(5)
.max(5);
eprintln!(
"{:<max_pat$} {:<max_rule$} {:<max_scope$} {:<max_src$} EXPIRES",
"PATTERN", "RULE", "SCOPE", "SOURCE"
);
let mut any_dangerous = false;
for row in &rows {
let rule_display = row.rule_id.as_deref().unwrap_or("-");
let expires_display = match (&row.expires, row.expired) {
(Some(exp), true) => format!("{exp} (EXPIRED)"),
(Some(exp), false) => match humanize_expiry(Some(exp)) {
Some(h) => format!("{exp} ({h})"),
None => exp.clone(),
},
(None, _) => "permanent".to_string(),
};
let scope_display = scope_render(row);
if row.broad_warning {
any_dangerous = true;
}
eprintln!(
"{:<max_pat$} {:<max_rule$} {:<max_scope$} {:<max_src$} {}",
row.pattern, rule_display, scope_display, row.source, expires_display
);
}
if any_dangerous {
eprintln!(
"\ntirith: '!' marks dangerously broad entries (wildcard / bare TLD). \
Run 'tirith trust explain <pattern>' for detail."
);
}
}
0
}
fn collect_rows(scope: &str, show_expired: bool) -> Result<Vec<TrustListRow>, String> {
let mut rows: Vec<TrustListRow> = Vec::new();
let scopes_to_load: Vec<&str> = match scope {
"all" => vec!["user", "repo"],
s => vec![s],
};
for s in &scopes_to_load {
let path = match trust_store_path(s) {
Ok(p) => p,
Err(e) => {
if scope != "all" {
return Err(e);
}
continue;
}
};
let store = load_store(&path)?;
let source = format!("trust-{s}");
for entry in &store.entries {
let expired = is_expired(entry);
if expired && !show_expired {
continue;
}
rows.push(make_row(
entry.pattern.clone(),
entry.rule_id.clone(),
source.clone(),
entry.ttl_expires.clone(),
expired,
));
}
}
if scope == "all" {
if let Some(config) = tirith_core::policy::config_dir() {
let allowlist_path = config.join("allowlist");
if let Ok(content) = fs::read_to_string(&allowlist_path) {
for line in content.lines() {
let line = line.trim();
if !line.is_empty() && !line.starts_with('#') {
rows.push(make_row(
line.to_string(),
None,
"allowlist-user".to_string(),
None,
false,
));
}
}
}
}
if let Some(repo_root) = tirith_core::policy::find_repo_root(None) {
let allowlist_path = repo_root.join(".tirith").join("allowlist");
if let Ok(content) = fs::read_to_string(&allowlist_path) {
for line in content.lines() {
let line = line.trim();
if !line.is_empty() && !line.starts_with('#') {
rows.push(make_row(
line.to_string(),
None,
"allowlist-org".to_string(),
None,
false,
));
}
}
}
}
let policy = tirith_core::policy::Policy::discover(None);
for pattern in &policy.allowlist {
if !rows
.iter()
.any(|r| r.pattern == *pattern && r.source.starts_with("allowlist"))
{
rows.push(make_row(
pattern.clone(),
None,
"policy".to_string(),
None,
false,
));
}
}
for rule in &policy.allowlist_rules {
for pattern in &rule.patterns {
rows.push(make_row(
pattern.clone(),
Some(rule.rule_id.clone()),
"policy".to_string(),
None,
false,
));
}
}
}
Ok(rows)
}
fn make_row(
pattern: String,
rule_id: Option<String>,
source: String,
expires: Option<String>,
expired: bool,
) -> TrustListRow {
let scope_kind = classify_scope(&pattern);
TrustListRow {
pattern,
rule_id,
source,
expires,
expired,
scope_kind,
scope_coverage: scope_kind.coverage().to_string(),
broad_warning: scope_kind.is_dangerous(),
}
}
pub fn remove(pattern: &str, rule_id: Option<&str>, scope: &str) -> i32 {
let path = match trust_store_path(scope) {
Ok(p) => p,
Err(e) => {
print_trust_error("remove", &e, Some(pattern));
return 1;
}
};
let mut store = match load_store(&path) {
Ok(s) => s,
Err(e) => {
eprintln!("tirith: trust remove: {e}");
return 1;
}
};
let before_len = store.entries.len();
store.entries.retain(|entry| {
let pattern_matches = entry.pattern == pattern;
let rule_matches = match (rule_id, &entry.rule_id) {
(Some(filter), Some(entry_rule)) => filter.eq_ignore_ascii_case(entry_rule),
(Some(_), None) => false,
(None, _) => true,
};
!(pattern_matches && rule_matches)
});
let removed = before_len - store.entries.len();
if removed == 0 {
eprintln!("tirith: trust remove: no matching entry found for '{pattern}'");
return 1;
}
if let Err(e) = write_store(&path, &store) {
eprintln!("tirith: trust remove: {e}");
return 1;
}
tirith_core::audit::log_trust_change(pattern, rule_id, "remove", None, scope);
eprintln!("tirith: removed {removed} trust entry/entries for '{pattern}' (scope: {scope})");
0
}
fn load_last_trigger_value() -> Result<Option<serde_json::Value>, String> {
let data_dir = tirith_core::policy::data_dir()
.ok_or_else(|| "cannot determine data directory".to_string())?;
let path = data_dir.join("last_trigger.json");
if !path.exists() {
return Ok(None);
}
let content =
fs::read_to_string(&path).map_err(|e| format!("failed to read last trigger: {e}"))?;
let val: serde_json::Value =
serde_json::from_str(&content).map_err(|e| format!("failed to parse last trigger: {e}"))?;
Ok(Some(val))
}
fn extract_target_rule_pairs(val: &serde_json::Value) -> Vec<(String, Option<String>)> {
let mut pairs: Vec<(String, Option<String>)> = Vec::new();
let push = |t: String, rid: &Option<String>, pairs: &mut Vec<(String, Option<String>)>| {
if t.is_empty() {
return;
}
let pair = (t, rid.clone());
if !pairs.contains(&pair) {
pairs.push(pair);
}
};
if let Some(findings) = val.get("findings").and_then(|v| v.as_array()) {
for finding in findings {
let rule_id = finding
.get("rule_id")
.and_then(|v| v.as_str())
.map(String::from);
if let Some(evidence) = finding.get("evidence").and_then(|v| v.as_array()) {
for ev in evidence {
if let Some(raw) = ev.get("raw").and_then(|v| v.as_str()) {
if raw.contains("://") && url::Url::parse(raw).is_ok() {
push(raw.to_string(), &rule_id, &mut pairs);
} else if let Some(host) = extract_host(raw) {
push(host, &rule_id, &mut pairs);
}
}
if let Some(host) = ev.get("raw_host").and_then(|v| v.as_str()) {
push(host.to_string(), &rule_id, &mut pairs);
}
}
}
}
}
pairs
}
fn target_host(target: &str) -> String {
extract_host(target).unwrap_or_else(|| target.to_string())
}
fn rules_for_host(val: &serde_json::Value, host: &str) -> Vec<String> {
let mut rules: Vec<String> = Vec::new();
for (target, rule_id) in extract_target_rule_pairs(val) {
if target_host(&target) != host {
continue;
}
if let Some(rid) = rule_id {
if !rules.contains(&rid) {
rules.push(rid);
}
}
}
rules
}
fn read_last_trigger() -> Result<Vec<(String, Option<String>)>, String> {
match load_last_trigger_value()? {
Some(val) => Ok(extract_target_rule_pairs(&val)),
None => Err("no recent trigger found".into()),
}
}
fn suggestion_lines(pairs: &[(String, Option<String>)]) -> Vec<String> {
pairs
.iter()
.map(|(target, rule_id)| {
let needs_broad = classify_scope(target).is_broad();
format_add_line(target, rule_id.as_deref(), needs_broad)
})
.collect()
}
fn format_add_line(target: &str, rule_id: Option<&str>, needs_broad: bool) -> String {
let scrubbed = tirith_core::mcp::output_filter::sanitize_text_str(target);
let Some(quoted) = tirith_core::safe_command::shell_single_quote(&scrubbed) else {
return "# trust this target manually with `tirith trust add` \
(it contains characters unsafe to embed in a suggested command)."
.to_string();
};
let broad = if needs_broad { " --broad" } else { "" };
match rule_id {
Some(rid) => format!("tirith trust add {quoted}{broad} --rule {rid} --ttl {DEFAULT_TTL}"),
None => format!("tirith trust add {quoted}{broad} --ttl {DEFAULT_TTL}"),
}
}
pub fn from_last_trigger(apply: bool) -> i32 {
let pairs = match read_last_trigger() {
Ok(v) => v,
Err(e) if e == "no recent trigger found" => {
eprintln!("tirith: no recent trigger to trust");
return 0;
}
Err(e) => {
eprintln!("tirith: trust from-last-trigger: {e}");
return 1;
}
};
if pairs.is_empty() {
eprintln!("tirith: no recent trigger to trust");
return 0;
}
if !apply {
eprintln!("Suggested trust commands (run the narrowest one that fits):");
eprintln!();
for line in suggestion_lines(&pairs) {
println!("{line}");
}
eprintln!();
eprintln!("Re-run with --apply to add these automatically.");
return 0;
}
let mut added = 0;
let mut failed = 0;
for (target, rule_id) in &pairs {
let broad = classify_scope(target).is_broad();
if add(
target,
rule_id.as_deref(),
Some(DEFAULT_TTL),
false,
broad,
None,
"user",
false,
) == 0
{
added += 1;
} else {
failed += 1;
}
}
eprintln!("tirith: added {added} trust entry/entries from last trigger");
if failed > 0 {
eprintln!("tirith: {failed} trust entry/entries could not be added");
return 1;
}
0
}
pub fn last() -> i32 {
let val = match load_last_trigger_value() {
Ok(Some(v)) => v,
Ok(None) => {
eprintln!("tirith: no recent trigger found");
return 1;
}
Err(e) => {
eprintln!("tirith: {e}");
return 1;
}
};
if let Some(ts) = val.get("timestamp").and_then(|v| v.as_str()) {
eprintln!("Last trigger at: {ts}");
}
if let Some(cmd) = val.get("command_redacted").and_then(|v| v.as_str()) {
eprintln!("Command: {cmd}");
}
let mut domains: Vec<String> = Vec::new();
if let Some(findings) = val.get("findings").and_then(|v| v.as_array()) {
for finding in findings {
if let Some(title) = finding.get("title").and_then(|v| v.as_str()) {
eprintln!(" - {title}");
}
if let Some(evidence) = finding.get("evidence").and_then(|v| v.as_array()) {
for ev in evidence {
if let Some(raw) = ev.get("raw").and_then(|v| v.as_str()) {
if let Some(host) = extract_host(raw) {
if !domains.contains(&host) {
domains.push(host);
}
}
}
if let Some(host) = ev.get("raw_host").and_then(|v| v.as_str()) {
let h = host.to_string();
if !domains.contains(&h) {
domains.push(h);
}
}
}
}
}
}
if domains.is_empty() {
eprintln!("\ntirith: no domain/URL found in last trigger to trust");
return 0;
}
for domain in &domains {
eprintln!();
eprint!("Trust {domain}? [y/N/r(rule-scoped)/t(temporary 7d)] ");
let _ = io::stderr().flush();
let stdin = io::stdin();
let mut line = String::new();
if stdin.lock().read_line(&mut line).is_err() {
continue;
}
let choice = line.trim().to_lowercase();
match choice.as_str() {
"y" | "yes" => {
add(domain, None, None, false, true, None, "user", false);
}
"r" | "rule" => {
let host_rules = rules_for_host(&val, domain);
if host_rules.is_empty() {
eprintln!("tirith: no rule IDs for {domain}, adding global trust");
add(domain, None, None, false, true, None, "user", false);
} else {
for rid in &host_rules {
add(domain, Some(rid), None, false, true, None, "user", false);
}
}
}
"t" | "temp" | "temporary" => {
add(domain, None, Some("7d"), false, true, None, "user", false);
}
_ => {
eprintln!("tirith: skipped {domain}");
}
}
}
0
}
pub fn gc(expired: bool, scope: &str, json: bool) -> i32 {
gc_with_action("gc", expired, scope, json)
}
pub fn prune(expired: bool, scope: &str, json: bool) -> i32 {
gc_with_action("prune", expired, scope, json)
}
fn gc_with_action(action_label: &str, expired: bool, scope: &str, json: bool) -> i32 {
if !matches!(scope, "user" | "repo" | "all") {
eprintln!(
"tirith: trust {action_label}: unknown scope '{scope}' (use 'user', 'repo', or 'all')",
);
return 1;
}
let _ = expired;
let scopes: Vec<&str> = match scope {
"all" => vec!["user", "repo"],
s => vec![s],
};
let mut total_removed = 0;
let mut per_scope: Vec<(String, usize)> = Vec::new();
for s in scopes {
let path = match trust_store_path(s) {
Ok(p) => p,
Err(e) => {
if scope != "all" {
print_trust_error(action_label, &e, None);
return 1;
}
continue;
}
};
if !path.exists() {
continue;
}
let mut store = match load_store(&path) {
Ok(s) => s,
Err(e) => {
eprintln!("tirith: trust {action_label}: {e}");
return 1;
}
};
let before = store.entries.len();
let expired_entries: Vec<TrustEntry> = store
.entries
.iter()
.filter(|entry| is_expired(entry))
.cloned()
.collect();
store.entries.retain(|entry| !is_expired(entry));
let removed = before - store.entries.len();
if removed > 0 {
if let Err(e) = write_store(&path, &store) {
eprintln!("tirith: trust {action_label}: {e}");
return 1;
}
for entry in &expired_entries {
tirith_core::audit::log_trust_change(
&entry.pattern,
entry.rule_id.as_deref(),
action_label,
entry.ttl_expires.as_deref(),
s,
);
}
if !json {
eprintln!(
"tirith: {action_label}: removed {removed} expired entries from {s} scope",
);
}
}
per_scope.push((s.to_string(), removed));
total_removed += removed;
}
if json {
let out = serde_json::json!({
"removed_total": total_removed,
"by_scope": per_scope
.iter()
.map(|(s, n)| serde_json::json!({ "scope": s, "removed": n }))
.collect::<Vec<_>>(),
});
return print_json(&out);
}
if total_removed == 0 {
eprintln!("tirith: {action_label}: no expired entries found");
}
0
}
#[derive(Debug, Serialize)]
struct ExplainReport {
pattern: String,
found: bool,
matches: Vec<ExplainMatch>,
}
#[derive(Debug, Serialize)]
struct ExplainMatch {
source: String,
rule_id: Option<String>,
scope_kind: ScopeKind,
scope_coverage: String,
broad_warning: bool,
added: Option<String>,
reason: Option<String>,
ttl_expires: Option<String>,
expires_in: Option<String>,
expired: bool,
permanent: bool,
}
pub fn explain(pattern: &str, scope: &str, json: bool) -> i32 {
if !matches!(scope, "user" | "repo" | "all") {
eprintln!("tirith: trust explain: unknown scope '{scope}' (use 'user', 'repo', or 'all')");
return 1;
}
if pattern.is_empty() {
eprintln!("tirith: trust explain: pattern must not be empty");
return 1;
}
let mut matches: Vec<ExplainMatch> = Vec::new();
let scopes: Vec<&str> = match scope {
"all" => vec!["user", "repo"],
s => vec![s],
};
for s in &scopes {
let path = match trust_store_path(s) {
Ok(p) => p,
Err(e) => {
if scope != "all" {
print_trust_error("explain", &e, None);
return 1;
}
continue;
}
};
let store = match load_store(&path) {
Ok(st) => st,
Err(e) => {
eprintln!("tirith: trust explain: {e}");
return 1;
}
};
for entry in &store.entries {
if entry.pattern == pattern {
let kind = classify_scope(&entry.pattern);
matches.push(ExplainMatch {
source: format!("trust-{s}"),
rule_id: entry.rule_id.clone(),
scope_kind: kind,
scope_coverage: kind.coverage().to_string(),
broad_warning: kind.is_dangerous(),
added: Some(entry.added.clone()),
reason: entry.reason.clone(),
ttl_expires: entry.ttl_expires.clone(),
expires_in: humanize_expiry(entry.ttl_expires.as_deref()),
expired: is_expired(entry),
permanent: entry.ttl_expires.is_none(),
});
}
}
}
if scope == "all" {
if let Ok(rows) = collect_rows("all", true) {
for r in rows {
let from_allowlist_or_policy =
r.source.starts_with("allowlist") || r.source == "policy";
if r.pattern == pattern && from_allowlist_or_policy {
matches.push(ExplainMatch {
source: r.source,
rule_id: r.rule_id,
scope_kind: r.scope_kind,
scope_coverage: r.scope_coverage,
broad_warning: r.broad_warning,
added: None,
reason: None,
ttl_expires: None,
expires_in: None,
expired: false,
permanent: true,
});
}
}
}
}
let report = ExplainReport {
pattern: pattern.to_string(),
found: !matches.is_empty(),
matches,
};
if json {
return print_json(&report);
}
if !report.found {
let kind = classify_scope(pattern);
eprintln!("tirith: '{pattern}' is not currently trusted in scope '{scope}'.");
eprintln!(
" If added, it would be a {} entry — {}.",
kind.label(),
kind.coverage()
);
if kind.is_broad() {
eprintln!(" That is a broad scope; `trust add` would require --broad to accept it.");
}
return 0;
}
println!("trust explain: {pattern}");
for (i, m) in report.matches.iter().enumerate() {
if i > 0 {
println!();
}
println!(" source: {}", m.source);
println!(
" scope: {} — {}",
m.scope_kind.label(),
m.scope_coverage
);
if let Some(rid) = &m.rule_id {
println!(" rule: {rid} (suppresses this rule only)");
} else {
println!(" rule: (global — suppresses every rule)");
}
if let Some(added) = &m.added {
println!(" added: {added}");
}
match &m.reason {
Some(r) => println!(" reason: {r}"),
None => println!(" reason: (none recorded)"),
}
match (&m.ttl_expires, m.permanent) {
(_, true) => println!(" expires: never (permanent)"),
(Some(exp), false) => {
let suffix = m
.expires_in
.as_deref()
.map(|h| format!(" ({h})"))
.unwrap_or_default();
println!(" expires: {exp}{suffix}");
}
(None, false) => println!(" expires: never (permanent)"),
}
if m.expired {
println!(" status: EXPIRED — run 'tirith trust gc --expired' to remove it");
}
if m.broad_warning {
println!(
" warning: dangerously broad — {}",
m.scope_kind.coverage()
);
}
}
0
}
const TRUST_HISTORY_FILE: &str = "trust-history.jsonl";
const TRUST_HISTORY_MAX_LINES: usize = 64;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct TrustSnapshot {
recorded_at: String,
entries: Vec<String>,
}
fn trust_history_path() -> Option<std::path::PathBuf> {
tirith_core::policy::state_dir().map(|d| d.join(TRUST_HISTORY_FILE))
}
fn row_key(r: &TrustListRow) -> String {
format!(
"{}\u{1f}{}\u{1f}{}",
r.source,
r.pattern,
r.rule_id.as_deref().unwrap_or("")
)
}
fn split_key(key: &str) -> (String, String, Option<String>) {
let mut it = key.split('\u{1f}');
let source = it.next().unwrap_or("").to_string();
let pattern = it.next().unwrap_or("").to_string();
let rule = it.next().filter(|s| !s.is_empty()).map(String::from);
(source, pattern, rule)
}
fn current_trust_snapshot() -> TrustSnapshot {
let mut entries: Vec<String> = collect_rows("all", true)
.unwrap_or_default()
.iter()
.map(row_key)
.collect();
entries.sort();
entries.dedup();
TrustSnapshot {
recorded_at: chrono::Utc::now().to_rfc3339(),
entries,
}
}
fn load_trust_history() -> (Vec<TrustSnapshot>, Option<String>) {
let Some(path) = trust_history_path() else {
return (Vec::new(), None);
};
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(e) if e.kind() == io::ErrorKind::NotFound => return (Vec::new(), None),
Err(e) => {
return (
Vec::new(),
Some(format!(
"could not read trust snapshot history at {} ({e}) — check file \
permissions; the diff below cannot use any earlier snapshot",
path.display()
)),
);
}
};
let snapshots = content
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str::<TrustSnapshot>(l).ok())
.collect();
(snapshots, None)
}
fn atomic_write(dest: &std::path::Path, data: &[u8]) -> Result<(), String> {
let parent = dest
.parent()
.ok_or_else(|| "cannot determine parent directory".to_string())?;
fs::create_dir_all(parent).map_err(|e| format!("failed to create directory: {e}"))?;
let mut tmp = tempfile::NamedTempFile::new_in(parent)
.map_err(|e| format!("failed to create temp file: {e}"))?;
tmp.write_all(data)
.map_err(|e| format!("failed to write temp file: {e}"))?;
tmp.flush()
.map_err(|e| format!("failed to flush temp file: {e}"))?;
tmp.persist(dest)
.map_err(|e| format!("failed to rename temp file: {e}"))?;
Ok(())
}
fn record_trust_snapshot(snapshot: &TrustSnapshot) {
let Some(path) = trust_history_path() else {
return;
};
let (mut history, _) = load_trust_history();
if history
.last()
.map(|s| s.entries == snapshot.entries)
.unwrap_or(false)
{
return;
}
history.push(snapshot.clone());
if history.len() > TRUST_HISTORY_MAX_LINES {
let drop = history.len() - TRUST_HISTORY_MAX_LINES;
history.drain(0..drop);
}
let mut body = String::new();
for s in &history {
if let Ok(line) = serde_json::to_string(s) {
body.push_str(&line);
body.push('\n');
}
}
let _ = atomic_write(&path, body.as_bytes());
}
pub fn snapshot_current_trust() {
record_trust_snapshot(¤t_trust_snapshot());
}
#[derive(Debug, Serialize)]
struct DiffEntry {
pattern: String,
source: String,
rule_id: Option<String>,
scope_kind: ScopeKind,
}
#[derive(Debug, Serialize)]
struct TrustDiffReport {
baseline_recorded_at: Option<String>,
added: Vec<DiffEntry>,
removed: Vec<DiffEntry>,
unchanged: bool,
note: Option<String>,
}
fn diff_entry_of(key: &str) -> DiffEntry {
let (source, pattern, rule_id) = split_key(key);
let scope_kind = classify_scope(&pattern);
DiffEntry {
pattern,
source,
rule_id,
scope_kind,
}
}
pub fn audit(since: Option<&str>, json: bool) -> i32 {
let cutoff = match since {
Some(s) => match parse_relative_duration(s) {
Ok(c) => Some(c),
Err(e) => {
eprintln!("tirith: trust audit: invalid --since value: {e}");
return 1;
}
},
None => None,
};
let Some(log_path) = tirith_core::audit::audit_log_path() else {
eprintln!("tirith: trust audit: cannot resolve audit log path (no data dir)");
return 1;
};
if !log_path.exists() {
if json {
let _ = print_json(&serde_json::json!({"entries": [], "skipped_lines": 0_usize}));
} else {
eprintln!(
"tirith: trust audit: no audit log yet at {}",
log_path.display()
);
}
return 0;
}
let result = match tirith_core::audit_aggregator::read_log(&log_path) {
Ok(r) => r,
Err(e) => {
eprintln!(
"tirith: trust audit: cannot read audit log at {}: {e}",
log_path.display(),
);
return 1;
}
};
if result.skipped_lines > 0 && !json {
eprintln!(
"tirith: trust audit: skipped {} malformed audit log line(s) at {}",
result.skipped_lines,
log_path.display(),
);
}
#[derive(Serialize)]
struct TrustAuditRow {
timestamp: String,
action: String,
scope: String,
pattern: String,
#[serde(skip_serializing_if = "Option::is_none")]
rule_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
ttl_expires: Option<String>,
}
let mut rows: Vec<TrustAuditRow> = Vec::new();
for entry in result.records {
if entry.entry_type != "trust_change" {
continue;
}
if let Some(cutoff_ts) = cutoff {
if let Ok(ts) = chrono::DateTime::parse_from_rfc3339(&entry.timestamp) {
if ts.to_utc() < cutoff_ts {
continue;
}
}
}
rows.push(TrustAuditRow {
timestamp: entry.timestamp,
action: entry.trust_action.unwrap_or_else(|| "?".to_string()),
scope: entry.trust_scope.unwrap_or_else(|| "?".to_string()),
pattern: entry.trust_pattern.unwrap_or_default(),
rule_id: entry.trust_rule_id,
ttl_expires: entry.trust_ttl_expires,
});
}
if json {
return print_json(&serde_json::json!({
"entries": rows,
"skipped_lines": result.skipped_lines,
}));
}
if rows.is_empty() {
eprintln!("tirith: trust audit: no trust-store mutations recorded");
return 0;
}
println!("{:<26} {:<8} {:<6} pattern", "timestamp", "action", "scope");
for r in &rows {
let rule_suffix = match &r.rule_id {
Some(rid) => format!(" [rule: {rid}]"),
None => String::new(),
};
println!(
"{:<26} {:<8} {:<6} {}{}",
r.timestamp, r.action, r.scope, r.pattern, rule_suffix
);
}
0
}
fn parse_relative_duration(s: &str) -> Result<chrono::DateTime<chrono::Utc>, String> {
let s = s.trim();
if s.is_empty() {
return Err("empty duration".into());
}
let (num_str, unit) = s.split_at(
s.find(|c: char| !c.is_ascii_digit())
.ok_or_else(|| format!("missing unit suffix (use e.g. '7d', '24h', '15m'): {s}"))?,
);
let n: i64 = num_str
.parse()
.map_err(|_| format!("not a number: {num_str:?}"))?;
let seconds = match unit {
"d" => n.checked_mul(86_400),
"h" => n.checked_mul(3_600),
"m" => n.checked_mul(60),
"s" => Some(n),
other => {
return Err(format!(
"unknown duration unit {other:?} (use 'd', 'h', 'm', or 's')",
))
}
}
.ok_or_else(|| format!("duration overflow: {s}"))?;
Ok(chrono::Utc::now() - chrono::Duration::seconds(seconds))
}
pub fn diff(json: bool) -> i32 {
let (history, history_read_error) = load_trust_history();
let current = current_trust_snapshot();
let baseline = history.last();
let report = match baseline {
None => TrustDiffReport {
baseline_recorded_at: None,
added: Vec::new(),
removed: Vec::new(),
unchanged: true,
note: Some(history_read_error.clone().unwrap_or_else(|| {
"No earlier trust snapshot to compare against — this is the first \
observation. Run a 'tirith trust' command again later to build a \
diff trail."
.to_string()
})),
},
Some(base) => {
let base_set: std::collections::BTreeSet<&String> = base.entries.iter().collect();
let cur_set: std::collections::BTreeSet<&String> = current.entries.iter().collect();
let added: Vec<DiffEntry> = cur_set
.difference(&base_set)
.map(|k| diff_entry_of(k))
.collect();
let removed: Vec<DiffEntry> = base_set
.difference(&cur_set)
.map(|k| diff_entry_of(k))
.collect();
let unchanged = added.is_empty() && removed.is_empty();
TrustDiffReport {
baseline_recorded_at: Some(base.recorded_at.clone()),
added,
removed,
unchanged,
note: None,
}
}
};
record_trust_snapshot(¤t);
if json {
return print_json(&report);
}
match &report.baseline_recorded_at {
Some(ts) => println!("trust diff (since {ts})"),
None => println!("trust diff"),
}
if let Some(note) = &report.note {
println!(" note: {note}");
return 0;
}
if report.unchanged {
println!(" no changes since the last snapshot");
return 0;
}
if !report.added.is_empty() {
println!(" added ({}):", report.added.len());
for e in &report.added {
let rule = e
.rule_id
.as_deref()
.map(|r| format!(" [rule: {r}]"))
.unwrap_or_default();
println!(
" + {} ({}, {}){rule}",
e.pattern,
e.source,
e.scope_kind.label()
);
}
}
if !report.removed.is_empty() {
println!(" removed ({}):", report.removed.len());
for e in &report.removed {
let rule = e
.rule_id
.as_deref()
.map(|r| format!(" [rule: {r}]"))
.unwrap_or_default();
println!(
" - {} ({}, {}){rule}",
e.pattern,
e.source,
e.scope_kind.label()
);
}
}
0
}
fn extract_host(raw: &str) -> Option<String> {
if raw.contains("://") {
if let Ok(parsed) = url::Url::parse(raw) {
return parsed.host_str().map(String::from);
}
}
let candidate = raw.split('/').next()?;
let candidate = candidate.trim();
if candidate.contains('.') && !candidate.contains(' ') {
let host = if let Some((h, port)) = candidate.rsplit_once(':') {
if port.chars().all(|c| c.is_ascii_digit()) && !port.is_empty() {
h
} else {
candidate
}
} else {
candidate
};
Some(host.to_string())
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_ttl_days() {
let result = parse_ttl("7d");
assert!(result.is_ok());
let expiry = chrono::DateTime::parse_from_rfc3339(&result.unwrap()).unwrap();
let expected_min = chrono::Utc::now() + chrono::Duration::days(6);
assert!(expiry > expected_min);
}
#[test]
fn test_parse_ttl_hours() {
let result = parse_ttl("1h");
assert!(result.is_ok());
}
#[test]
fn test_parse_ttl_minutes() {
let result = parse_ttl("30m");
assert!(result.is_ok());
}
#[test]
fn test_parse_ttl_invalid() {
assert!(parse_ttl("").is_err());
assert!(parse_ttl("0d").is_err());
assert!(parse_ttl("abc").is_err());
assert!(parse_ttl("7x").is_err());
}
#[test]
fn test_default_ttl_parses() {
assert!(parse_ttl(DEFAULT_TTL).is_ok());
}
#[test]
fn test_is_expired_no_ttl() {
let entry = TrustEntry {
pattern: "example.com".to_string(),
rule_id: None,
ttl_expires: None,
added: chrono::Utc::now().to_rfc3339(),
source: "cli".to_string(),
reason: None,
};
assert!(!is_expired(&entry));
}
#[test]
fn test_is_expired_future() {
let future = chrono::Utc::now() + chrono::Duration::hours(1);
let entry = TrustEntry {
pattern: "example.com".to_string(),
rule_id: None,
ttl_expires: Some(future.to_rfc3339()),
added: chrono::Utc::now().to_rfc3339(),
source: "cli".to_string(),
reason: None,
};
assert!(!is_expired(&entry));
}
#[test]
fn test_is_expired_past() {
let past = chrono::Utc::now() - chrono::Duration::hours(1);
let entry = TrustEntry {
pattern: "example.com".to_string(),
rule_id: None,
ttl_expires: Some(past.to_rfc3339()),
added: chrono::Utc::now().to_rfc3339(),
source: "cli".to_string(),
reason: None,
};
assert!(is_expired(&entry));
}
#[test]
fn test_is_expired_unparseable_ttl_is_not_expired() {
let entry = TrustEntry {
pattern: "example.com".to_string(),
rule_id: None,
ttl_expires: Some("not-a-timestamp".to_string()),
added: chrono::Utc::now().to_rfc3339(),
source: "cli".to_string(),
reason: None,
};
assert!(!is_expired(&entry));
}
#[test]
fn test_validate_pattern_empty() {
let policy = tirith_core::policy::Policy::default();
assert!(validate_pattern("", &policy).is_err());
}
#[test]
fn test_validate_pattern_control_chars() {
let policy = tirith_core::policy::Policy::default();
assert!(validate_pattern("hello\x00world", &policy).is_err());
assert!(validate_pattern("hello\x01world", &policy).is_err());
}
#[test]
fn test_validate_pattern_tab_ok() {
let policy = tirith_core::policy::Policy::default();
assert!(validate_pattern("hello\tworld", &policy).is_ok());
}
#[test]
fn test_validate_pattern_blocklisted() {
let policy = tirith_core::policy::Policy {
blocklist: vec!["evil.com".to_string()],
..Default::default()
};
assert!(validate_pattern("evil.com", &policy).is_err());
}
#[test]
fn test_validate_pattern_ok() {
let policy = tirith_core::policy::Policy::default();
assert!(validate_pattern("example.com", &policy).is_ok());
}
#[test]
fn test_extract_host_full_url() {
assert_eq!(
extract_host("https://example.com/path"),
Some("example.com".to_string())
);
}
#[test]
fn test_extract_host_schemeless() {
assert_eq!(
extract_host("example.com/path"),
Some("example.com".to_string())
);
}
#[test]
fn test_extract_host_with_port() {
assert_eq!(
extract_host("example.com:8080/path"),
Some("example.com".to_string())
);
}
#[test]
fn test_extract_host_no_dot() {
assert_eq!(extract_host("localhost"), None);
}
#[test]
fn test_store_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("trust.json");
let store = TrustStore {
version: 1,
entries: vec![TrustEntry {
pattern: "example.com".to_string(),
rule_id: Some("shortened_url".to_string()),
ttl_expires: None,
added: "2026-04-03T12:00:00Z".to_string(),
source: "cli".to_string(),
reason: Some("internal mirror".to_string()),
}],
};
write_store(&path, &store).unwrap();
let loaded = load_store(&path).unwrap();
assert_eq!(loaded.version, 1);
assert_eq!(loaded.entries.len(), 1);
assert_eq!(loaded.entries[0].pattern, "example.com");
assert_eq!(loaded.entries[0].rule_id.as_deref(), Some("shortened_url"));
assert_eq!(loaded.entries[0].reason.as_deref(), Some("internal mirror"));
}
#[test]
fn test_load_legacy_store_without_reason() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("trust.json");
let legacy = r#"{
"version": 1,
"entries": [
{
"pattern": "old.example.com",
"added": "2026-01-01T00:00:00Z",
"source": "cli"
}
]
}"#;
fs::write(&path, legacy).unwrap();
let loaded = load_store(&path).unwrap();
assert_eq!(loaded.entries.len(), 1);
assert_eq!(loaded.entries[0].pattern, "old.example.com");
assert!(loaded.entries[0].reason.is_none());
assert!(loaded.entries[0].ttl_expires.is_none());
assert!(!is_expired(&loaded.entries[0]));
}
#[test]
fn test_gc_removes_expired() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("trust.json");
let past = chrono::Utc::now() - chrono::Duration::hours(1);
let future = chrono::Utc::now() + chrono::Duration::hours(1);
let store = TrustStore {
version: 1,
entries: vec![
TrustEntry {
pattern: "expired.com".to_string(),
rule_id: None,
ttl_expires: Some(past.to_rfc3339()),
added: chrono::Utc::now().to_rfc3339(),
source: "cli".to_string(),
reason: None,
},
TrustEntry {
pattern: "valid.com".to_string(),
rule_id: None,
ttl_expires: Some(future.to_rfc3339()),
added: chrono::Utc::now().to_rfc3339(),
source: "cli".to_string(),
reason: None,
},
TrustEntry {
pattern: "forever.com".to_string(),
rule_id: None,
ttl_expires: None,
added: chrono::Utc::now().to_rfc3339(),
source: "cli".to_string(),
reason: None,
},
],
};
write_store(&path, &store).unwrap();
let mut loaded = load_store(&path).unwrap();
loaded.entries.retain(|e| !is_expired(e));
write_store(&path, &loaded).unwrap();
let after = load_store(&path).unwrap();
assert_eq!(after.entries.len(), 2);
assert!(after.entries.iter().any(|e| e.pattern == "valid.com"));
assert!(after.entries.iter().any(|e| e.pattern == "forever.com"));
assert!(!after.entries.iter().any(|e| e.pattern == "expired.com"));
}
#[test]
fn test_classify_scope_exact_url() {
assert_eq!(
classify_scope("https://example.com/install.sh"),
ScopeKind::Exact
);
assert_eq!(
classify_scope("raw.githubusercontent.com/org/repo/main/get.sh"),
ScopeKind::Exact
);
}
#[test]
fn test_classify_scope_domain() {
assert_eq!(classify_scope("github.com"), ScopeKind::Domain);
assert_eq!(classify_scope("api.github.com"), ScopeKind::Domain);
assert_eq!(classify_scope("get.docker.com"), ScopeKind::Domain);
}
#[test]
fn test_classify_scope_wildcard() {
assert_eq!(classify_scope("*.example.com"), ScopeKind::Wildcard);
assert_eq!(classify_scope("*.internal.corp.net"), ScopeKind::Wildcard);
}
#[test]
fn test_classify_scope_bare_tld() {
assert_eq!(classify_scope("com"), ScopeKind::BareTld);
assert_eq!(classify_scope("dev"), ScopeKind::BareTld);
assert_eq!(classify_scope("io"), ScopeKind::BareTld);
assert_eq!(classify_scope("*.com"), ScopeKind::BareTld);
}
#[test]
fn test_classify_scope_substring() {
assert_eq!(classify_scope("get-pip"), ScopeKind::Substring);
}
#[test]
fn test_scope_kind_broad_and_dangerous() {
assert!(!ScopeKind::Exact.is_broad());
assert!(!ScopeKind::Substring.is_broad());
assert!(ScopeKind::Domain.is_broad());
assert!(ScopeKind::Wildcard.is_broad());
assert!(ScopeKind::BareTld.is_broad());
assert!(!ScopeKind::Domain.is_dangerous());
assert!(ScopeKind::Wildcard.is_dangerous());
assert!(ScopeKind::BareTld.is_dangerous());
}
#[test]
fn test_humanize_expiry() {
assert_eq!(humanize_expiry(None), None);
let future = chrono::Utc::now() + chrono::Duration::days(6) + chrono::Duration::hours(2);
let h = humanize_expiry(Some(&future.to_rfc3339())).unwrap();
assert!(h.starts_with("in 6d"), "got {h}");
let past = chrono::Utc::now() - chrono::Duration::hours(1);
assert_eq!(
humanize_expiry(Some(&past.to_rfc3339())),
Some("expired".to_string())
);
}
#[test]
fn test_row_key_roundtrip() {
let row = make_row(
"github.com".to_string(),
Some("shortened_url".to_string()),
"trust-user".to_string(),
None,
false,
);
let key = row_key(&row);
let (source, pattern, rule) = split_key(&key);
assert_eq!(source, "trust-user");
assert_eq!(pattern, "github.com");
assert_eq!(rule.as_deref(), Some("shortened_url"));
}
#[test]
fn test_row_key_roundtrip_no_rule() {
let row = make_row(
"example.com".to_string(),
None,
"policy".to_string(),
None,
false,
);
let (source, pattern, rule) = split_key(&row_key(&row));
assert_eq!(source, "policy");
assert_eq!(pattern, "example.com");
assert_eq!(rule, None);
}
#[test]
fn test_diff_set_logic() {
let base: std::collections::BTreeSet<&str> = ["A", "B"].into_iter().collect();
let cur: std::collections::BTreeSet<&str> = ["B", "C"].into_iter().collect();
let added: Vec<_> = cur.difference(&base).collect();
let removed: Vec<_> = base.difference(&cur).collect();
assert_eq!(added, vec![&"C"]);
assert_eq!(removed, vec![&"A"]);
}
fn with_seeded_last_trigger<F: FnOnce()>(json: &str, f: F) {
use crate::cli::test_harness::{EnvGuard, ENV_LOCK};
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().expect("tempdir");
let _xdg = EnvGuard::set("XDG_DATA_HOME", dir.path());
let _appdata = EnvGuard::set("APPDATA", dir.path());
let tirith_data = dir.path().join("tirith");
fs::create_dir_all(&tirith_data).expect("create data dir");
fs::write(tirith_data.join("last_trigger.json"), json).expect("write last_trigger.json");
f();
}
fn with_empty_data_dir<F: FnOnce()>(f: F) {
use crate::cli::test_harness::{EnvGuard, ENV_LOCK};
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().expect("tempdir");
let _xdg = EnvGuard::set("XDG_DATA_HOME", dir.path());
let _appdata = EnvGuard::set("APPDATA", dir.path());
f();
}
#[test]
fn from_last_trigger_suggests_narrow_url_without_broad() {
let json = r#"{
"rule_ids": ["shortened_url"],
"severity": "high",
"command_redacted": "curl https://example.com/install.sh | sh",
"timestamp": "2026-06-10T00:00:00Z",
"findings": [
{
"rule_id": "shortened_url",
"title": "Shortened URL",
"evidence": [
{ "raw": "https://example.com/install.sh" }
]
}
]
}"#;
with_seeded_last_trigger(json, || {
let pairs = read_last_trigger().expect("read_last_trigger");
assert_eq!(
pairs,
vec![(
"https://example.com/install.sh".to_string(),
Some("shortened_url".to_string())
)]
);
let lines = suggestion_lines(&pairs);
let expected =
"tirith trust add 'https://example.com/install.sh' --rule shortened_url --ttl 30d";
assert!(
lines.iter().any(|l| l == expected),
"expected narrow single-quoted URL suggestion {expected:?}, got: {lines:?}"
);
assert!(
lines.iter().all(|l| !l.contains("--broad")),
"a full-URL target must NOT be suggested with --broad: {lines:?}"
);
assert_eq!(from_last_trigger(false), 0);
});
}
#[test]
fn from_last_trigger_bare_domain_gets_broad() {
let json = r#"{
"rule_ids": ["homograph"],
"findings": [
{ "rule_id": "homograph", "title": "Homograph", "evidence": [ { "raw_host": "example.com" } ] }
]
}"#;
with_seeded_last_trigger(json, || {
let pairs = read_last_trigger().expect("read_last_trigger");
assert_eq!(
pairs,
vec![("example.com".to_string(), Some("homograph".to_string()))]
);
let lines = suggestion_lines(&pairs);
let expected = "tirith trust add 'example.com' --broad --rule homograph --ttl 30d";
assert!(
lines.iter().any(|l| l == expected),
"expected single-quoted bare-domain suggestion with --broad {expected:?}, got: {lines:?}"
);
});
}
#[test]
fn from_last_trigger_pairs_each_target_with_its_own_rule() {
let json = r#"{
"rule_ids": ["shortened_url", "plain_http_to_sink"],
"findings": [
{
"rule_id": "shortened_url",
"title": "Shortened URL",
"evidence": [ { "raw": "https://a.example/x" } ]
},
{
"rule_id": "plain_http_to_sink",
"title": "Plain HTTP",
"evidence": [ { "raw": "http://b.example/y" } ]
}
]
}"#;
with_seeded_last_trigger(json, || {
let pairs = read_last_trigger().expect("read_last_trigger");
assert_eq!(
pairs,
vec![
(
"https://a.example/x".to_string(),
Some("shortened_url".to_string())
),
(
"http://b.example/y".to_string(),
Some("plain_http_to_sink".to_string())
),
],
"each target must keep its own finding's rule_id (no cartesian product)"
);
let lines = suggestion_lines(&pairs);
assert!(
lines.iter().any(|l| l
== "tirith trust add 'https://a.example/x' --rule shortened_url --ttl 30d"),
"A must pair with shortened_url: {lines:?}"
);
assert!(
lines.iter().any(|l| l
== "tirith trust add 'http://b.example/y' --rule plain_http_to_sink --ttl 30d"),
"B must pair with plain_http_to_sink: {lines:?}"
);
assert_eq!(
lines.len(),
2,
"exactly two lines, no cartesian product: {lines:?}"
);
assert!(
!lines.iter().any(
|l| l.contains("'https://a.example/x'") && l.contains("plain_http_to_sink")
),
"A must NOT be cross-paired with plain_http_to_sink: {lines:?}"
);
assert!(
!lines
.iter()
.any(|l| l.contains("'http://b.example/y'") && l.contains("shortened_url")),
"B must NOT be cross-paired with shortened_url: {lines:?}"
);
});
}
#[test]
fn from_last_trigger_apply_partial_failure_returns_one() {
let json = "{\
\"rule_ids\": [\"shortened_url\", \"homograph\"],\
\"findings\": [\
{\
\"rule_id\": \"shortened_url\",\
\"title\": \"Shortened URL\",\
\"evidence\": [ { \"raw\": \"https://good.example/install.sh\" } ]\
},\
{\
\"rule_id\": \"homograph\",\
\"title\": \"Homograph\",\
\"evidence\": [ { \"raw_host\": \"evil.example\\u0007\" } ]\
}\
]\
}";
with_seeded_last_trigger(json, || {
let pairs = read_last_trigger().expect("read_last_trigger");
assert_eq!(
pairs,
vec![
(
"https://good.example/install.sh".to_string(),
Some("shortened_url".to_string())
),
(
"evil.example\u{0007}".to_string(),
Some("homograph".to_string())
),
],
"both entries must reach the apply loop so one can succeed and one fail"
);
assert_eq!(from_last_trigger(false), 0);
assert_eq!(
from_last_trigger(true),
1,
"a partial apply (one entry rejected by add) must fail loud, not exit 0"
);
});
}
#[test]
fn from_last_trigger_shell_quotes_hostile_target() {
let json = r#"{
"rule_ids": ["confusable_domain"],
"findings": [
{
"rule_id": "confusable_domain",
"title": "Confusable",
"evidence": [ { "raw_host": "evil.example/$(touch X)" } ]
}
]
}"#;
with_seeded_last_trigger(json, || {
let pairs = read_last_trigger().expect("read_last_trigger");
let lines = suggestion_lines(&pairs);
let line = lines
.iter()
.find(|l| l.contains("tirith trust add"))
.expect("a suggestion line");
assert!(
line.contains("'evil.example/$(touch X)'"),
"hostile target must be single-quoted so $(touch X) cannot execute: {line}"
);
assert!(
!line.replace("'evil.example/$(touch X)'", "").contains("$("),
"no bare $( may survive outside the quoted token: {line}"
);
});
assert_eq!(
format_add_line("evil.example/a\nrm -rf ~", Some("confusable_domain"), true),
"# trust this target manually with `tirith trust add` \
(it contains characters unsafe to embed in a suggested command)."
);
let osc = format_add_line(
"evil.example/\u{1b}]0;pwned\u{7}\u{1b}[31m",
Some("confusable_domain"),
true,
);
assert!(
!osc.contains('\u{1b}'),
"ESC (0x1B) must be scrubbed before the suggestion is printed: {osc:?}"
);
assert!(
osc.contains("tirith trust add"),
"scrubbed target should still yield a runnable trust line: {osc:?}"
);
}
#[test]
fn rules_for_host_returns_only_that_hosts_rules() {
let val: serde_json::Value = serde_json::from_str(
r#"{
"rule_ids": ["shortened_url", "plain_http_to_sink"],
"findings": [
{
"rule_id": "shortened_url",
"title": "Shortened URL",
"evidence": [ { "raw": "https://a.example/x" } ]
},
{
"rule_id": "plain_http_to_sink",
"title": "Plain HTTP",
"evidence": [ { "raw": "http://b.example/y" } ]
}
]
}"#,
)
.unwrap();
assert_eq!(rules_for_host(&val, "a.example"), vec!["shortened_url"]);
assert_eq!(
rules_for_host(&val, "b.example"),
vec!["plain_http_to_sink"]
);
assert!(rules_for_host(&val, "c.example").is_empty());
}
#[test]
fn rules_for_host_returns_all_own_rules_deduped() {
let val: serde_json::Value = serde_json::from_str(
r#"{
"rule_ids": ["shortened_url", "plain_http_to_sink", "homograph"],
"findings": [
{
"rule_id": "shortened_url",
"evidence": [ { "raw": "https://a.example/x" }, { "raw_host": "a.example" } ]
},
{
"rule_id": "plain_http_to_sink",
"evidence": [ { "raw": "http://a.example/y" } ]
},
{
"rule_id": "homograph",
"evidence": [ { "raw_host": "b.example" } ]
}
]
}"#,
)
.unwrap();
assert_eq!(
rules_for_host(&val, "a.example"),
vec!["shortened_url", "plain_http_to_sink"],
"a host with multiple rules keeps all of its own rules, deduped"
);
assert_eq!(rules_for_host(&val, "b.example"), vec!["homograph"]);
}
#[test]
fn rules_for_host_empty_when_finding_has_no_rule_id() {
let val: serde_json::Value = serde_json::from_str(
r#"{
"findings": [
{ "title": "Mystery", "evidence": [ { "raw_host": "a.example" } ] }
]
}"#,
)
.unwrap();
assert!(rules_for_host(&val, "a.example").is_empty());
}
#[test]
fn from_last_trigger_missing_returns_zero() {
with_empty_data_dir(|| {
assert_eq!(from_last_trigger(false), 0);
assert_eq!(
read_last_trigger().unwrap_err(),
"no recent trigger found".to_string()
);
});
}
#[test]
fn last_unchanged_without_trigger_returns_one() {
with_empty_data_dir(|| {
assert_eq!(last(), 1);
});
}
}