1mod detect;
15
16use std::borrow::Cow;
17use std::collections::BTreeMap;
18
19use serde::{Deserialize, Serialize};
20use sha2::{Digest, Sha256};
21use unicode_normalization::UnicodeNormalization;
22
23use crate::error::{AreevError, Result};
24
25pub use detect::KNOWN_CATEGORIES;
26
27pub trait DetectorBackend: Send + Sync {
31 fn kind(&self) -> &str;
33 fn id(&self) -> &str;
35 fn detect(&self, text: &str) -> Result<Vec<Detection>>;
36}
37
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41pub struct Detection {
42 pub start: usize,
43 pub end: usize,
44 pub category: String,
45 #[serde(default = "confidence_one")]
46 pub confidence: f32,
47 #[serde(default)]
48 pub detector: String,
49}
50
51fn confidence_one() -> f32 {
52 1.0
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum Action {
60 Allow,
61 Pseudonym,
62 Generalize(GenBucket),
63 Mask,
64 Redact,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum GenBucket {
73 Month,
75 Year,
77 Decade,
79}
80
81impl Action {
82 fn severity(self) -> u8 {
83 match self {
84 Action::Allow => 0,
85 Action::Pseudonym => 1,
86 Action::Generalize(_) => 2,
87 Action::Mask => 3,
88 Action::Redact => 4,
89 }
90 }
91
92 fn parse(s: &str) -> Result<Action> {
93 match s {
94 "allow" => Ok(Action::Allow),
95 "pseudonym" => Ok(Action::Pseudonym),
96 "mask" => Ok(Action::Mask),
97 "redact" => Ok(Action::Redact),
98 "generalize:month" => Ok(Action::Generalize(GenBucket::Month)),
99 "generalize:year" => Ok(Action::Generalize(GenBucket::Year)),
100 "generalize:decade" => Ok(Action::Generalize(GenBucket::Decade)),
101 other if other == "generalize" || other.starts_with("generalize:") => {
102 Err(AreevError::Validation(format!(
103 "unknown generalization '{other}' (expected generalize:month, \
104 generalize:year, or generalize:decade)"
105 )))
106 }
107 other => Err(AreevError::Validation(format!(
108 "unknown anonymization action '{other}' (expected pseudonym, mask, \
109 redact, generalize:<bucket>, or allow)"
110 ))),
111 }
112 }
113}
114
115fn generalize_value(bucket: GenBucket, category: &str, value: &str) -> String {
118 let fallback = || format!("[GENERALIZED:{}]", category_upper(category));
119 match bucket {
120 GenBucket::Month | GenBucket::Year => {
121 let (year, month) = if value.len() >= 7
123 && value.as_bytes()[4] == b'-'
124 && value[..4].chars().all(|c| c.is_ascii_digit())
125 {
126 (value[..4].to_string(), value.get(5..7).unwrap_or("").to_string())
127 } else {
128 let parts: Vec<&str> = value.split(['/', '.']).collect();
129 match parts.as_slice() {
130 [_, m, y] if y.len() == 4 => ((*y).to_string(), format!("{:0>2}", m)),
131 _ => return fallback(),
132 }
133 };
134 if year.len() != 4 || !year.chars().all(|c| c.is_ascii_digit()) {
135 return fallback();
136 }
137 match bucket {
138 GenBucket::Year => year,
139 _ => {
140 if month.len() == 2 && month.chars().all(|c| c.is_ascii_digit()) {
141 format!("{year}-{month}")
142 } else {
143 fallback()
144 }
145 }
146 }
147 }
148 GenBucket::Decade => match value.trim().parse::<i64>() {
149 Ok(n) if (0..=9999).contains(&n) => format!("{}0s", n / 10),
150 _ => fallback(),
151 },
152 }
153}
154
155fn default_mode() -> String {
156 "egress".into()
157}
158fn default_action() -> String {
159 "pseudonym".into()
160}
161fn default_scope() -> String {
162 "context".into()
163}
164fn default_placeholder() -> String {
165 "[{CATEGORY}_{ID}]".into()
166}
167fn default_min_confidence() -> f32 {
168 0.5
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
180pub struct CoOccurrence {
181 pub when: String,
183 pub near: String,
186 #[serde(default = "default_within_chars")]
188 pub within_chars: usize,
189 pub as_category: String,
191}
192
193fn default_within_chars() -> usize {
194 120
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
205#[serde(deny_unknown_fields)]
206pub struct AnonPolicy {
207 #[serde(default = "default_mode")]
208 pub mode: String,
209 #[serde(default)]
211 pub categories: BTreeMap<String, String>,
212 #[serde(default = "default_action")]
215 pub default_action: String,
216 #[serde(default)]
218 pub custom_terms: Vec<String>,
219 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
225 pub term_sets: BTreeMap<String, Vec<String>>,
226 #[serde(default, skip_serializing_if = "Vec::is_empty")]
235 pub co_occurrence: Vec<CoOccurrence>,
236 #[serde(default = "default_scope")]
239 pub scope: String,
240 #[serde(default = "default_placeholder")]
242 pub placeholder: String,
243 #[serde(default = "default_min_confidence")]
244 pub min_confidence: f32,
245 #[serde(default = "default_detectors")]
249 pub detectors: Vec<String>,
250 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
253 pub vault: bool,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub vault_ttl_days: Option<f64>,
257 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub because: Option<String>,
259 #[serde(default, skip_serializing_if = "Vec::is_empty")]
268 pub known: Vec<KnownIdentity>,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct KnownIdentity {
276 pub value: String,
277 #[serde(default = "default_known_category")]
278 pub category: String,
279}
280
281fn default_known_category() -> String {
282 "person".to_string()
283}
284
285fn default_detectors() -> Vec<String> {
286 vec!["tier0".to_string()]
287}
288
289impl Default for AnonPolicy {
290 fn default() -> Self {
291 AnonPolicy {
292 mode: default_mode(),
293 categories: BTreeMap::new(),
294 default_action: default_action(),
295 custom_terms: Vec::new(),
296 term_sets: BTreeMap::new(),
297 co_occurrence: Vec::new(),
298 scope: default_scope(),
299 placeholder: default_placeholder(),
300 min_confidence: default_min_confidence(),
301 detectors: default_detectors(),
302 vault: false,
303 vault_ttl_days: None,
304 because: None,
305 known: Vec::new(),
306 }
307 }
308}
309
310impl AnonPolicy {
311 pub fn from_json(json: &str) -> Result<AnonPolicy> {
315 let policy: AnonPolicy = serde_json::from_str(json).map_err(|e| {
316 AreevError::Validation(format!("invalid anonymization policy: {e}"))
317 })?;
318 policy.validate()?;
319 Ok(policy)
320 }
321
322 pub fn validate(&self) -> Result<()> {
323 match self.mode.as_str() {
324 "off" | "egress" | "ingress" | "both" | "audit" => {}
325 other => {
326 return Err(AreevError::Validation(format!(
327 "invalid anonymization policy: unknown mode '{other}' (expected \
328 off, egress, ingress, both, or audit)"
329 )));
330 }
331 }
332 match self.scope.as_str() {
333 "context" | "session" | "memory" => {}
334 other => {
335 return Err(AreevError::Validation(format!(
336 "invalid anonymization policy: unknown scope '{other}' (expected \
337 context, session, or memory)"
338 )));
339 }
340 }
341 if !(self.placeholder.contains("{CATEGORY}") && self.placeholder.contains("{ID}")) {
342 return Err(AreevError::Validation(
343 "invalid anonymization policy: placeholder template must contain \
344 {CATEGORY} and {ID}"
345 .into(),
346 ));
347 }
348 if !(0.0..=1.0).contains(&self.min_confidence) || !self.min_confidence.is_finite() {
349 return Err(AreevError::Validation(
350 "invalid anonymization policy: min_confidence must be within 0.0..=1.0"
351 .into(),
352 ));
353 }
354 Action::parse(&self.default_action).map_err(|e| {
355 AreevError::Validation(format!("invalid anonymization policy default_action: {e}"))
356 })?;
357 for (cat, act) in &self.categories {
358 if cat.is_empty() {
359 return Err(AreevError::Validation(
360 "invalid anonymization policy: empty category name".into(),
361 ));
362 }
363 Action::parse(act).map_err(|e| {
364 AreevError::Validation(format!(
365 "invalid anonymization policy for category '{cat}': {e}"
366 ))
367 })?;
368 }
369 if self.detectors.is_empty() {
370 return Err(AreevError::Validation(
371 "invalid anonymization policy: detectors must not be empty (use \
372 [\"tier0\"])"
373 .into(),
374 ));
375 }
376 for d in &self.detectors {
377 if !matches!(d.as_str(), "tier0" | "ner" | "llm") {
378 return Err(AreevError::Validation(format!(
379 "invalid anonymization policy: unknown detector '{d}' (expected \
380 tier0, ner, or llm)"
381 )));
382 }
383 }
384 if self.vault && self.scope == "context" {
385 return Err(AreevError::Validation(
386 "invalid anonymization policy: vault persistence needs scope \
387 \"session\" or \"memory\" — context-scope mappings are ephemeral \
388 by definition"
389 .into(),
390 ));
391 }
392 if let Some(ttl) = self.vault_ttl_days {
393 if !self.vault {
394 return Err(AreevError::Validation(
395 "invalid anonymization policy: vault_ttl_days needs vault: true"
396 .into(),
397 ));
398 }
399 if ttl < 0.0 || !ttl.is_finite() {
400 return Err(AreevError::Validation(
401 "invalid anonymization policy: vault_ttl_days must be a \
402 non-negative, finite number"
403 .into(),
404 ));
405 }
406 }
407 for term in &self.custom_terms {
408 if term.trim().is_empty() {
409 return Err(AreevError::Validation(
410 "invalid anonymization policy: empty custom_terms entry".into(),
411 ));
412 }
413 }
414 for k in &self.known {
415 if k.value.trim().is_empty() {
416 return Err(AreevError::Validation(
417 "invalid anonymization policy: empty known[].value entry".into(),
418 ));
419 }
420 if k.category.trim().is_empty() {
421 return Err(AreevError::Validation(
422 "invalid anonymization policy: empty known[].category entry".into(),
423 ));
424 }
425 }
426 Ok(())
427 }
428
429 fn action_for(&self, category: &str) -> Action {
430 match self.categories.get(category) {
431 Some(act) => Action::parse(act).unwrap_or(Action::Redact),
433 None => Action::parse(&self.default_action).unwrap_or(Action::Redact),
434 }
435 }
436}
437
438#[derive(Debug, Clone, Serialize)]
441pub struct ScanOutcome {
442 pub text: String,
443 pub detections: Vec<Detection>,
444}
445
446#[derive(Debug, Clone, Serialize)]
450pub struct AnonOutcome {
451 pub text: String,
452 pub mapping: BTreeMap<String, String>,
453 pub mapping_id: String,
454 pub replaced: usize,
456}
457
458#[derive(Debug, Clone, Serialize)]
461pub struct RehydrateOutcome {
462 pub text: String,
463 pub replaced: usize,
464 pub unmatched: Vec<String>,
465}
466
467pub fn nfc(text: &str) -> Cow<'_, str> {
469 if unicode_normalization::is_nfc(text) {
470 Cow::Borrowed(text)
471 } else {
472 Cow::Owned(text.nfc().collect())
473 }
474}
475
476pub fn scan(text: &str, policy: &AnonPolicy, known_identities: &[String]) -> Result<ScanOutcome> {
485 scan_with(text, policy, known_identities, &[])
486}
487
488pub fn scan_with(
493 text: &str,
494 policy: &AnonPolicy,
495 known_identities: &[String],
496 backends: &[&dyn DetectorBackend],
497) -> Result<ScanOutcome> {
498 policy.validate()?;
499 let text = nfc(text).into_owned();
500 let mut detections = if policy.detectors.iter().any(|d| d == "tier0") {
501 detect::run_tier0(&text, &policy.custom_terms, known_identities, &policy.term_sets)?
502 } else {
503 Vec::new()
504 };
505 if !policy.known.is_empty() {
506 detections.extend(detect::run_known(&text, &policy.known)?);
507 }
508 for kind in policy.detectors.iter().filter(|d| *d != "tier0") {
509 let backend = backends
510 .iter()
511 .find(|b| b.kind() == kind)
512 .ok_or_else(|| {
513 AreevError::Validation(format!(
514 "anonymization policy demands detector \"{kind}\" but no such \
515 backend is installed on this host — egress fails closed (D6); \
516 install one (e.g. --anonymize-cmd) or drop it from the policy"
517 ))
518 })?;
519 for d in backend.detect(&text)? {
520 let in_bounds = d.start < d.end
521 && d.end <= text.len()
522 && text.is_char_boundary(d.start)
523 && text.is_char_boundary(d.end);
524 if !in_bounds || d.category.is_empty() {
525 return Err(AreevError::Validation(format!(
526 "detector {} returned an invalid span {}..{} — refusing the \
527 whole result (D6): a mis-sliced span is a silent leak",
528 backend.id(),
529 d.start,
530 d.end
531 )));
532 }
533 detections.push(d);
534 }
535 }
536 detections.retain(|d| d.confidence >= policy.min_confidence);
537 apply_co_occurrence(&mut detections, policy);
538 let mut survivors = resolve_overlaps(detections, policy);
539 survivors.retain(|d| policy.action_for(&d.category) != Action::Allow);
540 Ok(ScanOutcome { text, detections: survivors })
541}
542
543fn apply_co_occurrence(detections: &mut [Detection], policy: &AnonPolicy) {
559 if policy.co_occurrence.is_empty() {
560 return;
561 }
562 let snapshot: Vec<(String, usize, usize)> = detections
563 .iter()
564 .map(|d| (d.category.clone(), d.start, d.end))
565 .collect();
566 for (i, d) in detections.iter_mut().enumerate() {
567 for rule in &policy.co_occurrence {
568 if d.category != rule.when {
569 continue;
570 }
571 let hit = snapshot.iter().enumerate().any(|(j, (cat, start, end))| {
572 if i == j || cat != &rule.near {
573 return false;
574 }
575 let gap = if *start >= d.end {
577 start - d.end
578 } else if d.start >= *end {
579 d.start - end
580 } else {
581 0
582 };
583 gap <= rule.within_chars
584 });
585 if hit {
586 d.category = rule.as_category.clone();
587 break;
588 }
589 }
590 }
591}
592
593pub fn anonymize(
602 text: &str,
603 policy: &AnonPolicy,
604 known_identities: &[String],
605 key: Option<&[u8]>,
606) -> Result<AnonOutcome> {
607 let mut session = if policy.scope == "memory" {
608 let Some(k) = key else {
609 return Err(AreevError::Validation(
610 "anonymization scope \"memory\" needs a key (pass key_hex; \
611 value-derived tokens are keyed by design)"
612 .into(),
613 ));
614 };
615 SessionAnonymizer::new_keyed(policy.clone(), hmac_sha256(k, b"areev.anon.tokenkey.v1"))?
616 } else {
617 SessionAnonymizer::new(policy.clone())?
618 };
619 let (out, replaced) = session.transform_text(text, known_identities)?;
620 let mapping_id = session.mapping_id(key)?;
621 Ok(AnonOutcome { text: out, mapping: session.into_mapping(), mapping_id, replaced })
622}
623
624#[derive(Debug, Clone)]
631pub struct SessionAnonymizer {
632 policy: AnonPolicy,
633 by_value: BTreeMap<(String, String), String>,
634 order: std::collections::VecDeque<(String, String)>,
635 counters: BTreeMap<String, u64>,
636 mapping: BTreeMap<String, String>,
637 reserved: std::collections::BTreeSet<String>,
638 pending: Vec<(String, String)>,
641 token_key: Option<[u8; 32]>,
646}
647
648impl SessionAnonymizer {
649 pub fn new(policy: AnonPolicy) -> Result<Self> {
650 policy.validate()?;
651 if policy.scope == "memory" {
652 return Err(AreevError::Validation(
653 "anonymization scope \"memory\" needs a token key — use \
654 SessionAnonymizer::new_keyed (the store derives it from the \
655 file's encryption key)"
656 .into(),
657 ));
658 }
659 Ok(Self::build(policy, None))
660 }
661
662 pub fn new_keyed(policy: AnonPolicy, key: [u8; 32]) -> Result<Self> {
666 policy.validate()?;
667 Ok(Self::build(policy, Some(key)))
668 }
669
670 fn build(policy: AnonPolicy, token_key: Option<[u8; 32]>) -> Self {
671 SessionAnonymizer {
672 policy,
673 by_value: BTreeMap::new(),
674 order: std::collections::VecDeque::new(),
675 counters: BTreeMap::new(),
676 mapping: BTreeMap::new(),
677 reserved: std::collections::BTreeSet::new(),
678 pending: Vec::new(),
679 token_key,
680 }
681 }
682
683 pub fn policy(&self) -> &AnonPolicy {
684 &self.policy
685 }
686
687 pub fn mapping(&self) -> &BTreeMap<String, String> {
689 &self.mapping
690 }
691
692 pub fn into_mapping(self) -> BTreeMap<String, String> {
693 self.mapping
694 }
695
696 pub fn len(&self) -> usize {
697 self.by_value.len()
698 }
699
700 pub fn is_empty(&self) -> bool {
701 self.by_value.is_empty()
702 }
703
704 pub fn mapping_id(&self, key: Option<&[u8]>) -> Result<String> {
706 derive_mapping_id(key, &self.policy, &self.mapping)
707 }
708
709 pub fn transform_text(
713 &mut self,
714 text: &str,
715 known_identities: &[String],
716 ) -> Result<(String, usize)> {
717 self.transform_text_with(text, known_identities, &[])
718 }
719
720 pub fn transform_text_with(
723 &mut self,
724 text: &str,
725 known_identities: &[String],
726 backends: &[&dyn DetectorBackend],
727 ) -> Result<(String, usize)> {
728 let ScanOutcome { text, detections } =
729 scan_with(text, &self.policy, known_identities, backends)?;
730 for t in template_shaped_tokens(&text, &self.policy.placeholder) {
731 self.reserved.insert(t);
732 }
733 let mut out = String::with_capacity(text.len());
734 let mut cursor = 0usize;
735 let mut replaced = 0usize;
736 for d in &detections {
737 out.push_str(&text[cursor..d.start]);
738 let value = &text[d.start..d.end];
739 match self.policy.action_for(&d.category) {
740 Action::Allow => unreachable!("allow spans dropped in scan()"),
741 Action::Redact => {
742 out.push_str(&format!("[REDACTED:{}]", category_upper(&d.category)));
743 replaced += 1;
744 }
745 Action::Mask => {
746 out.push_str(&mask_value(value));
747 replaced += 1;
748 }
749 Action::Generalize(bucket) => {
750 out.push_str(&generalize_value(bucket, &d.category, value));
751 replaced += 1;
752 }
753 Action::Pseudonym => {
754 let token = self.token_for(&d.category, value);
755 out.push_str(&token);
756 replaced += 1;
757 }
758 }
759 cursor = d.end;
760 }
761 out.push_str(&text[cursor..]);
762 Ok((out, replaced))
763 }
764
765 pub fn transform_value(&mut self, category: &str, value: &str) -> String {
769 match self.policy.action_for(category) {
770 Action::Allow => value.to_string(),
771 Action::Redact => format!("[REDACTED:{}]", category_upper(category)),
772 Action::Mask => mask_value(value),
773 Action::Generalize(bucket) => generalize_value(bucket, category, value),
774 Action::Pseudonym => self.token_for(category, value),
775 }
776 }
777
778 pub fn token_if_known(&self, category: &str, value: &str) -> Option<&str> {
782 self.by_value
783 .get(&(category.to_string(), value.to_string()))
784 .map(String::as_str)
785 }
786
787 pub fn evict_to(&mut self, max_entries: usize) {
792 while self.by_value.len() > max_entries {
793 let Some(oldest) = self.order.pop_front() else { break };
794 if let Some(token) = self.by_value.remove(&oldest) {
795 self.mapping.remove(&token);
796 }
797 }
798 }
799
800 pub fn take_pending(&mut self) -> Vec<(String, String)> {
803 std::mem::take(&mut self.pending)
804 }
805
806 pub fn seed(&mut self, entries: Vec<(String, String)>) {
810 for (token, value) in entries {
811 for (cat_upper, id_part) in parse_token_parts(&self.policy.placeholder, &token) {
812 if let Ok(n) = id_part.parse::<u64>() {
813 let cat_key = cat_upper.to_ascii_lowercase();
814 let c = self.counters.entry(cat_key).or_insert(0);
815 if *c < n {
816 *c = n;
817 }
818 }
819 }
820 let cat = parse_token_parts(&self.policy.placeholder, &token)
823 .first()
824 .map(|(c, _)| c.to_ascii_lowercase())
825 .unwrap_or_else(|| "custom".into());
826 self.reserved.insert(token.clone());
827 self.by_value.insert((cat.clone(), value.clone()), token.clone());
828 self.order.push_back((cat, value.clone()));
829 self.mapping.insert(token, value);
830 }
831 }
832
833 fn token_for(&mut self, category: &str, value: &str) -> String {
834 let k = (category.to_string(), value.to_string());
835 if let Some(t) = self.by_value.get(&k) {
836 return t.clone();
837 }
838 let token = match self.token_key {
839 Some(key) => derived_token(&self.policy.placeholder, &key, category, value),
840 None => {
841 let counter = self.counters.entry(category.to_string()).or_insert(0);
842 mint_token(&self.policy.placeholder, category, counter, &self.reserved)
843 }
844 };
845 self.by_value.insert(k.clone(), token.clone());
846 self.order.push_back(k);
847 self.mapping.insert(token.clone(), value.to_string());
848 self.pending.push((token.clone(), value.to_string()));
849 token
850 }
851
852 pub fn scrub_values(&mut self, identities: &[String]) -> usize {
856 let doomed: Vec<(String, String)> = self
857 .by_value
858 .iter()
859 .filter(|((_, v), _)| identities.iter().any(|i| i == v))
860 .map(|(k, _)| k.clone())
861 .collect();
862 let n = doomed.len();
863 for k in doomed {
864 if let Some(token) = self.by_value.remove(&k) {
865 self.mapping.remove(&token);
866 }
867 self.order.retain(|o| *o != k);
868 }
869 self.pending.retain(|(_, v)| !identities.iter().any(|i| i == v));
870 n
871 }
872}
873
874fn parse_token_parts(template: &str, token: &str) -> Vec<(String, String)> {
877 let mut pattern = String::from("^");
878 let mut rest = template;
879 while let Some(idx) = rest.find('{') {
880 pattern.push_str(®ex::escape(&rest[..idx]));
881 if rest[idx..].starts_with("{CATEGORY}") {
882 pattern.push_str("([A-Z][A-Z0-9_]*)");
883 rest = &rest[idx + "{CATEGORY}".len()..];
884 } else if rest[idx..].starts_with("{ID}") {
885 pattern.push_str("([0-9A-Za-z]+)");
886 rest = &rest[idx + "{ID}".len()..];
887 } else {
888 pattern.push_str(®ex::escape(&rest[idx..idx + 1]));
889 rest = &rest[idx + 1..];
890 }
891 }
892 pattern.push_str(®ex::escape(rest));
893 pattern.push('$');
894 let Ok(re) = regex::Regex::new(&pattern) else { return Vec::new() };
895 let Some(c) = re.captures(token) else { return Vec::new() };
896 match (c.get(1), c.get(2)) {
897 (Some(cat), Some(id)) => vec![(cat.as_str().to_string(), id.as_str().to_string())],
898 _ => Vec::new(),
899 }
900}
901
902pub fn derived_token(template: &str, key: &[u8; 32], category: &str, value: &str) -> String {
907 let mut msg = Vec::with_capacity(category.len() + value.len() + 1);
908 msg.extend_from_slice(category.as_bytes());
909 msg.push(0x1e);
910 msg.extend_from_slice(value.as_bytes());
911 let digest = hmac_sha256(key, &msg);
912 template
913 .replace("{CATEGORY}", &category_upper(category))
914 .replace("{ID}", &hex::encode(&digest[..4]))
915}
916
917pub fn rehydrate(text: &str, mapping: &BTreeMap<String, String>) -> Result<RehydrateOutcome> {
924 rehydrate_with_template(text, mapping, &default_placeholder())
925}
926
927pub fn rehydrate_with_template(
934 text: &str,
935 mapping: &BTreeMap<String, String>,
936 template: &str,
937) -> Result<RehydrateOutcome> {
938 let text = nfc(text).into_owned();
939 for k in mapping.keys() {
940 if k.is_empty() {
941 return Err(AreevError::Validation(
942 "invalid anonymization mapping: empty placeholder key".into(),
943 ));
944 }
945 }
946
947 let mut hits: Vec<(usize, &str)> = Vec::new();
952 for key in mapping.keys() {
953 for (pos, _) in text.match_indices(key.as_str()) {
954 hits.push((pos, key.as_str()));
955 }
956 }
957 hits.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.len().cmp(&a.1.len())));
958
959 let mut out = String::with_capacity(text.len());
960 let mut cursor = 0usize;
961 let mut replaced = 0usize;
962 for (pos, key) in hits {
963 if pos < cursor {
964 continue; }
966 out.push_str(&text[cursor..pos]);
967 out.push_str(&mapping[key]);
968 cursor = pos + key.len();
969 replaced += 1;
970 }
971 out.push_str(&text[cursor..]);
972
973 let mut unmatched: Vec<String> = Vec::new();
976 for token in template_shaped_tokens(&out, template) {
977 if !mapping.contains_key(&token) && !unmatched.contains(&token) {
978 unmatched.push(token);
979 }
980 }
981 unmatched.sort();
982 Ok(RehydrateOutcome { text: out, replaced, unmatched })
983}
984
985pub fn mapping_from_json(json: &str) -> Result<BTreeMap<String, String>> {
987 serde_json::from_str(json)
988 .map_err(|e| AreevError::Validation(format!("invalid anonymization mapping: {e}")))
989}
990
991fn category_upper(category: &str) -> String {
994 category
995 .chars()
996 .map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_uppercase() } else { '_' })
997 .collect()
998}
999
1000fn mint_token(
1001 template: &str,
1002 category: &str,
1003 counter: &mut u64,
1004 reserved: &std::collections::BTreeSet<String>,
1005) -> String {
1006 loop {
1007 *counter += 1;
1008 let token = template
1009 .replace("{CATEGORY}", &category_upper(category))
1010 .replace("{ID}", &counter.to_string());
1011 if !reserved.contains(&token) {
1012 return token;
1013 }
1014 }
1015}
1016
1017fn template_shaped_tokens(text: &str, template: &str) -> std::collections::BTreeSet<String> {
1019 let mut pattern = String::new();
1020 let mut rest = template;
1021 while let Some(idx) = rest.find('{') {
1022 pattern.push_str(®ex::escape(&rest[..idx]));
1023 if rest[idx..].starts_with("{CATEGORY}") {
1024 pattern.push_str("[A-Z][A-Z0-9_]*");
1025 rest = &rest[idx + "{CATEGORY}".len()..];
1026 } else if rest[idx..].starts_with("{ID}") {
1027 pattern.push_str("[0-9A-Za-z]+");
1028 rest = &rest[idx + "{ID}".len()..];
1029 } else {
1030 pattern.push_str(®ex::escape(&rest[idx..idx + 1]));
1031 rest = &rest[idx + 1..];
1032 }
1033 }
1034 pattern.push_str(®ex::escape(rest));
1035 let mut out = std::collections::BTreeSet::new();
1036 if let Ok(re) = regex::Regex::new(&pattern) {
1037 for m in re.find_iter(text) {
1038 out.insert(m.as_str().to_string());
1039 }
1040 }
1041 out
1042}
1043
1044fn mask_value(value: &str) -> String {
1045 let mut out = String::with_capacity(value.len());
1046 let mut run_started = false;
1047 for c in value.chars() {
1048 if c.is_alphanumeric() {
1049 if run_started {
1050 out.push('*');
1051 } else {
1052 out.push(c);
1053 run_started = true;
1054 }
1055 } else {
1056 out.push(c);
1057 run_started = false;
1058 }
1059 }
1060 out
1061}
1062
1063fn resolve_overlaps(mut detections: Vec<Detection>, policy: &AnonPolicy) -> Vec<Detection> {
1077 let mut survivors: Vec<Detection> = Vec::new();
1078 while !detections.is_empty() {
1079 let best = detections
1080 .iter()
1081 .enumerate()
1082 .max_by(|(_, a), (_, b)| {
1083 let sa = policy.action_for(&a.category).severity();
1084 let sb = policy.action_for(&b.category).severity();
1085 let va = u8::from(detect::category_is_validated(&a.category));
1086 let vb = u8::from(detect::category_is_validated(&b.category));
1087 sa.cmp(&sb)
1088 .then((a.end - a.start).cmp(&(b.end - b.start)))
1089 .then(va.cmp(&vb))
1090 .then(b.start.cmp(&a.start))
1091 .then(b.category.cmp(&a.category))
1092 })
1093 .map(|(i, _)| i)
1094 .expect("non-empty");
1095 let winner = detections.swap_remove(best);
1096 detections.retain(|d| d.end <= winner.start || d.start >= winner.end);
1097 survivors.push(winner);
1098 }
1099 survivors.sort_by_key(|d| d.start);
1100 survivors
1101}
1102
1103fn derive_mapping_id(
1108 key: Option<&[u8]>,
1109 policy: &AnonPolicy,
1110 mapping: &BTreeMap<String, String>,
1111) -> Result<String> {
1112 let policy_json = serde_json::to_string(policy)
1113 .map_err(|e| AreevError::Validation(format!("anonymization policy serialize: {e}")))?;
1114 let mut msg = Vec::with_capacity(policy_json.len() + 64);
1115 msg.extend_from_slice(policy_json.as_bytes());
1116 msg.push(0x1f);
1117 msg.extend_from_slice(policy.scope.as_bytes());
1118 for (k, v) in mapping {
1119 msg.push(0x1f);
1120 msg.extend_from_slice(k.as_bytes());
1121 msg.push(0x1e);
1122 msg.extend_from_slice(v.as_bytes());
1123 }
1124 let zero_key = [0u8; 32];
1125 let digest = hmac_sha256(key.unwrap_or(&zero_key), &msg);
1126 Ok(hex::encode(&digest[..8]))
1127}
1128
1129pub fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
1132 const BLOCK: usize = 64;
1133 let mut key_block = [0u8; BLOCK];
1134 if key.len() > BLOCK {
1135 key_block[..32].copy_from_slice(&Sha256::digest(key));
1136 } else {
1137 key_block[..key.len()].copy_from_slice(key);
1138 }
1139 let mut inner = Sha256::new();
1140 let ipad: Vec<u8> = key_block.iter().map(|b| b ^ 0x36).collect();
1141 inner.update(&ipad);
1142 inner.update(message);
1143 let inner_digest = inner.finalize();
1144 let mut outer = Sha256::new();
1145 let opad: Vec<u8> = key_block.iter().map(|b| b ^ 0x5c).collect();
1146 outer.update(&opad);
1147 outer.update(inner_digest);
1148 outer.finalize().into()
1149}
1150
1151#[cfg(test)]
1152mod tests {
1153 use super::*;
1154
1155 #[test]
1156 fn hmac_sha256_matches_rfc4231_case_2() {
1157 let mac = hmac_sha256(b"Jefe", b"what do ya want for nothing?");
1159 assert_eq!(
1160 hex::encode(mac),
1161 "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
1162 );
1163 }
1164
1165 #[test]
1166 fn policy_rejects_unknown_field_and_unknown_bucket_and_unbuilt_scope() {
1167 assert!(AnonPolicy::from_json(r#"{"surprise": 1}"#).is_err());
1168 assert!(AnonPolicy::from_json(r#"{"categories": {"date": "generalize:month"}}"#).is_ok());
1169 assert!(AnonPolicy::from_json(r#"{"categories": {"date": "generalize:eon"}}"#).is_err());
1170 assert!(AnonPolicy::from_json(r#"{"scope": "session"}"#).is_ok()); assert!(AnonPolicy::from_json(r#"{"scope": "memory"}"#).is_ok()); let memory = AnonPolicy::from_json(r#"{"scope": "memory"}"#).unwrap();
1174 assert!(SessionAnonymizer::new(memory.clone()).is_err());
1175 assert!(anonymize("x", &memory, &[], None).is_err());
1176 assert!(anonymize("mail a@b.co", &memory, &[], Some(b"k")).is_ok());
1177 assert!(AnonPolicy::from_json("{}").is_ok());
1178 }
1179
1180 #[test]
1181 fn session_anonymizer_is_stable_across_texts_and_evicts_oldest() {
1182 let mut s = SessionAnonymizer::new(AnonPolicy::default()).unwrap();
1183 let (a, _) = s.transform_text("mail a@b.co", &[]).unwrap();
1184 let (b, _) = s.transform_text("again a@b.co and new c@d.io", &[]).unwrap();
1185 assert_eq!(a, "mail [EMAIL_1]");
1186 assert_eq!(b, "again [EMAIL_1] and new [EMAIL_2]"); assert_eq!(s.transform_value("person", "caller:john"), "[PERSON_1]");
1188 assert_eq!(s.transform_value("person", "caller:john"), "[PERSON_1]");
1189 assert_eq!(s.token_if_known("person", "caller:john"), Some("[PERSON_1]"));
1190 assert_eq!(s.len(), 3);
1191
1192 s.evict_to(1);
1193 assert_eq!(s.len(), 1);
1194 assert_eq!(s.token_if_known("email", "a@b.co"), None); assert_eq!(s.token_if_known("person", "caller:john"), Some("[PERSON_1]"));
1196 }
1197
1198 #[test]
1199 fn generalization_coarsens_and_never_leaks() {
1200 assert_eq!(generalize_value(GenBucket::Month, "date", "2026-08-16"), "2026-08");
1201 assert_eq!(generalize_value(GenBucket::Month, "date", "16/08/2026"), "2026-08");
1202 assert_eq!(generalize_value(GenBucket::Year, "date", "2026-08-16"), "2026");
1203 assert_eq!(generalize_value(GenBucket::Decade, "age", "47"), "40s");
1204 assert_eq!(generalize_value(GenBucket::Month, "date", "someday"), "[GENERALIZED:DATE]");
1206 assert_eq!(generalize_value(GenBucket::Decade, "age", "young"), "[GENERALIZED:AGE]");
1207
1208 let mut policy = AnonPolicy::default();
1209 policy.categories.insert("date".into(), "generalize:month".into());
1210 let out = anonymize("met on 2026-08-16 at noon", &policy, &[], None).unwrap();
1211 assert_eq!(out.text, "met on 2026-08 at noon");
1212 assert!(out.mapping.is_empty(), "generalization is one-way");
1213 }
1214
1215 #[test]
1216 fn mask_keeps_shape() {
1217 assert_eq!(mask_value("john.doe@example.com"), "j***.d**@e******.c**");
1218 assert_eq!(mask_value("+1-555-0142"), "+1-5**-0***");
1219 }
1220
1221 #[test]
1222 fn collision_renumbers_around_literal_tokens() {
1223 let policy = AnonPolicy::default();
1224 let out = anonymize(
1225 "already has [EMAIL_1] and a real x@y.io address",
1226 &policy,
1227 &[],
1228 None,
1229 )
1230 .unwrap();
1231 assert!(out.text.contains("[EMAIL_1]")); assert!(out.mapping.contains_key("[EMAIL_2]")); assert_eq!(out.mapping["[EMAIL_2]"], "x@y.io");
1234 }
1235
1236 #[test]
1237 fn mapping_id_is_keyed() {
1238 let policy = AnonPolicy::default();
1239 let a = anonymize("mail me at a@b.co", &policy, &[], None).unwrap();
1240 let b = anonymize("mail me at a@b.co", &policy, &[], Some(b"k1")).unwrap();
1241 let c = anonymize("mail me at a@b.co", &policy, &[], Some(b"k1")).unwrap();
1242 assert_ne!(a.mapping_id, b.mapping_id); assert_eq!(b.mapping_id, c.mapping_id); }
1245}
1246
1247#[cfg(test)]
1248mod co_occurrence_tests {
1249 use super::*;
1250
1251 fn policy() -> AnonPolicy {
1255 let mut p = AnonPolicy {
1256 mode: "egress".into(),
1257 default_action: "allow".into(),
1258 ..Default::default()
1259 };
1260 p.categories.insert("person".into(), "allow".into());
1261 p.categories.insert("condition".into(), "allow".into());
1262 p.categories.insert("phi".into(), "pseudonym".into());
1263 p.term_sets.insert(
1264 "condition".into(),
1265 vec!["type 2 diabetes".into(), "hypertension".into()],
1266 );
1267 p.co_occurrence.push(CoOccurrence {
1268 when: "person".into(),
1269 near: "condition".into(),
1270 within_chars: 40,
1271 as_category: "phi".into(),
1272 });
1273 p
1274 }
1275
1276 fn run(text: &str) -> String {
1277 let known = vec![KnownIdentity {
1278 value: "Jane Doe".into(),
1279 category: "person".into(),
1280 }];
1281 let p = AnonPolicy { known, ..policy() };
1282 anonymize(text, &p, &[], None).unwrap().text
1283 }
1284
1285 #[test]
1286 fn a_bare_name_passes_but_a_name_near_a_condition_does_not() {
1287 let alone = run("Jane Doe called about the invoice.");
1289 assert!(alone.contains("Jane Doe"), "bare name stays: {alone}");
1290
1291 let together = run("Jane Doe was diagnosed with type 2 diabetes.");
1295 assert!(
1296 !together.contains("Jane Doe"),
1297 "a name beside a condition must not survive: {together}"
1298 );
1299 assert!(
1300 together.contains("[PHI_1]"),
1301 "the placeholder should say why it was escalated: {together}"
1302 );
1303 }
1304
1305 #[test]
1306 fn the_proximity_window_bounds_the_rule() {
1307 let far = format!(
1309 "Jane Doe called about the invoice.{} Separately, type 2 diabetes rates rose.",
1310 " ".repeat(60)
1311 );
1312 let out = run(&far);
1313 assert!(out.contains("Jane Doe"), "outside the window: {out}");
1314 }
1315}