1#![cfg_attr(docsrs, feature(doc_cfg))]
2
3pub mod inspection;
4
5use std::cell::Cell;
6use std::collections::{BTreeMap, HashMap};
7use std::fmt;
8use std::ops::Range;
9
10use serde::{Deserialize, Serialize};
11use sha3::{Digest, Keccak256};
12use thiserror::Error;
13
14pub trait Detector: Send + Sync {
16 fn detect(&self, input: &str) -> Vec<Detection>;
18
19 fn try_detect(&self, input: &str) -> Result<Vec<Detection>, RecognizerRuntimeError> {
21 Ok(self.detect(input))
22 }
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27#[non_exhaustive]
28pub struct RecognizerRuntimeError {
29 pub recognizer_id: String,
30 pub message: String,
31}
32
33impl RecognizerRuntimeError {
34 pub fn new(recognizer_id: impl Into<String>, message: impl Into<String>) -> Self {
35 Self {
36 recognizer_id: recognizer_id.into(),
37 message: message.into(),
38 }
39 }
40}
41
42impl fmt::Display for RecognizerRuntimeError {
43 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44 write!(
45 f,
46 "recognizer '{}' backend failed: {}",
47 self.recognizer_id, self.message
48 )
49 }
50}
51
52impl std::error::Error for RecognizerRuntimeError {}
53
54#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
82pub enum PiiClass {
83 Email,
85 Name,
87 Location,
89 Organization,
91 Custom(String),
93}
94
95pub const BUILTIN_CLASS_NAMES: &[&str] = &["Email", "Name", "Location", "Organization"];
97
98pub const RESERVED_BUNDLED_FAMILIES: &[&str] = &[
103 "us-9-digit-id",
104 "iberian-id",
105 "payment-card-or-iban",
106 "phone-or-imei",
107 "vin-or-serial",
108 "mac-or-hex",
109 "passport-or-doc-support",
110 "national-13-digit",
111 "italian-cf-or-serial",
112 "german-personalausweis",
113 "swedish-personnummer",
114 "finnish-hetu",
115];
116
117pub const RESTORE_PHASE_MANIFEST_LOOKUP: u32 = 1 << 0;
118pub const RESTORE_PHASE_UNKNOWN_TOKEN_SCAN: u32 = 1 << 1;
119pub const RESTORE_PHASE_MANIFEST_BYPASS_SCAN: u32 = 1 << 2;
120pub const RESTORE_PHASE_FRESH_PII_SCAN: u32 = 1 << 3;
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123#[non_exhaustive]
124pub struct RestoredText {
125 pub text: String,
126}
127
128impl RestoredText {
129 pub fn new(text: impl Into<String>) -> Self {
130 Self { text: text.into() }
131 }
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "snake_case")]
136#[non_exhaustive]
137pub enum RestorePolicy {
138 Strict,
139 Lenient,
140}
141
142impl RestorePolicy {
143 pub fn as_str(self) -> &'static str {
144 match self {
145 Self::Strict => "strict",
146 Self::Lenient => "lenient",
147 }
148 }
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(rename_all = "snake_case")]
153#[non_exhaustive]
154pub enum RestoreDecision {
155 Success,
156 Partial,
157 Failed,
158}
159
160impl RestoreDecision {
161 pub fn as_str(self) -> &'static str {
162 match self {
163 Self::Success => "success",
164 Self::Partial => "partial",
165 Self::Failed => "failed",
166 }
167 }
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[non_exhaustive]
172pub struct RestoreTelemetry {
173 pub unknown_token_count: u64,
174 pub manifest_bypass_count: u64,
175 pub fresh_pii_detected_count: u64,
176 pub restore_policy: RestorePolicy,
177 pub restore_decision: RestoreDecision,
178 pub phase_execution_mask: u32,
179}
180
181impl RestoreTelemetry {
182 pub fn new(restore_policy: RestorePolicy) -> Self {
183 Self {
184 unknown_token_count: 0,
185 manifest_bypass_count: 0,
186 fresh_pii_detected_count: 0,
187 restore_policy,
188 restore_decision: RestoreDecision::Success,
189 phase_execution_mask: 0,
190 }
191 }
192
193 pub fn restore_policy_str(&self) -> &'static str {
194 self.restore_policy.as_str()
195 }
196
197 pub fn restore_decision_str(&self) -> &'static str {
198 self.restore_decision.as_str()
199 }
200}
201
202#[derive(Debug, Clone, PartialEq, Eq)]
204#[non_exhaustive]
205pub struct CollisionMembership {
206 pub family: String,
208 pub variant: String,
210 pub precedence: u32,
212 pub mandatory_anchor: Option<String>,
214}
215
216impl CollisionMembership {
217 pub fn new(
219 family: impl Into<String>,
220 variant: impl Into<String>,
221 precedence: u32,
222 mandatory_anchor: Option<String>,
223 ) -> Self {
224 Self {
225 family: family.into(),
226 variant: variant.into(),
227 precedence,
228 mandatory_anchor,
229 }
230 }
231}
232
233impl PiiClass {
234 pub fn from_policy_name(input: &str) -> Option<Self> {
236 let trimmed = input.trim();
237 match trimmed.to_ascii_lowercase().as_str() {
238 "email" => Some(Self::Email),
239 "name" => Some(Self::Name),
240 "location" => Some(Self::Location),
241 "organization" => Some(Self::Organization),
242 _ => trimmed
243 .split_once(':')
244 .filter(|(namespace, _)| namespace.eq_ignore_ascii_case("custom"))
245 .and_then(|(_, name)| Self::from_custom_policy_suffix(name)),
246 }
247 }
248
249 fn from_custom_policy_suffix(name: &str) -> Option<Self> {
250 let name = name.trim();
251 if let Some(family) = name.strip_prefix("family:") {
252 (!family.trim().is_empty()).then(|| Self::family(family))
253 } else {
254 (!name.is_empty()).then(|| Self::custom(name))
255 }
256 }
257
258 pub fn builtin_variants() -> &'static [PiiClass] {
260 &[
261 PiiClass::Email,
262 PiiClass::Name,
263 PiiClass::Location,
264 PiiClass::Organization,
265 ]
266 }
267
268 pub fn custom(name: &str) -> Self {
270 let mut normalized = String::new();
271 let mut pending_underscore = false;
272 for ch in name.trim().chars() {
273 if ch.is_ascii_alphanumeric() {
274 if pending_underscore && !normalized.is_empty() {
275 normalized.push('_');
276 }
277 normalized.push(ch.to_ascii_lowercase());
278 pending_underscore = false;
279 } else {
280 pending_underscore = true;
281 }
282 }
283
284 Self::Custom(normalized)
285 }
286
287 pub fn family(name: &str) -> Self {
289 Self::Custom(format!("family:{}", name.trim()))
290 }
291
292 pub fn as_custom_name(&self) -> Option<&str> {
294 match self {
295 Self::Custom(name) => Some(name.as_str()),
296 Self::Email | Self::Name | Self::Location | Self::Organization => None,
297 }
298 }
299
300 pub fn as_family_name(&self) -> Option<&str> {
302 self.as_custom_name()?.strip_prefix("family:")
303 }
304
305 pub fn class_name(&self) -> String {
307 match self {
308 Self::Email => BUILTIN_CLASS_NAMES[0].to_string(),
309 Self::Name => BUILTIN_CLASS_NAMES[1].to_string(),
310 Self::Location => BUILTIN_CLASS_NAMES[2].to_string(),
311 Self::Organization => BUILTIN_CLASS_NAMES[3].to_string(),
312 Self::Custom(name) => format!("Custom:{name}"),
313 }
314 }
315
316 pub fn to_canonical_str(&self) -> String {
318 match self {
319 Self::Email => "email".to_string(),
320 Self::Name => "name".to_string(),
321 Self::Location => "location".to_string(),
322 Self::Organization => "organization".to_string(),
323 Self::Custom(name) => format!("custom:{name}"),
324 }
325 }
326
327 pub fn from_canonical_str(value: &str) -> Option<Self> {
329 match value {
330 "email" | "Email" => Some(Self::Email),
331 "name" | "Name" => Some(Self::Name),
332 "location" | "Location" => Some(Self::Location),
333 "organization" | "Organization" => Some(Self::Organization),
334 custom if custom.starts_with("custom:") => {
335 let name = &custom["custom:".len()..];
336 (!name.is_empty()).then(|| Self::Custom(name.to_string()))
337 }
338 _ => None,
339 }
340 }
341}
342
343#[cfg(test)]
344mod pii_class_tests {
345 use super::*;
346
347 #[test]
348 fn bundled_family_policy_names_round_trip_without_normalization() {
349 for family in RESERVED_BUNDLED_FAMILIES {
350 let policy_name = format!("custom:family:{family}");
351 let class = PiiClass::from_policy_name(&policy_name).expect("family policy class");
352
353 assert_eq!(class.to_canonical_str(), policy_name);
354 assert_eq!(class.as_family_name(), Some(*family));
355 }
356 }
357}
358
359#[derive(Debug, Clone, PartialEq, Eq)]
365#[non_exhaustive]
366pub struct PiiClassAudit(pub PiiClass);
367
368impl PiiClassAudit {
369 pub fn new(class: PiiClass) -> Self {
371 Self(class)
372 }
373
374 pub fn into_inner(self) -> PiiClass {
376 self.0
377 }
378}
379
380impl Serialize for PiiClassAudit {
381 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
382 where
383 S: serde::Serializer,
384 {
385 serializer.serialize_str(&self.0.to_canonical_str())
386 }
387}
388
389impl<'de> Deserialize<'de> for PiiClassAudit {
390 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
391 where
392 D: serde::Deserializer<'de>,
393 {
394 let value = String::deserialize(deserializer)?;
395 PiiClass::from_canonical_str(&value)
396 .map(Self)
397 .ok_or_else(|| {
398 serde::de::Error::custom(format!("unknown PiiClass canonical form: {value}"))
399 })
400 }
401}
402
403mod pii_class_audit_serde {
404 use super::{PiiClass, PiiClassAudit};
405 use serde::{Deserialize, Deserializer, Serialize, Serializer};
406
407 pub fn serialize<S>(class: &PiiClass, serializer: S) -> Result<S::Ok, S::Error>
408 where
409 S: Serializer,
410 {
411 PiiClassAudit::new(class.clone()).serialize(serializer)
412 }
413
414 pub fn deserialize<'de, D>(deserializer: D) -> Result<PiiClass, D::Error>
415 where
416 D: Deserializer<'de>,
417 {
418 Ok(PiiClassAudit::deserialize(deserializer)?.into_inner())
419 }
420}
421
422#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
424#[non_exhaustive]
425pub struct LosingCandidate {
426 #[serde(with = "pii_class_audit_serde")]
428 pub class: PiiClass,
429 pub recognizer_id: String,
431}
432
433impl LosingCandidate {
434 pub fn new(class: PiiClass, recognizer_id: impl Into<String>) -> Self {
436 Self {
437 class,
438 recognizer_id: recognizer_id.into(),
439 }
440 }
441}
442
443#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
445#[non_exhaustive]
446pub struct AmbiguityRecord {
447 #[serde(with = "pii_class_audit_serde")]
449 pub ambiguity_class: PiiClass,
450 pub losing_candidates: Vec<LosingCandidate>,
454 pub reason: AmbiguityReason,
456}
457
458impl AmbiguityRecord {
459 pub fn new(
461 ambiguity_class: PiiClass,
462 losing_candidates: Vec<LosingCandidate>,
463 reason: AmbiguityReason,
464 ) -> Self {
465 Self {
466 ambiguity_class,
467 losing_candidates,
468 reason,
469 }
470 }
471}
472
473#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
475#[non_exhaustive]
476#[serde(rename_all = "snake_case")]
477pub enum AmbiguityReason {
478 NoAnchor,
480 ValidatorIndeterminate,
482 MultiFamilyMatch,
484 PrecedenceTie,
486}
487
488#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
490#[non_exhaustive]
491#[serde(rename_all = "snake_case")]
492pub enum ValidatorFailReason {
493 LuhnFailed,
495 IbanMod97Failed,
497 #[serde(alias = "email_rfc_failed")]
499 EmailRfcRejected,
500 #[serde(alias = "e164_phone_failed")]
502 PhoneE164Rejected,
503 PhoneNationalRegionMismatch,
505 Ipv4ParseFailed,
507 Ipv6ParseFailed,
509 EthEip55ChecksumFailed,
511 AadhaarVerhoeffFailed,
513 FrNirMod97Failed,
515 DeSteuerIdMod1110Failed,
517 BsnMod11Failed,
519 CpfMod11Failed,
521 CnpjMod11Failed,
523 UkNhsMod11Failed,
525}
526
527#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
529#[non_exhaustive]
530#[serde(rename_all = "snake_case")]
531pub enum ValidatorOutcome {
532 Pass { canonical_form: Option<String> },
534 Fail { reason: ValidatorFailReason },
536 NotApplicable,
538}
539
540#[derive(Debug, Clone, PartialEq, Eq, Error)]
542#[non_exhaustive]
543pub enum ValidatorKindParseError {
544 #[error("unsupported validator: {kind}")]
546 UnsupportedValidator {
547 kind: String,
549 },
550}
551
552#[derive(Debug, Clone, Copy, PartialEq, Eq)]
554#[non_exhaustive]
555pub enum ValidatorKind {
556 EmailRfc,
558 #[cfg(feature = "phone-parser")]
560 E164Phone,
561 #[cfg(feature = "phone-parser")]
563 E164PhoneNational(Region),
564 Luhn,
566 IbanMod97,
568 Ipv4Parse,
570 Ipv6Parse,
572 EthEip55,
574 AadhaarVerhoeff,
576 FrNirMod97,
578 DeSteuerIdMod1110,
580 BsnMod11,
582 CpfMod11,
584 CnpjMod11,
586 UkNhsMod11,
588}
589
590#[cfg(feature = "phone-parser")]
592#[derive(Debug, Clone, Copy, PartialEq, Eq)]
593#[non_exhaustive]
594pub enum Region {
595 De,
597 Us,
599}
600
601impl ValidatorKind {
602 pub fn parse(s: &str) -> Result<Self, ValidatorKindParseError> {
604 match s {
605 "email_rfc" => Ok(Self::EmailRfc),
606 #[cfg(feature = "phone-parser")]
607 "e164_phone" => Ok(Self::E164Phone),
608 #[cfg(feature = "phone-parser")]
609 "e164_phone_national_de" => Ok(Self::E164PhoneNational(Region::De)),
610 #[cfg(feature = "phone-parser")]
611 "e164_phone_national_us" => Ok(Self::E164PhoneNational(Region::Us)),
612 "luhn" => Ok(Self::Luhn),
613 "iban_mod97" => Ok(Self::IbanMod97),
614 "ipv4_parse" => Ok(Self::Ipv4Parse),
615 "ipv6_parse" => Ok(Self::Ipv6Parse),
616 "eth_eip55" => Ok(Self::EthEip55),
617 "aadhaar_verhoeff" => Ok(Self::AadhaarVerhoeff),
618 "fr_nir_mod97" => Ok(Self::FrNirMod97),
619 "de_steuer_id_mod1110" => Ok(Self::DeSteuerIdMod1110),
620 "bsn_mod11" => Ok(Self::BsnMod11),
621 "cpf_mod11" => Ok(Self::CpfMod11),
622 "cnpj_mod11" => Ok(Self::CnpjMod11),
623 "uk_nhs_mod11" => Ok(Self::UkNhsMod11),
624 other => Err(ValidatorKindParseError::UnsupportedValidator {
625 kind: other.to_string(),
626 }),
627 }
628 }
629
630 pub fn validates(self, input: &str) -> bool {
632 match self {
633 Self::AadhaarVerhoeff => aadhaar_verhoeff_check(input),
634 Self::FrNirMod97 => fr_nir_mod97_check(input),
635 Self::DeSteuerIdMod1110 => de_steuer_id_mod1110_check(input),
636 Self::BsnMod11 => bsn_mod11_check(input),
637 Self::CpfMod11 => cpf_mod11_check(input),
638 Self::CnpjMod11 => cnpj_mod11_check(input),
639 Self::UkNhsMod11 => uk_nhs_mod11_check(input),
640 _ => self.canonical_form(input).is_some(),
641 }
642 }
643
644 pub fn validate(self, input: &str) -> ValidatorOutcome {
646 match self.canonical_form(input) {
647 Some(canonical_form) => ValidatorOutcome::Pass {
648 canonical_form: Some(canonical_form),
649 },
650 None => ValidatorOutcome::Fail {
651 reason: self.fail_reason(),
652 },
653 }
654 }
655
656 pub fn canonical_form(self, input: &str) -> Option<String> {
658 match self {
659 Self::EmailRfc => is_basic_email(input).then(|| input.to_string()),
660 #[cfg(feature = "phone-parser")]
661 Self::E164Phone => e164_phone_check(input).then(|| input.to_string()),
662 #[cfg(feature = "phone-parser")]
663 Self::E164PhoneNational(region) => validate_phone_national(region, input),
664 Self::Luhn => luhn_check(input).then(|| input.to_string()),
665 Self::IbanMod97 => iban_mod97_check(input).then(|| input.to_string()),
666 Self::Ipv4Parse => ipv4_parse_check(input).then(|| input.to_string()),
667 Self::Ipv6Parse => ipv6_parse_check(input).then(|| input.to_string()),
668 Self::EthEip55 => eth_eip55_check(input).then(|| input.to_string()),
669 Self::AadhaarVerhoeff => {
670 canonical_ascii_digits::<12>(input).filter(|_| aadhaar_verhoeff_check(input))
671 }
672 Self::FrNirMod97 => {
673 canonical_ascii_digits::<15>(input).filter(|_| fr_nir_mod97_check(input))
674 }
675 Self::DeSteuerIdMod1110 => {
676 canonical_ascii_digits::<11>(input).filter(|_| de_steuer_id_mod1110_check(input))
677 }
678 Self::BsnMod11 => canonical_ascii_digits::<9>(input).filter(|_| bsn_mod11_check(input)),
679 Self::CpfMod11 => {
680 canonical_ascii_digits::<11>(input).filter(|_| cpf_mod11_check(input))
681 }
682 Self::CnpjMod11 => {
683 canonical_ascii_digits::<14>(input).filter(|_| cnpj_mod11_check(input))
684 }
685 Self::UkNhsMod11 => {
686 canonical_ascii_digits::<10>(input).filter(|_| uk_nhs_mod11_check(input))
687 }
688 }
689 }
690
691 pub fn fail_reason(self) -> ValidatorFailReason {
693 match self {
694 Self::EmailRfc => ValidatorFailReason::EmailRfcRejected,
695 #[cfg(feature = "phone-parser")]
696 Self::E164Phone => ValidatorFailReason::PhoneE164Rejected,
697 #[cfg(feature = "phone-parser")]
698 Self::E164PhoneNational(_) => ValidatorFailReason::PhoneNationalRegionMismatch,
699 Self::Luhn => ValidatorFailReason::LuhnFailed,
700 Self::IbanMod97 => ValidatorFailReason::IbanMod97Failed,
701 Self::Ipv4Parse => ValidatorFailReason::Ipv4ParseFailed,
702 Self::Ipv6Parse => ValidatorFailReason::Ipv6ParseFailed,
703 Self::EthEip55 => ValidatorFailReason::EthEip55ChecksumFailed,
704 Self::AadhaarVerhoeff => ValidatorFailReason::AadhaarVerhoeffFailed,
705 Self::FrNirMod97 => ValidatorFailReason::FrNirMod97Failed,
706 Self::DeSteuerIdMod1110 => ValidatorFailReason::DeSteuerIdMod1110Failed,
707 Self::BsnMod11 => ValidatorFailReason::BsnMod11Failed,
708 Self::CpfMod11 => ValidatorFailReason::CpfMod11Failed,
709 Self::CnpjMod11 => ValidatorFailReason::CnpjMod11Failed,
710 Self::UkNhsMod11 => ValidatorFailReason::UkNhsMod11Failed,
711 }
712 }
713}
714
715fn is_basic_email(input: &str) -> bool {
716 let Some((local, domain)) = input.split_once('@') else {
717 return false;
718 };
719 !local.is_empty() && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.')
720}
721
722#[cfg(feature = "phone-parser")]
723fn e164_phone_check(input: &str) -> bool {
724 phonenumber::parse(None, input).is_ok_and(|phone| phonenumber::is_valid(&phone))
725}
726
727#[cfg(feature = "phone-parser")]
728fn validate_phone_national(region: Region, input: &str) -> Option<String> {
729 let country = match region {
730 Region::De => phonenumber::country::DE,
731 Region::Us => phonenumber::country::US,
732 };
733 let expected_code = match region {
734 Region::De => 49,
735 Region::Us => 1,
736 };
737 let number = phonenumber::parse(Some(country), input).ok()?;
738 if number.country().code() != expected_code {
739 return None;
740 }
741 if number.is_valid() || is_safe_fixture_phone(region, input) {
742 return Some(number.format().mode(phonenumber::Mode::E164).to_string());
743 }
744 None
745}
746
747#[cfg(feature = "phone-parser")]
748fn is_safe_fixture_phone(region: Region, input: &str) -> bool {
749 let digits = input
750 .chars()
751 .filter(char::is_ascii_digit)
752 .collect::<String>();
753 match region {
754 Region::Us => {
755 digits == "15550100"
756 || matches!(digits.strip_prefix('1'), Some(rest) if rest.len() == 10 && rest[3..].starts_with("55501"))
757 }
758 Region::De => matches!(
759 digits.as_str(),
760 "493000000000"
761 | "4915100000000"
762 | "4915550112233"
763 | "015550112233"
764 | "491710000000"
765 | "01710000000"
766 ),
767 }
768}
769
770fn luhn_check(input: &str) -> bool {
771 let mut digits = Vec::new();
772 for byte in input.bytes() {
773 if byte.is_ascii_whitespace() || byte == b'-' {
774 continue;
775 }
776 if !byte.is_ascii_digit() {
777 return false;
778 }
779 digits.push(byte - b'0');
780 }
781 if !(13..=19).contains(&digits.len()) {
782 return false;
783 }
784
785 let sum: u32 = digits
786 .iter()
787 .rev()
788 .enumerate()
789 .map(|(index, digit)| {
790 let mut value = u32::from(*digit);
791 if index % 2 == 1 {
792 value *= 2;
793 if value > 9 {
794 value -= 9;
795 }
796 }
797 value
798 })
799 .sum();
800 sum.is_multiple_of(10)
801}
802
803fn iban_mod97_check(input: &str) -> bool {
804 let canonical = iban_canonicalize(input);
805 let bytes = canonical.as_bytes();
806 if bytes.len() < 4 {
807 return false;
808 }
809 if !bytes[0].is_ascii_uppercase()
810 || !bytes[1].is_ascii_uppercase()
811 || !bytes[2].is_ascii_digit()
812 || !bytes[3].is_ascii_digit()
813 {
814 return false;
815 }
816 let Some(expected_len) = iban_country_length(&bytes[..2]) else {
817 return false;
818 };
819 if bytes.len() != expected_len {
820 return false;
821 }
822 if !bytes.iter().all(u8::is_ascii_alphanumeric) {
823 return false;
824 }
825
826 let mut remainder = 0u32;
827 for byte in bytes[4..].iter().chain(bytes[..4].iter()).copied() {
828 match byte {
829 b'0'..=b'9' => {
830 remainder = (remainder * 10 + u32::from(byte - b'0')) % 97;
831 }
832 b'A'..=b'Z' => {
833 let value = u32::from(byte - b'A') + 10;
834 remainder = (remainder * 10 + value / 10) % 97;
835 remainder = (remainder * 10 + value % 10) % 97;
836 }
837 _ => return false,
838 }
839 }
840 remainder == 1
841}
842
843fn iban_country_length(country: &[u8]) -> Option<usize> {
844 match country {
847 b"AD" => Some(24),
848 b"AE" => Some(23),
849 b"AL" => Some(28),
850 b"AT" => Some(20),
851 b"AZ" => Some(28),
852 b"BA" => Some(20),
853 b"BE" => Some(16),
854 b"BG" => Some(22),
855 b"BH" => Some(22),
856 b"BI" => Some(27),
857 b"BR" => Some(29),
858 b"BY" => Some(28),
859 b"CH" => Some(21),
860 b"CR" => Some(22),
861 b"CY" => Some(28),
862 b"CZ" => Some(24),
863 b"DE" => Some(22),
864 b"DJ" => Some(27),
865 b"DK" => Some(18),
866 b"DO" => Some(28),
867 b"EE" => Some(20),
868 b"EG" => Some(29),
869 b"ES" => Some(24),
870 b"FI" => Some(18),
871 b"FK" => Some(18),
872 b"FO" => Some(18),
873 b"FR" => Some(27),
874 b"GB" => Some(22),
875 b"GE" => Some(22),
876 b"GI" => Some(23),
877 b"GL" => Some(18),
878 b"GR" => Some(27),
879 b"GT" => Some(28),
880 b"HN" => Some(28),
881 b"HR" => Some(21),
882 b"HU" => Some(28),
883 b"IE" => Some(22),
884 b"IL" => Some(23),
885 b"IQ" => Some(23),
886 b"IS" => Some(26),
887 b"IT" => Some(27),
888 b"JO" => Some(30),
889 b"KW" => Some(30),
890 b"KZ" => Some(20),
891 b"LB" => Some(28),
892 b"LC" => Some(32),
893 b"LI" => Some(21),
894 b"LT" => Some(20),
895 b"LU" => Some(20),
896 b"LV" => Some(21),
897 b"LY" => Some(25),
898 b"MC" => Some(27),
899 b"MD" => Some(24),
900 b"ME" => Some(22),
901 b"MK" => Some(19),
902 b"MN" => Some(20),
903 b"MR" => Some(27),
904 b"MT" => Some(31),
905 b"MU" => Some(30),
906 b"NI" => Some(28),
907 b"NL" => Some(18),
908 b"NO" => Some(15),
909 b"OM" => Some(23),
910 b"PK" => Some(24),
911 b"PL" => Some(28),
912 b"PS" => Some(29),
913 b"PT" => Some(25),
914 b"QA" => Some(29),
915 b"RO" => Some(24),
916 b"RS" => Some(22),
917 b"RU" => Some(33),
918 b"SA" => Some(24),
919 b"SC" => Some(31),
920 b"SD" => Some(18),
921 b"SE" => Some(24),
922 b"SI" => Some(19),
923 b"SK" => Some(24),
924 b"SM" => Some(27),
925 b"SO" => Some(23),
926 b"ST" => Some(25),
927 b"SV" => Some(28),
928 b"TL" => Some(23),
929 b"TN" => Some(24),
930 b"TR" => Some(26),
931 b"UA" => Some(29),
932 b"VA" => Some(22),
933 b"VG" => Some(24),
934 b"XK" => Some(20),
935 b"YE" => Some(30),
936 _ => None,
937 }
938}
939
940fn iban_canonicalize(input: &str) -> String {
941 input
942 .chars()
943 .filter(|ch| !ch.is_ascii_whitespace())
944 .flat_map(char::to_uppercase)
945 .collect()
946}
947
948fn ipv4_parse_check(input: &str) -> bool {
949 input.parse::<std::net::Ipv4Addr>().is_ok()
950}
951
952fn ipv6_parse_check(input: &str) -> bool {
953 input.parse::<std::net::Ipv6Addr>().is_ok()
954}
955
956fn eth_eip55_check(input: &str) -> bool {
957 let Some(address) = input.strip_prefix("0x") else {
958 return false;
959 };
960 if address.len() != 40 || !address.bytes().all(|byte| byte.is_ascii_hexdigit()) {
961 return false;
962 }
963 if address
964 .bytes()
965 .all(|byte| !byte.is_ascii_alphabetic() || byte.is_ascii_lowercase())
966 || address
967 .bytes()
968 .all(|byte| !byte.is_ascii_alphabetic() || byte.is_ascii_uppercase())
969 {
970 return true;
971 }
972
973 let lowercase = address.to_ascii_lowercase();
974 let hash = Keccak256::digest(lowercase.as_bytes());
975 for (index, byte) in address.bytes().enumerate() {
976 if byte.is_ascii_digit() {
977 continue;
978 }
979 let hash_nibble = if index % 2 == 0 {
980 hash[index / 2] >> 4
981 } else {
982 hash[index / 2] & 0x0f
983 };
984 if (hash_nibble > 7) != byte.is_ascii_uppercase() {
985 return false;
986 }
987 }
988 true
989}
990
991fn collect_ascii_digits<const N: usize>(input: &str) -> Option<[u8; N]> {
992 let mut digits = [0u8; N];
993 let mut count = 0usize;
994 for byte in input.bytes() {
995 if byte.is_ascii_digit() {
996 if count == N {
997 return None;
998 }
999 digits[count] = byte - b'0';
1000 count += 1;
1001 } else if matches!(byte, b' ' | b'\t' | b'\n' | b'\r' | b'-' | b'.' | b'/') {
1002 continue;
1003 } else {
1004 return None;
1005 }
1006 }
1007 (count == N).then_some(digits)
1008}
1009
1010fn canonical_ascii_digits<const N: usize>(input: &str) -> Option<String> {
1011 let digits = collect_ascii_digits::<N>(input)?;
1012 let mut canonical = String::with_capacity(N);
1013 for digit in digits {
1014 canonical.push(char::from(b'0' + digit));
1015 }
1016 Some(canonical)
1017}
1018
1019fn not_all_same<const N: usize>(digits: &[u8; N]) -> bool {
1020 digits[1..].iter().any(|digit| *digit != digits[0])
1021}
1022
1023fn aadhaar_verhoeff_check(input: &str) -> bool {
1024 const D: [[u8; 10]; 10] = [
1025 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
1026 [1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
1027 [2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
1028 [3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
1029 [4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
1030 [5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
1031 [6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
1032 [7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
1033 [8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
1034 [9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
1035 ];
1036 const P: [[u8; 10]; 8] = [
1037 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
1038 [1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
1039 [5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
1040 [8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
1041 [9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
1042 [4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
1043 [2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
1044 [7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
1045 ];
1046 let Some(digits) = collect_ascii_digits::<12>(input) else {
1047 return false;
1048 };
1049 if digits[0] < 2 || !not_all_same(&digits) {
1050 return false;
1051 }
1052 let mut checksum = 0u8;
1053 for (index, digit) in digits.iter().rev().enumerate() {
1054 checksum = D[checksum as usize][P[index % 8][*digit as usize] as usize];
1055 }
1056 checksum == 0
1057}
1058
1059fn fr_nir_mod97_check(input: &str) -> bool {
1060 let Some(digits) = collect_ascii_digits::<15>(input) else {
1061 return false;
1062 };
1063 if !matches!(digits[0], 1 | 2 | 3 | 4 | 7 | 8) {
1064 return false;
1065 }
1066 let month = digits[3] * 10 + digits[4];
1067 if !(1..=12).contains(&month) && !(20..=42).contains(&month) && !(50..=99).contains(&month) {
1068 return false;
1069 }
1070 let mut number = 0u32;
1071 for digit in &digits[..13] {
1072 number = (number * 10 + u32::from(*digit)) % 97;
1073 }
1074 let key = u32::from(digits[13]) * 10 + u32::from(digits[14]);
1075 97 - number == key
1076}
1077
1078fn de_steuer_id_mod1110_check(input: &str) -> bool {
1079 let Some(digits) = collect_ascii_digits::<11>(input) else {
1080 return false;
1081 };
1082 if !steuer_id_first_ten_digits_valid(&digits) {
1083 return false;
1084 }
1085 let mut product = 10u8;
1086 for digit in &digits[..10] {
1087 let mut sum = (*digit + product) % 10;
1088 if sum == 0 {
1089 sum = 10;
1090 }
1091 product = (2 * sum) % 11;
1092 }
1093 let check = (11 - product) % 10;
1094 check == digits[10]
1095}
1096
1097fn steuer_id_first_ten_digits_valid(digits: &[u8; 11]) -> bool {
1098 if digits[0] == 0 {
1099 return false;
1100 }
1101 let mut counts = [0u8; 10];
1102 for digit in &digits[..10] {
1103 counts[*digit as usize] += 1;
1104 }
1105 let repeated_digits = counts.iter().filter(|count| **count > 1).count();
1106 let missing_digits = counts.iter().filter(|count| **count == 0).count();
1107 let repeated_count_valid = counts.iter().any(|count| matches!(*count, 2 | 3));
1108 repeated_digits == 1 && repeated_count_valid && matches!(missing_digits, 1 | 2)
1109}
1110
1111fn bsn_mod11_check(input: &str) -> bool {
1112 let Some(digits) = collect_ascii_digits::<9>(input) else {
1113 return false;
1114 };
1115 if !not_all_same(&digits) {
1116 return false;
1117 }
1118 let sum: i32 = digits[..8]
1119 .iter()
1120 .enumerate()
1121 .map(|(index, digit)| i32::from(*digit) * (9 - index as i32))
1122 .sum::<i32>()
1123 - i32::from(digits[8]);
1124 sum.rem_euclid(11) == 0
1125}
1126
1127fn cpf_mod11_check(input: &str) -> bool {
1128 let Some(digits) = collect_ascii_digits::<11>(input) else {
1129 return false;
1130 };
1131 if !not_all_same(&digits) {
1132 return false;
1133 }
1134 mod11_check_digit(&digits[..9], 10) == digits[9]
1135 && mod11_check_digit(&digits[..10], 11) == digits[10]
1136}
1137
1138fn cnpj_mod11_check(input: &str) -> bool {
1139 let Some(digits) = collect_ascii_digits::<14>(input) else {
1140 return false;
1141 };
1142 if !not_all_same(&digits) {
1143 return false;
1144 }
1145 const FIRST: [u8; 12] = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
1146 const SECOND: [u8; 13] = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
1147 weighted_mod11_check_digit(&digits[..12], &FIRST) == digits[12]
1148 && weighted_mod11_check_digit(&digits[..13], &SECOND) == digits[13]
1149}
1150
1151fn uk_nhs_mod11_check(input: &str) -> bool {
1152 let Some(digits) = collect_ascii_digits::<10>(input) else {
1153 return false;
1154 };
1155 if !not_all_same(&digits) {
1156 return false;
1157 }
1158 let sum: u32 = digits[..9]
1159 .iter()
1160 .enumerate()
1161 .map(|(index, digit)| u32::from(*digit) * (10 - index as u32))
1162 .sum();
1163 let check = 11 - (sum % 11);
1164 let check = if check == 11 { 0 } else { check };
1165 check != 10 && check == u32::from(digits[9])
1166}
1167
1168fn mod11_check_digit(digits: &[u8], start_weight: u8) -> u8 {
1169 let weights = (2..=start_weight).rev();
1170 let sum: u32 = digits
1171 .iter()
1172 .zip(weights)
1173 .map(|(digit, weight)| u32::from(*digit) * u32::from(weight))
1174 .sum();
1175 let remainder = sum % 11;
1176 if remainder < 2 {
1177 0
1178 } else {
1179 (11 - remainder) as u8
1180 }
1181}
1182
1183fn weighted_mod11_check_digit(digits: &[u8], weights: &[u8]) -> u8 {
1184 let sum: u32 = digits
1185 .iter()
1186 .zip(weights)
1187 .map(|(digit, weight)| u32::from(*digit) * u32::from(*weight))
1188 .sum();
1189 let remainder = sum % 11;
1190 if remainder < 2 {
1191 0
1192 } else {
1193 (11 - remainder) as u8
1194 }
1195}
1196
1197#[derive(Debug, Clone, PartialEq, Eq)]
1199#[non_exhaustive]
1200pub struct Detection {
1201 pub span: Range<usize>,
1203 pub class: PiiClass,
1205 pub source: String,
1207}
1208
1209impl Detection {
1210 pub fn new(span: Range<usize>, class: PiiClass, source: impl Into<String>) -> Self {
1212 Self {
1213 span,
1214 class,
1215 source: source.into(),
1216 }
1217 }
1218}
1219
1220pub trait SafetyNet: Send + Sync {
1234 fn id(&self) -> &str;
1236
1237 fn supported_locales(&self) -> &[LocaleTag];
1239
1240 fn check(
1242 &self,
1243 clean_text: &str,
1244 context: SafetyNetContext<'_>,
1245 ) -> Result<Vec<LeakSuspect>, SafetyNetError>;
1246}
1247
1248#[derive(Debug, Clone, Copy)]
1250#[non_exhaustive]
1251pub struct SafetyNetContext<'a> {
1252 pub dictionaries: Option<&'a DictionaryBundle>,
1256 pub manifest: &'a Manifest,
1261 pub locale_chain: &'a [LocaleTag],
1265 pub document_kind: DocumentKind,
1267 pub session_id: Option<&'a str>,
1269 pub field_path: Option<&'a str>,
1271}
1272
1273impl<'a> SafetyNetContext<'a> {
1274 pub fn with_dictionaries(mut self, dictionaries: &'a DictionaryBundle) -> Self {
1276 self.dictionaries = Some(dictionaries);
1277 self
1278 }
1279
1280 pub fn new(
1282 manifest: &'a Manifest,
1283 locale_chain: &'a [LocaleTag],
1284 document_kind: DocumentKind,
1285 session_id: Option<&'a str>,
1286 field_path: Option<&'a str>,
1287 ) -> Self {
1288 Self {
1289 dictionaries: None,
1290 manifest,
1291 locale_chain,
1292 document_kind,
1293 session_id,
1294 field_path,
1295 }
1296 }
1297}
1298
1299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1301#[non_exhaustive]
1302pub struct EmittedTokenSpan {
1303 pub clean_span: Range<usize>,
1305 pub raw_span: Range<usize>,
1307 pub class: PiiClass,
1309}
1310
1311impl EmittedTokenSpan {
1312 pub fn new(clean_span: Range<usize>, raw_span: Range<usize>, class: PiiClass) -> Self {
1314 Self {
1315 clean_span,
1316 raw_span,
1317 class,
1318 }
1319 }
1320}
1321
1322#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1324#[non_exhaustive]
1325pub struct Manifest {
1326 pub spans: Vec<EmittedTokenSpan>,
1328}
1329
1330impl Manifest {
1331 pub fn from_spans(mut spans: Vec<EmittedTokenSpan>) -> Self {
1333 spans.sort_by_key(|span| (span.clean_span.start, span.clean_span.end));
1334 Self { spans }
1335 }
1336
1337 pub fn diff_against(
1345 &self,
1346 suspect_span: &Range<usize>,
1347 suspect_class: &PiiClass,
1348 ) -> Option<LeakKind> {
1349 if suspect_span.is_empty() {
1350 return None;
1351 }
1352
1353 let start_idx = self
1354 .spans
1355 .partition_point(|span| span.clean_span.end <= suspect_span.start);
1356 let overlapping = self.spans[start_idx..]
1357 .iter()
1358 .take_while(|span| span.clean_span.start < suspect_span.end)
1359 .filter(|span| ranges_overlap(&span.clean_span, suspect_span))
1360 .collect::<Vec<_>>();
1361
1362 if overlapping.is_empty() {
1363 return Some(LeakKind::Uncovered);
1364 }
1365
1366 let mut cursor = suspect_span.start;
1367 let mut first_mismatch = None::<&EmittedTokenSpan>;
1368 for span in overlapping {
1369 if span.clean_span.start > cursor {
1370 return Some(LeakKind::PartialBleed {
1371 uncovered: cursor..span.clean_span.start.min(suspect_span.end),
1372 });
1373 }
1374
1375 if span.clean_span.end > cursor {
1376 if first_mismatch.is_none() && &span.class != suspect_class {
1377 first_mismatch = Some(span);
1378 }
1379 cursor = cursor.max(span.clean_span.end.min(suspect_span.end));
1380 if cursor >= suspect_span.end {
1381 break;
1382 }
1383 }
1384 }
1385
1386 if cursor < suspect_span.end {
1387 return Some(LeakKind::PartialBleed {
1388 uncovered: cursor..suspect_span.end,
1389 });
1390 }
1391
1392 first_mismatch.map(|span| LeakKind::ClassMismatch {
1393 pipeline_class: span.class.clone(),
1394 safety_net_class: suspect_class.clone(),
1395 })
1396 }
1397}
1398
1399fn ranges_overlap(left: &Range<usize>, right: &Range<usize>) -> bool {
1400 left.start < right.end && right.start < left.end
1401}
1402
1403#[derive(Debug, Clone, PartialEq)]
1405#[non_exhaustive]
1406pub struct LeakSuspect {
1407 pub span: Range<usize>,
1409 pub class: PiiClass,
1411 pub safety_net_id: String,
1413 pub score: Option<f32>,
1415 pub kind: LeakKind,
1417 pub raw_label: String,
1419 pub field_path: Option<String>,
1421}
1422
1423impl LeakSuspect {
1424 pub fn new(
1426 span: Range<usize>,
1427 class: PiiClass,
1428 safety_net_id: impl Into<String>,
1429 score: Option<f32>,
1430 kind: LeakKind,
1431 raw_label: impl Into<String>,
1432 field_path: Option<String>,
1433 ) -> Self {
1434 Self {
1435 span,
1436 class,
1437 safety_net_id: safety_net_id.into(),
1438 score,
1439 kind,
1440 raw_label: raw_label.into(),
1441 field_path,
1442 }
1443 }
1444}
1445
1446#[derive(Debug, Clone, PartialEq, Eq)]
1450#[non_exhaustive]
1451pub enum LeakKind {
1452 Uncovered,
1454 PartialBleed {
1456 uncovered: Range<usize>,
1458 },
1459 ClassMismatch {
1461 pipeline_class: PiiClass,
1463 safety_net_class: PiiClass,
1465 },
1466}
1467
1468impl LeakKind {
1469 pub fn as_str(&self) -> &'static str {
1471 match self {
1472 Self::Uncovered => "uncovered",
1473 Self::PartialBleed { .. } => "partial_bleed",
1474 Self::ClassMismatch { .. } => "class_mismatch",
1475 }
1476 }
1477}
1478
1479#[derive(Debug, Clone, PartialEq, Eq)]
1481#[non_exhaustive]
1482pub enum LeakReportTelemetry {
1483 LocaleSkipped {
1485 safety_net_id: String,
1487 document_kind: DocumentKind,
1489 field_path: Option<String>,
1491 },
1492}
1493
1494#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1496#[non_exhaustive]
1497pub struct LeakReportStats {
1498 pub suspect_count: usize,
1500 pub uncovered_count: usize,
1502 pub partial_bleed_count: usize,
1504 pub class_mismatch_count: usize,
1506 pub locale_skipped_count: usize,
1508}
1509
1510#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1517#[non_exhaustive]
1518pub struct DocumentExtension {
1519 pub schema_version: u16,
1521 pub clean_md_sha256: [u8; 32],
1523 pub layout_json_sha256: [u8; 32],
1525 pub report_json_sha256: [u8; 32],
1527 #[serde(default, skip_serializing_if = "Option::is_none")]
1529 pub preview_png_sha256: Option<[u8; 32]>,
1530 pub page_count: u32,
1532 pub audit_session_id: String,
1534 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1536 pub clean_spans: Vec<EmittedTokenSpan>,
1537 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1539 pub codec_audit: Vec<CodecAuditRow>,
1540}
1541
1542impl DocumentExtension {
1543 pub fn builder(schema_version: u16) -> DocumentExtensionBuilder {
1545 DocumentExtensionBuilder {
1546 schema_version,
1547 clean_md_sha256: None,
1548 layout_json_sha256: None,
1549 report_json_sha256: None,
1550 preview_png_sha256: None,
1551 page_count: None,
1552 audit_session_id: None,
1553 clean_spans: Vec::new(),
1554 codec_audit: Vec::new(),
1555 }
1556 }
1557}
1558
1559#[derive(Debug, Clone)]
1561#[must_use]
1562pub struct DocumentExtensionBuilder {
1563 schema_version: u16,
1564 clean_md_sha256: Option<[u8; 32]>,
1565 layout_json_sha256: Option<[u8; 32]>,
1566 report_json_sha256: Option<[u8; 32]>,
1567 preview_png_sha256: Option<[u8; 32]>,
1568 page_count: Option<u32>,
1569 audit_session_id: Option<String>,
1570 clean_spans: Vec<EmittedTokenSpan>,
1571 codec_audit: Vec<CodecAuditRow>,
1572}
1573
1574impl DocumentExtensionBuilder {
1575 pub fn clean_md_sha256(mut self, hash: [u8; 32]) -> Self {
1576 self.clean_md_sha256 = Some(hash);
1577 self
1578 }
1579
1580 pub fn layout_json_sha256(mut self, hash: [u8; 32]) -> Self {
1581 self.layout_json_sha256 = Some(hash);
1582 self
1583 }
1584
1585 pub fn report_json_sha256(mut self, hash: [u8; 32]) -> Self {
1586 self.report_json_sha256 = Some(hash);
1587 self
1588 }
1589
1590 pub fn preview_png_sha256(mut self, hash: [u8; 32]) -> Self {
1591 self.preview_png_sha256 = Some(hash);
1592 self
1593 }
1594
1595 pub fn page_count(mut self, page_count: u32) -> Self {
1596 self.page_count = Some(page_count);
1597 self
1598 }
1599
1600 pub fn audit_session_id(mut self, audit_session_id: impl Into<String>) -> Self {
1601 self.audit_session_id = Some(audit_session_id.into());
1602 self
1603 }
1604
1605 pub fn clean_spans(mut self, clean_spans: Vec<EmittedTokenSpan>) -> Self {
1606 self.clean_spans = clean_spans;
1607 self
1608 }
1609
1610 pub fn codec_audit(mut self, codec_audit: Vec<CodecAuditRow>) -> Self {
1611 self.codec_audit = codec_audit;
1612 self
1613 }
1614
1615 pub fn build(self) -> Result<DocumentExtension, DocumentExtensionError> {
1616 Ok(DocumentExtension {
1617 schema_version: self.schema_version,
1618 clean_md_sha256: self
1619 .clean_md_sha256
1620 .ok_or(DocumentExtensionError::MissingField("clean_md_sha256"))?,
1621 layout_json_sha256: self
1622 .layout_json_sha256
1623 .ok_or(DocumentExtensionError::MissingField("layout_json_sha256"))?,
1624 report_json_sha256: self
1625 .report_json_sha256
1626 .ok_or(DocumentExtensionError::MissingField("report_json_sha256"))?,
1627 preview_png_sha256: self.preview_png_sha256,
1628 page_count: self
1629 .page_count
1630 .ok_or(DocumentExtensionError::MissingField("page_count"))?,
1631 audit_session_id: self
1632 .audit_session_id
1633 .ok_or(DocumentExtensionError::MissingField("audit_session_id"))?,
1634 clean_spans: self.clean_spans,
1635 codec_audit: self.codec_audit,
1636 })
1637 }
1638}
1639
1640#[derive(Debug, Clone, PartialEq, Eq, Error)]
1642#[non_exhaustive]
1643pub enum DocumentExtensionError {
1644 #[error("missing document extension field: {0}")]
1645 MissingField(&'static str),
1646}
1647
1648#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1650#[serde(rename_all = "snake_case")]
1651#[non_exhaustive]
1652pub enum TextOrigin {
1653 Ocr,
1655 EmbeddedText,
1657 Transcript,
1659 Hybrid,
1661}
1662
1663#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1665#[non_exhaustive]
1666pub struct CodecCapabilitySet {
1667 pub text: bool,
1669 pub layout: bool,
1671 pub confidence: bool,
1673 pub timestamps: bool,
1675}
1676
1677impl CodecCapabilitySet {
1678 pub const TEXT_ONLY: Self = Self {
1680 text: true,
1681 layout: false,
1682 confidence: false,
1683 timestamps: false,
1684 };
1685
1686 pub const fn new(text: bool, layout: bool, confidence: bool, timestamps: bool) -> Self {
1688 Self {
1689 text,
1690 layout,
1691 confidence,
1692 timestamps,
1693 }
1694 }
1695
1696 pub fn contains(self, requested: Self) -> bool {
1698 (!requested.text || self.text)
1699 && (!requested.layout || self.layout)
1700 && (!requested.confidence || self.confidence)
1701 && (!requested.timestamps || self.timestamps)
1702 }
1703}
1704
1705#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1707#[serde(rename_all = "snake_case")]
1708#[non_exhaustive]
1709pub enum ExtractionDensityPolicy {
1710 Required(f32),
1712 Exempt { reason: String },
1714}
1715
1716impl Default for ExtractionDensityPolicy {
1717 fn default() -> Self {
1718 Self::Exempt {
1719 reason: "calibration_pending".to_string(),
1720 }
1721 }
1722}
1723
1724#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1726#[non_exhaustive]
1727pub struct CodecAuditRow {
1728 pub codec_id: String,
1730 pub codec_version: String,
1732 pub accepted_mime: String,
1734 pub advertised: CodecCapabilitySet,
1736 pub delivered: CodecCapabilitySet,
1738 pub text_origin: TextOrigin,
1740 pub codec_output_schema_version: u16,
1742 #[serde(default, skip_serializing_if = "Option::is_none")]
1744 pub options_hash_hex: Option<String>,
1745 #[serde(default, skip_serializing_if = "Option::is_none")]
1747 pub engine_provenance: Option<String>,
1748 pub extraction_density_policy: ExtractionDensityPolicy,
1750}
1751
1752impl CodecAuditRow {
1753 pub fn new(
1755 codec_id: impl Into<String>,
1756 codec_version: impl Into<String>,
1757 accepted_mime: impl Into<String>,
1758 text_origin: TextOrigin,
1759 ) -> Self {
1760 Self {
1761 codec_id: codec_id.into(),
1762 codec_version: codec_version.into(),
1763 accepted_mime: accepted_mime.into(),
1764 advertised: CodecCapabilitySet::default(),
1765 delivered: CodecCapabilitySet::default(),
1766 text_origin,
1767 codec_output_schema_version: 1,
1768 options_hash_hex: None,
1769 engine_provenance: None,
1770 extraction_density_policy: ExtractionDensityPolicy::default(),
1771 }
1772 }
1773}
1774
1775#[derive(Debug, Clone, Default, PartialEq)]
1781#[non_exhaustive]
1782pub struct LeakReport {
1783 pub suspects: Vec<LeakSuspect>,
1785 pub telemetry: Vec<LeakReportTelemetry>,
1787 pub stats: LeakReportStats,
1789 pub replay_hash: Option<String>,
1794}
1795
1796impl LeakReport {
1797 pub fn from_parts(
1799 suspects: Vec<LeakSuspect>,
1800 telemetry: Vec<LeakReportTelemetry>,
1801 ) -> LeakReport {
1802 let mut stats = LeakReportStats {
1803 suspect_count: suspects.len(),
1804 locale_skipped_count: telemetry
1805 .iter()
1806 .filter(|event| matches!(event, LeakReportTelemetry::LocaleSkipped { .. }))
1807 .count(),
1808 ..LeakReportStats::default()
1809 };
1810 for suspect in &suspects {
1811 match suspect.kind {
1812 LeakKind::Uncovered => stats.uncovered_count += 1,
1813 LeakKind::PartialBleed { .. } => stats.partial_bleed_count += 1,
1814 LeakKind::ClassMismatch { .. } => stats.class_mismatch_count += 1,
1815 }
1816 }
1817 LeakReport {
1818 suspects,
1819 telemetry,
1820 stats,
1821 replay_hash: None,
1822 }
1823 }
1824
1825 pub fn extend(&mut self, other: LeakReport) {
1827 self.suspects.extend(other.suspects);
1828 self.telemetry.extend(other.telemetry);
1829 *self = LeakReport::from_parts(
1830 std::mem::take(&mut self.suspects),
1831 std::mem::take(&mut self.telemetry),
1832 );
1833 }
1834}
1835
1836#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1838#[non_exhaustive]
1839pub enum OpenAiPrivateLabel {
1840 PrivatePerson,
1842 PrivateAddress,
1844 PrivateEmail,
1846 PrivatePhone,
1848 PrivateUrl,
1850 PrivateDate,
1852 AccountNumber,
1854 Secret,
1856}
1857
1858impl OpenAiPrivateLabel {
1859 pub fn as_str(self) -> &'static str {
1861 match self {
1862 Self::PrivatePerson => "private_person",
1863 Self::PrivateAddress => "private_address",
1864 Self::PrivateEmail => "private_email",
1865 Self::PrivatePhone => "private_phone",
1866 Self::PrivateUrl => "private_url",
1867 Self::PrivateDate => "private_date",
1868 Self::AccountNumber => "account_number",
1869 Self::Secret => "secret",
1870 }
1871 }
1872}
1873
1874#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1876#[non_exhaustive]
1877pub enum SafetyNetPiiClass {
1878 Email,
1880 Name,
1882 Location,
1884 Phone,
1886 Url,
1888 Date,
1890 AccountNumber,
1892 Secret,
1894}
1895
1896impl SafetyNetPiiClass {
1897 pub fn to_pii_class(self) -> PiiClass {
1899 match self {
1900 Self::Email => PiiClass::Email,
1901 Self::Name => PiiClass::Name,
1902 Self::Location => PiiClass::Location,
1903 Self::Phone => PiiClass::custom("phone"),
1904 Self::Url => PiiClass::custom("url"),
1905 Self::Date => PiiClass::custom("date"),
1906 Self::AccountNumber => PiiClass::custom("account_number"),
1907 Self::Secret => PiiClass::custom("secret"),
1908 }
1909 }
1910}
1911
1912#[derive(Debug, Clone, PartialEq, Eq, Error)]
1914#[non_exhaustive]
1915pub enum SafetyNetError {
1916 #[error("safety net unavailable: {reason}")]
1918 Unavailable {
1919 reason: String,
1921 },
1922 #[error("safety net weights missing: {path}")]
1924 WeightsMissing {
1925 path: String,
1927 },
1928 #[error("safety net model unavailable: {reason}")]
1930 ModelUnavailable {
1931 reason: String,
1933 },
1934 #[error("safety net model integrity mismatch: expected={expected}, actual={actual}")]
1936 ModelIntegrityMismatch {
1937 expected: String,
1939 actual: String,
1941 },
1942 #[error("safety net input too large: limit={limit}, actual={actual}")]
1944 InputTooLarge {
1945 limit: usize,
1947 actual: usize,
1949 },
1950 #[error("safety net runtime failed: {message}")]
1952 Runtime {
1953 message: String,
1955 },
1956 #[error("safety net invalid output: {message}")]
1958 InvalidOutput {
1959 message: String,
1961 },
1962}
1963
1964#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1978#[non_exhaustive]
1979pub enum Action {
1980 Tokenize,
1982 Redact,
1984 FormatPreserve,
1986 Generalize,
1988 Preserve,
1990}
1991
1992impl Action {
1993 pub fn as_str(&self) -> &'static str {
1995 match self {
1996 Self::Tokenize => "tokenize",
1997 Self::Redact => "redact",
1998 Self::FormatPreserve => "format_preserve",
1999 Self::Generalize => "generalize",
2000 Self::Preserve => "preserve",
2001 }
2002 }
2003
2004 pub fn from_canonical_str(value: &str) -> Option<Self> {
2006 match value {
2007 "tokenize" => Some(Self::Tokenize),
2008 "redact" => Some(Self::Redact),
2009 "format_preserve" => Some(Self::FormatPreserve),
2010 "generalize" => Some(Self::Generalize),
2011 "preserve" => Some(Self::Preserve),
2012 _ => None,
2013 }
2014 }
2015}
2016
2017#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2019#[non_exhaustive]
2020pub enum ConflictTier {
2021 None,
2023 ClassPriority,
2025 RulePriority,
2027 Score,
2029 SpanLength,
2031 Validator,
2033 ValidatorVeto,
2035 CollisionPolicy,
2037 AnchoredContext,
2039 StructuredContainment,
2043 RecognizerId,
2045 Merged,
2047 Redact,
2049 Resolve,
2051 Fallback,
2053}
2054
2055impl ConflictTier {
2056 pub fn as_str(&self) -> &'static str {
2058 match self {
2059 Self::None => "none",
2060 Self::ClassPriority => "class_priority",
2061 Self::RulePriority => "rule_priority",
2062 Self::Score => "score",
2063 Self::SpanLength => "span_length",
2064 Self::Validator => "validator",
2065 Self::ValidatorVeto => "validator_veto",
2066 Self::CollisionPolicy => "collision_policy",
2067 Self::AnchoredContext => "anchored_context",
2068 Self::StructuredContainment => "structured_containment",
2069 Self::RecognizerId => "recognizer_id",
2070 Self::Merged => "merged",
2071 Self::Redact => "redact",
2072 Self::Resolve => "resolve",
2073 Self::Fallback => "fallback",
2074 }
2075 }
2076
2077 pub fn from_canonical_str(value: &str) -> Option<Self> {
2079 match value {
2080 "none" => Some(Self::None),
2081 "class_priority" => Some(Self::ClassPriority),
2082 "rule_priority" => Some(Self::RulePriority),
2083 "score" => Some(Self::Score),
2084 "span_length" => Some(Self::SpanLength),
2085 "validator" => Some(Self::Validator),
2086 "validator_veto" => Some(Self::ValidatorVeto),
2087 "collision_policy" => Some(Self::CollisionPolicy),
2088 "anchored_context" => Some(Self::AnchoredContext),
2089 "structured_containment" => Some(Self::StructuredContainment),
2090 "recognizer_id" => Some(Self::RecognizerId),
2091 "merged" => Some(Self::Merged),
2092 "redact" => Some(Self::Redact),
2093 "resolve" => Some(Self::Resolve),
2094 "fallback" => Some(Self::Fallback),
2095 _ => None,
2096 }
2097 }
2098}
2099
2100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2102#[non_exhaustive]
2103#[serde(rename_all = "snake_case")]
2104pub enum FallbackReason {
2105 #[serde(alias = "OverlapConflict")]
2107 OverlapConflict,
2108 #[serde(alias = "ValidatorVeto")]
2110 ValidatorVeto,
2111 #[serde(alias = "AnchorMissing")]
2113 AnchorMissing,
2114 #[serde(alias = "ResidualSuspect")]
2116 ResidualSuspect,
2117}
2118
2119impl FallbackReason {
2120 pub fn as_str(&self) -> &'static str {
2122 match self {
2123 Self::OverlapConflict => "overlap_conflict",
2124 Self::ValidatorVeto => "validator_veto",
2125 Self::AnchorMissing => "anchor_missing",
2126 Self::ResidualSuspect => "residual_suspect",
2127 }
2128 }
2129
2130 pub fn from_canonical_str(value: &str) -> Option<Self> {
2132 match value {
2133 "overlap_conflict" => Some(Self::OverlapConflict),
2134 "validator_veto" => Some(Self::ValidatorVeto),
2135 "anchor_missing" => Some(Self::AnchorMissing),
2136 "residual_suspect" => Some(Self::ResidualSuspect),
2137 _ => None,
2138 }
2139 }
2140}
2141
2142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2144#[non_exhaustive]
2145pub enum DocumentKind {
2146 Structured,
2148 Text,
2150}
2151
2152impl DocumentKind {
2153 pub fn as_str(&self) -> &'static str {
2155 match self {
2156 Self::Structured => "structured",
2157 Self::Text => "text",
2158 }
2159 }
2160
2161 pub fn from_canonical_str(value: &str) -> Option<Self> {
2163 match value {
2164 "structured" => Some(Self::Structured),
2165 "text" => Some(Self::Text),
2166 _ => None,
2167 }
2168 }
2169}
2170
2171#[derive(Debug, Clone, PartialEq, Eq)]
2181#[non_exhaustive]
2182pub struct RedactionEntry {
2183 pub source: String,
2185 pub recognizer_id: Option<String>,
2187 pub recognizer_version_id: Option<String>,
2189 pub class: PiiClass,
2191 pub action: Action,
2193 pub field_name: Option<String>,
2195 pub document_kind: DocumentKind,
2197 pub conflict_loser: bool,
2199 pub decided_by: ConflictTier,
2201 pub created_at: i64,
2203 pub session_id: Option<String>,
2205 pub validator_fail_reason: Option<ValidatorFailReason>,
2207 pub ambiguity_record: Option<AmbiguityRecord>,
2209 pub collision_family: Option<String>,
2211 pub collision_variant: Option<String>,
2213 pub fallback_triggered: Option<FallbackReason>,
2215 pub provenance_stage: Option<String>,
2217 pub provenance_model_id: Option<String>,
2218 pub provenance_model_version: Option<String>,
2219 pub provenance_artifact_sha256: Option<String>,
2220 pub provenance_tokenizer_sha256: Option<String>,
2221 pub provenance_locale_resolved: Option<String>,
2222 pub provenance_locale_match_kind: Option<String>,
2223 pub provenance_canonical_class: Option<String>,
2224 pub provenance_native_class: Option<String>,
2225 pub provenance_confidence: Option<String>,
2226 pub provenance_merged_from: Option<String>,
2227 pub backend_silently_dropped: Option<Vec<String>>,
2229 pub restore_policy: Option<String>,
2230 pub restore_decision: Option<String>,
2231 pub restore_unknown_token_count: Option<u64>,
2232 pub restore_manifest_bypass_count: Option<u64>,
2233 pub restore_fresh_pii_count: Option<u64>,
2234 pub restore_phase_mask: Option<u32>,
2235}
2236
2237impl Serialize for RedactionEntry {
2238 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
2239 where
2240 S: serde::Serializer,
2241 {
2242 use serde::ser::SerializeStruct;
2243
2244 let mut len = 14;
2245 if self.recognizer_id.is_some() {
2246 len += 1;
2247 }
2248 if self.recognizer_version_id.is_some() {
2249 len += 1;
2250 }
2251 len += [
2252 self.provenance_stage.as_ref(),
2253 self.provenance_model_id.as_ref(),
2254 self.provenance_model_version.as_ref(),
2255 self.provenance_artifact_sha256.as_ref(),
2256 self.provenance_tokenizer_sha256.as_ref(),
2257 self.provenance_locale_resolved.as_ref(),
2258 self.provenance_locale_match_kind.as_ref(),
2259 self.provenance_canonical_class.as_ref(),
2260 self.provenance_native_class.as_ref(),
2261 self.provenance_confidence.as_ref(),
2262 self.provenance_merged_from.as_ref(),
2263 ]
2264 .into_iter()
2265 .filter(|value| value.is_some())
2266 .count();
2267 if self.backend_silently_dropped.is_some() {
2268 len += 1;
2269 }
2270 len += [self.restore_policy.as_ref(), self.restore_decision.as_ref()]
2271 .into_iter()
2272 .filter(|value| value.is_some())
2273 .count();
2274 len += [
2275 self.restore_unknown_token_count.is_some(),
2276 self.restore_manifest_bypass_count.is_some(),
2277 self.restore_fresh_pii_count.is_some(),
2278 self.restore_phase_mask.is_some(),
2279 ]
2280 .into_iter()
2281 .filter(|value| *value)
2282 .count();
2283 let mut state = serializer.serialize_struct("RedactionEntry", len)?;
2284 state.serialize_field("source", &self.source)?;
2285 if let Some(recognizer_id) = &self.recognizer_id {
2286 state.serialize_field("recognizer_id", recognizer_id)?;
2287 }
2288 if let Some(recognizer_version_id) = &self.recognizer_version_id {
2289 state.serialize_field("recognizer_version_id", recognizer_version_id)?;
2290 }
2291 state.serialize_field("class", &self.class.to_canonical_str())?;
2292 state.serialize_field("action", self.action.as_str())?;
2293 state.serialize_field("field_name", &self.field_name)?;
2294 state.serialize_field("document_kind", self.document_kind.as_str())?;
2295 state.serialize_field("conflict_loser", &self.conflict_loser)?;
2296 state.serialize_field("decided_by", self.decided_by.as_str())?;
2297 state.serialize_field("created_at", &self.created_at)?;
2298 state.serialize_field("session_id", &self.session_id)?;
2299 state.serialize_field("validator_fail_reason", &self.validator_fail_reason)?;
2300 state.serialize_field("ambiguity_record", &self.ambiguity_record)?;
2301 state.serialize_field("collision_family", &self.collision_family)?;
2302 state.serialize_field("collision_variant", &self.collision_variant)?;
2303 state.serialize_field("fallback_triggered", &self.fallback_triggered)?;
2304 if let Some(value) = &self.provenance_stage {
2305 state.serialize_field("provenance_stage", value)?;
2306 }
2307 if let Some(value) = &self.provenance_model_id {
2308 state.serialize_field("provenance_model_id", value)?;
2309 }
2310 if let Some(value) = &self.provenance_model_version {
2311 state.serialize_field("provenance_model_version", value)?;
2312 }
2313 if let Some(value) = &self.provenance_artifact_sha256 {
2314 state.serialize_field("provenance_artifact_sha256", value)?;
2315 }
2316 if let Some(value) = &self.provenance_tokenizer_sha256 {
2317 state.serialize_field("provenance_tokenizer_sha256", value)?;
2318 }
2319 if let Some(value) = &self.provenance_locale_resolved {
2320 state.serialize_field("provenance_locale_resolved", value)?;
2321 }
2322 if let Some(value) = &self.provenance_locale_match_kind {
2323 state.serialize_field("provenance_locale_match_kind", value)?;
2324 }
2325 if let Some(value) = &self.provenance_canonical_class {
2326 state.serialize_field("provenance_canonical_class", value)?;
2327 }
2328 if let Some(value) = &self.provenance_native_class {
2329 state.serialize_field("provenance_native_class", value)?;
2330 }
2331 if let Some(value) = &self.provenance_confidence {
2332 state.serialize_field("provenance_confidence", value)?;
2333 }
2334 if let Some(value) = &self.provenance_merged_from {
2335 state.serialize_field("provenance_merged_from", value)?;
2336 }
2337 if let Some(dropped) = &self.backend_silently_dropped {
2338 state.serialize_field("backend_silently_dropped", dropped)?;
2339 }
2340 if let Some(value) = &self.restore_policy {
2341 state.serialize_field("restore_policy", value)?;
2342 }
2343 if let Some(value) = &self.restore_decision {
2344 state.serialize_field("restore_decision", value)?;
2345 }
2346 if let Some(value) = self.restore_unknown_token_count {
2347 state.serialize_field("restore_unknown_token_count", &value)?;
2348 }
2349 if let Some(value) = self.restore_manifest_bypass_count {
2350 state.serialize_field("restore_manifest_bypass_count", &value)?;
2351 }
2352 if let Some(value) = self.restore_fresh_pii_count {
2353 state.serialize_field("restore_fresh_pii_count", &value)?;
2354 }
2355 if let Some(value) = self.restore_phase_mask {
2356 state.serialize_field("restore_phase_mask", &value)?;
2357 }
2358 state.end()
2359 }
2360}
2361
2362impl RedactionEntry {
2363 #[allow(clippy::too_many_arguments)]
2365 pub fn new(
2366 source: impl Into<String>,
2367 class: PiiClass,
2368 action: Action,
2369 field_name: Option<String>,
2370 document_kind: DocumentKind,
2371 conflict_loser: bool,
2372 decided_by: ConflictTier,
2373 created_at: i64,
2374 session_id: Option<String>,
2375 ) -> Self {
2376 Self {
2377 source: source.into(),
2378 class,
2379 action,
2380 field_name,
2381 document_kind,
2382 conflict_loser,
2383 decided_by,
2384 created_at,
2385 session_id,
2386 recognizer_id: None,
2387 recognizer_version_id: None,
2388 validator_fail_reason: None,
2389 ambiguity_record: None,
2390 collision_family: None,
2391 collision_variant: None,
2392 fallback_triggered: None,
2393 provenance_stage: None,
2394 provenance_model_id: None,
2395 provenance_model_version: None,
2396 provenance_artifact_sha256: None,
2397 provenance_tokenizer_sha256: None,
2398 provenance_locale_resolved: None,
2399 provenance_locale_match_kind: None,
2400 provenance_canonical_class: None,
2401 provenance_native_class: None,
2402 provenance_confidence: None,
2403 provenance_merged_from: None,
2404 backend_silently_dropped: None,
2405 restore_policy: None,
2406 restore_decision: None,
2407 restore_unknown_token_count: None,
2408 restore_manifest_bypass_count: None,
2409 restore_fresh_pii_count: None,
2410 restore_phase_mask: None,
2411 }
2412 }
2413
2414 pub fn with_validator_fail_reason(mut self, reason: ValidatorFailReason) -> Self {
2416 self.validator_fail_reason = Some(reason);
2417 self
2418 }
2419
2420 pub fn with_ambiguity_record(mut self, record: AmbiguityRecord) -> Self {
2422 self.ambiguity_record = Some(record);
2423 self
2424 }
2425
2426 pub fn with_collision_metadata(
2428 mut self,
2429 family: Option<String>,
2430 variant: Option<String>,
2431 ) -> Self {
2432 self.collision_family = family;
2433 self.collision_variant = variant;
2434 self
2435 }
2436
2437 pub fn with_fallback_triggered(mut self, reason: FallbackReason) -> Self {
2439 self.fallback_triggered = Some(reason);
2440 self
2441 }
2442
2443 pub fn with_backend_silently_dropped(mut self, dropped: Vec<String>) -> Self {
2445 self.backend_silently_dropped = Some(dropped);
2446 self
2447 }
2448
2449 pub fn with_restore_telemetry(mut self, telemetry: RestoreTelemetry) -> Self {
2450 self.restore_policy = Some(telemetry.restore_policy_str().to_string());
2451 self.restore_decision = Some(telemetry.restore_decision_str().to_string());
2452 self.restore_unknown_token_count = Some(telemetry.unknown_token_count);
2453 self.restore_manifest_bypass_count = Some(telemetry.manifest_bypass_count);
2454 self.restore_fresh_pii_count = Some(telemetry.fresh_pii_detected_count);
2455 self.restore_phase_mask = Some(telemetry.phase_execution_mask);
2456 self
2457 }
2458
2459 pub fn with_recognizer_metadata(
2461 mut self,
2462 recognizer_id: Option<String>,
2463 recognizer_version_id: Option<String>,
2464 ) -> Self {
2465 self.recognizer_id = recognizer_id;
2466 self.recognizer_version_id = recognizer_version_id;
2467 self
2468 }
2469
2470 #[allow(clippy::too_many_arguments)]
2471 pub fn with_provenance_metadata(
2472 mut self,
2473 stage: Option<String>,
2474 model_id: Option<String>,
2475 model_version: Option<String>,
2476 artifact_sha256: Option<String>,
2477 tokenizer_sha256: Option<String>,
2478 locale_resolved: Option<String>,
2479 locale_match_kind: Option<String>,
2480 canonical_class: Option<String>,
2481 native_class: Option<String>,
2482 confidence: Option<f64>,
2483 merged_from: Option<String>,
2484 ) -> Self {
2485 self.provenance_stage = stage;
2486 self.provenance_model_id = model_id;
2487 self.provenance_model_version = model_version;
2488 self.provenance_artifact_sha256 = artifact_sha256;
2489 self.provenance_tokenizer_sha256 = tokenizer_sha256;
2490 self.provenance_locale_resolved = locale_resolved;
2491 self.provenance_locale_match_kind = locale_match_kind;
2492 self.provenance_canonical_class = canonical_class;
2493 self.provenance_native_class = native_class;
2494 self.provenance_confidence = confidence.map(|value| value.to_string());
2495 self.provenance_merged_from = merged_from;
2496 self
2497 }
2498}
2499
2500#[derive(Debug, Clone, PartialEq, Eq, Error)]
2502#[non_exhaustive]
2503pub enum RedactionLogError {
2504 #[error("sqlite redaction log error: {0}")]
2506 Sqlite(String),
2507 #[error("backend redaction log error: {0}")]
2509 Backend(String),
2510}
2511
2512pub trait RedactionLogger: Send + Sync {
2542 fn log(&self, entry: &RedactionEntry) -> Result<(), RedactionLogError>;
2544}
2545
2546#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
2548#[non_exhaustive]
2549pub enum SafetyTier {
2550 #[default]
2552 SafeDefault,
2553 LocaleGated,
2555 OptIn,
2557}
2558
2559#[derive(Debug, Clone, PartialEq, Eq)]
2561#[non_exhaustive]
2562pub struct SafetyTierParseError {
2563 value: String,
2564}
2565
2566impl SafetyTier {
2567 pub fn parse(value: &str) -> Result<Self, SafetyTierParseError> {
2569 match value {
2570 "safe_default" => Ok(Self::SafeDefault),
2571 "locale_gated" => Ok(Self::LocaleGated),
2572 "opt_in" => Ok(Self::OptIn),
2573 other => Err(SafetyTierParseError {
2574 value: other.to_string(),
2575 }),
2576 }
2577 }
2578
2579 pub fn as_str(self) -> &'static str {
2581 match self {
2582 Self::SafeDefault => "safe_default",
2583 Self::LocaleGated => "locale_gated",
2584 Self::OptIn => "opt_in",
2585 }
2586 }
2587}
2588
2589impl SafetyTierParseError {
2590 pub fn value(&self) -> &str {
2592 &self.value
2593 }
2594}
2595
2596impl fmt::Display for SafetyTierParseError {
2597 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2598 write!(f, "unsupported safety_tier '{}'", self.value)
2599 }
2600}
2601
2602impl std::error::Error for SafetyTierParseError {}
2603
2604#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
2606#[non_exhaustive]
2607pub enum LocaleBasis {
2608 #[default]
2610 Document,
2611 Format,
2613}
2614
2615#[derive(Debug, Clone, PartialEq, Eq)]
2617#[non_exhaustive]
2618pub struct LocaleBasisParseError {
2619 value: String,
2620}
2621
2622impl LocaleBasis {
2623 pub fn parse(value: &str) -> Result<Self, LocaleBasisParseError> {
2625 match value {
2626 "document" => Ok(Self::Document),
2627 "format" => Ok(Self::Format),
2628 other => Err(LocaleBasisParseError {
2629 value: other.to_string(),
2630 }),
2631 }
2632 }
2633
2634 pub fn as_str(self) -> &'static str {
2636 match self {
2637 Self::Document => "document",
2638 Self::Format => "format",
2639 }
2640 }
2641}
2642
2643impl LocaleBasisParseError {
2644 pub fn value(&self) -> &str {
2646 &self.value
2647 }
2648}
2649
2650impl fmt::Display for LocaleBasisParseError {
2651 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2652 write!(f, "unsupported locale_basis '{}'", self.value)
2653 }
2654}
2655
2656impl std::error::Error for LocaleBasisParseError {}
2657
2658#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2660#[non_exhaustive]
2661pub enum LocaleTag {
2662 Global,
2664 DeDe,
2666 DeAt,
2668 DeCh,
2670 EnUs,
2672 EnGb,
2674 EnIe,
2676 EnAu,
2678 EnCa,
2680 Other(String),
2682}
2683
2684#[derive(Debug, Clone, PartialEq, Eq)]
2686#[non_exhaustive]
2687pub enum LocaleError {
2688 Unsupported,
2690}
2691
2692impl fmt::Display for LocaleError {
2693 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2694 match self {
2695 LocaleError::Unsupported => f.write_str("unsupported locale"),
2696 }
2697 }
2698}
2699
2700impl std::error::Error for LocaleError {}
2701
2702#[derive(Debug, Clone, PartialEq, Eq)]
2704pub struct LocaleChain(Vec<LocaleTag>);
2705
2706impl LocaleTag {
2707 pub const GLOBAL: LocaleTag = LocaleTag::Global;
2709
2710 pub fn parse(s: &str) -> Result<LocaleTag, LocaleError> {
2712 let raw = s.trim().replace('_', "-");
2713 let normalized = raw.to_ascii_lowercase();
2714 match normalized.as_str() {
2715 "global" | "*" => Ok(LocaleTag::Global),
2716 "de-de" => Ok(LocaleTag::DeDe),
2717 "de-at" => Ok(LocaleTag::DeAt),
2718 "de-ch" => Ok(LocaleTag::DeCh),
2719 "en-us" => Ok(LocaleTag::EnUs),
2720 "en-gb" => Ok(LocaleTag::EnGb),
2721 "en-ie" => Ok(LocaleTag::EnIe),
2722 "en-au" => Ok(LocaleTag::EnAu),
2723 "en-ca" => Ok(LocaleTag::EnCa),
2724 "" => Err(LocaleError::Unsupported),
2725 _ if is_bcp47_parseable(&raw) => Ok(LocaleTag::Other(canonical_other(&raw))),
2726 _ => Err(LocaleError::Unsupported),
2727 }
2728 }
2729
2730 pub fn as_str(&self) -> &str {
2732 match self {
2733 LocaleTag::Global => "global",
2734 LocaleTag::DeDe => "de-DE",
2735 LocaleTag::DeAt => "de-AT",
2736 LocaleTag::DeCh => "de-CH",
2737 LocaleTag::EnUs => "en-US",
2738 LocaleTag::EnGb => "en-GB",
2739 LocaleTag::EnIe => "en-IE",
2740 LocaleTag::EnAu => "en-AU",
2741 LocaleTag::EnCa => "en-CA",
2742 LocaleTag::Other(tag) => tag.as_str(),
2743 }
2744 }
2745}
2746
2747impl LocaleChain {
2748 pub fn from_tags(mut tags: Vec<LocaleTag>) -> LocaleChain {
2750 ensure_global(&mut tags);
2751 LocaleChain(tags)
2752 }
2753
2754 pub fn from_cli(raw: &str) -> Result<LocaleChain, LocaleError> {
2756 let tags = raw
2757 .split(',')
2758 .map(LocaleTag::parse)
2759 .collect::<Result<Vec<_>, _>>()?;
2760 Ok(LocaleChain::from_tags(tags))
2761 }
2762
2763 pub fn merge_policy_and_cli(
2765 policy: Option<&[LocaleTag]>,
2766 cli: Option<&[LocaleTag]>,
2767 ) -> LocaleChain {
2768 Self::merge_cli_policy_rulepack_default(cli, policy, None)
2769 }
2770
2771 pub fn merge_cli_policy_rulepack_default(
2773 cli: Option<&[LocaleTag]>,
2774 policy: Option<&[LocaleTag]>,
2775 rulepack_defaults: Option<&[LocaleTag]>,
2776 ) -> LocaleChain {
2777 let tags = cli
2778 .filter(|tags| !tags.is_empty())
2779 .or_else(|| policy.filter(|tags| !tags.is_empty()))
2780 .or_else(|| rulepack_defaults.filter(|tags| !tags.is_empty()))
2781 .map(|tags| tags.to_vec())
2782 .unwrap_or_else(|| vec![LocaleTag::Global]);
2783 LocaleChain::from_tags(tags)
2784 }
2785
2786 pub fn intersects(&self, recognizer_locales: &[LocaleTag]) -> bool {
2788 if recognizer_locales.is_empty() {
2789 return true;
2790 }
2791 recognizer_locales.iter().any(|recognizer_locale| {
2792 *recognizer_locale == LocaleTag::Global
2793 || self.0.iter().any(|active| active == recognizer_locale)
2794 })
2795 }
2796
2797 pub fn as_slice(&self) -> &[LocaleTag] {
2799 &self.0
2800 }
2801
2802 pub fn to_strings(&self) -> Vec<String> {
2804 self.0.iter().map(ToString::to_string).collect()
2805 }
2806}
2807
2808impl From<&[LocaleTag]> for LocaleChain {
2809 fn from(tags: &[LocaleTag]) -> Self {
2810 let mut owned = tags.to_vec();
2811 ensure_global(&mut owned);
2812 LocaleChain(owned)
2813 }
2814}
2815
2816impl fmt::Display for LocaleTag {
2817 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2818 f.write_str(self.as_str())
2819 }
2820}
2821
2822#[derive(Debug, Clone)]
2832#[non_exhaustive]
2833pub enum RawDocument {
2834 Structured(BTreeMap<String, Value>),
2836 Text(String),
2838}
2839
2840#[derive(Debug, Clone, Serialize)]
2859#[serde(untagged)]
2860#[non_exhaustive]
2861pub enum CleanDocument {
2862 Structured(BTreeMap<String, Value>),
2864 Text(String),
2866}
2867
2868#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2870#[serde(untagged)]
2871#[non_exhaustive]
2872pub enum Value {
2873 Null,
2875 Bool(bool),
2877 String(String),
2879 I64(i64),
2881 Array(Vec<Value>),
2883 Object(BTreeMap<String, Value>),
2885}
2886
2887impl Value {
2888 pub fn as_str(&self) -> Option<&str> {
2890 match self {
2891 Self::String(value) => Some(value.as_str()),
2892 Self::Null | Self::Bool(_) | Self::I64(_) | Self::Array(_) | Self::Object(_) => None,
2893 }
2894 }
2895
2896 pub fn scalar_to_safety_net_string(&self) -> Option<String> {
2898 match self {
2899 Self::String(value) if !value.is_empty() => Some(value.clone()),
2900 Self::String(_) | Self::Null | Self::Array(_) | Self::Object(_) => None,
2901 Self::Bool(value) => Some(value.to_string()),
2902 Self::I64(value) => Some(value.to_string()),
2903 }
2904 }
2905}
2906
2907impl PartialEq<&str> for Value {
2908 fn eq(&self, other: &&str) -> bool {
2909 self.as_str() == Some(*other)
2910 }
2911}
2912
2913#[derive(Debug, Clone, Default)]
2915pub struct DictionaryBundle {
2916 entries: HashMap<String, DictionaryEntry>,
2917}
2918
2919#[derive(Debug, Clone)]
2921pub struct DictionaryEntry {
2922 terms: Vec<String>,
2923 case_sensitive: bool,
2924 source: DictionarySource,
2925}
2926
2927#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2929#[non_exhaustive]
2930pub enum DictionarySource {
2931 Cli,
2933 Rulepack,
2935}
2936
2937#[derive(Debug, Clone, PartialEq, Eq)]
2939#[non_exhaustive]
2940pub struct DictionaryStats {
2941 pub name: String,
2943 pub term_count: usize,
2945 pub source: DictionarySource,
2947}
2948
2949impl DictionaryStats {
2950 pub fn new(name: impl Into<String>, term_count: usize, source: DictionarySource) -> Self {
2952 Self {
2953 name: name.into(),
2954 term_count,
2955 source,
2956 }
2957 }
2958}
2959
2960#[derive(Debug, Clone, PartialEq, Eq)]
2962#[non_exhaustive]
2963pub struct RulepackDict {
2964 pub name: String,
2966 pub terms: Vec<String>,
2968 pub case_sensitive: bool,
2970}
2971
2972impl RulepackDict {
2973 pub fn new(name: impl Into<String>, terms: Vec<String>, case_sensitive: bool) -> Self {
2975 Self {
2976 name: name.into(),
2977 terms,
2978 case_sensitive,
2979 }
2980 }
2981}
2982
2983#[derive(Debug, Clone, PartialEq, Eq)]
2985#[non_exhaustive]
2986pub enum DictionaryLoadError {
2987 Empty { name: String },
2989 UnicodeInsensitiveUnsupported { name: String },
2991}
2992
2993impl fmt::Display for DictionaryLoadError {
2994 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2995 match self {
2996 Self::Empty { name } => write!(f, "dictionary '{name}' has no terms"),
2997 Self::UnicodeInsensitiveUnsupported { name } => write!(
2998 f,
2999 "dictionary '{name}' uses unicode terms with case-insensitive matching, unsupported in v0.4.0; use case_sensitive = true"
3000 ),
3001 }
3002 }
3003}
3004
3005impl std::error::Error for DictionaryLoadError {}
3006
3007impl DictionaryBundle {
3008 pub fn from_rulepack_terms(terms: &[RulepackDict]) -> Self {
3010 let mut entries = HashMap::with_capacity(terms.len());
3011 for dictionary in terms {
3012 let entry = DictionaryEntry::new(
3013 &dictionary.name,
3014 dictionary.terms.clone(),
3015 dictionary.case_sensitive,
3016 DictionarySource::Rulepack,
3017 )
3018 .expect("Policy validates dictionary terms before bundle construction");
3019 entries.insert(dictionary.name.clone(), entry);
3020 }
3021 Self { entries }
3022 }
3023
3024 pub fn from_entries(entries: impl IntoIterator<Item = (String, DictionaryEntry)>) -> Self {
3026 Self {
3027 entries: entries.into_iter().collect(),
3028 }
3029 }
3030
3031 pub fn merge(a: Self, b: Self) -> Self {
3033 let mut entries = a.entries;
3034 entries.extend(b.entries);
3035 Self { entries }
3036 }
3037
3038 pub fn get(&self, name: &str) -> Option<&DictionaryEntry> {
3040 self.entries.get(name)
3041 }
3042
3043 pub fn stats(&self) -> Vec<DictionaryStats> {
3045 let mut stats = self
3046 .entries
3047 .iter()
3048 .map(|(name, entry)| DictionaryStats {
3049 name: name.clone(),
3050 term_count: entry.terms.len(),
3051 source: entry.source,
3052 })
3053 .collect::<Vec<_>>();
3054 stats.sort_by(|a, b| a.name.cmp(&b.name));
3055 stats
3056 }
3057}
3058
3059impl DictionaryEntry {
3060 pub fn new(
3062 name: &str,
3063 terms: Vec<String>,
3064 case_sensitive: bool,
3065 source: DictionarySource,
3066 ) -> Result<Self, DictionaryLoadError> {
3067 if terms.is_empty() {
3068 return Err(DictionaryLoadError::Empty {
3069 name: name.to_string(),
3070 });
3071 }
3072 if !case_sensitive && terms.iter().any(|term| !term.is_ascii()) {
3073 return Err(DictionaryLoadError::UnicodeInsensitiveUnsupported {
3074 name: name.to_string(),
3075 });
3076 }
3077 Ok(Self {
3078 terms,
3079 case_sensitive,
3080 source,
3081 })
3082 }
3083
3084 pub fn case_sensitive(&self) -> bool {
3086 self.case_sensitive
3087 }
3088
3089 pub fn terms(&self) -> &[String] {
3091 &self.terms
3092 }
3093}
3094
3095#[cfg(test)]
3096mod dictionary_tests {
3097 use super::*;
3098
3099 #[test]
3100 fn dictionary_entry_rejects_empty_terms() {
3101 let err = DictionaryEntry::new("empty", Vec::new(), true, DictionarySource::Cli)
3102 .expect_err("empty dictionaries must fail closed");
3103
3104 assert!(matches!(err, DictionaryLoadError::Empty { name } if name == "empty"));
3105 }
3106
3107 #[test]
3108 fn dictionary_entry_rejects_non_ascii_case_insensitive_terms() {
3109 let err = DictionaryEntry::new(
3110 "songs",
3111 vec!["Beyonce".to_string(), "Caf\u{00e9}".to_string()],
3112 false,
3113 DictionarySource::Cli,
3114 )
3115 .expect_err("unicode case-insensitive dictionaries must fail closed");
3116
3117 assert!(matches!(
3118 err,
3119 DictionaryLoadError::UnicodeInsensitiveUnsupported { name } if name == "songs"
3120 ));
3121 }
3122}
3123
3124#[cfg(test)]
3125mod redaction_logger_tests {
3126 use super::*;
3127
3128 struct CapturingLogger;
3129
3130 impl RedactionLogger for CapturingLogger {
3131 fn log(&self, _entry: &RedactionEntry) -> Result<(), RedactionLogError> {
3132 Ok(())
3133 }
3134 }
3135
3136 fn assert_send_sync<T: Send + Sync + ?Sized>() {}
3137
3138 #[test]
3139 fn fallback_reason_serializes_snake_case_and_accepts_legacy_pascal_case() {
3140 for (legacy, canonical, value) in [
3141 (
3142 "OverlapConflict",
3143 "overlap_conflict",
3144 FallbackReason::OverlapConflict,
3145 ),
3146 (
3147 "ValidatorVeto",
3148 "validator_veto",
3149 FallbackReason::ValidatorVeto,
3150 ),
3151 (
3152 "AnchorMissing",
3153 "anchor_missing",
3154 FallbackReason::AnchorMissing,
3155 ),
3156 (
3157 "ResidualSuspect",
3158 "residual_suspect",
3159 FallbackReason::ResidualSuspect,
3160 ),
3161 ] {
3162 let legacy_json = format!(r#""{legacy}""#);
3163 assert_eq!(
3164 serde_json::from_str::<FallbackReason>(&legacy_json)
3165 .expect("legacy fallback reason"),
3166 value
3167 );
3168 assert_eq!(
3169 serde_json::to_string(&value).expect("fallback reason"),
3170 format!(r#""{canonical}""#)
3171 );
3172 }
3173 }
3174
3175 #[test]
3176 fn audit_enum_canonical_forms_round_trip() {
3177 for value in [
3178 Action::Tokenize,
3179 Action::Redact,
3180 Action::FormatPreserve,
3181 Action::Generalize,
3182 Action::Preserve,
3183 ] {
3184 assert_eq!(Action::from_canonical_str(value.as_str()), Some(value));
3185 }
3186 for value in [
3187 ConflictTier::None,
3188 ConflictTier::ClassPriority,
3189 ConflictTier::RulePriority,
3190 ConflictTier::Score,
3191 ConflictTier::SpanLength,
3192 ConflictTier::Validator,
3193 ConflictTier::ValidatorVeto,
3194 ConflictTier::CollisionPolicy,
3195 ConflictTier::AnchoredContext,
3196 ConflictTier::StructuredContainment,
3197 ConflictTier::RecognizerId,
3198 ConflictTier::Merged,
3199 ConflictTier::Redact,
3200 ConflictTier::Resolve,
3201 ConflictTier::Fallback,
3202 ] {
3203 assert_eq!(
3204 ConflictTier::from_canonical_str(value.as_str()),
3205 Some(value)
3206 );
3207 }
3208 for value in [DocumentKind::Structured, DocumentKind::Text] {
3209 assert_eq!(
3210 DocumentKind::from_canonical_str(value.as_str()),
3211 Some(value)
3212 );
3213 }
3214 for value in [
3215 FallbackReason::OverlapConflict,
3216 FallbackReason::ValidatorVeto,
3217 FallbackReason::AnchorMissing,
3218 FallbackReason::ResidualSuspect,
3219 ] {
3220 assert_eq!(
3221 FallbackReason::from_canonical_str(value.as_str()),
3222 Some(value)
3223 );
3224 }
3225 }
3226
3227 #[test]
3228 fn redaction_log_error_display_is_stable() {
3229 assert_eq!(
3230 RedactionLogError::Sqlite("write failed".to_string()).to_string(),
3231 "sqlite redaction log error: write failed"
3232 );
3233 assert_eq!(
3234 RedactionLogError::Backend("sink failed".to_string()).to_string(),
3235 "backend redaction log error: sink failed"
3236 );
3237 }
3238
3239 #[test]
3240 fn redaction_logger_trait_object_is_send_sync() {
3241 assert_send_sync::<dyn RedactionLogger>();
3242 }
3243
3244 #[test]
3245 fn local_logger_can_implement_redaction_logger() {
3246 let logger = CapturingLogger;
3247 let entry = RedactionEntry {
3248 source: "unit-test".to_string(),
3249 recognizer_id: None,
3250 recognizer_version_id: None,
3251 class: PiiClass::Email,
3252 action: Action::Tokenize,
3253 field_name: None,
3254 document_kind: DocumentKind::Text,
3255 conflict_loser: false,
3256 decided_by: ConflictTier::None,
3257 created_at: 0,
3258 session_id: None,
3259 validator_fail_reason: None,
3260 ambiguity_record: None,
3261 collision_family: None,
3262 collision_variant: None,
3263 fallback_triggered: None,
3264 provenance_stage: None,
3265 provenance_model_id: None,
3266 provenance_model_version: None,
3267 provenance_artifact_sha256: None,
3268 provenance_tokenizer_sha256: None,
3269 provenance_locale_resolved: None,
3270 provenance_locale_match_kind: None,
3271 provenance_canonical_class: None,
3272 provenance_native_class: None,
3273 provenance_confidence: None,
3274 provenance_merged_from: None,
3275 backend_silently_dropped: None,
3276 restore_policy: None,
3277 restore_decision: None,
3278 restore_unknown_token_count: None,
3279 restore_manifest_bypass_count: None,
3280 restore_fresh_pii_count: None,
3281 restore_phase_mask: None,
3282 };
3283
3284 let trait_object: &dyn RedactionLogger = &logger;
3285 trait_object.log(&entry).expect("log entry");
3286 }
3287
3288 #[test]
3289 fn redaction_entry_json_shape_omits_absent_recognizer_lineage() {
3290 let entry = RedactionEntry::new(
3291 "email.global",
3292 PiiClass::Email,
3293 Action::Tokenize,
3294 None,
3295 DocumentKind::Text,
3296 false,
3297 ConflictTier::None,
3298 0,
3299 None,
3300 );
3301
3302 let rendered = serde_json::to_string(&entry).expect("serialize redaction entry");
3303
3304 assert_eq!(
3305 rendered,
3306 r#"{"source":"email.global","class":"email","action":"tokenize","field_name":null,"document_kind":"text","conflict_loser":false,"decided_by":"none","created_at":0,"session_id":null,"validator_fail_reason":null,"ambiguity_record":null,"collision_family":null,"collision_variant":null,"fallback_triggered":null}"#
3307 );
3308 }
3309
3310 #[test]
3311 fn redaction_entry_json_shape_includes_recognizer_lineage_when_present() {
3312 let entry = RedactionEntry::new(
3313 "ner/ort",
3314 PiiClass::Name,
3315 Action::Tokenize,
3316 None,
3317 DocumentKind::Text,
3318 false,
3319 ConflictTier::None,
3320 0,
3321 None,
3322 )
3323 .with_recognizer_metadata(
3324 Some("ner".to_string()),
3325 Some("ner.davlan-mbert.v1".to_string()),
3326 );
3327
3328 let value: serde_json::Value =
3329 serde_json::to_value(&entry).expect("serialize redaction entry");
3330
3331 assert_eq!(value["recognizer_id"], "ner");
3332 assert_eq!(value["recognizer_version_id"], "ner.davlan-mbert.v1");
3333 }
3334
3335 #[test]
3336 fn candidate_keeps_versioned_and_unversioned_recognizer_ids() {
3337 let unversioned = Candidate::new(
3338 0..5,
3339 PiiClass::Email,
3340 "email.global",
3341 0.9,
3342 10,
3343 None,
3344 "email",
3345 "email.global",
3346 ConflictTier::None,
3347 Vec::new(),
3348 );
3349 assert_eq!(unversioned.recognizer_id, "email.global");
3350 assert_eq!(unversioned.recognizer_version_id, None);
3351
3352 let versioned = unversioned
3353 .clone()
3354 .with_recognizer_version_id("email.global.v1");
3355 assert_eq!(versioned.recognizer_id, "email.global");
3356 assert_eq!(
3357 versioned.recognizer_version_id.as_deref(),
3358 Some("email.global.v1")
3359 );
3360 }
3361}
3362
3363#[cfg(test)]
3364mod safety_net_manifest_tests {
3365 use super::*;
3366
3367 fn span(start: usize, end: usize, class: PiiClass) -> EmittedTokenSpan {
3368 EmittedTokenSpan {
3369 clean_span: start..end,
3370 raw_span: start..end,
3371 class,
3372 }
3373 }
3374
3375 fn diff(manifest: Manifest, suspect: Range<usize>, class: PiiClass) -> Option<LeakKind> {
3376 manifest.diff_against(&suspect, &class)
3377 }
3378
3379 #[test]
3380 fn exact_same_class_coverage_is_not_a_leak() {
3381 let manifest = Manifest::from_spans(vec![span(0, 8, PiiClass::Email)]);
3382
3383 assert_eq!(diff(manifest, 0..8, PiiClass::Email), None);
3384 }
3385
3386 #[test]
3387 fn uncovered_outside_all_tokens_is_uncovered() {
3388 let manifest = Manifest::from_spans(vec![span(20, 30, PiiClass::Email)]);
3389
3390 assert_eq!(
3391 diff(manifest, 0..10, PiiClass::Email),
3392 Some(LeakKind::Uncovered)
3393 );
3394 }
3395
3396 #[test]
3397 fn single_internal_gap_returns_partial_bleed() {
3398 let manifest = Manifest::from_spans(vec![
3399 span(0, 5, PiiClass::Email),
3400 span(10, 15, PiiClass::Email),
3401 ]);
3402
3403 assert_eq!(
3404 diff(manifest, 0..15, PiiClass::Email),
3405 Some(LeakKind::PartialBleed { uncovered: 5..10 })
3406 );
3407 }
3408
3409 #[test]
3410 fn multi_gap_returns_deterministic_first_uncovered_gap() {
3411 let manifest = Manifest::from_spans(vec![
3412 span(0, 3, PiiClass::Email),
3413 span(5, 7, PiiClass::Email),
3414 span(9, 12, PiiClass::Email),
3415 ]);
3416
3417 assert_eq!(
3420 diff(manifest, 0..12, PiiClass::Email),
3421 Some(LeakKind::PartialBleed { uncovered: 3..5 })
3422 );
3423 }
3424
3425 #[test]
3426 fn multi_class_overlap_reports_first_mismatch_deterministically() {
3427 let manifest = Manifest::from_spans(vec![
3428 span(0, 4, PiiClass::Name),
3429 span(4, 8, PiiClass::Location),
3430 ]);
3431
3432 assert_eq!(
3433 diff(manifest, 0..8, PiiClass::Email),
3434 Some(LeakKind::ClassMismatch {
3435 pipeline_class: PiiClass::Name,
3436 safety_net_class: PiiClass::Email,
3437 })
3438 );
3439 }
3440
3441 #[test]
3442 fn adjacent_same_class_tokens_cover_continuously() {
3443 let manifest = Manifest::from_spans(vec![
3444 span(0, 5, PiiClass::Email),
3445 span(5, 10, PiiClass::Email),
3446 ]);
3447
3448 assert_eq!(diff(manifest, 0..10, PiiClass::Email), None);
3449 }
3450
3451 #[test]
3452 fn partial_bleed_at_start_end_and_middle() {
3453 let manifest = Manifest::from_spans(vec![span(3, 8, PiiClass::Email)]);
3454
3455 assert_eq!(
3456 diff(manifest.clone(), 0..8, PiiClass::Email),
3457 Some(LeakKind::PartialBleed { uncovered: 0..3 })
3458 );
3459 assert_eq!(
3460 diff(manifest.clone(), 3..10, PiiClass::Email),
3461 Some(LeakKind::PartialBleed { uncovered: 8..10 })
3462 );
3463
3464 let with_gap = Manifest::from_spans(vec![
3465 span(0, 3, PiiClass::Email),
3466 span(6, 10, PiiClass::Email),
3467 ]);
3468 assert_eq!(
3469 diff(with_gap, 0..10, PiiClass::Email),
3470 Some(LeakKind::PartialBleed { uncovered: 3..6 })
3471 );
3472 }
3473
3474 #[test]
3475 fn byte_indices_are_not_character_indices() {
3476 let text = "ID: 😀 <Email_1>";
3477 let token_start = text.find("<Email_1>").expect("token start");
3478 assert_eq!(token_start, 9, "emoji is four bytes, not one char");
3479 let manifest = Manifest::from_spans(vec![span(token_start, text.len(), PiiClass::Email)]);
3480
3481 assert_eq!(
3482 diff(manifest, token_start..text.len(), PiiClass::Email),
3483 None
3484 );
3485 }
3486
3487 #[test]
3488 fn empty_suspect_range_is_not_a_leak() {
3489 let manifest = Manifest::default();
3490
3491 assert_eq!(diff(manifest, 3..3, PiiClass::Email), None);
3492 }
3493
3494 #[test]
3495 fn safety_net_error_display_is_variant_specific_and_bytes_free() {
3496 let cases = [
3497 SafetyNetError::Unavailable {
3498 reason: "not configured".to_string(),
3499 }
3500 .to_string(),
3501 SafetyNetError::WeightsMissing {
3502 path: "/models/opf".to_string(),
3503 }
3504 .to_string(),
3505 SafetyNetError::ModelUnavailable {
3506 reason: "load failed".to_string(),
3507 }
3508 .to_string(),
3509 SafetyNetError::ModelIntegrityMismatch {
3510 expected: "e3b0c44298fc1c149afbf4c8996fb924".to_string(),
3511 actual: "4e07408562bedb8b60ce05c1decfe3ad".to_string(),
3512 }
3513 .to_string(),
3514 SafetyNetError::InputTooLarge {
3515 limit: 1024,
3516 actual: 2048,
3517 }
3518 .to_string(),
3519 SafetyNetError::Runtime {
3520 message: "timeout".to_string(),
3521 }
3522 .to_string(),
3523 SafetyNetError::InvalidOutput {
3524 message: "bad json".to_string(),
3525 }
3526 .to_string(),
3527 ];
3528
3529 for rendered in cases {
3530 assert!(!rendered.contains("alice@example.invalid"));
3531 }
3532 }
3533}
3534
3535pub trait Recognizer: Send + Sync {
3537 fn id(&self) -> &str;
3539 fn supported_class(&self) -> &PiiClass;
3541 fn detect(
3543 &self,
3544 input: &str,
3545 ctx: &DetectContext<'_>,
3546 ) -> std::result::Result<Vec<Candidate>, DetectError>;
3547 fn token_family(&self) -> &str;
3549 fn validator_kind(&self) -> Option<ValidatorKind> {
3551 None
3552 }
3553 fn locales(&self) -> &[LocaleTag] {
3555 &[LocaleTag::Global]
3556 }
3557 fn locale_basis(&self) -> LocaleBasis {
3559 LocaleBasis::Document
3560 }
3561}
3562
3563#[derive(Debug, Clone, PartialEq, Eq, Error)]
3565#[non_exhaustive]
3566pub enum DetectError {
3567 #[error("recognizer {recognizer_id} backend failed: {message}")]
3569 Backend {
3570 recognizer_id: String,
3572 message: String,
3574 },
3575}
3576
3577impl DetectError {
3578 pub fn backend(recognizer_id: impl Into<String>, source: impl Into<String>) -> Self {
3580 Self::Backend {
3581 recognizer_id: recognizer_id.into(),
3582 message: source.into(),
3583 }
3584 }
3585}
3586
3587#[derive(Debug, Clone, PartialEq)]
3589#[non_exhaustive]
3590pub struct Candidate {
3591 pub span: Range<usize>,
3593 pub class: PiiClass,
3595 pub recognizer_id: String,
3597 pub recognizer_version_id: Option<String>,
3599 pub score: f32,
3601 pub priority: i32,
3603 pub canonical_form: Option<String>,
3605 pub token_family: String,
3607 pub source: String,
3609 pub decided_by: ConflictTier,
3611 pub merged_sources: Vec<String>,
3613}
3614
3615impl Candidate {
3616 #[allow(clippy::too_many_arguments)]
3618 pub fn new(
3619 span: Range<usize>,
3620 class: PiiClass,
3621 recognizer_id: impl Into<String>,
3622 score: f32,
3623 priority: i32,
3624 canonical_form: Option<String>,
3625 token_family: impl Into<String>,
3626 source: impl Into<String>,
3627 decided_by: ConflictTier,
3628 merged_sources: Vec<String>,
3629 ) -> Self {
3630 Self {
3631 span,
3632 class,
3633 recognizer_id: recognizer_id.into(),
3634 recognizer_version_id: None,
3635 score,
3636 priority,
3637 canonical_form,
3638 token_family: token_family.into(),
3639 source: source.into(),
3640 decided_by,
3641 merged_sources,
3642 }
3643 }
3644
3645 pub fn with_span(mut self, span: Range<usize>) -> Self {
3647 self.span = span;
3648 self
3649 }
3650
3651 pub fn with_recognizer_version_id(mut self, recognizer_version_id: impl Into<String>) -> Self {
3653 self.recognizer_version_id = Some(recognizer_version_id.into());
3654 self
3655 }
3656}
3657
3658#[non_exhaustive]
3660pub struct DetectContext<'a> {
3661 pub locale_chain: &'a [LocaleTag],
3663 pub dictionaries: &'a DictionaryBundle,
3665 pub fields: &'a (),
3667 pub degraded: Cell<bool>,
3669}
3670
3671impl<'a> DetectContext<'a> {
3672 pub fn new(locale_chain: &'a [LocaleTag], dictionaries: &'a DictionaryBundle) -> Self {
3674 Self {
3675 locale_chain,
3676 dictionaries,
3677 fields: &(),
3678 degraded: Cell::new(false),
3679 }
3680 }
3681}
3682
3683fn ensure_global(tags: &mut Vec<LocaleTag>) {
3684 if !tags.contains(&LocaleTag::Global) {
3685 tags.push(LocaleTag::Global);
3686 }
3687}
3688
3689fn is_bcp47_parseable(raw: &str) -> bool {
3690 let mut parts = raw.split('-');
3691 let Some(language) = parts.next() else {
3692 return false;
3693 };
3694 if !(2..=8).contains(&language.len()) || !language.chars().all(|ch| ch.is_ascii_alphabetic()) {
3695 return false;
3696 }
3697 parts.all(|part| {
3698 (2..=8).contains(&part.len()) && part.chars().all(|ch| ch.is_ascii_alphanumeric())
3699 })
3700}
3701
3702fn canonical_other(raw: &str) -> String {
3703 let mut parts = raw.split('-');
3704 let language = parts.next().unwrap_or_default().to_ascii_lowercase();
3705 let rest = parts.map(|part| {
3706 if part.len() == 2 && part.chars().all(|ch| ch.is_ascii_alphabetic()) {
3707 part.to_ascii_uppercase()
3708 } else {
3709 part.to_ascii_lowercase()
3710 }
3711 });
3712 std::iter::once(language)
3713 .chain(rest)
3714 .collect::<Vec<_>>()
3715 .join("-")
3716}