mod apps;
mod cli_args;
mod commands;
mod config;
mod crypto;
mod dispatch;
mod entropy;
mod guided;
mod hooks;
mod input;
mod progress;
mod run_header;
mod sanitize;
mod scanner_builder;
use cli_args::{Cli, SubCommand};
use clap::Parser;
use sanitize_engine::ArchiveFilter;
use std::collections::HashMap;
use std::ffi::OsString;
use std::path::PathBuf;
use std::process;
use std::sync::atomic::{AtomicBool, Ordering};
pub(crate) static INTERRUPTED: AtomicBool = AtomicBool::new(false);
pub(crate) fn is_interrupted() -> bool {
INTERRUPTED.load(Ordering::Relaxed)
}
fn run() -> Result<(), (String, i32)> {
let raw_args: Vec<OsString> = std::env::args_os().skip(1).collect();
let (raw_filter_map, cleaned_args) =
input::parse_archive_filters(&raw_args).map_err(|e| (e, 1))?;
let filter_map: HashMap<PathBuf, ArchiveFilter> = raw_filter_map
.into_iter()
.map(|(path, (only, exclude))| {
ArchiveFilter::new(only, exclude)
.map(|f| (path, f))
.map_err(|e| (e, 1))
})
.collect::<Result<HashMap<_, _>, _>>()?;
let cli = Cli::parse_from(std::iter::once(OsString::from("sanitize")).chain(cleaned_args));
input::init_logging(cli.effective_log_format(), cli.effective_log_level());
match &cli.command {
Some(SubCommand::Encrypt(args)) => return crypto::run_encrypt(args),
Some(SubCommand::Decrypt(args)) => return crypto::run_decrypt(args),
Some(SubCommand::Apps(args)) => return apps::run_apps(args),
Some(SubCommand::Guided) => return commands::run_guided(),
Some(SubCommand::Template(args)) => return commands::run_template(args),
Some(SubCommand::AllowTest(args)) => return commands::run_allow_test(args),
Some(SubCommand::InstallHook(args)) => return hooks::run_install_hook(args),
Some(SubCommand::InitHook(args)) => return config::run_init(args),
Some(SubCommand::ShowConfig) => return config::run_show_config(),
Some(SubCommand::Scan(args)) => return commands::run_scan(args),
Some(SubCommand::TestPattern(args)) => return commands::run_test_pattern(args),
None => {}
}
sanitize::run_sanitize(cli, None, filter_map)
}
fn main() {
match run() {
Ok(()) => {}
Err((msg, code)) => {
eprintln!("error: {msg}");
process::exit(code);
}
}
}
#[cfg(test)]
mod tests {
use crate::cli_args::{Cli, HookMode, HookType, InstallHookArgs, SubCommand};
use crate::guided::{
build_guided_entries, build_guided_profiles, CloudProvider, GuidedFormat, GuidedOptions,
GuidedPreset,
};
use crate::hooks::{
build_hook_flags, build_hook_script, hook_script_pre_commit_scan, remove_hook, sh_quote,
HOOK_MARKER,
};
use crate::input::{
cli_writes_to_stdout, file_inputs, format_to_ext, has_stdin_input, plan_input_targets,
validate_args, InputTarget,
};
use crate::progress::{ProgressContext, ProgressMode, ProgressPolicy};
use crate::scanner_builder::build_default_patterns;
use clap::Parser;
use sanitize_engine::secrets::entries_to_patterns;
use std::fs;
use std::path::PathBuf;
use tempfile::tempdir;
fn make_progress_context(
stderr_is_terminal: bool,
is_ci: bool,
term_is_dumb: bool,
json_logs: bool,
) -> ProgressContext {
ProgressContext {
stderr_is_terminal,
stdout_is_terminal: false,
stdout_is_output: false,
is_ci,
term_is_dumb,
json_logs,
}
}
#[test]
fn cli_debug_assert_does_not_panic() {
let _ = Cli::try_parse_from(["sanitize", "input.txt"]);
}
#[test]
fn cli_parses_basic_input() {
let cli = Cli::try_parse_from(["sanitize", "input.txt"]).unwrap();
assert_eq!(cli.input, vec![PathBuf::from("input.txt")]);
assert!(cli.command.is_none());
}
#[test]
fn cli_parses_input_with_output() {
let cli = Cli::try_parse_from(["sanitize", "input.txt", "-o", "output.txt"]).unwrap();
assert_eq!(cli.input, vec![PathBuf::from("input.txt")]);
assert_eq!(cli.output.unwrap(), PathBuf::from("output.txt"));
}
#[test]
fn cli_parses_multiple_inputs() {
let cli = Cli::try_parse_from(["sanitize", "test.txt", "a.json", "b.zip"]).unwrap();
assert_eq!(
cli.input,
vec![
PathBuf::from("test.txt"),
PathBuf::from("a.json"),
PathBuf::from("b.zip")
]
);
}
#[test]
fn cli_parses_output_long_flag() {
let cli = Cli::try_parse_from(["sanitize", "input.txt", "--output", "out.txt"]).unwrap();
assert_eq!(cli.output.unwrap(), PathBuf::from("out.txt"));
}
#[test]
fn cli_parses_secrets_file_flag() {
let cli = Cli::try_parse_from(["sanitize", "input.txt", "--secrets-file", "secrets.json"])
.unwrap();
assert_eq!(cli.secrets_file.unwrap(), PathBuf::from("secrets.json"));
}
#[test]
fn cli_parses_short_flags() {
let cli = Cli::try_parse_from([
"sanitize",
"input.txt",
"-s",
"secrets.json",
"-p",
"-P",
"/run/secrets/pw",
"-o",
"out.txt",
"-n",
"-d",
"-f",
"json",
])
.unwrap();
assert_eq!(cli.secrets_file.unwrap(), PathBuf::from("secrets.json"));
assert!(cli.password);
assert_eq!(cli.password_file.unwrap(), PathBuf::from("/run/secrets/pw"));
assert_eq!(cli.output.unwrap(), PathBuf::from("out.txt"));
assert!(cli.dry_run);
assert!(cli.deterministic);
assert_eq!(cli.format.unwrap(), "json");
}
#[test]
fn cli_parses_dry_run() {
let cli = Cli::try_parse_from(["sanitize", "input.txt", "--dry-run"]).unwrap();
assert!(cli.dry_run);
}
#[test]
fn cli_parses_progress_mode() {
let cli = Cli::try_parse_from(["sanitize", "input.txt", "--progress", "on"]).unwrap();
assert_eq!(cli.progress, Some(ProgressMode::On));
assert_eq!(cli.effective_progress_mode(), ProgressMode::On);
}
#[test]
fn cli_no_progress_maps_to_off() {
let cli = Cli::try_parse_from(["sanitize", "input.txt", "--no-progress"]).unwrap();
assert!(cli.no_progress);
assert_eq!(cli.effective_progress_mode(), ProgressMode::Off);
}
#[test]
fn cli_explicit_progress_takes_precedence_over_no_progress() {
let cli =
Cli::try_parse_from(["sanitize", "input.txt", "--no-progress", "--progress", "on"])
.unwrap();
assert!(cli.no_progress);
assert_eq!(cli.progress, Some(ProgressMode::On));
assert_eq!(cli.effective_progress_mode(), ProgressMode::On);
}
#[test]
fn cli_parses_progress_interval() {
let cli = Cli::try_parse_from(["sanitize", "input.txt", "--progress-interval-ms", "500"])
.unwrap();
assert_eq!(cli.progress_interval_ms, 500);
}
#[test]
fn validate_args_rejects_zero_progress_interval() {
let mut cli = Cli::try_parse_from(["sanitize", "input.txt"]).unwrap();
cli.input = vec![std::env::current_dir().unwrap().join("Cargo.toml")];
cli.progress_interval_ms = 0;
let err = validate_args(&cli).unwrap_err();
assert!(err.contains("--progress-interval-ms must be greater than 0"));
}
#[test]
fn progress_policy_auto_disables_live_updates_for_json_logs() {
let policy = ProgressPolicy::from_mode(
ProgressMode::Auto,
make_progress_context(true, false, false, true),
);
assert!(!policy.live_updates);
assert!(!policy.milestone_updates);
}
#[test]
fn progress_policy_auto_disables_live_updates_in_ci() {
let policy = ProgressPolicy::from_mode(
ProgressMode::Auto,
make_progress_context(true, true, false, false),
);
assert!(!policy.live_updates);
assert!(policy.milestone_updates);
}
#[test]
fn progress_policy_on_keeps_milestones_when_live_updates_are_unavailable() {
let policy = ProgressPolicy::from_mode(
ProgressMode::On,
make_progress_context(false, false, false, false),
);
assert!(!policy.live_updates);
assert!(policy.milestone_updates);
}
#[test]
fn progress_policy_auto_enables_live_updates_in_interactive_human_mode() {
let policy = ProgressPolicy::from_mode(
ProgressMode::Auto,
make_progress_context(true, false, false, false),
);
assert!(policy.live_updates);
assert!(policy.milestone_updates);
}
#[test]
fn progress_policy_auto_enables_live_when_stdout_is_tty_but_not_output_dest() {
let policy = ProgressPolicy::from_mode(
ProgressMode::Auto,
ProgressContext {
stderr_is_terminal: true,
stdout_is_terminal: true,
stdout_is_output: false,
is_ci: false,
term_is_dumb: false,
json_logs: false,
},
);
assert!(policy.live_updates);
}
#[test]
fn progress_policy_auto_disables_live_when_writing_to_stdout() {
let policy = ProgressPolicy::from_mode(
ProgressMode::Auto,
ProgressContext {
stderr_is_terminal: true,
stdout_is_terminal: true,
stdout_is_output: true,
is_ci: false,
term_is_dumb: false,
json_logs: false,
},
);
assert!(!policy.live_updates);
}
#[test]
fn cli_writes_to_stdout_stdin_no_output() {
let cli = Cli::try_parse_from(["sanitize"]).unwrap();
assert!(cli_writes_to_stdout(&cli));
}
#[test]
fn cli_writes_to_stdout_explicit_dash_input() {
let cli = Cli::try_parse_from(["sanitize", "-"]).unwrap();
assert!(cli_writes_to_stdout(&cli));
}
#[test]
fn cli_writes_to_stdout_explicit_dash_output() {
let cli = Cli::try_parse_from(["sanitize", "file.txt", "-o", "-"]).unwrap();
assert!(cli_writes_to_stdout(&cli));
}
#[test]
fn cli_writes_to_stdout_file_input_no_output_is_false() {
let cli = Cli::try_parse_from(["sanitize", "file.txt"]).unwrap();
assert!(!cli_writes_to_stdout(&cli));
}
#[test]
fn cli_writes_to_stdout_dir_input_no_output_is_false() {
let cli = Cli::try_parse_from(["sanitize", "some_dir/"]).unwrap();
assert!(!cli_writes_to_stdout(&cli));
}
#[test]
fn cli_parses_encrypt_subcommand() {
let cli = Cli::try_parse_from([
"sanitize",
"encrypt",
"secrets.json",
"secrets.enc",
"--password",
])
.unwrap();
assert!(cli.command.is_some());
assert!(cli.input.is_empty());
}
#[test]
fn cli_parses_decrypt_subcommand() {
let cli = Cli::try_parse_from([
"sanitize",
"decrypt",
"secrets.enc",
"secrets.json",
"--password",
])
.unwrap();
assert!(cli.command.is_some());
assert!(cli.input.is_empty());
}
#[test]
fn cli_parses_guided_subcommand() {
let cli = Cli::try_parse_from(["sanitize", "guided"]).unwrap();
assert!(matches!(cli.command, Some(SubCommand::Guided)));
assert!(cli.input.is_empty());
}
#[test]
fn cli_no_input_no_subcommand_is_ok_at_parse_time() {
let cli = Cli::try_parse_from(["sanitize", "--dry-run"]).unwrap();
assert!(cli.input.is_empty());
assert!(cli.command.is_none());
}
#[test]
fn cli_parses_all_flags() {
let cli = Cli::try_parse_from([
"sanitize",
"input.log",
"--output",
"output.log",
"--secrets-file",
"s.enc",
"--password",
"--dry-run",
"--fail-on-match",
"--deterministic",
"--strict",
"--include-binary",
"--encrypted-secrets",
"--chunk-size",
"4096",
"--threads",
"4",
"--max-mappings",
"500",
"--log-format",
"json",
"--format",
"yaml",
])
.unwrap();
assert!(cli.dry_run);
assert!(cli.fail_on_match);
assert!(cli.deterministic);
assert!(cli.strict);
assert!(cli.include_binary);
assert!(cli.encrypted_secrets);
assert_eq!(cli.chunk_size, 4096);
assert_eq!(cli.threads, Some(4));
assert_eq!(cli.max_mappings, 500);
assert_eq!(cli.format.unwrap(), "yaml");
assert_eq!(cli.output.unwrap(), PathBuf::from("output.log"));
}
#[test]
fn cli_stdin_dash_input() {
let cli = Cli::try_parse_from(["sanitize", "-", "-s", "s.json"]).unwrap();
assert!(has_stdin_input(&cli));
}
#[test]
fn cli_stdin_no_input() {
let cli = Cli::try_parse_from(["sanitize", "-s", "s.json"]).unwrap();
assert!(has_stdin_input(&cli));
}
#[test]
fn cli_file_input_not_stdin() {
let cli = Cli::try_parse_from(["sanitize", "data.log"]).unwrap();
assert!(!has_stdin_input(&cli));
}
#[test]
fn cli_file_and_stdin_mix_is_supported() {
let cli = Cli::try_parse_from(["sanitize", "test.txt", "-", "-s", "s.json"]).unwrap();
assert!(has_stdin_input(&cli));
assert_eq!(file_inputs(&cli).len(), 1);
}
#[test]
fn format_to_ext_mapping() {
assert_eq!(format_to_ext("json"), Some("json"));
assert_eq!(format_to_ext("yaml"), Some("yaml"));
assert_eq!(format_to_ext("xml"), Some("xml"));
assert_eq!(format_to_ext("csv"), Some("csv"));
assert_eq!(format_to_ext("key-value"), Some("conf"));
assert_eq!(format_to_ext("text"), None);
assert_eq!(format_to_ext("unknown"), None);
}
#[test]
fn plan_multi_input_outputs_preserve_types() {
let tmp = tempdir().unwrap();
let input_dir = tmp.path().join("in");
let out_dir = tmp.path().join("out");
fs::create_dir_all(&input_dir).unwrap();
let txt = input_dir.join("test.txt");
let json = input_dir.join("a.json");
let zip = input_dir.join("b.zip");
fs::write(&txt, "x").unwrap();
fs::write(&json, "{}\n").unwrap();
fs::write(&zip, "PK\x03\x04").unwrap();
let cli = Cli::try_parse_from([
"sanitize",
txt.to_str().unwrap(),
json.to_str().unwrap(),
zip.to_str().unwrap(),
"--output",
out_dir.to_str().unwrap(),
])
.unwrap();
let targets = plan_input_targets(&cli).unwrap();
let mut outputs = targets
.into_iter()
.filter_map(|t| match t {
InputTarget::File { output, .. } => {
Some(output.file_name().unwrap().to_string_lossy().to_string())
}
InputTarget::Stdin { .. } => None,
})
.collect::<Vec<_>>();
outputs.sort();
assert_eq!(
outputs,
vec![
"a-sanitized.json".to_string(),
"b.sanitized.zip".to_string(),
"test-sanitized.txt".to_string(),
]
);
}
#[test]
fn plan_multi_input_collision_adds_numeric_suffix() {
let tmp = tempdir().unwrap();
let dir1 = tmp.path().join("dir1");
let dir2 = tmp.path().join("dir2");
let out_dir = tmp.path().join("out");
fs::create_dir_all(&dir1).unwrap();
fs::create_dir_all(&dir2).unwrap();
let f1 = dir1.join("same.txt");
let f2 = dir2.join("same.txt");
fs::write(&f1, "x").unwrap();
fs::write(&f2, "y").unwrap();
let cli = Cli::try_parse_from([
"sanitize",
f1.to_str().unwrap(),
f2.to_str().unwrap(),
"--output",
out_dir.to_str().unwrap(),
])
.unwrap();
let targets = plan_input_targets(&cli).unwrap();
let outputs = targets
.into_iter()
.filter_map(|t| match t {
InputTarget::File { output, .. } => {
Some(output.file_name().unwrap().to_string_lossy().to_string())
}
InputTarget::Stdin { .. } => None,
})
.collect::<Vec<_>>();
assert!(outputs.contains(&"same-sanitized.txt".to_string()));
assert!(outputs.contains(&"same-sanitized-1.txt".to_string()));
}
#[test]
fn guided_entries_compile_balanced() {
let opts = GuidedOptions {
preset: GuidedPreset::Balanced,
domains: vec!["corp.internal".into()],
providers: vec![CloudProvider::Aws],
exclude_noise_ids: true,
formats: vec![GuidedFormat::YamlJson, GuidedFormat::Env],
};
let entries = build_guided_entries(&opts);
let (_patterns, warnings) = entries_to_patterns(&entries);
assert!(warnings.is_empty());
}
#[test]
fn guided_entries_include_gcp_custom_when_selected() {
let opts = GuidedOptions {
preset: GuidedPreset::Aggressive,
domains: vec![],
providers: vec![CloudProvider::Gcp],
exclude_noise_ids: false,
formats: vec![],
};
let entries = build_guided_entries(&opts);
assert!(entries
.iter()
.any(|e| e.category == "custom:gcp_service_account"));
assert!(entries.iter().any(|e| e.category == "custom:gcp_resource"));
}
#[test]
fn guided_profiles_use_known_processor_names() {
use sanitize_engine::processor::ProcessorRegistry;
let registry = ProcessorRegistry::with_builtins();
for preset in [
GuidedPreset::Balanced,
GuidedPreset::Aggressive,
GuidedPreset::WebApp,
GuidedPreset::Kubernetes,
GuidedPreset::Database,
] {
let opts = GuidedOptions {
preset,
domains: vec![],
providers: vec![],
exclude_noise_ids: false,
formats: vec![
GuidedFormat::YamlJson,
GuidedFormat::JsonLines,
GuidedFormat::Env,
GuidedFormat::Toml,
GuidedFormat::IniConf,
],
};
let profiles = build_guided_profiles(&opts);
for p in &profiles {
assert!(
registry.get(&p.processor).is_some(),
"preset {:?}: unknown processor '{}'",
preset,
p.processor
);
}
}
}
#[test]
fn guided_profiles_all_formats_produce_non_empty_field_rules() {
let opts = GuidedOptions {
preset: GuidedPreset::Balanced,
domains: vec![],
providers: vec![],
exclude_noise_ids: false,
formats: vec![
GuidedFormat::YamlJson,
GuidedFormat::JsonLines,
GuidedFormat::Env,
GuidedFormat::Toml,
GuidedFormat::IniConf,
],
};
let profiles = build_guided_profiles(&opts);
assert_eq!(
profiles.len(),
6,
"expected 6 profiles (yaml, json, jsonl, env, toml, ini)"
);
for p in &profiles {
assert!(
!p.fields.is_empty(),
"profile '{}' has no field rules",
p.processor
);
}
}
#[test]
fn guided_profiles_k8s_adds_secret_data_fields() {
let opts = GuidedOptions {
preset: GuidedPreset::Kubernetes,
domains: vec![],
providers: vec![],
exclude_noise_ids: false,
formats: vec![GuidedFormat::YamlJson],
};
let profiles = build_guided_profiles(&opts);
let yaml_profile = profiles.iter().find(|p| p.processor == "yaml").unwrap();
let patterns: Vec<&str> = yaml_profile
.fields
.iter()
.map(|f| f.pattern.as_str())
.collect();
assert!(
patterns.contains(&"data.*"),
"k8s yaml profile missing data.*"
);
assert!(
patterns.contains(&"stringData.*"),
"k8s yaml profile missing stringData.*"
);
}
#[test]
fn guided_profiles_jsonl_has_skip_invalid_option() {
let opts = GuidedOptions {
preset: GuidedPreset::Balanced,
domains: vec![],
providers: vec![],
exclude_noise_ids: false,
formats: vec![GuidedFormat::JsonLines],
};
let profiles = build_guided_profiles(&opts);
let jsonl = profiles.iter().find(|p| p.processor == "jsonl").unwrap();
assert_eq!(
jsonl.options.get("skip_invalid").map(|s| s.as_str()),
Some("true"),
"jsonl profile should have skip_invalid=true for mixed log files"
);
}
#[test]
fn guided_entries_k8s_includes_container_id_short() {
let opts = GuidedOptions {
preset: GuidedPreset::Kubernetes,
domains: vec![],
providers: vec![],
exclude_noise_ids: false,
formats: vec![],
};
let entries = build_guided_entries(&opts);
assert!(
entries
.iter()
.any(|e| e.label.as_deref() == Some("container_id_short")),
"k8s preset should include container_id_short"
);
}
#[test]
fn guided_entries_balanced_excludes_container_id_short() {
let opts = GuidedOptions {
preset: GuidedPreset::Balanced,
domains: vec![],
providers: vec![],
exclude_noise_ids: false,
formats: vec![],
};
let entries = build_guided_entries(&opts);
assert!(
!entries
.iter()
.any(|e| e.label.as_deref() == Some("container_id_short")),
"balanced preset should not include container_id_short"
);
}
fn real_file_cli() -> Cli {
let mut cli = Cli::try_parse_from(["sanitize", "placeholder"]).unwrap();
cli.input = vec![std::env::current_dir().unwrap().join("Cargo.toml")];
cli
}
#[test]
fn validate_args_rejects_invalid_format() {
let mut cli = real_file_cli();
cli.format = Some("notaformat".into());
let err = validate_args(&cli).unwrap_err();
assert!(err.contains("invalid --format"), "got: {err}");
}
#[test]
fn validate_args_rejects_invalid_log_format() {
let mut cli = real_file_cli();
cli.log_format = Some("xml".into());
let err = validate_args(&cli).unwrap_err();
assert!(err.contains("invalid --log-format"), "got: {err}");
}
#[test]
fn validate_args_rejects_zero_threads() {
let mut cli = real_file_cli();
cli.threads = Some(0);
let err = validate_args(&cli).unwrap_err();
assert!(err.contains("--threads must be"), "got: {err}");
}
#[test]
fn validate_args_rejects_password_without_encrypted_secrets() {
let mut cli = real_file_cli();
cli.password = true;
let err = validate_args(&cli).unwrap_err();
assert!(err.contains("--encrypted-secrets is not set"), "got: {err}");
}
#[test]
fn validate_args_allows_llm_with_output() {
let mut cli = real_file_cli();
cli.llm = Some("troubleshoot".into());
cli.output = Some(PathBuf::from("/tmp/out.txt"));
assert!(
validate_args(&cli).is_ok(),
"--llm + --output should be allowed for reference mode"
);
}
#[test]
fn validate_args_rejects_llm_with_dry_run() {
let mut cli = real_file_cli();
cli.llm = Some("troubleshoot".into());
cli.dry_run = true;
let err = validate_args(&cli).unwrap_err();
assert!(
err.contains("--llm and --dry-run cannot be combined"),
"got: {err}"
);
}
#[test]
fn validate_args_rejects_llm_with_nonexistent_template_path() {
let mut cli = real_file_cli();
cli.llm = Some("/nonexistent/template.txt".into());
let err = validate_args(&cli).unwrap_err();
assert!(err.contains("does not exist"), "got: {err}");
}
#[test]
fn validate_args_accepts_known_llm_templates() {
for name in ["troubleshoot", "review-config", "review-security"] {
let mut cli = real_file_cli();
cli.llm = Some(name.into());
assert!(
validate_args(&cli).is_ok(),
"built-in template '{}' should be accepted",
name
);
}
}
#[test]
fn build_default_patterns_returns_nonempty_set() {
let patterns = build_default_patterns();
assert!(
!patterns.is_empty(),
"built-in balanced patterns should not be empty"
);
let labels: Vec<_> = patterns.iter().map(|p| p.label()).collect();
assert!(labels.contains(&"email"), "expected email pattern");
assert!(
labels.contains(&"github_token"),
"expected github_token pattern"
);
assert!(
labels.contains(&"stripe_key"),
"expected stripe_key pattern"
);
}
#[test]
fn hook_script_pre_commit_scan_contains_marker_and_fail_on_match() {
let args = InstallHookArgs {
hook: HookType::PreCommit,
mode: HookMode::Scan,
global: false,
force: false,
remove: false,
app: None,
secrets_file: None,
dry_run: false,
};
let script = build_hook_script(&args);
assert!(script.contains(HOOK_MARKER), "marker must be present");
assert!(
script.contains("--dry-run --fail-on-match"),
"scan mode must use --dry-run --fail-on-match"
);
assert!(
script.contains("SANITIZE_SKIP"),
"escape hatch must be present"
);
assert!(
script.starts_with("#!/bin/sh"),
"must start with POSIX shebang"
);
}
#[test]
fn hook_script_pre_commit_sanitize_uses_output_dot() {
let args = InstallHookArgs {
hook: HookType::PreCommit,
mode: HookMode::Sanitize,
global: false,
force: false,
remove: false,
app: None,
secrets_file: None,
dry_run: false,
};
let script = build_hook_script(&args);
assert!(
script.contains("--output ."),
"sanitize mode must write output in place"
);
assert!(
script.contains("git add"),
"sanitize mode must re-stage files"
);
assert!(
!script.contains("--dry-run"),
"sanitize mode must not pass --dry-run"
);
}
#[test]
fn hook_script_pre_push_contains_while_read_loop() {
let args = InstallHookArgs {
hook: HookType::PrePush,
mode: HookMode::Scan,
global: false,
force: false,
remove: false,
app: Some("gitlab".into()),
secrets_file: None,
dry_run: false,
};
let script = build_hook_script(&args);
assert!(
script.contains("while IFS=' ' read -r"),
"pre-push must iterate stdin"
);
assert!(
script.contains("--app 'gitlab'"),
"app bundle must be quoted and forwarded"
);
}
#[test]
fn hook_flags_shell_quotes_paths_with_spaces() {
let args = InstallHookArgs {
hook: HookType::PreCommit,
mode: HookMode::Scan,
global: false,
force: false,
remove: false,
app: None,
secrets_file: Some(PathBuf::from("my secrets/file.yaml")),
dry_run: false,
};
let flags = build_hook_flags(&args);
assert!(
flags.contains("-s 'my secrets/file.yaml'"),
"space in path must be single-quoted: got {flags}"
);
}
#[test]
fn sh_quote_escapes_embedded_single_quotes() {
assert_eq!(sh_quote("it's a test"), "'it'\\''s a test'");
assert_eq!(sh_quote("normal"), "'normal'");
assert_eq!(sh_quote("a b c"), "'a b c'");
}
#[test]
fn remove_hook_deletes_file_when_entirely_ours() {
let dir = tempdir().unwrap();
let hook_path = dir.path().join("pre-commit");
let script = hook_script_pre_commit_scan("");
fs::write(&hook_path, &script).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
}
remove_hook(&hook_path, "pre-commit").expect("remove should succeed");
assert!(
!hook_path.exists(),
"file should be deleted when it is entirely our hook"
);
}
#[test]
fn remove_hook_strips_block_from_composite_hook() {
let dir = tempdir().unwrap();
let hook_path = dir.path().join("pre-commit");
let pre_existing = "#!/bin/sh\n# other team's linter\nnpm run lint\n";
let our_block = hook_script_pre_commit_scan("");
fs::write(&hook_path, format!("{pre_existing}{our_block}")).unwrap();
remove_hook(&hook_path, "pre-commit").expect("remove should succeed");
assert!(
hook_path.exists(),
"file should remain when other content is present"
);
let remaining = fs::read_to_string(&hook_path).unwrap();
assert!(
remaining.contains("npm run lint"),
"other hook content must be preserved"
);
assert!(!remaining.contains(HOOK_MARKER), "our marker must be gone");
}
#[test]
fn remove_hook_rejects_unrecognised_hook() {
let dir = tempdir().unwrap();
let hook_path = dir.path().join("pre-commit");
fs::write(&hook_path, "#!/bin/sh\necho hello\n").unwrap();
let result = remove_hook(&hook_path, "pre-commit");
assert!(
result.is_err(),
"should refuse to remove a hook we didn't install"
);
}
}