use super::*;
fn temp_home_and_project(tag: &str) -> (PathBuf, PathBuf) {
let root = std::env::temp_dir().join(format!("redacted-config-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let (home, cwd) = (root.join("home"), root.join("proj"));
fs::create_dir_all(home.join(".config/redacted")).unwrap();
fs::create_dir_all(&cwd).unwrap();
(home, cwd)
}
#[test]
fn missing_files_give_defaults() {
let (home, cwd) = temp_home_and_project("none");
let config = load(Some(&home), Some(&cwd)).unwrap();
assert!(
config.disabled_patterns.is_empty()
&& config.allowed_keys.is_empty()
&& !config.ignore_internal_tools
);
assert!(
load_engine(Some(&home), Some(&cwd))
.unwrap()
.patterns
.is_empty()
);
}
#[test]
fn project_merges_over_global() {
let (home, cwd) = temp_home_and_project("merge");
fs::write(
home.join(".config/redacted/config.yaml"),
"whitelist: [jwt]\nallow: [A]\n",
)
.unwrap();
fs::write(
cwd.join(".redacted.yaml"),
"allow: [B]\nignore_internal_tools: true\n",
)
.unwrap();
let config = load(Some(&home), Some(&cwd)).unwrap();
assert_eq!(config.disabled_patterns, ["jwt"]);
assert_eq!(config.allowed_keys, ["A", "B"]);
assert!(config.ignore_internal_tools);
}
#[test]
fn project_override_drops_global() {
let (home, cwd) = temp_home_and_project("override");
fs::write(
home.join(".config/redacted/config.yaml"),
"whitelist: [jwt]\n",
)
.unwrap();
fs::write(cwd.join(".redacted.yaml"), "override: true\nallow: [B]\n").unwrap();
let config = load(Some(&home), Some(&cwd)).unwrap();
assert!(config.disabled_patterns.is_empty());
assert_eq!(config.allowed_keys, ["B"]);
}
#[test]
fn a_malformed_global_file_is_an_error_naming_it_because_defaults_would_drop_learned_secrets() {
let (home, cwd) = temp_home_and_project("bad-global");
let path = home.join(".config/redacted/config.yaml");
fs::write(&path, "learned: [\n").unwrap();
let error = load(Some(&home), Some(&cwd)).unwrap_err().to_string();
assert!(
error.starts_with(&format!("{}: ", path.display())),
"{error}"
);
assert!(
error.contains("line"),
"the serde message points at a line: {error}"
);
}
#[test]
fn a_malformed_project_file_is_an_error_naming_it_instead_of_running_with_the_global_alone() {
let (home, cwd) = temp_home_and_project("bad-project");
fs::write(
home.join(".config/redacted/config.yaml"),
"whitelist: [jwt]\n",
)
.unwrap();
let path = cwd.join(".redacted.yaml");
fs::write(&path, "whitelist: {not: a list}\n").unwrap();
let error = load(Some(&home), Some(&cwd)).unwrap_err().to_string();
assert!(
error.starts_with(&format!("{}: ", path.display())),
"{error}"
);
}
#[test]
fn a_malformed_engine_file_is_an_error_naming_it_because_its_patterns_would_vanish() {
let (home, cwd) = temp_home_and_project("bad-engine");
let path = home.join(".config/redacted/engine.yml");
fs::write(&path, "patterns: {not: a list}\n").unwrap();
let error = load_engine(Some(&home), Some(&cwd))
.unwrap_err()
.to_string();
assert!(
error.starts_with(&format!("{}: ", path.display())),
"{error}"
);
}
#[test]
fn an_empty_or_comment_only_file_loads_as_defaults_because_config_edit_creates_an_empty_one() {
let (home, cwd) = temp_home_and_project("empty");
let path = home.join(".config/redacted/config.yaml");
for text in ["", "# nothing learned yet\n"] {
fs::write(&path, text).unwrap();
let config = load(Some(&home), Some(&cwd)).unwrap();
assert!(config.learned.is_empty() && !config.heuristic.enabled);
}
}
#[test]
fn v1_file_loads_with_no_learned_and_heuristic_off() {
let (home, cwd) = temp_home_and_project("v1");
fs::write(
home.join(".config/redacted/config.yaml"),
"whitelist: [jwt]\n",
)
.unwrap();
let config = load(Some(&home), Some(&cwd)).unwrap();
assert_eq!(config.version, 0);
assert!(config.learned.is_empty() && !config.heuristic.enabled);
assert_eq!(config.disabled_patterns, ["jwt"]);
}
const V2: &str = "version: 2\nlearned:\n- {name: stripe_key, sha256: ab12, len: 32, shape: 'x'}\n- {name: db_pass, sha256: cd34, len: 9}\nheuristic: {enabled: true, min_length: 20, max_length: 64, min_char_classes: 2, min_entropy: 3.0}\n";
#[test]
fn v2_global_file_loads_learned_and_heuristic() {
let (home, cwd) = temp_home_and_project("v2");
fs::write(home.join(".config/redacted/config.yaml"), V2).unwrap();
let config = load(Some(&home), Some(&cwd)).unwrap();
assert_eq!(config.version, 2);
assert_eq!(
config.learned[0],
LearnedSecret {
name: "stripe_key".into(),
sha256: "ab12".into(),
byte_len: 32,
shape: Some("x".into())
}
);
assert_eq!(config.learned[1].shape, None);
let heuristic = &config.heuristic;
assert!(heuristic.enabled);
assert_eq!(heuristic.min_length, 20);
assert_eq!((heuristic.max_length, heuristic.min_char_classes), (64, 2));
assert_eq!(heuristic.min_entropy, 3.0);
}
#[test]
fn heuristic_thresholds_default_per_field_and_explicit_values_win() {
let heuristic: HeuristicConfig = serde_yaml::from_str("{enabled: true}").unwrap();
assert!(heuristic.enabled);
let defaults = (
heuristic.min_length,
heuristic.max_length,
heuristic.min_char_classes,
heuristic.min_entropy,
);
assert_eq!(defaults, (16, 128, 3, 3.5));
let yaml = "{min_length: 20, max_length: 64, min_char_classes: 2, min_entropy: 3.0}";
let heuristic: HeuristicConfig = serde_yaml::from_str(yaml).unwrap();
let explicit = (
heuristic.min_length,
heuristic.max_length,
heuristic.min_char_classes,
heuristic.min_entropy,
);
assert_eq!(explicit, (20, 64, 2, 3.0));
}
#[test]
fn learned_and_heuristic_come_from_the_global_file_only() {
let (home, cwd) = temp_home_and_project("learned-global");
fs::write(home.join(".config/redacted/config.yaml"), V2).unwrap();
let project = "learned: [{name: leak, sha256: ff, len: 2}]\nheuristic: {enabled: false}\n";
fs::write(cwd.join(".redacted.yaml"), project).unwrap();
let config = load(Some(&home), Some(&cwd)).unwrap();
let names: Vec<_> = config.learned.iter().map(|l| l.name.as_str()).collect();
assert_eq!(names, ["stripe_key", "db_pass"]);
assert!(config.heuristic.enabled);
assert_eq!(project_learned_count(&cwd), 1);
fs::write(
cwd.join(".redacted.yaml"),
format!("override: true\n{project}"),
)
.unwrap();
let config = load(Some(&home), Some(&cwd)).unwrap();
assert_eq!(config.learned.len(), 2);
assert!(config.heuristic.enabled);
fs::remove_file(home.join(".config/redacted/config.yaml")).unwrap();
assert!(load(Some(&home), Some(&cwd)).unwrap().learned.is_empty());
}
#[test]
fn pii_toggles_are_off_unless_the_global_file_turns_each_one_on() {
let (home, cwd) = temp_home_and_project("pii-global");
let config = load(Some(&home), Some(&cwd)).unwrap();
assert!(!config.pii.email && !config.pii.phone);
fs::write(
home.join(".config/redacted/config.yaml"),
"pii: {email: true}\n",
)
.unwrap();
fs::write(
cwd.join(".redacted.yaml"),
"pii: {email: false, phone: true}\n",
)
.unwrap();
let config = load(Some(&home), Some(&cwd)).unwrap();
assert!(config.pii.email && !config.pii.phone);
}
#[test]
fn vendor_only_means_no_learned_entries_and_heuristic_off() {
let one = LearnedSecret::default();
let heuristic_on = HeuristicConfig {
enabled: true,
..Default::default()
};
assert!(is_vendor_only(&Config::default()));
assert!(!is_vendor_only(&Config {
learned: vec![one],
..Default::default()
}));
assert!(!is_vendor_only(&Config {
heuristic: heuristic_on,
..Default::default()
}));
assert!(VENDOR_ONLY_NOTICE.contains("redacted init --env PATH"));
}
#[test]
fn engine_yml_from_0_7_with_keywords_and_heuristic_still_loads() {
let (home, cwd) = temp_home_and_project("engine-v1");
let old = "heuristic:\n min_length: 16\n min_entropy: 3.5\nkeywords:\n - MONGO\nvalue_safe_char: '[^\\s]'\nallow_values: ['^svc_']\npatterns:\n - {name: g, regex: 'g_x'}\n";
fs::write(home.join(".config/redacted/engine.yml"), old).unwrap();
let engine_config = load_engine(Some(&home), Some(&cwd)).unwrap();
assert_eq!(engine_config.patterns.len(), 1);
assert_eq!(engine_config.allow_values, ["^svc_"]);
let scrubber = crate::scrub::Scrubber::new(&Config::default(), &engine_config).unwrap();
assert_eq!(scrubber.scrub("MONGO_URI_PART=plainvalue").count, 0);
}
#[test]
fn engine_lists_concatenate_and_project_override_drops_global() {
let (home, cwd) = temp_home_and_project("engine");
fs::write(
home.join(".config/redacted/engine.yml"),
"keywords: [FOO]\nheuristic: {min_length: 20}\npatterns: [{name: g, regex: 'g_x'}]\n",
)
.unwrap();
fs::write(
cwd.join(".redacted.engine.yml"),
"allow_values: ['^ok']\npatterns: [{name: p, regex: 'p_x'}]\n",
)
.unwrap();
let engine_config = load_engine(Some(&home), Some(&cwd)).unwrap();
let names: Vec<_> = engine_config
.patterns
.iter()
.map(|p| p.name.as_str())
.collect();
assert_eq!(names, ["g", "p"]);
assert_eq!(engine_config.allow_values, ["^ok"]);
fs::write(
cwd.join(".redacted.engine.yml"),
"override: true\npatterns: [{name: p, regex: 'p_x'}]\n",
)
.unwrap();
assert_eq!(
load_engine(Some(&home), Some(&cwd)).unwrap().patterns.len(),
1
);
}