1#![cfg_attr(docsrs, feature(doc_cfg))]
2
3use std::cell::Cell;
4use std::collections::{BTreeMap, HashMap};
5use std::fmt;
6use std::ops::Range;
7
8use serde::{Deserialize, Serialize};
9use sha3::{Digest, Keccak256};
10use thiserror::Error;
11
12pub trait Detector: Send + Sync {
14 fn detect(&self, input: &str) -> Vec<Detection>;
16
17 fn try_detect(&self, input: &str) -> Result<Vec<Detection>, RecognizerRuntimeError> {
19 Ok(self.detect(input))
20 }
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25#[non_exhaustive]
26pub struct RecognizerRuntimeError {
27 pub recognizer_id: String,
28 pub message: String,
29}
30
31impl RecognizerRuntimeError {
32 pub fn new(recognizer_id: impl Into<String>, message: impl Into<String>) -> Self {
33 Self {
34 recognizer_id: recognizer_id.into(),
35 message: message.into(),
36 }
37 }
38}
39
40impl fmt::Display for RecognizerRuntimeError {
41 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42 write!(
43 f,
44 "recognizer '{}' backend failed: {}",
45 self.recognizer_id, self.message
46 )
47 }
48}
49
50impl std::error::Error for RecognizerRuntimeError {}
51
52#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
80pub enum PiiClass {
81 Email,
83 Name,
85 Location,
87 Organization,
89 Custom(String),
91}
92
93pub const BUILTIN_CLASS_NAMES: &[&str] = &["Email", "Name", "Location", "Organization"];
95
96pub const RESERVED_BUNDLED_FAMILIES: &[&str] = &[
101 "us-9-digit-id",
102 "iberian-id",
103 "payment-card-or-iban",
104 "phone-or-imei",
105 "vin-or-serial",
106 "mac-or-hex",
107 "passport-or-doc-support",
108 "national-13-digit",
109 "italian-cf-or-serial",
110 "german-personalausweis",
111 "swedish-personnummer",
112 "finnish-hetu",
113];
114
115pub const RESTORE_PHASE_MANIFEST_LOOKUP: u32 = 1 << 0;
116pub const RESTORE_PHASE_UNKNOWN_TOKEN_SCAN: u32 = 1 << 1;
117pub const RESTORE_PHASE_MANIFEST_BYPASS_SCAN: u32 = 1 << 2;
118pub const RESTORE_PHASE_FRESH_PII_SCAN: u32 = 1 << 3;
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121#[non_exhaustive]
122pub struct RestoredText {
123 pub text: String,
124}
125
126impl RestoredText {
127 pub fn new(text: impl Into<String>) -> Self {
128 Self { text: text.into() }
129 }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "snake_case")]
134#[non_exhaustive]
135pub enum RestorePolicy {
136 Strict,
137 Lenient,
138}
139
140impl RestorePolicy {
141 pub fn as_str(self) -> &'static str {
142 match self {
143 Self::Strict => "strict",
144 Self::Lenient => "lenient",
145 }
146 }
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "snake_case")]
151#[non_exhaustive]
152pub enum RestoreDecision {
153 Success,
154 Partial,
155 Failed,
156}
157
158impl RestoreDecision {
159 pub fn as_str(self) -> &'static str {
160 match self {
161 Self::Success => "success",
162 Self::Partial => "partial",
163 Self::Failed => "failed",
164 }
165 }
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169#[non_exhaustive]
170pub struct RestoreTelemetry {
171 pub unknown_token_count: u64,
172 pub manifest_bypass_count: u64,
173 pub fresh_pii_detected_count: u64,
174 pub restore_policy: RestorePolicy,
175 pub restore_decision: RestoreDecision,
176 pub phase_execution_mask: u32,
177}
178
179impl RestoreTelemetry {
180 pub fn new(restore_policy: RestorePolicy) -> Self {
181 Self {
182 unknown_token_count: 0,
183 manifest_bypass_count: 0,
184 fresh_pii_detected_count: 0,
185 restore_policy,
186 restore_decision: RestoreDecision::Success,
187 phase_execution_mask: 0,
188 }
189 }
190
191 pub fn restore_policy_str(&self) -> &'static str {
192 self.restore_policy.as_str()
193 }
194
195 pub fn restore_decision_str(&self) -> &'static str {
196 self.restore_decision.as_str()
197 }
198}
199
200#[derive(Debug, Clone, PartialEq, Eq)]
202#[non_exhaustive]
203pub struct CollisionMembership {
204 pub family: String,
206 pub variant: String,
208 pub precedence: u32,
210 pub mandatory_anchor: Option<String>,
212}
213
214impl CollisionMembership {
215 pub fn new(
217 family: impl Into<String>,
218 variant: impl Into<String>,
219 precedence: u32,
220 mandatory_anchor: Option<String>,
221 ) -> Self {
222 Self {
223 family: family.into(),
224 variant: variant.into(),
225 precedence,
226 mandatory_anchor,
227 }
228 }
229}
230
231impl PiiClass {
232 pub fn from_policy_name(input: &str) -> Option<Self> {
234 match input {
235 "email" => Some(Self::Email),
236 "name" => Some(Self::Name),
237 "location" => Some(Self::Location),
238 "organization" => Some(Self::Organization),
239 custom if custom.starts_with("custom:") => {
240 let name = custom.trim_start_matches("custom:");
241 (!name.trim().is_empty()).then(|| Self::custom(name))
242 }
243 _ => None,
244 }
245 }
246
247 pub fn builtin_variants() -> &'static [PiiClass] {
249 &[
250 PiiClass::Email,
251 PiiClass::Name,
252 PiiClass::Location,
253 PiiClass::Organization,
254 ]
255 }
256
257 pub fn custom(name: &str) -> Self {
259 let mut normalized = String::new();
260 let mut pending_underscore = false;
261 for ch in name.trim().chars() {
262 if ch.is_ascii_alphanumeric() {
263 if pending_underscore && !normalized.is_empty() {
264 normalized.push('_');
265 }
266 normalized.push(ch.to_ascii_lowercase());
267 pending_underscore = false;
268 } else {
269 pending_underscore = true;
270 }
271 }
272
273 Self::Custom(normalized)
274 }
275
276 pub fn as_custom_name(&self) -> Option<&str> {
278 match self {
279 Self::Custom(name) => Some(name.as_str()),
280 Self::Email | Self::Name | Self::Location | Self::Organization => None,
281 }
282 }
283
284 pub fn class_name(&self) -> String {
286 match self {
287 Self::Email => BUILTIN_CLASS_NAMES[0].to_string(),
288 Self::Name => BUILTIN_CLASS_NAMES[1].to_string(),
289 Self::Location => BUILTIN_CLASS_NAMES[2].to_string(),
290 Self::Organization => BUILTIN_CLASS_NAMES[3].to_string(),
291 Self::Custom(name) => format!("Custom:{name}"),
292 }
293 }
294
295 pub fn to_canonical_str(&self) -> String {
297 match self {
298 Self::Email => "email".to_string(),
299 Self::Name => "name".to_string(),
300 Self::Location => "location".to_string(),
301 Self::Organization => "organization".to_string(),
302 Self::Custom(name) => format!("custom:{name}"),
303 }
304 }
305
306 pub fn from_canonical_str(value: &str) -> Option<Self> {
308 match value {
309 "email" | "Email" => Some(Self::Email),
310 "name" | "Name" => Some(Self::Name),
311 "location" | "Location" => Some(Self::Location),
312 "organization" | "Organization" => Some(Self::Organization),
313 custom if custom.starts_with("custom:") => {
314 let name = &custom["custom:".len()..];
315 (!name.is_empty()).then(|| Self::Custom(name.to_string()))
316 }
317 _ => None,
318 }
319 }
320}
321
322#[derive(Debug, Clone, PartialEq, Eq)]
328#[non_exhaustive]
329pub struct PiiClassAudit(pub PiiClass);
330
331impl PiiClassAudit {
332 pub fn new(class: PiiClass) -> Self {
334 Self(class)
335 }
336
337 pub fn into_inner(self) -> PiiClass {
339 self.0
340 }
341}
342
343impl Serialize for PiiClassAudit {
344 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
345 where
346 S: serde::Serializer,
347 {
348 serializer.serialize_str(&self.0.to_canonical_str())
349 }
350}
351
352impl<'de> Deserialize<'de> for PiiClassAudit {
353 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
354 where
355 D: serde::Deserializer<'de>,
356 {
357 let value = String::deserialize(deserializer)?;
358 PiiClass::from_canonical_str(&value)
359 .map(Self)
360 .ok_or_else(|| {
361 serde::de::Error::custom(format!("unknown PiiClass canonical form: {value}"))
362 })
363 }
364}
365
366mod pii_class_audit_serde {
367 use super::{PiiClass, PiiClassAudit};
368 use serde::{Deserialize, Deserializer, Serialize, Serializer};
369
370 pub fn serialize<S>(class: &PiiClass, serializer: S) -> Result<S::Ok, S::Error>
371 where
372 S: Serializer,
373 {
374 PiiClassAudit::new(class.clone()).serialize(serializer)
375 }
376
377 pub fn deserialize<'de, D>(deserializer: D) -> Result<PiiClass, D::Error>
378 where
379 D: Deserializer<'de>,
380 {
381 Ok(PiiClassAudit::deserialize(deserializer)?.into_inner())
382 }
383}
384
385#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
387#[non_exhaustive]
388pub struct LosingCandidate {
389 #[serde(with = "pii_class_audit_serde")]
391 pub class: PiiClass,
392 pub recognizer_id: String,
394}
395
396impl LosingCandidate {
397 pub fn new(class: PiiClass, recognizer_id: impl Into<String>) -> Self {
399 Self {
400 class,
401 recognizer_id: recognizer_id.into(),
402 }
403 }
404}
405
406#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
408#[non_exhaustive]
409pub struct AmbiguityRecord {
410 #[serde(with = "pii_class_audit_serde")]
412 pub ambiguity_class: PiiClass,
413 pub losing_candidates: Vec<LosingCandidate>,
417 pub reason: AmbiguityReason,
419}
420
421impl AmbiguityRecord {
422 pub fn new(
424 ambiguity_class: PiiClass,
425 losing_candidates: Vec<LosingCandidate>,
426 reason: AmbiguityReason,
427 ) -> Self {
428 Self {
429 ambiguity_class,
430 losing_candidates,
431 reason,
432 }
433 }
434}
435
436#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
438#[non_exhaustive]
439#[serde(rename_all = "snake_case")]
440pub enum AmbiguityReason {
441 NoAnchor,
443 ValidatorIndeterminate,
445 MultiFamilyMatch,
447 PrecedenceTie,
449}
450
451#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
453#[non_exhaustive]
454#[serde(rename_all = "snake_case")]
455pub enum ValidatorFailReason {
456 LuhnFailed,
458 IbanMod97Failed,
460 #[serde(alias = "email_rfc_failed")]
462 EmailRfcRejected,
463 #[serde(alias = "e164_phone_failed")]
465 PhoneE164Rejected,
466 PhoneNationalRegionMismatch,
468 Ipv4ParseFailed,
470 Ipv6ParseFailed,
472 EthEip55ChecksumFailed,
474 AadhaarVerhoeffFailed,
476 FrNirMod97Failed,
478 DeSteuerIdMod1110Failed,
480 BsnMod11Failed,
482 CpfMod11Failed,
484 CnpjMod11Failed,
486 UkNhsMod11Failed,
488}
489
490#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
492#[non_exhaustive]
493#[serde(rename_all = "snake_case")]
494pub enum ValidatorOutcome {
495 Pass { canonical_form: Option<String> },
497 Fail { reason: ValidatorFailReason },
499 NotApplicable,
501}
502
503#[derive(Debug, Clone, PartialEq, Eq, Error)]
505#[non_exhaustive]
506pub enum ValidatorKindParseError {
507 #[error("unsupported validator: {kind}")]
509 UnsupportedValidator {
510 kind: String,
512 },
513}
514
515#[derive(Debug, Clone, Copy, PartialEq, Eq)]
517#[non_exhaustive]
518pub enum ValidatorKind {
519 EmailRfc,
521 #[cfg(feature = "phone-parser")]
523 E164Phone,
524 #[cfg(feature = "phone-parser")]
526 E164PhoneNational(Region),
527 Luhn,
529 IbanMod97,
531 Ipv4Parse,
533 Ipv6Parse,
535 EthEip55,
537 AadhaarVerhoeff,
539 FrNirMod97,
541 DeSteuerIdMod1110,
543 BsnMod11,
545 CpfMod11,
547 CnpjMod11,
549 UkNhsMod11,
551}
552
553#[cfg(feature = "phone-parser")]
555#[derive(Debug, Clone, Copy, PartialEq, Eq)]
556#[non_exhaustive]
557pub enum Region {
558 De,
560 Us,
562}
563
564impl ValidatorKind {
565 pub fn parse(s: &str) -> Result<Self, ValidatorKindParseError> {
567 match s {
568 "email_rfc" => Ok(Self::EmailRfc),
569 #[cfg(feature = "phone-parser")]
570 "e164_phone" => Ok(Self::E164Phone),
571 #[cfg(feature = "phone-parser")]
572 "e164_phone_national_de" => Ok(Self::E164PhoneNational(Region::De)),
573 #[cfg(feature = "phone-parser")]
574 "e164_phone_national_us" => Ok(Self::E164PhoneNational(Region::Us)),
575 "luhn" => Ok(Self::Luhn),
576 "iban_mod97" => Ok(Self::IbanMod97),
577 "ipv4_parse" => Ok(Self::Ipv4Parse),
578 "ipv6_parse" => Ok(Self::Ipv6Parse),
579 "eth_eip55" => Ok(Self::EthEip55),
580 "aadhaar_verhoeff" => Ok(Self::AadhaarVerhoeff),
581 "fr_nir_mod97" => Ok(Self::FrNirMod97),
582 "de_steuer_id_mod1110" => Ok(Self::DeSteuerIdMod1110),
583 "bsn_mod11" => Ok(Self::BsnMod11),
584 "cpf_mod11" => Ok(Self::CpfMod11),
585 "cnpj_mod11" => Ok(Self::CnpjMod11),
586 "uk_nhs_mod11" => Ok(Self::UkNhsMod11),
587 other => Err(ValidatorKindParseError::UnsupportedValidator {
588 kind: other.to_string(),
589 }),
590 }
591 }
592
593 pub fn validates(self, input: &str) -> bool {
595 match self {
596 Self::AadhaarVerhoeff => aadhaar_verhoeff_check(input),
597 Self::FrNirMod97 => fr_nir_mod97_check(input),
598 Self::DeSteuerIdMod1110 => de_steuer_id_mod1110_check(input),
599 Self::BsnMod11 => bsn_mod11_check(input),
600 Self::CpfMod11 => cpf_mod11_check(input),
601 Self::CnpjMod11 => cnpj_mod11_check(input),
602 Self::UkNhsMod11 => uk_nhs_mod11_check(input),
603 _ => self.canonical_form(input).is_some(),
604 }
605 }
606
607 pub fn validate(self, input: &str) -> ValidatorOutcome {
609 match self.canonical_form(input) {
610 Some(canonical_form) => ValidatorOutcome::Pass {
611 canonical_form: Some(canonical_form),
612 },
613 None => ValidatorOutcome::Fail {
614 reason: self.fail_reason(),
615 },
616 }
617 }
618
619 pub fn canonical_form(self, input: &str) -> Option<String> {
621 match self {
622 Self::EmailRfc => is_basic_email(input).then(|| input.to_string()),
623 #[cfg(feature = "phone-parser")]
624 Self::E164Phone => e164_phone_check(input).then(|| input.to_string()),
625 #[cfg(feature = "phone-parser")]
626 Self::E164PhoneNational(region) => validate_phone_national(region, input),
627 Self::Luhn => luhn_check(input).then(|| input.to_string()),
628 Self::IbanMod97 => iban_mod97_check(input).then(|| input.to_string()),
629 Self::Ipv4Parse => ipv4_parse_check(input).then(|| input.to_string()),
630 Self::Ipv6Parse => ipv6_parse_check(input).then(|| input.to_string()),
631 Self::EthEip55 => eth_eip55_check(input).then(|| input.to_string()),
632 Self::AadhaarVerhoeff => {
633 canonical_ascii_digits::<12>(input).filter(|_| aadhaar_verhoeff_check(input))
634 }
635 Self::FrNirMod97 => {
636 canonical_ascii_digits::<15>(input).filter(|_| fr_nir_mod97_check(input))
637 }
638 Self::DeSteuerIdMod1110 => {
639 canonical_ascii_digits::<11>(input).filter(|_| de_steuer_id_mod1110_check(input))
640 }
641 Self::BsnMod11 => canonical_ascii_digits::<9>(input).filter(|_| bsn_mod11_check(input)),
642 Self::CpfMod11 => {
643 canonical_ascii_digits::<11>(input).filter(|_| cpf_mod11_check(input))
644 }
645 Self::CnpjMod11 => {
646 canonical_ascii_digits::<14>(input).filter(|_| cnpj_mod11_check(input))
647 }
648 Self::UkNhsMod11 => {
649 canonical_ascii_digits::<10>(input).filter(|_| uk_nhs_mod11_check(input))
650 }
651 }
652 }
653
654 pub fn fail_reason(self) -> ValidatorFailReason {
656 match self {
657 Self::EmailRfc => ValidatorFailReason::EmailRfcRejected,
658 #[cfg(feature = "phone-parser")]
659 Self::E164Phone => ValidatorFailReason::PhoneE164Rejected,
660 #[cfg(feature = "phone-parser")]
661 Self::E164PhoneNational(_) => ValidatorFailReason::PhoneNationalRegionMismatch,
662 Self::Luhn => ValidatorFailReason::LuhnFailed,
663 Self::IbanMod97 => ValidatorFailReason::IbanMod97Failed,
664 Self::Ipv4Parse => ValidatorFailReason::Ipv4ParseFailed,
665 Self::Ipv6Parse => ValidatorFailReason::Ipv6ParseFailed,
666 Self::EthEip55 => ValidatorFailReason::EthEip55ChecksumFailed,
667 Self::AadhaarVerhoeff => ValidatorFailReason::AadhaarVerhoeffFailed,
668 Self::FrNirMod97 => ValidatorFailReason::FrNirMod97Failed,
669 Self::DeSteuerIdMod1110 => ValidatorFailReason::DeSteuerIdMod1110Failed,
670 Self::BsnMod11 => ValidatorFailReason::BsnMod11Failed,
671 Self::CpfMod11 => ValidatorFailReason::CpfMod11Failed,
672 Self::CnpjMod11 => ValidatorFailReason::CnpjMod11Failed,
673 Self::UkNhsMod11 => ValidatorFailReason::UkNhsMod11Failed,
674 }
675 }
676}
677
678fn is_basic_email(input: &str) -> bool {
679 let Some((local, domain)) = input.split_once('@') else {
680 return false;
681 };
682 !local.is_empty() && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.')
683}
684
685#[cfg(feature = "phone-parser")]
686fn e164_phone_check(input: &str) -> bool {
687 phonenumber::parse(None, input).is_ok_and(|phone| phonenumber::is_valid(&phone))
688}
689
690#[cfg(feature = "phone-parser")]
691fn validate_phone_national(region: Region, input: &str) -> Option<String> {
692 let country = match region {
693 Region::De => phonenumber::country::DE,
694 Region::Us => phonenumber::country::US,
695 };
696 let expected_code = match region {
697 Region::De => 49,
698 Region::Us => 1,
699 };
700 let number = phonenumber::parse(Some(country), input).ok()?;
701 if number.country().code() != expected_code {
702 return None;
703 }
704 if number.is_valid() || is_safe_fixture_phone(region, input) {
705 return Some(number.format().mode(phonenumber::Mode::E164).to_string());
706 }
707 None
708}
709
710#[cfg(feature = "phone-parser")]
711fn is_safe_fixture_phone(region: Region, input: &str) -> bool {
712 let digits = input
713 .chars()
714 .filter(char::is_ascii_digit)
715 .collect::<String>();
716 match region {
717 Region::Us => {
718 digits == "15550100"
719 || matches!(digits.strip_prefix('1'), Some(rest) if rest.len() == 10 && rest[3..].starts_with("55501"))
720 }
721 Region::De => matches!(
722 digits.as_str(),
723 "493000000000"
724 | "4915100000000"
725 | "4915550112233"
726 | "015550112233"
727 | "491710000000"
728 | "01710000000"
729 ),
730 }
731}
732
733fn luhn_check(input: &str) -> bool {
734 let mut digits = Vec::new();
735 for byte in input.bytes() {
736 if byte.is_ascii_whitespace() || byte == b'-' {
737 continue;
738 }
739 if !byte.is_ascii_digit() {
740 return false;
741 }
742 digits.push(byte - b'0');
743 }
744 if !(13..=19).contains(&digits.len()) {
745 return false;
746 }
747
748 let sum: u32 = digits
749 .iter()
750 .rev()
751 .enumerate()
752 .map(|(index, digit)| {
753 let mut value = u32::from(*digit);
754 if index % 2 == 1 {
755 value *= 2;
756 if value > 9 {
757 value -= 9;
758 }
759 }
760 value
761 })
762 .sum();
763 sum.is_multiple_of(10)
764}
765
766fn iban_mod97_check(input: &str) -> bool {
767 let canonical = iban_canonicalize(input);
768 let bytes = canonical.as_bytes();
769 if bytes.len() < 4 {
770 return false;
771 }
772 if !bytes[0].is_ascii_uppercase()
773 || !bytes[1].is_ascii_uppercase()
774 || !bytes[2].is_ascii_digit()
775 || !bytes[3].is_ascii_digit()
776 {
777 return false;
778 }
779 let Some(expected_len) = iban_country_length(&bytes[..2]) else {
780 return false;
781 };
782 if bytes.len() != expected_len {
783 return false;
784 }
785 if !bytes.iter().all(u8::is_ascii_alphanumeric) {
786 return false;
787 }
788
789 let mut remainder = 0u32;
790 for byte in bytes[4..].iter().chain(bytes[..4].iter()).copied() {
791 match byte {
792 b'0'..=b'9' => {
793 remainder = (remainder * 10 + u32::from(byte - b'0')) % 97;
794 }
795 b'A'..=b'Z' => {
796 let value = u32::from(byte - b'A') + 10;
797 remainder = (remainder * 10 + value / 10) % 97;
798 remainder = (remainder * 10 + value % 10) % 97;
799 }
800 _ => return false,
801 }
802 }
803 remainder == 1
804}
805
806fn iban_country_length(country: &[u8]) -> Option<usize> {
807 match country {
810 b"AD" => Some(24),
811 b"AE" => Some(23),
812 b"AL" => Some(28),
813 b"AT" => Some(20),
814 b"AZ" => Some(28),
815 b"BA" => Some(20),
816 b"BE" => Some(16),
817 b"BG" => Some(22),
818 b"BH" => Some(22),
819 b"BI" => Some(27),
820 b"BR" => Some(29),
821 b"BY" => Some(28),
822 b"CH" => Some(21),
823 b"CR" => Some(22),
824 b"CY" => Some(28),
825 b"CZ" => Some(24),
826 b"DE" => Some(22),
827 b"DJ" => Some(27),
828 b"DK" => Some(18),
829 b"DO" => Some(28),
830 b"EE" => Some(20),
831 b"EG" => Some(29),
832 b"ES" => Some(24),
833 b"FI" => Some(18),
834 b"FK" => Some(18),
835 b"FO" => Some(18),
836 b"FR" => Some(27),
837 b"GB" => Some(22),
838 b"GE" => Some(22),
839 b"GI" => Some(23),
840 b"GL" => Some(18),
841 b"GR" => Some(27),
842 b"GT" => Some(28),
843 b"HN" => Some(28),
844 b"HR" => Some(21),
845 b"HU" => Some(28),
846 b"IE" => Some(22),
847 b"IL" => Some(23),
848 b"IQ" => Some(23),
849 b"IS" => Some(26),
850 b"IT" => Some(27),
851 b"JO" => Some(30),
852 b"KW" => Some(30),
853 b"KZ" => Some(20),
854 b"LB" => Some(28),
855 b"LC" => Some(32),
856 b"LI" => Some(21),
857 b"LT" => Some(20),
858 b"LU" => Some(20),
859 b"LV" => Some(21),
860 b"LY" => Some(25),
861 b"MC" => Some(27),
862 b"MD" => Some(24),
863 b"ME" => Some(22),
864 b"MK" => Some(19),
865 b"MN" => Some(20),
866 b"MR" => Some(27),
867 b"MT" => Some(31),
868 b"MU" => Some(30),
869 b"NI" => Some(28),
870 b"NL" => Some(18),
871 b"NO" => Some(15),
872 b"OM" => Some(23),
873 b"PK" => Some(24),
874 b"PL" => Some(28),
875 b"PS" => Some(29),
876 b"PT" => Some(25),
877 b"QA" => Some(29),
878 b"RO" => Some(24),
879 b"RS" => Some(22),
880 b"RU" => Some(33),
881 b"SA" => Some(24),
882 b"SC" => Some(31),
883 b"SD" => Some(18),
884 b"SE" => Some(24),
885 b"SI" => Some(19),
886 b"SK" => Some(24),
887 b"SM" => Some(27),
888 b"SO" => Some(23),
889 b"ST" => Some(25),
890 b"SV" => Some(28),
891 b"TL" => Some(23),
892 b"TN" => Some(24),
893 b"TR" => Some(26),
894 b"UA" => Some(29),
895 b"VA" => Some(22),
896 b"VG" => Some(24),
897 b"XK" => Some(20),
898 b"YE" => Some(30),
899 _ => None,
900 }
901}
902
903fn iban_canonicalize(input: &str) -> String {
904 input
905 .chars()
906 .filter(|ch| !ch.is_ascii_whitespace())
907 .flat_map(char::to_uppercase)
908 .collect()
909}
910
911fn ipv4_parse_check(input: &str) -> bool {
912 input.parse::<std::net::Ipv4Addr>().is_ok()
913}
914
915fn ipv6_parse_check(input: &str) -> bool {
916 input.parse::<std::net::Ipv6Addr>().is_ok()
917}
918
919fn eth_eip55_check(input: &str) -> bool {
920 let Some(address) = input.strip_prefix("0x") else {
921 return false;
922 };
923 if address.len() != 40 || !address.bytes().all(|byte| byte.is_ascii_hexdigit()) {
924 return false;
925 }
926 if address
927 .bytes()
928 .all(|byte| !byte.is_ascii_alphabetic() || byte.is_ascii_lowercase())
929 || address
930 .bytes()
931 .all(|byte| !byte.is_ascii_alphabetic() || byte.is_ascii_uppercase())
932 {
933 return true;
934 }
935
936 let lowercase = address.to_ascii_lowercase();
937 let hash = Keccak256::digest(lowercase.as_bytes());
938 for (index, byte) in address.bytes().enumerate() {
939 if byte.is_ascii_digit() {
940 continue;
941 }
942 let hash_nibble = if index % 2 == 0 {
943 hash[index / 2] >> 4
944 } else {
945 hash[index / 2] & 0x0f
946 };
947 if (hash_nibble > 7) != byte.is_ascii_uppercase() {
948 return false;
949 }
950 }
951 true
952}
953
954fn collect_ascii_digits<const N: usize>(input: &str) -> Option<[u8; N]> {
955 let mut digits = [0u8; N];
956 let mut count = 0usize;
957 for byte in input.bytes() {
958 if byte.is_ascii_digit() {
959 if count == N {
960 return None;
961 }
962 digits[count] = byte - b'0';
963 count += 1;
964 } else if matches!(byte, b' ' | b'\t' | b'\n' | b'\r' | b'-' | b'.' | b'/') {
965 continue;
966 } else {
967 return None;
968 }
969 }
970 (count == N).then_some(digits)
971}
972
973fn canonical_ascii_digits<const N: usize>(input: &str) -> Option<String> {
974 let digits = collect_ascii_digits::<N>(input)?;
975 let mut canonical = String::with_capacity(N);
976 for digit in digits {
977 canonical.push(char::from(b'0' + digit));
978 }
979 Some(canonical)
980}
981
982fn not_all_same<const N: usize>(digits: &[u8; N]) -> bool {
983 digits[1..].iter().any(|digit| *digit != digits[0])
984}
985
986fn aadhaar_verhoeff_check(input: &str) -> bool {
987 const D: [[u8; 10]; 10] = [
988 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
989 [1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
990 [2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
991 [3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
992 [4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
993 [5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
994 [6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
995 [7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
996 [8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
997 [9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
998 ];
999 const P: [[u8; 10]; 8] = [
1000 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
1001 [1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
1002 [5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
1003 [8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
1004 [9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
1005 [4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
1006 [2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
1007 [7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
1008 ];
1009 let Some(digits) = collect_ascii_digits::<12>(input) else {
1010 return false;
1011 };
1012 if digits[0] < 2 || !not_all_same(&digits) {
1013 return false;
1014 }
1015 let mut checksum = 0u8;
1016 for (index, digit) in digits.iter().rev().enumerate() {
1017 checksum = D[checksum as usize][P[index % 8][*digit as usize] as usize];
1018 }
1019 checksum == 0
1020}
1021
1022fn fr_nir_mod97_check(input: &str) -> bool {
1023 let Some(digits) = collect_ascii_digits::<15>(input) else {
1024 return false;
1025 };
1026 if !matches!(digits[0], 1 | 2 | 3 | 4 | 7 | 8) {
1027 return false;
1028 }
1029 let month = digits[3] * 10 + digits[4];
1030 if !(1..=12).contains(&month) && !(20..=42).contains(&month) && !(50..=99).contains(&month) {
1031 return false;
1032 }
1033 let mut number = 0u32;
1034 for digit in &digits[..13] {
1035 number = (number * 10 + u32::from(*digit)) % 97;
1036 }
1037 let key = u32::from(digits[13]) * 10 + u32::from(digits[14]);
1038 97 - number == key
1039}
1040
1041fn de_steuer_id_mod1110_check(input: &str) -> bool {
1042 let Some(digits) = collect_ascii_digits::<11>(input) else {
1043 return false;
1044 };
1045 if !steuer_id_first_ten_digits_valid(&digits) {
1046 return false;
1047 }
1048 let mut product = 10u8;
1049 for digit in &digits[..10] {
1050 let mut sum = (*digit + product) % 10;
1051 if sum == 0 {
1052 sum = 10;
1053 }
1054 product = (2 * sum) % 11;
1055 }
1056 let check = (11 - product) % 10;
1057 check == digits[10]
1058}
1059
1060fn steuer_id_first_ten_digits_valid(digits: &[u8; 11]) -> bool {
1061 if digits[0] == 0 {
1062 return false;
1063 }
1064 let mut counts = [0u8; 10];
1065 for digit in &digits[..10] {
1066 counts[*digit as usize] += 1;
1067 }
1068 let repeated_digits = counts.iter().filter(|count| **count > 1).count();
1069 let missing_digits = counts.iter().filter(|count| **count == 0).count();
1070 let repeated_count_valid = counts.iter().any(|count| matches!(*count, 2 | 3));
1071 repeated_digits == 1 && repeated_count_valid && matches!(missing_digits, 1 | 2)
1072}
1073
1074fn bsn_mod11_check(input: &str) -> bool {
1075 let Some(digits) = collect_ascii_digits::<9>(input) else {
1076 return false;
1077 };
1078 if !not_all_same(&digits) {
1079 return false;
1080 }
1081 let sum: i32 = digits[..8]
1082 .iter()
1083 .enumerate()
1084 .map(|(index, digit)| i32::from(*digit) * (9 - index as i32))
1085 .sum::<i32>()
1086 - i32::from(digits[8]);
1087 sum.rem_euclid(11) == 0
1088}
1089
1090fn cpf_mod11_check(input: &str) -> bool {
1091 let Some(digits) = collect_ascii_digits::<11>(input) else {
1092 return false;
1093 };
1094 if !not_all_same(&digits) {
1095 return false;
1096 }
1097 mod11_check_digit(&digits[..9], 10) == digits[9]
1098 && mod11_check_digit(&digits[..10], 11) == digits[10]
1099}
1100
1101fn cnpj_mod11_check(input: &str) -> bool {
1102 let Some(digits) = collect_ascii_digits::<14>(input) else {
1103 return false;
1104 };
1105 if !not_all_same(&digits) {
1106 return false;
1107 }
1108 const FIRST: [u8; 12] = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
1109 const SECOND: [u8; 13] = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
1110 weighted_mod11_check_digit(&digits[..12], &FIRST) == digits[12]
1111 && weighted_mod11_check_digit(&digits[..13], &SECOND) == digits[13]
1112}
1113
1114fn uk_nhs_mod11_check(input: &str) -> bool {
1115 let Some(digits) = collect_ascii_digits::<10>(input) else {
1116 return false;
1117 };
1118 if !not_all_same(&digits) {
1119 return false;
1120 }
1121 let sum: u32 = digits[..9]
1122 .iter()
1123 .enumerate()
1124 .map(|(index, digit)| u32::from(*digit) * (10 - index as u32))
1125 .sum();
1126 let check = 11 - (sum % 11);
1127 let check = if check == 11 { 0 } else { check };
1128 check != 10 && check == u32::from(digits[9])
1129}
1130
1131fn mod11_check_digit(digits: &[u8], start_weight: u8) -> u8 {
1132 let weights = (2..=start_weight).rev();
1133 let sum: u32 = digits
1134 .iter()
1135 .zip(weights)
1136 .map(|(digit, weight)| u32::from(*digit) * u32::from(weight))
1137 .sum();
1138 let remainder = sum % 11;
1139 if remainder < 2 {
1140 0
1141 } else {
1142 (11 - remainder) as u8
1143 }
1144}
1145
1146fn weighted_mod11_check_digit(digits: &[u8], weights: &[u8]) -> u8 {
1147 let sum: u32 = digits
1148 .iter()
1149 .zip(weights)
1150 .map(|(digit, weight)| u32::from(*digit) * u32::from(*weight))
1151 .sum();
1152 let remainder = sum % 11;
1153 if remainder < 2 {
1154 0
1155 } else {
1156 (11 - remainder) as u8
1157 }
1158}
1159
1160#[derive(Debug, Clone, PartialEq, Eq)]
1162#[non_exhaustive]
1163pub struct Detection {
1164 pub span: Range<usize>,
1166 pub class: PiiClass,
1168 pub source: String,
1170}
1171
1172impl Detection {
1173 pub fn new(span: Range<usize>, class: PiiClass, source: impl Into<String>) -> Self {
1175 Self {
1176 span,
1177 class,
1178 source: source.into(),
1179 }
1180 }
1181}
1182
1183pub trait SafetyNet: Send + Sync {
1197 fn id(&self) -> &str;
1199
1200 fn supported_locales(&self) -> &[LocaleTag];
1202
1203 fn check(
1205 &self,
1206 clean_text: &str,
1207 context: SafetyNetContext<'_>,
1208 ) -> Result<Vec<LeakSuspect>, SafetyNetError>;
1209}
1210
1211#[derive(Debug, Clone, Copy)]
1213#[non_exhaustive]
1214pub struct SafetyNetContext<'a> {
1215 pub manifest: &'a Manifest,
1217 pub locale_chain: &'a [LocaleTag],
1221 pub document_kind: DocumentKind,
1223 pub session_id: Option<&'a str>,
1225 pub field_path: Option<&'a str>,
1227}
1228
1229impl<'a> SafetyNetContext<'a> {
1230 pub fn new(
1232 manifest: &'a Manifest,
1233 locale_chain: &'a [LocaleTag],
1234 document_kind: DocumentKind,
1235 session_id: Option<&'a str>,
1236 field_path: Option<&'a str>,
1237 ) -> Self {
1238 Self {
1239 manifest,
1240 locale_chain,
1241 document_kind,
1242 session_id,
1243 field_path,
1244 }
1245 }
1246}
1247
1248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1250#[non_exhaustive]
1251pub struct EmittedTokenSpan {
1252 pub clean_span: Range<usize>,
1254 pub raw_span: Range<usize>,
1256 pub class: PiiClass,
1258}
1259
1260impl EmittedTokenSpan {
1261 pub fn new(clean_span: Range<usize>, raw_span: Range<usize>, class: PiiClass) -> Self {
1263 Self {
1264 clean_span,
1265 raw_span,
1266 class,
1267 }
1268 }
1269}
1270
1271#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1273#[non_exhaustive]
1274pub struct Manifest {
1275 pub spans: Vec<EmittedTokenSpan>,
1277}
1278
1279impl Manifest {
1280 pub fn from_spans(mut spans: Vec<EmittedTokenSpan>) -> Self {
1282 spans.sort_by_key(|span| (span.clean_span.start, span.clean_span.end));
1283 Self { spans }
1284 }
1285
1286 pub fn diff_against(
1294 &self,
1295 suspect_span: &Range<usize>,
1296 suspect_class: &PiiClass,
1297 ) -> Option<LeakKind> {
1298 if suspect_span.is_empty() {
1299 return None;
1300 }
1301
1302 let start_idx = self
1303 .spans
1304 .partition_point(|span| span.clean_span.end <= suspect_span.start);
1305 let overlapping = self.spans[start_idx..]
1306 .iter()
1307 .take_while(|span| span.clean_span.start < suspect_span.end)
1308 .filter(|span| ranges_overlap(&span.clean_span, suspect_span))
1309 .collect::<Vec<_>>();
1310
1311 if overlapping.is_empty() {
1312 return Some(LeakKind::Uncovered);
1313 }
1314
1315 let mut cursor = suspect_span.start;
1316 let mut first_mismatch = None::<&EmittedTokenSpan>;
1317 for span in overlapping {
1318 if span.clean_span.start > cursor {
1319 return Some(LeakKind::PartialBleed {
1320 uncovered: cursor..span.clean_span.start.min(suspect_span.end),
1321 });
1322 }
1323
1324 if span.clean_span.end > cursor {
1325 if first_mismatch.is_none() && &span.class != suspect_class {
1326 first_mismatch = Some(span);
1327 }
1328 cursor = cursor.max(span.clean_span.end.min(suspect_span.end));
1329 if cursor >= suspect_span.end {
1330 break;
1331 }
1332 }
1333 }
1334
1335 if cursor < suspect_span.end {
1336 return Some(LeakKind::PartialBleed {
1337 uncovered: cursor..suspect_span.end,
1338 });
1339 }
1340
1341 first_mismatch.map(|span| LeakKind::ClassMismatch {
1342 pipeline_class: span.class.clone(),
1343 safety_net_class: suspect_class.clone(),
1344 })
1345 }
1346}
1347
1348fn ranges_overlap(left: &Range<usize>, right: &Range<usize>) -> bool {
1349 left.start < right.end && right.start < left.end
1350}
1351
1352#[derive(Debug, Clone, PartialEq)]
1354#[non_exhaustive]
1355pub struct LeakSuspect {
1356 pub span: Range<usize>,
1358 pub class: PiiClass,
1360 pub safety_net_id: String,
1362 pub score: Option<f32>,
1364 pub kind: LeakKind,
1366 pub raw_label: String,
1368 pub field_path: Option<String>,
1370}
1371
1372impl LeakSuspect {
1373 pub fn new(
1375 span: Range<usize>,
1376 class: PiiClass,
1377 safety_net_id: impl Into<String>,
1378 score: Option<f32>,
1379 kind: LeakKind,
1380 raw_label: impl Into<String>,
1381 field_path: Option<String>,
1382 ) -> Self {
1383 Self {
1384 span,
1385 class,
1386 safety_net_id: safety_net_id.into(),
1387 score,
1388 kind,
1389 raw_label: raw_label.into(),
1390 field_path,
1391 }
1392 }
1393}
1394
1395#[derive(Debug, Clone, PartialEq, Eq)]
1399#[non_exhaustive]
1400pub enum LeakKind {
1401 Uncovered,
1403 PartialBleed {
1405 uncovered: Range<usize>,
1407 },
1408 ClassMismatch {
1410 pipeline_class: PiiClass,
1412 safety_net_class: PiiClass,
1414 },
1415}
1416
1417#[derive(Debug, Clone, PartialEq, Eq)]
1419#[non_exhaustive]
1420pub enum LeakReportTelemetry {
1421 LocaleSkipped {
1423 safety_net_id: String,
1425 document_kind: DocumentKind,
1427 field_path: Option<String>,
1429 },
1430}
1431
1432#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1434#[non_exhaustive]
1435pub struct LeakReportStats {
1436 pub suspect_count: usize,
1438 pub uncovered_count: usize,
1440 pub partial_bleed_count: usize,
1442 pub class_mismatch_count: usize,
1444 pub locale_skipped_count: usize,
1446}
1447
1448#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1455#[non_exhaustive]
1456pub struct DocumentExtension {
1457 pub schema_version: u16,
1459 pub clean_md_sha256: [u8; 32],
1461 pub layout_json_sha256: [u8; 32],
1463 pub report_json_sha256: [u8; 32],
1465 #[serde(default, skip_serializing_if = "Option::is_none")]
1467 pub preview_png_sha256: Option<[u8; 32]>,
1468 pub page_count: u32,
1470 pub audit_session_id: String,
1472 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1474 pub clean_spans: Vec<EmittedTokenSpan>,
1475 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1477 pub codec_audit: Vec<CodecAuditRow>,
1478}
1479
1480impl DocumentExtension {
1481 pub fn builder(schema_version: u16) -> DocumentExtensionBuilder {
1483 DocumentExtensionBuilder {
1484 schema_version,
1485 clean_md_sha256: None,
1486 layout_json_sha256: None,
1487 report_json_sha256: None,
1488 preview_png_sha256: None,
1489 page_count: None,
1490 audit_session_id: None,
1491 clean_spans: Vec::new(),
1492 codec_audit: Vec::new(),
1493 }
1494 }
1495}
1496
1497#[derive(Debug, Clone)]
1499#[must_use]
1500pub struct DocumentExtensionBuilder {
1501 schema_version: u16,
1502 clean_md_sha256: Option<[u8; 32]>,
1503 layout_json_sha256: Option<[u8; 32]>,
1504 report_json_sha256: Option<[u8; 32]>,
1505 preview_png_sha256: Option<[u8; 32]>,
1506 page_count: Option<u32>,
1507 audit_session_id: Option<String>,
1508 clean_spans: Vec<EmittedTokenSpan>,
1509 codec_audit: Vec<CodecAuditRow>,
1510}
1511
1512impl DocumentExtensionBuilder {
1513 pub fn clean_md_sha256(mut self, hash: [u8; 32]) -> Self {
1514 self.clean_md_sha256 = Some(hash);
1515 self
1516 }
1517
1518 pub fn layout_json_sha256(mut self, hash: [u8; 32]) -> Self {
1519 self.layout_json_sha256 = Some(hash);
1520 self
1521 }
1522
1523 pub fn report_json_sha256(mut self, hash: [u8; 32]) -> Self {
1524 self.report_json_sha256 = Some(hash);
1525 self
1526 }
1527
1528 pub fn preview_png_sha256(mut self, hash: [u8; 32]) -> Self {
1529 self.preview_png_sha256 = Some(hash);
1530 self
1531 }
1532
1533 pub fn page_count(mut self, page_count: u32) -> Self {
1534 self.page_count = Some(page_count);
1535 self
1536 }
1537
1538 pub fn audit_session_id(mut self, audit_session_id: impl Into<String>) -> Self {
1539 self.audit_session_id = Some(audit_session_id.into());
1540 self
1541 }
1542
1543 pub fn clean_spans(mut self, clean_spans: Vec<EmittedTokenSpan>) -> Self {
1544 self.clean_spans = clean_spans;
1545 self
1546 }
1547
1548 pub fn codec_audit(mut self, codec_audit: Vec<CodecAuditRow>) -> Self {
1549 self.codec_audit = codec_audit;
1550 self
1551 }
1552
1553 pub fn build(self) -> Result<DocumentExtension, DocumentExtensionError> {
1554 Ok(DocumentExtension {
1555 schema_version: self.schema_version,
1556 clean_md_sha256: self
1557 .clean_md_sha256
1558 .ok_or(DocumentExtensionError::MissingField("clean_md_sha256"))?,
1559 layout_json_sha256: self
1560 .layout_json_sha256
1561 .ok_or(DocumentExtensionError::MissingField("layout_json_sha256"))?,
1562 report_json_sha256: self
1563 .report_json_sha256
1564 .ok_or(DocumentExtensionError::MissingField("report_json_sha256"))?,
1565 preview_png_sha256: self.preview_png_sha256,
1566 page_count: self
1567 .page_count
1568 .ok_or(DocumentExtensionError::MissingField("page_count"))?,
1569 audit_session_id: self
1570 .audit_session_id
1571 .ok_or(DocumentExtensionError::MissingField("audit_session_id"))?,
1572 clean_spans: self.clean_spans,
1573 codec_audit: self.codec_audit,
1574 })
1575 }
1576}
1577
1578#[derive(Debug, Clone, PartialEq, Eq, Error)]
1580#[non_exhaustive]
1581pub enum DocumentExtensionError {
1582 #[error("missing document extension field: {0}")]
1583 MissingField(&'static str),
1584}
1585
1586#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1588#[serde(rename_all = "snake_case")]
1589#[non_exhaustive]
1590pub enum TextOrigin {
1591 Ocr,
1593 EmbeddedText,
1595 Transcript,
1597 Hybrid,
1599}
1600
1601#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1603#[non_exhaustive]
1604pub struct CodecCapabilitySet {
1605 pub text: bool,
1607 pub layout: bool,
1609 pub confidence: bool,
1611 pub timestamps: bool,
1613}
1614
1615impl CodecCapabilitySet {
1616 pub const TEXT_ONLY: Self = Self {
1618 text: true,
1619 layout: false,
1620 confidence: false,
1621 timestamps: false,
1622 };
1623
1624 pub const fn new(text: bool, layout: bool, confidence: bool, timestamps: bool) -> Self {
1626 Self {
1627 text,
1628 layout,
1629 confidence,
1630 timestamps,
1631 }
1632 }
1633
1634 pub fn contains(self, requested: Self) -> bool {
1636 (!requested.text || self.text)
1637 && (!requested.layout || self.layout)
1638 && (!requested.confidence || self.confidence)
1639 && (!requested.timestamps || self.timestamps)
1640 }
1641}
1642
1643#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1645#[serde(rename_all = "snake_case")]
1646#[non_exhaustive]
1647pub enum ExtractionDensityPolicy {
1648 Required(f32),
1650 Exempt { reason: String },
1652}
1653
1654impl Default for ExtractionDensityPolicy {
1655 fn default() -> Self {
1656 Self::Exempt {
1657 reason: "calibration_pending".to_string(),
1658 }
1659 }
1660}
1661
1662#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1664#[non_exhaustive]
1665pub struct CodecAuditRow {
1666 pub codec_id: String,
1668 pub codec_version: String,
1670 pub accepted_mime: String,
1672 pub advertised: CodecCapabilitySet,
1674 pub delivered: CodecCapabilitySet,
1676 pub text_origin: TextOrigin,
1678 pub codec_output_schema_version: u16,
1680 #[serde(default, skip_serializing_if = "Option::is_none")]
1682 pub options_hash_hex: Option<String>,
1683 #[serde(default, skip_serializing_if = "Option::is_none")]
1685 pub engine_provenance: Option<String>,
1686 pub extraction_density_policy: ExtractionDensityPolicy,
1688}
1689
1690impl CodecAuditRow {
1691 pub fn new(
1693 codec_id: impl Into<String>,
1694 codec_version: impl Into<String>,
1695 accepted_mime: impl Into<String>,
1696 text_origin: TextOrigin,
1697 ) -> Self {
1698 Self {
1699 codec_id: codec_id.into(),
1700 codec_version: codec_version.into(),
1701 accepted_mime: accepted_mime.into(),
1702 advertised: CodecCapabilitySet::default(),
1703 delivered: CodecCapabilitySet::default(),
1704 text_origin,
1705 codec_output_schema_version: 1,
1706 options_hash_hex: None,
1707 engine_provenance: None,
1708 extraction_density_policy: ExtractionDensityPolicy::default(),
1709 }
1710 }
1711}
1712
1713#[derive(Debug, Clone, Default, PartialEq)]
1719#[non_exhaustive]
1720pub struct LeakReport {
1721 pub suspects: Vec<LeakSuspect>,
1723 pub telemetry: Vec<LeakReportTelemetry>,
1725 pub stats: LeakReportStats,
1727 pub replay_hash: Option<String>,
1732}
1733
1734impl LeakReport {
1735 pub fn from_parts(
1737 suspects: Vec<LeakSuspect>,
1738 telemetry: Vec<LeakReportTelemetry>,
1739 ) -> LeakReport {
1740 let mut stats = LeakReportStats {
1741 suspect_count: suspects.len(),
1742 locale_skipped_count: telemetry
1743 .iter()
1744 .filter(|event| matches!(event, LeakReportTelemetry::LocaleSkipped { .. }))
1745 .count(),
1746 ..LeakReportStats::default()
1747 };
1748 for suspect in &suspects {
1749 match suspect.kind {
1750 LeakKind::Uncovered => stats.uncovered_count += 1,
1751 LeakKind::PartialBleed { .. } => stats.partial_bleed_count += 1,
1752 LeakKind::ClassMismatch { .. } => stats.class_mismatch_count += 1,
1753 }
1754 }
1755 LeakReport {
1756 suspects,
1757 telemetry,
1758 stats,
1759 replay_hash: None,
1760 }
1761 }
1762
1763 pub fn extend(&mut self, other: LeakReport) {
1765 self.suspects.extend(other.suspects);
1766 self.telemetry.extend(other.telemetry);
1767 *self = LeakReport::from_parts(
1768 std::mem::take(&mut self.suspects),
1769 std::mem::take(&mut self.telemetry),
1770 );
1771 }
1772}
1773
1774#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1776#[non_exhaustive]
1777pub enum OpenAiPrivateLabel {
1778 PrivatePerson,
1780 PrivateAddress,
1782 PrivateEmail,
1784 PrivatePhone,
1786 PrivateUrl,
1788 PrivateDate,
1790 AccountNumber,
1792 Secret,
1794}
1795
1796impl OpenAiPrivateLabel {
1797 pub fn as_str(self) -> &'static str {
1799 match self {
1800 Self::PrivatePerson => "private_person",
1801 Self::PrivateAddress => "private_address",
1802 Self::PrivateEmail => "private_email",
1803 Self::PrivatePhone => "private_phone",
1804 Self::PrivateUrl => "private_url",
1805 Self::PrivateDate => "private_date",
1806 Self::AccountNumber => "account_number",
1807 Self::Secret => "secret",
1808 }
1809 }
1810}
1811
1812#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1814#[non_exhaustive]
1815pub enum SafetyNetPiiClass {
1816 Email,
1818 Name,
1820 Location,
1822 Phone,
1824 Url,
1826 Date,
1828 AccountNumber,
1830 Secret,
1832}
1833
1834impl SafetyNetPiiClass {
1835 pub fn to_pii_class(self) -> PiiClass {
1837 match self {
1838 Self::Email => PiiClass::Email,
1839 Self::Name => PiiClass::Name,
1840 Self::Location => PiiClass::Location,
1841 Self::Phone => PiiClass::custom("phone"),
1842 Self::Url => PiiClass::custom("url"),
1843 Self::Date => PiiClass::custom("date"),
1844 Self::AccountNumber => PiiClass::custom("account_number"),
1845 Self::Secret => PiiClass::custom("secret"),
1846 }
1847 }
1848}
1849
1850#[derive(Debug, Clone, PartialEq, Eq, Error)]
1852#[non_exhaustive]
1853pub enum SafetyNetError {
1854 #[error("safety net unavailable: {reason}")]
1856 Unavailable {
1857 reason: String,
1859 },
1860 #[error("safety net weights missing: {path}")]
1862 WeightsMissing {
1863 path: String,
1865 },
1866 #[error("safety net model unavailable: {reason}")]
1868 ModelUnavailable {
1869 reason: String,
1871 },
1872 #[error("safety net model integrity mismatch: expected={expected}, actual={actual}")]
1874 ModelIntegrityMismatch {
1875 expected: String,
1877 actual: String,
1879 },
1880 #[error("safety net input too large: limit={limit}, actual={actual}")]
1882 InputTooLarge {
1883 limit: usize,
1885 actual: usize,
1887 },
1888 #[error("safety net runtime failed: {message}")]
1890 Runtime {
1891 message: String,
1893 },
1894 #[error("safety net invalid output: {message}")]
1896 InvalidOutput {
1897 message: String,
1899 },
1900}
1901
1902#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1916#[non_exhaustive]
1917pub enum Action {
1918 Tokenize,
1920 Redact,
1922 FormatPreserve,
1924 Generalize,
1926 Preserve,
1928}
1929
1930#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1932#[non_exhaustive]
1933pub enum ConflictTier {
1934 None,
1936 ClassPriority,
1938 RulePriority,
1940 Score,
1942 SpanLength,
1944 Validator,
1946 ValidatorVeto,
1948 CollisionPolicy,
1950 AnchoredContext,
1952 RecognizerId,
1954 Merged,
1956 Redact,
1958 Resolve,
1960 Fallback,
1962}
1963
1964#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1966#[non_exhaustive]
1967pub enum FallbackReason {
1968 OverlapConflict,
1970 ValidatorVeto,
1972 AnchorMissing,
1974 ResidualSuspect,
1976}
1977
1978#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1980#[non_exhaustive]
1981pub enum DocumentKind {
1982 Structured,
1984 Text,
1986}
1987
1988#[derive(Debug, Clone, PartialEq, Eq)]
1998#[non_exhaustive]
1999pub struct RedactionEntry {
2000 pub source: String,
2002 pub recognizer_id: Option<String>,
2004 pub recognizer_version_id: Option<String>,
2006 pub class: PiiClass,
2008 pub action: Action,
2010 pub field_name: Option<String>,
2012 pub document_kind: DocumentKind,
2014 pub conflict_loser: bool,
2016 pub decided_by: ConflictTier,
2018 pub created_at: i64,
2020 pub session_id: Option<String>,
2022 pub validator_fail_reason: Option<ValidatorFailReason>,
2024 pub ambiguity_record: Option<AmbiguityRecord>,
2026 pub collision_family: Option<String>,
2028 pub collision_variant: Option<String>,
2030 pub fallback_triggered: Option<FallbackReason>,
2032 pub provenance_stage: Option<String>,
2034 pub provenance_model_id: Option<String>,
2035 pub provenance_model_version: Option<String>,
2036 pub provenance_artifact_sha256: Option<String>,
2037 pub provenance_tokenizer_sha256: Option<String>,
2038 pub provenance_locale_resolved: Option<String>,
2039 pub provenance_locale_match_kind: Option<String>,
2040 pub provenance_canonical_class: Option<String>,
2041 pub provenance_native_class: Option<String>,
2042 pub provenance_confidence: Option<String>,
2043 pub provenance_merged_from: Option<String>,
2044 pub backend_silently_dropped: Option<Vec<String>>,
2046 pub restore_policy: Option<String>,
2047 pub restore_decision: Option<String>,
2048 pub restore_unknown_token_count: Option<u64>,
2049 pub restore_manifest_bypass_count: Option<u64>,
2050 pub restore_fresh_pii_count: Option<u64>,
2051 pub restore_phase_mask: Option<u32>,
2052}
2053
2054impl Serialize for RedactionEntry {
2055 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
2056 where
2057 S: serde::Serializer,
2058 {
2059 use serde::ser::SerializeStruct;
2060
2061 let mut len = 14;
2062 if self.recognizer_id.is_some() {
2063 len += 1;
2064 }
2065 if self.recognizer_version_id.is_some() {
2066 len += 1;
2067 }
2068 len += [
2069 self.provenance_stage.as_ref(),
2070 self.provenance_model_id.as_ref(),
2071 self.provenance_model_version.as_ref(),
2072 self.provenance_artifact_sha256.as_ref(),
2073 self.provenance_tokenizer_sha256.as_ref(),
2074 self.provenance_locale_resolved.as_ref(),
2075 self.provenance_locale_match_kind.as_ref(),
2076 self.provenance_canonical_class.as_ref(),
2077 self.provenance_native_class.as_ref(),
2078 self.provenance_confidence.as_ref(),
2079 self.provenance_merged_from.as_ref(),
2080 ]
2081 .into_iter()
2082 .filter(|value| value.is_some())
2083 .count();
2084 if self.backend_silently_dropped.is_some() {
2085 len += 1;
2086 }
2087 len += [self.restore_policy.as_ref(), self.restore_decision.as_ref()]
2088 .into_iter()
2089 .filter(|value| value.is_some())
2090 .count();
2091 len += [
2092 self.restore_unknown_token_count.is_some(),
2093 self.restore_manifest_bypass_count.is_some(),
2094 self.restore_fresh_pii_count.is_some(),
2095 self.restore_phase_mask.is_some(),
2096 ]
2097 .into_iter()
2098 .filter(|value| *value)
2099 .count();
2100 let mut state = serializer.serialize_struct("RedactionEntry", len)?;
2101 state.serialize_field("source", &self.source)?;
2102 if let Some(recognizer_id) = &self.recognizer_id {
2103 state.serialize_field("recognizer_id", recognizer_id)?;
2104 }
2105 if let Some(recognizer_version_id) = &self.recognizer_version_id {
2106 state.serialize_field("recognizer_version_id", recognizer_version_id)?;
2107 }
2108 state.serialize_field("class", &self.class.to_canonical_str())?;
2109 state.serialize_field("action", redaction_action_as_str(self.action))?;
2110 state.serialize_field("field_name", &self.field_name)?;
2111 state.serialize_field(
2112 "document_kind",
2113 redaction_document_kind_as_str(self.document_kind),
2114 )?;
2115 state.serialize_field("conflict_loser", &self.conflict_loser)?;
2116 state.serialize_field(
2117 "decided_by",
2118 redaction_conflict_tier_as_str(self.decided_by),
2119 )?;
2120 state.serialize_field("created_at", &self.created_at)?;
2121 state.serialize_field("session_id", &self.session_id)?;
2122 state.serialize_field("validator_fail_reason", &self.validator_fail_reason)?;
2123 state.serialize_field("ambiguity_record", &self.ambiguity_record)?;
2124 state.serialize_field("collision_family", &self.collision_family)?;
2125 state.serialize_field("collision_variant", &self.collision_variant)?;
2126 state.serialize_field("fallback_triggered", &self.fallback_triggered)?;
2127 if let Some(value) = &self.provenance_stage {
2128 state.serialize_field("provenance_stage", value)?;
2129 }
2130 if let Some(value) = &self.provenance_model_id {
2131 state.serialize_field("provenance_model_id", value)?;
2132 }
2133 if let Some(value) = &self.provenance_model_version {
2134 state.serialize_field("provenance_model_version", value)?;
2135 }
2136 if let Some(value) = &self.provenance_artifact_sha256 {
2137 state.serialize_field("provenance_artifact_sha256", value)?;
2138 }
2139 if let Some(value) = &self.provenance_tokenizer_sha256 {
2140 state.serialize_field("provenance_tokenizer_sha256", value)?;
2141 }
2142 if let Some(value) = &self.provenance_locale_resolved {
2143 state.serialize_field("provenance_locale_resolved", value)?;
2144 }
2145 if let Some(value) = &self.provenance_locale_match_kind {
2146 state.serialize_field("provenance_locale_match_kind", value)?;
2147 }
2148 if let Some(value) = &self.provenance_canonical_class {
2149 state.serialize_field("provenance_canonical_class", value)?;
2150 }
2151 if let Some(value) = &self.provenance_native_class {
2152 state.serialize_field("provenance_native_class", value)?;
2153 }
2154 if let Some(value) = &self.provenance_confidence {
2155 state.serialize_field("provenance_confidence", value)?;
2156 }
2157 if let Some(value) = &self.provenance_merged_from {
2158 state.serialize_field("provenance_merged_from", value)?;
2159 }
2160 if let Some(dropped) = &self.backend_silently_dropped {
2161 state.serialize_field("backend_silently_dropped", dropped)?;
2162 }
2163 if let Some(value) = &self.restore_policy {
2164 state.serialize_field("restore_policy", value)?;
2165 }
2166 if let Some(value) = &self.restore_decision {
2167 state.serialize_field("restore_decision", value)?;
2168 }
2169 if let Some(value) = self.restore_unknown_token_count {
2170 state.serialize_field("restore_unknown_token_count", &value)?;
2171 }
2172 if let Some(value) = self.restore_manifest_bypass_count {
2173 state.serialize_field("restore_manifest_bypass_count", &value)?;
2174 }
2175 if let Some(value) = self.restore_fresh_pii_count {
2176 state.serialize_field("restore_fresh_pii_count", &value)?;
2177 }
2178 if let Some(value) = self.restore_phase_mask {
2179 state.serialize_field("restore_phase_mask", &value)?;
2180 }
2181 state.end()
2182 }
2183}
2184
2185fn redaction_action_as_str(action: Action) -> &'static str {
2186 match action {
2187 Action::Tokenize => "tokenize",
2188 Action::Redact => "redact",
2189 Action::FormatPreserve => "format_preserve",
2190 Action::Generalize => "generalize",
2191 Action::Preserve => "preserve",
2192 }
2193}
2194
2195fn redaction_document_kind_as_str(kind: DocumentKind) -> &'static str {
2196 match kind {
2197 DocumentKind::Structured => "structured",
2198 DocumentKind::Text => "text",
2199 }
2200}
2201
2202fn redaction_conflict_tier_as_str(tier: ConflictTier) -> &'static str {
2203 match tier {
2204 ConflictTier::None => "none",
2205 ConflictTier::ClassPriority => "class_priority",
2206 ConflictTier::RulePriority => "rule_priority",
2207 ConflictTier::Score => "score",
2208 ConflictTier::SpanLength => "span_length",
2209 ConflictTier::Validator => "validator",
2210 ConflictTier::ValidatorVeto => "validator_veto",
2211 ConflictTier::CollisionPolicy => "collision_policy",
2212 ConflictTier::AnchoredContext => "anchored_context",
2213 ConflictTier::RecognizerId => "recognizer_id",
2214 ConflictTier::Merged => "merged",
2215 ConflictTier::Redact => "redact",
2216 ConflictTier::Resolve => "resolve",
2217 ConflictTier::Fallback => "fallback",
2218 }
2219}
2220
2221impl RedactionEntry {
2222 #[allow(clippy::too_many_arguments)]
2224 pub fn new(
2225 source: impl Into<String>,
2226 class: PiiClass,
2227 action: Action,
2228 field_name: Option<String>,
2229 document_kind: DocumentKind,
2230 conflict_loser: bool,
2231 decided_by: ConflictTier,
2232 created_at: i64,
2233 session_id: Option<String>,
2234 ) -> Self {
2235 Self {
2236 source: source.into(),
2237 class,
2238 action,
2239 field_name,
2240 document_kind,
2241 conflict_loser,
2242 decided_by,
2243 created_at,
2244 session_id,
2245 recognizer_id: None,
2246 recognizer_version_id: None,
2247 validator_fail_reason: None,
2248 ambiguity_record: None,
2249 collision_family: None,
2250 collision_variant: None,
2251 fallback_triggered: None,
2252 provenance_stage: None,
2253 provenance_model_id: None,
2254 provenance_model_version: None,
2255 provenance_artifact_sha256: None,
2256 provenance_tokenizer_sha256: None,
2257 provenance_locale_resolved: None,
2258 provenance_locale_match_kind: None,
2259 provenance_canonical_class: None,
2260 provenance_native_class: None,
2261 provenance_confidence: None,
2262 provenance_merged_from: None,
2263 backend_silently_dropped: None,
2264 restore_policy: None,
2265 restore_decision: None,
2266 restore_unknown_token_count: None,
2267 restore_manifest_bypass_count: None,
2268 restore_fresh_pii_count: None,
2269 restore_phase_mask: None,
2270 }
2271 }
2272
2273 pub fn with_validator_fail_reason(mut self, reason: ValidatorFailReason) -> Self {
2275 self.validator_fail_reason = Some(reason);
2276 self
2277 }
2278
2279 pub fn with_ambiguity_record(mut self, record: AmbiguityRecord) -> Self {
2281 self.ambiguity_record = Some(record);
2282 self
2283 }
2284
2285 pub fn with_collision_metadata(
2287 mut self,
2288 family: Option<String>,
2289 variant: Option<String>,
2290 ) -> Self {
2291 self.collision_family = family;
2292 self.collision_variant = variant;
2293 self
2294 }
2295
2296 pub fn with_fallback_triggered(mut self, reason: FallbackReason) -> Self {
2298 self.fallback_triggered = Some(reason);
2299 self
2300 }
2301
2302 pub fn with_backend_silently_dropped(mut self, dropped: Vec<String>) -> Self {
2304 self.backend_silently_dropped = Some(dropped);
2305 self
2306 }
2307
2308 pub fn with_restore_telemetry(mut self, telemetry: RestoreTelemetry) -> Self {
2309 self.restore_policy = Some(telemetry.restore_policy_str().to_string());
2310 self.restore_decision = Some(telemetry.restore_decision_str().to_string());
2311 self.restore_unknown_token_count = Some(telemetry.unknown_token_count);
2312 self.restore_manifest_bypass_count = Some(telemetry.manifest_bypass_count);
2313 self.restore_fresh_pii_count = Some(telemetry.fresh_pii_detected_count);
2314 self.restore_phase_mask = Some(telemetry.phase_execution_mask);
2315 self
2316 }
2317
2318 pub fn with_recognizer_metadata(
2320 mut self,
2321 recognizer_id: Option<String>,
2322 recognizer_version_id: Option<String>,
2323 ) -> Self {
2324 self.recognizer_id = recognizer_id;
2325 self.recognizer_version_id = recognizer_version_id;
2326 self
2327 }
2328
2329 #[allow(clippy::too_many_arguments)]
2330 pub fn with_provenance_metadata(
2331 mut self,
2332 stage: Option<String>,
2333 model_id: Option<String>,
2334 model_version: Option<String>,
2335 artifact_sha256: Option<String>,
2336 tokenizer_sha256: Option<String>,
2337 locale_resolved: Option<String>,
2338 locale_match_kind: Option<String>,
2339 canonical_class: Option<String>,
2340 native_class: Option<String>,
2341 confidence: Option<f64>,
2342 merged_from: Option<String>,
2343 ) -> Self {
2344 self.provenance_stage = stage;
2345 self.provenance_model_id = model_id;
2346 self.provenance_model_version = model_version;
2347 self.provenance_artifact_sha256 = artifact_sha256;
2348 self.provenance_tokenizer_sha256 = tokenizer_sha256;
2349 self.provenance_locale_resolved = locale_resolved;
2350 self.provenance_locale_match_kind = locale_match_kind;
2351 self.provenance_canonical_class = canonical_class;
2352 self.provenance_native_class = native_class;
2353 self.provenance_confidence = confidence.map(|value| value.to_string());
2354 self.provenance_merged_from = merged_from;
2355 self
2356 }
2357}
2358
2359#[derive(Debug, Clone, PartialEq, Eq, Error)]
2361#[non_exhaustive]
2362pub enum RedactionLogError {
2363 #[error("sqlite redaction log error: {0}")]
2365 Sqlite(String),
2366 #[error("backend redaction log error: {0}")]
2368 Backend(String),
2369}
2370
2371pub trait RedactionLogger: Send + Sync {
2401 fn log(&self, entry: &RedactionEntry) -> Result<(), RedactionLogError>;
2403}
2404
2405#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
2407#[non_exhaustive]
2408pub enum SafetyTier {
2409 #[default]
2411 SafeDefault,
2412 LocaleGated,
2414 OptIn,
2416}
2417
2418#[derive(Debug, Clone, PartialEq, Eq)]
2420#[non_exhaustive]
2421pub struct SafetyTierParseError {
2422 value: String,
2423}
2424
2425impl SafetyTier {
2426 pub fn parse(value: &str) -> Result<Self, SafetyTierParseError> {
2428 match value {
2429 "safe_default" => Ok(Self::SafeDefault),
2430 "locale_gated" => Ok(Self::LocaleGated),
2431 "opt_in" => Ok(Self::OptIn),
2432 other => Err(SafetyTierParseError {
2433 value: other.to_string(),
2434 }),
2435 }
2436 }
2437
2438 pub fn as_str(self) -> &'static str {
2440 match self {
2441 Self::SafeDefault => "safe_default",
2442 Self::LocaleGated => "locale_gated",
2443 Self::OptIn => "opt_in",
2444 }
2445 }
2446}
2447
2448impl SafetyTierParseError {
2449 pub fn value(&self) -> &str {
2451 &self.value
2452 }
2453}
2454
2455impl fmt::Display for SafetyTierParseError {
2456 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2457 write!(f, "unsupported safety_tier '{}'", self.value)
2458 }
2459}
2460
2461impl std::error::Error for SafetyTierParseError {}
2462
2463#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2465#[non_exhaustive]
2466pub enum LocaleTag {
2467 Global,
2469 DeDe,
2471 DeAt,
2473 DeCh,
2475 EnUs,
2477 EnGb,
2479 EnIe,
2481 EnAu,
2483 EnCa,
2485 Other(String),
2487}
2488
2489#[derive(Debug, Clone, PartialEq, Eq)]
2491#[non_exhaustive]
2492pub enum LocaleError {
2493 Unsupported,
2495}
2496
2497impl fmt::Display for LocaleError {
2498 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2499 match self {
2500 LocaleError::Unsupported => f.write_str("unsupported locale"),
2501 }
2502 }
2503}
2504
2505impl std::error::Error for LocaleError {}
2506
2507#[derive(Debug, Clone, PartialEq, Eq)]
2509pub struct LocaleChain(Vec<LocaleTag>);
2510
2511impl LocaleTag {
2512 pub const GLOBAL: LocaleTag = LocaleTag::Global;
2514
2515 pub fn parse(s: &str) -> Result<LocaleTag, LocaleError> {
2517 let raw = s.trim().replace('_', "-");
2518 let normalized = raw.to_ascii_lowercase();
2519 match normalized.as_str() {
2520 "global" | "*" => Ok(LocaleTag::Global),
2521 "de-de" => Ok(LocaleTag::DeDe),
2522 "de-at" => Ok(LocaleTag::DeAt),
2523 "de-ch" => Ok(LocaleTag::DeCh),
2524 "en-us" => Ok(LocaleTag::EnUs),
2525 "en-gb" => Ok(LocaleTag::EnGb),
2526 "en-ie" => Ok(LocaleTag::EnIe),
2527 "en-au" => Ok(LocaleTag::EnAu),
2528 "en-ca" => Ok(LocaleTag::EnCa),
2529 "" => Err(LocaleError::Unsupported),
2530 _ if is_bcp47_parseable(&raw) => Ok(LocaleTag::Other(canonical_other(&raw))),
2531 _ => Err(LocaleError::Unsupported),
2532 }
2533 }
2534
2535 pub fn as_str(&self) -> &str {
2537 match self {
2538 LocaleTag::Global => "global",
2539 LocaleTag::DeDe => "de-DE",
2540 LocaleTag::DeAt => "de-AT",
2541 LocaleTag::DeCh => "de-CH",
2542 LocaleTag::EnUs => "en-US",
2543 LocaleTag::EnGb => "en-GB",
2544 LocaleTag::EnIe => "en-IE",
2545 LocaleTag::EnAu => "en-AU",
2546 LocaleTag::EnCa => "en-CA",
2547 LocaleTag::Other(tag) => tag.as_str(),
2548 }
2549 }
2550}
2551
2552impl LocaleChain {
2553 pub fn from_tags(mut tags: Vec<LocaleTag>) -> LocaleChain {
2555 ensure_global(&mut tags);
2556 LocaleChain(tags)
2557 }
2558
2559 pub fn from_cli(raw: &str) -> Result<LocaleChain, LocaleError> {
2561 let tags = raw
2562 .split(',')
2563 .map(LocaleTag::parse)
2564 .collect::<Result<Vec<_>, _>>()?;
2565 Ok(LocaleChain::from_tags(tags))
2566 }
2567
2568 pub fn merge_policy_and_cli(
2570 policy: Option<&[LocaleTag]>,
2571 cli: Option<&[LocaleTag]>,
2572 ) -> LocaleChain {
2573 Self::merge_cli_policy_rulepack_default(cli, policy, None)
2574 }
2575
2576 pub fn merge_cli_policy_rulepack_default(
2578 cli: Option<&[LocaleTag]>,
2579 policy: Option<&[LocaleTag]>,
2580 rulepack_defaults: Option<&[LocaleTag]>,
2581 ) -> LocaleChain {
2582 let tags = cli
2583 .filter(|tags| !tags.is_empty())
2584 .or_else(|| policy.filter(|tags| !tags.is_empty()))
2585 .or_else(|| rulepack_defaults.filter(|tags| !tags.is_empty()))
2586 .map(|tags| tags.to_vec())
2587 .unwrap_or_else(|| vec![LocaleTag::Global]);
2588 LocaleChain::from_tags(tags)
2589 }
2590
2591 pub fn intersects(&self, recognizer_locales: &[LocaleTag]) -> bool {
2593 if recognizer_locales.is_empty() {
2594 return true;
2595 }
2596 recognizer_locales.iter().any(|recognizer_locale| {
2597 *recognizer_locale == LocaleTag::Global
2598 || self.0.iter().any(|active| active == recognizer_locale)
2599 })
2600 }
2601
2602 pub fn as_slice(&self) -> &[LocaleTag] {
2604 &self.0
2605 }
2606
2607 pub fn to_strings(&self) -> Vec<String> {
2609 self.0.iter().map(ToString::to_string).collect()
2610 }
2611}
2612
2613impl From<&[LocaleTag]> for LocaleChain {
2614 fn from(tags: &[LocaleTag]) -> Self {
2615 let mut owned = tags.to_vec();
2616 ensure_global(&mut owned);
2617 LocaleChain(owned)
2618 }
2619}
2620
2621impl fmt::Display for LocaleTag {
2622 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2623 f.write_str(self.as_str())
2624 }
2625}
2626
2627#[derive(Debug, Clone)]
2637#[non_exhaustive]
2638pub enum RawDocument {
2639 Structured(BTreeMap<String, Value>),
2641 Text(String),
2643}
2644
2645#[derive(Debug, Clone, Serialize)]
2664#[serde(untagged)]
2665#[non_exhaustive]
2666pub enum CleanDocument {
2667 Structured(BTreeMap<String, Value>),
2669 Text(String),
2671}
2672
2673#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2675#[serde(untagged)]
2676#[non_exhaustive]
2677pub enum Value {
2678 Null,
2680 Bool(bool),
2682 String(String),
2684 I64(i64),
2686 Array(Vec<Value>),
2688 Object(BTreeMap<String, Value>),
2690}
2691
2692impl Value {
2693 pub fn as_str(&self) -> Option<&str> {
2695 match self {
2696 Self::String(value) => Some(value.as_str()),
2697 Self::Null | Self::Bool(_) | Self::I64(_) | Self::Array(_) | Self::Object(_) => None,
2698 }
2699 }
2700
2701 pub fn scalar_to_safety_net_string(&self) -> Option<String> {
2703 match self {
2704 Self::String(value) if !value.is_empty() => Some(value.clone()),
2705 Self::String(_) | Self::Null | Self::Array(_) | Self::Object(_) => None,
2706 Self::Bool(value) => Some(value.to_string()),
2707 Self::I64(value) => Some(value.to_string()),
2708 }
2709 }
2710}
2711
2712impl PartialEq<&str> for Value {
2713 fn eq(&self, other: &&str) -> bool {
2714 self.as_str() == Some(*other)
2715 }
2716}
2717
2718#[derive(Debug, Clone, Default)]
2720pub struct DictionaryBundle {
2721 entries: HashMap<String, DictionaryEntry>,
2722}
2723
2724#[derive(Debug, Clone)]
2726pub struct DictionaryEntry {
2727 terms: Vec<String>,
2728 case_sensitive: bool,
2729 source: DictionarySource,
2730}
2731
2732#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2734#[non_exhaustive]
2735pub enum DictionarySource {
2736 Cli,
2738 Rulepack,
2740}
2741
2742#[derive(Debug, Clone, PartialEq, Eq)]
2744#[non_exhaustive]
2745pub struct DictionaryStats {
2746 pub name: String,
2748 pub term_count: usize,
2750 pub source: DictionarySource,
2752}
2753
2754impl DictionaryStats {
2755 pub fn new(name: impl Into<String>, term_count: usize, source: DictionarySource) -> Self {
2757 Self {
2758 name: name.into(),
2759 term_count,
2760 source,
2761 }
2762 }
2763}
2764
2765#[derive(Debug, Clone, PartialEq, Eq)]
2767#[non_exhaustive]
2768pub struct RulepackDict {
2769 pub name: String,
2771 pub terms: Vec<String>,
2773 pub case_sensitive: bool,
2775}
2776
2777impl RulepackDict {
2778 pub fn new(name: impl Into<String>, terms: Vec<String>, case_sensitive: bool) -> Self {
2780 Self {
2781 name: name.into(),
2782 terms,
2783 case_sensitive,
2784 }
2785 }
2786}
2787
2788#[derive(Debug, Clone, PartialEq, Eq)]
2790#[non_exhaustive]
2791pub enum DictionaryLoadError {
2792 Empty { name: String },
2794 UnicodeInsensitiveUnsupported { name: String },
2796}
2797
2798impl fmt::Display for DictionaryLoadError {
2799 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2800 match self {
2801 Self::Empty { name } => write!(f, "dictionary '{name}' has no terms"),
2802 Self::UnicodeInsensitiveUnsupported { name } => write!(
2803 f,
2804 "dictionary '{name}' uses unicode terms with case-insensitive matching, unsupported in v0.4.0; use case_sensitive = true"
2805 ),
2806 }
2807 }
2808}
2809
2810impl std::error::Error for DictionaryLoadError {}
2811
2812impl DictionaryBundle {
2813 pub fn from_rulepack_terms(terms: &[RulepackDict]) -> Self {
2815 let mut entries = HashMap::with_capacity(terms.len());
2816 for dictionary in terms {
2817 let entry = DictionaryEntry::new(
2818 &dictionary.name,
2819 dictionary.terms.clone(),
2820 dictionary.case_sensitive,
2821 DictionarySource::Rulepack,
2822 )
2823 .expect("Policy validates dictionary terms before bundle construction");
2824 entries.insert(dictionary.name.clone(), entry);
2825 }
2826 Self { entries }
2827 }
2828
2829 pub fn from_entries(entries: impl IntoIterator<Item = (String, DictionaryEntry)>) -> Self {
2831 Self {
2832 entries: entries.into_iter().collect(),
2833 }
2834 }
2835
2836 pub fn merge(a: Self, b: Self) -> Self {
2838 let mut entries = a.entries;
2839 entries.extend(b.entries);
2840 Self { entries }
2841 }
2842
2843 pub fn get(&self, name: &str) -> Option<&DictionaryEntry> {
2845 self.entries.get(name)
2846 }
2847
2848 pub fn stats(&self) -> Vec<DictionaryStats> {
2850 let mut stats = self
2851 .entries
2852 .iter()
2853 .map(|(name, entry)| DictionaryStats {
2854 name: name.clone(),
2855 term_count: entry.terms.len(),
2856 source: entry.source,
2857 })
2858 .collect::<Vec<_>>();
2859 stats.sort_by(|a, b| a.name.cmp(&b.name));
2860 stats
2861 }
2862}
2863
2864impl DictionaryEntry {
2865 pub fn new(
2867 name: &str,
2868 terms: Vec<String>,
2869 case_sensitive: bool,
2870 source: DictionarySource,
2871 ) -> Result<Self, DictionaryLoadError> {
2872 if terms.is_empty() {
2873 return Err(DictionaryLoadError::Empty {
2874 name: name.to_string(),
2875 });
2876 }
2877 if !case_sensitive && terms.iter().any(|term| !term.is_ascii()) {
2878 return Err(DictionaryLoadError::UnicodeInsensitiveUnsupported {
2879 name: name.to_string(),
2880 });
2881 }
2882 Ok(Self {
2883 terms,
2884 case_sensitive,
2885 source,
2886 })
2887 }
2888
2889 pub fn case_sensitive(&self) -> bool {
2891 self.case_sensitive
2892 }
2893
2894 pub fn terms(&self) -> &[String] {
2896 &self.terms
2897 }
2898}
2899
2900#[cfg(test)]
2901mod dictionary_tests {
2902 use super::*;
2903
2904 #[test]
2905 fn dictionary_entry_rejects_empty_terms() {
2906 let err = DictionaryEntry::new("empty", Vec::new(), true, DictionarySource::Cli)
2907 .expect_err("empty dictionaries must fail closed");
2908
2909 assert!(matches!(err, DictionaryLoadError::Empty { name } if name == "empty"));
2910 }
2911
2912 #[test]
2913 fn dictionary_entry_rejects_non_ascii_case_insensitive_terms() {
2914 let err = DictionaryEntry::new(
2915 "songs",
2916 vec!["Beyonce".to_string(), "Caf\u{00e9}".to_string()],
2917 false,
2918 DictionarySource::Cli,
2919 )
2920 .expect_err("unicode case-insensitive dictionaries must fail closed");
2921
2922 assert!(matches!(
2923 err,
2924 DictionaryLoadError::UnicodeInsensitiveUnsupported { name } if name == "songs"
2925 ));
2926 }
2927}
2928
2929#[cfg(test)]
2930mod redaction_logger_tests {
2931 use super::*;
2932
2933 struct CapturingLogger;
2934
2935 impl RedactionLogger for CapturingLogger {
2936 fn log(&self, _entry: &RedactionEntry) -> Result<(), RedactionLogError> {
2937 Ok(())
2938 }
2939 }
2940
2941 fn assert_send_sync<T: Send + Sync + ?Sized>() {}
2942
2943 #[test]
2944 fn redaction_log_error_display_is_stable() {
2945 assert_eq!(
2946 RedactionLogError::Sqlite("write failed".to_string()).to_string(),
2947 "sqlite redaction log error: write failed"
2948 );
2949 assert_eq!(
2950 RedactionLogError::Backend("sink failed".to_string()).to_string(),
2951 "backend redaction log error: sink failed"
2952 );
2953 }
2954
2955 #[test]
2956 fn redaction_logger_trait_object_is_send_sync() {
2957 assert_send_sync::<dyn RedactionLogger>();
2958 }
2959
2960 #[test]
2961 fn local_logger_can_implement_redaction_logger() {
2962 let logger = CapturingLogger;
2963 let entry = RedactionEntry {
2964 source: "unit-test".to_string(),
2965 recognizer_id: None,
2966 recognizer_version_id: None,
2967 class: PiiClass::Email,
2968 action: Action::Tokenize,
2969 field_name: None,
2970 document_kind: DocumentKind::Text,
2971 conflict_loser: false,
2972 decided_by: ConflictTier::None,
2973 created_at: 0,
2974 session_id: None,
2975 validator_fail_reason: None,
2976 ambiguity_record: None,
2977 collision_family: None,
2978 collision_variant: None,
2979 fallback_triggered: None,
2980 provenance_stage: None,
2981 provenance_model_id: None,
2982 provenance_model_version: None,
2983 provenance_artifact_sha256: None,
2984 provenance_tokenizer_sha256: None,
2985 provenance_locale_resolved: None,
2986 provenance_locale_match_kind: None,
2987 provenance_canonical_class: None,
2988 provenance_native_class: None,
2989 provenance_confidence: None,
2990 provenance_merged_from: None,
2991 backend_silently_dropped: None,
2992 restore_policy: None,
2993 restore_decision: None,
2994 restore_unknown_token_count: None,
2995 restore_manifest_bypass_count: None,
2996 restore_fresh_pii_count: None,
2997 restore_phase_mask: None,
2998 };
2999
3000 let trait_object: &dyn RedactionLogger = &logger;
3001 trait_object.log(&entry).expect("log entry");
3002 }
3003
3004 #[test]
3005 fn redaction_entry_json_shape_omits_absent_recognizer_lineage() {
3006 let entry = RedactionEntry::new(
3007 "email.global",
3008 PiiClass::Email,
3009 Action::Tokenize,
3010 None,
3011 DocumentKind::Text,
3012 false,
3013 ConflictTier::None,
3014 0,
3015 None,
3016 );
3017
3018 let rendered = serde_json::to_string(&entry).expect("serialize redaction entry");
3019
3020 assert_eq!(
3021 rendered,
3022 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}"#
3023 );
3024 }
3025
3026 #[test]
3027 fn redaction_entry_json_shape_includes_recognizer_lineage_when_present() {
3028 let entry = RedactionEntry::new(
3029 "ner/ort",
3030 PiiClass::Name,
3031 Action::Tokenize,
3032 None,
3033 DocumentKind::Text,
3034 false,
3035 ConflictTier::None,
3036 0,
3037 None,
3038 )
3039 .with_recognizer_metadata(
3040 Some("ner".to_string()),
3041 Some("ner.davlan-mbert.v1".to_string()),
3042 );
3043
3044 let value: serde_json::Value =
3045 serde_json::to_value(&entry).expect("serialize redaction entry");
3046
3047 assert_eq!(value["recognizer_id"], "ner");
3048 assert_eq!(value["recognizer_version_id"], "ner.davlan-mbert.v1");
3049 }
3050
3051 #[test]
3052 fn candidate_keeps_versioned_and_unversioned_recognizer_ids() {
3053 let unversioned = Candidate::new(
3054 0..5,
3055 PiiClass::Email,
3056 "email.global",
3057 0.9,
3058 10,
3059 None,
3060 "email",
3061 "email.global",
3062 ConflictTier::None,
3063 Vec::new(),
3064 );
3065 assert_eq!(unversioned.recognizer_id, "email.global");
3066 assert_eq!(unversioned.recognizer_version_id, None);
3067
3068 let versioned = unversioned
3069 .clone()
3070 .with_recognizer_version_id("email.global.v1");
3071 assert_eq!(versioned.recognizer_id, "email.global");
3072 assert_eq!(
3073 versioned.recognizer_version_id.as_deref(),
3074 Some("email.global.v1")
3075 );
3076 }
3077}
3078
3079#[cfg(test)]
3080mod safety_net_manifest_tests {
3081 use super::*;
3082
3083 fn span(start: usize, end: usize, class: PiiClass) -> EmittedTokenSpan {
3084 EmittedTokenSpan {
3085 clean_span: start..end,
3086 raw_span: start..end,
3087 class,
3088 }
3089 }
3090
3091 fn diff(manifest: Manifest, suspect: Range<usize>, class: PiiClass) -> Option<LeakKind> {
3092 manifest.diff_against(&suspect, &class)
3093 }
3094
3095 #[test]
3096 fn exact_same_class_coverage_is_not_a_leak() {
3097 let manifest = Manifest::from_spans(vec![span(0, 8, PiiClass::Email)]);
3098
3099 assert_eq!(diff(manifest, 0..8, PiiClass::Email), None);
3100 }
3101
3102 #[test]
3103 fn uncovered_outside_all_tokens_is_uncovered() {
3104 let manifest = Manifest::from_spans(vec![span(20, 30, PiiClass::Email)]);
3105
3106 assert_eq!(
3107 diff(manifest, 0..10, PiiClass::Email),
3108 Some(LeakKind::Uncovered)
3109 );
3110 }
3111
3112 #[test]
3113 fn single_internal_gap_returns_partial_bleed() {
3114 let manifest = Manifest::from_spans(vec![
3115 span(0, 5, PiiClass::Email),
3116 span(10, 15, PiiClass::Email),
3117 ]);
3118
3119 assert_eq!(
3120 diff(manifest, 0..15, PiiClass::Email),
3121 Some(LeakKind::PartialBleed { uncovered: 5..10 })
3122 );
3123 }
3124
3125 #[test]
3126 fn multi_gap_returns_deterministic_first_uncovered_gap() {
3127 let manifest = Manifest::from_spans(vec![
3128 span(0, 3, PiiClass::Email),
3129 span(5, 7, PiiClass::Email),
3130 span(9, 12, PiiClass::Email),
3131 ]);
3132
3133 assert_eq!(
3136 diff(manifest, 0..12, PiiClass::Email),
3137 Some(LeakKind::PartialBleed { uncovered: 3..5 })
3138 );
3139 }
3140
3141 #[test]
3142 fn multi_class_overlap_reports_first_mismatch_deterministically() {
3143 let manifest = Manifest::from_spans(vec![
3144 span(0, 4, PiiClass::Name),
3145 span(4, 8, PiiClass::Location),
3146 ]);
3147
3148 assert_eq!(
3149 diff(manifest, 0..8, PiiClass::Email),
3150 Some(LeakKind::ClassMismatch {
3151 pipeline_class: PiiClass::Name,
3152 safety_net_class: PiiClass::Email,
3153 })
3154 );
3155 }
3156
3157 #[test]
3158 fn adjacent_same_class_tokens_cover_continuously() {
3159 let manifest = Manifest::from_spans(vec![
3160 span(0, 5, PiiClass::Email),
3161 span(5, 10, PiiClass::Email),
3162 ]);
3163
3164 assert_eq!(diff(manifest, 0..10, PiiClass::Email), None);
3165 }
3166
3167 #[test]
3168 fn partial_bleed_at_start_end_and_middle() {
3169 let manifest = Manifest::from_spans(vec![span(3, 8, PiiClass::Email)]);
3170
3171 assert_eq!(
3172 diff(manifest.clone(), 0..8, PiiClass::Email),
3173 Some(LeakKind::PartialBleed { uncovered: 0..3 })
3174 );
3175 assert_eq!(
3176 diff(manifest.clone(), 3..10, PiiClass::Email),
3177 Some(LeakKind::PartialBleed { uncovered: 8..10 })
3178 );
3179
3180 let with_gap = Manifest::from_spans(vec![
3181 span(0, 3, PiiClass::Email),
3182 span(6, 10, PiiClass::Email),
3183 ]);
3184 assert_eq!(
3185 diff(with_gap, 0..10, PiiClass::Email),
3186 Some(LeakKind::PartialBleed { uncovered: 3..6 })
3187 );
3188 }
3189
3190 #[test]
3191 fn byte_indices_are_not_character_indices() {
3192 let text = "ID: 😀 <Email_1>";
3193 let token_start = text.find("<Email_1>").expect("token start");
3194 assert_eq!(token_start, 9, "emoji is four bytes, not one char");
3195 let manifest = Manifest::from_spans(vec![span(token_start, text.len(), PiiClass::Email)]);
3196
3197 assert_eq!(
3198 diff(manifest, token_start..text.len(), PiiClass::Email),
3199 None
3200 );
3201 }
3202
3203 #[test]
3204 fn empty_suspect_range_is_not_a_leak() {
3205 let manifest = Manifest::default();
3206
3207 assert_eq!(diff(manifest, 3..3, PiiClass::Email), None);
3208 }
3209
3210 #[test]
3211 fn safety_net_error_display_is_variant_specific_and_bytes_free() {
3212 let cases = [
3213 SafetyNetError::Unavailable {
3214 reason: "not configured".to_string(),
3215 }
3216 .to_string(),
3217 SafetyNetError::WeightsMissing {
3218 path: "/models/opf".to_string(),
3219 }
3220 .to_string(),
3221 SafetyNetError::ModelUnavailable {
3222 reason: "load failed".to_string(),
3223 }
3224 .to_string(),
3225 SafetyNetError::ModelIntegrityMismatch {
3226 expected: "e3b0c44298fc1c149afbf4c8996fb924".to_string(),
3227 actual: "4e07408562bedb8b60ce05c1decfe3ad".to_string(),
3228 }
3229 .to_string(),
3230 SafetyNetError::InputTooLarge {
3231 limit: 1024,
3232 actual: 2048,
3233 }
3234 .to_string(),
3235 SafetyNetError::Runtime {
3236 message: "timeout".to_string(),
3237 }
3238 .to_string(),
3239 SafetyNetError::InvalidOutput {
3240 message: "bad json".to_string(),
3241 }
3242 .to_string(),
3243 ];
3244
3245 for rendered in cases {
3246 assert!(!rendered.contains("alice@example.invalid"));
3247 }
3248 }
3249}
3250
3251pub trait Recognizer: Send + Sync {
3253 fn id(&self) -> &str;
3255 fn supported_class(&self) -> &PiiClass;
3257 fn detect(
3259 &self,
3260 input: &str,
3261 ctx: &DetectContext<'_>,
3262 ) -> std::result::Result<Vec<Candidate>, DetectError>;
3263 fn token_family(&self) -> &str;
3265 fn validator_kind(&self) -> Option<ValidatorKind> {
3267 None
3268 }
3269 fn locales(&self) -> &[LocaleTag] {
3271 &[LocaleTag::Global]
3272 }
3273}
3274
3275#[derive(Debug, Clone, PartialEq, Eq, Error)]
3277#[non_exhaustive]
3278pub enum DetectError {
3279 #[error("recognizer {recognizer_id} backend failed: {message}")]
3281 Backend {
3282 recognizer_id: String,
3284 message: String,
3286 },
3287}
3288
3289impl DetectError {
3290 pub fn backend(recognizer_id: impl Into<String>, source: impl Into<String>) -> Self {
3292 Self::Backend {
3293 recognizer_id: recognizer_id.into(),
3294 message: source.into(),
3295 }
3296 }
3297}
3298
3299#[derive(Debug, Clone, PartialEq)]
3301#[non_exhaustive]
3302pub struct Candidate {
3303 pub span: Range<usize>,
3305 pub class: PiiClass,
3307 pub recognizer_id: String,
3309 pub recognizer_version_id: Option<String>,
3311 pub score: f32,
3313 pub priority: i32,
3315 pub canonical_form: Option<String>,
3317 pub token_family: String,
3319 pub source: String,
3321 pub decided_by: ConflictTier,
3323 pub merged_sources: Vec<String>,
3325}
3326
3327impl Candidate {
3328 #[allow(clippy::too_many_arguments)]
3330 pub fn new(
3331 span: Range<usize>,
3332 class: PiiClass,
3333 recognizer_id: impl Into<String>,
3334 score: f32,
3335 priority: i32,
3336 canonical_form: Option<String>,
3337 token_family: impl Into<String>,
3338 source: impl Into<String>,
3339 decided_by: ConflictTier,
3340 merged_sources: Vec<String>,
3341 ) -> Self {
3342 Self {
3343 span,
3344 class,
3345 recognizer_id: recognizer_id.into(),
3346 recognizer_version_id: None,
3347 score,
3348 priority,
3349 canonical_form,
3350 token_family: token_family.into(),
3351 source: source.into(),
3352 decided_by,
3353 merged_sources,
3354 }
3355 }
3356
3357 pub fn with_span(mut self, span: Range<usize>) -> Self {
3359 self.span = span;
3360 self
3361 }
3362
3363 pub fn with_recognizer_version_id(mut self, recognizer_version_id: impl Into<String>) -> Self {
3365 self.recognizer_version_id = Some(recognizer_version_id.into());
3366 self
3367 }
3368}
3369
3370#[non_exhaustive]
3372pub struct DetectContext<'a> {
3373 pub locale_chain: &'a [LocaleTag],
3375 pub dictionaries: &'a DictionaryBundle,
3377 pub fields: &'a (),
3379 pub degraded: Cell<bool>,
3381}
3382
3383impl<'a> DetectContext<'a> {
3384 pub fn new(locale_chain: &'a [LocaleTag], dictionaries: &'a DictionaryBundle) -> Self {
3386 Self {
3387 locale_chain,
3388 dictionaries,
3389 fields: &(),
3390 degraded: Cell::new(false),
3391 }
3392 }
3393}
3394
3395fn ensure_global(tags: &mut Vec<LocaleTag>) {
3396 if !tags.contains(&LocaleTag::Global) {
3397 tags.push(LocaleTag::Global);
3398 }
3399}
3400
3401fn is_bcp47_parseable(raw: &str) -> bool {
3402 let mut parts = raw.split('-');
3403 let Some(language) = parts.next() else {
3404 return false;
3405 };
3406 if !(2..=8).contains(&language.len()) || !language.chars().all(|ch| ch.is_ascii_alphabetic()) {
3407 return false;
3408 }
3409 parts.all(|part| {
3410 (2..=8).contains(&part.len()) && part.chars().all(|ch| ch.is_ascii_alphanumeric())
3411 })
3412}
3413
3414fn canonical_other(raw: &str) -> String {
3415 let mut parts = raw.split('-');
3416 let language = parts.next().unwrap_or_default().to_ascii_lowercase();
3417 let rest = parts.map(|part| {
3418 if part.len() == 2 && part.chars().all(|ch| ch.is_ascii_alphabetic()) {
3419 part.to_ascii_uppercase()
3420 } else {
3421 part.to_ascii_lowercase()
3422 }
3423 });
3424 std::iter::once(language)
3425 .chain(rest)
3426 .collect::<Vec<_>>()
3427 .join("-")
3428}