cratefield_core/
logging.rs1use sha2::{Digest, Sha256};
10use std::collections::BTreeMap;
11use tracing::field::{Field, Visit};
12
13#[must_use]
16pub fn is_secret_field(name: &str) -> bool {
17 let lowered = name.to_ascii_lowercase();
18 ["secret", "token", "key", "authorization", "password"]
19 .iter()
20 .any(|needle| lowered.contains(needle))
21}
22
23#[must_use]
25pub fn is_email_field(name: &str) -> bool {
26 let lowered = name.to_ascii_lowercase();
27 lowered.contains("email") || lowered == "subject"
28}
29
30#[must_use]
33pub fn subject_hash(value: &str) -> String {
34 use std::fmt::Write as _;
35 let digest = Sha256::digest(value.as_bytes());
36 let mut hex = String::with_capacity(12);
37 for byte in digest.iter().take(6) {
38 let _ = write!(hex, "{byte:02x}");
39 }
40 hex
41}
42
43#[must_use]
46pub fn redacted_value(name: &str, value: &str) -> String {
47 if is_secret_field(name) {
48 "[redacted]".to_owned()
49 } else if is_email_field(name) && value.contains('@') {
50 format!("subject_hash:{}", subject_hash(value))
51 } else {
52 value.to_owned()
53 }
54}
55
56#[derive(Debug, Default)]
60pub struct RedactingVisitor {
61 pub fields: BTreeMap<String, String>,
62}
63
64impl RedactingVisitor {
65 #[must_use]
66 pub fn new() -> Self {
67 Self::default()
68 }
69
70 fn record(&mut self, name: &str, value: &str) {
71 self.fields
72 .insert(name.to_owned(), redacted_value(name, value));
73 }
74}
75
76impl Visit for RedactingVisitor {
77 fn record_str(&mut self, field: &Field, value: &str) {
78 self.record(field.name(), value);
79 }
80
81 fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
82 self.record(field.name(), &format!("{value:?}"));
83 }
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89
90 #[test]
91 fn secret_names_are_detected_case_insensitively() {
92 for name in [
93 "authorization",
94 "Authorization",
95 "api_token",
96 "captchaToken",
97 "HARNESS_SECRET",
98 "kid_key",
99 "password",
100 ] {
101 assert!(is_secret_field(name), "{name}");
102 }
103 assert!(!is_secret_field("outcome"));
104 assert!(is_secret_field("idempotency_key"));
107 }
108
109 #[test]
110 fn subject_hash_is_twelve_hex_without_the_address() {
111 let hash = subject_hash("nick@example.com");
112 assert_eq!(hash.len(), 12);
113 assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
114 assert!(!hash.contains('@'));
115 assert_eq!(hash, subject_hash("nick@example.com"), "deterministic");
116 assert_ne!(hash, subject_hash("nick2@example.com"));
117 }
118
119 #[test]
120 fn visitor_rules_match_redacted_value() {
121 for (name, value) in [
122 ("authorization", "Bearer super-secret-token"),
123 ("token", "captcha-value"),
124 ("email", "nick@example.com"),
125 ("subject", "nick@example.com"),
126 ("outcome", "sent"),
127 ] {
128 let stored = redacted_value(name, value);
129 if is_secret_field(name) {
130 assert_eq!(stored, "[redacted]", "{name}");
131 } else if is_email_field(name) && value.contains('@') {
132 assert!(stored.starts_with("subject_hash:"), "{name}: {stored}");
133 assert!(!stored.contains('@'));
134 } else {
135 assert_eq!(stored, value, "{name}");
136 }
137 }
138 }
139
140 #[test]
141 fn non_email_values_in_email_fields_pass_through() {
142 assert_eq!(
143 redacted_value("email_domain", "factory0.ventures"),
144 "factory0.ventures"
145 );
146 }
147}
148
149use std::sync::OnceLock;
153
154type ErrorForwarder = fn(&str);
157
158static ERROR_FORWARDER: OnceLock<ErrorForwarder> = OnceLock::new();
159
160pub fn set_error_forwarder(forwarder: ErrorForwarder) {
174 let _ = ERROR_FORWARDER.set(forwarder);
175}
176
177pub(crate) fn forward_internal_error(line: &str) {
183 if let Some(forwarder) = ERROR_FORWARDER.get() {
184 forwarder(line);
185 }
186}
187
188#[cfg(test)]
189#[allow(clippy::disallowed_types)] mod forwarder_tests {
191 use super::*;
192 use std::sync::Mutex;
193
194 static CAPTURED: Mutex<Vec<String>> = Mutex::new(Vec::new());
195
196 fn capture(line: &str) {
197 CAPTURED.lock().unwrap().push(line.to_owned());
198 }
199
200 #[test]
201 fn an_installed_forwarder_receives_the_line() {
202 set_error_forwarder(capture);
205 forward_internal_error("database error mapped to internal problem: boom");
206 assert!(
207 CAPTURED
208 .lock()
209 .unwrap()
210 .iter()
211 .any(|line| line.contains("boom")),
212 "the installed forwarder should have received the diagnostic"
213 );
214 }
215
216 #[test]
217 fn forwarding_without_a_sink_is_a_noop() {
218 forward_internal_error("ignored when no sink or captured when set");
221 }
222}