Skip to main content

apollo_redaction/strategy/
card.rs

1use crate::RedactionStrategy;
2use std::fmt;
3
4/// Redact credit card numbers while preserving format.
5///
6/// Replaces all digits except the last 4 with asterisks, preserving
7/// any separators (spaces, dashes) in their original positions.
8///
9/// Card numbers with fewer than 13 digits are considered invalid.
10///
11/// ```rust
12/// use apollo_redaction::Redacted;
13/// use apollo_redaction::strategy::CardRedactor;
14///
15/// fn card(input: &str) -> String {
16///     Redacted::<_, CardRedactor>::new(input).to_string()
17/// }
18///
19/// assert_eq!(card("4242424242424242"), "************4242");
20/// assert_eq!(card("4242-4242-4242-4242"), "****-****-****-4242");
21/// assert_eq!(card("4242 4242 4242 4242"), "**** **** **** 4242");
22/// assert_eq!(card("1234"), "[REDACTED: invalid card number]");
23/// ```
24#[derive(Default, Clone)]
25pub struct CardRedactor;
26
27/// Minimum valid card number length (some Maestro cards)
28const MIN_CARD_DIGITS: usize = 13;
29
30impl<T: AsRef<str>> RedactionStrategy<T> for CardRedactor {
31    fn display(&self, value: &T, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        let s = value.as_ref();
33
34        // Count total digits to determine how many to redact
35        let total_digits = s.chars().filter(|c| c.is_ascii_digit()).count();
36
37        // Invalid card numbers get a fixed redaction message
38        if total_digits < MIN_CARD_DIGITS {
39            return write!(f, "[REDACTED: invalid card number]");
40        }
41
42        let redact_count = total_digits - 4;
43
44        // Single pass: redact first N digits, preserve everything else
45        let mut digits_seen = 0;
46        for c in s.chars() {
47            if c.is_ascii_digit() {
48                if digits_seen < redact_count {
49                    write!(f, "*")?;
50                } else {
51                    write!(f, "{c}")?;
52                }
53                digits_seen += 1;
54            } else {
55                write!(f, "{c}")?;
56            }
57        }
58
59        Ok(())
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66    use crate::Redacted;
67
68    fn card(input: &str) -> String {
69        Redacted::<_, CardRedactor>::new(input).to_string()
70    }
71
72    #[test]
73    fn test_plain_card() {
74        assert_eq!(card("4242424242424242"), "************4242");
75    }
76
77    #[test]
78    fn test_amex() {
79        assert_eq!(card("378282246310005"), "***********0005");
80    }
81
82    #[test]
83    fn test_19_digit_card() {
84        assert_eq!(card("6011000000000000000"), "***************0000");
85    }
86
87    #[test]
88    fn test_invalid_card() {
89        let expected = "[REDACTED: invalid card number]";
90        assert_eq!(card("123"), expected);
91        assert_eq!(card("1234"), expected);
92        assert_eq!(card("123456789012"), expected); // 12 digits
93        assert_eq!(card("1234-5678-9012"), expected); // 12 digits with formatting
94        assert_eq!(card("no digits here"), expected);
95    }
96
97    #[test]
98    fn test_13_digit_card() {
99        assert_eq!(card("4000000000000"), "*********0000");
100    }
101
102    #[test]
103    fn test_with_dashes() {
104        assert_eq!(card("4242-4242-4242-4242"), "****-****-****-4242");
105    }
106
107    #[test]
108    fn test_with_spaces() {
109        assert_eq!(card("4242 4242 4242 4242"), "**** **** **** 4242");
110    }
111
112    #[test]
113    fn test_leading_trailing_spaces() {
114        assert_eq!(card("  4242424242424242  "), "  ************4242  ");
115    }
116
117    #[test]
118    fn test_mixed_formatting() {
119        assert_eq!(card(" 4242-4242 4242-4242 "), " ****-**** ****-4242 ");
120    }
121}