use crate::sanitize::TaskValue;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub enum PiiKind {
Email,
CreditCard,
Phone,
Ssn,
}
impl PiiKind {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
PiiKind::Email => "email",
PiiKind::CreditCard => "credit_card",
PiiKind::Phone => "phone",
PiiKind::Ssn => "ssn",
}
}
}
impl fmt::Display for PiiKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PiiMatch {
pub kind: PiiKind,
pub start: usize,
pub end: usize,
pub matched: String,
}
impl PiiMatch {
#[must_use]
pub const fn len(&self) -> usize {
self.end - self.start
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.start == self.end
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PiiReport {
pub matches: Vec<PiiMatch>,
}
impl PiiReport {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn total(&self) -> usize {
self.matches.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.matches.is_empty()
}
#[must_use]
pub fn contains_kind(&self, kind: PiiKind) -> bool {
self.matches.iter().any(|m| m.kind == kind)
}
#[must_use]
pub fn count_kind(&self, kind: PiiKind) -> usize {
self.matches.iter().filter(|m| m.kind == kind).count()
}
#[must_use]
pub fn kinds(&self) -> BTreeSet<PiiKind> {
self.matches.iter().map(|m| m.kind).collect()
}
pub fn merge(&mut self, other: PiiReport) {
self.matches.extend(other.matches);
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PiiConfig {
pub detect_email: bool,
pub detect_credit_card: bool,
pub detect_phone: bool,
pub detect_ssn: bool,
pub mask: String,
pub preserve_length: bool,
}
impl Default for PiiConfig {
fn default() -> Self {
Self {
detect_email: true,
detect_credit_card: true,
detect_phone: true,
detect_ssn: true,
mask: "[REDACTED]".to_string(),
preserve_length: false,
}
}
}
impl PiiConfig {
#[must_use]
pub fn email_only() -> Self {
Self {
detect_email: true,
detect_credit_card: false,
detect_phone: false,
detect_ssn: false,
..Self::default()
}
}
#[must_use]
pub fn with_mask(mut self, mask: impl Into<String>) -> Self {
self.mask = mask.into();
self
}
#[must_use]
pub const fn with_preserve_length(mut self, preserve: bool) -> Self {
self.preserve_length = preserve;
self
}
}
#[derive(Debug, Clone)]
pub struct PiiDetector {
config: PiiConfig,
}
impl Default for PiiDetector {
fn default() -> Self {
Self::new()
}
}
impl PiiDetector {
#[must_use]
pub fn new() -> Self {
Self {
config: PiiConfig::default(),
}
}
#[must_use]
pub fn with_config(config: PiiConfig) -> Self {
Self { config }
}
#[must_use]
pub const fn config(&self) -> &PiiConfig {
&self.config
}
#[must_use]
pub fn scan_str(&self, text: &str) -> PiiReport {
let mut candidates: Vec<PiiMatch> = Vec::new();
if self.config.detect_email {
scan_emails(text, &mut candidates);
}
if self.config.detect_credit_card {
scan_credit_cards(text, &mut candidates);
}
if self.config.detect_ssn {
scan_ssn(text, &mut candidates);
}
if self.config.detect_phone {
scan_phones(text, &mut candidates);
}
candidates.sort_by(|a, b| {
a.start
.cmp(&b.start)
.then_with(|| kind_priority(a.kind).cmp(&kind_priority(b.kind)))
.then_with(|| b.len().cmp(&a.len()))
});
let mut report = PiiReport::new();
let mut last_end = 0usize;
for m in candidates {
if m.start >= last_end {
last_end = m.end;
report.matches.push(m);
}
}
report
}
#[must_use]
pub fn mask_str(&self, text: &str) -> (String, PiiReport) {
let report = self.scan_str(text);
if report.is_empty() {
return (text.to_string(), report);
}
let mut out = String::with_capacity(text.len());
let mut cursor = 0usize;
for m in &report.matches {
if m.start > cursor {
out.push_str(&text[cursor..m.start]);
}
out.push_str(&self.mask_for(m));
cursor = m.end;
}
if cursor < text.len() {
out.push_str(&text[cursor..]);
}
(out, report)
}
#[must_use]
pub fn scan_value(&self, value: &TaskValue) -> PiiReport {
let mut report = PiiReport::new();
self.scan_value_into(value, &mut report);
report
}
fn scan_value_into(&self, value: &TaskValue, report: &mut PiiReport) {
match value {
TaskValue::String(s) => report.merge(self.scan_str(s)),
TaskValue::Array(items) => {
for item in items {
self.scan_value_into(item, report);
}
}
TaskValue::Object(entries) => {
for (_, v) in entries {
self.scan_value_into(v, report);
}
}
_ => {}
}
}
pub fn mask_value(&self, value: &mut TaskValue) -> PiiReport {
let mut report = PiiReport::new();
self.mask_value_into(value, &mut report);
report
}
fn mask_value_into(&self, value: &mut TaskValue, report: &mut PiiReport) {
match value {
TaskValue::String(s) => {
let (masked, found) = self.mask_str(s);
if !found.is_empty() {
*s = masked;
report.merge(found);
}
}
TaskValue::Array(items) => {
for item in items.iter_mut() {
self.mask_value_into(item, report);
}
}
TaskValue::Object(entries) => {
for (_, v) in entries.iter_mut() {
self.mask_value_into(v, report);
}
}
_ => {}
}
}
#[must_use]
pub fn scan_call(&self, args: &[TaskValue], kwargs: &[(String, TaskValue)]) -> PiiReport {
let mut report = PiiReport::new();
for v in args {
self.scan_value_into(v, &mut report);
}
for (_, v) in kwargs {
self.scan_value_into(v, &mut report);
}
report
}
pub fn mask_call(
&self,
args: &mut [TaskValue],
kwargs: &mut [(String, TaskValue)],
) -> PiiReport {
let mut report = PiiReport::new();
for v in args.iter_mut() {
self.mask_value_into(v, &mut report);
}
for (_, v) in kwargs.iter_mut() {
self.mask_value_into(v, &mut report);
}
report
}
fn mask_for(&self, m: &PiiMatch) -> String {
if self.config.preserve_length {
let fill = self.config.mask.chars().next().unwrap_or('*');
std::iter::repeat_n(fill, m.matched.chars().count()).collect()
} else {
self.config.mask.clone()
}
}
}
const fn kind_priority(kind: PiiKind) -> u8 {
match kind {
PiiKind::Email => 0,
PiiKind::CreditCard => 1,
PiiKind::Ssn => 2,
PiiKind::Phone => 3,
}
}
#[inline]
const fn is_ascii_digit(b: u8) -> bool {
b.is_ascii_digit()
}
#[inline]
fn is_email_local(b: u8) -> bool {
b.is_ascii_alphanumeric()
|| matches!(
b,
b'.' | b'_'
| b'%'
| b'+'
| b'-'
| b'!'
| b'#'
| b'$'
| b'&'
| b'\''
| b'*'
| b'='
| b'?'
| b'^'
| b'`'
| b'{'
| b'|'
| b'}'
| b'~'
)
}
#[inline]
fn is_email_domain(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'-' || b == b'.'
}
fn scan_emails(text: &str, out: &mut Vec<PiiMatch>) {
let bytes = text.as_bytes();
let n = bytes.len();
let mut i = 0usize;
while i < n {
if bytes[i] != b'@' {
i += 1;
continue;
}
let at = i;
let mut start = at;
while start > 0 && is_email_local(bytes[start - 1]) {
start -= 1;
}
if start == at || bytes[start] == b'.' || bytes[at - 1] == b'.' {
i += 1;
continue;
}
let mut end = at + 1;
while end < n && is_email_domain(bytes[end]) {
end += 1;
}
let domain = &bytes[at + 1..end];
if let Some(domain_len) = valid_email_domain(domain) {
let real_end = at + 1 + domain_len;
out.push(PiiMatch {
kind: PiiKind::Email,
start,
end: real_end,
matched: text[start..real_end].to_string(),
});
i = real_end;
} else {
i = at + 1;
}
}
}
fn valid_email_domain(bytes: &[u8]) -> Option<usize> {
let mut end = bytes.len();
while end > 0 && (bytes[end - 1] == b'.' || bytes[end - 1] == b'-') {
end -= 1;
}
let domain = &bytes[..end];
if domain.is_empty() {
return None;
}
let labels: Vec<&[u8]> = domain.split(|&b| b == b'.').collect();
if labels.len() < 2 {
return None;
}
for label in &labels {
if label.is_empty() {
return None;
}
if label[0] == b'-' || label[label.len() - 1] == b'-' {
return None;
}
if !label
.iter()
.all(|&b| b.is_ascii_alphanumeric() || b == b'-')
{
return None;
}
}
let tld = labels[labels.len() - 1];
if tld.len() < 2 || !tld.iter().all(u8::is_ascii_alphabetic) {
return None;
}
Some(end)
}
#[must_use]
pub fn luhn_check(digits: &[u8]) -> bool {
if digits.is_empty() {
return false;
}
let mut sum = 0u32;
let mut double = false;
let mut all_zero = true;
for &d in digits.iter().rev() {
if !d.is_ascii_digit() {
return false;
}
let mut v = u32::from(d - b'0');
if v != 0 {
all_zero = false;
}
if double {
v *= 2;
if v > 9 {
v -= 9;
}
}
sum += v;
double = !double;
}
!all_zero && sum.is_multiple_of(10)
}
fn scan_credit_cards(text: &str, out: &mut Vec<PiiMatch>) {
let bytes = text.as_bytes();
let n = bytes.len();
let mut i = 0usize;
while i < n {
if !is_ascii_digit(bytes[i]) {
i += 1;
continue;
}
if i > 0 && is_ascii_digit(bytes[i - 1]) {
i += 1;
continue;
}
let start = i;
let mut j = i;
let mut digits: Vec<u8> = Vec::with_capacity(19);
let mut last_digit_end = i;
while j < n {
if is_ascii_digit(bytes[j]) {
digits.push(bytes[j]);
j += 1;
last_digit_end = j;
if digits.len() > 19 {
break;
}
} else if (bytes[j] == b' ' || bytes[j] == b'-')
&& j + 1 < n
&& is_ascii_digit(bytes[j + 1])
{
j += 1;
} else {
break;
}
}
let count = digits.len();
if (13..=19).contains(&count) && luhn_check(&digits) {
let after_ok = last_digit_end >= n || !is_ascii_digit(bytes[last_digit_end]);
let before_ok = start == 0 || !is_ascii_digit(bytes[start - 1]);
if after_ok && before_ok {
out.push(PiiMatch {
kind: PiiKind::CreditCard,
start,
end: last_digit_end,
matched: text[start..last_digit_end].to_string(),
});
i = last_digit_end;
continue;
}
}
i = if last_digit_end > start {
last_digit_end
} else {
start + 1
};
}
}
fn valid_ssn_parts(area: u16, group: u8, serial: u16) -> bool {
if area == 0 || area == 666 || area >= 900 {
return false;
}
if group == 0 {
return false;
}
if serial == 0 {
return false;
}
true
}
fn scan_ssn(text: &str, out: &mut Vec<PiiMatch>) {
let bytes = text.as_bytes();
let n = bytes.len();
let mut i = 0usize;
while i + 9 <= n {
if !is_ascii_digit(bytes[i]) {
i += 1;
continue;
}
if i > 0 && is_ascii_digit(bytes[i - 1]) {
i += 1;
continue;
}
if let Some((end, area, group, serial)) = parse_ssn_separated(bytes, i) {
if valid_ssn_parts(area, group, serial)
&& (end >= n || !is_ascii_digit(bytes[end]))
&& (end >= n || bytes[end] != b'-')
{
out.push(PiiMatch {
kind: PiiKind::Ssn,
start: i,
end,
matched: text[i..end].to_string(),
});
i = end;
continue;
}
}
if i + 9 <= n
&& bytes[i..i + 9].iter().all(|&b| is_ascii_digit(b))
&& (i + 9 >= n || !is_ascii_digit(bytes[i + 9]))
{
let area = parse_u16(&bytes[i..i + 3]);
let group = (bytes[i + 3] - b'0') * 10 + (bytes[i + 4] - b'0');
let serial = parse_u16(&bytes[i + 5..i + 9]);
if valid_ssn_parts(area, group, serial) {
out.push(PiiMatch {
kind: PiiKind::Ssn,
start: i,
end: i + 9,
matched: text[i..i + 9].to_string(),
});
i += 9;
continue;
}
}
i += 1;
}
}
fn parse_ssn_separated(bytes: &[u8], pos: usize) -> Option<(usize, u16, u8, u16)> {
let n = bytes.len();
if pos + 11 > n {
return None;
}
if !bytes[pos..pos + 3].iter().all(|&b| is_ascii_digit(b)) {
return None;
}
let sep = bytes[pos + 3];
if sep != b'-' && sep != b' ' {
return None;
}
if !bytes[pos + 4..pos + 6].iter().all(|&b| is_ascii_digit(b)) {
return None;
}
if bytes[pos + 6] != sep {
return None;
}
if !bytes[pos + 7..pos + 11].iter().all(|&b| is_ascii_digit(b)) {
return None;
}
let area = parse_u16(&bytes[pos..pos + 3]);
let group = (bytes[pos + 4] - b'0') * 10 + (bytes[pos + 5] - b'0');
let serial = parse_u16(&bytes[pos + 7..pos + 11]);
Some((pos + 11, area, group, serial))
}
#[inline]
fn parse_u16(digits: &[u8]) -> u16 {
let mut v = 0u16;
for &d in digits {
v = v * 10 + u16::from(d - b'0');
}
v
}
fn scan_phones(text: &str, out: &mut Vec<PiiMatch>) {
let bytes = text.as_bytes();
let n = bytes.len();
let mut i = 0usize;
while i < n {
let has_plus = bytes[i] == b'+';
let digit_start = if has_plus { i + 1 } else { i };
if digit_start >= n || !is_ascii_digit(bytes[digit_start]) {
i += 1;
continue;
}
if i > 0 && (is_ascii_digit(bytes[i - 1]) || bytes[i - 1] == b'+') {
i += 1;
continue;
}
let start = i;
let mut j = digit_start;
let mut digit_count = 0usize;
let mut has_separator = false;
let mut last_digit_end = digit_start;
while j < n {
let b = bytes[j];
if is_ascii_digit(b) {
digit_count += 1;
j += 1;
last_digit_end = j;
if digit_count > 15 {
break;
}
} else if matches!(b, b' ' | b'-' | b'.' | b'(' | b')')
&& j + 1 < n
&& (is_ascii_digit(bytes[j + 1])
|| matches!(bytes[j + 1], b' ' | b'-' | b'.' | b'(' | b')'))
{
has_separator = true;
j += 1;
} else {
break;
}
}
let qualifies = (10..=15).contains(&digit_count) && (has_separator || has_plus);
if qualifies {
let end = last_digit_end;
out.push(PiiMatch {
kind: PiiKind::Phone,
start,
end,
matched: text[start..end].to_string(),
});
i = end;
} else {
i = if last_digit_end > start {
last_digit_end
} else {
start + 1
};
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn luhn_known_valid() {
assert!(luhn_check(b"4111111111111111"));
assert!(luhn_check(b"5500005555555559"));
assert!(luhn_check(b"378282246310005"));
assert!(luhn_check(b"6011000990139424"));
}
#[test]
fn luhn_known_invalid() {
assert!(!luhn_check(b"4111111111111112"));
assert!(!luhn_check(b"1234567812345678"));
assert!(!luhn_check(b"0000000000000000"));
assert!(!luhn_check(b""));
}
#[test]
fn detects_simple_email() {
let d = PiiDetector::new();
let r = d.scan_str("reach me: jane.doe@example.com please");
assert_eq!(r.count_kind(PiiKind::Email), 1);
assert_eq!(r.matches[0].matched, "jane.doe@example.com");
}
#[test]
fn detects_email_with_plus_and_subdomain() {
let d = PiiDetector::new();
let r = d.scan_str("a+b@mail.sub.example.co.uk");
assert_eq!(r.count_kind(PiiKind::Email), 1);
assert_eq!(r.matches[0].matched, "a+b@mail.sub.example.co.uk");
}
#[test]
fn ignores_non_email_at() {
let d = PiiDetector::new();
assert!(d.scan_str("@handle and user@ and @ alone").is_empty());
assert!(!d.scan_str("foo@localhost").contains_kind(PiiKind::Email));
}
#[test]
fn email_trailing_dot_trimmed() {
let d = PiiDetector::new();
let r = d.scan_str("ping bob@example.com. done");
assert_eq!(r.matches[0].matched, "bob@example.com");
}
#[test]
fn detects_credit_card_plain() {
let d = PiiDetector::new();
let r = d.scan_str("card 4111111111111111 on file");
assert_eq!(r.count_kind(PiiKind::CreditCard), 1);
assert_eq!(r.matches[0].matched, "4111111111111111");
}
#[test]
fn detects_credit_card_with_separators() {
let d = PiiDetector::new();
let r = d.scan_str("4111-1111-1111-1111");
assert_eq!(r.count_kind(PiiKind::CreditCard), 1);
assert_eq!(r.matches[0].matched, "4111-1111-1111-1111");
let r2 = d.scan_str("4111 1111 1111 1111");
assert_eq!(r2.count_kind(PiiKind::CreditCard), 1);
}
#[test]
fn ignores_invalid_luhn_card() {
let d = PiiDetector::new();
let r = d.scan_str("4111111111111112");
assert!(!r.contains_kind(PiiKind::CreditCard));
}
#[test]
fn ignores_too_short_or_too_long_digit_runs() {
let d = PiiDetector::new();
assert!(!d
.scan_str("411111111111")
.contains_kind(PiiKind::CreditCard));
assert!(!d
.scan_str("411111111111111111111")
.contains_kind(PiiKind::CreditCard));
}
#[test]
fn detects_ssn_dashed() {
let d = PiiDetector::new();
let r = d.scan_str("ssn: 123-45-6789");
assert_eq!(r.count_kind(PiiKind::Ssn), 1);
assert_eq!(r.matches[0].matched, "123-45-6789");
}
#[test]
fn detects_ssn_spaced_and_bare() {
let d = PiiDetector::new();
assert!(d.scan_str("123 45 6789").contains_kind(PiiKind::Ssn));
assert!(d.scan_str("078051120").contains_kind(PiiKind::Ssn));
}
#[test]
fn ignores_invalid_ssn_ranges() {
let d = PiiDetector::new();
assert!(!d.scan_str("000-45-6789").contains_kind(PiiKind::Ssn));
assert!(!d.scan_str("666-45-6789").contains_kind(PiiKind::Ssn));
assert!(!d.scan_str("123-00-6789").contains_kind(PiiKind::Ssn));
assert!(!d.scan_str("123-45-0000").contains_kind(PiiKind::Ssn));
assert!(!d.scan_str("900-45-6789").contains_kind(PiiKind::Ssn));
}
#[test]
fn detects_phone_us_formats() {
let d = PiiDetector::new();
assert!(d
.scan_str("call 555-123-4567")
.contains_kind(PiiKind::Phone));
assert!(d.scan_str("(555) 123-4567").contains_kind(PiiKind::Phone));
assert!(d.scan_str("+1 555 123 4567").contains_kind(PiiKind::Phone));
assert!(d.scan_str("+44 20 7946 0958").contains_kind(PiiKind::Phone));
}
#[test]
fn phone_requires_separator_or_plus() {
let d = PiiDetector::default();
let r = d.scan_str("5551234567");
assert!(!r.contains_kind(PiiKind::Phone));
}
#[test]
fn mask_replaces_all_pii() {
let d = PiiDetector::new();
let text = "email a@b.com card 4111111111111111 ssn 123-45-6789 tel 555-123-4567";
let (masked, report) = d.mask_str(text);
assert!(!masked.contains("a@b.com"));
assert!(!masked.contains("4111111111111111"));
assert!(!masked.contains("123-45-6789"));
assert!(!masked.contains("555-123-4567"));
assert_eq!(report.total(), 4);
assert!(report.contains_kind(PiiKind::Email));
assert!(report.contains_kind(PiiKind::CreditCard));
assert!(report.contains_kind(PiiKind::Ssn));
assert!(report.contains_kind(PiiKind::Phone));
}
#[test]
fn mask_preserve_length() {
let config = PiiConfig::default()
.with_mask("*")
.with_preserve_length(true);
let d = PiiDetector::with_config(config);
let (masked, _) = d.mask_str("a@b.com");
assert_eq!(masked, "*".repeat("a@b.com".chars().count()));
}
#[test]
fn mask_preserves_surrounding_text() {
let d = PiiDetector::new();
let (masked, _) = d.mask_str("before a@b.com after");
assert!(masked.starts_with("before "));
assert!(masked.ends_with(" after"));
assert!(masked.contains("[REDACTED]"));
}
#[test]
fn clean_text_unchanged() {
let d = PiiDetector::new();
let (masked, report) = d.mask_str("nothing sensitive here at all");
assert_eq!(masked, "nothing sensitive here at all");
assert!(report.is_empty());
}
#[test]
fn scan_and_mask_task_values() {
let d = PiiDetector::new();
let mut value = TaskValue::Object(vec![
("name".to_string(), TaskValue::from("Alice")),
("contact".to_string(), TaskValue::from("alice@example.com")),
(
"cards".to_string(),
TaskValue::Array(vec![TaskValue::from("4111111111111111")]),
),
]);
let scan = d.scan_value(&value);
assert_eq!(scan.count_kind(PiiKind::Email), 1);
assert_eq!(scan.count_kind(PiiKind::CreditCard), 1);
let report = d.mask_value(&mut value);
assert_eq!(report.total(), 2);
let json = serde_json::to_string(&value).unwrap();
assert!(!json.contains("alice@example.com"));
assert!(!json.contains("4111111111111111"));
}
#[test]
fn scan_call_args_and_kwargs() {
let d = PiiDetector::new();
let args = vec![TaskValue::from("ping bob@host.org")];
let kwargs = vec![("ssn".to_string(), TaskValue::from("123-45-6789"))];
let report = d.scan_call(&args, &kwargs);
assert!(report.contains_kind(PiiKind::Email));
assert!(report.contains_kind(PiiKind::Ssn));
}
#[test]
fn report_helpers() {
let d = PiiDetector::new();
let report = d.scan_str("a@b.com c@d.com");
assert_eq!(report.total(), 2);
assert_eq!(report.count_kind(PiiKind::Email), 2);
assert_eq!(report.kinds().len(), 1);
assert!(!report.is_empty());
}
#[test]
fn config_serde_roundtrip() {
let config = PiiConfig::email_only().with_mask("###");
let json = serde_json::to_string(&config).expect("serialize");
let restored: PiiConfig = serde_json::from_str(&json).expect("deserialize");
assert!(restored.detect_email);
assert!(!restored.detect_phone);
assert_eq!(restored.mask, "###");
}
#[test]
fn disabled_detectors_skip() {
let d = PiiDetector::with_config(PiiConfig::email_only());
let r = d.scan_str("card 4111111111111111 mail x@y.com");
assert!(r.contains_kind(PiiKind::Email));
assert!(!r.contains_kind(PiiKind::CreditCard));
}
#[test]
fn non_overlapping_matches() {
let d = PiiDetector::new();
let r = d.scan_str("123-45-6789");
assert_eq!(r.total(), 1);
assert_eq!(r.matches[0].kind, PiiKind::Ssn);
}
}