1use std::collections::BTreeMap;
2use std::ops::Range;
3use std::sync::Arc;
4
5#[cfg(feature = "bundled-recognizers")]
6use gaze_recognizers::{
7 LocaleAwareModelRegistry, ModelError, ModelHints, ModelInput, ModelSpan, ModelStage,
8};
9use gaze_types::{
10 AmbiguityReason, AmbiguityRecord, CollisionMembership, EmittedTokenSpan, FallbackReason,
11 LeakKind, LeakReport, LeakReportTelemetry, LeakSuspect, Manifest, RedactionLogError,
12 RedactionLogger, RestoreDecision, RestorePolicy, RestoreTelemetry, RestoredText, SafetyNet,
13 SafetyNetContext, SafetyNetError, RESTORE_PHASE_MANIFEST_BYPASS_SCAN,
14 RESTORE_PHASE_MANIFEST_LOOKUP, RESTORE_PHASE_UNKNOWN_TOKEN_SCAN,
15};
16use thiserror::Error;
17
18use crate::detector::{Detection, Detector, PiiClass};
19use crate::normalize::normalize;
20use crate::policy::PolicyError;
21use crate::redaction_log::{ConflictTier, DocumentKind, RedactionEntry};
22use crate::registry::{Candidate, DetectContext, Recognizer, RecognizerRegistry};
23use crate::rule::{Action, Rule, RuleContext};
24use crate::rulepack::RulepackError;
25use crate::session::{RestoreEvent, Session};
26use crate::types::{CleanDocument, RawDocument, Value};
27use crate::DictionaryBundle;
28
29pub type Result<T> = std::result::Result<T, Error>;
30
31#[derive(Debug, Error)]
32#[non_exhaustive]
33pub enum Error {
34 #[error("invalid regex: {0}")]
35 InvalidRegex(#[source] regex::Error),
36 #[error("unknown token: [REDACTED]")]
37 UnknownToken {
38 class: PiiClass,
39 ordinal: u32,
40 raw: String,
41 },
42 #[error("ephemeral sessions cannot be exported")]
43 ExportForbidden,
44 #[error("document extension integrity fields cannot be empty")]
45 EmptyDocumentIntegrity,
46 #[error("invalid snapshot version: {0}")]
47 InvalidSnapshotVersion(u8),
48 #[error("snapshot signature verification failed")]
49 InvalidSnapshotSignature,
50 #[error("snapshot expired: issued_at={issued_at}, ttl_secs={ttl_secs}")]
51 BlobExpired { issued_at: u64, ttl_secs: u64 },
52 #[error("snapshot decode failed: {0}")]
53 SnapshotDecode(#[source] serde_json::Error),
54 #[error("invalid snapshot payload")]
55 InvalidSnapshotPayload,
56 #[error("sqlite error: {0}")]
57 Sqlite(String),
58 #[error("policy error: {0}")]
59 Policy(#[from] PolicyError),
60 #[error("rulepack error: {0}")]
61 Rulepack(#[from] RulepackError),
62 #[error("safety net error: {0}")]
63 SafetyNet(#[from] SafetyNetError),
64 #[error("recognizer detection failed: {0}")]
65 RecognizerDetect(#[from] gaze_types::DetectError),
66 #[error("redaction log error: {0}")]
67 RedactionLog(#[from] RedactionLogError),
68 #[error("safety net fallback failed closed: {0:?}")]
69 SafetyNetFallback(FallbackReason),
70 #[error("safety net span invalid: start={start}, end={end}, text_len={text_len}")]
71 SafetyNetSpanInvalid {
72 start: usize,
73 end: usize,
74 text_len: usize,
75 },
76 #[error("capitals heuristic gate is unsupported for locale {locale}")]
77 UnsupportedCapitalHeuristicLocale { locale: String },
78 #[error("unsupported raw document variant")]
79 UnsupportedRawDocumentVariant,
80 #[error("unsupported structured value variant")]
81 UnsupportedValueVariant,
82 #[error("unsupported policy action variant")]
83 UnsupportedActionVariant,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87#[non_exhaustive]
88pub enum SafetyNetMode {
89 Strict,
90 Tolerant,
91 Redact,
92 Resolve,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96#[non_exhaustive]
97pub enum SafetyNetFallback {
98 Strict,
99 Tolerant,
100 Redact,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104#[non_exhaustive]
105pub struct SafetyNetPolicy {
106 pub mode: SafetyNetMode,
107 pub fallback: SafetyNetFallback,
108}
109
110#[derive(Debug, Clone, Default, PartialEq, Eq)]
111#[non_exhaustive]
112pub struct PipelineOptimizationConfig {
113 pub skip_class_gating: bool,
114 pub capitals_heuristic_gate: bool,
115 pub prefix_cache: bool,
116 pub length_bucketing: bool,
117}
118
119impl PipelineOptimizationConfig {
120 pub fn new() -> Self {
121 Self::default()
122 }
123 pub fn with_skip_class_gating(mut self, enabled: bool) -> Self {
124 self.skip_class_gating = enabled;
125 self
126 }
127 pub fn with_capitals_heuristic_gate(mut self, enabled: bool) -> Self {
128 self.capitals_heuristic_gate = enabled;
129 self
130 }
131 pub fn with_prefix_cache(mut self, enabled: bool) -> Self {
132 self.prefix_cache = enabled;
133 self
134 }
135 pub fn with_length_bucketing(mut self, enabled: bool) -> Self {
136 self.length_bucketing = enabled;
137 self
138 }
139}
140
141impl Default for SafetyNetPolicy {
142 fn default() -> Self {
143 Self {
144 mode: SafetyNetMode::Resolve,
145 fallback: SafetyNetFallback::Redact,
146 }
147 }
148}
149
150impl SafetyNetPolicy {
151 pub fn new(mode: SafetyNetMode, fallback: SafetyNetFallback) -> Self {
152 Self { mode, fallback }
153 }
154}
155
156#[derive(Clone)]
188pub struct Pipeline {
189 registry: Arc<RecognizerRegistry>,
190 redaction_loggers: Vec<Arc<dyn RedactionLogger>>,
191 safety_nets: Vec<Arc<dyn SafetyNet>>,
192 #[cfg(feature = "bundled-recognizers")]
193 safety_net_registry: Option<Arc<LocaleAwareModelRegistry>>,
194 optimization_config: PipelineOptimizationConfig,
195 restore_boundary_dlp_audit: bool,
196 rules: Vec<Arc<dyn Rule>>,
197}
198
199#[derive(Debug, Clone, Default)]
201#[non_exhaustive]
202pub struct SafetyNetResult {
203 pub nets_run: usize,
205 pub report: LeakReport,
207}
208
209impl std::fmt::Debug for Pipeline {
210 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211 f.debug_struct("Pipeline").finish_non_exhaustive()
212 }
213}
214
215impl Pipeline {
216 pub fn builder() -> PipelineBuilder {
217 PipelineBuilder::default()
218 }
219
220 pub fn with_redaction_logger<L>(mut self, logger: L) -> Pipeline
221 where
222 L: RedactionLogger + 'static,
223 {
224 self.redaction_loggers.push(Arc::new(logger));
225 self
226 }
227
228 pub fn with_safety_net<N>(mut self, safety_net: N) -> Pipeline
229 where
230 N: SafetyNet + 'static,
231 {
232 self.safety_nets.push(Arc::new(safety_net));
233 self
234 }
235
236 pub fn restore_with_telemetry(
237 &self,
238 session: &Session,
239 text: &str,
240 ) -> Result<(RestoredText, RestoreTelemetry)> {
241 self.restore_with_policy_telemetry(session, text, RestorePolicy::Strict)
242 }
243
244 pub fn restore_with_policy_telemetry(
245 &self,
246 session: &Session,
247 text: &str,
248 policy: RestorePolicy,
249 ) -> Result<(RestoredText, RestoreTelemetry)> {
250 let mut telemetry = RestoreTelemetry::new(policy);
251 telemetry.phase_execution_mask |= RESTORE_PHASE_MANIFEST_LOOKUP;
252 let restored = restore_known_tokens(session, text)?;
253 telemetry.phase_execution_mask |=
254 RESTORE_PHASE_UNKNOWN_TOKEN_SCAN | RESTORE_PHASE_MANIFEST_BYPASS_SCAN;
255 let unknown_token_count = count_unknown_restore_tokens(session, &restored);
256 telemetry.unknown_token_count = unknown_token_count;
257 telemetry.manifest_bypass_count = unknown_token_count;
258 telemetry.restore_decision = match (policy, unknown_token_count) {
259 (_, 0) => RestoreDecision::Success,
260 (RestorePolicy::Strict, _) => RestoreDecision::Failed,
261 (RestorePolicy::Lenient, _) => RestoreDecision::Partial,
262 (_, _) => RestoreDecision::Failed,
263 };
264 Ok((RestoredText::new(restored), telemetry))
265 }
266
267 pub fn with_pipeline_optimizations(mut self, config: PipelineOptimizationConfig) -> Pipeline {
268 self.optimization_config = config;
269 self
270 }
271
272 #[cfg(feature = "bundled-recognizers")]
273 pub fn with_safety_net_registry(mut self, registry: LocaleAwareModelRegistry) -> Pipeline {
274 self.safety_net_registry = Some(Arc::new(registry));
275 self
276 }
277
278 pub fn redact(&self, session: &Session, raw: RawDocument) -> Result<CleanDocument> {
283 let locale_chain = [crate::LocaleTag::Global];
284 self.pseudonymize_with_context(session, raw, &locale_chain)
285 }
286
287 pub fn pseudonymize_with_context(
288 &self,
289 session: &Session,
290 raw: RawDocument,
291 locale_chain: &[crate::LocaleTag],
292 ) -> Result<CleanDocument> {
293 let dictionaries = DictionaryBundle::default();
294 self.pseudonymize_with_detect_context(session, raw, locale_chain, &dictionaries)
295 }
296
297 pub fn pseudonymize_with_detect_context(
298 &self,
299 session: &Session,
300 raw: RawDocument,
301 locale_chain: &[crate::LocaleTag],
302 dictionaries: &DictionaryBundle,
303 ) -> Result<CleanDocument> {
304 match raw {
305 RawDocument::Structured(structured_fields) => redact_structured(
306 self,
307 session,
308 structured_fields,
309 DocumentKind::Structured,
310 locale_chain,
311 dictionaries,
312 ),
313 RawDocument::Text(text) => Ok(CleanDocument::Text(self.pseudonymize_text(
314 session,
315 &text,
316 None,
317 DocumentKind::Text,
318 locale_chain,
319 dictionaries,
320 )?)),
321 _ => Err(Error::UnsupportedRawDocumentVariant),
322 }
323 }
324
325 pub fn clean_with_safety_net(
326 &self,
327 session: &Session,
328 raw: RawDocument,
329 locale_chain: &[crate::LocaleTag],
330 ) -> Result<(CleanDocument, Vec<EmittedTokenSpan>, LeakReport)> {
331 let dictionaries = DictionaryBundle::default();
332 self.clean_with_safety_net_detect_context(session, raw, locale_chain, &dictionaries)
333 }
334
335 pub fn clean_with_safety_net_detect_context(
336 &self,
337 session: &Session,
338 raw: RawDocument,
339 locale_chain: &[crate::LocaleTag],
340 dictionaries: &DictionaryBundle,
341 ) -> Result<(CleanDocument, Vec<EmittedTokenSpan>, LeakReport)> {
342 self.clean_with_safety_net_policy_detect_context(
343 session,
344 raw,
345 locale_chain,
346 dictionaries,
347 SafetyNetPolicy::new(SafetyNetMode::Strict, SafetyNetFallback::Redact),
348 )
349 }
350
351 pub fn clean_with_safety_net_policy_detect_context(
352 &self,
353 session: &Session,
354 raw: RawDocument,
355 locale_chain: &[crate::LocaleTag],
356 dictionaries: &DictionaryBundle,
357 policy: SafetyNetPolicy,
358 ) -> Result<(CleanDocument, Vec<EmittedTokenSpan>, LeakReport)> {
359 match raw {
360 RawDocument::Structured(structured_fields) => {
361 let mut report = LeakReport::default();
362 let clean = redact_structured_with_safety_net(
363 self,
364 session,
365 structured_fields,
366 locale_chain,
367 dictionaries,
368 &mut report,
369 policy,
370 )?;
371 Ok((CleanDocument::Structured(clean), Vec::new(), report))
372 }
373 RawDocument::Text(text) => {
374 let mut clean = self.redact_text_with_manifest(
375 session,
376 &text,
377 None,
378 DocumentKind::Text,
379 locale_chain,
380 dictionaries,
381 )?;
382 let report = self.run_safety_nets(
383 session,
384 &clean.text,
385 &Manifest::from_spans(clean.manifest.clone()),
386 DocumentKind::Text,
387 locale_chain,
388 None,
389 policy.mode,
390 )?;
391 self.apply_safety_net_policy(
392 session,
393 &mut clean,
394 &report,
395 DocumentKind::Text,
396 locale_chain,
397 None,
398 policy,
399 )?;
400 Ok((CleanDocument::Text(clean.text), clean.manifest, report))
401 }
402 _ => Err(Error::UnsupportedRawDocumentVariant),
403 }
404 }
405
406 pub fn scan_safety_nets(
407 &self,
408 session: &Session,
409 clean_text: &str,
410 locale_chain: &[crate::LocaleTag],
411 ) -> Result<SafetyNetResult> {
412 let nets_run = self.safety_nets_len();
413 if nets_run == 0 {
414 return Ok(SafetyNetResult {
415 nets_run,
416 report: LeakReport::default(),
417 });
418 }
419
420 let report = self.run_safety_nets(
421 session,
422 clean_text,
423 &Manifest::default(),
424 DocumentKind::Text,
425 locale_chain,
426 None,
427 SafetyNetMode::Strict,
428 )?;
429 Ok(SafetyNetResult { nets_run, report })
430 }
431
432 pub fn scan_safety_nets_structured(
433 &self,
434 session: &Session,
435 document: &BTreeMap<String, Value>,
436 locale_chain: &[crate::LocaleTag],
437 ) -> Result<SafetyNetResult> {
438 let nets_run = self.safety_nets_len();
439 if nets_run == 0 {
440 return Ok(SafetyNetResult {
441 nets_run,
442 report: LeakReport::default(),
443 });
444 }
445
446 let mut report = LeakReport::default();
447 for (key, value) in document {
448 walk_value_for_safety_net_scan(self, session, value, key, locale_chain, &mut report)?;
449 }
450 Ok(SafetyNetResult { nets_run, report })
451 }
452
453 pub fn restore_strict_text(&self, session: &Session, text: &str) -> Result<String> {
454 match session.restore_strict_text_with_events(text) {
455 Ok((restored, events)) => {
456 if self.restore_boundary_dlp_audit {
457 self.log_restore_events(session, &events)?;
458 }
459 Ok(restored)
460 }
461 Err(Error::UnknownToken {
462 class,
463 ordinal,
464 raw,
465 }) => {
466 self.log_restore_strict_rejection(session, class.clone(), ordinal)?;
467 Err(Error::UnknownToken {
468 class,
469 ordinal,
470 raw,
471 })
472 }
473 Err(err) => Err(err),
474 }
475 }
476
477 #[allow(clippy::too_many_arguments)]
478 fn pseudonymize_text(
479 &self,
480 session: &Session,
481 text: &str,
482 field_name: Option<&str>,
483 document_kind: DocumentKind,
484 locale_chain: &[crate::LocaleTag],
485 dictionaries: &DictionaryBundle,
486 ) -> Result<String> {
487 Ok(self
488 .redact_text_with_manifest(
489 session,
490 text,
491 field_name,
492 document_kind,
493 locale_chain,
494 dictionaries,
495 )?
496 .text)
497 }
498
499 #[allow(clippy::too_many_arguments)]
500 fn redact_text_with_manifest(
501 &self,
502 session: &Session,
503 text: &str,
504 field_name: Option<&str>,
505 document_kind: DocumentKind,
506 locale_chain: &[crate::LocaleTag],
507 dictionaries: &DictionaryBundle,
508 ) -> Result<CleanText> {
509 if self.optimization_config.prefix_cache {
510 if let Some(hit) = session
511 .lookup_prefix_cache(text)
512 .filter(|hit| hit.raw_len < text.len())
513 {
514 let suffix = &text[hit.raw_len..];
515 let suffix_clean = self.redact_text_with_manifest_uncached(
516 session,
517 suffix,
518 field_name,
519 document_kind,
520 locale_chain,
521 dictionaries,
522 )?;
523 let clean_offset = hit.clean_text.len();
524 let raw_offset = hit.raw_len;
525 let mut manifest = hit.manifest.clone();
526 manifest.extend(suffix_clean.manifest.into_iter().map(|mut span| {
527 span.clean_span.start += clean_offset;
528 span.clean_span.end += clean_offset;
529 span.raw_span.start += raw_offset;
530 span.raw_span.end += raw_offset;
531 span
532 }));
533 let mut clean_text = hit.clean_text;
534 clean_text.push_str(&suffix_clean.text);
535 self.log_prefix_cache_entries(
536 session,
537 &hit.manifest,
538 field_name,
539 document_kind,
540 locale_chain,
541 )?;
542 session.store_prefix_cache(text, &clean_text, &manifest);
543 return Ok(CleanText {
544 text: clean_text,
545 manifest,
546 });
547 }
548 }
549
550 let clean = self.redact_text_with_manifest_uncached(
551 session,
552 text,
553 field_name,
554 document_kind,
555 locale_chain,
556 dictionaries,
557 )?;
558 if self.optimization_config.prefix_cache {
559 session.store_prefix_cache(text, &clean.text, &clean.manifest);
560 }
561 Ok(clean)
562 }
563
564 #[allow(clippy::too_many_arguments)]
565 fn redact_text_with_manifest_uncached(
566 &self,
567 session: &Session,
568 text: &str,
569 field_name: Option<&str>,
570 document_kind: DocumentKind,
571 locale_chain: &[crate::LocaleTag],
572 dictionaries: &DictionaryBundle,
573 ) -> Result<CleanText> {
574 let normalized = normalize(text);
575 let spans = &normalized.spans;
576 let ctx = DetectContext::new(locale_chain, dictionaries);
577 let (resolved, vetoed) = self.registry.detect_all_resolved(&normalized.text, &ctx)?;
578 let vetoed = vetoed
579 .into_iter()
580 .filter_map(|vetoed| translate_vetoed_candidate(vetoed, spans))
581 .collect::<Vec<_>>();
582 let resolved = resolved
583 .into_iter()
584 .filter_map(|candidate| translate_candidate(candidate, spans))
585 .collect::<Vec<_>>();
586 let losers = merged_losers(&resolved, &self.registry);
587 let mut detections = resolved
588 .into_iter()
589 .map(|candidate| indexed_detection_from_candidate(candidate, &self.registry))
590 .collect::<Vec<_>>();
591 for loser in &losers {
592 self.log_entry(
593 session,
594 loser,
595 field_name,
596 document_kind,
597 self.action_for(&loser.detection, &build_context(field_name)),
598 true,
599 )?;
600 }
601 for vetoed in &vetoed {
602 self.log_vetoed_entry(session, vetoed, field_name, document_kind)?;
603 }
604
605 detections.sort_by_key(|d| d.detection.span.start);
606 let mut out = String::with_capacity(text.len());
607 let mut emitted = Vec::with_capacity(detections.len());
608 let mut cursor = 0usize;
609
610 for detection in detections {
611 let raw = text[detection.detection.span.clone()].to_string();
612 let context = build_context(field_name);
613 let action = self.action_for(&detection.detection, &context);
614 self.log_entry(
615 session,
616 &detection,
617 field_name,
618 document_kind,
619 action,
620 false,
621 )?;
622
623 let replacement = match action {
624 Action::Tokenize => Some(session.tokenize_with_family(
625 &detection.family,
626 &detection.detection.class,
627 &raw,
628 )?),
629 Action::Redact => Some("[REDACTED]".to_string()),
630 Action::FormatPreserve => {
631 Some(session.format_preserving_fake(&detection.detection.class, &raw)?)
632 }
633 Action::Generalize => Some(generalize_token(&detection.detection.class)),
634 Action::Preserve => None,
635 _ => return Err(Error::UnsupportedActionVariant),
636 };
637
638 let span = detection.detection.span;
639 if span.start > cursor {
640 out.push_str(&text[cursor..span.start]);
641 }
642 match replacement {
643 Some(replacement) => {
644 let clean_start = out.len();
645 out.push_str(&replacement);
646 emitted.push(EmittedTokenSpan::new(
647 clean_start..out.len(),
648 span.clone(),
649 detection.detection.class,
650 ));
651 }
652 None => out.push_str(&text[span.clone()]),
653 }
654 cursor = span.end;
655 }
656
657 if cursor < text.len() {
658 out.push_str(&text[cursor..]);
659 }
660
661 Ok(CleanText {
662 text: out,
663 manifest: emitted,
664 })
665 }
666
667 #[allow(clippy::too_many_arguments)]
668 fn run_safety_nets(
669 &self,
670 session: &Session,
671 clean_text: &str,
672 manifest: &Manifest,
673 document_kind: DocumentKind,
674 locale_chain: &[crate::LocaleTag],
675 field_path: Option<&str>,
676 safety_net_mode: SafetyNetMode,
677 ) -> Result<LeakReport> {
678 if self.safety_nets_len() == 0 {
679 return Ok(LeakReport::default());
680 }
681 if self.should_skip_safety_nets(clean_text, manifest, locale_chain, safety_net_mode)? {
682 return Ok(LeakReport::default());
683 }
684
685 let mut suspects = Vec::<LeakSuspect>::new();
686 let mut telemetry = Vec::new();
687 let active = gaze_types::LocaleChain::from(locale_chain);
688 for net in &self.safety_nets {
689 if !active.intersects(net.supported_locales()) {
690 telemetry.push(LeakReportTelemetry::LocaleSkipped {
691 safety_net_id: net.id().to_string(),
692 document_kind,
693 field_path: field_path.map(str::to_string),
694 });
695 continue;
696 }
697
698 let context = SafetyNetContext::new(
699 manifest,
700 locale_chain,
701 document_kind,
702 Some(session.audit_session_id()),
703 field_path,
704 );
705 let mut reported = net.check(clean_text, context)?;
706 if let Some(path) = field_path {
707 for suspect in &mut reported {
708 if suspect.field_path.is_none() {
709 suspect.field_path = Some(path.to_string());
710 }
711 }
712 }
713 suspects.extend(reported);
714 }
715 #[cfg(feature = "bundled-recognizers")]
716 if let Some(registry) = &self.safety_net_registry {
717 let locale = locale_chain
718 .first()
719 .cloned()
720 .unwrap_or(crate::LocaleTag::Global);
721 let selected = registry
722 .resolve(&locale, ModelStage::Pass3SafetyNet)
723 .map_err(model_error_to_safety_net_error)?;
724 if selected.len() > 1 {
725 let selected_backend = selected[0].name();
726 let dropped = selected
727 .iter()
728 .skip(1)
729 .map(|backend| backend.name().to_string())
730 .collect::<Vec<_>>();
731 tracing::debug!(
732 selected_backend,
733 backend_silently_dropped = ?dropped,
734 "locale-aware safety-net registry resolved multiple backends; using first"
735 );
736 self.log_backend_silently_dropped(
737 session,
738 document_kind,
739 field_path,
740 selected_backend,
741 dropped,
742 )?;
743 }
744 if let Some(model) = selected.first() {
745 let spans = model
746 .infer(
747 ModelInput {
748 text: clean_text.to_string(),
749 locale,
750 },
751 ModelHints {
752 stage: ModelStage::Pass3SafetyNet,
753 max_spans: None,
754 },
755 )
756 .map_err(model_error_to_safety_net_error)?;
757 for span in spans {
758 if let Some(suspect) =
759 model_span_to_suspect(span, model.name(), manifest, field_path)
760 {
761 suspects.push(suspect);
762 }
763 }
764 }
765 }
766
767 Ok(LeakReport::from_parts(suspects, telemetry))
768 }
769
770 fn should_skip_safety_nets(
771 &self,
772 clean_text: &str,
773 manifest: &Manifest,
774 locale_chain: &[crate::LocaleTag],
775 safety_net_mode: SafetyNetMode,
776 ) -> Result<bool> {
777 if !matches!(
778 safety_net_mode,
779 SafetyNetMode::Strict | SafetyNetMode::Tolerant
780 ) {
781 return Ok(false);
782 }
783 if self.optimization_config.capitals_heuristic_gate {
784 validate_capitals_gate_locales(locale_chain)?;
785 if is_numeric_heavy(clean_text) || !has_non_sentence_start_capital(clean_text) {
786 return Ok(true);
787 }
788 }
789 if self.optimization_config.skip_class_gating
790 && !manifest.spans.is_empty()
791 && !has_residual_gold_shape(clean_text)
792 {
793 return Ok(true);
794 }
795 Ok(false)
796 }
797
798 fn safety_nets_len(&self) -> usize {
799 let single = self.safety_nets.len();
800 #[cfg(feature = "bundled-recognizers")]
801 {
802 single
803 + self
804 .safety_net_registry
805 .as_ref()
806 .map_or(0, |registry| usize::from(!registry.is_empty()))
807 }
808 #[cfg(not(feature = "bundled-recognizers"))]
809 {
810 single
811 }
812 }
813
814 #[allow(clippy::too_many_arguments)]
815 fn apply_safety_net_policy(
816 &self,
817 session: &Session,
818 clean: &mut CleanText,
819 report: &LeakReport,
820 document_kind: DocumentKind,
821 locale_chain: &[crate::LocaleTag],
822 field_path: Option<&str>,
823 policy: SafetyNetPolicy,
824 ) -> Result<()> {
825 match policy.mode {
826 SafetyNetMode::Strict | SafetyNetMode::Tolerant => Ok(()),
827 SafetyNetMode::Redact => self.redact_safety_net_suspects(
828 session,
829 clean,
830 report,
831 document_kind,
832 field_path,
833 None,
834 true,
835 ),
836 SafetyNetMode::Resolve => {
837 let reason = match self.resolve_safety_net_suspects(
838 session,
839 clean,
840 report,
841 document_kind,
842 field_path,
843 )? {
844 Some(reason) => Some(reason),
845 None => {
846 let follow_up = self.run_safety_nets(
847 session,
848 &clean.text,
849 &Manifest::from_spans(clean.manifest.clone()),
850 document_kind,
851 locale_chain,
852 field_path,
853 SafetyNetMode::Resolve,
854 )?;
855 (follow_up.stats.uncovered_count + follow_up.stats.partial_bleed_count > 0)
856 .then_some(FallbackReason::ResidualSuspect)
857 }
858 };
859 if let Some(reason) = reason {
860 self.apply_safety_net_fallback(
861 session,
862 clean,
863 report,
864 document_kind,
865 field_path,
866 policy.fallback,
867 reason,
868 )?;
869 }
870 Ok(())
871 }
872 }
873 }
874
875 fn resolve_safety_net_suspects(
876 &self,
877 session: &Session,
878 clean: &mut CleanText,
879 report: &LeakReport,
880 document_kind: DocumentKind,
881 field_path: Option<&str>,
882 ) -> Result<Option<FallbackReason>> {
883 if report
884 .suspects
885 .iter()
886 .any(|suspect| matches!(suspect.kind, LeakKind::ClassMismatch { .. }))
887 {
888 return Ok(Some(FallbackReason::OverlapConflict));
889 }
890 for suspect in actionable_suspects(report).into_iter().rev() {
891 let span = suspect_action_span(suspect);
892 if !is_char_boundary_range(&clean.text, &span) {
893 return Ok(Some(FallbackReason::ResidualSuspect));
894 }
895 let raw = clean.text[span.clone()].to_string();
896 let replacement = session.tokenize_with_family("safety_net", &suspect.class, &raw)?;
897 self.log_safety_net_entry(
898 session,
899 suspect,
900 document_kind,
901 field_path,
902 Action::Tokenize,
903 false,
904 ConflictTier::Resolve,
905 None,
906 )?;
907 replace_clean_span(
908 clean,
909 span.clone(),
910 &replacement,
911 Some(EmittedTokenSpan::new(
912 span.start..span.start + replacement.len(),
913 span,
914 suspect.class.clone(),
915 )),
916 );
917 }
918 Ok(None)
919 }
920
921 #[allow(clippy::too_many_arguments)]
922 fn apply_safety_net_fallback(
923 &self,
924 session: &Session,
925 clean: &mut CleanText,
926 report: &LeakReport,
927 document_kind: DocumentKind,
928 field_path: Option<&str>,
929 fallback: SafetyNetFallback,
930 reason: FallbackReason,
931 ) -> Result<()> {
932 for suspect in redaction_suspects(report) {
933 self.log_safety_net_entry(
934 session,
935 suspect,
936 document_kind,
937 field_path,
938 fallback_action(fallback),
939 true,
940 ConflictTier::Fallback,
941 Some(reason),
942 )?;
943 }
944 match fallback {
945 SafetyNetFallback::Strict => Err(Error::SafetyNetFallback(reason)),
946 SafetyNetFallback::Tolerant => Ok(()),
947 SafetyNetFallback::Redact => self.redact_safety_net_suspects(
948 session,
949 clean,
950 report,
951 document_kind,
952 field_path,
953 Some(reason),
954 false,
955 ),
956 }
957 }
958
959 #[allow(clippy::too_many_arguments)]
960 fn redact_safety_net_suspects(
961 &self,
962 session: &Session,
963 clean: &mut CleanText,
964 report: &LeakReport,
965 document_kind: DocumentKind,
966 field_path: Option<&str>,
967 fallback_reason: Option<FallbackReason>,
968 log_entries: bool,
969 ) -> Result<()> {
970 for suspect in redaction_suspects(report).into_iter().rev() {
971 let span =
972 round_span_outward_to_char_boundaries(&clean.text, suspect_action_span(suspect))?;
973 let span = expand_span_to_overlapping_manifest_entries(clean, span);
974 for existing in clean
975 .manifest
976 .iter()
977 .filter(|existing| ranges_overlap(&existing.clean_span, &span))
978 {
979 tracing::warn!(
980 class = ?existing.class,
981 clean_span_start = existing.clean_span.start,
982 clean_span_end = existing.clean_span.end,
983 "safety net redaction dropping overlapping manifest entry"
984 );
985 }
986 if log_entries {
987 self.log_safety_net_entry(
988 session,
989 suspect,
990 document_kind,
991 field_path,
992 Action::Redact,
993 fallback_reason.is_some(),
994 if fallback_reason.is_some() {
995 ConflictTier::Fallback
996 } else {
997 ConflictTier::Redact
998 },
999 fallback_reason,
1000 )?;
1001 }
1002 replace_clean_span(clean, span, "", None);
1003 }
1004 Ok(())
1005 }
1006
1007 #[allow(clippy::too_many_arguments)]
1008 fn log_safety_net_entry(
1009 &self,
1010 session: &Session,
1011 suspect: &LeakSuspect,
1012 document_kind: DocumentKind,
1013 field_path: Option<&str>,
1014 action: Action,
1015 conflict_loser: bool,
1016 decided_by: ConflictTier,
1017 fallback_reason: Option<FallbackReason>,
1018 ) -> Result<()> {
1019 let source = format!("safety_net.{}", suspect.safety_net_id);
1020 let mut entry = RedactionEntry::new(
1021 source.clone(),
1022 suspect.class.clone(),
1023 action,
1024 field_path
1025 .map(str::to_string)
1026 .or_else(|| suspect.field_path.clone()),
1027 document_kind,
1028 conflict_loser,
1029 decided_by,
1030 crate::redaction_log::current_epoch_ms(),
1031 Some(session.audit_session_id().to_string()),
1032 )
1033 .with_recognizer_metadata(Some(source), None);
1034 if let Some(reason) = fallback_reason {
1035 entry = entry.with_fallback_triggered(reason);
1036 }
1037 for logger in &self.redaction_loggers {
1038 logger.log(&entry)?;
1039 }
1040 Ok(())
1041 }
1042
1043 fn log_backend_silently_dropped(
1044 &self,
1045 session: &Session,
1046 document_kind: DocumentKind,
1047 field_path: Option<&str>,
1048 selected_backend: &str,
1049 dropped: Vec<String>,
1050 ) -> Result<()> {
1051 let entry = RedactionEntry::new(
1052 format!("safety_net.{selected_backend}"),
1053 PiiClass::Custom("safety_net.backend".to_string()),
1054 Action::Preserve,
1055 field_path.map(str::to_string),
1056 document_kind,
1057 true,
1058 ConflictTier::None,
1059 crate::redaction_log::current_epoch_ms(),
1060 Some(session.audit_session_id().to_string()),
1061 )
1062 .with_backend_silently_dropped(dropped);
1063 for logger in &self.redaction_loggers {
1064 logger.log(&entry)?;
1065 }
1066 Ok(())
1067 }
1068
1069 fn action_for(&self, detection: &Detection, context: &RuleContext) -> Action {
1070 self.rules
1071 .iter()
1072 .find_map(|rule| rule.action(&detection.class, context))
1073 .unwrap_or(Action::Preserve)
1074 }
1075
1076 fn log_entry(
1077 &self,
1078 session: &Session,
1079 detection: &IndexedDetection,
1080 field_name: Option<&str>,
1081 document_kind: DocumentKind,
1082 action: Action,
1083 conflict_loser: bool,
1084 ) -> Result<()> {
1085 let mut entry = RedactionEntry::new(
1086 detection.detection.source.clone(),
1087 detection.detection.class.clone(),
1088 action,
1089 field_name.map(str::to_string),
1090 document_kind,
1091 conflict_loser,
1092 detection.decided_by,
1093 crate::redaction_log::current_epoch_ms(),
1094 Some(session.audit_session_id().to_string()),
1095 )
1096 .with_recognizer_metadata(
1097 detection.recognizer_id.clone(),
1098 detection.recognizer_version_id.clone(),
1099 );
1100 if let Some(record) = detection.ambiguity_record.clone() {
1101 entry = entry.with_ambiguity_record(record);
1102 }
1103 if detection.collision_family.is_some() || detection.collision_variant.is_some() {
1104 entry = entry.with_collision_metadata(
1105 detection.collision_family.clone(),
1106 detection.collision_variant.clone(),
1107 );
1108 }
1109
1110 for logger in &self.redaction_loggers {
1111 logger.log(&entry)?;
1112 }
1113
1114 Ok(())
1115 }
1116
1117 fn log_prefix_cache_entries(
1118 &self,
1119 session: &Session,
1120 manifest: &[EmittedTokenSpan],
1121 field_name: Option<&str>,
1122 document_kind: DocumentKind,
1123 locale_chain: &[crate::LocaleTag],
1124 ) -> Result<()> {
1125 let locale = locale_chain
1126 .first()
1127 .map(crate::LocaleTag::as_str)
1128 .unwrap_or("global")
1129 .to_string();
1130 for span in manifest {
1131 let entry = RedactionEntry::new(
1132 "prefix_cache",
1133 span.class.clone(),
1134 Action::Tokenize,
1135 field_name.map(str::to_string),
1136 document_kind,
1137 false,
1138 ConflictTier::None,
1139 crate::redaction_log::current_epoch_ms(),
1140 Some(session.audit_session_id().to_string()),
1141 )
1142 .with_recognizer_metadata(Some("prefix_cache".to_string()), None)
1143 .with_provenance_metadata(
1144 Some("prefix_cache".to_string()),
1145 None,
1146 None,
1147 None,
1148 None,
1149 Some(locale.clone()),
1150 Some("session_prefix".to_string()),
1151 Some(span.class.to_canonical_str()),
1152 Some(span.class.to_canonical_str()),
1153 None,
1154 None,
1155 );
1156 for logger in &self.redaction_loggers {
1157 logger.log(&entry)?;
1158 }
1159 }
1160 Ok(())
1161 }
1162
1163 fn log_restore_strict_rejection(
1164 &self,
1165 session: &Session,
1166 class: PiiClass,
1167 ordinal: u32,
1168 ) -> Result<()> {
1169 let entry = RedactionEntry::new(
1170 "restore_strict",
1171 class.clone(),
1172 Action::Preserve,
1173 None,
1174 DocumentKind::Text,
1175 true,
1176 ConflictTier::None,
1177 crate::redaction_log::current_epoch_ms(),
1178 Some(session.audit_session_id().to_string()),
1179 )
1180 .with_recognizer_metadata(Some("restore_strict".to_string()), None)
1181 .with_provenance_metadata(
1182 Some("restore_strict".to_string()),
1183 None,
1184 None,
1185 None,
1186 None,
1187 None,
1188 None,
1189 Some(class.to_canonical_str()),
1190 Some(class.to_canonical_str()),
1191 None,
1192 Some(format!("ordinal:{ordinal}")),
1193 );
1194 for logger in &self.redaction_loggers {
1195 logger.log(&entry)?;
1196 }
1197 Ok(())
1198 }
1199
1200 fn log_restore_events(&self, session: &Session, events: &[RestoreEvent]) -> Result<()> {
1201 for event in events {
1202 let entry = RedactionEntry::new(
1203 event.kind.as_str(),
1204 event.class.clone(),
1205 Action::Preserve,
1206 None,
1207 DocumentKind::Text,
1208 false,
1209 ConflictTier::None,
1210 crate::redaction_log::current_epoch_ms(),
1211 Some(session.audit_session_id().to_string()),
1212 )
1213 .with_recognizer_metadata(Some("restore_boundary_dlp".to_string()), None)
1214 .with_provenance_metadata(
1215 Some("restore_boundary_dlp".to_string()),
1216 None,
1217 None,
1218 None,
1219 None,
1220 None,
1221 Some(event.kind.as_str().to_string()),
1222 Some(event.class.to_canonical_str()),
1223 Some(event.class.to_canonical_str()),
1224 None,
1225 Some(format!(
1226 "sha256:{};span:{}..{}",
1227 event.raw_sha256, event.location.start, event.location.end
1228 )),
1229 );
1230 for logger in &self.redaction_loggers {
1231 logger.log(&entry)?;
1232 }
1233 }
1234 Ok(())
1235 }
1236
1237 fn log_vetoed_entry(
1238 &self,
1239 session: &Session,
1240 vetoed: &crate::validator_veto::VetoedCandidate,
1241 field_name: Option<&str>,
1242 document_kind: DocumentKind,
1243 ) -> Result<()> {
1244 let entry = RedactionEntry::new(
1245 vetoed.candidate.source.clone(),
1246 vetoed.candidate.class.clone(),
1247 self.action_for(
1248 &Detection::new(
1249 vetoed.candidate.span.clone(),
1250 vetoed.candidate.class.clone(),
1251 vetoed.candidate.source.clone(),
1252 ),
1253 &build_context(field_name),
1254 ),
1255 field_name.map(str::to_string),
1256 document_kind,
1257 true,
1258 ConflictTier::ValidatorVeto,
1259 crate::redaction_log::current_epoch_ms(),
1260 Some(session.audit_session_id().to_string()),
1261 )
1262 .with_recognizer_metadata(
1263 Some(vetoed.candidate.recognizer_id.clone()),
1264 vetoed.candidate.recognizer_version_id.clone(),
1265 )
1266 .with_validator_fail_reason(vetoed.reason);
1267
1268 for logger in &self.redaction_loggers {
1269 logger.log(&entry)?;
1270 }
1271
1272 Ok(())
1273 }
1274}
1275
1276#[derive(Clone)]
1277struct IndexedDetection {
1278 detection: Detection,
1279 recognizer_id: Option<String>,
1280 recognizer_version_id: Option<String>,
1281 decided_by: ConflictTier,
1282 family: String,
1283 ambiguity_record: Option<AmbiguityRecord>,
1284 collision_family: Option<String>,
1285 collision_variant: Option<String>,
1286}
1287
1288struct CleanText {
1289 text: String,
1290 manifest: Vec<EmittedTokenSpan>,
1291}
1292
1293fn actionable_suspects(report: &LeakReport) -> Vec<&LeakSuspect> {
1294 let mut suspects = report
1295 .suspects
1296 .iter()
1297 .filter(|suspect| {
1298 matches!(
1299 suspect.kind,
1300 LeakKind::Uncovered | LeakKind::PartialBleed { .. }
1301 )
1302 })
1303 .collect::<Vec<_>>();
1304 suspects.sort_by_key(|suspect| suspect_action_span(suspect).start);
1305 suspects
1306}
1307
1308fn redaction_suspects(report: &LeakReport) -> Vec<&LeakSuspect> {
1309 let mut suspects = report.suspects.iter().collect::<Vec<_>>();
1310 suspects.sort_by_key(|suspect| suspect_action_span(suspect).start);
1311 suspects
1312}
1313
1314fn suspect_action_span(suspect: &LeakSuspect) -> Range<usize> {
1315 match &suspect.kind {
1316 LeakKind::PartialBleed { uncovered } => uncovered.clone(),
1317 _ => suspect.span.clone(),
1318 }
1319}
1320
1321fn fallback_action(fallback: SafetyNetFallback) -> Action {
1322 match fallback {
1323 SafetyNetFallback::Strict | SafetyNetFallback::Tolerant => Action::Preserve,
1324 SafetyNetFallback::Redact => Action::Redact,
1325 }
1326}
1327
1328fn is_char_boundary_range(text: &str, span: &Range<usize>) -> bool {
1329 span.start <= span.end
1330 && span.end <= text.len()
1331 && text.is_char_boundary(span.start)
1332 && text.is_char_boundary(span.end)
1333}
1334
1335fn round_span_outward_to_char_boundaries(text: &str, span: Range<usize>) -> Result<Range<usize>> {
1336 if span.start > span.end || span.end > text.len() {
1337 return Err(Error::SafetyNetSpanInvalid {
1338 start: span.start,
1339 end: span.end,
1340 text_len: text.len(),
1341 });
1342 }
1343
1344 let mut start = span.start;
1345 while start > 0 && !text.is_char_boundary(start) {
1346 start -= 1;
1347 }
1348
1349 let mut end = span.end;
1350 while end < text.len() && !text.is_char_boundary(end) {
1351 end += 1;
1352 }
1353
1354 if !is_char_boundary_range(text, &(start..end)) {
1355 return Err(Error::SafetyNetSpanInvalid {
1356 start: span.start,
1357 end: span.end,
1358 text_len: text.len(),
1359 });
1360 }
1361
1362 Ok(start..end)
1363}
1364
1365fn expand_span_to_overlapping_manifest_entries(
1366 clean: &CleanText,
1367 span: Range<usize>,
1368) -> Range<usize> {
1369 let mut expanded = span;
1370 loop {
1371 let mut changed = false;
1372 for existing in &clean.manifest {
1373 if ranges_overlap(&existing.clean_span, &expanded) {
1374 let start = expanded.start.min(existing.clean_span.start);
1375 let end = expanded.end.max(existing.clean_span.end);
1376 changed |= start != expanded.start || end != expanded.end;
1377 expanded = start..end;
1378 }
1379 }
1380 if !changed {
1381 return expanded;
1382 }
1383 }
1384}
1385
1386fn ranges_overlap(left: &Range<usize>, right: &Range<usize>) -> bool {
1387 left.start < right.end && right.start < left.end
1388}
1389
1390fn validate_capitals_gate_locales(locale_chain: &[crate::LocaleTag]) -> Result<()> {
1391 for locale in locale_chain {
1392 if !capital_case_locale_supported(locale) {
1393 return Err(Error::UnsupportedCapitalHeuristicLocale {
1394 locale: locale.as_str().to_string(),
1395 });
1396 }
1397 }
1398 Ok(())
1399}
1400
1401fn capital_case_locale_supported(locale: &crate::LocaleTag) -> bool {
1402 matches!(
1403 locale,
1404 crate::LocaleTag::Global
1405 | crate::LocaleTag::DeDe
1406 | crate::LocaleTag::DeAt
1407 | crate::LocaleTag::DeCh
1408 | crate::LocaleTag::EnUs
1409 | crate::LocaleTag::EnGb
1410 | crate::LocaleTag::EnIe
1411 | crate::LocaleTag::EnAu
1412 | crate::LocaleTag::EnCa
1413 )
1414}
1415
1416fn is_numeric_heavy(text: &str) -> bool {
1417 let digits = text.chars().filter(|ch| ch.is_ascii_digit()).count();
1418 let letters = text.chars().filter(|ch| ch.is_alphabetic()).count();
1419 digits >= 6 && digits > letters.saturating_mul(2)
1420}
1421
1422fn has_non_sentence_start_capital(text: &str) -> bool {
1426 let mut sentence_start = true;
1427 for ch in text.chars() {
1428 if ch.is_whitespace() {
1429 continue;
1430 }
1431 if ch.is_uppercase() && !sentence_start {
1432 return true;
1433 }
1434 sentence_start = matches!(ch, '.' | '!' | '?');
1435 }
1436 false
1437}
1438
1439fn has_residual_gold_shape(text: &str) -> bool {
1440 text.contains('@') || is_numeric_heavy(text)
1441}
1442
1443fn replace_clean_span(
1444 clean: &mut CleanText,
1445 span: Range<usize>,
1446 replacement: &str,
1447 emitted: Option<EmittedTokenSpan>,
1448) {
1449 let removed_len = span.end - span.start;
1450 let replacement_len = replacement.len();
1451 clean.text.replace_range(span.clone(), replacement);
1452 clean.manifest = clean
1453 .manifest
1454 .iter()
1455 .filter_map(|existing| adjust_emitted_span(existing, &span, replacement_len, removed_len))
1456 .chain(emitted)
1457 .collect();
1458 clean.manifest.sort_by_key(|span| span.clean_span.start);
1459}
1460
1461fn restore_known_tokens(session: &Session, text: &str) -> Result<String> {
1462 let Some(re) = session.restore_regex()? else {
1463 return Ok(text.to_string());
1464 };
1465 let mut out = String::with_capacity(text.len());
1466 let mut last = 0usize;
1467 for matched in re.find_iter(text) {
1468 out.push_str(&text[last..matched.start()]);
1469 out.push_str(&session.restore_strict(matched.as_str())?);
1470 last = matched.end();
1471 }
1472 out.push_str(&text[last..]);
1473 Ok(out)
1474}
1475
1476fn count_unknown_restore_tokens(session: &Session, text: &str) -> u64 {
1477 crate::token_shape::pattern()
1478 .find_iter(text)
1479 .filter(|matched| {
1480 let matched_text = matched.as_str();
1481 crate::token_shape::is_trap(matched_text) || !session.contains_token(matched_text)
1482 })
1483 .count() as u64
1484}
1485
1486fn adjust_emitted_span(
1487 existing: &EmittedTokenSpan,
1488 edited: &Range<usize>,
1489 replacement_len: usize,
1490 removed_len: usize,
1491) -> Option<EmittedTokenSpan> {
1492 if existing.clean_span.start < edited.end && edited.start < existing.clean_span.end {
1493 return None;
1494 }
1495 let mut span = existing.clone();
1496 if span.clean_span.start >= edited.end {
1497 if replacement_len >= removed_len {
1498 let delta = replacement_len - removed_len;
1499 span.clean_span.start += delta;
1500 span.clean_span.end += delta;
1501 } else {
1502 let delta = removed_len - replacement_len;
1503 span.clean_span.start -= delta;
1504 span.clean_span.end -= delta;
1505 }
1506 }
1507 Some(span)
1508}
1509
1510#[derive(Default)]
1518pub struct PipelineBuilder {
1519 recognizers: Vec<Arc<dyn Recognizer>>,
1520 collision_memberships: Vec<(String, CollisionMembership)>,
1521 anchor_cue_bundles: Vec<(crate::LocaleTag, String, Vec<String>, Option<u16>)>,
1522 redaction_loggers: Vec<Arc<dyn RedactionLogger>>,
1523 safety_nets: Vec<Arc<dyn SafetyNet>>,
1524 #[cfg(feature = "bundled-recognizers")]
1525 safety_net_registry: Option<Arc<LocaleAwareModelRegistry>>,
1526 optimization_config: PipelineOptimizationConfig,
1527 restore_boundary_dlp_audit: bool,
1528 rules: Vec<Arc<dyn Rule>>,
1529}
1530
1531impl PipelineBuilder {
1532 pub fn detector<D>(mut self, detector: D) -> Self
1533 where
1534 D: Detector + 'static,
1535 {
1536 self.recognizers
1537 .push(Arc::new(DetectorRecognizer::new(detector)));
1538 self
1539 }
1540
1541 pub fn recognizer<R>(mut self, recognizer: R) -> Self
1542 where
1543 R: Recognizer + 'static,
1544 {
1545 self.recognizers.push(Arc::new(recognizer));
1546 self
1547 }
1548
1549 pub fn register_collision(
1550 mut self,
1551 recognizer_id: impl Into<String>,
1552 membership: CollisionMembership,
1553 ) -> Self {
1554 self.collision_memberships
1555 .push((recognizer_id.into(), membership));
1556 self
1557 }
1558
1559 pub fn register_anchor_cue_bundle(
1560 mut self,
1561 locale: crate::LocaleTag,
1562 anchor_key: impl Into<String>,
1563 names: Vec<String>,
1564 window_chars: Option<u16>,
1565 ) -> Self {
1566 self.anchor_cue_bundles
1567 .push((locale, anchor_key.into(), names, window_chars));
1568 self
1569 }
1570
1571 pub fn rule<R>(mut self, rule: R) -> Self
1572 where
1573 R: Rule + 'static,
1574 {
1575 self.rules.push(Arc::new(rule));
1576 self
1577 }
1578
1579 pub fn redaction_logger<L>(mut self, logger: L) -> Self
1580 where
1581 L: RedactionLogger + 'static,
1582 {
1583 self.redaction_loggers.push(Arc::new(logger));
1584 self
1585 }
1586
1587 pub fn register_safety_net<N>(mut self, safety_net: N) -> Self
1588 where
1589 N: SafetyNet + 'static,
1590 {
1591 self.safety_nets.push(Arc::new(safety_net));
1592 self
1593 }
1594
1595 pub fn pipeline_optimizations(mut self, config: PipelineOptimizationConfig) -> Self {
1596 self.optimization_config = config;
1597 self
1598 }
1599
1600 pub fn enable_restore_boundary_dlp_audit(mut self) -> Self {
1601 self.restore_boundary_dlp_audit = true;
1602 self
1603 }
1604
1605 pub fn enable_skip_class_gating(mut self) -> Self {
1606 self.optimization_config.skip_class_gating = true;
1607 self
1608 }
1609
1610 pub fn enable_capitals_heuristic_gate(mut self) -> Self {
1611 self.optimization_config.capitals_heuristic_gate = true;
1612 self
1613 }
1614
1615 pub fn enable_prefix_cache(mut self) -> Self {
1616 self.optimization_config.prefix_cache = true;
1617 self
1618 }
1619
1620 pub fn enable_length_bucketing(mut self) -> Self {
1621 self.optimization_config.length_bucketing = true;
1622 self
1623 }
1624
1625 #[cfg(feature = "bundled-recognizers")]
1626 pub fn register_safety_net_registry(mut self, registry: LocaleAwareModelRegistry) -> Self {
1627 self.safety_net_registry = Some(Arc::new(registry));
1628 self
1629 }
1630
1631 pub fn build(self) -> Result<Pipeline> {
1632 let mut registry = RecognizerRegistry::builder();
1633 for recognizer in self.recognizers {
1634 registry = registry.register_arc(recognizer);
1635 }
1636 for (recognizer_id, membership) in self.collision_memberships {
1637 registry = registry.register_collision(recognizer_id, membership);
1638 }
1639 for (locale, anchor_key, names, window_chars) in self.anchor_cue_bundles {
1640 registry = registry.register_anchor_cue_bundle(locale, anchor_key, names, window_chars);
1641 }
1642 Ok(Pipeline {
1643 registry: Arc::new(registry.build()),
1644 redaction_loggers: self.redaction_loggers,
1645 safety_nets: self.safety_nets,
1646 #[cfg(feature = "bundled-recognizers")]
1647 safety_net_registry: self.safety_net_registry,
1648 optimization_config: self.optimization_config,
1649 restore_boundary_dlp_audit: self.restore_boundary_dlp_audit,
1650 rules: self.rules,
1651 })
1652 }
1653}
1654
1655#[cfg(feature = "bundled-recognizers")]
1656fn model_span_to_suspect(
1657 span: ModelSpan,
1658 backend_name: &str,
1659 manifest: &Manifest,
1660 field_path: Option<&str>,
1661) -> Option<LeakSuspect> {
1662 let kind = manifest.diff_against(&span.byte_range, &span.class)?;
1663 Some(LeakSuspect::new(
1664 span.byte_range,
1665 span.class.clone(),
1666 backend_name,
1667 span.confidence,
1668 kind,
1669 format!("{:?}", span.class),
1670 field_path.map(str::to_string),
1671 ))
1672}
1673
1674#[cfg(feature = "bundled-recognizers")]
1675fn model_error_to_safety_net_error(error: ModelError) -> SafetyNetError {
1676 match error {
1677 ModelError::NoLocaleModelCoverage { .. } | ModelError::LocaleNotSupported(_) => {
1678 SafetyNetError::Unavailable {
1679 reason: error.to_string(),
1680 }
1681 }
1682 ModelError::IntegrityMismatch => SafetyNetError::ModelIntegrityMismatch {
1683 expected: "locale-aware registry".to_string(),
1684 actual: "backend integrity mismatch".to_string(),
1685 },
1686 ModelError::InitFailed(reason) => SafetyNetError::ModelUnavailable { reason },
1687 ModelError::InferenceFailed(message) | ModelError::Internal(message) => {
1688 SafetyNetError::Runtime { message }
1689 }
1690 }
1691}
1692
1693fn redact_structured(
1694 pipeline: &Pipeline,
1695 session: &Session,
1696 fields: BTreeMap<String, Value>,
1697 document_kind: DocumentKind,
1698 locale_chain: &[crate::LocaleTag],
1699 dictionaries: &DictionaryBundle,
1700) -> Result<CleanDocument> {
1701 let mut clean = BTreeMap::new();
1702 for (key, value) in fields {
1703 let path = format!("$.{key}");
1704 clean.insert(
1705 key.clone(),
1706 redact_structured_value(
1707 pipeline,
1708 session,
1709 value,
1710 &key,
1711 &path,
1712 document_kind,
1713 locale_chain,
1714 dictionaries,
1715 )?,
1716 );
1717 }
1718 Ok(CleanDocument::Structured(clean))
1719}
1720
1721#[allow(clippy::too_many_arguments)]
1722fn redact_structured_value(
1723 pipeline: &Pipeline,
1724 session: &Session,
1725 value: Value,
1726 field_name: &str,
1727 field_path: &str,
1728 document_kind: DocumentKind,
1729 locale_chain: &[crate::LocaleTag],
1730 dictionaries: &DictionaryBundle,
1731) -> Result<Value> {
1732 match value {
1733 Value::String(text) => Ok(Value::String(pipeline.pseudonymize_text(
1734 session,
1735 &text,
1736 Some(field_name),
1737 document_kind,
1738 locale_chain,
1739 dictionaries,
1740 )?)),
1741 Value::Array(values) => values
1742 .into_iter()
1743 .enumerate()
1744 .map(|(idx, value)| {
1745 redact_structured_value(
1746 pipeline,
1747 session,
1748 value,
1749 field_name,
1750 &format!("{field_path}[{idx}]"),
1751 document_kind,
1752 locale_chain,
1753 dictionaries,
1754 )
1755 })
1756 .collect::<Result<Vec<_>>>()
1757 .map(Value::Array),
1758 Value::Object(fields) => {
1759 let mut clean = BTreeMap::new();
1760 for (key, value) in fields {
1761 let child_path = format!("{field_path}.{key}");
1762 clean.insert(
1763 key.clone(),
1764 redact_structured_value(
1765 pipeline,
1766 session,
1767 value,
1768 &key,
1769 &child_path,
1770 document_kind,
1771 locale_chain,
1772 dictionaries,
1773 )?,
1774 );
1775 }
1776 Ok(Value::Object(clean))
1777 }
1778 Value::Null | Value::Bool(_) | Value::I64(_) => Ok(value),
1779 _ => Err(Error::UnsupportedValueVariant),
1780 }
1781}
1782
1783#[allow(clippy::too_many_arguments)]
1784fn redact_structured_with_safety_net(
1785 pipeline: &Pipeline,
1786 session: &Session,
1787 fields: BTreeMap<String, Value>,
1788 locale_chain: &[crate::LocaleTag],
1789 dictionaries: &DictionaryBundle,
1790 report: &mut LeakReport,
1791 policy: SafetyNetPolicy,
1792) -> Result<BTreeMap<String, Value>> {
1793 let mut clean = BTreeMap::new();
1794 for (key, value) in fields {
1795 let path = format!("$.{key}");
1796 clean.insert(
1797 key.clone(),
1798 redact_structured_value_with_safety_net(
1799 pipeline,
1800 session,
1801 value,
1802 &key,
1803 &path,
1804 locale_chain,
1805 dictionaries,
1806 report,
1807 policy,
1808 )?,
1809 );
1810 }
1811 Ok(clean)
1812}
1813
1814#[allow(clippy::too_many_arguments)]
1815fn redact_structured_value_with_safety_net(
1816 pipeline: &Pipeline,
1817 session: &Session,
1818 value: Value,
1819 field_name: &str,
1820 field_path: &str,
1821 locale_chain: &[crate::LocaleTag],
1822 dictionaries: &DictionaryBundle,
1823 report: &mut LeakReport,
1824 policy: SafetyNetPolicy,
1825) -> Result<Value> {
1826 match value {
1827 Value::String(text) => {
1828 if text.is_empty() {
1829 return Ok(Value::String(text));
1830 }
1831 let clean = pipeline.redact_text_with_manifest(
1832 session,
1833 &text,
1834 Some(field_name),
1835 DocumentKind::Structured,
1836 locale_chain,
1837 dictionaries,
1838 )?;
1839 let field_report = pipeline.run_safety_nets(
1842 session,
1843 &clean.text,
1844 &Manifest::from_spans(clean.manifest),
1845 DocumentKind::Structured,
1846 locale_chain,
1847 Some(field_path),
1848 policy.mode,
1849 )?;
1850 report.extend(field_report);
1851 Ok(Value::String(clean.text))
1852 }
1853 Value::Array(values) => values
1854 .into_iter()
1855 .enumerate()
1856 .map(|(idx, value)| {
1857 redact_structured_value_with_safety_net(
1858 pipeline,
1859 session,
1860 value,
1861 field_name,
1862 &format!("{field_path}[{idx}]"),
1863 locale_chain,
1864 dictionaries,
1865 report,
1866 policy,
1867 )
1868 })
1869 .collect::<Result<Vec<_>>>()
1870 .map(Value::Array),
1871 Value::Object(fields) => {
1872 let mut clean = BTreeMap::new();
1873 for (key, value) in fields {
1874 let child_path = format!("{field_path}.{key}");
1875 clean.insert(
1876 key.clone(),
1877 redact_structured_value_with_safety_net(
1878 pipeline,
1879 session,
1880 value,
1881 &key,
1882 &child_path,
1883 locale_chain,
1884 dictionaries,
1885 report,
1886 policy,
1887 )?,
1888 );
1889 }
1890 Ok(Value::Object(clean))
1891 }
1892 Value::Null | Value::Bool(_) | Value::I64(_) => {
1893 if let Some(scalar) = value.scalar_to_safety_net_string() {
1894 let field_report = pipeline.run_safety_nets(
1895 session,
1896 &scalar,
1897 &Manifest::default(),
1898 DocumentKind::Structured,
1899 locale_chain,
1900 Some(field_path),
1901 policy.mode,
1902 )?;
1903 report.extend(field_report);
1904 }
1905 Ok(value)
1906 }
1907 _ => Err(Error::UnsupportedValueVariant),
1908 }
1909}
1910
1911fn walk_value_for_safety_net_scan(
1912 pipeline: &Pipeline,
1913 session: &Session,
1914 value: &Value,
1915 field_path: &str,
1916 locale_chain: &[crate::LocaleTag],
1917 report: &mut LeakReport,
1918) -> Result<()> {
1919 match value {
1920 Value::String(text) => {
1921 if !text.is_empty() {
1922 let field_report = pipeline.run_safety_nets(
1923 session,
1924 text,
1925 &Manifest::default(),
1926 DocumentKind::Structured,
1927 locale_chain,
1928 Some(field_path),
1929 SafetyNetMode::Strict,
1930 )?;
1931 report.extend(field_report);
1932 }
1933 }
1934 Value::Null => {}
1935 Value::Bool(_) | Value::I64(_) => {
1936 if let Some(scalar) = value.scalar_to_safety_net_string() {
1937 let field_report = pipeline.run_safety_nets(
1938 session,
1939 &scalar,
1940 &Manifest::default(),
1941 DocumentKind::Structured,
1942 locale_chain,
1943 Some(field_path),
1944 SafetyNetMode::Strict,
1945 )?;
1946 report.extend(field_report);
1947 }
1948 }
1949 Value::Array(values) => {
1950 for (idx, value) in values.iter().enumerate() {
1951 walk_value_for_safety_net_scan(
1952 pipeline,
1953 session,
1954 value,
1955 &format!("{field_path}[{idx}]"),
1956 locale_chain,
1957 report,
1958 )?;
1959 }
1960 }
1961 Value::Object(fields) => {
1962 for (key, value) in fields {
1963 walk_value_for_safety_net_scan(
1964 pipeline,
1965 session,
1966 value,
1967 &format!("{field_path}.{key}"),
1968 locale_chain,
1969 report,
1970 )?;
1971 }
1972 }
1973 _ => return Err(Error::UnsupportedValueVariant),
1974 }
1975 Ok(())
1976}
1977
1978fn translate_candidate(candidate: Candidate, spans: &[(usize, usize)]) -> Option<Candidate> {
1979 translate_span(candidate.span.clone(), spans).map(|span| candidate.with_span(span))
1980}
1981
1982fn translate_vetoed_candidate(
1983 vetoed: crate::validator_veto::VetoedCandidate,
1984 spans: &[(usize, usize)],
1985) -> Option<crate::validator_veto::VetoedCandidate> {
1986 translate_candidate(vetoed.candidate, spans).map(|candidate| {
1987 crate::validator_veto::VetoedCandidate {
1988 candidate,
1989 reason: vetoed.reason,
1990 }
1991 })
1992}
1993
1994fn translate_span(
1995 span: std::ops::Range<usize>,
1996 spans: &[(usize, usize)],
1997) -> Option<std::ops::Range<usize>> {
1998 if span.is_empty() || span.end > spans.len() {
1999 return None;
2000 }
2001
2002 let start = spans[span.start].0;
2003 let end = spans[span.end - 1].1;
2004 Some(start..end)
2005}
2006
2007fn merged_losers(resolved: &[Candidate], registry: &RecognizerRegistry) -> Vec<IndexedDetection> {
2008 resolved
2009 .iter()
2010 .flat_map(|winner| {
2011 winner.merged_sources.iter().map(|source| {
2012 let class = registry
2013 .recognizer(source)
2014 .map(|recognizer| recognizer.supported_class().clone())
2015 .unwrap_or_else(|| winner.class.clone());
2016 let membership = registry.family_policy().membership(source);
2017 IndexedDetection {
2018 detection: Detection::new(winner.span.clone(), class, source.clone()),
2019 recognizer_id: Some(source.clone()),
2020 recognizer_version_id: None,
2021 decided_by: if winner.decided_by == ConflictTier::Merged {
2022 ConflictTier::Merged
2023 } else {
2024 winner.decided_by
2025 },
2026 family: winner.token_family.clone(),
2027 ambiguity_record: None,
2028 collision_family: membership.map(|membership| membership.family.clone()),
2029 collision_variant: membership.map(|membership| membership.variant.clone()),
2030 }
2031 })
2032 })
2033 .collect()
2034}
2035
2036fn indexed_detection_from_candidate(
2037 candidate: Candidate,
2038 registry: &RecognizerRegistry,
2039) -> IndexedDetection {
2040 let membership = registry
2041 .family_policy()
2042 .membership(&candidate.recognizer_id);
2043 let mut collision_family = membership.map(|membership| membership.family.clone());
2044 let collision_variant = membership.map(|membership| membership.variant.clone());
2045 let mut ambiguity_record = None;
2046
2047 let hybrid_reason = match candidate.decided_by {
2048 ConflictTier::CollisionPolicy => Some(AmbiguityReason::PrecedenceTie),
2049 ConflictTier::AnchoredContext => Some(AmbiguityReason::NoAnchor),
2050 _ => None,
2051 };
2052 if let Some(reason) = hybrid_reason {
2053 if let Some(hybrid) = crate::conflict::hybrid_fallback::emit(&candidate, registry, reason) {
2054 ambiguity_record = Some(hybrid.ambiguity_record);
2055 collision_family = Some(hybrid.collision_family);
2056 }
2057 }
2058
2059 IndexedDetection {
2060 detection: Detection::new(candidate.span, candidate.class, candidate.source),
2061 recognizer_id: Some(candidate.recognizer_id),
2062 recognizer_version_id: candidate.recognizer_version_id,
2063 decided_by: candidate.decided_by,
2064 family: candidate.token_family,
2065 ambiguity_record,
2066 collision_family,
2067 collision_variant,
2068 }
2069}
2070
2071struct DetectorRecognizer<D> {
2072 detector: D,
2073 class: crate::PiiClass,
2074}
2075
2076impl<D> DetectorRecognizer<D> {
2077 fn new(detector: D) -> Self {
2078 Self {
2079 detector,
2080 class: crate::PiiClass::Custom("__legacy_detector__".to_string()),
2081 }
2082 }
2083}
2084
2085impl<D> Recognizer for DetectorRecognizer<D>
2086where
2087 D: Detector + Send + Sync + 'static,
2088{
2089 fn id(&self) -> &str {
2090 "legacy-detector"
2091 }
2092
2093 fn supported_class(&self) -> &crate::PiiClass {
2094 &self.class
2095 }
2096
2097 fn detect(
2098 &self,
2099 input: &str,
2100 _ctx: &DetectContext<'_>,
2101 ) -> std::result::Result<Vec<Candidate>, gaze_types::DetectError> {
2102 Ok(self
2103 .detector
2104 .try_detect(input)
2105 .map_err(|err| gaze_types::DetectError::backend(err.recognizer_id, err.message))?
2106 .into_iter()
2107 .map(|detection| {
2108 let source = detection.source;
2109 Candidate::new(
2110 detection.span,
2111 detection.class,
2112 source.clone(),
2113 1.0,
2114 0,
2115 None,
2116 "counter",
2117 source,
2118 ConflictTier::None,
2119 Vec::new(),
2120 )
2121 })
2122 .collect())
2123 }
2124
2125 fn token_family(&self) -> &str {
2126 "counter"
2127 }
2128}
2129
2130fn generalize_token(class: &PiiClass) -> String {
2131 match class {
2132 PiiClass::Email => "[EMAIL]".to_string(),
2133 PiiClass::Name => "[NAME]".to_string(),
2134 PiiClass::Location => "[LOCATION]".to_string(),
2135 PiiClass::Organization => "[ORGANIZATION]".to_string(),
2136 PiiClass::Custom(name) => format!("[{}]", name.to_ascii_uppercase()),
2137 }
2138}
2139
2140fn build_context(field_name: Option<&str>) -> RuleContext {
2141 RuleContext {
2142 field_name: field_name.map(str::to_string),
2143 }
2144}
2145
2146#[cfg(test)]
2147mod tests {
2148 use super::*;
2149 use crate::detector::{Detection, PiiClass};
2150 use crate::rule::{ClassRule, DefaultRule};
2151 use crate::session::{Scope, Session};
2152 use std::sync::atomic::{AtomicUsize, Ordering};
2153 use std::sync::Mutex;
2154
2155 struct CapturingLogger {
2159 entries: Arc<Mutex<Vec<RedactionEntry>>>,
2160 }
2161
2162 struct FixedDetector {
2163 detections: Vec<Detection>,
2164 }
2165
2166 impl Detector for FixedDetector {
2167 fn detect(&self, _input: &str) -> Vec<Detection> {
2168 self.detections.clone()
2169 }
2170 }
2171
2172 struct FailingRecognizer;
2173
2174 impl Recognizer for FailingRecognizer {
2175 fn id(&self) -> &str {
2176 "ner"
2177 }
2178
2179 fn supported_class(&self) -> &PiiClass {
2180 &PiiClass::Name
2181 }
2182
2183 fn detect(
2184 &self,
2185 _input: &str,
2186 _ctx: &DetectContext<'_>,
2187 ) -> std::result::Result<Vec<Candidate>, gaze_types::DetectError> {
2188 Err(gaze_types::DetectError::backend(
2189 self.id(),
2190 "synthetic backend failure",
2191 ))
2192 }
2193
2194 fn token_family(&self) -> &str {
2195 "counter"
2196 }
2197 }
2198
2199 fn detector_with_detections(source: &str, detections: Vec<Detection>) -> FixedDetector {
2200 FixedDetector {
2201 detections: detections
2202 .into_iter()
2203 .map(|mut detection| {
2204 detection.source = source.to_string();
2205 detection
2206 })
2207 .collect(),
2208 }
2209 }
2210
2211 #[test]
2212 fn recognizer_backend_failure_fails_closed_before_output() {
2213 let pipeline = Pipeline::builder()
2214 .recognizer(FailingRecognizer)
2215 .build()
2216 .expect("pipeline");
2217 let session =
2218 Session::new(Scope::Conversation("detect-failclosed".to_string())).expect("session");
2219
2220 let err = pipeline
2221 .redact(&session, RawDocument::Text("Hello Dr. Schmidt".to_string()))
2222 .expect_err("recognizer backend failure must abort redaction");
2223
2224 assert!(matches!(
2225 err,
2226 Error::RecognizerDetect(gaze_types::DetectError::Backend {
2227 recognizer_id,
2228 ..
2229 }) if recognizer_id == "ner"
2230 ));
2231 }
2232
2233 impl RedactionLogger for CapturingLogger {
2234 fn log(&self, entry: &RedactionEntry) -> std::result::Result<(), RedactionLogError> {
2235 self.entries.lock().unwrap().push(entry.clone());
2236 Ok(())
2237 }
2238 }
2239
2240 #[test]
2241 fn restore_with_telemetry_counts_unknown_tokens_deterministically() {
2242 let pipeline = Pipeline::builder().build().expect("pipeline");
2243 let session =
2244 Session::new(Scope::Conversation("restore-telemetry".to_string())).expect("session");
2245 let clean = pipeline
2246 .redact(
2247 &session,
2248 RawDocument::Text("Reach alice@example.invalid".to_string()),
2249 )
2250 .expect("redact");
2251 let CleanDocument::Text(clean) = clean else {
2252 panic!("expected text");
2253 };
2254 let input = format!("{clean} <Email_999> <Name_100>");
2255
2256 let (restored, telemetry) = pipeline
2257 .restore_with_policy_telemetry(&session, &input, RestorePolicy::Lenient)
2258 .expect("restore telemetry");
2259 let (_, telemetry_again) = pipeline
2260 .restore_with_policy_telemetry(&session, &input, RestorePolicy::Lenient)
2261 .expect("restore telemetry");
2262
2263 assert!(restored.text.contains("alice@example.invalid"));
2264 assert_eq!(telemetry.unknown_token_count, 2);
2265 assert_eq!(telemetry.manifest_bypass_count, 2);
2266 assert_eq!(telemetry.fresh_pii_detected_count, 0);
2267 assert_eq!(telemetry.restore_policy, RestorePolicy::Lenient);
2268 assert_eq!(telemetry.restore_decision, RestoreDecision::Partial);
2269 assert_eq!(
2270 telemetry.phase_execution_mask,
2271 RESTORE_PHASE_MANIFEST_LOOKUP
2272 | RESTORE_PHASE_UNKNOWN_TOKEN_SCAN
2273 | RESTORE_PHASE_MANIFEST_BYPASS_SCAN
2274 );
2275 assert_eq!(telemetry_again, telemetry);
2276 }
2277
2278 struct CountingSafetyNet {
2279 calls: Arc<AtomicUsize>,
2280 }
2281
2282 impl SafetyNet for CountingSafetyNet {
2283 fn id(&self) -> &str {
2284 "counting"
2285 }
2286 fn supported_locales(&self) -> &[crate::LocaleTag] {
2287 &[crate::LocaleTag::Global]
2288 }
2289 fn check(
2290 &self,
2291 _clean_text: &str,
2292 _context: SafetyNetContext<'_>,
2293 ) -> std::result::Result<Vec<LeakSuspect>, SafetyNetError> {
2294 self.calls.fetch_add(1, Ordering::SeqCst);
2295 Ok(Vec::new())
2296 }
2297 }
2298
2299 #[test]
2300 fn generalize_token_custom_class_preserves_identity() {
2301 assert_eq!(generalize_token(&PiiClass::Custom("foo".into())), "[FOO]");
2303 }
2304
2305 #[test]
2306 fn skip_class_gating_skips_only_observer_mode() {
2307 let calls = Arc::new(AtomicUsize::new(0));
2308 let pipeline = Pipeline::builder()
2309 .detector(detector_with_detections(
2310 "email.global",
2311 vec![Detection::new(6..27, PiiClass::Email, "email.global")],
2312 ))
2313 .rule(ClassRule::new(PiiClass::Email, Action::Tokenize))
2314 .rule(DefaultRule::new(Action::Preserve))
2315 .register_safety_net(CountingSafetyNet {
2316 calls: Arc::clone(&calls),
2317 })
2318 .enable_skip_class_gating()
2319 .build()
2320 .expect("pipeline");
2321 let session = Session::new(Scope::Ephemeral).expect("session");
2322 let dictionaries = DictionaryBundle::default();
2323 pipeline
2324 .clean_with_safety_net_policy_detect_context(
2325 &session,
2326 RawDocument::Text("Reach alice@example.invalid".to_string()),
2327 &[crate::LocaleTag::Global],
2328 &dictionaries,
2329 SafetyNetPolicy::new(SafetyNetMode::Strict, SafetyNetFallback::Redact),
2330 )
2331 .expect("observer safety net");
2332 assert_eq!(calls.load(Ordering::SeqCst), 0);
2333 pipeline
2334 .clean_with_safety_net_policy_detect_context(
2335 &session,
2336 RawDocument::Text("Reach alice@example.invalid".to_string()),
2337 &[crate::LocaleTag::Global],
2338 &dictionaries,
2339 SafetyNetPolicy::new(SafetyNetMode::Resolve, SafetyNetFallback::Redact),
2340 )
2341 .expect("resolve safety net");
2342 assert!(calls.load(Ordering::SeqCst) > 0);
2343 }
2344
2345 #[test]
2346 fn capitals_gate_fails_closed_for_unsupported_locale() {
2347 let calls = Arc::new(AtomicUsize::new(0));
2348 let pipeline = Pipeline::builder()
2349 .register_safety_net(CountingSafetyNet {
2350 calls: Arc::clone(&calls),
2351 })
2352 .enable_capitals_heuristic_gate()
2353 .build()
2354 .expect("pipeline");
2355 let session = Session::new(Scope::Ephemeral).expect("session");
2356 let dictionaries = DictionaryBundle::default();
2357 let err = pipeline
2358 .clean_with_safety_net_policy_detect_context(
2359 &session,
2360 RawDocument::Text("مرحبا".to_string()),
2361 &[crate::LocaleTag::Other("ar-SA".to_string())],
2362 &dictionaries,
2363 SafetyNetPolicy::new(SafetyNetMode::Strict, SafetyNetFallback::Redact),
2364 )
2365 .expect_err("unsupported capitals locale must fail closed");
2366 assert!(matches!(
2367 err,
2368 Error::UnsupportedCapitalHeuristicLocale { locale } if locale == "ar-SA"
2369 ));
2370 assert_eq!(calls.load(Ordering::SeqCst), 0);
2371 }
2372
2373 #[test]
2374 fn prefix_cache_hits_emit_audit_provenance() {
2375 struct NameRecognizer;
2376 impl Recognizer for NameRecognizer {
2377 fn id(&self) -> &str {
2378 "name.fixture"
2379 }
2380 fn supported_class(&self) -> &PiiClass {
2381 &PiiClass::Name
2382 }
2383 fn detect(
2384 &self,
2385 input: &str,
2386 _ctx: &DetectContext<'_>,
2387 ) -> std::result::Result<Vec<Candidate>, gaze_types::DetectError> {
2388 Ok(input
2389 .find("Dr. Schmidt")
2390 .map(|start| {
2391 Candidate::new(
2392 start..start + "Dr. Schmidt".len(),
2393 PiiClass::Name,
2394 self.id(),
2395 1.0,
2396 0,
2397 None,
2398 self.token_family(),
2399 self.id(),
2400 ConflictTier::None,
2401 Vec::new(),
2402 )
2403 })
2404 .into_iter()
2405 .collect())
2406 }
2407 fn token_family(&self) -> &str {
2408 "counter"
2409 }
2410 }
2411 let entries = Arc::new(Mutex::new(Vec::<RedactionEntry>::new()));
2412 let pipeline = Pipeline::builder()
2413 .recognizer(NameRecognizer)
2414 .rule(ClassRule::new(PiiClass::Name, Action::Tokenize))
2415 .rule(DefaultRule::new(Action::Preserve))
2416 .redaction_logger(CapturingLogger {
2417 entries: Arc::clone(&entries),
2418 })
2419 .enable_prefix_cache()
2420 .build()
2421 .expect("pipeline");
2422 let session = Session::new(Scope::Ephemeral).expect("session");
2423 pipeline
2424 .redact(&session, RawDocument::Text("Dr. Schmidt".to_string()))
2425 .expect("prime cache");
2426 pipeline
2427 .redact(
2428 &session,
2429 RawDocument::Text("Dr. Schmidt reports".to_string()),
2430 )
2431 .expect("cache hit");
2432 let entries = entries.lock().unwrap();
2433 assert!(entries.iter().any(|entry| {
2434 entry.source == "prefix_cache"
2435 && entry.provenance_stage.as_deref() == Some("prefix_cache")
2436 }));
2437 }
2438
2439 #[test]
2440 fn restore_strict_rejection_emits_audit_provenance() {
2441 let entries = Arc::new(Mutex::new(Vec::<RedactionEntry>::new()));
2442 let pipeline = Pipeline::builder()
2443 .redaction_logger(CapturingLogger {
2444 entries: Arc::clone(&entries),
2445 })
2446 .build()
2447 .expect("pipeline");
2448 let session = Session::new(Scope::Ephemeral).expect("session");
2449
2450 let err = pipeline
2451 .restore_strict_text(&session, "Reply to <deadbeef:Email_1>.")
2452 .expect_err("unknown token must fail closed");
2453
2454 assert!(matches!(
2455 err,
2456 Error::UnknownToken {
2457 class: PiiClass::Email,
2458 ordinal: 1,
2459 raw,
2460 } if raw == "<deadbeef:Email_1>"
2461 ));
2462 let entries = entries.lock().unwrap();
2463 assert_eq!(entries.len(), 1);
2464 assert_eq!(entries[0].source, "restore_strict");
2465 assert_eq!(entries[0].class, PiiClass::Email);
2466 assert_eq!(entries[0].action, Action::Preserve);
2467 assert_eq!(
2468 entries[0].provenance_stage.as_deref(),
2469 Some("restore_strict")
2470 );
2471 }
2472
2473 #[test]
2474 fn restore_boundary_dlp_emits_metadata_only_audit_rows() {
2475 let entries = Arc::new(Mutex::new(Vec::<RedactionEntry>::new()));
2476 let pipeline = Pipeline::builder()
2477 .redaction_logger(CapturingLogger {
2478 entries: Arc::clone(&entries),
2479 })
2480 .enable_restore_boundary_dlp_audit()
2481 .build()
2482 .expect("pipeline");
2483 let session = Session::new(Scope::Ephemeral).expect("session");
2484 let token = session
2485 .tokenize(&PiiClass::Email, "alice@example.invalid")
2486 .expect("token");
2487
2488 let restored = pipeline
2489 .restore_strict_text(
2490 &session,
2491 &format!("{token} raw alice@example.invalid fresh bob@example.invalid"),
2492 )
2493 .expect("restore remains audit-only");
2494
2495 assert!(restored.contains("alice@example.invalid"));
2496 let entries = entries.lock().unwrap();
2497 let dlp_entries = entries
2498 .iter()
2499 .filter(|entry| entry.provenance_stage.as_deref() == Some("restore_boundary_dlp"))
2500 .collect::<Vec<_>>();
2501 assert_eq!(dlp_entries.len(), 2);
2502 assert!(dlp_entries
2503 .iter()
2504 .any(|entry| entry.source == "manifest_bypass"));
2505 assert!(dlp_entries
2506 .iter()
2507 .any(|entry| entry.source == "fresh_pii_detected"));
2508 let serialized = serde_json::to_string(&dlp_entries).expect("audit json");
2509 assert!(!serialized.contains("alice@example.invalid"));
2510 assert!(!serialized.contains("bob@example.invalid"));
2511 assert!(serialized.contains("sha256:"));
2512 }
2513
2514 #[test]
2515 fn restore_boundary_dlp_audit_is_default_off() {
2516 let entries = Arc::new(Mutex::new(Vec::<RedactionEntry>::new()));
2517 let pipeline = Pipeline::builder()
2518 .redaction_logger(CapturingLogger {
2519 entries: Arc::clone(&entries),
2520 })
2521 .build()
2522 .expect("pipeline");
2523 let session = Session::new(Scope::Ephemeral).expect("session");
2524
2525 pipeline
2526 .restore_strict_text(&session, "fresh bob@example.invalid")
2527 .expect("restore remains audit-only");
2528
2529 assert!(entries.lock().unwrap().is_empty());
2530 }
2531
2532 #[test]
2533 fn stacked_ner_detectors_resolve_via_span_conflict() {
2534 let text = "Alice Smith works here";
2536 let short_detection = Detection::new(0..5, PiiClass::Name, "ner/bert");
2537 let long_detection = Detection::new(0..11, PiiClass::Name, "ner/gliner");
2538
2539 let bert = detector_with_detections("ner/bert", vec![short_detection]);
2540 let gliner = detector_with_detections("ner/gliner", vec![long_detection]);
2541
2542 let entries = Arc::new(Mutex::new(Vec::<RedactionEntry>::new()));
2543
2544 let pipeline = Pipeline::builder()
2545 .detector(bert)
2546 .detector(gliner)
2547 .rule(ClassRule::new(PiiClass::Name, Action::Redact))
2548 .rule(DefaultRule::new(Action::Preserve))
2549 .redaction_logger(CapturingLogger {
2550 entries: Arc::clone(&entries),
2551 })
2552 .build()
2553 .expect("pipeline");
2554
2555 let session = Session::new(Scope::Ephemeral).expect("session");
2556 let clean = pipeline
2557 .redact(&session, RawDocument::Text(text.to_string()))
2558 .expect("redact");
2559
2560 let out = match clean {
2561 CleanDocument::Text(t) => t,
2562 _ => panic!("expected text"),
2563 };
2564
2565 assert_eq!(out, "[REDACTED] works here");
2567
2568 let entries = entries.lock().unwrap();
2569 assert_eq!(
2570 entries.len(),
2571 2,
2572 "expected one winner + one loser: {entries:?}"
2573 );
2574 let winner = entries.iter().find(|e| !e.conflict_loser).expect("winner");
2575 let loser = entries.iter().find(|e| e.conflict_loser).expect("loser");
2576 assert_eq!(winner.source, "ner/gliner", "longer span should win");
2577 assert_eq!(loser.source, "ner/bert", "shorter span should lose");
2578 assert_eq!(loser.decided_by, ConflictTier::SpanLength);
2579 }
2580
2581 #[test]
2582 fn stacked_detectors_both_win_when_spans_disjoint() {
2583 let text = "Alice visited Berlin";
2584 let alice = Detection::new(0..5, PiiClass::Name, "ner/bert");
2585 let berlin = Detection::new(14..20, PiiClass::Location, "ner/gliner");
2586
2587 let bert = detector_with_detections("ner/bert", vec![alice]);
2588 let gliner = detector_with_detections("ner/gliner", vec![berlin]);
2589
2590 let entries = Arc::new(Mutex::new(Vec::<RedactionEntry>::new()));
2591
2592 let pipeline = Pipeline::builder()
2593 .detector(bert)
2594 .detector(gliner)
2595 .rule(ClassRule::new(PiiClass::Name, Action::Redact))
2596 .rule(ClassRule::new(PiiClass::Location, Action::Redact))
2597 .rule(DefaultRule::new(Action::Preserve))
2598 .redaction_logger(CapturingLogger {
2599 entries: Arc::clone(&entries),
2600 })
2601 .build()
2602 .expect("pipeline");
2603
2604 let session = Session::new(Scope::Ephemeral).expect("session");
2605 let clean = pipeline
2606 .redact(&session, RawDocument::Text(text.to_string()))
2607 .expect("redact");
2608
2609 let out = match clean {
2610 CleanDocument::Text(t) => t,
2611 _ => panic!("expected text"),
2612 };
2613
2614 assert_eq!(out, "[REDACTED] visited [REDACTED]");
2615 let entries = entries.lock().unwrap();
2616 assert_eq!(entries.len(), 2);
2617 assert!(entries.iter().all(|e| !e.conflict_loser));
2618 }
2619
2620 #[test]
2621 fn audit_entry_threads_recognizer_lineage_from_candidate() {
2622 struct VersionedRecognizer;
2623
2624 impl Recognizer for VersionedRecognizer {
2625 fn id(&self) -> &str {
2626 "name.semantic"
2627 }
2628
2629 fn supported_class(&self) -> &PiiClass {
2630 &PiiClass::Name
2631 }
2632
2633 fn detect(
2634 &self,
2635 input: &str,
2636 _ctx: &DetectContext<'_>,
2637 ) -> std::result::Result<Vec<Candidate>, gaze_types::DetectError> {
2638 let Some(start) = input.find("Dr. Schmidt") else {
2639 return Ok(Vec::new());
2640 };
2641 let end = start + "Dr. Schmidt".len();
2642 Ok(vec![Candidate::new(
2643 start..end,
2644 PiiClass::Name,
2645 self.id(),
2646 1.0,
2647 0,
2648 None,
2649 self.token_family(),
2650 "ner/ort",
2651 ConflictTier::None,
2652 Vec::new(),
2653 )
2654 .with_recognizer_version_id("name.semantic.v2")])
2655 }
2656
2657 fn token_family(&self) -> &str {
2658 "counter"
2659 }
2660 }
2661
2662 let entries = Arc::new(Mutex::new(Vec::<RedactionEntry>::new()));
2663 let pipeline = Pipeline::builder()
2664 .recognizer(VersionedRecognizer)
2665 .rule(ClassRule::new(PiiClass::Name, Action::Tokenize))
2666 .rule(DefaultRule::new(Action::Preserve))
2667 .redaction_logger(CapturingLogger {
2668 entries: Arc::clone(&entries),
2669 })
2670 .build()
2671 .expect("pipeline");
2672 let session = Session::new(Scope::Ephemeral).expect("session");
2673
2674 pipeline
2675 .redact(
2676 &session,
2677 RawDocument::Text("Contact Dr. Schmidt today.".to_string()),
2678 )
2679 .expect("redact");
2680
2681 let entries = entries.lock().unwrap();
2682 assert_eq!(entries.len(), 1);
2683 assert_eq!(entries[0].source, "ner/ort");
2684 assert_eq!(entries[0].recognizer_id.as_deref(), Some("name.semantic"));
2685 assert_eq!(
2686 entries[0].recognizer_version_id.as_deref(),
2687 Some("name.semantic.v2")
2688 );
2689 }
2690
2691 #[cfg(feature = "bundled-recognizers")]
2692 #[test]
2693 fn locale_safety_net_registry_logs_dropped_backends() {
2694 struct EmptyLocaleModel {
2695 name: &'static str,
2696 locales: Vec<crate::LocaleTag>,
2697 }
2698
2699 impl gaze_recognizers::LocaleAwareModel for EmptyLocaleModel {
2700 fn name(&self) -> &str {
2701 self.name
2702 }
2703
2704 fn native_locales(&self) -> &[crate::LocaleTag] {
2705 &self.locales
2706 }
2707
2708 fn infer(
2709 &self,
2710 _input: ModelInput,
2711 _hints: ModelHints,
2712 ) -> std::result::Result<Vec<ModelSpan>, ModelError> {
2713 Ok(Vec::new())
2714 }
2715 }
2716
2717 let entries = Arc::new(Mutex::new(Vec::<RedactionEntry>::new()));
2718 let registry = LocaleAwareModelRegistry::from_backends(vec![
2719 Box::new(EmptyLocaleModel {
2720 name: "opf-primary",
2721 locales: vec![crate::LocaleTag::EnUs],
2722 }),
2723 Box::new(EmptyLocaleModel {
2724 name: "opf-shadow",
2725 locales: vec![crate::LocaleTag::EnUs],
2726 }),
2727 ]);
2728 let pipeline = Pipeline::builder()
2729 .rule(DefaultRule::new(Action::Preserve))
2730 .redaction_logger(CapturingLogger {
2731 entries: Arc::clone(&entries),
2732 })
2733 .register_safety_net_registry(registry)
2734 .build()
2735 .expect("pipeline");
2736 let session = Session::new(Scope::Ephemeral).expect("session");
2737
2738 pipeline
2739 .scan_safety_nets(&session, "plain text", &[crate::LocaleTag::EnUs])
2740 .expect("safety net scan");
2741
2742 let entries = entries.lock().unwrap();
2743 assert_eq!(entries.len(), 1);
2744 assert_eq!(
2745 entries[0].backend_silently_dropped.as_deref(),
2746 Some(&["opf-shadow".to_string()][..])
2747 );
2748 assert_eq!(entries[0].source, "safety_net.opf-primary");
2749 }
2750
2751 #[test]
2752 fn pipeline_builder_detects_email() {
2753 struct EmailDetector(regex::Regex);
2754
2755 impl Detector for EmailDetector {
2756 fn detect(&self, input: &str) -> Vec<Detection> {
2757 self.0
2758 .find_iter(input)
2759 .map(|m| Detection::new(m.range(), PiiClass::Email, "regex"))
2760 .collect()
2761 }
2762 }
2763
2764 let pipeline = Pipeline::builder()
2765 .detector(EmailDetector(
2766 regex::Regex::new(r"(?i)\b[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}\b").unwrap(),
2767 ))
2768 .rule(ClassRule::new(PiiClass::Email, Action::Tokenize))
2769 .rule(DefaultRule::new(Action::Preserve))
2770 .build()
2771 .unwrap();
2772 let session = Session::new(Scope::Ephemeral).unwrap();
2773
2774 let clean = pipeline
2775 .redact(
2776 &session,
2777 RawDocument::Text("Reach alice@example.invalid today".to_string()),
2778 )
2779 .unwrap();
2780
2781 match clean {
2782 CleanDocument::Text(text) => {
2783 assert!(text.starts_with("Reach <"));
2784 assert!(text.ends_with(":Email_1> today"));
2785 }
2786 other => panic!("expected text output, got {other:?}"),
2787 }
2788 }
2789
2790 #[test]
2791 fn t21d_token_family_threads_from_recognizer_to_session() {
2792 struct FamilyRecognizer;
2793
2794 impl Recognizer for FamilyRecognizer {
2795 fn id(&self) -> &str {
2796 "name.alpha"
2797 }
2798
2799 fn supported_class(&self) -> &PiiClass {
2800 &PiiClass::Name
2801 }
2802
2803 fn detect(
2804 &self,
2805 input: &str,
2806 _ctx: &DetectContext<'_>,
2807 ) -> std::result::Result<Vec<Candidate>, gaze_types::DetectError> {
2808 let Some(start) = input.find("Dr. Schmidt") else {
2809 return Ok(Vec::new());
2810 };
2811 let end = start + "Dr. Schmidt".len();
2812 Ok(vec![Candidate::new(
2813 start..end,
2814 PiiClass::Name,
2815 self.id(),
2816 1.0,
2817 0,
2818 None,
2819 self.token_family(),
2820 self.id(),
2821 ConflictTier::None,
2822 Vec::new(),
2823 )])
2824 }
2825
2826 fn token_family(&self) -> &str {
2827 "alpha"
2828 }
2829 }
2830
2831 let pipeline = Pipeline::builder()
2832 .recognizer(FamilyRecognizer)
2833 .rule(ClassRule::new(PiiClass::Name, Action::Tokenize))
2834 .rule(DefaultRule::new(Action::Preserve))
2835 .build()
2836 .expect("pipeline");
2837 let session = Session::new(Scope::Ephemeral).expect("session");
2838
2839 let clean = pipeline
2840 .redact(
2841 &session,
2842 RawDocument::Text("Assigned to Dr. Schmidt".to_string()),
2843 )
2844 .expect("redact");
2845 let CleanDocument::Text(text) = clean else {
2846 panic!("expected text");
2847 };
2848 let token = text
2849 .strip_prefix("Assigned to ")
2850 .expect("token prefix")
2851 .to_string();
2852 assert!(regex::Regex::new(r"^<[0-9a-f]{8}:Name_[0-9]+>$")
2853 .unwrap()
2854 .is_match(&token));
2855
2856 let beta = session
2857 .tokenize_with_family("beta", &PiiClass::Name, "Dr. Schmidt")
2858 .expect("beta token");
2859 assert_ne!(token, beta);
2860 assert_eq!(
2861 session
2862 .tokenize_with_family("alpha", &PiiClass::Name, "Dr. Schmidt")
2863 .expect("alpha token"),
2864 token
2865 );
2866 assert_eq!(session.restore(&token).as_deref(), Some("Dr. Schmidt"));
2867 assert_eq!(session.restore(&beta).as_deref(), Some("Dr. Schmidt"));
2868 }
2869}