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)]
179#[serde(deny_unknown_fields)]
180pub struct AnonPolicy {
181 #[serde(default = "default_mode")]
182 pub mode: String,
183 #[serde(default)]
185 pub categories: BTreeMap<String, String>,
186 #[serde(default = "default_action")]
189 pub default_action: String,
190 #[serde(default)]
192 pub custom_terms: Vec<String>,
193 #[serde(default = "default_scope")]
196 pub scope: String,
197 #[serde(default = "default_placeholder")]
199 pub placeholder: String,
200 #[serde(default = "default_min_confidence")]
201 pub min_confidence: f32,
202 #[serde(default = "default_detectors")]
206 pub detectors: Vec<String>,
207 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
210 pub vault: bool,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub vault_ttl_days: Option<f64>,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub because: Option<String>,
216}
217
218fn default_detectors() -> Vec<String> {
219 vec!["tier0".to_string()]
220}
221
222impl Default for AnonPolicy {
223 fn default() -> Self {
224 AnonPolicy {
225 mode: default_mode(),
226 categories: BTreeMap::new(),
227 default_action: default_action(),
228 custom_terms: Vec::new(),
229 scope: default_scope(),
230 placeholder: default_placeholder(),
231 min_confidence: default_min_confidence(),
232 detectors: default_detectors(),
233 vault: false,
234 vault_ttl_days: None,
235 because: None,
236 }
237 }
238}
239
240impl AnonPolicy {
241 pub fn from_json(json: &str) -> Result<AnonPolicy> {
245 let policy: AnonPolicy = serde_json::from_str(json).map_err(|e| {
246 AreevError::Validation(format!("invalid anonymization policy: {e}"))
247 })?;
248 policy.validate()?;
249 Ok(policy)
250 }
251
252 pub fn validate(&self) -> Result<()> {
253 match self.mode.as_str() {
254 "off" | "egress" | "ingress" | "both" | "audit" => {}
255 other => {
256 return Err(AreevError::Validation(format!(
257 "invalid anonymization policy: unknown mode '{other}' (expected \
258 off, egress, ingress, both, or audit)"
259 )));
260 }
261 }
262 match self.scope.as_str() {
263 "context" | "session" | "memory" => {}
264 other => {
265 return Err(AreevError::Validation(format!(
266 "invalid anonymization policy: unknown scope '{other}' (expected \
267 context, session, or memory)"
268 )));
269 }
270 }
271 if !(self.placeholder.contains("{CATEGORY}") && self.placeholder.contains("{ID}")) {
272 return Err(AreevError::Validation(
273 "invalid anonymization policy: placeholder template must contain \
274 {CATEGORY} and {ID}"
275 .into(),
276 ));
277 }
278 if !(0.0..=1.0).contains(&self.min_confidence) || !self.min_confidence.is_finite() {
279 return Err(AreevError::Validation(
280 "invalid anonymization policy: min_confidence must be within 0.0..=1.0"
281 .into(),
282 ));
283 }
284 Action::parse(&self.default_action).map_err(|e| {
285 AreevError::Validation(format!("invalid anonymization policy default_action: {e}"))
286 })?;
287 for (cat, act) in &self.categories {
288 if cat.is_empty() {
289 return Err(AreevError::Validation(
290 "invalid anonymization policy: empty category name".into(),
291 ));
292 }
293 Action::parse(act).map_err(|e| {
294 AreevError::Validation(format!(
295 "invalid anonymization policy for category '{cat}': {e}"
296 ))
297 })?;
298 }
299 if self.detectors.is_empty() {
300 return Err(AreevError::Validation(
301 "invalid anonymization policy: detectors must not be empty (use \
302 [\"tier0\"])"
303 .into(),
304 ));
305 }
306 for d in &self.detectors {
307 if !matches!(d.as_str(), "tier0" | "ner" | "llm") {
308 return Err(AreevError::Validation(format!(
309 "invalid anonymization policy: unknown detector '{d}' (expected \
310 tier0, ner, or llm)"
311 )));
312 }
313 }
314 if self.vault && self.scope == "context" {
315 return Err(AreevError::Validation(
316 "invalid anonymization policy: vault persistence needs scope \
317 \"session\" or \"memory\" — context-scope mappings are ephemeral \
318 by definition"
319 .into(),
320 ));
321 }
322 if let Some(ttl) = self.vault_ttl_days {
323 if !self.vault {
324 return Err(AreevError::Validation(
325 "invalid anonymization policy: vault_ttl_days needs vault: true"
326 .into(),
327 ));
328 }
329 if ttl < 0.0 || !ttl.is_finite() {
330 return Err(AreevError::Validation(
331 "invalid anonymization policy: vault_ttl_days must be a \
332 non-negative, finite number"
333 .into(),
334 ));
335 }
336 }
337 for term in &self.custom_terms {
338 if term.trim().is_empty() {
339 return Err(AreevError::Validation(
340 "invalid anonymization policy: empty custom_terms entry".into(),
341 ));
342 }
343 }
344 Ok(())
345 }
346
347 fn action_for(&self, category: &str) -> Action {
348 match self.categories.get(category) {
349 Some(act) => Action::parse(act).unwrap_or(Action::Redact),
351 None => Action::parse(&self.default_action).unwrap_or(Action::Redact),
352 }
353 }
354}
355
356#[derive(Debug, Clone, Serialize)]
359pub struct ScanOutcome {
360 pub text: String,
361 pub detections: Vec<Detection>,
362}
363
364#[derive(Debug, Clone, Serialize)]
368pub struct AnonOutcome {
369 pub text: String,
370 pub mapping: BTreeMap<String, String>,
371 pub mapping_id: String,
372 pub replaced: usize,
374}
375
376#[derive(Debug, Clone, Serialize)]
379pub struct RehydrateOutcome {
380 pub text: String,
381 pub replaced: usize,
382 pub unmatched: Vec<String>,
383}
384
385pub fn nfc(text: &str) -> Cow<'_, str> {
387 if unicode_normalization::is_nfc(text) {
388 Cow::Borrowed(text)
389 } else {
390 Cow::Owned(text.nfc().collect())
391 }
392}
393
394pub fn scan(text: &str, policy: &AnonPolicy, known_identities: &[String]) -> Result<ScanOutcome> {
403 scan_with(text, policy, known_identities, &[])
404}
405
406pub fn scan_with(
411 text: &str,
412 policy: &AnonPolicy,
413 known_identities: &[String],
414 backends: &[&dyn DetectorBackend],
415) -> Result<ScanOutcome> {
416 policy.validate()?;
417 let text = nfc(text).into_owned();
418 let mut detections = if policy.detectors.iter().any(|d| d == "tier0") {
419 detect::run_tier0(&text, &policy.custom_terms, known_identities)?
420 } else {
421 Vec::new()
422 };
423 for kind in policy.detectors.iter().filter(|d| *d != "tier0") {
424 let backend = backends
425 .iter()
426 .find(|b| b.kind() == kind)
427 .ok_or_else(|| {
428 AreevError::Validation(format!(
429 "anonymization policy demands detector \"{kind}\" but no such \
430 backend is installed on this host — egress fails closed (D6); \
431 install one (e.g. --anonymize-cmd) or drop it from the policy"
432 ))
433 })?;
434 for d in backend.detect(&text)? {
435 let in_bounds = d.start < d.end
436 && d.end <= text.len()
437 && text.is_char_boundary(d.start)
438 && text.is_char_boundary(d.end);
439 if !in_bounds || d.category.is_empty() {
440 return Err(AreevError::Validation(format!(
441 "detector {} returned an invalid span {}..{} — refusing the \
442 whole result (D6): a mis-sliced span is a silent leak",
443 backend.id(),
444 d.start,
445 d.end
446 )));
447 }
448 detections.push(d);
449 }
450 }
451 detections.retain(|d| d.confidence >= policy.min_confidence);
452 let mut survivors = resolve_overlaps(detections, policy);
453 survivors.retain(|d| policy.action_for(&d.category) != Action::Allow);
454 Ok(ScanOutcome { text, detections: survivors })
455}
456
457pub fn anonymize(
466 text: &str,
467 policy: &AnonPolicy,
468 known_identities: &[String],
469 key: Option<&[u8]>,
470) -> Result<AnonOutcome> {
471 let mut session = if policy.scope == "memory" {
472 let Some(k) = key else {
473 return Err(AreevError::Validation(
474 "anonymization scope \"memory\" needs a key (pass key_hex; \
475 value-derived tokens are keyed by design)"
476 .into(),
477 ));
478 };
479 SessionAnonymizer::new_keyed(policy.clone(), hmac_sha256(k, b"areev.anon.tokenkey.v1"))?
480 } else {
481 SessionAnonymizer::new(policy.clone())?
482 };
483 let (out, replaced) = session.transform_text(text, known_identities)?;
484 let mapping_id = session.mapping_id(key)?;
485 Ok(AnonOutcome { text: out, mapping: session.into_mapping(), mapping_id, replaced })
486}
487
488#[derive(Debug, Clone)]
495pub struct SessionAnonymizer {
496 policy: AnonPolicy,
497 by_value: BTreeMap<(String, String), String>,
498 order: std::collections::VecDeque<(String, String)>,
499 counters: BTreeMap<String, u64>,
500 mapping: BTreeMap<String, String>,
501 reserved: std::collections::BTreeSet<String>,
502 pending: Vec<(String, String)>,
505 token_key: Option<[u8; 32]>,
510}
511
512impl SessionAnonymizer {
513 pub fn new(policy: AnonPolicy) -> Result<Self> {
514 policy.validate()?;
515 if policy.scope == "memory" {
516 return Err(AreevError::Validation(
517 "anonymization scope \"memory\" needs a token key — use \
518 SessionAnonymizer::new_keyed (the store derives it from the \
519 file's encryption key)"
520 .into(),
521 ));
522 }
523 Ok(Self::build(policy, None))
524 }
525
526 pub fn new_keyed(policy: AnonPolicy, key: [u8; 32]) -> Result<Self> {
530 policy.validate()?;
531 Ok(Self::build(policy, Some(key)))
532 }
533
534 fn build(policy: AnonPolicy, token_key: Option<[u8; 32]>) -> Self {
535 SessionAnonymizer {
536 policy,
537 by_value: BTreeMap::new(),
538 order: std::collections::VecDeque::new(),
539 counters: BTreeMap::new(),
540 mapping: BTreeMap::new(),
541 reserved: std::collections::BTreeSet::new(),
542 pending: Vec::new(),
543 token_key,
544 }
545 }
546
547 pub fn policy(&self) -> &AnonPolicy {
548 &self.policy
549 }
550
551 pub fn mapping(&self) -> &BTreeMap<String, String> {
553 &self.mapping
554 }
555
556 pub fn into_mapping(self) -> BTreeMap<String, String> {
557 self.mapping
558 }
559
560 pub fn len(&self) -> usize {
561 self.by_value.len()
562 }
563
564 pub fn is_empty(&self) -> bool {
565 self.by_value.is_empty()
566 }
567
568 pub fn mapping_id(&self, key: Option<&[u8]>) -> Result<String> {
570 derive_mapping_id(key, &self.policy, &self.mapping)
571 }
572
573 pub fn transform_text(
577 &mut self,
578 text: &str,
579 known_identities: &[String],
580 ) -> Result<(String, usize)> {
581 self.transform_text_with(text, known_identities, &[])
582 }
583
584 pub fn transform_text_with(
587 &mut self,
588 text: &str,
589 known_identities: &[String],
590 backends: &[&dyn DetectorBackend],
591 ) -> Result<(String, usize)> {
592 let ScanOutcome { text, detections } =
593 scan_with(text, &self.policy, known_identities, backends)?;
594 for t in template_shaped_tokens(&text, &self.policy.placeholder) {
595 self.reserved.insert(t);
596 }
597 let mut out = String::with_capacity(text.len());
598 let mut cursor = 0usize;
599 let mut replaced = 0usize;
600 for d in &detections {
601 out.push_str(&text[cursor..d.start]);
602 let value = &text[d.start..d.end];
603 match self.policy.action_for(&d.category) {
604 Action::Allow => unreachable!("allow spans dropped in scan()"),
605 Action::Redact => {
606 out.push_str(&format!("[REDACTED:{}]", category_upper(&d.category)));
607 replaced += 1;
608 }
609 Action::Mask => {
610 out.push_str(&mask_value(value));
611 replaced += 1;
612 }
613 Action::Generalize(bucket) => {
614 out.push_str(&generalize_value(bucket, &d.category, value));
615 replaced += 1;
616 }
617 Action::Pseudonym => {
618 let token = self.token_for(&d.category, value);
619 out.push_str(&token);
620 replaced += 1;
621 }
622 }
623 cursor = d.end;
624 }
625 out.push_str(&text[cursor..]);
626 Ok((out, replaced))
627 }
628
629 pub fn transform_value(&mut self, category: &str, value: &str) -> String {
633 match self.policy.action_for(category) {
634 Action::Allow => value.to_string(),
635 Action::Redact => format!("[REDACTED:{}]", category_upper(category)),
636 Action::Mask => mask_value(value),
637 Action::Generalize(bucket) => generalize_value(bucket, category, value),
638 Action::Pseudonym => self.token_for(category, value),
639 }
640 }
641
642 pub fn token_if_known(&self, category: &str, value: &str) -> Option<&str> {
646 self.by_value
647 .get(&(category.to_string(), value.to_string()))
648 .map(String::as_str)
649 }
650
651 pub fn evict_to(&mut self, max_entries: usize) {
656 while self.by_value.len() > max_entries {
657 let Some(oldest) = self.order.pop_front() else { break };
658 if let Some(token) = self.by_value.remove(&oldest) {
659 self.mapping.remove(&token);
660 }
661 }
662 }
663
664 pub fn take_pending(&mut self) -> Vec<(String, String)> {
667 std::mem::take(&mut self.pending)
668 }
669
670 pub fn seed(&mut self, entries: Vec<(String, String)>) {
674 for (token, value) in entries {
675 for (cat_upper, id_part) in parse_token_parts(&self.policy.placeholder, &token) {
676 if let Ok(n) = id_part.parse::<u64>() {
677 let cat_key = cat_upper.to_ascii_lowercase();
678 let c = self.counters.entry(cat_key).or_insert(0);
679 if *c < n {
680 *c = n;
681 }
682 }
683 }
684 let cat = parse_token_parts(&self.policy.placeholder, &token)
687 .first()
688 .map(|(c, _)| c.to_ascii_lowercase())
689 .unwrap_or_else(|| "custom".into());
690 self.reserved.insert(token.clone());
691 self.by_value.insert((cat.clone(), value.clone()), token.clone());
692 self.order.push_back((cat, value.clone()));
693 self.mapping.insert(token, value);
694 }
695 }
696
697 fn token_for(&mut self, category: &str, value: &str) -> String {
698 let k = (category.to_string(), value.to_string());
699 if let Some(t) = self.by_value.get(&k) {
700 return t.clone();
701 }
702 let token = match self.token_key {
703 Some(key) => derived_token(&self.policy.placeholder, &key, category, value),
704 None => {
705 let counter = self.counters.entry(category.to_string()).or_insert(0);
706 mint_token(&self.policy.placeholder, category, counter, &self.reserved)
707 }
708 };
709 self.by_value.insert(k.clone(), token.clone());
710 self.order.push_back(k);
711 self.mapping.insert(token.clone(), value.to_string());
712 self.pending.push((token.clone(), value.to_string()));
713 token
714 }
715
716 pub fn scrub_values(&mut self, identities: &[String]) -> usize {
720 let doomed: Vec<(String, String)> = self
721 .by_value
722 .iter()
723 .filter(|((_, v), _)| identities.iter().any(|i| i == v))
724 .map(|(k, _)| k.clone())
725 .collect();
726 let n = doomed.len();
727 for k in doomed {
728 if let Some(token) = self.by_value.remove(&k) {
729 self.mapping.remove(&token);
730 }
731 self.order.retain(|o| *o != k);
732 }
733 self.pending.retain(|(_, v)| !identities.iter().any(|i| i == v));
734 n
735 }
736}
737
738fn parse_token_parts(template: &str, token: &str) -> Vec<(String, String)> {
741 let mut pattern = String::from("^");
742 let mut rest = template;
743 while let Some(idx) = rest.find('{') {
744 pattern.push_str(®ex::escape(&rest[..idx]));
745 if rest[idx..].starts_with("{CATEGORY}") {
746 pattern.push_str("([A-Z][A-Z0-9_]*)");
747 rest = &rest[idx + "{CATEGORY}".len()..];
748 } else if rest[idx..].starts_with("{ID}") {
749 pattern.push_str("([0-9A-Za-z]+)");
750 rest = &rest[idx + "{ID}".len()..];
751 } else {
752 pattern.push_str(®ex::escape(&rest[idx..idx + 1]));
753 rest = &rest[idx + 1..];
754 }
755 }
756 pattern.push_str(®ex::escape(rest));
757 pattern.push('$');
758 let Ok(re) = regex::Regex::new(&pattern) else { return Vec::new() };
759 let Some(c) = re.captures(token) else { return Vec::new() };
760 match (c.get(1), c.get(2)) {
761 (Some(cat), Some(id)) => vec![(cat.as_str().to_string(), id.as_str().to_string())],
762 _ => Vec::new(),
763 }
764}
765
766pub fn derived_token(template: &str, key: &[u8; 32], category: &str, value: &str) -> String {
771 let mut msg = Vec::with_capacity(category.len() + value.len() + 1);
772 msg.extend_from_slice(category.as_bytes());
773 msg.push(0x1e);
774 msg.extend_from_slice(value.as_bytes());
775 let digest = hmac_sha256(key, &msg);
776 template
777 .replace("{CATEGORY}", &category_upper(category))
778 .replace("{ID}", &hex::encode(&digest[..4]))
779}
780
781pub fn rehydrate(text: &str, mapping: &BTreeMap<String, String>) -> Result<RehydrateOutcome> {
785 let text = nfc(text).into_owned();
786 for k in mapping.keys() {
787 if k.is_empty() {
788 return Err(AreevError::Validation(
789 "invalid anonymization mapping: empty placeholder key".into(),
790 ));
791 }
792 }
793
794 let mut hits: Vec<(usize, &str)> = Vec::new();
799 for key in mapping.keys() {
800 for (pos, _) in text.match_indices(key.as_str()) {
801 hits.push((pos, key.as_str()));
802 }
803 }
804 hits.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.len().cmp(&a.1.len())));
805
806 let mut out = String::with_capacity(text.len());
807 let mut cursor = 0usize;
808 let mut replaced = 0usize;
809 for (pos, key) in hits {
810 if pos < cursor {
811 continue; }
813 out.push_str(&text[cursor..pos]);
814 out.push_str(&mapping[key]);
815 cursor = pos + key.len();
816 replaced += 1;
817 }
818 out.push_str(&text[cursor..]);
819
820 let mut unmatched: Vec<String> = Vec::new();
824 for token in template_shaped_tokens(&out, &default_placeholder()) {
825 if !mapping.contains_key(&token) && !unmatched.contains(&token) {
826 unmatched.push(token);
827 }
828 }
829 unmatched.sort();
830 Ok(RehydrateOutcome { text: out, replaced, unmatched })
831}
832
833pub fn mapping_from_json(json: &str) -> Result<BTreeMap<String, String>> {
835 serde_json::from_str(json)
836 .map_err(|e| AreevError::Validation(format!("invalid anonymization mapping: {e}")))
837}
838
839fn category_upper(category: &str) -> String {
842 category
843 .chars()
844 .map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_uppercase() } else { '_' })
845 .collect()
846}
847
848fn mint_token(
849 template: &str,
850 category: &str,
851 counter: &mut u64,
852 reserved: &std::collections::BTreeSet<String>,
853) -> String {
854 loop {
855 *counter += 1;
856 let token = template
857 .replace("{CATEGORY}", &category_upper(category))
858 .replace("{ID}", &counter.to_string());
859 if !reserved.contains(&token) {
860 return token;
861 }
862 }
863}
864
865fn template_shaped_tokens(text: &str, template: &str) -> std::collections::BTreeSet<String> {
867 let mut pattern = String::new();
868 let mut rest = template;
869 while let Some(idx) = rest.find('{') {
870 pattern.push_str(®ex::escape(&rest[..idx]));
871 if rest[idx..].starts_with("{CATEGORY}") {
872 pattern.push_str("[A-Z][A-Z0-9_]*");
873 rest = &rest[idx + "{CATEGORY}".len()..];
874 } else if rest[idx..].starts_with("{ID}") {
875 pattern.push_str("[0-9A-Za-z]+");
876 rest = &rest[idx + "{ID}".len()..];
877 } else {
878 pattern.push_str(®ex::escape(&rest[idx..idx + 1]));
879 rest = &rest[idx + 1..];
880 }
881 }
882 pattern.push_str(®ex::escape(rest));
883 let mut out = std::collections::BTreeSet::new();
884 if let Ok(re) = regex::Regex::new(&pattern) {
885 for m in re.find_iter(text) {
886 out.insert(m.as_str().to_string());
887 }
888 }
889 out
890}
891
892fn mask_value(value: &str) -> String {
893 let mut out = String::with_capacity(value.len());
894 let mut run_started = false;
895 for c in value.chars() {
896 if c.is_alphanumeric() {
897 if run_started {
898 out.push('*');
899 } else {
900 out.push(c);
901 run_started = true;
902 }
903 } else {
904 out.push(c);
905 run_started = false;
906 }
907 }
908 out
909}
910
911fn resolve_overlaps(mut detections: Vec<Detection>, policy: &AnonPolicy) -> Vec<Detection> {
915 let mut survivors: Vec<Detection> = Vec::new();
916 while !detections.is_empty() {
917 let best = detections
918 .iter()
919 .enumerate()
920 .max_by(|(_, a), (_, b)| {
921 let sa = policy.action_for(&a.category).severity();
922 let sb = policy.action_for(&b.category).severity();
923 sa.cmp(&sb)
924 .then((a.end - a.start).cmp(&(b.end - b.start)))
925 .then(b.start.cmp(&a.start))
926 .then(b.category.cmp(&a.category))
927 })
928 .map(|(i, _)| i)
929 .expect("non-empty");
930 let winner = detections.swap_remove(best);
931 detections.retain(|d| d.end <= winner.start || d.start >= winner.end);
932 survivors.push(winner);
933 }
934 survivors.sort_by_key(|d| d.start);
935 survivors
936}
937
938fn derive_mapping_id(
943 key: Option<&[u8]>,
944 policy: &AnonPolicy,
945 mapping: &BTreeMap<String, String>,
946) -> Result<String> {
947 let policy_json = serde_json::to_string(policy)
948 .map_err(|e| AreevError::Validation(format!("anonymization policy serialize: {e}")))?;
949 let mut msg = Vec::with_capacity(policy_json.len() + 64);
950 msg.extend_from_slice(policy_json.as_bytes());
951 msg.push(0x1f);
952 msg.extend_from_slice(policy.scope.as_bytes());
953 for (k, v) in mapping {
954 msg.push(0x1f);
955 msg.extend_from_slice(k.as_bytes());
956 msg.push(0x1e);
957 msg.extend_from_slice(v.as_bytes());
958 }
959 let zero_key = [0u8; 32];
960 let digest = hmac_sha256(key.unwrap_or(&zero_key), &msg);
961 Ok(hex::encode(&digest[..8]))
962}
963
964fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
967 const BLOCK: usize = 64;
968 let mut key_block = [0u8; BLOCK];
969 if key.len() > BLOCK {
970 key_block[..32].copy_from_slice(&Sha256::digest(key));
971 } else {
972 key_block[..key.len()].copy_from_slice(key);
973 }
974 let mut inner = Sha256::new();
975 let ipad: Vec<u8> = key_block.iter().map(|b| b ^ 0x36).collect();
976 inner.update(&ipad);
977 inner.update(message);
978 let inner_digest = inner.finalize();
979 let mut outer = Sha256::new();
980 let opad: Vec<u8> = key_block.iter().map(|b| b ^ 0x5c).collect();
981 outer.update(&opad);
982 outer.update(inner_digest);
983 outer.finalize().into()
984}
985
986#[cfg(test)]
987mod tests {
988 use super::*;
989
990 #[test]
991 fn hmac_sha256_matches_rfc4231_case_2() {
992 let mac = hmac_sha256(b"Jefe", b"what do ya want for nothing?");
994 assert_eq!(
995 hex::encode(mac),
996 "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
997 );
998 }
999
1000 #[test]
1001 fn policy_rejects_unknown_field_and_unknown_bucket_and_unbuilt_scope() {
1002 assert!(AnonPolicy::from_json(r#"{"surprise": 1}"#).is_err());
1003 assert!(AnonPolicy::from_json(r#"{"categories": {"date": "generalize:month"}}"#).is_ok());
1004 assert!(AnonPolicy::from_json(r#"{"categories": {"date": "generalize:eon"}}"#).is_err());
1005 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();
1009 assert!(SessionAnonymizer::new(memory.clone()).is_err());
1010 assert!(anonymize("x", &memory, &[], None).is_err());
1011 assert!(anonymize("mail a@b.co", &memory, &[], Some(b"k")).is_ok());
1012 assert!(AnonPolicy::from_json("{}").is_ok());
1013 }
1014
1015 #[test]
1016 fn session_anonymizer_is_stable_across_texts_and_evicts_oldest() {
1017 let mut s = SessionAnonymizer::new(AnonPolicy::default()).unwrap();
1018 let (a, _) = s.transform_text("mail a@b.co", &[]).unwrap();
1019 let (b, _) = s.transform_text("again a@b.co and new c@d.io", &[]).unwrap();
1020 assert_eq!(a, "mail [EMAIL_1]");
1021 assert_eq!(b, "again [EMAIL_1] and new [EMAIL_2]"); assert_eq!(s.transform_value("person", "caller:john"), "[PERSON_1]");
1023 assert_eq!(s.transform_value("person", "caller:john"), "[PERSON_1]");
1024 assert_eq!(s.token_if_known("person", "caller:john"), Some("[PERSON_1]"));
1025 assert_eq!(s.len(), 3);
1026
1027 s.evict_to(1);
1028 assert_eq!(s.len(), 1);
1029 assert_eq!(s.token_if_known("email", "a@b.co"), None); assert_eq!(s.token_if_known("person", "caller:john"), Some("[PERSON_1]"));
1031 }
1032
1033 #[test]
1034 fn generalization_coarsens_and_never_leaks() {
1035 assert_eq!(generalize_value(GenBucket::Month, "date", "2026-08-16"), "2026-08");
1036 assert_eq!(generalize_value(GenBucket::Month, "date", "16/08/2026"), "2026-08");
1037 assert_eq!(generalize_value(GenBucket::Year, "date", "2026-08-16"), "2026");
1038 assert_eq!(generalize_value(GenBucket::Decade, "age", "47"), "40s");
1039 assert_eq!(generalize_value(GenBucket::Month, "date", "someday"), "[GENERALIZED:DATE]");
1041 assert_eq!(generalize_value(GenBucket::Decade, "age", "young"), "[GENERALIZED:AGE]");
1042
1043 let mut policy = AnonPolicy::default();
1044 policy.categories.insert("date".into(), "generalize:month".into());
1045 let out = anonymize("met on 2026-08-16 at noon", &policy, &[], None).unwrap();
1046 assert_eq!(out.text, "met on 2026-08 at noon");
1047 assert!(out.mapping.is_empty(), "generalization is one-way");
1048 }
1049
1050 #[test]
1051 fn mask_keeps_shape() {
1052 assert_eq!(mask_value("john.doe@example.com"), "j***.d**@e******.c**");
1053 assert_eq!(mask_value("+1-555-0142"), "+1-5**-0***");
1054 }
1055
1056 #[test]
1057 fn collision_renumbers_around_literal_tokens() {
1058 let policy = AnonPolicy::default();
1059 let out = anonymize(
1060 "already has [EMAIL_1] and a real x@y.io address",
1061 &policy,
1062 &[],
1063 None,
1064 )
1065 .unwrap();
1066 assert!(out.text.contains("[EMAIL_1]")); assert!(out.mapping.contains_key("[EMAIL_2]")); assert_eq!(out.mapping["[EMAIL_2]"], "x@y.io");
1069 }
1070
1071 #[test]
1072 fn mapping_id_is_keyed() {
1073 let policy = AnonPolicy::default();
1074 let a = anonymize("mail me at a@b.co", &policy, &[], None).unwrap();
1075 let b = anonymize("mail me at a@b.co", &policy, &[], Some(b"k1")).unwrap();
1076 let c = anonymize("mail me at a@b.co", &policy, &[], Some(b"k1")).unwrap();
1077 assert_ne!(a.mapping_id, b.mapping_id); assert_eq!(b.mapping_id, c.mapping_id); }
1080}