1use hmac::{Hmac, KeyInit, Mac};
18use sha2::Sha256;
19use std::collections::BTreeMap;
20use std::sync::OnceLock;
21use tracing::field::{Field, Visit};
22
23static LOG_PSEUDONYM_KEY: OnceLock<Vec<u8>> = OnceLock::new();
26
27const PSEUDONYM_DOMAIN: &[u8] = b"cratefield/log-pseudonym/v1\x00";
31
32const UNKEYED_PSEUDONYM: &str = "000000000000";
43
44pub fn set_log_pseudonym_key(key: &[u8]) {
53 let _ = LOG_PSEUDONYM_KEY.set(key.to_vec());
54}
55
56#[must_use]
59pub fn is_secret_field(name: &str) -> bool {
60 let lowered = name.to_ascii_lowercase();
61 ["secret", "token", "key", "authorization", "password"]
62 .iter()
63 .any(|needle| lowered.contains(needle))
64}
65
66#[must_use]
68pub fn is_email_field(name: &str) -> bool {
69 let lowered = name.to_ascii_lowercase();
70 lowered.contains("email") || lowered == "subject"
71}
72
73#[must_use]
80pub fn subject_hash(value: &str) -> String {
81 pseudonym_hex(LOG_PSEUDONYM_KEY.get().map(Vec::as_slice), value)
82}
83
84fn pseudonym_hex(key: Option<&[u8]>, value: &str) -> String {
88 use std::fmt::Write as _;
89 let digest = key.and_then(|secret| {
93 Hmac::<Sha256>::new_from_slice(secret).ok().map(|mut mac| {
94 mac.update(PSEUDONYM_DOMAIN);
95 mac.update(value.as_bytes());
96 mac.finalize().into_bytes()
97 })
98 });
99 let Some(digest) = digest else {
100 return UNKEYED_PSEUDONYM.to_owned();
101 };
102 let mut hex = String::with_capacity(12);
103 for byte in digest.iter().take(6) {
104 let _ = write!(hex, "{byte:02x}");
105 }
106 hex
107}
108
109#[must_use]
114pub fn redacted_value(name: &str, value: &str) -> String {
115 if is_secret_field(name) {
116 "[redacted]".to_owned()
117 } else if is_email_field(name) && value.contains('@') {
118 format!("subject_hash:{}", subject_hash(value))
119 } else {
120 scrub_text(value)
121 }
122}
123
124const REDACTED: &str = "[redacted]";
129
130fn url_body_byte(b: u8) -> bool {
136 !matches!(
137 b,
138 b' ' | b'\t'
139 | b'\n'
140 | b'\r'
141 | b'"'
142 | b'\''
143 | b'<'
144 | b'>'
145 | b'`'
146 | b','
147 | b';'
148 | b')'
149 | b'}'
150 | b'\\'
151 | b'|'
152 )
153}
154
155fn base64url_byte(b: u8) -> bool {
156 b.is_ascii_alphanumeric() || b == b'-' || b == b'_'
157}
158
159fn email_local_byte(b: u8) -> bool {
160 b.is_ascii_alphanumeric()
161 || matches!(
162 b,
163 b'.' | b'!'
164 | b'#'
165 | b'$'
166 | b'%'
167 | b'&'
168 | b'\''
169 | b'*'
170 | b'+'
171 | b'-'
172 | b'/'
173 | b'='
174 | b'?'
175 | b'^'
176 | b'_'
177 | b'`'
178 | b'{'
179 | b'|'
180 | b'}'
181 | b'~'
182 )
183}
184
185fn email_domain_byte(b: u8) -> bool {
186 b.is_ascii_alphanumeric() || b == b'.' || b == b'-'
187}
188
189#[must_use]
215pub fn scrub_text(value: &str) -> String {
216 let urls = scrub_urls(value);
217 let paths = scrub_path_queries(&urls);
218 let tokens = scrub_dotted_tokens(&paths);
219 let bearer = scrub_bearer(&tokens);
220 scrub_emails(&bearer)
221}
222
223#[must_use]
259pub fn scrub_request_url(message: &str, url: &str) -> String {
260 let Some((origin, target)) = origin_and_target(url) else {
261 return message.to_owned();
262 };
263 let reduced = message.replace(url, &origin);
264 if target.len() > 1 {
266 reduced.replace(target, REDACTED)
267 } else {
268 reduced
269 }
270}
271
272fn origin_and_target(url: &str) -> Option<(String, &str)> {
276 let colon = url.find("://")?;
277 let scheme = &url[..colon];
278 if scheme.is_empty()
279 || !scheme
280 .bytes()
281 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.'))
282 {
283 return None;
284 }
285 let rest = &url[colon + 3..];
286 let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
287 let (authority, target) = rest.split_at(end);
288 let host = authority.rsplit('@').next().unwrap_or(authority);
291 if host.is_empty() {
292 return None;
293 }
294 Some((format!("{scheme}://{host}"), target))
295}
296
297fn scrub_urls(value: &str) -> String {
300 let bytes = value.as_bytes();
301 let mut out = String::with_capacity(value.len());
302 let mut processed = 0;
303 let mut search = 0;
304 while let Some(relative) = value[search..].find("://") {
305 let colon = search + relative;
306 let mut scheme_start = colon;
307 while scheme_start > processed {
308 let b = bytes[scheme_start - 1];
309 if b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.') {
310 scheme_start -= 1;
311 } else {
312 break;
313 }
314 }
315 if scheme_start == colon {
316 search = colon + 3;
318 continue;
319 }
320 let mut end = colon + 3;
321 while end < bytes.len() && url_body_byte(bytes[end]) {
322 end += 1;
323 }
324 out.push_str(&value[processed..scheme_start]);
325 out.push_str(&scrub_one_url(&value[scheme_start..end]));
326 processed = end;
327 search = end;
328 }
329 out.push_str(&value[processed..]);
330 out
331}
332
333fn scrub_one_url(url: &str) -> String {
334 let Some((scheme, rest)) = url.split_once("://") else {
335 return url.to_owned();
336 };
337 let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
338 let (authority, tail) = rest.split_at(authority_end);
339 let mut out = String::with_capacity(url.len());
340 out.push_str(scheme);
341 out.push_str("://");
342 match authority.rfind('@') {
343 Some(at) => {
344 out.push_str(REDACTED);
345 out.push_str(&authority[at..]);
346 }
347 None => out.push_str(authority),
348 }
349 match tail.find('?') {
350 Some(query) => {
351 out.push_str(&tail[..query]);
352 out.push('?');
353 out.push_str(REDACTED);
354 }
355 None => out.push_str(tail),
356 }
357 out
358}
359
360fn scrub_path_queries(value: &str) -> String {
364 let mut out = String::with_capacity(value.len());
365 let mut rest = value;
366 while !rest.is_empty() {
367 let lead = rest
368 .find(|c: char| !c.is_ascii_whitespace())
369 .unwrap_or(rest.len());
370 out.push_str(&rest[..lead]);
371 let word_end = rest[lead..]
372 .find(|c: char| c.is_ascii_whitespace())
373 .map_or(rest.len(), |offset| lead + offset);
374 let word = &rest[lead..word_end];
375 match word.strip_prefix('/') {
376 Some(_) => match word.find('?') {
377 Some(query) => {
378 out.push_str(&word[..query]);
379 out.push('?');
380 out.push_str(REDACTED);
381 }
382 None => out.push_str(word),
383 },
384 None => out.push_str(word),
385 }
386 rest = &rest[word_end..];
387 }
388 out
389}
390
391fn scrub_dotted_tokens(value: &str) -> String {
394 let bytes = value.as_bytes();
395 let mut out = String::with_capacity(value.len());
396 let mut processed = 0;
397 let mut search = 0;
398 while let Some(relative) = value[search..].find("eyJ") {
399 let start = search + relative;
400 if start > processed && (base64url_byte(bytes[start - 1]) || bytes[start - 1] == b'.') {
401 search = start + 3;
403 continue;
404 }
405 let mut end = start;
406 let mut segments = 0;
407 let mut all_segments_long = true;
408 loop {
409 let segment_start = end;
410 while end < bytes.len() && base64url_byte(bytes[end]) {
411 end += 1;
412 }
413 segments += 1;
414 if end - segment_start < 8 {
415 all_segments_long = false;
416 }
417 if end + 1 < bytes.len() && bytes[end] == b'.' && base64url_byte(bytes[end + 1]) {
418 end += 1;
419 continue;
420 }
421 break;
422 }
423 if segments >= 2 && all_segments_long {
424 out.push_str(&value[processed..start]);
425 out.push_str(REDACTED);
426 processed = end;
427 }
428 search = end.max(start + 3);
429 }
430 out.push_str(&value[processed..]);
431 out
432}
433
434fn scrub_bearer(value: &str) -> String {
436 const LABEL: &str = "Bearer ";
437 let bytes = value.as_bytes();
438 let mut out = String::with_capacity(value.len());
439 let mut processed = 0;
440 let mut search = 0;
441 while let Some(relative) = value[search..].find(LABEL) {
442 let label_end = search + relative + LABEL.len();
443 let mut end = label_end;
444 while end < bytes.len()
445 && (base64url_byte(bytes[end])
446 || matches!(bytes[end], b'.' | b'+' | b'/' | b'=' | b'~'))
447 {
448 end += 1;
449 }
450 if end - label_end >= 8 {
451 out.push_str(&value[processed..label_end]);
452 out.push_str(REDACTED);
453 processed = end;
454 }
455 search = label_end.max(processed);
456 }
457 out.push_str(&value[processed..]);
458 out
459}
460
461fn scrub_emails(value: &str) -> String {
467 let bytes = value.as_bytes();
468 let mut out = String::with_capacity(value.len());
469 let mut processed = 0;
470 let mut search = 0;
471 while let Some(relative) = value[search..].find('@') {
472 let at = search + relative;
473 let mut start = at;
474 while start > processed && email_local_byte(bytes[start - 1]) {
475 start -= 1;
476 }
477 let mut end = at + 1;
478 while end < bytes.len() && email_domain_byte(bytes[end]) {
479 end += 1;
480 }
481 let local = &value[start..at];
482 let domain = &value[at + 1..end];
483 if email_shape_ok(local, domain) {
484 out.push_str(&value[processed..start]);
485 out.push_str("[subject_hash:");
486 out.push_str(&subject_hash(&value[start..end]));
487 out.push(']');
488 processed = end;
489 }
490 search = (at + 1).max(processed);
491 }
492 out.push_str(&value[processed..]);
493 out
494}
495
496fn email_shape_ok(local: &str, domain: &str) -> bool {
497 if local.is_empty() || local.len() > crate::email::MAX_LOCAL_BYTES {
498 return false;
499 }
500 if local.starts_with('.') || local.ends_with('.') {
501 return false;
502 }
503 if domain.is_empty() || !domain.contains('.') {
504 return false;
505 }
506 if domain.starts_with('.') || domain.ends_with('.') || domain.contains("..") {
507 return false;
508 }
509 if domain.len() > crate::email::MAX_EMAIL_BYTES {
510 return false;
511 }
512 domain
513 .split('.')
514 .all(|label| !label.is_empty() && !label.starts_with('-') && !label.ends_with('-'))
515 && domain
516 .rsplit('.')
517 .next()
518 .is_some_and(|tld| tld.len() >= 2 && tld.bytes().all(|b| b.is_ascii_alphabetic()))
519}
520
521#[derive(Debug, Default)]
525pub struct RedactingVisitor {
526 pub fields: BTreeMap<String, String>,
527}
528
529impl RedactingVisitor {
530 #[must_use]
531 pub fn new() -> Self {
532 Self::default()
533 }
534
535 fn record(&mut self, name: &str, value: &str) {
536 self.fields
537 .insert(name.to_owned(), redacted_value(name, value));
538 }
539}
540
541impl Visit for RedactingVisitor {
542 fn record_str(&mut self, field: &Field, value: &str) {
543 self.record(field.name(), value);
544 }
545
546 fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
547 self.record(field.name(), &format!("{value:?}"));
548 }
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554
555 fn hex_prefix(bytes: &[u8]) -> String {
557 use std::fmt::Write as _;
558 let mut hex = String::with_capacity(12);
559 for byte in bytes.iter().take(6) {
560 let _ = write!(hex, "{byte:02x}");
561 }
562 hex
563 }
564
565 #[test]
566 fn secret_names_are_detected_case_insensitively() {
567 for name in [
568 "authorization",
569 "Authorization",
570 "api_token",
571 "captchaToken",
572 "HARNESS_SECRET",
573 "kid_key",
574 "password",
575 ] {
576 assert!(is_secret_field(name), "{name}");
577 }
578 assert!(!is_secret_field("outcome"));
579 assert!(is_secret_field("idempotency_key"));
582 }
583
584 #[test]
585 fn subject_hash_without_a_key_is_the_fixed_placeholder() {
586 let hash = subject_hash("nick@example.com");
590 assert_eq!(hash, UNKEYED_PSEUDONYM, "no key, no digest");
591 assert_eq!(hash.len(), 12);
592 assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
593 assert!(!hash.contains('@'));
594 assert_eq!(
595 hash,
596 subject_hash("nick2@example.com"),
597 "unkeyed output must not vary with the address"
598 );
599 }
600
601 #[test]
602 fn pseudonym_hex_without_a_key_is_constant_and_not_a_bare_digest() {
603 use sha2::Digest as _;
608 for value in ["a@x.com", "b@x.com", "nick@example.com", ""] {
609 assert_eq!(pseudonym_hex(None, value), UNKEYED_PSEUDONYM, "{value}");
610 let digest: [u8; 32] = Sha256::digest(value.as_bytes()).into();
611 assert_ne!(
612 hex_prefix(&digest),
613 pseudonym_hex(None, value),
614 "the placeholder must not be a SHA-256 prefix of {value}"
615 );
616 }
617 assert_eq!(
618 pseudonym_hex(None, "a@x.com"),
619 pseudonym_hex(None, "b@x.com"),
620 "input-independent"
621 );
622 }
623
624 #[test]
625 fn pseudonym_hex_with_a_key_is_the_input_sensitive_hmac_form() {
626 let key: &[u8] = b"harness-secret-long-enough-for-this-test-0123456789";
627 let a = pseudonym_hex(Some(key), "a@x.com");
628 assert_eq!(a.len(), 12, "keyed pseudonym keeps the shape: {a}");
629 assert!(a.chars().all(|c| c.is_ascii_hexdigit()), "{a}");
630 assert_ne!(a, UNKEYED_PSEUDONYM, "keyed output is not the placeholder");
631 assert_eq!(
632 a,
633 pseudonym_hex(Some(key), "a@x.com"),
634 "stable under one key"
635 );
636 assert_ne!(
637 a,
638 pseudonym_hex(Some(key), "b@x.com"),
639 "keyed pseudonym stays input-sensitive"
640 );
641 let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts any key length");
644 mac.update(PSEUDONYM_DOMAIN);
645 mac.update(b"a@x.com");
646 assert_eq!(a, hex_prefix(&mac.finalize().into_bytes()));
647 }
648
649 #[test]
650 fn visitor_rules_match_redacted_value() {
651 for (name, value) in [
652 ("authorization", "Bearer super-secret-token"),
653 ("token", "captcha-value"),
654 ("email", "nick@example.com"),
655 ("subject", "nick@example.com"),
656 ("outcome", "sent"),
657 ] {
658 let stored = redacted_value(name, value);
659 if is_secret_field(name) {
660 assert_eq!(stored, "[redacted]", "{name}");
661 } else if is_email_field(name) && value.contains('@') {
662 assert!(stored.starts_with("subject_hash:"), "{name}: {stored}");
663 assert!(!stored.contains('@'));
664 } else {
665 assert_eq!(stored, value, "{name}");
666 }
667 }
668 }
669
670 #[test]
671 fn non_email_values_in_email_fields_pass_through() {
672 assert_eq!(
673 redacted_value("email_domain", "factory0.ventures"),
674 "factory0.ventures"
675 );
676 }
677
678 #[test]
682 fn scrub_text_pseudonymises_emails_inside_generic_values() {
683 let driver = "duplicate key value violates unique constraint \
684 \"subscribers_email_normalized_key\"\n\
685 DETAIL: Key (email_normalized)=(nick@example.com) already exists.";
686 let scrubbed = scrub_text(driver);
687 assert!(!scrubbed.contains('@'), "{scrubbed}");
688 assert!(!scrubbed.contains("nick"), "{scrubbed}");
689 assert!(scrubbed.contains("[subject_hash:"), "{scrubbed}");
690 assert!(
691 scrubbed.contains(&subject_hash("nick@example.com")),
692 "the pseudonym is the same one the email fields use: {scrubbed}"
693 );
694 assert!(
695 scrubbed.contains("duplicate key value violates unique constraint"),
696 "the diagnostic itself survives: {scrubbed}"
697 );
698
699 assert_eq!(
700 redacted_value("message", "mailer rejected bob@example.com"),
701 format!(
702 "mailer rejected [subject_hash:{}]",
703 subject_hash("bob@example.com")
704 )
705 );
706 }
707
708 #[test]
709 fn scrub_text_drops_query_strings_from_urls_and_paths() {
710 let message = "GET https://api.factory0.ventures/v1/email-signup/confirm?token=abc \
711 redirected to /v1/waitlist/status?token=def";
712 let scrubbed = scrub_text(message);
713 assert!(!scrubbed.contains("token=abc"), "{scrubbed}");
714 assert!(!scrubbed.contains("token=def"), "{scrubbed}");
715 assert!(
716 scrubbed.contains("https://api.factory0.ventures/v1/email-signup/confirm?[redacted]"),
717 "{scrubbed}"
718 );
719 assert!(
720 scrubbed.contains("redirected to /v1/waitlist/status?[redacted]"),
721 "{scrubbed}"
722 );
723
724 let uri = scrub_text("/ui/waitlist/status?token=01J.secret");
725 assert_eq!(uri, "/ui/waitlist/status?[redacted]");
726
727 assert_eq!(
728 scrub_text("/v1/email-signup/confirm"),
729 "/v1/email-signup/confirm",
730 "a path without a query is untouched"
731 );
732 }
733
734 #[test]
735 fn scrub_text_redacts_url_credentials() {
736 let connect = "error connecting to postgres://venture:sup3r-s3cret@db.internal:5432/app";
737 let scrubbed = scrub_text(connect);
738 assert!(!scrubbed.contains("sup3r-s3cret"), "{scrubbed}");
739 assert!(!scrubbed.contains("venture:"), "{scrubbed}");
740 assert!(
741 scrubbed.contains("postgres://[redacted]@db.internal:5432/app"),
742 "{scrubbed}"
743 );
744 }
745
746 #[test]
747 fn scrub_text_redacts_signed_tokens_wherever_they_appear() {
748 use crate::ports::signer::{Kid, Payload, Signer};
749 use crate::signer::HmacSigner;
750
751 let signer = HmacSigner::new("0".repeat(crate::signer::MIN_SECRET_BYTES), None)
752 .expect("test secret is long enough");
753 let token = signer.sign(&Payload {
754 purpose: "email-signup.confirm".to_owned(),
755 subject: "01J0000000000000000000000A".to_owned(),
756 exp: None,
757 kid: Kid::Cur,
758 });
759 assert!(token.starts_with("eyJ"), "test premise: {token}");
760
761 let scrubbed = scrub_text(&format!("confirm token {token} was rejected"));
762 assert!(!scrubbed.contains(&token), "{scrubbed}");
763 assert!(!scrubbed.contains("eyJ"), "{scrubbed}");
764 assert!(scrubbed.contains(REDACTED), "{scrubbed}");
765 assert!(
766 scrubbed.contains("confirm token") && scrubbed.contains("was rejected"),
767 "surrounding words survive: {scrubbed}"
768 );
769
770 let in_url = scrub_text(&format!("https://x.example/confirm?token={token}"));
773 assert!(!in_url.contains("eyJ"), "{in_url}");
774
775 let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIwMUoifQ.c2lnbmF0dXJlLXJ1bg";
777 assert!(!scrub_text(jwt).contains("eyJ"));
778
779 assert_eq!(scrub_text("eyJhYi.短"), "eyJhYi.短");
781 }
782
783 #[test]
784 fn scrub_text_redacts_bearer_credentials() {
785 let scrubbed = scrub_text("request carried Authorization: Bearer 01Jsupersecretvalue");
786 assert!(!scrubbed.contains("01Jsupersecretvalue"), "{scrubbed}");
787 assert!(scrubbed.contains("Bearer [redacted]"), "{scrubbed}");
788 }
789
790 #[test]
791 fn scrub_text_leaves_ordinary_log_values_alone() {
792 for value in [
793 "sent",
794 "not_configured",
795 "01J8Z4QWERTYUIOPASDFGH",
796 "/v1/email-signup/confirm",
797 "factory0.ventures",
798 "cratefield-core@0.1.0",
799 "database did not answer within 2 s",
800 "[redacted]",
801 ] {
802 assert_eq!(scrub_text(value), value, "{value}");
803 }
804 }
805
806 #[test]
807 fn scrub_text_is_idempotent() {
808 let nasty = "failed for nick@example.com with Bearer abcdefgh12345 at \
809 https://x.example/v1/confirm?token=eyJ and postgres://u:p@h/db";
810 let once = scrub_text(nasty);
811 assert_eq!(scrub_text(&once), once, "{once}");
812 }
813
814 #[test]
819 fn scrub_request_url_removes_a_credential_path_scrub_text_cannot_see() {
820 const TOKEN: &str = "0a1b2c3d4e5f60718293a4b5c6d7e8f9";
821 let url = &format!("https://api.push.apple.com/3/device/{TOKEN}");
822
823 assert!(
824 scrub_text(url).contains(TOKEN),
825 "if scrub_text ever learns to do this, this pass can go"
826 );
827
828 let message = format!("Fetch API cannot load: {url}. sending POST /3/device/{TOKEN}");
831 let safe = scrub_request_url(&message, url);
832 assert!(!safe.contains(TOKEN), "{safe}");
833 assert!(!safe.contains("/3/device"), "{safe}");
834 assert!(safe.contains("https://api.push.apple.com"), "{safe}");
835 assert!(safe.contains("Fetch API cannot load"), "{safe}");
837 assert!(safe.contains(REDACTED), "{safe}");
838 }
839
840 #[test]
841 fn scrub_request_url_keeps_the_port_and_never_the_userinfo() {
842 let url = "http://user:pw@127.0.0.1:8787/3/device/tok";
843 let safe = scrub_request_url(&format!("connect failed: {url}"), url);
844 assert_eq!(safe, "connect failed: http://127.0.0.1:8787");
845 }
846
847 #[test]
848 fn scrub_request_url_ignores_anything_that_is_not_an_absolute_url() {
849 for url in ["", "/3/device/tok", "api.push.apple.com", "://no-scheme/x"] {
852 let message = "the message is not the place to find out";
853 assert_eq!(scrub_request_url(message, url), message, "{url:?}");
854 }
855 }
856
857 #[test]
858 fn scrub_request_url_leaves_a_root_target_alone() {
859 let safe = scrub_request_url("GET / failed at https://x.example/", "https://x.example/");
862 assert_eq!(safe, "GET / failed at https://x.example");
863 }
864}
865
866type ErrorForwarder = fn(&str);
872
873static ERROR_FORWARDER: OnceLock<ErrorForwarder> = OnceLock::new();
874
875pub fn set_error_forwarder(forwarder: ErrorForwarder) {
889 let _ = ERROR_FORWARDER.set(forwarder);
890}
891
892pub(crate) fn forward_internal_error(line: &str) {
898 if let Some(forwarder) = ERROR_FORWARDER.get() {
899 forwarder(&scrub_text(line));
900 }
901}
902
903#[cfg(test)]
904#[allow(clippy::disallowed_types)] mod forwarder_tests {
906 use super::*;
907 use std::sync::Mutex;
908
909 static CAPTURED: Mutex<Vec<String>> = Mutex::new(Vec::new());
910
911 fn capture(line: &str) {
912 CAPTURED.lock().unwrap().push(line.to_owned());
913 }
914
915 #[test]
916 fn an_installed_forwarder_receives_the_line() {
917 set_error_forwarder(capture);
920 forward_internal_error("database error mapped to internal problem: boom");
921 assert!(
922 CAPTURED
923 .lock()
924 .unwrap()
925 .iter()
926 .any(|line| line.contains("boom")),
927 "the installed forwarder should have received the diagnostic"
928 );
929
930 forward_internal_error(
935 "database error: DETAIL: Key (email)=(nick@example.com) already exists",
936 );
937 forward_internal_error("redirect to /v1/waitlist/status?token=eyJhYmNkZWZnaA.mac failed");
938 let captured = CAPTURED.lock().unwrap();
939 let joined = captured.join("\n");
940 assert!(!joined.contains('@'), "email reached the sink: {joined}");
941 assert!(
942 !joined.contains("token=eyJ"),
943 "token reached the sink: {joined}"
944 );
945 assert!(
946 joined.contains("[subject_hash:"),
947 "the pseudonym should survive for correlation: {joined}"
948 );
949 }
950
951 #[test]
952 fn forwarding_without_a_sink_is_a_noop() {
953 forward_internal_error("ignored when no sink or captured when set");
956 }
957}