1use anyhow::{Context, Result, anyhow, bail};
15use regex::Regex;
16use std::path::{Path, PathBuf};
17use std::sync::{LazyLock, OnceLock};
18
19#[derive(Debug)]
23pub struct RedactRule {
24 pub re: Regex,
25 pub replacement: String,
26}
27
28#[derive(Debug)]
30pub struct RedactRules {
31 rules: Vec<RedactRule>,
32}
33
34static RULES: OnceLock<RedactRules> = OnceLock::new();
36
37static DEFAULT_RULES: LazyLock<RedactRules> = LazyLock::new(|| RedactRules {
39 rules: RedactRules::compile_defaults(),
40});
41
42fn defaults() -> &'static [(&'static str, &'static str)] {
45 &[
46 (r"(?i)(AKIA[0-9A-Z]{16})", "[REDACTED:aws-key]"),
47 (
48 r"(?i)(aws_secret_access_key|aws_secret_key)\s*[=:]\s*\S+",
49 "$1=[REDACTED:aws-secret]",
50 ),
51 (
52 r"ghp_[A-Za-z0-9]{36,}|gho_[A-Za-z0-9]{36,}|ghs_[A-Za-z0-9]{36,}|ghr_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,}",
53 "[REDACTED:github-token]",
54 ),
55 (r"glpat-[A-Za-z0-9\-_]{20,}", "[REDACTED:gitlab-token]"),
56 (r"xox[bpsar]-[A-Za-z0-9\-]{24,}", "[REDACTED:slack-token]"),
57 (
58 r#"(?i)(api[_-]?key|api[_-]?secret|api[_-]?token|access[_-]?token|secret[_-]?key|auth[_-]?token|private[_-]?key)\s*[=:]\s*['"]?([A-Za-z0-9/+=\-_.]{16,})['"]?"#,
59 "$1=[REDACTED]",
60 ),
61 (r"(?i)(Bearer\s+)[A-Za-z0-9\-_.~+/]+=*", "${1}[REDACTED:bearer]"),
62 (
63 r"eyJ[A-Za-z0-9\-_]+\.eyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_.+/=]+",
64 "[REDACTED:jwt]",
65 ),
66 (
67 r"(?s)-----BEGIN[A-Z ]*PRIVATE KEY-----.*?-----END[A-Z ]*PRIVATE KEY-----",
68 "[REDACTED:private-key]",
69 ),
70 (r"(://[^:]+:)[^@\s]+(@)", "${1}[REDACTED]${2}"),
71 (r"(?i)(HEROKU_API_KEY)\s*[=:]\s*\S+", "$1=[REDACTED:heroku]"),
72 (
73 r#"(?i)(secret|token|password|passwd|credential)\s*[=:]\s*['"]?([0-9a-f]{32,})['"]?"#,
74 "$1=[REDACTED]",
75 ),
76 ]
77}
78
79impl RedactRules {
80 fn compile_defaults() -> Vec<RedactRule> {
81 defaults()
82 .iter()
83 .map(|(p, r)| RedactRule {
84 re: Regex::new(p).expect("built-in redact pattern must compile"),
85 replacement: r.to_string(),
86 })
87 .collect()
88 }
89
90 fn parse_file(path: &Path) -> Result<(Vec<RedactRule>, bool)> {
94 let text = std::fs::read_to_string(path)
95 .with_context(|| format!("reading {}", path.display()))?;
96 let mut rules = Vec::new();
97 let mut no_defaults = false;
98 for (i, raw) in text.lines().enumerate() {
99 let line = raw.trim();
100 if line.is_empty() || line.starts_with('#') {
101 continue;
102 }
103 if line == "!no-defaults" {
104 no_defaults = true;
105 continue;
106 }
107 let Some((pat, repl)) = line.split_once(" => ") else {
108 bail!(
109 "{}:{}: expected `<regex> => <replacement>`, got `{}`",
110 path.display(),
111 i + 1,
112 line
113 );
114 };
115 let pat = pat.trim();
116 if pat.is_empty() {
117 bail!("{}:{}: empty regex", path.display(), i + 1);
118 }
119 let re = Regex::new(pat).map_err(|e| {
120 anyhow!(
121 "{}:{}: invalid regex `{}`: {}",
122 path.display(),
123 i + 1,
124 pat,
125 e
126 )
127 })?;
128 rules.push(RedactRule {
129 re,
130 replacement: repl.trim().to_string(),
131 });
132 }
133 Ok((rules, no_defaults))
134 }
135
136 pub fn load(global: Option<&Path>, project: Option<&Path>) -> Result<Self> {
139 let mut user_rules = Vec::new();
140 let mut no_defaults = false;
141 for path in [global, project].into_iter().flatten() {
142 if path.is_file() {
143 let (mut r, nd) = Self::parse_file(path)?;
144 no_defaults |= nd;
145 user_rules.append(&mut r);
146 }
147 }
148 let mut rules = if no_defaults {
149 Vec::new()
150 } else {
151 Self::compile_defaults()
152 };
153 rules.append(&mut user_rules);
154 Ok(Self { rules })
155 }
156
157 pub fn apply(&self, text: &str) -> String {
159 let mut out = text.to_string();
160 for rule in &self.rules {
161 out = rule
162 .re
163 .replace_all(&out, rule.replacement.as_str())
164 .into_owned();
165 }
166 out
167 }
168
169 pub fn len(&self) -> usize {
170 self.rules.len()
171 }
172
173 pub fn is_empty(&self) -> bool {
174 self.rules.is_empty()
175 }
176}
177
178pub fn paths(home_dir: &Path, config_path: Option<&Path>) -> (PathBuf, Option<PathBuf>) {
181 let global = home_dir.join("redact.conf");
182 let project = config_path
183 .and_then(|p| p.parent())
184 .map(|d| d.join("redact.conf"));
185 (global, project)
186}
187
188pub fn init(global: Option<&Path>, project: Option<&Path>) {
192 let rules = match RedactRules::load(global, project) {
193 Ok(r) => r,
194 Err(e) => {
195 eprintln!("[lowfat] redact.conf error: {e:#}");
196 eprintln!("[lowfat] falling back to built-in redaction defaults");
197 RedactRules {
198 rules: RedactRules::compile_defaults(),
199 }
200 }
201 };
202 let _ = RULES.set(rules);
203}
204
205pub fn redact(text: &str) -> String {
208 RULES.get().unwrap_or(&DEFAULT_RULES).apply(text)
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 fn write(dir: &Path, name: &str, body: &str) -> PathBuf {
216 let p = dir.join(name);
217 std::fs::write(&p, body).unwrap();
218 p
219 }
220
221 #[test]
222 fn parse_basic_rule() {
223 let dir = tempfile::tempdir().unwrap();
224 let f = write(dir.path(), "redact.conf", "# a comment\nFOO-[0-9]+ => [X]\n");
225 let (rules, nd) = RedactRules::parse_file(&f).unwrap();
226 assert_eq!(rules.len(), 1);
227 assert!(!nd);
228 }
229
230 #[test]
231 fn malformed_regex_errors() {
232 let dir = tempfile::tempdir().unwrap();
233 let f = write(dir.path(), "redact.conf", "FOO( => [X]\n");
234 let err = format!("{:#}", RedactRules::parse_file(&f).unwrap_err());
235 assert!(err.contains("invalid regex"), "got: {err}");
236 }
237
238 #[test]
239 fn missing_separator_errors() {
240 let dir = tempfile::tempdir().unwrap();
241 let f = write(dir.path(), "redact.conf", "no separator here\n");
242 assert!(RedactRules::parse_file(&f).is_err());
243 }
244
245 #[test]
246 fn layering_defaults_plus_custom() {
247 let dir = tempfile::tempdir().unwrap();
248 let g = write(dir.path(), "g.conf", "EMP-[0-9]{3} => [EMP]\n");
249 let rs = RedactRules::load(Some(&g), None).unwrap();
250 let out = rs.apply("key AKIA0000000000000000 staff EMP-123");
251 assert!(out.contains("[REDACTED:aws-key]"), "default applied: {out}");
252 assert!(out.contains("[EMP]"), "custom applied: {out}");
253 }
254
255 #[test]
256 fn no_defaults_directive() {
257 let dir = tempfile::tempdir().unwrap();
258 let g = write(dir.path(), "g.conf", "!no-defaults\nEMP-[0-9]{3} => [EMP]\n");
259 let rs = RedactRules::load(Some(&g), None).unwrap();
260 let out = rs.apply("AKIA0000000000000000 EMP-123");
261 assert!(out.contains("AKIA0000000000000000"), "default dropped: {out}");
262 assert!(out.contains("[EMP]"));
263 }
264
265 #[test]
266 fn project_layers_over_global() {
267 let dir = tempfile::tempdir().unwrap();
268 let g = write(dir.path(), "g.conf", "!no-defaults\nGLOBAL-X => [G]\n");
269 let p = write(dir.path(), "p.conf", "PROJ-Y => [P]\n");
270 let rs = RedactRules::load(Some(&g), Some(&p)).unwrap();
271 let out = rs.apply("GLOBAL-X and PROJ-Y");
272 assert_eq!(out, "[G] and [P]");
273 }
274
275 #[test]
276 fn capture_group_partial_mask() {
277 let dir = tempfile::tempdir().unwrap();
278 let f = write(
279 dir.path(),
280 "c.conf",
281 "!no-defaults\n(TOK_)[A-Z0-9]+ => ${1}[REDACTED]\n",
282 );
283 let rs = RedactRules::load(Some(&f), None).unwrap();
284 assert_eq!(rs.apply("TOK_ABC123"), "TOK_[REDACTED]");
285 }
286
287 #[test]
288 fn missing_file_keeps_defaults() {
289 let rs = RedactRules::load(Some(Path::new("/no/such/redact.conf")), None).unwrap();
290 assert!(!rs.is_empty());
291 }
292}