1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3
4mod allowlist;
5mod builder;
6mod detect;
7mod error;
8mod kind;
9mod ner;
10mod placeholder;
11mod rules;
12mod scan_cache;
13mod stream;
14mod vault;
15mod walk;
16
17pub use builder::EngineBuilder;
18pub use detect::Source;
19pub use error::{Error, RecognizerError, VaultError};
20pub use kind::{kinds, EntityKind, InvalidKind};
21pub use ner::{Entity, EntityRecognizer};
22pub use placeholder::{PlaceholderFormat, Token, DEFAULT_FORMAT};
23pub use rules::{
24 CompiledRules, Detectors, DictionaryEntry, IpOptions, NatIdOptions, NerOptions, NoOptions, Options, PhoneOptions,
25 RegexRule, RulesError, RulesFile, Setting,
26};
27pub use scan_cache::{ScanCacheStats, DEFAULT_BUDGET as DEFAULT_SCAN_CACHE_BUDGET};
28pub use stream::StreamRestorer;
29pub use vault::{MemoryVault, Vault};
30pub use walk::{KeyAction, KeyPolicy, WalkAll};
31
32pub const VERSION: &str = env!("CARGO_PKG_VERSION");
35
36use std::borrow::Cow;
37use std::collections::{BTreeMap, HashMap, HashSet};
38use std::fmt;
39use std::sync::Arc;
40
41use arc_swap::ArcSwap;
42use scan_cache::{ScanCache, Scanned};
43use serde_json::Value;
44use walk::StrKind;
45
46#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
48pub struct ScopeId(String);
49
50impl ScopeId {
51 pub fn new(id: impl Into<String>) -> Self {
53 ScopeId(id.into())
54 }
55
56 pub fn profile(name: &str) -> Self {
59 ScopeId(format!("profile:{name}"))
60 }
61
62 pub fn keyed(key: &[u8], hint: &str) -> Self {
66 use hmac::{Hmac, Mac};
67 let mut mac = Hmac::<sha2::Sha256>::new_from_slice(key).expect("HMAC accepts any key length");
68 mac.update(hint.as_bytes());
69 let digest = mac.finalize().into_bytes();
70 let hex: String = digest[..16].iter().map(|b| format!("{b:02x}")).collect();
71 ScopeId(format!("conv:{hex}"))
72 }
73
74 pub fn as_str(&self) -> &str {
76 &self.0
77 }
78}
79
80impl fmt::Display for ScopeId {
81 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82 f.write_str(&self.0)
83 }
84}
85
86#[derive(Clone, Debug, PartialEq, Eq)]
88#[non_exhaustive]
89pub struct Match {
90 pub start: usize,
92 pub end: usize,
94 pub kind: EntityKind,
96 pub source: Source,
98}
99
100#[derive(Clone, Debug, Default, PartialEq, Eq)]
102#[non_exhaustive]
103pub struct Report {
104 pub redacted: usize,
106 pub by_kind: BTreeMap<String, usize>,
108 pub restored: usize,
110 pub unknown_placeholders: usize,
113}
114
115impl Report {
116 pub fn merge(&mut self, other: &Report) {
118 self.redacted += other.redacted;
119 for (k, v) in &other.by_kind {
120 *self.by_kind.entry(k.clone()).or_default() += v;
121 }
122 self.restored += other.restored;
123 self.unknown_placeholders += other.unknown_placeholders;
124 }
125
126 fn count(&mut self, kind: &EntityKind) {
127 self.redacted += 1;
128 *self.by_kind.entry(kind.as_str().to_owned()).or_default() += 1;
129 }
130}
131
132#[derive(Clone, Debug, PartialEq, Eq)]
134#[non_exhaustive]
135pub struct Redacted {
136 pub text: String,
138 pub matches: Vec<Match>,
140 pub report: Report,
142}
143
144#[derive(Clone, Debug, PartialEq, Eq)]
146#[non_exhaustive]
147pub struct Restored {
148 pub text: String,
150 pub report: Report,
152}
153
154pub trait Recorder: Send + Sync {
157 fn matched(&self, kind: &EntityKind, value: &str, placeholder: Option<&str>, times: u64);
161}
162
163pub struct Engine {
166 rules: ArcSwap<CompiledRules>,
167 vault: Arc<dyn Vault>,
168 recorder: Option<Arc<dyn Recorder>>,
169 scans: ScanCache,
170}
171
172#[derive(Default)]
175struct Pass {
176 index: HashMap<EntityKind, HashMap<String, usize>>,
177 seen: Vec<Seen>,
178 reserved: HashSet<(EntityKind, u64)>,
179}
180
181struct Seen {
182 kind: EntityKind,
183 value: String,
184 placeholder: Option<String>,
185 times: u64,
186}
187
188impl Pass {
189 fn find(&self, kind: &EntityKind, value: &str) -> Option<usize> {
190 self.index.get(kind).and_then(|m| m.get(value)).copied()
191 }
192
193 fn add(&mut self, kind: &EntityKind, value: &str, placeholder: Option<String>) -> usize {
194 let i = self.seen.len();
195 self.seen.push(Seen { kind: kind.clone(), value: value.to_owned(), placeholder, times: 0 });
196 self.index.entry(kind.clone()).or_default().insert(value.to_owned(), i);
197 i
198 }
199}
200
201impl Default for Engine {
202 fn default() -> Self {
204 Engine::builder().build().expect("default rules compile")
205 }
206}
207
208impl Engine {
209 pub fn builder() -> EngineBuilder {
212 EngineBuilder::default()
213 }
214
215 pub fn cache_stats(&self) -> ScanCacheStats {
217 self.scans.stats()
218 }
219
220 pub fn set_rules(&self, rules: CompiledRules) {
223 self.rules.store(Arc::new(rules));
224 self.scans.clear();
225 }
226
227 pub fn rules(&self) -> Arc<CompiledRules> {
229 self.rules.load_full()
230 }
231
232 pub fn vault(&self) -> &Arc<dyn Vault> {
234 &self.vault
235 }
236
237 pub fn scan(&self, text: &str) -> Result<Vec<Match>, Error> {
239 scan_with(&self.rules(), text)
240 }
241
242 pub fn redact_text(&self, scope: &ScopeId, text: &str) -> Result<Redacted, Error> {
244 let rules = self.rules();
245 let mut pass = Pass::default();
246 let (out, report, scanned) = self.redact_with(&rules, scope, text, &mut pass)?;
247 self.report(pass);
248 Ok(Redacted { text: out.into_owned(), matches: scanned.matches.clone(), report })
249 }
250
251 pub fn redact_json(&self, scope: &ScopeId, body: &mut Value, policy: &dyn KeyPolicy) -> Result<Report, Error> {
253 let rules = self.rules();
254 let mut report = Report::default();
255 let mut pass = Pass::default();
256 walk::walk(body, policy, &mut |s, kind| self.redact_string(&rules, scope, s, kind, &mut report, &mut pass))?;
257 self.report(pass);
258 Ok(report)
259 }
260
261 pub fn scan_json(&self, body: &Value, policy: &dyn KeyPolicy) -> Result<Report, Error> {
264 let rules = self.rules();
265 let mut report = Report::default();
266 let mut pass = Pass::default();
267 let mut copy = body.clone();
268 walk::walk(&mut copy, policy, &mut |s, kind| {
269 let text: Cow<'_, str> = match kind {
270 StrKind::Json => {
271 serde_json::from_str::<Value>(s).map_or(Cow::Borrowed(s.as_str()), |v| json_strings(&v).into())
272 }
273 StrKind::Plain => Cow::Borrowed(s.as_str()),
274 };
275 let scanned = self.scanned(&rules, &text)?;
276 for m in &scanned.matches {
277 report.count(&m.kind);
278 if self.recorder.is_some() {
279 let value = &text[m.start..m.end];
280 let i = pass.find(&m.kind, value).unwrap_or_else(|| pass.add(&m.kind, value, None));
281 pass.seen[i].times += 1;
282 }
283 }
284 Ok(())
285 })?;
286 self.report(pass);
287 Ok(report)
288 }
289
290 pub fn restore_text(&self, scope: &ScopeId, text: &str) -> Restored {
293 self.restore_with(scope, text, |v| v.to_owned())
294 }
295
296 pub fn restore_with(&self, scope: &ScopeId, text: &str, escape: impl Fn(&str) -> String) -> Restored {
299 self.restore_after(scope, None, text, escape)
300 }
301
302 pub(crate) fn restore_after(
305 &self,
306 scope: &ScopeId,
307 before: Option<char>,
308 text: &str,
309 escape: impl Fn(&str) -> String,
310 ) -> Restored {
311 let rules = self.rules();
312 let tokens: Vec<(usize, usize, String, u64, bool)> = if rules.fuzzy_restore {
313 rules
314 .placeholder
315 .fuzzy_tokens_after(before, text)
316 .into_iter()
317 .map(|t| (t.start, t.end, t.kind, t.n, t.exact))
318 .collect()
319 } else {
320 rules.placeholder.tokens(text).map(|t| (t.start, t.end, t.kind.to_owned(), t.n, true)).collect()
321 };
322 let mut report = Report::default();
323 let mut out = String::with_capacity(text.len());
324 let mut last = 0;
325 for (start, end, kind, n, exact) in tokens {
326 let value = EntityKind::new(&kind).ok().and_then(|kind| self.vault.lookup(scope, &kind, n).ok().flatten());
327 out.push_str(&text[last..start]);
328 match value {
329 Some(v) => {
330 out.push_str(&escape(&v));
331 report.restored += 1;
332 }
333 None => {
334 out.push_str(&text[start..end]);
335 if exact {
337 report.unknown_placeholders += 1;
338 }
339 }
340 }
341 last = end;
342 }
343 out.push_str(&text[last..]);
344 Restored { text: out, report }
345 }
346
347 pub fn restore_json(&self, scope: &ScopeId, value: &mut Value, policy: &dyn KeyPolicy) -> Report {
349 let mut report = Report::default();
350 let result = walk::walk(value, policy, &mut |s, kind| {
351 let r = match kind {
352 StrKind::Plain => self.restore_text(scope, s),
353 StrKind::Json => self.restore_with(scope, s, json_escape),
354 };
355 *s = r.text;
356 report.merge(&r.report);
357 Ok(())
358 });
359 debug_assert!(result.is_ok(), "restoration never fails");
360 report
361 }
362
363 fn redact_string(
364 &self,
365 rules: &CompiledRules,
366 scope: &ScopeId,
367 s: &mut String,
368 kind: StrKind,
369 report: &mut Report,
370 pass: &mut Pass,
371 ) -> Result<(), Error> {
372 if kind == StrKind::Json {
373 if let Ok(mut inner) = serde_json::from_str::<Value>(s) {
374 walk::walk(&mut inner, &WalkAll, &mut |s, k| self.redact_string(rules, scope, s, k, report, pass))?;
375 *s = inner.to_string();
376 return Ok(());
377 }
378 }
379 let (out, r, _) = self.redact_with(rules, scope, s, pass)?;
380 report.merge(&r);
381 if let Cow::Owned(out) = out {
382 *s = out;
383 }
384 Ok(())
385 }
386
387 fn redact_with<'t>(
389 &self,
390 rules: &CompiledRules,
391 scope: &ScopeId,
392 text: &'t str,
393 pass: &mut Pass,
394 ) -> Result<(Cow<'t, str>, Report, Arc<Scanned>), Error> {
395 let scanned = self.scanned(rules, text)?;
396 for (kind, n) in &scanned.tokens {
399 if pass.reserved.insert((kind.clone(), *n)) && self.vault.lookup(scope, kind, *n)?.is_none() {
400 self.vault.reserve(scope, kind, *n)?;
401 }
402 }
403 let mut report = Report::default();
404 if scanned.matches.is_empty() {
405 return Ok((Cow::Borrowed(text), report, scanned));
406 }
407 let mut out = String::with_capacity(text.len());
408 let mut last = 0;
409 for m in &scanned.matches {
410 out.push_str(&text[last..m.start]);
411 let value = &text[m.start..m.end];
412 let i = match pass.find(&m.kind, value) {
413 Some(i) => i,
414 None => {
415 let n = self.vault.get_or_create(scope, &m.kind, value)?;
416 pass.add(&m.kind, value, Some(rules.placeholder.format(&m.kind, n)))
417 }
418 };
419 let seen = &mut pass.seen[i];
420 seen.times += 1;
421 out.push_str(seen.placeholder.as_deref().expect("redacted values have a placeholder"));
422 report.count(&m.kind);
423 last = m.end;
424 }
425 out.push_str(&text[last..]);
426 Ok((Cow::Owned(out), report, scanned))
427 }
428
429 fn scanned(&self, rules: &CompiledRules, text: &str) -> Result<Arc<Scanned>, Error> {
431 if !self.scans.enabled() || text.len() < scan_cache::MIN_LEN {
432 return Ok(Arc::new(scan_full(rules, text)?));
433 }
434 let key = ScanCache::key(rules.id, text);
435 if let Some(hit) = self.scans.get(&key) {
436 return Ok(hit);
437 }
438 let scanned = Arc::new(scan_full(rules, text)?);
439 self.scans.put(key, scanned.clone());
440 Ok(scanned)
441 }
442
443 fn report(&self, pass: Pass) {
445 if let Some(r) = &self.recorder {
446 for s in pass.seen {
447 r.matched(&s.kind, &s.value, s.placeholder.as_deref(), s.times);
448 }
449 }
450 }
451}
452
453fn scan_full(rules: &CompiledRules, text: &str) -> Result<Scanned, Error> {
454 let tokens: Vec<Token<'_>> = rules.placeholder.tokens(text).collect();
455 let matches = scan_tokens(rules, text, &tokens)?;
456 let tokens = tokens.iter().filter_map(|t| EntityKind::new(t.kind).ok().map(|k| (k, t.n))).collect();
457 Ok(Scanned { matches, tokens })
458}
459
460fn scan_with(rules: &CompiledRules, text: &str) -> Result<Vec<Match>, Error> {
461 let tokens: Vec<Token<'_>> = rules.placeholder.tokens(text).collect();
462 scan_tokens(rules, text, &tokens)
463}
464
465fn scan_tokens(rules: &CompiledRules, text: &str, tokens: &[Token<'_>]) -> Result<Vec<Match>, Error> {
466 let protected: Vec<(usize, usize)> = tokens.iter().map(|t| (t.start, t.end)).collect();
467 let mut candidates = Vec::new();
468 for d in &rules.detectors {
469 d.find(text, &mut candidates);
470 }
471 if let Some(ner) = &rules.ner {
472 if text.chars().any(char::is_alphabetic) {
473 let recognizer = ner.recognizer.as_ref().ok_or(Error::RecognizerMissing)?;
474 for e in recognizer.recognize(text)? {
475 let valid = e.start < e.end
476 && e.end <= text.len()
477 && text.is_char_boundary(e.start)
478 && text.is_char_boundary(e.end);
479 if valid && e.score >= ner.threshold && ner.kinds.contains(&e.kind) {
480 candidates.push(detect::Candidate {
481 start: e.start,
482 end: e.end,
483 kind: e.kind,
484 source: Source::Ner,
485 priority: detect::P_NER,
486 });
487 }
488 }
489 }
490 }
491 candidates.retain(|c| protected.iter().all(|&(s, e)| c.end <= s || c.start >= e));
492 Ok(detect::resolve(candidates)
493 .into_iter()
494 .filter(|c| !rules.allowlist.allows(&text[c.start..c.end]))
495 .map(|c| Match { start: c.start, end: c.end, kind: c.kind, source: c.source })
496 .collect())
497}
498
499fn json_strings(v: &Value) -> String {
501 let mut out = String::new();
502 let mut copy = v.clone();
503 let _ = walk::walk(&mut copy, &WalkAll, &mut |s, _| {
504 out.push_str(s);
505 out.push('\n');
506 Ok(())
507 });
508 out
509}
510
511fn json_escape(v: &str) -> String {
512 let quoted = Value::String(v.to_owned()).to_string();
513 quoted[1..quoted.len() - 1].to_owned()
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519 use serde_json::json;
520
521 fn engine_with(rules: RulesFile) -> Engine {
522 Engine::builder().rules(rules).build().unwrap()
523 }
524
525 fn engine() -> Engine {
526 engine_with(RulesFile::default())
527 }
528
529 fn scope() -> ScopeId {
530 ScopeId::profile("default")
531 }
532
533 #[test]
534 fn redacts_and_restores() {
535 let e = engine();
536 let r = e.redact_text(&scope(), "Mail ana@example.com or bob@test.org, ana@example.com.").unwrap();
537 assert_eq!(r.text, "Mail [EMAIL_1] or [EMAIL_2], [EMAIL_1].");
538 assert_eq!(r.report.redacted, 3);
539 assert_eq!(r.report.by_kind["EMAIL"], 3);
540 let Restored { text: restored, report } = e.restore_text(&scope(), &r.text);
541 assert_eq!(restored, "Mail ana@example.com or bob@test.org, ana@example.com.");
542 assert_eq!(report.restored, 3);
543 }
544
545 #[test]
546 fn several_kinds_in_one_text() {
547 let e = engine();
548 let text = "Card 4111 1111 1111 1111, IBAN GB82 WEST 1234 5698 7654 32, call +44 20 7946 0958, key sk-ant-api03-abcdefghijklmnopqrstuvwxyz";
549 let r = e.redact_text(&scope(), text).unwrap();
550 assert_eq!(r.text, "Card [CARD_1], IBAN [IBAN_1], call [PHONE_1], key [SECRET_1]");
551 assert_eq!(e.restore_text(&scope(), &r.text).text, text);
552 }
553
554 #[test]
555 fn scopes_do_not_share_placeholders() {
556 let e = engine();
557 let redacted = e.redact_text(&ScopeId::profile("a"), "ana@example.com").unwrap().text;
558 let Restored { text: restored, report } = e.restore_text(&ScopeId::profile("b"), &redacted);
559 assert_eq!(restored, "[EMAIL_1]");
560 assert_eq!(report.unknown_placeholders, 1);
561 }
562
563 #[test]
564 fn keyed_scopes_never_change() {
565 assert_eq!(ScopeId::keyed(b"fixed key", "conversation 1").as_str(), "conv:994b52d98b13f5df97e24363ad999bbc");
568 assert_ne!(ScopeId::keyed(b"other key", "conversation 1"), ScopeId::keyed(b"fixed key", "conversation 1"));
569 }
570
571 #[test]
572 fn user_written_placeholders_are_never_reassigned() {
573 let e = engine();
574 let text = "Template uses [EMAIL_1] and [EMAIL_2]; real one is ana@example.com";
575 let r = e.redact_text(&scope(), text).unwrap();
576 assert_eq!(r.text, "Template uses [EMAIL_1] and [EMAIL_2]; real one is [EMAIL_3]");
577 assert_eq!(e.restore_text(&scope(), &r.text).text, text);
579 }
580
581 #[test]
582 fn redaction_is_idempotent() {
583 let e = engine();
584 let once = e.redact_text(&scope(), "ana@example.com and 4111111111111111").unwrap().text;
585 let twice = e.redact_text(&scope(), &once).unwrap().text;
586 assert_eq!(once, twice);
587 }
588
589 #[test]
590 fn allowlist_applies_after_overlap_resolution() {
591 let mut rules = RulesFile::default();
592 rules.allowlist.push("support@acme.com".into());
593 rules.dictionary.push(DictionaryEntry { term: "acme".into(), kind: "CLIENT".into(), match_case: false });
594 let e = engine_with(rules);
595 assert_eq!(
596 e.redact_text(&scope(), "write support@acme.com about ACME").unwrap().text,
597 "write support@acme.com about [CLIENT_1]"
598 );
599 assert_eq!(e.redact_text(&scope(), "sales@acme.com").unwrap().text, "[EMAIL_1]");
600 }
601
602 #[test]
603 fn the_default_rules_allow_nothing() {
604 assert!(RulesFile::default().allowlist.is_empty());
605 assert_eq!(engine().redact_text(&scope(), "noreply@anthropic.com").unwrap().text, "[EMAIL_1]");
606 }
607
608 #[test]
609 fn dictionary_and_custom_regex() {
610 let mut rules = RulesFile::default();
611 rules.dictionary.push(DictionaryEntry {
612 term: "Project Halcyon".into(),
613 kind: "CODENAME".into(),
614 match_case: true,
615 });
616 rules.dictionary.push(DictionaryEntry {
617 term: "Acme Holdings".into(),
618 kind: "CLIENT".into(),
619 match_case: false,
620 });
621 rules.regex.push(RegexRule { name: "tickets".into(), kind: "TICKET".into(), pattern: r"\bTCK-\d{6}\b".into() });
622 let e = engine_with(rules);
623 let r = e
624 .redact_text(&scope(), "Project Halcyon for ACME HOLDINGS, see TCK-004211 not TCK-12; project halcyon")
625 .unwrap();
626 assert_eq!(r.text, "[CODENAME_1] for [CLIENT_1], see [TICKET_1] not TCK-12; project halcyon");
627 assert_eq!(r.matches[0].source, Source::Dictionary);
628 assert_eq!(r.matches[2].source, Source::Regex("tickets".into()));
629 }
630
631 #[test]
632 fn redact_json_respects_policy_and_json_strings() {
633 struct Policy;
634 impl KeyPolicy for Policy {
635 fn action(&self, _: &serde_json::Map<String, Value>, key: &str) -> KeyAction {
636 match key {
637 "model" => KeyAction::Skip,
638 "arguments" => KeyAction::JsonString,
639 _ => KeyAction::Walk,
640 }
641 }
642 }
643 let e = engine();
644 let mut body = json!({
645 "model": "a@b.co",
646 "messages": [{"content": "hi a@b.co"}],
647 "tool": {"arguments": "{\"to\":\"a@b.co\",\"n\":1}"},
648 "broken": {"arguments": "{\"to\": \"a@b.co\""}
649 });
650 let report = e.redact_json(&scope(), &mut body, &Policy).unwrap();
651 assert_eq!(body["model"], "a@b.co");
652 assert_eq!(body["messages"][0]["content"], "hi [EMAIL_1]");
653 assert_eq!(body["tool"]["arguments"], "{\"to\":\"[EMAIL_1]\",\"n\":1}");
654 assert_eq!(body["broken"]["arguments"], "{\"to\": \"[EMAIL_1]\"");
655 assert_eq!(report.redacted, 3);
656
657 let audit = e
658 .scan_json(
659 &json!({"model": "a@b.co", "x": "c@d.co", "tool": {"arguments": "{\"to\":\"e@f.co\"}"}}),
660 &Policy,
661 )
662 .unwrap();
663 assert_eq!(audit.redacted, 2);
664 }
665
666 #[test]
667 fn restore_json_escapes_inside_json_strings() {
668 let e = engine();
669 e.redact_text(&scope(), "\"ana\"@example.com").unwrap();
671 let mut rules = RulesFile::default();
672 rules.dictionary.push(DictionaryEntry { term: "say \"hi\"".into(), kind: "PHRASE".into(), match_case: false });
673 let e = engine_with(rules);
674 let r = e.redact_text(&scope(), "say \"hi\" now").unwrap();
675 assert_eq!(r.text, "[PHRASE_1] now");
676 struct Args;
677 impl KeyPolicy for Args {
678 fn action(&self, _: &serde_json::Map<String, Value>, _: &str) -> KeyAction {
679 KeyAction::JsonString
680 }
681 }
682 let mut v = json!({"arguments": "{\"text\":\"[PHRASE_1]\"}"});
683 e.restore_json(&scope(), &mut v, &Args);
684 let inner: Value = serde_json::from_str(v["arguments"].as_str().unwrap()).unwrap();
685 assert_eq!(inner["text"], "say \"hi\"");
686 }
687
688 #[test]
689 fn rules_can_be_swapped_live() {
690 let e = engine();
691 assert_eq!(e.redact_text(&scope(), "see https://x.example/a").unwrap().text, "see https://x.example/a");
692 let mut rules = RulesFile::default();
693 rules.detectors.url = Setting::on();
694 e.set_rules(CompiledRules::compile(&rules, &Options::default(), None).unwrap());
695 assert_eq!(e.redact_text(&scope(), "see https://x.example/a").unwrap().text, "see [URL_1]");
696 }
697
698 struct FakeNer(Vec<(&'static str, &'static str, f32)>);
700
701 impl EntityRecognizer for FakeNer {
702 fn recognize(&self, text: &str) -> Result<Vec<Entity>, RecognizerError> {
703 if text.contains("FAIL") {
704 return Err(RecognizerError::new("model crashed"));
705 }
706 Ok(self
707 .0
708 .iter()
709 .flat_map(|&(word, kind, score)| {
710 text.match_indices(word).map(move |(i, _)| Entity {
711 start: i,
712 end: i + word.len(),
713 kind: EntityKind::new(kind).unwrap(),
714 score,
715 })
716 })
717 .collect())
718 }
719 }
720
721 fn ner_engine(rules: &RulesFile) -> Engine {
722 let ner = FakeNer(vec![
723 ("Ana Pop", "PERSON", 0.97),
724 ("Acme", "ORG", 0.9),
725 ("Jordan", "PERSON", 0.4),
726 ("Paris", "LOCATION", 0.95),
727 ]);
728 Engine::builder().rules(rules.clone()).recognizer(Arc::new(ner)).build().unwrap()
729 }
730
731 #[test]
732 fn ner_redacts_names_above_the_threshold() {
733 let mut rules = RulesFile::default();
734 rules.detectors.ner = Setting::on();
735 let e = ner_engine(&rules);
736 let text = "Ana Pop (ana@example.com) of Acme met Jordan in Paris";
737 let r = e.redact_text(&scope(), text).unwrap();
738 assert_eq!(r.text, "[PERSON_1] ([EMAIL_1]) of [ORG_1] met Jordan in [LOCATION_1]");
739 assert!(r.matches.iter().any(|m| m.source == Source::Ner));
740 assert_eq!(e.restore_text(&scope(), &r.text).text, text);
741
742 rules.detectors.ner.options.kinds = vec!["PERSON".into()];
743 rules.detectors.ner.options.threshold = 0.3;
744 rules.allowlist.push("Ana Pop".into());
745 let e = ner_engine(&rules);
746 assert_eq!(e.redact_text(&scope(), text).unwrap().text, "Ana Pop ([EMAIL_1]) of Acme met [PERSON_1] in Paris");
747 }
748
749 #[test]
750 fn dictionary_beats_ner_on_the_same_span() {
751 let mut rules = RulesFile::default();
752 rules.detectors.ner = Setting::on();
753 rules.dictionary.push(DictionaryEntry { term: "Acme".into(), kind: "CLIENT".into(), match_case: true });
754 assert_eq!(ner_engine(&rules).redact_text(&scope(), "Acme").unwrap().text, "[CLIENT_1]");
755 }
756
757 #[test]
758 fn ner_fails_closed() {
759 let mut rules = RulesFile::default();
760 rules.detectors.ner = Setting::on();
761 let compiled = CompiledRules::compile(&rules, &Options::default(), None).unwrap();
763 assert!(compiled.ner_missing());
764 let e = Engine::builder().compiled_rules(compiled).build().unwrap();
765 assert!(matches!(e.redact_text(&scope(), "Ana Pop"), Err(Error::RecognizerMissing)));
766 assert_eq!(e.redact_text(&scope(), "12 + 3").unwrap().text, "12 + 3");
768 assert!(matches!(ner_engine(&rules).redact_text(&scope(), "FAIL"), Err(Error::Recognizer(_))));
769 }
770
771 #[test]
772 fn fuzzy_restore_is_opt_in() {
773 let compile = |fuzzy| {
774 let options = Options { fuzzy_restore: fuzzy, ..Options::default() };
775 CompiledRules::compile(&RulesFile::default(), &options, None).unwrap()
776 };
777 let e = Engine::builder().compiled_rules(compile(false)).build().unwrap();
778 e.redact_text(&scope(), "ana@example.com and +44 20 7946 0958").unwrap();
779 let model = r"Wrote to [email_1], [ PHONE 1 ], EMAIL_1 and [EMAIL\_1]; [email_9] is unknown.";
780 let Restored { text: exact, report } = e.restore_text(&scope(), model);
781 assert_eq!(exact, model);
782 assert_eq!(report.restored, 0);
783 e.set_rules(compile(true));
784 let Restored { text: fuzzy, report } = e.restore_text(&scope(), model);
785 assert_eq!(
786 fuzzy,
787 "Wrote to ana@example.com, +44 20 7946 0958, ana@example.com and ana@example.com; [email_9] is unknown."
788 );
789 assert_eq!((report.restored, report.unknown_placeholders), (4, 0));
790 }
791
792 #[test]
793 fn ner_options_are_checked() {
794 let mut rules = RulesFile::default();
795 rules.detectors.ner = Setting::on();
796 rules.detectors.ner.options.kinds = vec!["DATE".into()];
797 assert!(matches!(CompiledRules::compile(&rules, &Options::default(), None), Err(RulesError::Ner(_))));
798 rules.detectors.ner.options = NerOptions { threshold: 1.5, ..NerOptions::default() };
799 assert!(matches!(CompiledRules::compile(&rules, &Options::default(), None), Err(RulesError::Ner(_))));
800 }
801
802 mod props {
803 use super::*;
804 use proptest::prelude::*;
805
806 proptest! {
807 #[test]
808 fn redact_then_restore_is_identity(text in "[a-z0-9 .,@\\[\\]_A-Z\\n+-]{0,80}") {
809 let e = engine();
810 let r = e.redact_text(&scope(), &text).unwrap();
811 prop_assert_eq!(e.restore_text(&scope(), &r.text).text, text);
812 }
813
814 #[test]
815 fn redaction_is_deterministic(text in "[a-z0-9 .,@\\[\\]_A-Z\\n+-]{0,80}") {
816 let e = engine();
817 let a = e.redact_text(&scope(), &text).unwrap().text;
818 let b = e.redact_text(&scope(), &text).unwrap().text;
819 prop_assert_eq!(a, b);
820 }
821 }
822 }
823}