#![allow(clippy::doc_markdown, clippy::uninlined_format_args)]
use std::io::Write;
use std::process::{Command, Stdio};
fn dcg_binary() -> std::path::PathBuf {
let mut path = std::env::current_exe().unwrap();
path.pop(); path.pop(); path.push("dcg");
path
}
fn run_hook_mode(command: &str) -> (String, String, i32) {
let input = format!(
r#"{{"tool_name":"Bash","tool_input":{{"command":"{}"}}}}"#,
command.replace('\\', "\\\\").replace('"', "\\\"")
);
let mut child = Command::new(dcg_binary())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn dcg process");
{
let stdin = child.stdin.as_mut().expect("failed to get stdin");
stdin
.write_all(input.as_bytes())
.expect("failed to write to stdin");
}
let output = child.wait_with_output().expect("failed to wait for dcg");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let exit_code = output.status.code().unwrap_or(-1);
(stdout, stderr, exit_code)
}
#[test]
fn test_hook_output_contains_hook_event_name() {
let (stdout, stderr, exit_code) = run_hook_mode("git reset --hard");
assert_eq!(
exit_code, 0,
"hook mode should exit 0 even on deny\nstderr: {stderr}"
);
assert!(!stdout.is_empty(), "stdout should contain JSON output");
let json: serde_json::Value =
serde_json::from_str(&stdout).expect("hook output should be valid JSON");
let hook_output = &json["hookSpecificOutput"];
assert!(
hook_output.get("hookEventName").is_some(),
"hookEventName field required in output"
);
assert_eq!(
hook_output["hookEventName"], "PreToolUse",
"hookEventName should be 'PreToolUse'"
);
}
#[test]
fn test_hook_output_contains_permission_decision() {
let (stdout, _stderr, _) = run_hook_mode("git reset --hard");
let json: serde_json::Value =
serde_json::from_str(&stdout).expect("hook output should be valid JSON");
let hook_output = &json["hookSpecificOutput"];
assert!(
hook_output.get("permissionDecision").is_some(),
"permissionDecision field required in output"
);
let decision = hook_output["permissionDecision"].as_str().unwrap();
assert!(
decision == "allow" || decision == "deny",
"permissionDecision should be 'allow' or 'deny', got: {decision}"
);
}
#[test]
fn test_hook_output_deny_has_rule_id() {
let (stdout, _stderr, _) = run_hook_mode("git reset --hard");
let json: serde_json::Value =
serde_json::from_str(&stdout).expect("hook output should be valid JSON");
let hook_output = &json["hookSpecificOutput"];
if hook_output["permissionDecision"] == "deny" {
assert!(
hook_output.get("ruleId").is_some(),
"ruleId field should be present for denied commands"
);
let rule_id = hook_output["ruleId"].as_str().unwrap();
assert!(
rule_id.contains(':'),
"ruleId should have format 'packId:patternName', got: {rule_id}"
);
}
}
#[test]
fn test_hook_output_deny_has_pack_id() {
let (stdout, _stderr, _) = run_hook_mode("git reset --hard");
let json: serde_json::Value =
serde_json::from_str(&stdout).expect("hook output should be valid JSON");
let hook_output = &json["hookSpecificOutput"];
if hook_output["permissionDecision"] == "deny" {
assert!(
hook_output.get("packId").is_some(),
"packId field should be present for denied commands"
);
let pack_id = hook_output["packId"].as_str().unwrap();
assert!(!pack_id.is_empty(), "packId should not be empty");
}
}
#[test]
fn test_hook_output_deny_has_severity() {
let (stdout, _stderr, _) = run_hook_mode("git reset --hard");
let json: serde_json::Value =
serde_json::from_str(&stdout).expect("hook output should be valid JSON");
let hook_output = &json["hookSpecificOutput"];
if hook_output["permissionDecision"] == "deny" {
assert!(
hook_output.get("severity").is_some(),
"severity field should be present for denied commands"
);
let severity = hook_output["severity"].as_str().unwrap();
let valid_severities = ["critical", "high", "medium", "low"];
assert!(
valid_severities.contains(&severity),
"severity should be one of {:?}, got: {severity}",
valid_severities
);
}
}
#[test]
fn test_hook_output_deny_has_remediation() {
let (stdout, _stderr, _) = run_hook_mode("git reset --hard");
let json: serde_json::Value =
serde_json::from_str(&stdout).expect("hook output should be valid JSON");
let hook_output = &json["hookSpecificOutput"];
if hook_output["permissionDecision"] == "deny" {
assert!(
hook_output.get("remediation").is_some(),
"remediation field should be present for denied commands"
);
let remediation = &hook_output["remediation"];
assert!(
remediation.get("explanation").is_some(),
"remediation.explanation should be present"
);
assert!(
remediation.get("allowOnceCommand").is_some(),
"remediation.allowOnceCommand should be present"
);
}
}
#[test]
fn test_hook_output_deny_has_allow_once_code() {
let (stdout, _stderr, _) = run_hook_mode("git reset --hard");
let json: serde_json::Value =
serde_json::from_str(&stdout).expect("hook output should be valid JSON");
let hook_output = &json["hookSpecificOutput"];
if hook_output["permissionDecision"] == "deny" {
assert!(
hook_output.get("allowOnceCode").is_some(),
"allowOnceCode should be present for denied commands"
);
let code = hook_output["allowOnceCode"].as_str().unwrap();
assert!(!code.is_empty(), "allowOnceCode should not be empty");
if let Some(remediation) = hook_output.get("remediation") {
let allow_cmd = remediation["allowOnceCommand"].as_str().unwrap();
assert!(
allow_cmd.contains("dcg allow-once"),
"allowOnceCommand should contain 'dcg allow-once'"
);
assert!(
allow_cmd.contains(code),
"allowOnceCommand should contain the allowOnceCode"
);
}
}
}
#[test]
fn test_hook_output_permission_decision_reason() {
let (stdout, _stderr, _) = run_hook_mode("git reset --hard");
let json: serde_json::Value =
serde_json::from_str(&stdout).expect("hook output should be valid JSON");
let hook_output = &json["hookSpecificOutput"];
assert!(
hook_output.get("permissionDecisionReason").is_some(),
"permissionDecisionReason should be present"
);
let reason = hook_output["permissionDecisionReason"].as_str().unwrap();
assert!(
!reason.is_empty(),
"permissionDecisionReason should not be empty"
);
if hook_output["permissionDecision"] == "deny" {
assert!(
reason.contains("BLOCKED") || reason.contains("Reason:"),
"permissionDecisionReason for deny should explain the block"
);
}
}
#[test]
fn test_hook_output_stderr_includes_allowlist_add_hint() {
let (stdout, stderr, exit_code) = run_hook_mode("git reset --hard");
assert_eq!(exit_code, 0, "hook mode should exit 0");
assert!(!stdout.trim().is_empty(), "deny should emit JSON on stdout");
let json: serde_json::Value =
serde_json::from_str(&stdout).expect("hook output should be valid JSON");
let hook_output = &json["hookSpecificOutput"];
assert_eq!(
hook_output["permissionDecision"], "deny",
"git reset --hard should be denied"
);
assert!(
stderr.contains("dcg allowlist add core.git:reset-hard --project"),
"stderr should include allowlist add hint for matched rule.\nstderr:\n{stderr}"
);
}
#[test]
fn test_hook_output_safe_command_returns_no_output() {
let (stdout, _stderr, exit_code) = run_hook_mode("git status");
assert_eq!(exit_code, 0, "safe command should exit 0");
assert!(
stdout.is_empty() || stdout.trim().is_empty(),
"safe command should produce no stdout output, got: {stdout}"
);
}
#[test]
fn test_hook_output_git_clean_dry_run_allowed() {
let (stdout, _stderr, exit_code) = run_hook_mode("git clean -n");
assert_eq!(exit_code, 0, "git clean -n should exit 0");
assert!(
stdout.is_empty() || stdout.trim().is_empty(),
"git clean -n (dry run) should be allowed with no output, got: {stdout}"
);
}
#[test]
fn test_hook_output_multiple_destructive_commands() {
let commands = [
"git reset --hard HEAD~5",
"git clean -fd",
"git push --force origin main",
"rm -rf /important/data",
];
for cmd in commands {
let (stdout, stderr, exit_code) = run_hook_mode(cmd);
assert_eq!(
exit_code, 0,
"hook mode should exit 0 for cmd: {cmd}\nstderr: {stderr}"
);
if !stdout.is_empty() {
let json: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("invalid JSON for cmd '{cmd}': {e}\nstdout: {stdout}"));
let hook_output = &json["hookSpecificOutput"];
if hook_output["permissionDecision"] == "deny" {
assert!(
hook_output.get("ruleId").is_some() || hook_output.get("packId").is_some(),
"denied command should have ruleId or packId: {cmd}"
);
assert!(
hook_output.get("severity").is_some(),
"denied command should have severity: {cmd}"
);
}
}
}
}
#[test]
fn test_hook_output_rule_id_format() {
let (stdout, _stderr, _) = run_hook_mode("git reset --hard");
let json: serde_json::Value =
serde_json::from_str(&stdout).expect("hook output should be valid JSON");
let hook_output = &json["hookSpecificOutput"];
if let Some(rule_id) = hook_output.get("ruleId") {
let rule_id_str = rule_id.as_str().unwrap();
let parts: Vec<&str> = rule_id_str.split(':').collect();
assert_eq!(
parts.len(),
2,
"ruleId should have format 'packId:patternName', got: {rule_id_str}"
);
if let Some(pack_id) = hook_output.get("packId") {
assert_eq!(
parts[0],
pack_id.as_str().unwrap(),
"ruleId pack portion should match packId"
);
}
}
}
#[test]
fn test_hook_output_remediation_safe_alternative() {
let (stdout, _stderr, _) = run_hook_mode("git reset --hard");
let json: serde_json::Value =
serde_json::from_str(&stdout).expect("hook output should be valid JSON");
let hook_output = &json["hookSpecificOutput"];
if let Some(remediation) = hook_output.get("remediation") {
if let Some(safe_alt) = remediation.get("safeAlternative") {
let alt_str = safe_alt.as_str().unwrap();
assert!(
!alt_str.is_empty(),
"safeAlternative when present should not be empty"
);
}
}
}