#![allow(clippy::doc_markdown)]
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_with_env(command: &str, env_vars: &[(&str, &str)]) -> (String, String, i32) {
let temp = tempfile::tempdir().expect("failed to create temp dir");
std::fs::create_dir_all(temp.path().join(".git")).expect("failed to create .git dir");
let home_dir = temp.path().join("home");
let xdg_config_dir = temp.path().join("xdg_config");
std::fs::create_dir_all(&home_dir).expect("failed to create HOME dir");
std::fs::create_dir_all(&xdg_config_dir).expect("failed to create XDG_CONFIG_HOME dir");
let input = serde_json::json!({
"tool_name": "Bash",
"tool_input": {
"command": command,
}
});
let mut cmd = Command::new(dcg_binary());
cmd.env_clear()
.env("HOME", &home_dir)
.env("XDG_CONFIG_HOME", &xdg_config_dir)
.env("DCG_ALLOWLIST_SYSTEM_PATH", "")
.env("DCG_PACKS", "core.git,core.filesystem")
.current_dir(temp.path())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (key, value) in env_vars {
cmd.env(key, value);
}
let mut child = cmd.spawn().expect("failed to spawn dcg hook mode");
{
let stdin = child.stdin.as_mut().expect("failed to open stdin");
serde_json::to_writer(stdin, &input).expect("failed to write hook input JSON");
}
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)
}
fn run_dcg_with_env(args: &[&str], env_vars: &[(&str, &str)]) -> (String, String, i32) {
let temp = tempfile::tempdir().expect("failed to create temp dir");
let mut cmd = Command::new(dcg_binary());
cmd.args(args)
.env_clear()
.env("HOME", temp.path())
.env("DCG_ALLOWLIST_SYSTEM_PATH", "")
.current_dir(temp.path())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (key, value) in env_vars {
cmd.env(key, value);
}
let output = cmd.output().expect("failed to execute 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)
}
mod ci_fallback_tests {
use super::*;
fn contains_ansi_codes(s: &str) -> bool {
s.contains("\x1b[")
}
fn contains_denial_box_chars(s: &str) -> bool {
s.contains('\u{256d}') || s.contains('\u{256e}') || s.contains('\u{256f}') || s.contains('\u{2570}') || s.contains('\u{2502}') || s.contains('\u{251c}') || s.contains('\u{2524}') }
#[test]
fn ci_env_disables_ansi_codes() {
let (stdout, stderr, exit_code) = run_hook_with_env("git reset --hard", &[("CI", "true")]);
assert_eq!(exit_code, 0, "hook should exit 0");
assert!(
!stdout.is_empty(),
"stdout should have JSON output for denied command"
);
assert!(
!contains_ansi_codes(&stderr) || stderr.is_empty(),
"CI=true should disable ANSI codes in stderr output"
);
}
#[test]
fn ci_env_disables_denial_box_chars() {
let (_, stderr, _) = run_hook_with_env("git reset --hard", &[("CI", "true")]);
assert!(
!contains_denial_box_chars(&stderr),
"CI=true should disable denial box border characters"
);
}
#[test]
fn ci_env_with_explain_command() {
let (stdout, stderr, exit_code) =
run_dcg_with_env(&["explain", "git reset --hard"], &[("CI", "true")]);
assert_eq!(exit_code, 0, "explain should succeed");
assert!(
stdout.contains("DENY") || stdout.contains("deny"),
"should show deny decision"
);
assert!(
!contains_ansi_codes(&stdout) || !contains_ansi_codes(&stderr),
"CI mode should not have ANSI codes"
);
}
}
mod no_color_tests {
use super::*;
fn contains_ansi_codes(s: &str) -> bool {
s.contains("\x1b[")
}
#[test]
fn no_color_env_disables_colors() {
let (stdout, stderr, exit_code) =
run_hook_with_env("git reset --hard", &[("NO_COLOR", "1")]);
assert_eq!(exit_code, 0, "hook should exit 0");
assert!(
!contains_ansi_codes(&stdout),
"NO_COLOR=1 should disable ANSI codes in stdout"
);
assert!(
!contains_ansi_codes(&stderr),
"NO_COLOR=1 should disable ANSI codes in stderr"
);
}
#[test]
fn no_color_env_with_any_value() {
for value in ["1", "true", "yes", "anything"] {
let (stdout, stderr, _) = run_hook_with_env("git reset --hard", &[("NO_COLOR", value)]);
assert!(
!contains_ansi_codes(&stdout) && !contains_ansi_codes(&stderr),
"NO_COLOR={value} should disable colors"
);
}
}
#[test]
fn no_color_empty_value_still_disables() {
let (stdout, stderr, _) = run_hook_with_env("git reset --hard", &[("NO_COLOR", "")]);
assert!(
!contains_ansi_codes(&stdout) && !contains_ansi_codes(&stderr),
"NO_COLOR='' should disable colors (env var is set)"
);
}
#[test]
fn no_color_with_scan_command() {
let temp = tempfile::tempdir().expect("failed to create temp dir");
let dockerfile = temp.path().join("Dockerfile");
std::fs::write(&dockerfile, "RUN rm -rf /\n").expect("failed to write test file");
let (stdout, stderr, _) = run_dcg_with_env(
&["scan", temp.path().to_str().unwrap()],
&[("NO_COLOR", "1")],
);
let combined = format!("{stdout}{stderr}");
assert!(
!contains_ansi_codes(&combined),
"scan output with NO_COLOR should not have ANSI codes"
);
}
}
mod term_dumb_tests {
use super::*;
fn contains_denial_box_chars(s: &str) -> bool {
s.contains('\u{256d}') || s.contains('\u{256e}') || s.contains('\u{256f}') || s.contains('\u{2570}') || s.contains('\u{2502}') || s.contains('\u{251c}') || s.contains('\u{2524}') }
fn contains_ansi_codes(s: &str) -> bool {
s.contains("\x1b[")
}
#[test]
fn term_dumb_disables_denial_box() {
let (_, stderr, exit_code) = run_hook_with_env("git reset --hard", &[("TERM", "dumb")]);
assert_eq!(exit_code, 0, "hook should exit 0");
assert!(
!contains_denial_box_chars(&stderr),
"TERM=dumb should not have denial box border characters"
);
}
#[test]
fn term_dumb_disables_colors() {
let (stdout, stderr, _) = run_hook_with_env("git reset --hard", &[("TERM", "dumb")]);
assert!(
!contains_ansi_codes(&stdout) && !contains_ansi_codes(&stderr),
"TERM=dumb should disable ANSI colors"
);
}
#[test]
fn term_dumb_with_explain() {
let (stdout, stderr, exit_code) =
run_dcg_with_env(&["explain", "git reset --hard"], &[("TERM", "dumb")]);
assert_eq!(exit_code, 0, "explain should succeed");
let combined = format!("{stdout}{stderr}");
assert!(
!contains_denial_box_chars(&combined),
"TERM=dumb explain should not have denial box border chars"
);
assert!(
!contains_ansi_codes(&combined),
"TERM=dumb explain should not have ANSI codes"
);
}
}
mod json_format_tests {
use super::*;
fn contains_box_chars(s: &str) -> bool {
s.contains('\u{256d}')
|| s.contains('\u{256f}')
|| s.contains('\u{2502}')
|| (s.contains('+') && s.contains('-') && s.contains('|'))
}
#[test]
fn json_format_is_pure_json() {
let (stdout, _, exit_code) =
run_dcg_with_env(&["explain", "--format", "json", "git reset --hard"], &[]);
assert_eq!(exit_code, 0, "explain --format json should succeed");
let json: serde_json::Value =
serde_json::from_str(&stdout).expect("output should be valid JSON");
assert!(json.is_object(), "JSON output should be an object");
assert!(
json.get("decision").is_some() || json.get("command").is_some(),
"JSON should have expected fields"
);
assert!(
!contains_box_chars(&stdout),
"JSON format should not have box-drawing characters"
);
}
#[test]
fn json_format_scan_is_valid() {
let temp = tempfile::tempdir().expect("failed to create temp dir");
let dockerfile = temp.path().join("Dockerfile");
std::fs::write(&dockerfile, "RUN rm -rf /\n").expect("failed to write test file");
let (stdout, stderr, exit_code) = run_dcg_with_env(
&["scan", "--format", "json", temp.path().to_str().unwrap()],
&[],
);
let _ = exit_code;
let _ = stderr;
if !stdout.trim().is_empty() {
let json: serde_json::Value = serde_json::from_str(&stdout)
.expect("scan --format json should produce valid JSON");
assert!(
json.is_array() || json.is_object(),
"JSON output should be array or object"
);
assert!(
!contains_box_chars(&stdout),
"JSON scan output should not have box-drawing characters"
);
}
}
#[test]
fn hook_output_is_pure_json_when_denied() {
let (stdout, _, exit_code) = run_hook_with_env("git reset --hard", &[]);
assert_eq!(exit_code, 0, "hook should exit 0");
let json: serde_json::Value =
serde_json::from_str(&stdout).expect("hook stdout should be valid JSON");
assert!(
json.get("hookSpecificOutput").is_some(),
"should have hookSpecificOutput"
);
assert!(
!contains_box_chars(&stdout),
"hook JSON output should not have box-drawing characters"
);
}
}
mod safe_command_tests {
use super::*;
#[test]
fn safe_command_produces_no_stdout() {
let (stdout, _, exit_code) = run_hook_with_env("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, got: {stdout}"
);
}
#[test]
fn safe_command_produces_no_rich_stderr() {
let (_, stderr, exit_code) = run_hook_with_env("git status", &[]);
assert_eq!(exit_code, 0, "safe command should exit 0");
assert!(
stderr.is_empty() || stderr.trim().is_empty(),
"safe command should produce no stderr, got: {stderr}"
);
}
#[test]
fn git_clean_dry_run_is_safe() {
let (stdout, stderr, exit_code) = run_hook_with_env("git clean -n", &[]);
assert_eq!(exit_code, 0, "git clean -n should exit 0");
assert!(
stdout.trim().is_empty(),
"git clean -n (dry run) should be allowed with no stdout"
);
assert!(
stderr.trim().is_empty(),
"git clean -n should have no stderr"
);
}
}
mod denial_content_tests {
use super::*;
#[test]
fn denial_stderr_contains_blocked_message() {
let (_, stderr, _) = run_hook_with_env("git reset --hard", &[]);
assert!(
stderr.contains("BLOCKED") || stderr.contains("blocked") || stderr.is_empty(),
"denial stderr should contain BLOCKED message or be empty (in non-TTY mode)"
);
}
#[test]
fn denial_stdout_has_structured_json() {
let (stdout, _, exit_code) = run_hook_with_env("git reset --hard", &[]);
assert_eq!(exit_code, 0, "hook exits 0 even on deny");
assert!(!stdout.is_empty(), "denied command should have JSON output");
let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let hook_output = &json["hookSpecificOutput"];
assert_eq!(hook_output["permissionDecision"], "deny");
assert!(hook_output.get("ruleId").is_some(), "should have ruleId");
assert!(
hook_output.get("severity").is_some(),
"should have severity"
);
}
#[test]
fn denial_has_allow_once_info() {
let (stdout, _, _) = run_hook_with_env("git reset --hard", &[]);
let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let hook_output = &json["hookSpecificOutput"];
assert!(
hook_output.get("allowOnceCode").is_some(),
"denied command should have allowOnceCode"
);
if let Some(remediation) = hook_output.get("remediation") {
let allow_cmd = remediation["allowOnceCommand"].as_str().unwrap_or("");
assert!(
allow_cmd.contains("dcg allow-once"),
"remediation should contain dcg allow-once command"
);
}
}
}
mod output_mode_consistency_tests {
use super::*;
#[test]
fn all_env_modes_deny_same_command() {
let command = "git reset --hard HEAD~5";
let configs: Vec<(&str, &[(&str, &str)])> = vec![
("default", &[]),
("CI=true", &[("CI", "true")]),
("NO_COLOR=1", &[("NO_COLOR", "1")]),
("TERM=dumb", &[("TERM", "dumb")]),
];
for (config_name, env_vars) in configs {
let (stdout, _, exit_code) = run_hook_with_env(command, env_vars);
assert_eq!(exit_code, 0, "{config_name}: hook should exit 0");
let json: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("{config_name}: invalid JSON: {e}\nstdout: {stdout}"));
let decision = json["hookSpecificOutput"]["permissionDecision"]
.as_str()
.unwrap_or("unknown");
assert_eq!(
decision, "deny",
"{config_name}: should deny destructive command"
);
}
}
#[test]
fn all_env_modes_allow_safe_command() {
let command = "git status";
let configs = vec![
("default", vec![]),
("CI=true", vec![("CI", "true")]),
("NO_COLOR=1", vec![("NO_COLOR", "1")]),
("TERM=dumb", vec![("TERM", "dumb")]),
];
for (config_name, env_vars) in configs {
let (stdout, _, exit_code) = run_hook_with_env(
command,
&env_vars.iter().map(|(k, v)| (*k, *v)).collect::<Vec<_>>(),
);
assert_eq!(exit_code, 0, "{config_name}: should exit 0");
assert!(
stdout.trim().is_empty(),
"{config_name}: safe command should produce no output"
);
}
}
}