1#![cfg_attr(docsrs, feature(doc_cfg))]
2
3pub mod inspection;
4pub mod nym;
5pub mod payment_card;
6pub mod redaction_marker;
7
8use std::cell::Cell;
9use std::collections::{BTreeMap, BTreeSet, HashMap};
10use std::fmt;
11use std::ops::Range;
12
13use serde::{Deserialize, Serialize};
14use sha3::{Digest, Keccak256};
15use thiserror::Error;
16
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
25pub enum EvidenceKind {
26 #[default]
28 Learned,
29 Rule,
31}
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
36#[serde(rename_all = "snake_case")]
37pub enum RecordMatchKind {
38 Exact,
39 WhitespaceFlexible,
40 CaseFolded,
41 WhitespaceCaseFolded,
42 CorroboratedSingle,
43}
44
45pub trait Detector: Send + Sync {
47 fn detect(&self, input: &str) -> Vec<Detection>;
49
50 fn evidence(&self) -> EvidenceKind {
53 EvidenceKind::Learned
54 }
55
56 fn try_detect(&self, input: &str) -> Result<Vec<Detection>, RecognizerRuntimeError> {
58 Ok(self.detect(input))
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64#[non_exhaustive]
65pub struct RecognizerRuntimeError {
66 pub recognizer_id: String,
67 pub message: String,
68}
69
70impl RecognizerRuntimeError {
71 pub fn new(recognizer_id: impl Into<String>, message: impl Into<String>) -> Self {
72 Self {
73 recognizer_id: recognizer_id.into(),
74 message: message.into(),
75 }
76 }
77}
78
79impl fmt::Display for RecognizerRuntimeError {
80 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81 write!(
82 f,
83 "recognizer '{}' backend failed: {}",
84 self.recognizer_id, self.message
85 )
86 }
87}
88
89impl std::error::Error for RecognizerRuntimeError {}
90
91#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
119pub enum PiiClass {
120 Email,
122 Name,
124 Location,
126 Organization,
128 Custom(String),
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
134#[error("custom class name normalizes to empty")]
135pub struct EmptyCustomClassName;
136
137pub const NER_RECOGNIZER_ID: &str = "ner";
141
142pub const BUILTIN_CLASS_NAMES: &[&str] = &["Email", "Name", "Location", "Organization"];
144
145pub const RESERVED_BUNDLED_FAMILIES: &[&str] = &[
150 "us-9-digit-id",
151 "iberian-id",
152 "payment-card-or-iban",
153 "phone-or-imei",
154 "vin-or-serial",
155 "mac-or-hex",
156 "passport-or-doc-support",
157 "national-13-digit",
158 "italian-cf-or-serial",
159 "german-personalausweis",
160 "swedish-personnummer",
161 "finnish-hetu",
162];
163
164pub const RESTORE_PHASE_MANIFEST_LOOKUP: u32 = 1 << 0;
165pub const RESTORE_PHASE_UNKNOWN_TOKEN_SCAN: u32 = 1 << 1;
166pub const RESTORE_PHASE_MANIFEST_BYPASS_SCAN: u32 = 1 << 2;
167pub const RESTORE_PHASE_FRESH_PII_SCAN: u32 = 1 << 3;
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[non_exhaustive]
171pub struct RestoredText {
172 pub text: String,
173}
174
175impl RestoredText {
176 pub fn new(text: impl Into<String>) -> Self {
177 Self { text: text.into() }
178 }
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(rename_all = "snake_case")]
183#[non_exhaustive]
184pub enum RestorePolicy {
185 Strict,
186 Lenient,
187}
188
189impl RestorePolicy {
190 pub fn as_str(self) -> &'static str {
191 match self {
192 Self::Strict => "strict",
193 Self::Lenient => "lenient",
194 }
195 }
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
199#[serde(rename_all = "snake_case")]
200#[non_exhaustive]
201pub enum RestoreDecision {
202 Success,
203 Partial,
204 Failed,
205}
206
207impl RestoreDecision {
208 pub fn as_str(self) -> &'static str {
209 match self {
210 Self::Success => "success",
211 Self::Partial => "partial",
212 Self::Failed => "failed",
213 }
214 }
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218#[non_exhaustive]
219pub struct RestoreTelemetry {
220 pub unknown_token_count: u64,
222 pub manifest_bypass_count: u64,
224 #[serde(default)]
226 pub trap_shape_count: u64,
227 pub fresh_pii_detected_count: u64,
228 pub restore_policy: RestorePolicy,
229 pub restore_decision: RestoreDecision,
230 pub phase_execution_mask: u32,
231}
232
233impl RestoreTelemetry {
234 pub fn new(restore_policy: RestorePolicy) -> Self {
235 Self {
236 unknown_token_count: 0,
237 manifest_bypass_count: 0,
238 trap_shape_count: 0,
239 fresh_pii_detected_count: 0,
240 restore_policy,
241 restore_decision: RestoreDecision::Success,
242 phase_execution_mask: 0,
243 }
244 }
245
246 pub fn restore_policy_str(&self) -> &'static str {
247 self.restore_policy.as_str()
248 }
249
250 pub fn restore_decision_str(&self) -> &'static str {
251 self.restore_decision.as_str()
252 }
253}
254
255#[derive(Debug, Clone, PartialEq, Eq)]
257#[non_exhaustive]
258pub struct CollisionMembership {
259 pub family: String,
261 pub variant: String,
263 pub precedence: u32,
265 pub mandatory_anchor: Option<String>,
267}
268
269impl CollisionMembership {
270 pub fn new(
272 family: impl Into<String>,
273 variant: impl Into<String>,
274 precedence: u32,
275 mandatory_anchor: Option<String>,
276 ) -> Self {
277 Self {
278 family: family.into(),
279 variant: variant.into(),
280 precedence,
281 mandatory_anchor,
282 }
283 }
284}
285
286impl PiiClass {
287 pub fn from_policy_name(input: &str) -> Option<Self> {
289 let trimmed = input.trim();
290 match trimmed.to_ascii_lowercase().as_str() {
291 "email" => Some(Self::Email),
292 "name" => Some(Self::Name),
293 "location" => Some(Self::Location),
294 "organization" => Some(Self::Organization),
295 _ => trimmed
296 .split_once(':')
297 .filter(|(namespace, _)| namespace.eq_ignore_ascii_case("custom"))
298 .and_then(|(_, name)| Self::from_custom_policy_suffix(name)),
299 }
300 }
301
302 fn from_custom_policy_suffix(name: &str) -> Option<Self> {
303 let name = name.trim();
304 if let Some(family) = name.strip_prefix("family:") {
305 (!family.trim().is_empty()).then(|| Self::family(family))
306 } else {
307 Self::custom(name).ok()
308 }
309 }
310
311 pub fn builtin_variants() -> &'static [PiiClass] {
313 &[
314 PiiClass::Email,
315 PiiClass::Name,
316 PiiClass::Location,
317 PiiClass::Organization,
318 ]
319 }
320
321 pub fn custom(name: &str) -> Result<Self, EmptyCustomClassName> {
325 let mut normalized = String::new();
326 let mut pending_underscore = false;
327 for ch in name.trim().chars() {
328 if ch.is_ascii_alphanumeric() {
329 if pending_underscore && !normalized.is_empty() {
330 normalized.push('_');
331 }
332 normalized.push(ch.to_ascii_lowercase());
333 pending_underscore = false;
334 } else {
335 pending_underscore = true;
336 }
337 }
338
339 let class = Self::Custom(normalized);
340 class.validate_custom_name()?;
341 Ok(class)
342 }
343
344 pub fn validate_custom_name(&self) -> Result<(), EmptyCustomClassName> {
346 if matches!(self, Self::Custom(name) if !name.chars().any(|ch| ch.is_ascii_alphanumeric()))
347 {
348 return Err(EmptyCustomClassName);
349 }
350 Ok(())
351 }
352
353 pub fn family(name: &str) -> Self {
355 Self::Custom(format!("family:{}", name.trim()))
356 }
357
358 pub fn as_custom_name(&self) -> Option<&str> {
360 match self {
361 Self::Custom(name) => Some(name.as_str()),
362 Self::Email | Self::Name | Self::Location | Self::Organization => None,
363 }
364 }
365
366 pub fn as_family_name(&self) -> Option<&str> {
368 self.as_custom_name()?.strip_prefix("family:")
369 }
370
371 pub fn class_name(&self) -> String {
373 match self {
374 Self::Email => BUILTIN_CLASS_NAMES[0].to_string(),
375 Self::Name => BUILTIN_CLASS_NAMES[1].to_string(),
376 Self::Location => BUILTIN_CLASS_NAMES[2].to_string(),
377 Self::Organization => BUILTIN_CLASS_NAMES[3].to_string(),
378 Self::Custom(name) => format!("Custom:{name}"),
379 }
380 }
381
382 pub fn to_canonical_str(&self) -> String {
384 match self {
385 Self::Email => "email".to_string(),
386 Self::Name => "name".to_string(),
387 Self::Location => "location".to_string(),
388 Self::Organization => "organization".to_string(),
389 Self::Custom(name) => format!("custom:{name}"),
390 }
391 }
392
393 pub fn from_canonical_str(value: &str) -> Option<Self> {
395 match value {
396 "email" | "Email" => Some(Self::Email),
397 "name" | "Name" => Some(Self::Name),
398 "location" | "Location" => Some(Self::Location),
399 "organization" | "Organization" => Some(Self::Organization),
400 custom if custom.starts_with("custom:") => {
401 let name = &custom["custom:".len()..];
402 (!name.is_empty()).then(|| Self::Custom(name.to_string()))
403 }
404 _ => None,
405 }
406 }
407}
408
409#[cfg(test)]
410mod pii_class_tests {
411 use super::*;
412
413 #[test]
414 fn bundled_family_policy_names_round_trip_without_normalization() {
415 for family in RESERVED_BUNDLED_FAMILIES {
416 let policy_name = format!("custom:family:{family}");
417 let class = PiiClass::from_policy_name(&policy_name).expect("family policy class");
418
419 assert_eq!(class.to_canonical_str(), policy_name);
420 assert_eq!(class.as_family_name(), Some(*family));
421 }
422 }
423}
424
425#[derive(Debug, Clone, PartialEq, Eq)]
431#[non_exhaustive]
432pub struct PiiClassAudit(pub PiiClass);
433
434impl PiiClassAudit {
435 pub fn new(class: PiiClass) -> Self {
437 Self(class)
438 }
439
440 pub fn into_inner(self) -> PiiClass {
442 self.0
443 }
444}
445
446impl Serialize for PiiClassAudit {
447 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
448 where
449 S: serde::Serializer,
450 {
451 serializer.serialize_str(&self.0.to_canonical_str())
452 }
453}
454
455impl<'de> Deserialize<'de> for PiiClassAudit {
456 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
457 where
458 D: serde::Deserializer<'de>,
459 {
460 let value = String::deserialize(deserializer)?;
461 PiiClass::from_canonical_str(&value)
462 .map(Self)
463 .ok_or_else(|| {
464 serde::de::Error::custom(format!("unknown PiiClass canonical form: {value}"))
465 })
466 }
467}
468
469mod pii_class_audit_serde {
470 use super::{PiiClass, PiiClassAudit};
471 use serde::{Deserialize, Deserializer, Serialize, Serializer};
472
473 pub fn serialize<S>(class: &PiiClass, serializer: S) -> Result<S::Ok, S::Error>
474 where
475 S: Serializer,
476 {
477 PiiClassAudit::new(class.clone()).serialize(serializer)
478 }
479
480 pub fn deserialize<'de, D>(deserializer: D) -> Result<PiiClass, D::Error>
481 where
482 D: Deserializer<'de>,
483 {
484 Ok(PiiClassAudit::deserialize(deserializer)?.into_inner())
485 }
486}
487
488#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
490#[non_exhaustive]
491pub struct LosingCandidate {
492 #[serde(with = "pii_class_audit_serde")]
494 pub class: PiiClass,
495 pub recognizer_id: String,
497}
498
499impl LosingCandidate {
500 pub fn new(class: PiiClass, recognizer_id: impl Into<String>) -> Self {
502 Self {
503 class,
504 recognizer_id: recognizer_id.into(),
505 }
506 }
507}
508
509#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
513#[non_exhaustive]
514pub struct DerivedFamilyAction {
515 pub action: Action,
517 #[serde(with = "optional_pii_class_audit_serde", default)]
522 pub member_class: Option<PiiClass>,
523}
524
525impl DerivedFamilyAction {
526 pub fn new(action: Action, member_class: Option<PiiClass>) -> Self {
528 Self {
529 action,
530 member_class,
531 }
532 }
533}
534
535mod optional_pii_class_audit_serde {
536 use super::{PiiClass, PiiClassAudit};
537 use serde::{Deserialize, Deserializer, Serialize, Serializer};
538
539 pub fn serialize<S>(class: &Option<PiiClass>, serializer: S) -> Result<S::Ok, S::Error>
540 where
541 S: Serializer,
542 {
543 class.clone().map(PiiClassAudit::new).serialize(serializer)
544 }
545
546 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<PiiClass>, D::Error>
547 where
548 D: Deserializer<'de>,
549 {
550 Ok(Option::<PiiClassAudit>::deserialize(deserializer)?.map(PiiClassAudit::into_inner))
551 }
552}
553
554#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
556#[non_exhaustive]
557pub struct AmbiguityRecord {
558 #[serde(with = "pii_class_audit_serde")]
560 pub ambiguity_class: PiiClass,
561 pub losing_candidates: Vec<LosingCandidate>,
565 pub reason: AmbiguityReason,
567 #[serde(default, skip_serializing_if = "Option::is_none")]
571 pub derived_action: Option<DerivedFamilyAction>,
572}
573
574impl AmbiguityRecord {
575 pub fn new(
577 ambiguity_class: PiiClass,
578 losing_candidates: Vec<LosingCandidate>,
579 reason: AmbiguityReason,
580 ) -> Self {
581 Self {
582 ambiguity_class,
583 losing_candidates,
584 reason,
585 derived_action: None,
586 }
587 }
588
589 pub fn with_derived_action(mut self, derived: DerivedFamilyAction) -> Self {
591 self.derived_action = Some(derived);
592 self
593 }
594}
595
596#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
598#[non_exhaustive]
599#[serde(rename_all = "snake_case")]
600pub enum AmbiguityReason {
601 NoAnchor,
603 ValidatorIndeterminate,
605 MultiFamilyMatch,
607 PrecedenceTie,
609}
610
611#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
613#[non_exhaustive]
614#[serde(rename_all = "snake_case")]
615pub enum ValidatorFailReason {
616 RegexGuardRejected,
618 LuhnFailed,
620 IbanMod97Failed,
622 #[serde(alias = "email_rfc_failed")]
624 EmailRfcRejected,
625 #[serde(alias = "e164_phone_failed")]
627 PhoneE164Rejected,
628 PhoneNationalRegionMismatch,
630 Ipv4ParseFailed,
632 Ipv6ParseFailed,
634 Ipv4DocumentationRange,
636 Ipv6DocumentationRange,
638 EthEip55ChecksumFailed,
640 AadhaarVerhoeffFailed,
642 FrNirMod97Failed,
644 DeSteuerIdMod1110Failed,
646 BsnMod11Failed,
648 CpfMod11Failed,
650 CnpjMod11Failed,
652 UkNhsMod11Failed,
654 Ipv4LoopbackRange,
656 Ipv6LoopbackRange,
659}
660
661impl ValidatorFailReason {
662 #[doc(hidden)]
664 pub const ALL: &'static [Self] = &[
665 Self::RegexGuardRejected,
666 Self::LuhnFailed,
667 Self::IbanMod97Failed,
668 Self::EmailRfcRejected,
669 Self::PhoneE164Rejected,
670 Self::PhoneNationalRegionMismatch,
671 Self::Ipv4ParseFailed,
672 Self::Ipv6ParseFailed,
673 Self::Ipv4DocumentationRange,
674 Self::Ipv6DocumentationRange,
675 Self::EthEip55ChecksumFailed,
676 Self::AadhaarVerhoeffFailed,
677 Self::FrNirMod97Failed,
678 Self::DeSteuerIdMod1110Failed,
679 Self::BsnMod11Failed,
680 Self::CpfMod11Failed,
681 Self::CnpjMod11Failed,
682 Self::UkNhsMod11Failed,
683 Self::Ipv4LoopbackRange,
684 Self::Ipv6LoopbackRange,
685 ];
686
687 #[allow(dead_code)]
689 const fn all_is_exhaustive(value: Self) {
690 match value {
691 Self::RegexGuardRejected
692 | Self::LuhnFailed
693 | Self::IbanMod97Failed
694 | Self::EmailRfcRejected
695 | Self::PhoneE164Rejected
696 | Self::PhoneNationalRegionMismatch
697 | Self::Ipv4ParseFailed
698 | Self::Ipv6ParseFailed
699 | Self::Ipv4DocumentationRange
700 | Self::Ipv6DocumentationRange
701 | Self::EthEip55ChecksumFailed
702 | Self::AadhaarVerhoeffFailed
703 | Self::FrNirMod97Failed
704 | Self::DeSteuerIdMod1110Failed
705 | Self::BsnMod11Failed
706 | Self::CpfMod11Failed
707 | Self::CnpjMod11Failed
708 | Self::UkNhsMod11Failed
709 | Self::Ipv4LoopbackRange
710 | Self::Ipv6LoopbackRange => {}
711 }
712 }
713}
714
715pub const LABELLED_FIELD_CONNECTORS: &[char] = &[':', '=', '|', '\t', ',', ';', '-'];
717
718#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
720#[non_exhaustive]
721#[serde(rename_all = "snake_case")]
722pub enum LabelledValueScanReason {
723 LimitExceeded,
725 DateBoundary,
727 LabelBoundary,
729 OtherClassBoundary,
731}
732
733#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
735#[non_exhaustive]
736#[serde(rename_all = "snake_case")]
737pub enum ValidatorOutcome {
738 Pass { canonical_form: Option<String> },
740 Fail { reason: ValidatorFailReason },
742 NotApplicable,
744}
745
746#[derive(Debug, Clone, PartialEq, Eq, Error)]
748#[non_exhaustive]
749pub enum ValidatorKindParseError {
750 #[error("unsupported validator: {kind}")]
752 UnsupportedValidator {
753 kind: String,
755 },
756}
757
758#[derive(Debug, Clone, Copy, PartialEq, Eq)]
760#[non_exhaustive]
761pub enum ValidatorKind {
762 EmailRfc,
764 #[cfg(feature = "phone-parser")]
766 E164Phone,
767 #[cfg(feature = "phone-parser")]
769 E164PhoneNational(Region),
770 #[cfg(feature = "phone-parser")]
773 PhoneNumber,
774 Luhn,
776 IbanMod97,
778 Ipv4Parse,
780 Ipv6Parse,
782 Ipv4ParseNonDocumentation,
784 Ipv6ParseNonDocumentation,
786 EthEip55,
788 AadhaarVerhoeff,
790 FrNirMod97,
792 DeSteuerIdMod1110,
794 BsnMod11,
796 CpfMod11,
798 CnpjMod11,
800 UkNhsMod11,
802}
803
804#[cfg(feature = "phone-parser")]
806#[derive(Debug, Clone, Copy, PartialEq, Eq)]
807#[non_exhaustive]
808pub enum Region {
809 De,
811 Us,
813 At,
815 Ch,
817 Gb,
819 Ie,
821 Au,
823 Ca,
825 Nz,
827 Za,
829 Fr,
831}
832
833#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
835#[non_exhaustive]
836pub enum ValidatorOnFail {
837 #[default]
839 Veto,
840 Record,
843}
844
845impl ValidatorOnFail {
846 pub fn parse(s: &str) -> Option<Self> {
848 match s {
849 "veto" => Some(Self::Veto),
850 "record" => Some(Self::Record),
851 _ => None,
852 }
853 }
854}
855
856impl ValidatorKind {
857 pub fn is_phone(self) -> bool {
859 #[cfg(feature = "phone-parser")]
860 {
861 matches!(
862 self,
863 Self::E164Phone | Self::E164PhoneNational(_) | Self::PhoneNumber
864 )
865 }
866 #[cfg(not(feature = "phone-parser"))]
867 {
868 false
869 }
870 }
871
872 pub fn allows_recorded_failure(self) -> bool {
875 matches!(
876 self,
877 Self::Luhn
878 | Self::IbanMod97
879 | Self::DeSteuerIdMod1110
880 | Self::BsnMod11
881 | Self::CpfMod11
882 | Self::UkNhsMod11
883 ) || {
884 #[cfg(feature = "phone-parser")]
885 {
886 matches!(
887 self,
888 Self::E164Phone | Self::PhoneNumber | Self::E164PhoneNational(Region::Us)
889 )
890 }
891 #[cfg(not(feature = "phone-parser"))]
892 {
893 false
894 }
895 }
896 }
897
898 pub fn parse(s: &str) -> Result<Self, ValidatorKindParseError> {
900 match s {
901 "email_rfc" => Ok(Self::EmailRfc),
902 #[cfg(feature = "phone-parser")]
903 "e164_phone" => Ok(Self::E164Phone),
904 #[cfg(feature = "phone-parser")]
905 "phone_number" => Ok(Self::PhoneNumber),
906 #[cfg(feature = "phone-parser")]
907 "e164_phone_national_de" => Ok(Self::E164PhoneNational(Region::De)),
908 #[cfg(feature = "phone-parser")]
909 "e164_phone_national_us" => Ok(Self::E164PhoneNational(Region::Us)),
910 "luhn" => Ok(Self::Luhn),
911 "iban_mod97" => Ok(Self::IbanMod97),
912 "ipv4_parse" => Ok(Self::Ipv4Parse),
913 "ipv6_parse" => Ok(Self::Ipv6Parse),
914 "ipv4_parse_non_documentation" => Ok(Self::Ipv4ParseNonDocumentation),
915 "ipv6_parse_non_documentation" => Ok(Self::Ipv6ParseNonDocumentation),
916 "eth_eip55" => Ok(Self::EthEip55),
917 "aadhaar_verhoeff" => Ok(Self::AadhaarVerhoeff),
918 "fr_nir_mod97" => Ok(Self::FrNirMod97),
919 "de_steuer_id_mod1110" => Ok(Self::DeSteuerIdMod1110),
920 "bsn_mod11" => Ok(Self::BsnMod11),
921 "cpf_mod11" => Ok(Self::CpfMod11),
922 "cnpj_mod11" => Ok(Self::CnpjMod11),
923 "uk_nhs_mod11" => Ok(Self::UkNhsMod11),
924 other => Err(ValidatorKindParseError::UnsupportedValidator {
925 kind: other.to_string(),
926 }),
927 }
928 }
929
930 pub fn validates(self, input: &str) -> bool {
932 match self {
933 Self::AadhaarVerhoeff => aadhaar_verhoeff_check(input),
934 Self::FrNirMod97 => fr_nir_mod97_check(input),
935 Self::DeSteuerIdMod1110 => de_steuer_id_mod1110_check(input),
936 Self::BsnMod11 => bsn_mod11_check(input),
937 Self::CpfMod11 => cpf_mod11_check(input),
938 Self::CnpjMod11 => cnpj_mod11_check(input),
939 Self::UkNhsMod11 => uk_nhs_mod11_check(input),
940 _ => self.canonical_form(input).is_some(),
941 }
942 }
943
944 pub fn validate(self, input: &str) -> ValidatorOutcome {
946 match self.canonical_form(input) {
947 Some(canonical_form) => ValidatorOutcome::Pass {
948 canonical_form: Some(canonical_form),
949 },
950 None => ValidatorOutcome::Fail {
951 reason: match self {
952 #[cfg(feature = "phone-parser")]
953 Self::PhoneNumber if input.starts_with('+') || input.starts_with("00") => {
954 ValidatorFailReason::PhoneE164Rejected
955 }
956 Self::Ipv4ParseNonDocumentation => match input.parse() {
957 Ok(address) if ipv4_is_documentation(address) => {
958 ValidatorFailReason::Ipv4DocumentationRange
959 }
960 Ok(_) => ValidatorFailReason::Ipv4LoopbackRange,
961 Err(_) => self.fail_reason(),
962 },
963 Self::Ipv6ParseNonDocumentation => match input.parse() {
964 Ok(address) if ipv6_is_documentation(address) => {
965 ValidatorFailReason::Ipv6DocumentationRange
966 }
967 Ok(_) => ValidatorFailReason::Ipv6LoopbackRange,
968 Err(_) => self.fail_reason(),
969 },
970 _ => self.fail_reason(),
971 },
972 },
973 }
974 }
975
976 pub fn canonical_form(self, input: &str) -> Option<String> {
978 match self {
979 Self::EmailRfc => is_basic_email(input).then(|| input.to_string()),
980 #[cfg(feature = "phone-parser")]
981 Self::E164Phone => e164_phone_check(input).then(|| input.to_string()),
982 #[cfg(feature = "phone-parser")]
983 Self::E164PhoneNational(region) => validate_phone_national(region, input),
984 #[cfg(feature = "phone-parser")]
985 Self::PhoneNumber => phone_number_check(input).then(|| input.to_string()),
986 Self::Luhn => luhn_check(input).then(|| input.to_string()),
987 Self::IbanMod97 => iban_mod97_check(input).then(|| input.to_string()),
988 Self::Ipv4Parse => ipv4_parse_check(input).then(|| input.to_string()),
989 Self::Ipv6Parse => ipv6_parse_check(input).then(|| input.to_string()),
990 Self::Ipv4ParseNonDocumentation => input
991 .parse::<std::net::Ipv4Addr>()
992 .ok()
993 .filter(|address| !ipv4_is_documentation(*address) && !address.is_loopback())
994 .map(|_| input.to_string()),
995 Self::Ipv6ParseNonDocumentation => input
996 .parse::<std::net::Ipv6Addr>()
997 .ok()
998 .filter(|address| !ipv6_is_documentation(*address) && !ipv6_is_loopback(*address))
999 .map(|_| input.to_string()),
1000 Self::EthEip55 => eth_eip55_check(input).then(|| input.to_string()),
1001 Self::AadhaarVerhoeff => {
1002 canonical_ascii_digits::<12>(input).filter(|_| aadhaar_verhoeff_check(input))
1003 }
1004 Self::FrNirMod97 => {
1005 canonical_ascii_digits::<15>(input).filter(|_| fr_nir_mod97_check(input))
1006 }
1007 Self::DeSteuerIdMod1110 => {
1008 canonical_ascii_digits::<11>(input).filter(|_| de_steuer_id_mod1110_check(input))
1009 }
1010 Self::BsnMod11 => canonical_ascii_digits::<9>(input).filter(|_| bsn_mod11_check(input)),
1011 Self::CpfMod11 => {
1012 canonical_ascii_digits::<11>(input).filter(|_| cpf_mod11_check(input))
1013 }
1014 Self::CnpjMod11 => {
1015 canonical_ascii_digits::<14>(input).filter(|_| cnpj_mod11_check(input))
1016 }
1017 Self::UkNhsMod11 => {
1018 canonical_ascii_digits::<10>(input).filter(|_| uk_nhs_mod11_check(input))
1019 }
1020 }
1021 }
1022
1023 pub fn fail_reason(self) -> ValidatorFailReason {
1026 match self {
1027 Self::EmailRfc => ValidatorFailReason::EmailRfcRejected,
1028 #[cfg(feature = "phone-parser")]
1029 Self::E164Phone => ValidatorFailReason::PhoneE164Rejected,
1030 #[cfg(feature = "phone-parser")]
1031 Self::PhoneNumber => ValidatorFailReason::PhoneNationalRegionMismatch,
1032 #[cfg(feature = "phone-parser")]
1033 Self::E164PhoneNational(_) => ValidatorFailReason::PhoneNationalRegionMismatch,
1034 Self::Luhn => ValidatorFailReason::LuhnFailed,
1035 Self::IbanMod97 => ValidatorFailReason::IbanMod97Failed,
1036 Self::Ipv4Parse => ValidatorFailReason::Ipv4ParseFailed,
1037 Self::Ipv6Parse => ValidatorFailReason::Ipv6ParseFailed,
1038 Self::Ipv4ParseNonDocumentation => ValidatorFailReason::Ipv4ParseFailed,
1039 Self::Ipv6ParseNonDocumentation => ValidatorFailReason::Ipv6ParseFailed,
1040 Self::EthEip55 => ValidatorFailReason::EthEip55ChecksumFailed,
1041 Self::AadhaarVerhoeff => ValidatorFailReason::AadhaarVerhoeffFailed,
1042 Self::FrNirMod97 => ValidatorFailReason::FrNirMod97Failed,
1043 Self::DeSteuerIdMod1110 => ValidatorFailReason::DeSteuerIdMod1110Failed,
1044 Self::BsnMod11 => ValidatorFailReason::BsnMod11Failed,
1045 Self::CpfMod11 => ValidatorFailReason::CpfMod11Failed,
1046 Self::CnpjMod11 => ValidatorFailReason::CnpjMod11Failed,
1047 Self::UkNhsMod11 => ValidatorFailReason::UkNhsMod11Failed,
1048 }
1049 }
1050}
1051
1052fn is_basic_email(input: &str) -> bool {
1053 let Some((local, domain)) = input.split_once('@') else {
1054 return false;
1055 };
1056 !local.is_empty() && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.')
1057}
1058
1059#[cfg(feature = "phone-parser")]
1060fn e164_phone_check(input: &str) -> bool {
1061 phonenumber::parse(None, input).is_ok_and(|phone| phonenumber::is_valid(&phone))
1062}
1063
1064#[cfg(feature = "phone-parser")]
1066fn phone_number_check(input: &str) -> bool {
1067 let normalized = input.strip_prefix("00").map(|tail| format!("+{tail}"));
1068 let value = normalized.as_deref().unwrap_or(input);
1069 if value.starts_with('+') {
1070 return phonenumber::parse(None, value).is_ok_and(|phone| {
1071 if phone
1075 .format()
1076 .mode(phonenumber::Mode::E164)
1077 .to_string()
1078 .bytes()
1079 .filter(u8::is_ascii_digit)
1080 .count()
1081 > 15
1082 {
1083 return false;
1084 }
1085 phone.is_valid()
1086 || (phone.country().code() == 44
1087 && value.bytes().filter(|byte| *byte == b'/').count() <= 1
1090 && is_safe_fixture_phone(
1091 Region::Gb,
1092 &phone.format().mode(phonenumber::Mode::E164).to_string(),
1093 ))
1094 });
1095 }
1096 [
1097 Region::De,
1098 Region::Us,
1099 Region::At,
1100 Region::Ch,
1101 Region::Gb,
1102 Region::Ie,
1103 Region::Au,
1104 Region::Ca,
1105 Region::Nz,
1106 Region::Za,
1107 Region::Fr,
1108 ]
1109 .into_iter()
1110 .any(|region| validate_phone_national(region, value).is_some())
1111}
1112
1113#[cfg(feature = "phone-parser")]
1114fn validate_phone_national(region: Region, input: &str) -> Option<String> {
1115 let country = match region {
1116 Region::De => phonenumber::country::DE,
1117 Region::Us => phonenumber::country::US,
1118 Region::At => phonenumber::country::AT,
1119 Region::Ch => phonenumber::country::CH,
1120 Region::Gb => phonenumber::country::GB,
1121 Region::Ie => phonenumber::country::IE,
1122 Region::Au => phonenumber::country::AU,
1123 Region::Ca => phonenumber::country::CA,
1124 Region::Nz => phonenumber::country::NZ,
1125 Region::Za => phonenumber::country::ZA,
1126 Region::Fr => phonenumber::country::FR,
1127 };
1128 let expected_code = match region {
1129 Region::De => 49,
1130 Region::Us | Region::Ca => 1,
1131 Region::At => 43,
1132 Region::Ch => 41,
1133 Region::Gb => 44,
1134 Region::Ie => 353,
1135 Region::Au => 61,
1136 Region::Nz => 64,
1137 Region::Za => 27,
1138 Region::Fr => 33,
1139 };
1140 let number = phonenumber::parse(Some(country), input).ok()?;
1141 if number.country().code() != expected_code {
1142 return None;
1143 }
1144 if number.is_valid() || is_safe_fixture_phone(region, input) {
1145 return Some(number.format().mode(phonenumber::Mode::E164).to_string());
1146 }
1147 None
1148}
1149
1150#[cfg(feature = "phone-parser")]
1151fn is_safe_fixture_phone(region: Region, input: &str) -> bool {
1152 let digits = input
1153 .chars()
1154 .filter(char::is_ascii_digit)
1155 .collect::<String>();
1156 match region {
1157 Region::Us => {
1158 digits == "15550100"
1159 || matches!(digits.strip_prefix('1'), Some(rest) if rest.len() == 10 && rest[3..].starts_with("55501"))
1160 }
1161 Region::Gb => {
1165 if input.bytes().filter(|byte| *byte == b'/').count() > 1 {
1166 return false;
1167 }
1168 let national = digits
1169 .strip_prefix("44")
1170 .or_else(|| digits.strip_prefix('0'))
1171 .unwrap_or(&digits);
1172 national.len() == 10 && national.starts_with("7700900")
1173 }
1174 Region::De => matches!(
1175 digits.as_str(),
1176 "493000000000"
1177 | "4915100000000"
1178 | "4915550112233"
1179 | "015550112233"
1180 | "491710000000"
1181 | "01710000000"
1182 ),
1183 _ => false,
1184 }
1185}
1186
1187pub(crate) fn luhn_check(input: &str) -> bool {
1194 let mut digits = Vec::new();
1195 for ch in input.chars() {
1196 if ch.is_whitespace() || ch == '-' || (ch.is_numeric() && !ch.is_ascii_digit()) {
1197 continue;
1198 }
1199 if !ch.is_ascii_digit() {
1200 return false;
1201 }
1202 digits.push(ch as u8 - b'0');
1203 }
1204 if !(13..=19).contains(&digits.len()) || digits.iter().all(|digit| *digit == 0) {
1205 return false;
1206 }
1207
1208 let sum: u32 = digits
1209 .iter()
1210 .rev()
1211 .enumerate()
1212 .map(|(index, digit)| {
1213 let mut value = u32::from(*digit);
1214 if index % 2 == 1 {
1215 value *= 2;
1216 if value > 9 {
1217 value -= 9;
1218 }
1219 }
1220 value
1221 })
1222 .sum();
1223 sum.is_multiple_of(10)
1224}
1225
1226fn iban_mod97_check(input: &str) -> bool {
1227 let canonical = iban_canonicalize(input);
1228 let bytes = canonical.as_bytes();
1229 if bytes.len() < 4 {
1230 return false;
1231 }
1232 if !bytes[0].is_ascii_uppercase()
1233 || !bytes[1].is_ascii_uppercase()
1234 || !bytes[2].is_ascii_digit()
1235 || !bytes[3].is_ascii_digit()
1236 {
1237 return false;
1238 }
1239 let Some(expected_len) = iban_country_length(&bytes[..2]) else {
1240 return false;
1241 };
1242 if bytes.len() != expected_len {
1243 return false;
1244 }
1245 if !bytes.iter().all(u8::is_ascii_alphanumeric) {
1246 return false;
1247 }
1248
1249 let mut remainder = 0u32;
1250 for byte in bytes[4..].iter().chain(bytes[..4].iter()).copied() {
1251 match byte {
1252 b'0'..=b'9' => {
1253 remainder = (remainder * 10 + u32::from(byte - b'0')) % 97;
1254 }
1255 b'A'..=b'Z' => {
1256 let value = u32::from(byte - b'A') + 10;
1257 remainder = (remainder * 10 + value / 10) % 97;
1258 remainder = (remainder * 10 + value % 10) % 97;
1259 }
1260 _ => return false,
1261 }
1262 }
1263 remainder == 1
1264}
1265
1266#[must_use]
1277pub fn iban_registry_length(country: &str) -> Option<usize> {
1278 iban_country_length(country.as_bytes())
1279}
1280
1281fn iban_country_length(country: &[u8]) -> Option<usize> {
1282 match country {
1285 b"AD" => Some(24),
1286 b"AE" => Some(23),
1287 b"AL" => Some(28),
1288 b"AT" => Some(20),
1289 b"AZ" => Some(28),
1290 b"BA" => Some(20),
1291 b"BE" => Some(16),
1292 b"BG" => Some(22),
1293 b"BH" => Some(22),
1294 b"BI" => Some(27),
1295 b"BR" => Some(29),
1296 b"BY" => Some(28),
1297 b"CH" => Some(21),
1298 b"CR" => Some(22),
1299 b"CY" => Some(28),
1300 b"CZ" => Some(24),
1301 b"DE" => Some(22),
1302 b"DJ" => Some(27),
1303 b"DK" => Some(18),
1304 b"DO" => Some(28),
1305 b"EE" => Some(20),
1306 b"EG" => Some(29),
1307 b"ES" => Some(24),
1308 b"FI" => Some(18),
1309 b"FK" => Some(18),
1310 b"FO" => Some(18),
1311 b"FR" => Some(27),
1312 b"GB" => Some(22),
1313 b"GE" => Some(22),
1314 b"GI" => Some(23),
1315 b"GL" => Some(18),
1316 b"GR" => Some(27),
1317 b"GT" => Some(28),
1318 b"HN" => Some(28),
1319 b"HR" => Some(21),
1320 b"HU" => Some(28),
1321 b"IE" => Some(22),
1322 b"IL" => Some(23),
1323 b"IQ" => Some(23),
1324 b"IS" => Some(26),
1325 b"IT" => Some(27),
1326 b"JO" => Some(30),
1327 b"KW" => Some(30),
1328 b"KZ" => Some(20),
1329 b"LB" => Some(28),
1330 b"LC" => Some(32),
1331 b"LI" => Some(21),
1332 b"LT" => Some(20),
1333 b"LU" => Some(20),
1334 b"LV" => Some(21),
1335 b"LY" => Some(25),
1336 b"MC" => Some(27),
1337 b"MD" => Some(24),
1338 b"ME" => Some(22),
1339 b"MK" => Some(19),
1340 b"MN" => Some(20),
1341 b"MR" => Some(27),
1342 b"MT" => Some(31),
1343 b"MU" => Some(30),
1344 b"NI" => Some(28),
1345 b"NL" => Some(18),
1346 b"NO" => Some(15),
1347 b"OM" => Some(23),
1348 b"PK" => Some(24),
1349 b"PL" => Some(28),
1350 b"PS" => Some(29),
1351 b"PT" => Some(25),
1352 b"QA" => Some(29),
1353 b"RO" => Some(24),
1354 b"RS" => Some(22),
1355 b"RU" => Some(33),
1356 b"SA" => Some(24),
1357 b"SC" => Some(31),
1358 b"SD" => Some(18),
1359 b"SE" => Some(24),
1360 b"SI" => Some(19),
1361 b"SK" => Some(24),
1362 b"SM" => Some(27),
1363 b"SO" => Some(23),
1364 b"ST" => Some(25),
1365 b"SV" => Some(28),
1366 b"TL" => Some(23),
1367 b"TN" => Some(24),
1368 b"TR" => Some(26),
1369 b"UA" => Some(29),
1370 b"VA" => Some(22),
1371 b"VG" => Some(24),
1372 b"XK" => Some(20),
1373 b"YE" => Some(30),
1374 _ => None,
1375 }
1376}
1377
1378fn iban_canonicalize(input: &str) -> String {
1379 input
1380 .chars()
1381 .filter(|ch| !ch.is_ascii_whitespace())
1382 .flat_map(char::to_uppercase)
1383 .collect()
1384}
1385
1386fn ipv4_parse_check(input: &str) -> bool {
1387 input.parse::<std::net::Ipv4Addr>().is_ok()
1388}
1389
1390fn ipv6_parse_check(input: &str) -> bool {
1391 input.parse::<std::net::Ipv6Addr>().is_ok()
1392}
1393
1394fn ipv4_is_documentation(address: std::net::Ipv4Addr) -> bool {
1395 let [a, b, c, _] = address.octets();
1396 matches!((a, b, c), (192, 0, 2) | (198, 51, 100) | (203, 0, 113))
1397}
1398
1399fn ipv6_is_documentation(address: std::net::Ipv6Addr) -> bool {
1400 let segments = address.segments();
1401 (segments[0] == 0x2001 && segments[1] == 0x0db8)
1402 || address.to_ipv4().is_some_and(ipv4_is_documentation)
1403}
1404
1405fn ipv6_is_loopback(address: std::net::Ipv6Addr) -> bool {
1408 address.is_loopback() || address.to_ipv4().is_some_and(|v4| v4.is_loopback())
1409}
1410
1411fn eth_eip55_check(input: &str) -> bool {
1412 let Some(address) = input.strip_prefix("0x") else {
1413 return false;
1414 };
1415 if address.len() != 40 || !address.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1416 return false;
1417 }
1418 if address
1419 .bytes()
1420 .all(|byte| !byte.is_ascii_alphabetic() || byte.is_ascii_lowercase())
1421 || address
1422 .bytes()
1423 .all(|byte| !byte.is_ascii_alphabetic() || byte.is_ascii_uppercase())
1424 {
1425 return true;
1426 }
1427
1428 let lowercase = address.to_ascii_lowercase();
1429 let hash = Keccak256::digest(lowercase.as_bytes());
1430 for (index, byte) in address.bytes().enumerate() {
1431 if byte.is_ascii_digit() {
1432 continue;
1433 }
1434 let hash_nibble = if index % 2 == 0 {
1435 hash[index / 2] >> 4
1436 } else {
1437 hash[index / 2] & 0x0f
1438 };
1439 if (hash_nibble > 7) != byte.is_ascii_uppercase() {
1440 return false;
1441 }
1442 }
1443 true
1444}
1445
1446fn collect_ascii_digits<const N: usize>(input: &str) -> Option<[u8; N]> {
1447 let mut digits = [0u8; N];
1448 let mut count = 0usize;
1449 for byte in input.bytes() {
1450 if byte.is_ascii_digit() {
1451 if count == N {
1452 return None;
1453 }
1454 digits[count] = byte - b'0';
1455 count += 1;
1456 } else if matches!(byte, b' ' | b'\t' | b'\n' | b'\r' | b'-' | b'.' | b'/') {
1457 continue;
1458 } else {
1459 return None;
1460 }
1461 }
1462 (count == N).then_some(digits)
1463}
1464
1465fn canonical_ascii_digits<const N: usize>(input: &str) -> Option<String> {
1466 let digits = collect_ascii_digits::<N>(input)?;
1467 let mut canonical = String::with_capacity(N);
1468 for digit in digits {
1469 canonical.push(char::from(b'0' + digit));
1470 }
1471 Some(canonical)
1472}
1473
1474fn not_all_same<const N: usize>(digits: &[u8; N]) -> bool {
1475 digits[1..].iter().any(|digit| *digit != digits[0])
1476}
1477
1478fn aadhaar_verhoeff_check(input: &str) -> bool {
1479 const D: [[u8; 10]; 10] = [
1480 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
1481 [1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
1482 [2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
1483 [3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
1484 [4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
1485 [5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
1486 [6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
1487 [7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
1488 [8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
1489 [9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
1490 ];
1491 const P: [[u8; 10]; 8] = [
1492 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
1493 [1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
1494 [5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
1495 [8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
1496 [9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
1497 [4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
1498 [2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
1499 [7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
1500 ];
1501 let Some(digits) = collect_ascii_digits::<12>(input) else {
1502 return false;
1503 };
1504 if digits[0] < 2 || !not_all_same(&digits) {
1505 return false;
1506 }
1507 let mut checksum = 0u8;
1508 for (index, digit) in digits.iter().rev().enumerate() {
1509 checksum = D[checksum as usize][P[index % 8][*digit as usize] as usize];
1510 }
1511 checksum == 0
1512}
1513
1514fn fr_nir_mod97_check(input: &str) -> bool {
1515 let Some(digits) = collect_ascii_digits::<15>(input) else {
1516 return false;
1517 };
1518 if !matches!(digits[0], 1 | 2 | 3 | 4 | 7 | 8) {
1519 return false;
1520 }
1521 let month = digits[3] * 10 + digits[4];
1522 if !(1..=12).contains(&month) && !(20..=42).contains(&month) && !(50..=99).contains(&month) {
1523 return false;
1524 }
1525 let mut number = 0u32;
1526 for digit in &digits[..13] {
1527 number = (number * 10 + u32::from(*digit)) % 97;
1528 }
1529 let key = u32::from(digits[13]) * 10 + u32::from(digits[14]);
1530 97 - number == key
1531}
1532
1533fn de_steuer_id_mod1110_check(input: &str) -> bool {
1534 let Some(digits) = collect_ascii_digits::<11>(input) else {
1535 return false;
1536 };
1537 if !steuer_id_first_ten_digits_valid(&digits) {
1538 return false;
1539 }
1540 let mut product = 10u8;
1541 for digit in &digits[..10] {
1542 let mut sum = (*digit + product) % 10;
1543 if sum == 0 {
1544 sum = 10;
1545 }
1546 product = (2 * sum) % 11;
1547 }
1548 let check = (11 - product) % 10;
1549 check == digits[10]
1550}
1551
1552fn steuer_id_first_ten_digits_valid(digits: &[u8; 11]) -> bool {
1553 if digits[0] == 0 {
1554 return false;
1555 }
1556 let mut counts = [0u8; 10];
1557 for digit in &digits[..10] {
1558 counts[*digit as usize] += 1;
1559 }
1560 let repeated_digits = counts.iter().filter(|count| **count > 1).count();
1561 let missing_digits = counts.iter().filter(|count| **count == 0).count();
1562 let repeated_count_valid = counts.iter().any(|count| matches!(*count, 2 | 3));
1563 repeated_digits == 1 && repeated_count_valid && matches!(missing_digits, 1 | 2)
1564}
1565
1566fn bsn_mod11_check(input: &str) -> bool {
1567 let Some(digits) = collect_ascii_digits::<9>(input) else {
1568 return false;
1569 };
1570 if !not_all_same(&digits) {
1571 return false;
1572 }
1573 let sum: i32 = digits[..8]
1574 .iter()
1575 .enumerate()
1576 .map(|(index, digit)| i32::from(*digit) * (9 - index as i32))
1577 .sum::<i32>()
1578 - i32::from(digits[8]);
1579 sum.rem_euclid(11) == 0
1580}
1581
1582fn cpf_mod11_check(input: &str) -> bool {
1583 let Some(digits) = collect_ascii_digits::<11>(input) else {
1584 return false;
1585 };
1586 if !not_all_same(&digits) {
1587 return false;
1588 }
1589 mod11_check_digit(&digits[..9], 10) == digits[9]
1590 && mod11_check_digit(&digits[..10], 11) == digits[10]
1591}
1592
1593fn cnpj_mod11_check(input: &str) -> bool {
1594 let Some(digits) = collect_ascii_digits::<14>(input) else {
1595 return false;
1596 };
1597 if !not_all_same(&digits) {
1598 return false;
1599 }
1600 const FIRST: [u8; 12] = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
1601 const SECOND: [u8; 13] = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
1602 weighted_mod11_check_digit(&digits[..12], &FIRST) == digits[12]
1603 && weighted_mod11_check_digit(&digits[..13], &SECOND) == digits[13]
1604}
1605
1606fn uk_nhs_mod11_check(input: &str) -> bool {
1607 let Some(digits) = collect_ascii_digits::<10>(input) else {
1608 return false;
1609 };
1610 if !not_all_same(&digits) {
1611 return false;
1612 }
1613 let sum: u32 = digits[..9]
1614 .iter()
1615 .enumerate()
1616 .map(|(index, digit)| u32::from(*digit) * (10 - index as u32))
1617 .sum();
1618 let check = 11 - (sum % 11);
1619 let check = if check == 11 { 0 } else { check };
1620 check != 10 && check == u32::from(digits[9])
1621}
1622
1623fn mod11_check_digit(digits: &[u8], start_weight: u8) -> u8 {
1624 let weights = (2..=start_weight).rev();
1625 let sum: u32 = digits
1626 .iter()
1627 .zip(weights)
1628 .map(|(digit, weight)| u32::from(*digit) * u32::from(weight))
1629 .sum();
1630 let remainder = sum % 11;
1631 if remainder < 2 {
1632 0
1633 } else {
1634 (11 - remainder) as u8
1635 }
1636}
1637
1638fn weighted_mod11_check_digit(digits: &[u8], weights: &[u8]) -> u8 {
1639 let sum: u32 = digits
1640 .iter()
1641 .zip(weights)
1642 .map(|(digit, weight)| u32::from(*digit) * u32::from(*weight))
1643 .sum();
1644 let remainder = sum % 11;
1645 if remainder < 2 {
1646 0
1647 } else {
1648 (11 - remainder) as u8
1649 }
1650}
1651
1652#[derive(Debug, Clone, PartialEq, Eq)]
1654#[non_exhaustive]
1655pub struct Detection {
1656 pub span: Range<usize>,
1658 pub class: PiiClass,
1660 pub source: String,
1662}
1663
1664impl Detection {
1665 pub fn new(span: Range<usize>, class: PiiClass, source: impl Into<String>) -> Self {
1667 Self {
1668 span,
1669 class,
1670 source: source.into(),
1671 }
1672 }
1673}
1674
1675pub trait SafetyNet: Send + Sync {
1689 fn id(&self) -> &str;
1691
1692 fn supported_locales(&self) -> &[LocaleTag];
1694
1695 fn check(
1697 &self,
1698 clean_text: &str,
1699 context: SafetyNetContext<'_>,
1700 ) -> Result<Vec<LeakSuspect>, SafetyNetError>;
1701
1702 fn check_with_neutral(
1705 &self,
1706 stable_text: &str,
1707 _neutral_text: Option<&str>,
1708 context: SafetyNetContext<'_>,
1709 ) -> Result<Vec<LeakSuspect>, SafetyNetError> {
1710 self.check(stable_text, context)
1711 }
1712
1713 fn check_with_neutral_and_telemetry(
1716 &self,
1717 stable_text: &str,
1718 _neutral_text: Option<&str>,
1719 context: SafetyNetContext<'_>,
1720 ) -> Result<(Vec<LeakSuspect>, Vec<LeakReportTelemetry>), SafetyNetError> {
1721 self.check_with_telemetry(stable_text, context)
1722 }
1723
1724 fn check_with_telemetry(
1727 &self,
1728 clean_text: &str,
1729 context: SafetyNetContext<'_>,
1730 ) -> Result<(Vec<LeakSuspect>, Vec<LeakReportTelemetry>), SafetyNetError> {
1731 self.check(clean_text, context)
1732 .map(|suspects| (suspects, Vec::new()))
1733 }
1734}
1735
1736#[derive(Debug, Clone, Copy)]
1738#[non_exhaustive]
1739pub struct SafetyNetContext<'a> {
1740 pub dictionaries: Option<&'a DictionaryBundle>,
1744 pub manifest: &'a Manifest,
1749 pub locale_chain: &'a [LocaleTag],
1753 pub document_kind: DocumentKind,
1755 pub session_id: Option<&'a str>,
1757 pub field_path: Option<&'a str>,
1759}
1760
1761impl<'a> SafetyNetContext<'a> {
1762 pub fn with_dictionaries(mut self, dictionaries: &'a DictionaryBundle) -> Self {
1764 self.dictionaries = Some(dictionaries);
1765 self
1766 }
1767
1768 pub fn new(
1770 manifest: &'a Manifest,
1771 locale_chain: &'a [LocaleTag],
1772 document_kind: DocumentKind,
1773 session_id: Option<&'a str>,
1774 field_path: Option<&'a str>,
1775 ) -> Self {
1776 Self {
1777 dictionaries: None,
1778 manifest,
1779 locale_chain,
1780 document_kind,
1781 session_id,
1782 field_path,
1783 }
1784 }
1785}
1786
1787#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
1800#[serde(rename_all = "snake_case")]
1801#[non_exhaustive]
1802pub enum EmittedTokenOrigin {
1803 #[default]
1805 Whole,
1806 ResidualFragment,
1808}
1809
1810impl EmittedTokenOrigin {
1811 pub fn is_whole(&self) -> bool {
1813 matches!(self, Self::Whole)
1814 }
1815
1816 pub fn is_residual_fragment(&self) -> bool {
1818 matches!(self, Self::ResidualFragment)
1819 }
1820}
1821
1822#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1824#[non_exhaustive]
1825pub struct EmittedTokenSpan {
1826 pub clean_span: Range<usize>,
1828 pub raw_span: Range<usize>,
1830 pub class: PiiClass,
1832 #[serde(default, skip_serializing_if = "EmittedTokenOrigin::is_whole")]
1834 pub origin: EmittedTokenOrigin,
1835}
1836
1837impl EmittedTokenSpan {
1838 pub fn new(clean_span: Range<usize>, raw_span: Range<usize>, class: PiiClass) -> Self {
1840 Self {
1841 clean_span,
1842 raw_span,
1843 class,
1844 origin: EmittedTokenOrigin::Whole,
1845 }
1846 }
1847
1848 pub fn residual_fragment(
1850 clean_span: Range<usize>,
1851 raw_span: Range<usize>,
1852 class: PiiClass,
1853 ) -> Self {
1854 Self {
1855 clean_span,
1856 raw_span,
1857 class,
1858 origin: EmittedTokenOrigin::ResidualFragment,
1859 }
1860 }
1861}
1862
1863#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1865#[non_exhaustive]
1866pub struct Manifest {
1867 pub spans: Vec<EmittedTokenSpan>,
1869}
1870
1871impl Manifest {
1872 pub fn from_spans(mut spans: Vec<EmittedTokenSpan>) -> Self {
1874 spans.sort_by_key(|span| (span.clean_span.start, span.clean_span.end));
1875 Self { spans }
1876 }
1877
1878 pub fn diff_against(
1886 &self,
1887 suspect_span: &Range<usize>,
1888 suspect_class: &PiiClass,
1889 ) -> Option<LeakKind> {
1890 if suspect_span.is_empty() {
1891 return None;
1892 }
1893
1894 let start_idx = self
1895 .spans
1896 .partition_point(|span| span.clean_span.end <= suspect_span.start);
1897 let overlapping = self.spans[start_idx..]
1898 .iter()
1899 .take_while(|span| span.clean_span.start < suspect_span.end)
1900 .filter(|span| ranges_overlap(&span.clean_span, suspect_span))
1901 .collect::<Vec<_>>();
1902
1903 if overlapping.is_empty() {
1904 return Some(LeakKind::Uncovered);
1905 }
1906
1907 let mut cursor = suspect_span.start;
1908 let mut first_mismatch = None::<&EmittedTokenSpan>;
1909 for span in overlapping {
1910 if span.clean_span.start > cursor {
1911 return Some(LeakKind::PartialBleed {
1912 uncovered: cursor..span.clean_span.start.min(suspect_span.end),
1913 });
1914 }
1915
1916 if span.clean_span.end > cursor {
1917 if first_mismatch.is_none() && &span.class != suspect_class {
1918 first_mismatch = Some(span);
1919 }
1920 cursor = cursor.max(span.clean_span.end.min(suspect_span.end));
1921 if cursor >= suspect_span.end {
1922 break;
1923 }
1924 }
1925 }
1926
1927 if cursor < suspect_span.end {
1928 return Some(LeakKind::PartialBleed {
1929 uncovered: cursor..suspect_span.end,
1930 });
1931 }
1932
1933 first_mismatch.map(|span| LeakKind::ClassMismatch {
1934 pipeline_class: span.class.clone(),
1935 safety_net_class: suspect_class.clone(),
1936 })
1937 }
1938}
1939
1940fn ranges_overlap(left: &Range<usize>, right: &Range<usize>) -> bool {
1941 left.start < right.end && right.start < left.end
1942}
1943
1944#[derive(Debug, Clone, PartialEq)]
1946#[non_exhaustive]
1947pub struct LeakSuspect {
1948 pub span: Range<usize>,
1950 pub class: PiiClass,
1952 pub safety_net_id: String,
1954 pub score: Option<f32>,
1956 pub kind: LeakKind,
1958 pub raw_label: String,
1960 pub field_path: Option<String>,
1962}
1963
1964impl LeakSuspect {
1965 pub fn new(
1967 span: Range<usize>,
1968 class: PiiClass,
1969 safety_net_id: impl Into<String>,
1970 score: Option<f32>,
1971 kind: LeakKind,
1972 raw_label: impl Into<String>,
1973 field_path: Option<String>,
1974 ) -> Self {
1975 Self {
1976 span,
1977 class,
1978 safety_net_id: safety_net_id.into(),
1979 score,
1980 kind,
1981 raw_label: raw_label.into(),
1982 field_path,
1983 }
1984 }
1985}
1986
1987#[derive(Debug, Clone, PartialEq, Eq)]
1991#[non_exhaustive]
1992pub enum LeakKind {
1993 Uncovered,
1995 PartialBleed {
1997 uncovered: Range<usize>,
1999 },
2000 ClassMismatch {
2002 pipeline_class: PiiClass,
2004 safety_net_class: PiiClass,
2006 },
2007}
2008
2009impl LeakKind {
2010 pub fn as_str(&self) -> &'static str {
2012 match self {
2013 Self::Uncovered => "uncovered",
2014 Self::PartialBleed { .. } => "partial_bleed",
2015 Self::ClassMismatch { .. } => "class_mismatch",
2016 }
2017 }
2018}
2019
2020#[derive(Debug, Clone, PartialEq, Eq)]
2022#[non_exhaustive]
2023pub enum LeakReportTelemetry {
2024 LocaleSkipped {
2026 safety_net_id: String,
2028 document_kind: DocumentKind,
2030 field_path: Option<String>,
2032 },
2033 UnactionableSubword {
2041 safety_net_id: String,
2043 class: PiiClass,
2045 span: Range<usize>,
2047 document_kind: DocumentKind,
2049 field_path: Option<String>,
2051 },
2052 ModelSpanRefused {
2054 safety_net_id: String,
2056 reason: SafetyNetRefusalReason,
2058 span: Range<usize>,
2060 document_kind: DocumentKind,
2062 field_path: Option<String>,
2064 },
2065}
2066
2067#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2069pub enum SafetyNetRefusalReason {
2070 NymPaginationKeyV1,
2072}
2073
2074impl SafetyNetRefusalReason {
2075 pub fn as_str(self) -> &'static str {
2077 match self {
2078 Self::NymPaginationKeyV1 => "nym_pagination_key_v1",
2079 }
2080 }
2081
2082 pub fn audit_labels(self) -> (&'static str, &'static str) {
2084 match self {
2085 Self::NymPaginationKeyV1 => ("BUILDING_NUMBER", "custom:building_number"),
2086 }
2087 }
2088}
2089
2090#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2092#[non_exhaustive]
2093pub struct LeakReportStats {
2094 pub suspect_count: usize,
2096 pub uncovered_count: usize,
2098 pub partial_bleed_count: usize,
2100 pub class_mismatch_count: usize,
2102 pub locale_skipped_count: usize,
2104}
2105
2106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2113#[non_exhaustive]
2114pub struct DocumentExtension {
2115 pub schema_version: u16,
2117 pub clean_md_sha256: [u8; 32],
2119 pub layout_json_sha256: [u8; 32],
2121 pub report_json_sha256: [u8; 32],
2123 #[serde(default, skip_serializing_if = "Option::is_none")]
2125 pub preview_png_sha256: Option<[u8; 32]>,
2126 pub page_count: u32,
2128 pub audit_session_id: String,
2130 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2132 pub clean_spans: Vec<EmittedTokenSpan>,
2133 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2135 pub codec_audit: Vec<CodecAuditRow>,
2136}
2137
2138impl DocumentExtension {
2139 pub fn builder(schema_version: u16) -> DocumentExtensionBuilder {
2141 DocumentExtensionBuilder {
2142 schema_version,
2143 clean_md_sha256: None,
2144 layout_json_sha256: None,
2145 report_json_sha256: None,
2146 preview_png_sha256: None,
2147 page_count: None,
2148 audit_session_id: None,
2149 clean_spans: Vec::new(),
2150 codec_audit: Vec::new(),
2151 }
2152 }
2153}
2154
2155#[derive(Debug, Clone)]
2157#[must_use]
2158pub struct DocumentExtensionBuilder {
2159 schema_version: u16,
2160 clean_md_sha256: Option<[u8; 32]>,
2161 layout_json_sha256: Option<[u8; 32]>,
2162 report_json_sha256: Option<[u8; 32]>,
2163 preview_png_sha256: Option<[u8; 32]>,
2164 page_count: Option<u32>,
2165 audit_session_id: Option<String>,
2166 clean_spans: Vec<EmittedTokenSpan>,
2167 codec_audit: Vec<CodecAuditRow>,
2168}
2169
2170impl DocumentExtensionBuilder {
2171 pub fn clean_md_sha256(mut self, hash: [u8; 32]) -> Self {
2172 self.clean_md_sha256 = Some(hash);
2173 self
2174 }
2175
2176 pub fn layout_json_sha256(mut self, hash: [u8; 32]) -> Self {
2177 self.layout_json_sha256 = Some(hash);
2178 self
2179 }
2180
2181 pub fn report_json_sha256(mut self, hash: [u8; 32]) -> Self {
2182 self.report_json_sha256 = Some(hash);
2183 self
2184 }
2185
2186 pub fn preview_png_sha256(mut self, hash: [u8; 32]) -> Self {
2187 self.preview_png_sha256 = Some(hash);
2188 self
2189 }
2190
2191 pub fn page_count(mut self, page_count: u32) -> Self {
2192 self.page_count = Some(page_count);
2193 self
2194 }
2195
2196 pub fn audit_session_id(mut self, audit_session_id: impl Into<String>) -> Self {
2197 self.audit_session_id = Some(audit_session_id.into());
2198 self
2199 }
2200
2201 pub fn clean_spans(mut self, clean_spans: Vec<EmittedTokenSpan>) -> Self {
2202 self.clean_spans = clean_spans;
2203 self
2204 }
2205
2206 pub fn codec_audit(mut self, codec_audit: Vec<CodecAuditRow>) -> Self {
2207 self.codec_audit = codec_audit;
2208 self
2209 }
2210
2211 pub fn build(self) -> Result<DocumentExtension, DocumentExtensionError> {
2212 Ok(DocumentExtension {
2213 schema_version: self.schema_version,
2214 clean_md_sha256: self
2215 .clean_md_sha256
2216 .ok_or(DocumentExtensionError::MissingField("clean_md_sha256"))?,
2217 layout_json_sha256: self
2218 .layout_json_sha256
2219 .ok_or(DocumentExtensionError::MissingField("layout_json_sha256"))?,
2220 report_json_sha256: self
2221 .report_json_sha256
2222 .ok_or(DocumentExtensionError::MissingField("report_json_sha256"))?,
2223 preview_png_sha256: self.preview_png_sha256,
2224 page_count: self
2225 .page_count
2226 .ok_or(DocumentExtensionError::MissingField("page_count"))?,
2227 audit_session_id: self
2228 .audit_session_id
2229 .ok_or(DocumentExtensionError::MissingField("audit_session_id"))?,
2230 clean_spans: self.clean_spans,
2231 codec_audit: self.codec_audit,
2232 })
2233 }
2234}
2235
2236#[derive(Debug, Clone, PartialEq, Eq, Error)]
2238#[non_exhaustive]
2239pub enum DocumentExtensionError {
2240 #[error("missing document extension field: {0}")]
2241 MissingField(&'static str),
2242}
2243
2244#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2246#[serde(rename_all = "snake_case")]
2247#[non_exhaustive]
2248pub enum TextOrigin {
2249 Ocr,
2251 EmbeddedText,
2253 Transcript,
2255 Hybrid,
2257}
2258
2259#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
2261#[non_exhaustive]
2262pub struct CodecCapabilitySet {
2263 pub text: bool,
2265 pub layout: bool,
2267 pub confidence: bool,
2269 pub timestamps: bool,
2271}
2272
2273impl CodecCapabilitySet {
2274 pub const TEXT_ONLY: Self = Self {
2276 text: true,
2277 layout: false,
2278 confidence: false,
2279 timestamps: false,
2280 };
2281
2282 pub const fn new(text: bool, layout: bool, confidence: bool, timestamps: bool) -> Self {
2284 Self {
2285 text,
2286 layout,
2287 confidence,
2288 timestamps,
2289 }
2290 }
2291
2292 pub fn contains(self, requested: Self) -> bool {
2294 (!requested.text || self.text)
2295 && (!requested.layout || self.layout)
2296 && (!requested.confidence || self.confidence)
2297 && (!requested.timestamps || self.timestamps)
2298 }
2299}
2300
2301#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2303#[serde(rename_all = "snake_case")]
2304#[non_exhaustive]
2305pub enum ExtractionDensityPolicy {
2306 Required(f32),
2308 Exempt { reason: String },
2310}
2311
2312impl Default for ExtractionDensityPolicy {
2313 fn default() -> Self {
2314 Self::Exempt {
2315 reason: "calibration_pending".to_string(),
2316 }
2317 }
2318}
2319
2320#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2322#[non_exhaustive]
2323pub struct CodecAuditRow {
2324 pub codec_id: String,
2326 pub codec_version: String,
2328 pub accepted_mime: String,
2330 pub advertised: CodecCapabilitySet,
2332 pub delivered: CodecCapabilitySet,
2334 pub text_origin: TextOrigin,
2336 pub codec_output_schema_version: u16,
2338 #[serde(default, skip_serializing_if = "Option::is_none")]
2340 pub options_hash_hex: Option<String>,
2341 #[serde(default, skip_serializing_if = "Option::is_none")]
2343 pub engine_provenance: Option<String>,
2344 pub extraction_density_policy: ExtractionDensityPolicy,
2346}
2347
2348impl CodecAuditRow {
2349 pub fn new(
2351 codec_id: impl Into<String>,
2352 codec_version: impl Into<String>,
2353 accepted_mime: impl Into<String>,
2354 text_origin: TextOrigin,
2355 ) -> Self {
2356 Self {
2357 codec_id: codec_id.into(),
2358 codec_version: codec_version.into(),
2359 accepted_mime: accepted_mime.into(),
2360 advertised: CodecCapabilitySet::default(),
2361 delivered: CodecCapabilitySet::default(),
2362 text_origin,
2363 codec_output_schema_version: 1,
2364 options_hash_hex: None,
2365 engine_provenance: None,
2366 extraction_density_policy: ExtractionDensityPolicy::default(),
2367 }
2368 }
2369}
2370
2371#[derive(Debug, Clone, Default, PartialEq)]
2377#[non_exhaustive]
2378pub struct LeakReport {
2379 pub suspects: Vec<LeakSuspect>,
2381 pub telemetry: Vec<LeakReportTelemetry>,
2383 pub stats: LeakReportStats,
2385 pub replay_hash: Option<String>,
2390}
2391
2392impl LeakReport {
2393 pub fn from_parts(
2395 suspects: Vec<LeakSuspect>,
2396 telemetry: Vec<LeakReportTelemetry>,
2397 ) -> LeakReport {
2398 let mut stats = LeakReportStats {
2399 suspect_count: suspects.len(),
2400 locale_skipped_count: telemetry
2401 .iter()
2402 .filter(|event| matches!(event, LeakReportTelemetry::LocaleSkipped { .. }))
2403 .count(),
2404 ..LeakReportStats::default()
2405 };
2406 for suspect in &suspects {
2407 match suspect.kind {
2408 LeakKind::Uncovered => stats.uncovered_count += 1,
2409 LeakKind::PartialBleed { .. } => stats.partial_bleed_count += 1,
2410 LeakKind::ClassMismatch { .. } => stats.class_mismatch_count += 1,
2411 }
2412 }
2413 LeakReport {
2414 suspects,
2415 telemetry,
2416 stats,
2417 replay_hash: None,
2418 }
2419 }
2420
2421 pub fn extend(&mut self, other: LeakReport) {
2423 self.suspects.extend(other.suspects);
2424 self.telemetry.extend(other.telemetry);
2425 *self = LeakReport::from_parts(
2426 std::mem::take(&mut self.suspects),
2427 std::mem::take(&mut self.telemetry),
2428 );
2429 }
2430}
2431
2432#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
2434#[non_exhaustive]
2435pub enum OpenAiPrivateLabel {
2436 PrivatePerson,
2438 PrivateAddress,
2440 PrivateEmail,
2442 PrivatePhone,
2444 PrivateUrl,
2446 PrivateDate,
2448 AccountNumber,
2450 Secret,
2452}
2453
2454impl OpenAiPrivateLabel {
2455 pub fn as_str(self) -> &'static str {
2457 match self {
2458 Self::PrivatePerson => "private_person",
2459 Self::PrivateAddress => "private_address",
2460 Self::PrivateEmail => "private_email",
2461 Self::PrivatePhone => "private_phone",
2462 Self::PrivateUrl => "private_url",
2463 Self::PrivateDate => "private_date",
2464 Self::AccountNumber => "account_number",
2465 Self::Secret => "secret",
2466 }
2467 }
2468}
2469
2470pub fn is_inside_word(text: &str, at: usize) -> bool {
2477 let before = text.get(..at).and_then(|head| head.chars().next_back());
2478 let after = text.get(at..).and_then(|tail| tail.chars().next());
2479 before.is_some_and(is_word_char) && after.is_some_and(is_word_char)
2480}
2481
2482pub fn expand_to_word_edges(text: &str, span: Range<usize>) -> Range<usize> {
2488 if span.start > span.end
2489 || !text.is_char_boundary(span.start)
2490 || !text.is_char_boundary(span.end)
2491 {
2492 return span;
2493 }
2494 let mut start = span.start;
2495 while is_inside_word(text, start) {
2496 start -= text[..start].chars().next_back().map_or(0, char::len_utf8);
2497 }
2498 let mut end = span.end;
2499 while is_inside_word(text, end) {
2500 end += text[end..].chars().next().map_or(0, char::len_utf8);
2501 }
2502 start..end
2503}
2504
2505pub fn extend_over_name_joiners(text: &str, span: Range<usize>) -> Range<usize> {
2513 const JOINERS: [char; 3] = ['-', '\'', '\u{2019}'];
2514 if span.start > span.end
2515 || !text.is_char_boundary(span.start)
2516 || !text.is_char_boundary(span.end)
2517 {
2518 return span;
2519 }
2520 let mut end = span.end;
2521 loop {
2522 let mut rest = text[end..].chars();
2523 if !rest.next().is_some_and(|ch| JOINERS.contains(&ch)) {
2524 break;
2525 }
2526 let letters = rest.take_while(|ch| ch.is_alphabetic()).collect::<Vec<_>>();
2527 let joiner_len = text[end..].chars().next().map_or(0, char::len_utf8);
2528 let next = end + joiner_len + letters.iter().map(|ch| ch.len_utf8()).sum::<usize>();
2529 if letters.len() < 2 || is_inside_word(text, next) {
2530 break;
2531 }
2532 end = next;
2533 }
2534 let mut start = span.start;
2535 loop {
2536 let mut back = text[..start].chars().rev();
2537 if !back.next().is_some_and(|ch| JOINERS.contains(&ch)) {
2538 break;
2539 }
2540 let letters = back.take_while(|ch| ch.is_alphabetic()).collect::<Vec<_>>();
2541 let joiner_len = text[..start].chars().next_back().map_or(0, char::len_utf8);
2542 let prev = start - joiner_len - letters.iter().map(|ch| ch.len_utf8()).sum::<usize>();
2543 if letters.is_empty() || is_inside_word(text, prev) {
2544 break;
2545 }
2546 start = prev;
2547 }
2548 start..end
2549}
2550
2551fn is_word_char(ch: char) -> bool {
2555 ch.is_alphanumeric()
2556}
2557
2558pub fn word_run_extends_identifier(text: &str, at: usize) -> bool {
2573 text.get(at..).is_some_and(|tail| {
2574 tail.chars()
2575 .take_while(|ch| is_word_char(*ch) || *ch == '_')
2576 .any(|ch| !ch.is_alphabetic())
2577 })
2578}
2579
2580#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
2582#[non_exhaustive]
2583pub enum SafetyNetPiiClass {
2584 Email,
2586 Name,
2588 Location,
2590 Phone,
2592 Url,
2594 Date,
2596 AccountNumber,
2598 Secret,
2600 BuildingNumber,
2602 LicensePlate,
2604 Username,
2606 PostalCode,
2608 TaxId,
2610}
2611
2612impl SafetyNetPiiClass {
2613 pub fn to_pii_class(self) -> PiiClass {
2615 match self {
2616 Self::Email => PiiClass::Email,
2617 Self::Name => PiiClass::Name,
2618 Self::Location => PiiClass::Location,
2619 Self::Phone => PiiClass::custom("phone").expect("valid custom class"),
2620 Self::Url => PiiClass::custom("url").expect("valid custom class"),
2621 Self::Date => PiiClass::custom("date").expect("valid custom class"),
2622 Self::AccountNumber => PiiClass::custom("account_number").expect("valid custom class"),
2623 Self::Secret => PiiClass::custom("secret").expect("valid custom class"),
2624 Self::BuildingNumber => {
2625 PiiClass::custom("building_number").expect("valid custom class")
2626 }
2627 Self::LicensePlate => PiiClass::custom("license_plate").expect("valid custom class"),
2628 Self::Username => PiiClass::custom("username").expect("valid custom class"),
2629 Self::PostalCode => PiiClass::custom("postal_code").expect("valid custom class"),
2630 Self::TaxId => PiiClass::custom("tax_id").expect("valid custom class"),
2631 }
2632 }
2633}
2634
2635#[derive(Debug, Clone, PartialEq, Eq, Error)]
2637#[non_exhaustive]
2638pub enum SafetyNetError {
2639 #[error("safety net unavailable: {reason}")]
2641 Unavailable {
2642 reason: String,
2644 },
2645 #[error("safety net weights missing: {path}")]
2647 WeightsMissing {
2648 path: String,
2650 },
2651 #[error("safety net model unavailable: {reason}")]
2653 ModelUnavailable {
2654 reason: String,
2656 },
2657 #[error("safety net model integrity mismatch: expected={expected}, actual={actual}")]
2659 ModelIntegrityMismatch {
2660 expected: String,
2662 actual: String,
2664 },
2665 #[error("safety net input too large: limit={limit}, actual={actual}")]
2667 InputTooLarge {
2668 limit: usize,
2670 actual: usize,
2672 },
2673 #[error("safety net runtime failed: {message}")]
2675 Runtime {
2676 message: String,
2678 },
2679 #[error("safety net invalid output: {message}")]
2681 InvalidOutput {
2682 message: String,
2684 },
2685}
2686
2687#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2705#[non_exhaustive]
2706#[serde(rename_all = "snake_case")]
2707pub enum Action {
2708 Tokenize,
2710 Redact,
2712 FormatPreserve,
2714 Generalize,
2716 Preserve,
2718}
2719
2720impl Action {
2721 pub fn as_str(&self) -> &'static str {
2723 match self {
2724 Self::Tokenize => "tokenize",
2725 Self::Redact => "redact",
2726 Self::FormatPreserve => "format_preserve",
2727 Self::Generalize => "generalize",
2728 Self::Preserve => "preserve",
2729 }
2730 }
2731
2732 pub fn from_canonical_str(value: &str) -> Option<Self> {
2734 match value {
2735 "tokenize" => Some(Self::Tokenize),
2736 "redact" => Some(Self::Redact),
2737 "format_preserve" => Some(Self::FormatPreserve),
2738 "generalize" => Some(Self::Generalize),
2739 "preserve" => Some(Self::Preserve),
2740 _ => None,
2741 }
2742 }
2743
2744 pub const fn strictness_rank(self) -> u8 {
2764 match self {
2765 Self::Redact => 4,
2766 Self::Tokenize => 3,
2767 Self::Generalize => 2,
2768 Self::FormatPreserve => 1,
2769 Self::Preserve => 0,
2770 }
2771 }
2772
2773 pub fn strictest(self, other: Self) -> Self {
2776 if other.strictness_rank() > self.strictness_rank() {
2777 other
2778 } else {
2779 self
2780 }
2781 }
2782
2783 pub const fn is_protective(self) -> bool {
2792 match self {
2793 Self::Tokenize | Self::Redact | Self::Generalize | Self::FormatPreserve => true,
2794 Self::Preserve => false,
2795 }
2796 }
2797}
2798
2799#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2801#[non_exhaustive]
2802pub enum ConflictTier {
2803 None,
2805 ClassPriority,
2807 RulePriority,
2809 Score,
2811 SpanLength,
2813 Validator,
2815 SameClassContainment,
2817 ValidatorVeto,
2819 CollisionPolicy,
2821 AnchoredContext,
2823 StructuredContainment,
2827 ContainmentPrecedence,
2833 ProtectionOverride,
2837 RecognizerId,
2839 Merged,
2841 Redact,
2843 Resolve,
2845 Fallback,
2847 ManifestSweep,
2850}
2851
2852impl ConflictTier {
2853 #[doc(hidden)]
2855 pub const ALL: &'static [Self] = &[
2856 Self::None,
2857 Self::ClassPriority,
2858 Self::RulePriority,
2859 Self::Score,
2860 Self::SpanLength,
2861 Self::Validator,
2862 Self::SameClassContainment,
2863 Self::ValidatorVeto,
2864 Self::CollisionPolicy,
2865 Self::AnchoredContext,
2866 Self::StructuredContainment,
2867 Self::ContainmentPrecedence,
2868 Self::ProtectionOverride,
2869 Self::RecognizerId,
2870 Self::Merged,
2871 Self::Redact,
2872 Self::Resolve,
2873 Self::Fallback,
2874 Self::ManifestSweep,
2875 ];
2876
2877 #[allow(dead_code)]
2879 const fn all_is_exhaustive(value: Self) {
2880 match value {
2881 Self::None
2882 | Self::ClassPriority
2883 | Self::RulePriority
2884 | Self::Score
2885 | Self::SpanLength
2886 | Self::Validator
2887 | Self::SameClassContainment
2888 | Self::ValidatorVeto
2889 | Self::CollisionPolicy
2890 | Self::AnchoredContext
2891 | Self::StructuredContainment
2892 | Self::ContainmentPrecedence
2893 | Self::ProtectionOverride
2894 | Self::RecognizerId
2895 | Self::Merged
2896 | Self::Redact
2897 | Self::Resolve
2898 | Self::Fallback
2899 | Self::ManifestSweep => {}
2900 }
2901 }
2902
2903 pub fn as_str(&self) -> &'static str {
2905 match self {
2906 Self::None => "none",
2907 Self::ClassPriority => "class_priority",
2908 Self::RulePriority => "rule_priority",
2909 Self::Score => "score",
2910 Self::SpanLength => "span_length",
2911 Self::Validator => "validator",
2912 Self::SameClassContainment => "same_class_containment",
2913 Self::ValidatorVeto => "validator_veto",
2914 Self::CollisionPolicy => "collision_policy",
2915 Self::AnchoredContext => "anchored_context",
2916 Self::StructuredContainment => "structured_containment",
2917 Self::ContainmentPrecedence => "containment_precedence",
2918 Self::ProtectionOverride => "protection_override",
2919 Self::RecognizerId => "recognizer_id",
2920 Self::Merged => "merged",
2921 Self::Redact => "redact",
2922 Self::Resolve => "resolve",
2923 Self::Fallback => "fallback",
2924 Self::ManifestSweep => "manifest_sweep",
2925 }
2926 }
2927
2928 pub fn from_canonical_str(value: &str) -> Option<Self> {
2930 match value {
2931 "none" => Some(Self::None),
2932 "class_priority" => Some(Self::ClassPriority),
2933 "rule_priority" => Some(Self::RulePriority),
2934 "score" => Some(Self::Score),
2935 "span_length" => Some(Self::SpanLength),
2936 "validator" => Some(Self::Validator),
2937 "same_class_containment" => Some(Self::SameClassContainment),
2938 "validator_veto" => Some(Self::ValidatorVeto),
2939 "collision_policy" => Some(Self::CollisionPolicy),
2940 "anchored_context" => Some(Self::AnchoredContext),
2941 "structured_containment" => Some(Self::StructuredContainment),
2942 "containment_precedence" => Some(Self::ContainmentPrecedence),
2943 "protection_override" => Some(Self::ProtectionOverride),
2944 "recognizer_id" => Some(Self::RecognizerId),
2945 "merged" => Some(Self::Merged),
2946 "redact" => Some(Self::Redact),
2947 "resolve" => Some(Self::Resolve),
2948 "fallback" => Some(Self::Fallback),
2949 "manifest_sweep" => Some(Self::ManifestSweep),
2950 _ => None,
2951 }
2952 }
2953}
2954
2955#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2957#[non_exhaustive]
2958#[serde(rename_all = "snake_case")]
2959pub enum FallbackReason {
2960 #[serde(alias = "OverlapConflict")]
2962 OverlapConflict,
2963 #[serde(alias = "ValidatorVeto")]
2965 ValidatorVeto,
2966 #[serde(alias = "AnchorMissing")]
2968 AnchorMissing,
2969 #[serde(alias = "ResidualSuspect")]
2971 ResidualSuspect,
2972}
2973
2974impl FallbackReason {
2975 pub fn as_str(&self) -> &'static str {
2977 match self {
2978 Self::OverlapConflict => "overlap_conflict",
2979 Self::ValidatorVeto => "validator_veto",
2980 Self::AnchorMissing => "anchor_missing",
2981 Self::ResidualSuspect => "residual_suspect",
2982 }
2983 }
2984
2985 pub fn from_canonical_str(value: &str) -> Option<Self> {
2987 match value {
2988 "overlap_conflict" => Some(Self::OverlapConflict),
2989 "validator_veto" => Some(Self::ValidatorVeto),
2990 "anchor_missing" => Some(Self::AnchorMissing),
2991 "residual_suspect" => Some(Self::ResidualSuspect),
2992 _ => None,
2993 }
2994 }
2995}
2996
2997#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2999#[non_exhaustive]
3000pub enum DocumentKind {
3001 Structured,
3003 Text,
3005}
3006
3007impl DocumentKind {
3008 pub fn as_str(&self) -> &'static str {
3010 match self {
3011 Self::Structured => "structured",
3012 Self::Text => "text",
3013 }
3014 }
3015
3016 pub fn from_canonical_str(value: &str) -> Option<Self> {
3018 match value {
3019 "structured" => Some(Self::Structured),
3020 "text" => Some(Self::Text),
3021 _ => None,
3022 }
3023 }
3024}
3025
3026#[derive(Debug, Clone, PartialEq, Eq)]
3036#[non_exhaustive]
3037pub struct RedactionEntry {
3038 pub source: String,
3040 pub recognizer_id: Option<String>,
3042 pub recognizer_version_id: Option<String>,
3044 pub class: PiiClass,
3046 pub action: Action,
3048 pub field_name: Option<String>,
3050 pub document_kind: DocumentKind,
3052 pub conflict_loser: bool,
3054 pub decided_by: ConflictTier,
3056 pub created_at: i64,
3058 pub session_id: Option<String>,
3060 pub validator_fail_reason: Option<ValidatorFailReason>,
3062 pub labelled_value_scan_reason: Option<LabelledValueScanReason>,
3064 pub ambiguity_record: Option<AmbiguityRecord>,
3066 pub collision_family: Option<String>,
3068 pub collision_variant: Option<String>,
3070 pub fallback_triggered: Option<FallbackReason>,
3072 pub provenance_stage: Option<String>,
3074 pub provenance_model_id: Option<String>,
3075 pub provenance_model_version: Option<String>,
3076 pub provenance_artifact_sha256: Option<String>,
3077 pub provenance_tokenizer_sha256: Option<String>,
3078 pub provenance_locale_resolved: Option<String>,
3079 pub provenance_locale_match_kind: Option<String>,
3080 pub provenance_canonical_class: Option<String>,
3081 pub provenance_native_class: Option<String>,
3082 pub provenance_confidence: Option<String>,
3083 pub provenance_merged_from: Option<String>,
3084 pub backend_silently_dropped: Option<Vec<String>>,
3086 pub restore_policy: Option<String>,
3087 pub restore_decision: Option<String>,
3088 pub restore_unknown_token_count: Option<u64>,
3089 pub restore_manifest_bypass_count: Option<u64>,
3090 pub restore_fresh_pii_count: Option<u64>,
3091 pub restore_phase_mask: Option<u32>,
3092 pub restore_trap_shape_count: Option<u64>,
3093}
3094
3095impl Serialize for RedactionEntry {
3096 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
3097 where
3098 S: serde::Serializer,
3099 {
3100 use serde::ser::SerializeStruct;
3101
3102 let mut len = 14;
3103 if self.recognizer_id.is_some() {
3104 len += 1;
3105 }
3106 if self.recognizer_version_id.is_some() {
3107 len += 1;
3108 }
3109 len += [
3110 self.provenance_stage.as_ref(),
3111 self.provenance_model_id.as_ref(),
3112 self.provenance_model_version.as_ref(),
3113 self.provenance_artifact_sha256.as_ref(),
3114 self.provenance_tokenizer_sha256.as_ref(),
3115 self.provenance_locale_resolved.as_ref(),
3116 self.provenance_locale_match_kind.as_ref(),
3117 self.provenance_canonical_class.as_ref(),
3118 self.provenance_native_class.as_ref(),
3119 self.provenance_confidence.as_ref(),
3120 self.provenance_merged_from.as_ref(),
3121 ]
3122 .into_iter()
3123 .filter(|value| value.is_some())
3124 .count();
3125 if self.backend_silently_dropped.is_some() {
3126 len += 1;
3127 }
3128 len += [self.restore_policy.as_ref(), self.restore_decision.as_ref()]
3129 .into_iter()
3130 .filter(|value| value.is_some())
3131 .count();
3132 len += [
3133 self.restore_unknown_token_count.is_some(),
3134 self.restore_manifest_bypass_count.is_some(),
3135 self.restore_fresh_pii_count.is_some(),
3136 self.restore_phase_mask.is_some(),
3137 self.restore_trap_shape_count.is_some(),
3138 ]
3139 .into_iter()
3140 .filter(|value| *value)
3141 .count();
3142 let mut state = serializer.serialize_struct("RedactionEntry", len)?;
3143 state.serialize_field("source", &self.source)?;
3144 if let Some(recognizer_id) = &self.recognizer_id {
3145 state.serialize_field("recognizer_id", recognizer_id)?;
3146 }
3147 if let Some(recognizer_version_id) = &self.recognizer_version_id {
3148 state.serialize_field("recognizer_version_id", recognizer_version_id)?;
3149 }
3150 state.serialize_field("class", &self.class.to_canonical_str())?;
3151 state.serialize_field("action", self.action.as_str())?;
3152 state.serialize_field("field_name", &self.field_name)?;
3153 state.serialize_field("document_kind", self.document_kind.as_str())?;
3154 state.serialize_field("conflict_loser", &self.conflict_loser)?;
3155 state.serialize_field("decided_by", self.decided_by.as_str())?;
3156 state.serialize_field("created_at", &self.created_at)?;
3157 state.serialize_field("session_id", &self.session_id)?;
3158 state.serialize_field("validator_fail_reason", &self.validator_fail_reason)?;
3159 state.serialize_field("ambiguity_record", &self.ambiguity_record)?;
3160 state.serialize_field("collision_family", &self.collision_family)?;
3161 state.serialize_field("collision_variant", &self.collision_variant)?;
3162 state.serialize_field("fallback_triggered", &self.fallback_triggered)?;
3163 if let Some(value) = &self.provenance_stage {
3164 state.serialize_field("provenance_stage", value)?;
3165 }
3166 if let Some(value) = &self.provenance_model_id {
3167 state.serialize_field("provenance_model_id", value)?;
3168 }
3169 if let Some(value) = &self.provenance_model_version {
3170 state.serialize_field("provenance_model_version", value)?;
3171 }
3172 if let Some(value) = &self.provenance_artifact_sha256 {
3173 state.serialize_field("provenance_artifact_sha256", value)?;
3174 }
3175 if let Some(value) = &self.provenance_tokenizer_sha256 {
3176 state.serialize_field("provenance_tokenizer_sha256", value)?;
3177 }
3178 if let Some(value) = &self.provenance_locale_resolved {
3179 state.serialize_field("provenance_locale_resolved", value)?;
3180 }
3181 if let Some(value) = &self.provenance_locale_match_kind {
3182 state.serialize_field("provenance_locale_match_kind", value)?;
3183 }
3184 if let Some(value) = &self.provenance_canonical_class {
3185 state.serialize_field("provenance_canonical_class", value)?;
3186 }
3187 if let Some(value) = &self.provenance_native_class {
3188 state.serialize_field("provenance_native_class", value)?;
3189 }
3190 if let Some(value) = &self.provenance_confidence {
3191 state.serialize_field("provenance_confidence", value)?;
3192 }
3193 if let Some(value) = &self.provenance_merged_from {
3194 state.serialize_field("provenance_merged_from", value)?;
3195 }
3196 if let Some(dropped) = &self.backend_silently_dropped {
3197 state.serialize_field("backend_silently_dropped", dropped)?;
3198 }
3199 if let Some(value) = &self.restore_policy {
3200 state.serialize_field("restore_policy", value)?;
3201 }
3202 if let Some(value) = &self.restore_decision {
3203 state.serialize_field("restore_decision", value)?;
3204 }
3205 if let Some(value) = self.restore_unknown_token_count {
3206 state.serialize_field("restore_unknown_token_count", &value)?;
3207 }
3208 if let Some(value) = self.restore_manifest_bypass_count {
3209 state.serialize_field("restore_manifest_bypass_count", &value)?;
3210 }
3211 if let Some(value) = self.restore_fresh_pii_count {
3212 state.serialize_field("restore_fresh_pii_count", &value)?;
3213 }
3214 if let Some(value) = self.restore_trap_shape_count {
3215 state.serialize_field("restore_trap_shape_count", &value)?;
3216 }
3217 if let Some(value) = self.restore_phase_mask {
3218 state.serialize_field("restore_phase_mask", &value)?;
3219 }
3220 state.end()
3221 }
3222}
3223
3224impl RedactionEntry {
3225 #[allow(clippy::too_many_arguments)]
3227 pub fn new(
3228 source: impl Into<String>,
3229 class: PiiClass,
3230 action: Action,
3231 field_name: Option<String>,
3232 document_kind: DocumentKind,
3233 conflict_loser: bool,
3234 decided_by: ConflictTier,
3235 created_at: i64,
3236 session_id: Option<String>,
3237 ) -> Self {
3238 Self {
3239 source: source.into(),
3240 class,
3241 action,
3242 field_name,
3243 document_kind,
3244 conflict_loser,
3245 decided_by,
3246 created_at,
3247 session_id,
3248 recognizer_id: None,
3249 recognizer_version_id: None,
3250 validator_fail_reason: None,
3251 labelled_value_scan_reason: None,
3252 ambiguity_record: None,
3253 collision_family: None,
3254 collision_variant: None,
3255 fallback_triggered: None,
3256 provenance_stage: None,
3257 provenance_model_id: None,
3258 provenance_model_version: None,
3259 provenance_artifact_sha256: None,
3260 provenance_tokenizer_sha256: None,
3261 provenance_locale_resolved: None,
3262 provenance_locale_match_kind: None,
3263 provenance_canonical_class: None,
3264 provenance_native_class: None,
3265 provenance_confidence: None,
3266 provenance_merged_from: None,
3267 backend_silently_dropped: None,
3268 restore_policy: None,
3269 restore_decision: None,
3270 restore_unknown_token_count: None,
3271 restore_manifest_bypass_count: None,
3272 restore_fresh_pii_count: None,
3273 restore_phase_mask: None,
3274 restore_trap_shape_count: None,
3275 }
3276 }
3277
3278 pub fn with_validator_fail_reason(mut self, reason: ValidatorFailReason) -> Self {
3280 self.validator_fail_reason = Some(reason);
3281 self
3282 }
3283
3284 pub fn with_ambiguity_record(mut self, record: AmbiguityRecord) -> Self {
3286 self.ambiguity_record = Some(record);
3287 self
3288 }
3289
3290 pub fn with_collision_metadata(
3292 mut self,
3293 family: Option<String>,
3294 variant: Option<String>,
3295 ) -> Self {
3296 self.collision_family = family;
3297 self.collision_variant = variant;
3298 self
3299 }
3300
3301 pub fn with_fallback_triggered(mut self, reason: FallbackReason) -> Self {
3303 self.fallback_triggered = Some(reason);
3304 self
3305 }
3306
3307 pub fn with_backend_silently_dropped(mut self, dropped: Vec<String>) -> Self {
3309 self.backend_silently_dropped = Some(dropped);
3310 self
3311 }
3312
3313 pub fn with_restore_telemetry(mut self, telemetry: RestoreTelemetry) -> Self {
3314 self.restore_policy = Some(telemetry.restore_policy_str().to_string());
3315 self.restore_decision = Some(telemetry.restore_decision_str().to_string());
3316 self.restore_unknown_token_count = Some(telemetry.unknown_token_count);
3317 self.restore_manifest_bypass_count = Some(telemetry.manifest_bypass_count);
3318 self.restore_fresh_pii_count = Some(telemetry.fresh_pii_detected_count);
3319 self.restore_phase_mask = Some(telemetry.phase_execution_mask);
3320 self.restore_trap_shape_count = Some(telemetry.trap_shape_count);
3321 self
3322 }
3323
3324 pub fn with_recognizer_metadata(
3326 mut self,
3327 recognizer_id: Option<String>,
3328 recognizer_version_id: Option<String>,
3329 ) -> Self {
3330 self.recognizer_id = recognizer_id;
3331 self.recognizer_version_id = recognizer_version_id;
3332 self
3333 }
3334
3335 #[allow(clippy::too_many_arguments)]
3336 pub fn with_provenance_metadata(
3337 mut self,
3338 stage: Option<String>,
3339 model_id: Option<String>,
3340 model_version: Option<String>,
3341 artifact_sha256: Option<String>,
3342 tokenizer_sha256: Option<String>,
3343 locale_resolved: Option<String>,
3344 locale_match_kind: Option<String>,
3345 canonical_class: Option<String>,
3346 native_class: Option<String>,
3347 confidence: Option<f64>,
3348 merged_from: Option<String>,
3349 ) -> Self {
3350 self.provenance_stage = stage;
3351 self.provenance_model_id = model_id;
3352 self.provenance_model_version = model_version;
3353 self.provenance_artifact_sha256 = artifact_sha256;
3354 self.provenance_tokenizer_sha256 = tokenizer_sha256;
3355 self.provenance_locale_resolved = locale_resolved;
3356 self.provenance_locale_match_kind = locale_match_kind;
3357 self.provenance_canonical_class = canonical_class;
3358 self.provenance_native_class = native_class;
3359 self.provenance_confidence = confidence.map(|value| value.to_string());
3360 self.provenance_merged_from = merged_from;
3361 self
3362 }
3363}
3364
3365#[derive(Debug, Clone, PartialEq, Eq, Error)]
3367#[non_exhaustive]
3368pub enum RedactionLogError {
3369 #[error("sqlite redaction log error: {0}")]
3371 Sqlite(String),
3372 #[error("backend redaction log error: {0}")]
3374 Backend(String),
3375}
3376
3377pub trait RedactionLogger: Send + Sync {
3407 fn log(&self, entry: &RedactionEntry) -> Result<(), RedactionLogError>;
3409}
3410
3411#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
3413#[non_exhaustive]
3414pub enum SafetyTier {
3415 #[default]
3417 SafeDefault,
3418 LocaleGated,
3420 OptIn,
3422}
3423
3424#[derive(Debug, Clone, PartialEq, Eq)]
3426#[non_exhaustive]
3427pub struct SafetyTierParseError {
3428 value: String,
3429}
3430
3431impl SafetyTier {
3432 pub fn parse(value: &str) -> Result<Self, SafetyTierParseError> {
3434 match value {
3435 "safe_default" => Ok(Self::SafeDefault),
3436 "locale_gated" => Ok(Self::LocaleGated),
3437 "opt_in" => Ok(Self::OptIn),
3438 other => Err(SafetyTierParseError {
3439 value: other.to_string(),
3440 }),
3441 }
3442 }
3443
3444 pub fn as_str(self) -> &'static str {
3446 match self {
3447 Self::SafeDefault => "safe_default",
3448 Self::LocaleGated => "locale_gated",
3449 Self::OptIn => "opt_in",
3450 }
3451 }
3452}
3453
3454impl SafetyTierParseError {
3455 pub fn value(&self) -> &str {
3457 &self.value
3458 }
3459}
3460
3461impl fmt::Display for SafetyTierParseError {
3462 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3463 write!(f, "unsupported safety_tier '{}'", self.value)
3464 }
3465}
3466
3467impl std::error::Error for SafetyTierParseError {}
3468
3469#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
3471#[non_exhaustive]
3472pub enum LocaleBasis {
3473 #[default]
3475 Document,
3476 Format,
3478}
3479
3480#[derive(Debug, Clone, PartialEq, Eq)]
3482#[non_exhaustive]
3483pub struct LocaleBasisParseError {
3484 value: String,
3485}
3486
3487impl LocaleBasis {
3488 pub fn parse(value: &str) -> Result<Self, LocaleBasisParseError> {
3490 match value {
3491 "document" => Ok(Self::Document),
3492 "format" => Ok(Self::Format),
3493 other => Err(LocaleBasisParseError {
3494 value: other.to_string(),
3495 }),
3496 }
3497 }
3498
3499 pub fn as_str(self) -> &'static str {
3501 match self {
3502 Self::Document => "document",
3503 Self::Format => "format",
3504 }
3505 }
3506}
3507
3508impl LocaleBasisParseError {
3509 pub fn value(&self) -> &str {
3511 &self.value
3512 }
3513}
3514
3515impl fmt::Display for LocaleBasisParseError {
3516 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3517 write!(f, "unsupported locale_basis '{}'", self.value)
3518 }
3519}
3520
3521impl std::error::Error for LocaleBasisParseError {}
3522
3523#[derive(Debug, Clone, PartialEq, Eq, Hash)]
3525#[non_exhaustive]
3526pub enum LocaleTag {
3527 Global,
3529 DeDe,
3531 DeAt,
3533 DeCh,
3535 EnUs,
3537 EnGb,
3539 EnIe,
3541 EnAu,
3543 EnCa,
3545 Other(String),
3547}
3548
3549#[derive(Debug, Clone, PartialEq, Eq)]
3551#[non_exhaustive]
3552pub enum LocaleError {
3553 Unsupported,
3555}
3556
3557impl fmt::Display for LocaleError {
3558 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3559 match self {
3560 LocaleError::Unsupported => f.write_str("unsupported locale"),
3561 }
3562 }
3563}
3564
3565impl std::error::Error for LocaleError {}
3566
3567#[derive(Debug, Clone, PartialEq, Eq)]
3569pub struct LocaleChain(Vec<LocaleTag>);
3570
3571impl LocaleTag {
3572 pub const GLOBAL: LocaleTag = LocaleTag::Global;
3574
3575 pub fn parse(s: &str) -> Result<LocaleTag, LocaleError> {
3577 let raw = s.trim().replace('_', "-");
3578 let normalized = raw.to_ascii_lowercase();
3579 match normalized.as_str() {
3580 "global" | "*" => Ok(LocaleTag::Global),
3581 "de-de" => Ok(LocaleTag::DeDe),
3582 "de-at" => Ok(LocaleTag::DeAt),
3583 "de-ch" => Ok(LocaleTag::DeCh),
3584 "en-us" => Ok(LocaleTag::EnUs),
3585 "en-gb" => Ok(LocaleTag::EnGb),
3586 "en-ie" => Ok(LocaleTag::EnIe),
3587 "en-au" => Ok(LocaleTag::EnAu),
3588 "en-ca" => Ok(LocaleTag::EnCa),
3589 "" => Err(LocaleError::Unsupported),
3590 _ if is_bcp47_parseable(&raw) => Ok(LocaleTag::Other(canonical_other(&raw))),
3591 _ => Err(LocaleError::Unsupported),
3592 }
3593 }
3594
3595 pub fn as_str(&self) -> &str {
3597 match self {
3598 LocaleTag::Global => "global",
3599 LocaleTag::DeDe => "de-DE",
3600 LocaleTag::DeAt => "de-AT",
3601 LocaleTag::DeCh => "de-CH",
3602 LocaleTag::EnUs => "en-US",
3603 LocaleTag::EnGb => "en-GB",
3604 LocaleTag::EnIe => "en-IE",
3605 LocaleTag::EnAu => "en-AU",
3606 LocaleTag::EnCa => "en-CA",
3607 LocaleTag::Other(tag) => tag.as_str(),
3608 }
3609 }
3610}
3611
3612impl LocaleChain {
3613 pub fn from_tags(mut tags: Vec<LocaleTag>) -> LocaleChain {
3615 ensure_global(&mut tags);
3616 LocaleChain(tags)
3617 }
3618
3619 pub fn from_cli(raw: &str) -> Result<LocaleChain, LocaleError> {
3621 let tags = raw
3622 .split(',')
3623 .map(LocaleTag::parse)
3624 .collect::<Result<Vec<_>, _>>()?;
3625 Ok(LocaleChain::from_tags(tags))
3626 }
3627
3628 pub fn merge_policy_and_cli(
3630 policy: Option<&[LocaleTag]>,
3631 cli: Option<&[LocaleTag]>,
3632 ) -> LocaleChain {
3633 Self::merge_cli_policy_rulepack_default(cli, policy, None)
3634 }
3635
3636 pub fn merge_cli_policy_rulepack_default(
3638 cli: Option<&[LocaleTag]>,
3639 policy: Option<&[LocaleTag]>,
3640 rulepack_defaults: Option<&[LocaleTag]>,
3641 ) -> LocaleChain {
3642 let tags = cli
3643 .filter(|tags| !tags.is_empty())
3644 .or_else(|| policy.filter(|tags| !tags.is_empty()))
3645 .or_else(|| rulepack_defaults.filter(|tags| !tags.is_empty()))
3646 .map(|tags| tags.to_vec())
3647 .unwrap_or_else(|| vec![LocaleTag::Global]);
3648 LocaleChain::from_tags(tags)
3649 }
3650
3651 pub fn intersects(&self, recognizer_locales: &[LocaleTag]) -> bool {
3653 if recognizer_locales.is_empty() {
3654 return true;
3655 }
3656 recognizer_locales.iter().any(|recognizer_locale| {
3657 *recognizer_locale == LocaleTag::Global
3658 || self.0.iter().any(|active| active == recognizer_locale)
3659 })
3660 }
3661
3662 pub fn as_slice(&self) -> &[LocaleTag] {
3664 &self.0
3665 }
3666
3667 pub fn to_strings(&self) -> Vec<String> {
3669 self.0.iter().map(ToString::to_string).collect()
3670 }
3671}
3672
3673impl From<&[LocaleTag]> for LocaleChain {
3674 fn from(tags: &[LocaleTag]) -> Self {
3675 let mut owned = tags.to_vec();
3676 ensure_global(&mut owned);
3677 LocaleChain(owned)
3678 }
3679}
3680
3681impl fmt::Display for LocaleTag {
3682 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3683 f.write_str(self.as_str())
3684 }
3685}
3686
3687#[derive(Debug, Clone)]
3697#[non_exhaustive]
3698pub enum RawDocument {
3699 Structured(BTreeMap<String, Value>),
3701 Text(String),
3703}
3704
3705#[derive(Debug, Clone, Serialize)]
3724#[serde(untagged)]
3725#[non_exhaustive]
3726pub enum CleanDocument {
3727 Structured(BTreeMap<String, Value>),
3729 Text(String),
3731}
3732
3733#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3735#[serde(untagged)]
3736#[non_exhaustive]
3737pub enum Value {
3738 Null,
3740 Bool(bool),
3742 String(String),
3744 I64(i64),
3746 Array(Vec<Value>),
3748 Object(BTreeMap<String, Value>),
3750}
3751
3752impl Value {
3753 pub fn as_str(&self) -> Option<&str> {
3755 match self {
3756 Self::String(value) => Some(value.as_str()),
3757 Self::Null | Self::Bool(_) | Self::I64(_) | Self::Array(_) | Self::Object(_) => None,
3758 }
3759 }
3760
3761 pub fn scalar_to_safety_net_string(&self) -> Option<String> {
3763 match self {
3764 Self::String(value) if !value.is_empty() => Some(value.clone()),
3765 Self::String(_) | Self::Null | Self::Array(_) | Self::Object(_) => None,
3766 Self::Bool(value) => Some(value.to_string()),
3767 Self::I64(value) => Some(value.to_string()),
3768 }
3769 }
3770}
3771
3772impl PartialEq<&str> for Value {
3773 fn eq(&self, other: &&str) -> bool {
3774 self.as_str() == Some(*other)
3775 }
3776}
3777
3778#[derive(Debug, Clone, Default)]
3780pub struct DictionaryBundle {
3781 entries: HashMap<String, DictionaryEntry>,
3782}
3783
3784#[derive(Debug, Clone)]
3786pub struct DictionaryEntry {
3787 terms: Vec<String>,
3788 case_sensitive: bool,
3789 source: DictionarySource,
3790 record_allowed_kinds: Option<BTreeSet<RecordMatchKind>>,
3791}
3792
3793#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3795#[non_exhaustive]
3796pub enum DictionarySource {
3797 Cli,
3799 Rulepack,
3801}
3802
3803#[derive(Debug, Clone, PartialEq, Eq)]
3805#[non_exhaustive]
3806pub struct DictionaryStats {
3807 pub name: String,
3809 pub term_count: usize,
3811 pub source: DictionarySource,
3813}
3814
3815impl DictionaryStats {
3816 pub fn new(name: impl Into<String>, term_count: usize, source: DictionarySource) -> Self {
3818 Self {
3819 name: name.into(),
3820 term_count,
3821 source,
3822 }
3823 }
3824}
3825
3826#[derive(Debug, Clone, PartialEq, Eq)]
3828#[non_exhaustive]
3829pub struct RulepackDict {
3830 pub name: String,
3832 pub terms: Vec<String>,
3834 pub case_sensitive: bool,
3836}
3837
3838impl RulepackDict {
3839 pub fn new(name: impl Into<String>, terms: Vec<String>, case_sensitive: bool) -> Self {
3841 Self {
3842 name: name.into(),
3843 terms,
3844 case_sensitive,
3845 }
3846 }
3847}
3848
3849#[derive(Debug, Clone, PartialEq, Eq)]
3851#[non_exhaustive]
3852pub enum DictionaryLoadError {
3853 Empty { name: String },
3855 UnicodeInsensitiveUnsupported { name: String },
3857}
3858
3859impl fmt::Display for DictionaryLoadError {
3860 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3861 match self {
3862 Self::Empty { name } => write!(f, "dictionary '{name}' has no terms"),
3863 Self::UnicodeInsensitiveUnsupported { name } => write!(
3864 f,
3865 "dictionary '{name}' uses unicode terms with case-insensitive matching, unsupported in v0.4.0; use case_sensitive = true"
3866 ),
3867 }
3868 }
3869}
3870
3871impl std::error::Error for DictionaryLoadError {}
3872
3873impl DictionaryBundle {
3874 pub fn from_rulepack_terms(terms: &[RulepackDict]) -> Self {
3876 let mut entries = HashMap::with_capacity(terms.len());
3877 for dictionary in terms {
3878 let entry = DictionaryEntry::new(
3879 &dictionary.name,
3880 dictionary.terms.clone(),
3881 dictionary.case_sensitive,
3882 DictionarySource::Rulepack,
3883 )
3884 .expect("Policy validates dictionary terms before bundle construction");
3885 entries.insert(dictionary.name.clone(), entry);
3886 }
3887 Self { entries }
3888 }
3889
3890 pub fn from_entries(entries: impl IntoIterator<Item = (String, DictionaryEntry)>) -> Self {
3892 Self {
3893 entries: entries.into_iter().collect(),
3894 }
3895 }
3896
3897 pub fn merge(a: Self, b: Self) -> Self {
3899 let mut entries = a.entries;
3900 entries.extend(b.entries);
3901 Self { entries }
3902 }
3903
3904 pub fn get(&self, name: &str) -> Option<&DictionaryEntry> {
3906 self.entries.get(name)
3907 }
3908
3909 pub fn iter(&self) -> impl Iterator<Item = (&str, &DictionaryEntry)> {
3911 self.entries
3912 .iter()
3913 .map(|(name, entry)| (name.as_str(), entry))
3914 }
3915
3916 pub fn stats(&self) -> Vec<DictionaryStats> {
3918 let mut stats = self
3919 .entries
3920 .iter()
3921 .map(|(name, entry)| DictionaryStats {
3922 name: name.clone(),
3923 term_count: entry.terms.len(),
3924 source: entry.source,
3925 })
3926 .collect::<Vec<_>>();
3927 stats.sort_by(|a, b| a.name.cmp(&b.name));
3928 stats
3929 }
3930}
3931
3932impl DictionaryEntry {
3933 pub fn new(
3935 name: &str,
3936 terms: Vec<String>,
3937 case_sensitive: bool,
3938 source: DictionarySource,
3939 ) -> Result<Self, DictionaryLoadError> {
3940 if terms.is_empty() {
3941 return Err(DictionaryLoadError::Empty {
3942 name: name.to_string(),
3943 });
3944 }
3945 if !case_sensitive && terms.iter().any(|term| !term.is_ascii()) {
3946 return Err(DictionaryLoadError::UnicodeInsensitiveUnsupported {
3947 name: name.to_string(),
3948 });
3949 }
3950 Ok(Self {
3951 terms,
3952 case_sensitive,
3953 source,
3954 record_allowed_kinds: None,
3955 })
3956 }
3957
3958 pub fn with_record_allowed_kinds(mut self, kinds: BTreeSet<RecordMatchKind>) -> Self {
3960 self.record_allowed_kinds = Some(kinds);
3961 self
3962 }
3963
3964 pub fn record_allowed_kinds(&self) -> Option<&BTreeSet<RecordMatchKind>> {
3966 self.record_allowed_kinds.as_ref()
3967 }
3968
3969 pub fn case_sensitive(&self) -> bool {
3971 self.case_sensitive
3972 }
3973
3974 pub fn terms(&self) -> &[String] {
3976 &self.terms
3977 }
3978}
3979
3980#[cfg(test)]
3981mod action_tests {
3982 use super::*;
3983
3984 #[test]
3987 fn protective_means_ranked_above_preserve() {
3988 for action in [
3989 Action::Tokenize,
3990 Action::Redact,
3991 Action::FormatPreserve,
3992 Action::Generalize,
3993 Action::Preserve,
3994 ] {
3995 assert_eq!(
3996 action.is_protective(),
3997 action.strictness_rank() > Action::Preserve.strictness_rank(),
3998 "{action:?}"
3999 );
4000 }
4001 assert!(!Action::Preserve.is_protective());
4002 }
4003}
4004
4005#[cfg(test)]
4006mod word_boundary_tests {
4007 use super::*;
4008
4009 const IBAN: &str = "AT611904300234573201";
4010
4011 fn extends(trailer: &str) -> bool {
4012 let text = format!("IBAN {IBAN}{trailer}");
4013 word_run_extends_identifier(&text, 5 + IBAN.len())
4014 }
4015
4016 #[test]
4019 fn a_run_of_letters_does_not_extend_the_identifier() {
4020 for trailer in [
4021 "",
4022 "BIC",
4023 "bic",
4024 "BIC:BKAUATWW",
4025 "Überweisung",
4026 "über",
4027 "Straße",
4028 "élan",
4029 ] {
4030 assert!(!extends(trailer), "{trailer:?}");
4031 }
4032 }
4033
4034 #[test]
4036 fn a_digit_or_underscore_anywhere_in_the_run_extends_the_identifier() {
4037 for trailer in [
4038 "1",
4039 "XQ7",
4040 "BIC1",
4041 "_",
4042 "_x",
4043 "x_",
4044 "\u{661}",
4045 "BIC\u{661}",
4046 "٣",
4047 ] {
4048 assert!(extends(trailer), "{trailer:?}");
4049 }
4050 }
4051
4052 #[test]
4055 fn the_run_stops_at_a_separator_or_a_combining_mark() {
4056 for trailer in [" 1234", ":1234", "BIC:1", "\u{301}1", "\u{301}", "-1", ".1"] {
4057 assert!(!extends(trailer), "{trailer:?}");
4058 }
4059 assert!(!is_inside_word("a\u{301}", 1));
4060 }
4061
4062 #[test]
4064 fn out_of_range_or_mid_character_offsets_return_false() {
4065 assert!(!word_run_extends_identifier("AT61", 99));
4066 assert!(!word_run_extends_identifier("AT61", 4));
4067 assert!(!word_run_extends_identifier("ü1", 1));
4068 }
4069
4070 #[test]
4072 fn both_edge_checks_agree_on_word_characters() {
4073 for ch in ['a', 'Z', '9', 'ü', '\u{661}', '_', '-', ' ', '\u{301}', '<'] {
4074 let text = format!("x{ch}");
4075 assert_eq!(
4076 is_inside_word(&text, 1),
4077 is_word_char(ch),
4078 "{ch:?}: is_inside_word must follow is_word_char"
4079 );
4080 let run_reads_it = word_run_extends_identifier(&format!("{ch}1"), 0);
4081 assert_eq!(
4082 run_reads_it,
4083 is_word_char(ch) || ch == '_',
4084 "{ch:?}: the run must read exactly the word characters plus underscore"
4085 );
4086 }
4087 }
4088}
4089
4090#[cfg(test)]
4091mod dictionary_tests {
4092 use super::*;
4093
4094 #[test]
4095 fn dictionary_entry_rejects_empty_terms() {
4096 let err = DictionaryEntry::new("empty", Vec::new(), true, DictionarySource::Cli)
4097 .expect_err("empty dictionaries must fail closed");
4098
4099 assert!(matches!(err, DictionaryLoadError::Empty { name } if name == "empty"));
4100 }
4101
4102 #[test]
4103 fn dictionary_entry_rejects_non_ascii_case_insensitive_terms() {
4104 let err = DictionaryEntry::new(
4105 "songs",
4106 vec!["Beyonce".to_string(), "Caf\u{00e9}".to_string()],
4107 false,
4108 DictionarySource::Cli,
4109 )
4110 .expect_err("unicode case-insensitive dictionaries must fail closed");
4111
4112 assert!(matches!(
4113 err,
4114 DictionaryLoadError::UnicodeInsensitiveUnsupported { name } if name == "songs"
4115 ));
4116 }
4117}
4118
4119#[cfg(test)]
4120mod redaction_logger_tests {
4121 use super::*;
4122
4123 struct CapturingLogger;
4124
4125 impl RedactionLogger for CapturingLogger {
4126 fn log(&self, _entry: &RedactionEntry) -> Result<(), RedactionLogError> {
4127 Ok(())
4128 }
4129 }
4130
4131 fn assert_send_sync<T: Send + Sync + ?Sized>() {}
4132
4133 #[test]
4134 fn fallback_reason_serializes_snake_case_and_accepts_legacy_pascal_case() {
4135 for (legacy, canonical, value) in [
4136 (
4137 "OverlapConflict",
4138 "overlap_conflict",
4139 FallbackReason::OverlapConflict,
4140 ),
4141 (
4142 "ValidatorVeto",
4143 "validator_veto",
4144 FallbackReason::ValidatorVeto,
4145 ),
4146 (
4147 "AnchorMissing",
4148 "anchor_missing",
4149 FallbackReason::AnchorMissing,
4150 ),
4151 (
4152 "ResidualSuspect",
4153 "residual_suspect",
4154 FallbackReason::ResidualSuspect,
4155 ),
4156 ] {
4157 let legacy_json = format!(r#""{legacy}""#);
4158 assert_eq!(
4159 serde_json::from_str::<FallbackReason>(&legacy_json)
4160 .expect("legacy fallback reason"),
4161 value
4162 );
4163 assert_eq!(
4164 serde_json::to_string(&value).expect("fallback reason"),
4165 format!(r#""{canonical}""#)
4166 );
4167 }
4168 }
4169
4170 #[test]
4171 fn audit_enum_canonical_forms_round_trip() {
4172 for value in [
4173 Action::Tokenize,
4174 Action::Redact,
4175 Action::FormatPreserve,
4176 Action::Generalize,
4177 Action::Preserve,
4178 ] {
4179 assert_eq!(Action::from_canonical_str(value.as_str()), Some(value));
4180 }
4181 for value in [
4182 ConflictTier::None,
4183 ConflictTier::ClassPriority,
4184 ConflictTier::RulePriority,
4185 ConflictTier::Score,
4186 ConflictTier::SpanLength,
4187 ConflictTier::Validator,
4188 ConflictTier::SameClassContainment,
4189 ConflictTier::ValidatorVeto,
4190 ConflictTier::CollisionPolicy,
4191 ConflictTier::AnchoredContext,
4192 ConflictTier::StructuredContainment,
4193 ConflictTier::ContainmentPrecedence,
4194 ConflictTier::ProtectionOverride,
4195 ConflictTier::RecognizerId,
4196 ConflictTier::Merged,
4197 ConflictTier::Redact,
4198 ConflictTier::Resolve,
4199 ConflictTier::Fallback,
4200 ConflictTier::ManifestSweep,
4201 ] {
4202 assert_eq!(
4203 ConflictTier::from_canonical_str(value.as_str()),
4204 Some(value)
4205 );
4206 }
4207 for value in [DocumentKind::Structured, DocumentKind::Text] {
4208 assert_eq!(
4209 DocumentKind::from_canonical_str(value.as_str()),
4210 Some(value)
4211 );
4212 }
4213 for value in [
4214 FallbackReason::OverlapConflict,
4215 FallbackReason::ValidatorVeto,
4216 FallbackReason::AnchorMissing,
4217 FallbackReason::ResidualSuspect,
4218 ] {
4219 assert_eq!(
4220 FallbackReason::from_canonical_str(value.as_str()),
4221 Some(value)
4222 );
4223 }
4224 }
4225
4226 #[test]
4227 fn redaction_log_error_display_is_stable() {
4228 assert_eq!(
4229 RedactionLogError::Sqlite("write failed".to_string()).to_string(),
4230 "sqlite redaction log error: write failed"
4231 );
4232 assert_eq!(
4233 RedactionLogError::Backend("sink failed".to_string()).to_string(),
4234 "backend redaction log error: sink failed"
4235 );
4236 }
4237
4238 #[test]
4239 fn redaction_logger_trait_object_is_send_sync() {
4240 assert_send_sync::<dyn RedactionLogger>();
4241 }
4242
4243 #[test]
4244 fn local_logger_can_implement_redaction_logger() {
4245 let logger = CapturingLogger;
4246 let entry = RedactionEntry {
4247 source: "unit-test".to_string(),
4248 recognizer_id: None,
4249 recognizer_version_id: None,
4250 class: PiiClass::Email,
4251 action: Action::Tokenize,
4252 field_name: None,
4253 document_kind: DocumentKind::Text,
4254 conflict_loser: false,
4255 decided_by: ConflictTier::None,
4256 created_at: 0,
4257 session_id: None,
4258 validator_fail_reason: None,
4259 labelled_value_scan_reason: None,
4260 ambiguity_record: None,
4261 collision_family: None,
4262 collision_variant: None,
4263 fallback_triggered: None,
4264 provenance_stage: None,
4265 provenance_model_id: None,
4266 provenance_model_version: None,
4267 provenance_artifact_sha256: None,
4268 provenance_tokenizer_sha256: None,
4269 provenance_locale_resolved: None,
4270 provenance_locale_match_kind: None,
4271 provenance_canonical_class: None,
4272 provenance_native_class: None,
4273 provenance_confidence: None,
4274 provenance_merged_from: None,
4275 backend_silently_dropped: None,
4276 restore_policy: None,
4277 restore_decision: None,
4278 restore_unknown_token_count: None,
4279 restore_manifest_bypass_count: None,
4280 restore_fresh_pii_count: None,
4281 restore_phase_mask: None,
4282 restore_trap_shape_count: None,
4283 };
4284
4285 let trait_object: &dyn RedactionLogger = &logger;
4286 trait_object.log(&entry).expect("log entry");
4287 }
4288
4289 #[test]
4290 fn redaction_entry_json_shape_omits_absent_recognizer_lineage() {
4291 let entry = RedactionEntry::new(
4292 "email.global",
4293 PiiClass::Email,
4294 Action::Tokenize,
4295 None,
4296 DocumentKind::Text,
4297 false,
4298 ConflictTier::None,
4299 0,
4300 None,
4301 );
4302
4303 let rendered = serde_json::to_string(&entry).expect("serialize redaction entry");
4304
4305 assert_eq!(
4306 rendered,
4307 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}"#
4308 );
4309 }
4310
4311 #[test]
4312 fn redaction_entry_json_shape_includes_recognizer_lineage_when_present() {
4313 let entry = RedactionEntry::new(
4314 "ner/ort",
4315 PiiClass::Name,
4316 Action::Tokenize,
4317 None,
4318 DocumentKind::Text,
4319 false,
4320 ConflictTier::None,
4321 0,
4322 None,
4323 )
4324 .with_recognizer_metadata(
4325 Some("ner".to_string()),
4326 Some("ner.davlan-mbert.v1".to_string()),
4327 );
4328
4329 let value: serde_json::Value =
4330 serde_json::to_value(&entry).expect("serialize redaction entry");
4331
4332 assert_eq!(value["recognizer_id"], "ner");
4333 assert_eq!(value["recognizer_version_id"], "ner.davlan-mbert.v1");
4334 }
4335
4336 #[test]
4337 fn candidate_keeps_versioned_and_unversioned_recognizer_ids() {
4338 let unversioned = Candidate::new(
4339 0..5,
4340 PiiClass::Email,
4341 "email.global",
4342 0.9,
4343 10,
4344 None,
4345 "email",
4346 "email.global",
4347 ConflictTier::None,
4348 Vec::new(),
4349 );
4350 assert_eq!(unversioned.recognizer_id, "email.global");
4351 assert_eq!(unversioned.recognizer_version_id, None);
4352
4353 let versioned = unversioned
4354 .clone()
4355 .with_recognizer_version_id("email.global.v1");
4356 assert_eq!(versioned.recognizer_id, "email.global");
4357 assert_eq!(
4358 versioned.recognizer_version_id.as_deref(),
4359 Some("email.global.v1")
4360 );
4361 }
4362}
4363
4364#[cfg(test)]
4365mod safety_net_manifest_tests {
4366 use super::*;
4367
4368 fn span(start: usize, end: usize, class: PiiClass) -> EmittedTokenSpan {
4369 EmittedTokenSpan::new(start..end, start..end, class)
4370 }
4371
4372 fn diff(manifest: Manifest, suspect: Range<usize>, class: PiiClass) -> Option<LeakKind> {
4373 manifest.diff_against(&suspect, &class)
4374 }
4375
4376 #[test]
4377 fn exact_same_class_coverage_is_not_a_leak() {
4378 let manifest = Manifest::from_spans(vec![span(0, 8, PiiClass::Email)]);
4379
4380 assert_eq!(diff(manifest, 0..8, PiiClass::Email), None);
4381 }
4382
4383 #[test]
4384 fn uncovered_outside_all_tokens_is_uncovered() {
4385 let manifest = Manifest::from_spans(vec![span(20, 30, PiiClass::Email)]);
4386
4387 assert_eq!(
4388 diff(manifest, 0..10, PiiClass::Email),
4389 Some(LeakKind::Uncovered)
4390 );
4391 }
4392
4393 #[test]
4394 fn single_internal_gap_returns_partial_bleed() {
4395 let manifest = Manifest::from_spans(vec![
4396 span(0, 5, PiiClass::Email),
4397 span(10, 15, PiiClass::Email),
4398 ]);
4399
4400 assert_eq!(
4401 diff(manifest, 0..15, PiiClass::Email),
4402 Some(LeakKind::PartialBleed { uncovered: 5..10 })
4403 );
4404 }
4405
4406 #[test]
4407 fn multi_gap_returns_deterministic_first_uncovered_gap() {
4408 let manifest = Manifest::from_spans(vec![
4409 span(0, 3, PiiClass::Email),
4410 span(5, 7, PiiClass::Email),
4411 span(9, 12, PiiClass::Email),
4412 ]);
4413
4414 assert_eq!(
4417 diff(manifest, 0..12, PiiClass::Email),
4418 Some(LeakKind::PartialBleed { uncovered: 3..5 })
4419 );
4420 }
4421
4422 #[test]
4423 fn multi_class_overlap_reports_first_mismatch_deterministically() {
4424 let manifest = Manifest::from_spans(vec![
4425 span(0, 4, PiiClass::Name),
4426 span(4, 8, PiiClass::Location),
4427 ]);
4428
4429 assert_eq!(
4430 diff(manifest, 0..8, PiiClass::Email),
4431 Some(LeakKind::ClassMismatch {
4432 pipeline_class: PiiClass::Name,
4433 safety_net_class: PiiClass::Email,
4434 })
4435 );
4436 }
4437
4438 #[test]
4439 fn adjacent_same_class_tokens_cover_continuously() {
4440 let manifest = Manifest::from_spans(vec![
4441 span(0, 5, PiiClass::Email),
4442 span(5, 10, PiiClass::Email),
4443 ]);
4444
4445 assert_eq!(diff(manifest, 0..10, PiiClass::Email), None);
4446 }
4447
4448 #[test]
4449 fn partial_bleed_at_start_end_and_middle() {
4450 let manifest = Manifest::from_spans(vec![span(3, 8, PiiClass::Email)]);
4451
4452 assert_eq!(
4453 diff(manifest.clone(), 0..8, PiiClass::Email),
4454 Some(LeakKind::PartialBleed { uncovered: 0..3 })
4455 );
4456 assert_eq!(
4457 diff(manifest.clone(), 3..10, PiiClass::Email),
4458 Some(LeakKind::PartialBleed { uncovered: 8..10 })
4459 );
4460
4461 let with_gap = Manifest::from_spans(vec![
4462 span(0, 3, PiiClass::Email),
4463 span(6, 10, PiiClass::Email),
4464 ]);
4465 assert_eq!(
4466 diff(with_gap, 0..10, PiiClass::Email),
4467 Some(LeakKind::PartialBleed { uncovered: 3..6 })
4468 );
4469 }
4470
4471 #[test]
4472 fn byte_indices_are_not_character_indices() {
4473 let text = "ID: 😀 <Email_1>";
4474 let token_start = text.find("<Email_1>").expect("token start");
4475 assert_eq!(token_start, 9, "emoji is four bytes, not one char");
4476 let manifest = Manifest::from_spans(vec![span(token_start, text.len(), PiiClass::Email)]);
4477
4478 assert_eq!(
4479 diff(manifest, token_start..text.len(), PiiClass::Email),
4480 None
4481 );
4482 }
4483
4484 #[test]
4485 fn empty_suspect_range_is_not_a_leak() {
4486 let manifest = Manifest::default();
4487
4488 assert_eq!(diff(manifest, 3..3, PiiClass::Email), None);
4489 }
4490
4491 #[test]
4492 fn safety_net_error_display_is_variant_specific_and_bytes_free() {
4493 let cases = [
4494 SafetyNetError::Unavailable {
4495 reason: "not configured".to_string(),
4496 }
4497 .to_string(),
4498 SafetyNetError::WeightsMissing {
4499 path: "/models/opf".to_string(),
4500 }
4501 .to_string(),
4502 SafetyNetError::ModelUnavailable {
4503 reason: "load failed".to_string(),
4504 }
4505 .to_string(),
4506 SafetyNetError::ModelIntegrityMismatch {
4507 expected: "e3b0c44298fc1c149afbf4c8996fb924".to_string(),
4508 actual: "4e07408562bedb8b60ce05c1decfe3ad".to_string(),
4509 }
4510 .to_string(),
4511 SafetyNetError::InputTooLarge {
4512 limit: 1024,
4513 actual: 2048,
4514 }
4515 .to_string(),
4516 SafetyNetError::Runtime {
4517 message: "timeout".to_string(),
4518 }
4519 .to_string(),
4520 SafetyNetError::InvalidOutput {
4521 message: "bad json".to_string(),
4522 }
4523 .to_string(),
4524 ];
4525
4526 for rendered in cases {
4527 assert!(!rendered.contains("alice@example.invalid"));
4528 }
4529 }
4530}
4531
4532pub trait Recognizer: Send + Sync {
4534 fn id(&self) -> &str;
4536 fn supported_class(&self) -> &PiiClass;
4538 fn possible_classes(&self) -> Vec<PiiClass> {
4540 vec![self.supported_class().clone()]
4541 }
4542 fn detect(
4544 &self,
4545 input: &str,
4546 ctx: &DetectContext<'_>,
4547 ) -> std::result::Result<Vec<Candidate>, DetectError>;
4548 fn detect_for_registry(
4551 &self,
4552 input: &str,
4553 ctx: &DetectContext<'_>,
4554 ) -> std::result::Result<Vec<Candidate>, DetectError> {
4555 self.detect(input, ctx)
4556 }
4557 fn token_family(&self) -> &str;
4559 fn validator_kind(&self) -> Option<ValidatorKind> {
4561 None
4562 }
4563 fn validator_on_fail(&self) -> ValidatorOnFail {
4567 ValidatorOnFail::Veto
4568 }
4569 fn locales(&self) -> &[LocaleTag] {
4571 &[LocaleTag::Global]
4572 }
4573 fn locale_basis(&self) -> LocaleBasis {
4575 LocaleBasis::Document
4576 }
4577 fn detect_is_locale_invariant(&self) -> bool {
4585 false
4586 }
4587
4588 fn requires_prior_candidates(&self) -> bool {
4590 false
4591 }
4592
4593 fn evidence(&self) -> EvidenceKind {
4598 EvidenceKind::Learned
4599 }
4600}
4601
4602#[derive(Debug, Clone, PartialEq, Eq, Error)]
4604#[non_exhaustive]
4605pub enum DetectError {
4606 #[error("recognizer {recognizer_id} backend failed: {message}")]
4608 Backend {
4609 recognizer_id: String,
4611 message: String,
4613 },
4614}
4615
4616impl DetectError {
4617 pub fn backend(recognizer_id: impl Into<String>, source: impl Into<String>) -> Self {
4619 Self::Backend {
4620 recognizer_id: recognizer_id.into(),
4621 message: source.into(),
4622 }
4623 }
4624}
4625
4626#[derive(Debug, Clone, PartialEq)]
4628#[non_exhaustive]
4629pub struct Candidate {
4630 pub span: Range<usize>,
4632 pub class: PiiClass,
4634 pub recognizer_id: String,
4636 pub recognizer_version_id: Option<String>,
4638 pub score: f32,
4640 pub priority: i32,
4642 pub canonical_form: Option<String>,
4644 pub token_family: String,
4646 pub source: String,
4648 pub decided_by: ConflictTier,
4650 pub merged_sources: Vec<String>,
4652 pub source_recognizer_ids: Vec<String>,
4654 pub evidence: EvidenceKind,
4657 pub validator_fail_reason: Option<ValidatorFailReason>,
4660 pub labelled_value_scan_reason: Option<LabelledValueScanReason>,
4662 pub labelled_value_capture_end: Option<usize>,
4664 pub regex_guard_rejected: bool,
4666}
4667
4668impl Candidate {
4669 #[allow(clippy::too_many_arguments)]
4671 pub fn new(
4672 span: Range<usize>,
4673 class: PiiClass,
4674 recognizer_id: impl Into<String>,
4675 score: f32,
4676 priority: i32,
4677 canonical_form: Option<String>,
4678 token_family: impl Into<String>,
4679 source: impl Into<String>,
4680 decided_by: ConflictTier,
4681 merged_sources: Vec<String>,
4682 ) -> Self {
4683 let recognizer_id = recognizer_id.into();
4684 Self {
4685 span,
4686 class,
4687 source_recognizer_ids: vec![recognizer_id.clone()],
4688 recognizer_id,
4689 recognizer_version_id: None,
4690 score,
4691 priority,
4692 canonical_form,
4693 token_family: token_family.into(),
4694 source: source.into(),
4695 decided_by,
4696 merged_sources,
4697 evidence: EvidenceKind::Learned,
4698 validator_fail_reason: None,
4699 labelled_value_scan_reason: None,
4700 labelled_value_capture_end: None,
4701 regex_guard_rejected: false,
4702 }
4703 }
4704
4705 pub fn checksum_validated(&self) -> bool {
4710 self.canonical_form.is_some() && self.validator_fail_reason.is_none()
4711 }
4712
4713 pub fn with_evidence(mut self, evidence: EvidenceKind) -> Self {
4715 self.evidence = evidence;
4716 self
4717 }
4718
4719 pub fn with_span(mut self, span: Range<usize>) -> Self {
4721 self.span = span;
4722 self
4723 }
4724
4725 pub fn with_recognizer_version_id(mut self, recognizer_version_id: impl Into<String>) -> Self {
4727 self.recognizer_version_id = Some(recognizer_version_id.into());
4728 self
4729 }
4730}
4731
4732#[non_exhaustive]
4734pub struct DetectContext<'a> {
4735 pub locale_chain: &'a [LocaleTag],
4737 pub dictionaries: &'a DictionaryBundle,
4739 pub fields: &'a (),
4741 pub degraded: Cell<bool>,
4743 pub source_spans: Option<&'a [(usize, usize)]>,
4748 pub prior_candidates: Option<&'a [Candidate]>,
4751}
4752
4753impl<'a> DetectContext<'a> {
4754 pub fn new(locale_chain: &'a [LocaleTag], dictionaries: &'a DictionaryBundle) -> Self {
4756 Self {
4757 locale_chain,
4758 dictionaries,
4759 fields: &(),
4760 degraded: Cell::new(false),
4761 source_spans: None,
4762 prior_candidates: None,
4763 }
4764 }
4765
4766 pub fn with_source_spans(mut self, source_spans: &'a [(usize, usize)]) -> Self {
4768 self.source_spans = Some(source_spans);
4769 self
4770 }
4771
4772 pub fn with_prior_candidates(mut self, candidates: &'a [Candidate]) -> Self {
4773 self.prior_candidates = Some(candidates);
4774 self
4775 }
4776}
4777
4778fn ensure_global(tags: &mut Vec<LocaleTag>) {
4779 if !tags.contains(&LocaleTag::Global) {
4780 tags.push(LocaleTag::Global);
4781 }
4782}
4783
4784fn is_bcp47_parseable(raw: &str) -> bool {
4785 let mut parts = raw.split('-');
4786 let Some(language) = parts.next() else {
4787 return false;
4788 };
4789 if !(2..=8).contains(&language.len()) || !language.chars().all(|ch| ch.is_ascii_alphabetic()) {
4790 return false;
4791 }
4792 parts.all(|part| {
4793 (2..=8).contains(&part.len()) && part.chars().all(|ch| ch.is_ascii_alphanumeric())
4794 })
4795}
4796
4797fn canonical_other(raw: &str) -> String {
4798 let mut parts = raw.split('-');
4799 let language = parts.next().unwrap_or_default().to_ascii_lowercase();
4800 let rest = parts.map(|part| {
4801 if part.len() == 2 && part.chars().all(|ch| ch.is_ascii_alphabetic()) {
4802 part.to_ascii_uppercase()
4803 } else {
4804 part.to_ascii_lowercase()
4805 }
4806 });
4807 std::iter::once(language)
4808 .chain(rest)
4809 .collect::<Vec<_>>()
4810 .join("-")
4811}
4812
4813#[cfg(test)]
4814mod emitted_token_origin_tests {
4815 use super::*;
4816
4817 const LEGACY_WHOLE_JSON: &str =
4823 r#"{"clean_span":{"start":0,"end":8},"raw_span":{"start":0,"end":12},"class":"Email"}"#;
4824
4825 #[test]
4826 fn whole_spans_serialize_byte_identically_to_the_pre_origin_snapshot() {
4827 let span = EmittedTokenSpan::new(0..8, 0..12, PiiClass::Email);
4828 let json = serde_json::to_string(&span).unwrap();
4829 assert_eq!(
4830 json, LEGACY_WHOLE_JSON,
4831 "adding `origin` changed the on-disk form of an existing whole span"
4832 );
4833 assert!(!json.contains("origin"));
4834 }
4835
4836 #[test]
4837 fn json_written_before_the_field_existed_reads_back_as_whole() {
4838 let span: EmittedTokenSpan = serde_json::from_str(LEGACY_WHOLE_JSON).unwrap();
4839 assert_eq!(span.origin, EmittedTokenOrigin::Whole);
4840 assert!(span.origin.is_whole());
4841 assert_eq!(span, EmittedTokenSpan::new(0..8, 0..12, PiiClass::Email));
4842 }
4843
4844 #[test]
4845 fn fragments_serialize_explicitly_and_round_trip() {
4846 let fragment = EmittedTokenSpan::residual_fragment(12..18, 27..33, PiiClass::Email);
4847 let json = serde_json::to_string(&fragment).unwrap();
4848 assert!(
4849 json.contains(r#""origin":"residual_fragment""#),
4850 "a fragment must say so on the wire: {json}"
4851 );
4852 assert_eq!(
4853 serde_json::from_str::<EmittedTokenSpan>(&json).unwrap(),
4854 fragment
4855 );
4856 assert!(fragment.origin.is_residual_fragment());
4857 assert!(!fragment.origin.is_whole());
4858 }
4859
4860 #[test]
4861 fn a_fragment_is_never_equal_to_the_same_geometry_as_a_whole() {
4862 let whole = EmittedTokenSpan::new(12..18, 27..33, PiiClass::Email);
4865 let fragment = EmittedTokenSpan::residual_fragment(12..18, 27..33, PiiClass::Email);
4866 assert_ne!(whole, fragment);
4867 }
4868}
4869
4870#[cfg(test)]
4871mod name_edge_tests {
4872 use super::{expand_to_word_edges, extend_over_name_joiners};
4873
4874 fn grown(
4875 text: &str,
4876 part: &str,
4877 grow: fn(&str, std::ops::Range<usize>) -> std::ops::Range<usize>,
4878 ) -> String {
4879 let start = text.find(part).expect("part in text");
4880 text[grow(text, start..start + part.len())].to_string()
4881 }
4882
4883 #[test]
4884 fn word_edges_take_the_whole_word_and_nothing_past_it() {
4885 assert_eq!(
4886 grown(
4887 "ping jorunn vasquez-ellery",
4888 "jorunn vas",
4889 expand_to_word_edges
4890 ),
4891 "jorunn vasquez"
4892 );
4893 assert_eq!(grown("x JORUNN y", "ORUNN", expand_to_word_edges), "JORUNN");
4894 assert_eq!(
4895 grown("Müllerstraße 5", "llerstr", expand_to_word_edges),
4896 "Müllerstraße"
4897 );
4898 assert_eq!(grown("a b", "a", expand_to_word_edges), "a");
4899 assert_eq!(expand_to_word_edges("abc", 5..9), 5..9);
4900 }
4901
4902 #[test]
4903 fn joiners_take_glued_name_parts_on_either_side() {
4904 let grow = extend_over_name_joiners;
4905 assert_eq!(
4906 grown("ping vasquez-ellery now", "vasquez", grow),
4907 "vasquez-ellery"
4908 );
4909 assert_eq!(
4910 grown("ping ellery-vasquez now", "vasquez", grow),
4911 "ellery-vasquez"
4912 );
4913 assert_eq!(
4914 grown("Anna-Lena Kim-Park", "Lena Kim", grow),
4915 "Anna-Lena Kim-Park"
4916 );
4917 assert_eq!(grown("O'Brien said", "Brien", grow), "O'Brien");
4918 assert_eq!(
4919 grown("d\u{2019}Artagnan", "Artagnan", grow),
4920 "d\u{2019}Artagnan"
4921 );
4922 }
4923
4924 #[test]
4925 fn joiners_refuse_possessives_digits_and_bare_joiners() {
4926 let grow = extend_over_name_joiners;
4927 assert_eq!(grown("Vasquez's desk", "Vasquez", grow), "Vasquez");
4928 assert_eq!(grown("Vasquez\u{2019}s desk", "Vasquez", grow), "Vasquez");
4929 assert_eq!(grown("Vasquez-2024", "Vasquez", grow), "Vasquez");
4930 assert_eq!(grown("Vasquez-ab12", "Vasquez", grow), "Vasquez");
4931 assert_eq!(grown("Vasquez - Ellery", "Vasquez", grow), "Vasquez");
4932 assert_eq!(grown("Vasquez-", "Vasquez", grow), "Vasquez");
4933 assert_eq!(grown("1-Vasquez", "Vasquez", grow), "Vasquez");
4934 }
4935}