use std::collections::HashSet;
use std::path::PathBuf;
use serde::Deserialize;
type ParseFn = fn(&str, &mut HashSet<String>, &mut Vec<String>);
fn parse_lines(content: &str, seen: &mut HashSet<String>, sink: &mut Vec<String>) {
for line in content.lines() {
let l = line.trim();
if l.is_empty() || l.starts_with('#') {
continue;
}
if seen.insert(l.to_string()) {
sink.push(l.to_string());
}
}
}
fn parse_wordlist(content: &str, seen: &mut HashSet<String>, sink: &mut Vec<String>) {
for line in content.lines() {
let l = line.trim();
if l.is_empty() || l.starts_with('#') {
continue;
}
let l = l.strip_prefix('/').unwrap_or(l).to_lowercase();
if !l.is_empty() && seen.insert(l.clone()) {
sink.push(l);
}
}
}
fn roots(kind: &str, extra: &[PathBuf]) -> Vec<PathBuf> {
let mut v = Vec::new();
if let Ok(d) = std::env::var("GOSSAN_RULES_DIR") {
let d = PathBuf::from(d);
v.push(d.join(kind));
v.push(d);
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
v.push(dir.join("rules").join(kind));
v.push(dir.join(kind));
}
}
v.push(PathBuf::from("rules").join(kind));
v.extend_from_slice(extra);
v
}
#[must_use]
pub fn dropin_files(kind: &str, extra_roots: &[PathBuf], ext: &str) -> Vec<PathBuf> {
let mut out = Vec::new();
for root in roots(kind, extra_roots) {
if let Ok(rd) = std::fs::read_dir(&root) {
for entry in rd.flatten() {
let p = entry.path();
if p.extension().and_then(|x| x.to_str()) == Some(ext) {
out.push(p);
}
}
}
}
out
}
fn load_with(
parse: ParseFn,
embedded: &[&str],
kind: &str,
custom: Option<&str>,
extra_roots: &[PathBuf],
) -> Vec<String> {
if let Some(path) = custom {
if let Ok(c) = std::fs::read_to_string(path) {
let mut seen = HashSet::new();
let mut out = Vec::new();
parse(&c, &mut seen, &mut out);
if !out.is_empty() {
tracing::info!(count = out.len(), path, kind, "custom Tier-B list (replaces default)");
return out;
}
}
tracing::warn!(path, kind, "custom Tier-B list unreadable/empty - using default union");
}
let mut seen = HashSet::new();
let mut out = Vec::new();
for e in embedded {
parse(e, &mut seen, &mut out);
}
let base = out.len();
let mut drop_in_files = 0usize;
for p in dropin_files(kind, extra_roots, "txt") {
if let Ok(c) = std::fs::read_to_string(&p) {
let before = out.len();
parse(&c, &mut seen, &mut out);
if out.len() > before {
drop_in_files += 1;
}
}
}
tracing::info!(
total = out.len(),
embedded = base,
drop_in_files,
kind,
"Tier-B list (embedded baseline ∪ drop-ins)"
);
out
}
#[must_use]
pub fn load_wordlist(
embedded: &[&str],
kind: &str,
custom: Option<&str>,
extra_roots: &[PathBuf],
) -> Vec<String> {
load_with(parse_wordlist, embedded, kind, custom, extra_roots)
}
#[must_use]
pub fn load_lines(
embedded: &[&str],
kind: &str,
custom: Option<&str>,
extra_roots: &[PathBuf],
) -> Vec<String> {
load_with(parse_lines, embedded, kind, custom, extra_roots)
}
#[derive(Debug, Default, Deserialize)]
struct WordlistToml {
#[serde(default)]
words: Vec<String>,
#[serde(default)]
entry: Vec<WordEntry>,
#[serde(default)]
path: Vec<WordEntry>,
}
#[derive(Debug, Deserialize)]
struct WordEntry {
#[serde(alias = "value", alias = "path", alias = "entry")]
value: String,
}
#[must_use]
pub fn load_wordlist_toml(
embedded: &[&str],
kind: &str,
custom: Option<&str>,
extra_roots: &[PathBuf],
) -> Vec<String> {
if let Some(path) = custom {
if let Ok(c) = std::fs::read_to_string(path) {
let mut seen = HashSet::new();
let mut out = Vec::new();
parse_wordlist(&c, &mut seen, &mut out);
if !out.is_empty() {
tracing::info!(count = out.len(), path, kind, "custom Tier-B list (replaces default)");
return out;
}
}
tracing::warn!(path, kind, "custom Tier-B list unreadable/empty - using default union");
}
let mut seen = HashSet::new();
let mut out = Vec::new();
for e in embedded {
if let Ok(parsed) = toml::from_str::<WordlistToml>(e) {
for w in &parsed.words {
let l = w.trim().strip_prefix('/').unwrap_or(w).to_lowercase();
if !l.is_empty() && seen.insert(l.clone()) { out.push(l); }
}
for ent in parsed.entry.iter().chain(&parsed.path) {
let w = &ent.value;
let l = w.trim().strip_prefix('/').unwrap_or(w).to_lowercase();
if !l.is_empty() && seen.insert(l.clone()) { out.push(l); }
}
}
}
let base = out.len();
let mut drop_in_files = 0usize;
for ext in ["toml", "txt"] {
for p in dropin_files(kind, extra_roots, ext) {
if let Ok(c) = std::fs::read_to_string(&p) {
let before = out.len();
if ext == "toml" {
if let Ok(parsed) = toml::from_str::<WordlistToml>(&c) {
for w in &parsed.words {
let l = w.trim().strip_prefix('/').unwrap_or(w).to_lowercase();
if !l.is_empty() && seen.insert(l.clone()) { out.push(l); }
}
for ent in parsed.entry.iter().chain(&parsed.path) {
let w = &ent.value;
let l = w.trim().strip_prefix('/').unwrap_or(w).to_lowercase();
if !l.is_empty() && seen.insert(l.clone()) { out.push(l); }
}
}
} else {
parse_wordlist(&c, &mut seen, &mut out);
}
if out.len() > before { drop_in_files += 1; }
}
}
}
tracing::info!(total = out.len(), embedded = base, drop_in_files, kind, "Tier-B wordlist (toml baseline ∪ drop-ins)");
out
}
#[cfg(test)]
mod tests {
use super::*;
const A: &str = "# c\n/api\nDEV\napi\n\n";
const B: &str = "staging\nDEV\nvpn\n";
#[test]
fn parse_wordlist_strips_comment_slash_case_and_dedups() {
let mut s = HashSet::new();
let mut o = Vec::new();
parse_wordlist(A, &mut s, &mut o);
assert_eq!(o, vec!["api", "dev"], "got {o:?}");
}
#[test]
fn parse_lines_preserves_case_and_slash_unlike_wordlist() {
let rx = r#"["'`](/(?:api|Admin|GraphQL)[^"'`\s]{0,200})["'`]"#;
let src = format!("# js endpoint patterns\n{rx}\n{rx}\n");
let mut s = HashSet::new();
let mut verbatim = Vec::new();
parse_lines(&src, &mut s, &mut verbatim);
assert_eq!(verbatim, vec![rx.to_string()], "lines: verbatim + deduped");
let mut s2 = HashSet::new();
let mut mangled = Vec::new();
parse_wordlist(&src, &mut s2, &mut mangled);
assert_ne!(
mangled, verbatim,
"wordlist parse MUST corrupt a regex (proves the split is load-bearing)"
);
assert!(
mangled[0].contains("admin") && mangled[0].contains("graphql"),
"wordlist parse lowercased the alternation: {:?}",
mangled[0]
);
}
#[test]
fn multi_embedded_unions_and_dedups_cross_set() {
let w = load_wordlist(&[A, B], "kind-xyz-none", None, &[]);
assert!(w.contains(&"api".to_string()));
assert!(w.contains(&"staging".to_string()));
assert!(w.contains(&"vpn".to_string()));
assert_eq!(w.iter().filter(|x| *x == "dev").count(), 1, "cross-set dedup");
}
#[test]
fn embedded_is_the_floor_with_no_dropins() {
let w = load_wordlist(&[A], "definitely-no-such-kind-zzz", None, &[]);
assert!(w.contains(&"api".to_string()) && w.len() >= 2);
}
#[test]
fn custom_path_replaces_all_embedded() {
let f = tempfile::NamedTempFile::new().unwrap();
std::fs::write(f.path(), "only-this\nonly-this\n").unwrap();
let w = load_wordlist(&[A, B], "k", Some(&f.path().to_string_lossy()), &[]);
assert_eq!(w, vec!["only-this"], "explicit list REPLACES, deduped");
}
#[test]
fn dropped_file_extends_without_shadowing() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("wl")).unwrap();
std::fs::write(dir.path().join("wl").join("x.txt"), "zzz-core-tierb-sentinel\n").unwrap();
std::env::set_var("GOSSAN_RULES_DIR", dir.path());
let w = load_wordlist(&[A], "wl", None, &[]);
std::env::remove_var("GOSSAN_RULES_DIR");
assert!(w.iter().any(|x| x == "zzz-core-tierb-sentinel"), "drop-in extends");
assert!(w.contains(&"api".to_string()), "drop-in must NOT shadow embedded");
}
#[test]
fn dropin_files_discovers_by_extension_under_resolved_root() {
let dir = tempfile::tempdir().unwrap();
let kd = dir.path().join("packs");
std::fs::create_dir_all(&kd).unwrap();
std::fs::write(kd.join("a.toml"), "x=1\n").unwrap();
std::fs::write(kd.join("b.toml"), "y=2\n").unwrap();
std::fs::write(kd.join("note.txt"), "ignore me\n").unwrap();
std::env::set_var("GOSSAN_RULES_DIR", dir.path());
let toml = dropin_files("packs", &[], "toml");
std::env::remove_var("GOSSAN_RULES_DIR");
assert_eq!(toml.len(), 2, "two .toml found, .txt excluded: {toml:?}");
assert!(toml.iter().all(|p| p.extension().unwrap() == "toml"));
}
#[test]
fn load_lines_unions_verbatim_patterns() {
let base = r#"fetch\(["'`]([^"'`\s]{1,200})["'`]"#;
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("js-endpoints")).unwrap();
std::fs::write(
dir.path().join("js-endpoints").join("extra.txt"),
"# community pattern\n\\.AjaxCall\\(\"(/[A-Z][^\"]+)\"\n",
)
.unwrap();
std::env::set_var("GOSSAN_RULES_DIR", dir.path());
let w = load_lines(&[base], "js-endpoints", None, &[]);
std::env::remove_var("GOSSAN_RULES_DIR");
assert!(w.contains(&base.to_string()), "embedded regex verbatim");
assert!(
w.iter().any(|p| p == r#"\.AjaxCall\("(/[A-Z][^"]+)""#),
"drop-in regex verbatim (uppercase + `/` intact): {w:?}"
);
}
}