Skip to main content

stdbr_core/
cpf.rs

1//! CPF (Cadastro de Pessoas Físicas) - validation, formatting and generation.
2//!
3//! Uses modulo-11 weighted sums as specified by Receita Federal do Brasil.
4
5use alloc::string::String;
6use alloc::vec::Vec;
7use core::fmt;
8
9use crate::rand::{simple_seed, xorshift64};
10use crate::util::{self, impl_document_traits};
11
12const CPF_LEN: usize = 11;
13const WEIGHTS_D1: [u32; 9] = [10, 9, 8, 7, 6, 5, 4, 3, 2];
14const WEIGHTS_D2: [u32; 10] = [11, 10, 9, 8, 7, 6, 5, 4, 3, 2];
15const FORMATTED_DIGIT_POS: [usize; 11] = [0, 1, 2, 4, 5, 6, 8, 9, 10, 12, 13];
16
17/// Fiscal region mapped by the 9th digit of a CPF.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19#[repr(u8)]
20pub enum FiscalRegion {
21    Rs = 0,
22    DfGoMsMtTo = 1,
23    AcAmApPaRoRr = 2,
24    CeMaPi = 3,
25    AlPbPeRn = 4,
26    BaSe = 5,
27    Mg = 6,
28    EsRj = 7,
29    Sp = 8,
30    PrSc = 9,
31}
32
33impl FiscalRegion {
34    fn from_digit(d: u8) -> Self {
35        assert!(d <= 9, "digit must be 0..=9");
36        // SAFETY: repr(u8) with discriminants 0..=9, d validated above.
37        unsafe { core::mem::transmute(d) }
38    }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum CpfError {
43    InvalidLength,
44    InvalidCharacter,
45    InvalidFormat,
46    AllDigitsEqual,
47    InvalidCheckDigits,
48}
49
50impl fmt::Display for CpfError {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        f.write_str(match self {
53            Self::InvalidLength => "CPF must contain exactly 11 digits",
54            Self::InvalidCharacter => "CPF contains invalid characters",
55            Self::InvalidFormat => "CPF format must be ###.###.###-## or 11 digits",
56            Self::AllDigitsEqual => "CPF with all equal digits is invalid",
57            Self::InvalidCheckDigits => "CPF check digits are invalid",
58        })
59    }
60}
61
62/// A validated CPF stored as 11 ASCII bytes.
63///
64/// ```
65/// use stdbr_core::cpf::{Cpf, generate_cpf};
66///
67/// let cpf = generate_cpf();
68/// assert_eq!(cpf.as_str().len(), 11);
69/// assert_eq!(cpf.to_string().len(), 14); // ###.###.###-##
70///
71/// let parsed: Cpf = cpf.to_string().parse().unwrap();
72/// assert_eq!(cpf, parsed);
73/// ```
74#[derive(Clone, Copy, PartialEq, Eq, Hash)]
75pub struct Cpf {
76    bytes: [u8; CPF_LEN],
77}
78
79impl Cpf {
80    /// Unformatted 11-digit `&str`.
81    pub fn as_str(&self) -> &str {
82        // SAFETY: constructors guarantee ASCII digits only.
83        unsafe { core::str::from_utf8_unchecked(&self.bytes) }
84    }
85
86    /// The 11 numeric digits (0–9).
87    pub fn digits(&self) -> [u8; CPF_LEN] {
88        self.bytes.map(|b| b - b'0')
89    }
90
91    /// Fiscal region derived from the 9th digit.
92    pub fn fiscal_region(&self) -> FiscalRegion {
93        FiscalRegion::from_digit(self.bytes[8] - b'0')
94    }
95
96    /// Masked: `XXX.***.***-XX`.
97    pub fn masked(&self) -> String {
98        let s = self.as_str();
99        alloc::format!("{}.***.***-{}", &s[0..3], &s[9..11])
100    }
101
102    /// The two check digits `(d1, d2)`.
103    pub fn check_digits(&self) -> (u8, u8) {
104        (self.bytes[9] - b'0', self.bytes[10] - b'0')
105    }
106
107    fn from_numeric(digits: [u8; CPF_LEN]) -> Self {
108        Self {
109            bytes: digits.map(|d| d + b'0'),
110        }
111    }
112}
113
114impl fmt::Display for Cpf {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        let s = self.as_str();
117        write!(f, "{}.{}.{}-{}", &s[0..3], &s[3..6], &s[6..9], &s[9..11])
118    }
119}
120
121impl_document_traits!(Cpf, CpfError);
122
123pub fn remove_symbols(cpf: &str) -> String {
124    cpf.chars().filter(char::is_ascii_digit).collect()
125}
126
127pub fn is_valid(cpf: &str) -> bool {
128    let raw = remove_symbols(cpf);
129    if raw.len() != CPF_LEN {
130        return false;
131    }
132    let d: Vec<u8> = raw.bytes().map(|b| b - b'0').collect();
133    validate_digits(&d)
134}
135
136/// Strict validation - accepts `###.###.###-##` or `###########` only.
137pub fn is_valid_strict(cpf: &str) -> Result<(), CpfError> {
138    parse_strict(cpf).map(|_| ())
139}
140
141/// Formats as `###.###.###-##`, or `None` if not 11 digits.
142pub fn format_cpf(cpf: &str) -> Option<String> {
143    let d = remove_symbols(cpf);
144    (d.len() == CPF_LEN)
145        .then(|| alloc::format!("{}.{}.{}-{}", &d[0..3], &d[3..6], &d[6..9], &d[9..11]))
146}
147
148/// Generates a random valid CPF as an 11-digit string.
149pub fn generate() -> String {
150    generate_cpf().as_str().into()
151}
152
153/// Generates a random valid [`Cpf`].
154pub fn generate_cpf() -> Cpf {
155    generate_with_seed(simple_seed())
156}
157
158/// Generates a random valid [`Cpf`] for a given fiscal region.
159pub fn generate_for_region(region: FiscalRegion) -> Cpf {
160    let mut seed = simple_seed();
161    let mut digits = [0u8; CPF_LEN];
162
163    loop {
164        for d in &mut digits[..8] {
165            seed = xorshift64(seed);
166            *d = (seed % 10) as u8;
167        }
168        digits[8] = region as u8;
169        if !all_equal(&digits[..9]) {
170            break;
171        }
172    }
173
174    append_check_digits(&mut digits);
175    Cpf::from_numeric(digits)
176}
177
178pub fn compute_check_digits(base: &str) -> Option<(u8, u8)> {
179    let raw = remove_symbols(base);
180    if raw.len() != 9 {
181        return None;
182    }
183
184    let d: Vec<u8> = raw.bytes().map(|b| b - b'0').collect();
185    if all_equal(&d) {
186        return None;
187    }
188
189    let d1 = check_digit(&d, &WEIGHTS_D1);
190    let mut full = [0u8; 10];
191    full[..9].copy_from_slice(&d);
192    full[9] = d1;
193    let d2 = check_digit(&full, &WEIGHTS_D2);
194
195    Some((d1, d2))
196}
197
198fn all_equal(digits: &[u8]) -> bool {
199    util::all_equal(digits)
200}
201
202fn validate_digits(d: &[u8]) -> bool {
203    !all_equal(d)
204        && d[9] == check_digit(&d[..9], &WEIGHTS_D1)
205        && d[10] == check_digit(&d[..10], &WEIGHTS_D2)
206}
207
208fn check_digit(digits: &[u8], weights: &[u32]) -> u8 {
209    let sum: u32 = digits
210        .iter()
211        .zip(weights)
212        .map(|(&d, &w)| u32::from(d) * w)
213        .sum();
214    let rem = (sum * 10) % 11;
215    if rem == 10 { 0 } else { rem as u8 }
216}
217
218fn append_check_digits(digits: &mut [u8; CPF_LEN]) {
219    digits[9] = check_digit(&digits[..9], &WEIGHTS_D1);
220    digits[10] = check_digit(&digits[..10], &WEIGHTS_D2);
221}
222
223fn parse_strict(s: &str) -> Result<Cpf, CpfError> {
224    let raw = s.as_bytes();
225
226    let numeric: Vec<u8> = match raw.len() {
227        11 => {
228            if !raw.iter().all(u8::is_ascii_digit) {
229                return Err(CpfError::InvalidCharacter);
230            }
231            raw.iter().map(|b| b - b'0').collect()
232        }
233        14 => {
234            if raw[3] != b'.' || raw[7] != b'.' || raw[11] != b'-' {
235                return Err(CpfError::InvalidFormat);
236            }
237            for &i in &FORMATTED_DIGIT_POS {
238                if !raw[i].is_ascii_digit() {
239                    return Err(CpfError::InvalidCharacter);
240                }
241            }
242            FORMATTED_DIGIT_POS.iter().map(|&i| raw[i] - b'0').collect()
243        }
244        _ => return Err(CpfError::InvalidLength),
245    };
246
247    if all_equal(&numeric) {
248        return Err(CpfError::AllDigitsEqual);
249    }
250
251    let d1 = check_digit(&numeric[..9], &WEIGHTS_D1);
252    let d2 = check_digit(&numeric[..10], &WEIGHTS_D2);
253    if numeric[9] != d1 || numeric[10] != d2 {
254        return Err(CpfError::InvalidCheckDigits);
255    }
256
257    let mut digits = [0u8; CPF_LEN];
258    digits.copy_from_slice(&numeric);
259    Ok(Cpf::from_numeric(digits))
260}
261
262fn generate_with_seed(mut seed: u64) -> Cpf {
263    let mut digits = [0u8; CPF_LEN];
264
265    loop {
266        for d in &mut digits[..9] {
267            seed = xorshift64(seed);
268            *d = (seed % 10) as u8;
269        }
270        if !all_equal(&digits[..9]) {
271            break;
272        }
273    }
274
275    append_check_digits(&mut digits);
276    Cpf::from_numeric(digits)
277}
278
279#[cfg(test)]
280fn make_cpf(base: [u8; 9]) -> Cpf {
281    let mut digits = [0u8; CPF_LEN];
282    digits[..9].copy_from_slice(&base);
283    append_check_digits(&mut digits);
284    Cpf::from_numeric(digits)
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use alloc::string::ToString;
291
292    fn cpf_a() -> Cpf {
293        make_cpf([5, 2, 9, 9, 8, 2, 2, 4, 7])
294    }
295    fn cpf_b() -> Cpf {
296        make_cpf([3, 4, 7, 0, 6, 6, 1, 2, 0])
297    }
298    fn cpf_c() -> Cpf {
299        make_cpf([0, 0, 1, 2, 3, 4, 5, 6, 7])
300    }
301
302    #[test]
303    fn is_valid_accepts_valid_unformatted() {
304        assert!(is_valid(cpf_a().as_str()));
305        assert!(is_valid(cpf_b().as_str()));
306        assert!(is_valid(cpf_c().as_str()));
307    }
308
309    #[test]
310    fn is_valid_accepts_valid_formatted() {
311        let a = cpf_a().to_string();
312        let b = cpf_b().to_string();
313        assert!(is_valid(&a));
314        assert!(is_valid(&b));
315    }
316
317    #[test]
318    fn is_valid_accepts_leading_zero_cpf() {
319        let cpf = cpf_c();
320        assert!(cpf.as_str().starts_with("00"));
321        assert!(is_valid(cpf.as_str()));
322    }
323
324    #[test]
325    fn is_valid_lenient_strips_garbage() {
326        let cpf = cpf_a();
327        let s = cpf.as_str();
328        let garbage = alloc::format!("{}${}#{}!{}", &s[0..3], &s[3..6], &s[6..9], &s[9..11]);
329        assert!(is_valid(&garbage));
330
331        let padded = alloc::format!("  {}  ", cpf_a());
332        assert!(is_valid(&padded));
333
334        let mut spaced = String::new();
335        for c in s.bytes() {
336            use core::fmt::Write;
337            write!(spaced, "{} ", c as char).unwrap();
338        }
339        assert!(is_valid(spaced.trim()));
340    }
341
342    #[test]
343    fn is_valid_rejects_wrong_check_digits() {
344        let mut bad = cpf_a().digits();
345        bad[10] = (bad[10] + 1) % 10;
346        let s: String = bad.iter().map(|&d| (b'0' + d) as char).collect();
347        assert!(!is_valid(&s));
348    }
349
350    #[test]
351    fn is_valid_rejects_all_same_digits() {
352        for d in 0..=9u8 {
353            let cpf: String = core::iter::repeat_n(char::from(b'0' + d), 11).collect();
354            assert!(!is_valid(&cpf), "should reject {cpf}");
355        }
356    }
357
358    #[test]
359    fn is_valid_rejects_wrong_length() {
360        assert!(!is_valid(""));
361        assert!(!is_valid("1234567890"));
362        assert!(!is_valid("123456789012"));
363    }
364
365    #[test]
366    fn is_valid_rejects_no_digits() {
367        assert!(!is_valid("abc.def.ghi-jk"));
368        assert!(!is_valid("...---"));
369    }
370
371    #[test]
372    fn is_valid_rejects_embedded_digits_in_long_string() {
373        assert!(!is_valid("abc1234567890123def"));
374    }
375
376    #[test]
377    fn strict_accepts_valid_unformatted() {
378        assert!(is_valid_strict(cpf_a().as_str()).is_ok());
379        assert!(is_valid_strict(cpf_b().as_str()).is_ok());
380    }
381
382    #[test]
383    fn strict_accepts_valid_formatted() {
384        let a = cpf_a().to_string();
385        let b = cpf_b().to_string();
386        assert!(is_valid_strict(&a).is_ok());
387        assert!(is_valid_strict(&b).is_ok());
388    }
389
390    #[test]
391    fn strict_rejects_garbage_between_digits() {
392        let cpf = cpf_a();
393        let s = cpf.as_str();
394        let garbage = alloc::format!("{}${}#{}!{}", &s[0..3], &s[3..6], &s[6..9], &s[9..11]);
395        assert!(is_valid_strict(&garbage).is_err());
396    }
397
398    #[test]
399    fn strict_rejects_whitespace() {
400        let padded = alloc::format!("  {}  ", cpf_a().as_str());
401        assert_eq!(is_valid_strict(&padded), Err(CpfError::InvalidLength));
402
403        let padded_fmt = alloc::format!(" {} ", cpf_a());
404        assert_eq!(is_valid_strict(&padded_fmt), Err(CpfError::InvalidLength));
405    }
406
407    #[test]
408    fn strict_rejects_misplaced_separators() {
409        let cpf = cpf_a();
410        let s = cpf.as_str();
411        let bad_fmt = alloc::format!("{}.{}.{}.{}", &s[0..4], &s[4..6], &s[6..9], &s[9..11]);
412        assert!(is_valid_strict(&bad_fmt).is_err());
413    }
414
415    #[test]
416    fn strict_rejects_letters() {
417        assert_eq!(
418            is_valid_strict("abcdefghijk"),
419            Err(CpfError::InvalidCharacter)
420        );
421    }
422
423    #[test]
424    fn strict_rejects_all_same_digits() {
425        assert_eq!(
426            is_valid_strict("11111111111"),
427            Err(CpfError::AllDigitsEqual)
428        );
429        assert_eq!(
430            is_valid_strict("000.000.000-00"),
431            Err(CpfError::AllDigitsEqual)
432        );
433    }
434
435    #[test]
436    fn strict_rejects_invalid_check_digits() {
437        let mut bad = cpf_a().digits();
438        bad[10] = (bad[10] + 1) % 10;
439        let s: String = bad.iter().map(|&d| (b'0' + d) as char).collect();
440        assert_eq!(is_valid_strict(&s), Err(CpfError::InvalidCheckDigits));
441    }
442
443    #[test]
444    fn parse_roundtrip() {
445        let cpf = cpf_a();
446        let parsed: Cpf = cpf.to_string().parse().unwrap();
447        assert_eq!(cpf, parsed);
448        assert_eq!(parsed.as_str(), cpf.as_str());
449    }
450
451    #[test]
452    fn parse_unformatted() {
453        let cpf = cpf_a();
454        let parsed: Cpf = cpf.as_str().parse().unwrap();
455        assert_eq!(cpf, parsed);
456    }
457
458    #[test]
459    fn parse_equality_across_formats() {
460        let from_fmt: Cpf = cpf_a().to_string().parse().unwrap();
461        let from_raw: Cpf = cpf_a().as_str().parse().unwrap();
462        assert_eq!(from_fmt, from_raw);
463    }
464
465    #[test]
466    fn cpf_is_copy() {
467        let a = cpf_a();
468        let b = a;
469        assert_eq!(a, b);
470    }
471
472    #[test]
473    fn cpf_as_ref_str() {
474        let cpf = cpf_a();
475        let r: &str = cpf.as_ref();
476        assert_eq!(r, cpf.as_str());
477    }
478
479    #[test]
480    fn debug_format() {
481        let cpf = cpf_a();
482        let dbg = alloc::format!("{cpf:?}");
483        assert!(dbg.starts_with("Cpf("));
484        assert!(dbg.ends_with(')'));
485        assert!(dbg.contains('.'));
486        assert!(dbg.contains('-'));
487    }
488
489    #[test]
490    fn fiscal_region() {
491        let cpf = cpf_a();
492        let d = cpf.digits();
493        assert_eq!(cpf.fiscal_region(), FiscalRegion::from_digit(d[8]));
494
495        let cpf = cpf_b();
496        assert_eq!(cpf.digits()[8], 0);
497        assert_eq!(cpf.fiscal_region(), FiscalRegion::Rs);
498    }
499
500    #[test]
501    fn digits_array() {
502        let cpf = cpf_a();
503        assert_eq!(cpf.digits()[..9], [5, 2, 9, 9, 8, 2, 2, 4, 7]);
504    }
505
506    #[test]
507    fn parse_rejects_invalid() {
508        let mut bad = cpf_a().digits();
509        bad[10] = (bad[10] + 1) % 10;
510        let s: String = bad.iter().map(|&d| (b'0' + d) as char).collect();
511        assert!(s.parse::<Cpf>().is_err());
512        assert!("abc".parse::<Cpf>().is_err());
513        assert!("".parse::<Cpf>().is_err());
514    }
515
516    #[test]
517    fn masked() {
518        let cpf = cpf_a();
519        let s = cpf.as_str();
520        let expected = alloc::format!("{}.***.***-{}", &s[0..3], &s[9..11]);
521        assert_eq!(cpf.masked(), expected);
522        assert_eq!(cpf.masked().len(), 14);
523    }
524
525    #[test]
526    fn check_digits() {
527        let cpf = cpf_a();
528        let (d1, d2) = cpf.check_digits();
529        assert_eq!(d1, cpf.digits()[9]);
530        assert_eq!(d2, cpf.digits()[10]);
531    }
532
533    #[test]
534    fn remove_symbols_strips_formatting() {
535        let cpf = cpf_a();
536        let formatted = cpf.to_string();
537        assert_eq!(remove_symbols(&formatted), cpf.as_str());
538        assert_eq!(remove_symbols(cpf.as_str()), cpf.as_str());
539        assert_eq!(remove_symbols(""), "");
540    }
541
542    #[test]
543    fn remove_symbols_strips_arbitrary_chars() {
544        assert_eq!(remove_symbols("abc123def456ghi78901"), "12345678901");
545    }
546
547    #[test]
548    fn format_cpf_produces_formatted_output() {
549        let cpf = cpf_a();
550        let formatted = cpf.to_string();
551        assert_eq!(format_cpf(cpf.as_str()), Some(formatted.clone()));
552        assert_eq!(format_cpf(&formatted), Some(formatted));
553    }
554
555    #[test]
556    fn format_cpf_returns_none_on_bad_length() {
557        assert_eq!(format_cpf("1234"), None);
558        assert_eq!(format_cpf(""), None);
559    }
560
561    #[test]
562    fn format_cpf_preserves_leading_zeros() {
563        let cpf = cpf_c();
564        let formatted = format_cpf(cpf.as_str()).unwrap();
565        assert!(formatted.starts_with("001."));
566    }
567
568    #[test]
569    fn generate_produces_valid_cpfs() {
570        for _ in 0..100 {
571            let cpf = generate();
572            assert_eq!(cpf.len(), 11);
573            assert!(is_valid(&cpf), "generated invalid CPF: {cpf}");
574        }
575    }
576
577    #[test]
578    fn generate_cpf_roundtrips() {
579        for _ in 0..100 {
580            let cpf = generate_cpf();
581            assert!(is_valid(cpf.as_str()));
582            let parsed: Cpf = cpf.as_str().parse().unwrap();
583            assert_eq!(cpf, parsed);
584        }
585    }
586
587    #[test]
588    fn generate_for_region_respects_region_digit() {
589        let regions = [
590            FiscalRegion::Rs,
591            FiscalRegion::DfGoMsMtTo,
592            FiscalRegion::AcAmApPaRoRr,
593            FiscalRegion::CeMaPi,
594            FiscalRegion::AlPbPeRn,
595            FiscalRegion::BaSe,
596            FiscalRegion::Mg,
597            FiscalRegion::EsRj,
598            FiscalRegion::Sp,
599            FiscalRegion::PrSc,
600        ];
601        for region in regions {
602            let cpf = generate_for_region(region);
603            assert_eq!(cpf.fiscal_region(), region);
604            assert!(is_valid(cpf.as_str()));
605        }
606    }
607
608    #[test]
609    fn compute_check_digits_known_base() {
610        let cpf = cpf_a();
611        let base = &cpf.as_str()[..9];
612        let (d1, d2) = compute_check_digits(base).unwrap();
613        assert_eq!(d1, cpf.digits()[9]);
614        assert_eq!(d2, cpf.digits()[10]);
615    }
616
617    #[test]
618    fn compute_check_digits_rejects_bad_input() {
619        assert_eq!(compute_check_digits("12345678"), None);
620        assert_eq!(compute_check_digits("1234567890"), None);
621        assert_eq!(compute_check_digits("000000000"), None);
622    }
623
624    #[test]
625    fn leading_zero_cpf() {
626        let cpf = cpf_c();
627        assert!(cpf.as_str().starts_with("00"));
628        assert!(is_valid(cpf.as_str()));
629
630        let parsed: Cpf = cpf.as_str().parse().unwrap();
631        assert_eq!(parsed.digits()[0], 0);
632        assert_eq!(parsed.digits()[1], 0);
633    }
634}