Skip to main content

areev_core/anon/
mod.rs

1//! Text pseudonymization: the Tier-0 detection chain, the placeholder codec,
2//! and the keyed round-trip derivations (docs/anonymization-proposal.md, P0).
3//!
4//! Everything here is pure — no store access, no clock, no host config — so
5//! every surface (store boundary, facade, CLI, bindings, LLM decorator)
6//! shares one implementation. Offsets are UTF-8 byte positions over
7//! NFC-normalized text: the same normalization canonical serialization
8//! applies, so a span always slices exactly what would be stored or hashed
9//! (proposal §5).
10//!
11//! Vocabulary is deliberate: this module *pseudonymizes* (D10). Only
12//! `pseudonym` spans enter the mapping; `mask` and `redact` are one-way.
13
14mod 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
27/// A pluggable detector (proposal §5.2–5.3): Tier-1 NER over a command
28/// seam, Tier-2 LLM — installed by the host, never shipped as a dependency.
29/// Object-safe; spans use the same UTF-8-bytes-over-NFC contract as Tier 0.
30pub trait DetectorBackend: Send + Sync {
31    /// Which policy `detectors` entry this backend serves: "ner" or "llm".
32    fn kind(&self) -> &str;
33    /// Provenance id stamped on detections (e.g. "presidio/2.2").
34    fn id(&self) -> &str;
35    fn detect(&self, text: &str) -> Result<Vec<Detection>>;
36}
37
38/// One detected sensitive span. `start`/`end` are UTF-8 byte offsets into the
39/// NFC-normalized text returned alongside the detections.
40#[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/// What the policy does with a detected span. Ranked by severity for overlap
56/// resolution (proposal §5): a long low-severity span must never swallow a
57/// high-severity one into a weaker treatment.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum Action {
60    Allow,
61    Pseudonym,
62    Generalize(GenBucket),
63    Mask,
64    Redact,
65}
66
67/// Generalization buckets (proposal §6, quasi-identifier damping): coarsen
68/// a value instead of replacing it. Irreversible by design; a value the
69/// bucket cannot parse degrades to `[GENERALIZED:<CATEGORY>]` — coarsening
70/// must never fall back to leaking the original.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum GenBucket {
73    /// Dates → `YYYY-MM`.
74    Month,
75    /// Dates → `YYYY`.
76    Year,
77    /// Integers (ages, years) → `N0s`.
78    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
115/// Coarsen one value into its bucket; unparseable values degrade to the
116/// redaction form rather than leaking.
117fn 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            // ISO first (YYYY-MM-DD), then D/M/Y-with-4-digit-year shapes.
122            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/// "Redact category A when category B appears within N characters."
172///
173/// Expressed as a RE-CATEGORIZATION rather than a bare action override: the
174/// matching detection takes `as_category`, and the policy's ordinary
175/// `categories` map then decides what happens to it. That keeps one place
176/// where actions are decided, and it makes the output self-explaining — the
177/// placeholder says `[PHI_1]` rather than `[PERSON_1]`, so a reader can see
178/// WHY the span was treated more strictly than a bare name would be.
179#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
180pub struct CoOccurrence {
181    /// The category to escalate (e.g. `person`).
182    pub when: String,
183    /// The category whose nearby presence triggers it (e.g. `condition`,
184    /// usually supplied via [`AnonPolicy::term_sets`]).
185    pub near: String,
186    /// Proximity window in characters, measured between span edges.
187    #[serde(default = "default_within_chars")]
188    pub within_chars: usize,
189    /// The category the `when` detection takes on when the rule fires.
190    pub as_category: String,
191}
192
193fn default_within_chars() -> usize {
194    120
195}
196
197/// The declarative anonymization policy (proposal §8.1). Serialized as the
198/// JSON value of an `anon:<ns>` meta row from P1 on; in P0 it is supplied
199/// explicitly to the text APIs.
200///
201/// `deny_unknown_fields` is the fail-closed half of D3: a policy field this
202/// build does not understand could be the field that *strengthens* the
203/// policy, so refusing it loudly beats silently ignoring it.
204#[derive(Debug, Clone, Serialize, Deserialize)]
205#[serde(deny_unknown_fields)]
206pub struct AnonPolicy {
207    #[serde(default = "default_mode")]
208    pub mode: String,
209    /// category → action ("pseudonym" | "mask" | "redact" | "allow").
210    #[serde(default)]
211    pub categories: BTreeMap<String, String>,
212    /// Action for categories a detector emits but the map omits — the chain
213    /// fails closed on categories it didn't anticipate, not open.
214    #[serde(default = "default_action")]
215    pub default_action: String,
216    /// User dictionary; matches become category `custom`.
217    #[serde(default)]
218    pub custom_terms: Vec<String>,
219    /// Named dictionaries: `category -> terms`. Each set's matches carry that
220    /// category, so the policy can act on them separately and — the reason
221    /// they exist — so a [`CoOccurrence`] rule can NAME one side of the
222    /// relationship. The single `custom_terms` bucket cannot express
223    /// "a person near a condition" because both halves land in `custom`.
224    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
225    pub term_sets: BTreeMap<String, Vec<String>>,
226    /// Context-sensitive escalation: re-categorize a detection when another
227    /// category appears close to it.
228    ///
229    /// This is the rule healthcare actually needs and no per-category action
230    /// can express. A name alone may be acceptable in a prompt; a name
231    /// *together with* a condition, medication or procedure in the same span
232    /// is health data under most privacy regimes. The distinction is not a
233    /// property of either detection — it is a property of the pair.
234    #[serde(default, skip_serializing_if = "Vec::is_empty")]
235    pub co_occurrence: Vec<CoOccurrence>,
236    /// Pseudonym stability scope. P0 supports `context` only (per-call
237    /// numbering); `session`/`memory` arrive with the store boundary.
238    #[serde(default = "default_scope")]
239    pub scope: String,
240    /// Placeholder template; must contain `{CATEGORY}` and `{ID}`.
241    #[serde(default = "default_placeholder")]
242    pub placeholder: String,
243    #[serde(default = "default_min_confidence")]
244    pub min_confidence: f32,
245    /// Which chain links this policy demands (proposal §5.4): "tier0" runs
246    /// in-tree; "ner"/"llm" require a host-installed [`DetectorBackend`] of
247    /// that kind and FAIL CLOSED without one (D6).
248    #[serde(default = "default_detectors")]
249    pub detectors: Vec<String>,
250    /// Persist the pseudonym mapping to the file's sealed vault (proposal
251    /// §7). Requires scope `session`/`memory` and an encrypted memory.
252    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
253    pub vault: bool,
254    /// Storage limitation for vault rows (REQ-ANON-6), in days.
255    #[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    /// Caller-supplied identities to detect verbatim in free text (issue
260    /// #32's escape hatch): a host that hasn't interned the identity as a
261    /// grain subject first — an email's From header, a CRM row, a project
262    /// codename — still gets it detected and pseudonymized, without
263    /// writing it to the store just to make it detectable. Distinct from
264    /// the automatic propagation `scan_text`/`anonymize_text` already pull
265    /// from the store's own interned subjects for the call's namespace
266    /// (same table the grain-egress path builds).
267    #[serde(default, skip_serializing_if = "Vec::is_empty")]
268    pub known: Vec<KnownIdentity>,
269}
270
271/// One entry of [`AnonPolicy::known`]: a bare value and the category it
272/// should be detected/pseudonymized as — not always `person` (a project
273/// codename is `custom`, say).
274#[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    /// Parse and validate a policy from JSON. Parse or validation failure is a
312    /// hard `VAL` error (D3): a policy this build cannot read must not
313    /// silently mean "no policy".
314    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            // Validated in `validate()`; unreachable fallback keeps this total.
432            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/// Scan outcome: the NFC-normalized text the offsets refer to, plus the
439/// surviving detections after overlap resolution (sorted by start).
440#[derive(Debug, Clone, Serialize)]
441pub struct ScanOutcome {
442    pub text: String,
443    pub detections: Vec<Detection>,
444}
445
446/// Anonymize outcome. `mapping` holds pseudonym spans only — `mask` and
447/// `redact` are one-way by definition (proposal §6) — keyed by placeholder
448/// token. `mapping_id` is the keyed round-trip handle (D11).
449#[derive(Debug, Clone, Serialize)]
450pub struct AnonOutcome {
451    pub text: String,
452    pub mapping: BTreeMap<String, String>,
453    pub mapping_id: String,
454    /// Spans replaced (all actions except allow).
455    pub replaced: usize,
456}
457
458/// Rehydrate outcome. `unmatched` lists placeholder-shaped tokens left in the
459/// text that the mapping did not cover — reported, never guessed.
460#[derive(Debug, Clone, Serialize)]
461pub struct RehydrateOutcome {
462    pub text: String,
463    pub replaced: usize,
464    pub unmatched: Vec<String>,
465}
466
467/// NFC-normalize without allocating when the input already is.
468pub 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
476/// Run the Tier-0 detector chain and resolve overlaps (proposal §5).
477///
478/// `known_identities` are identities the caller already holds (e.g. interned
479/// subjects like `caller:john`); each matches as category `person` — the
480/// schema-aware detector a text-only proxy cannot have. Detections below
481/// `min_confidence` are dropped; overlapping spans coalesce by action
482/// severity first, span length second, then (start, category) as the
483/// deterministic tiebreak; `allow` spans are resolved and then discarded.
484pub fn scan(text: &str, policy: &AnonPolicy, known_identities: &[String]) -> Result<ScanOutcome> {
485    scan_with(text, policy, known_identities, &[])
486}
487
488/// [`scan`] with host-installed detector backends. Fail-closed (D6): a
489/// policy demanding "ner"/"llm" with no matching backend installed errors —
490/// it never silently degrades to Tier 0 alone. Backend spans are validated
491/// (in-bounds, on char boundaries) and garbage is an error, not a skip.
492pub 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
543/// Re-categorize detections whose neighbours make them more sensitive.
544///
545/// Runs BEFORE overlap resolution and before the `Allow` drop, which is the
546/// whole point: `person` is frequently mapped to `allow`, and the rule exists
547/// to stop that drop when a condition/medication/procedure sits beside the
548/// name. Running it afterwards would find the detection already gone.
549///
550/// Proximity is edge-to-edge in characters, so "Jane Doe — type 2 diabetes"
551/// counts the gap between the two spans rather than their start offsets, and a
552/// long name does not push its own neighbour out of range.
553///
554/// Categories are read from a snapshot taken up front, so one rule firing can
555/// never cascade into another rule's `near` test — the escalation is decided
556/// by the ORIGINAL detections, which keeps the result independent of rule
557/// order.
558fn 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                // Edge-to-edge gap; overlapping spans have gap 0.
576                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
593/// Detect, then apply the policy's actions, producing prompt-safe text plus
594/// the mapping for the pseudonym spans. One-shot (`context`-scope) form of
595/// [`SessionAnonymizer`]: numbering and mapping start fresh per call.
596///
597/// `key` keys the `mapping_id` derivation (D11). Callers on trusted
598/// in-process surfaces may pass `None` (the id is then derived under an
599/// all-zero key and MUST NOT be shipped to untrusted surfaces without the
600/// mapping — from P1 on, the store boundary always supplies a real key).
601pub 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/// Stateful pseudonym assignment shared across texts: the same
625/// (category, value) pair yields the same token for the lifetime of the
626/// session, which is what keeps tokens consistent across the grains of one
627/// recall and across the calls of one process session (`session` scope).
628/// Long-lived holders bound it with [`SessionAnonymizer::evict_to`] — an
629/// unbounded in-process re-identification table is exactly what D5 forbids.
630#[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    /// (token, value) pairs minted since the last [`Self::take_pending`]
639    /// drain — the vault write-behind queue.
640    pending: Vec<(String, String)>,
641    /// When set, pseudonym token ids are value-derived HMAC fragments —
642    /// stable across handles and processes — instead of appearance-order
643    /// counters. Required for `memory` scope and for every ingress
644    /// transform (D8: the same raw text must always transform identically).
645    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    /// A session whose pseudonym ids derive from `key` — the `memory`-scope
663    /// and ingress form. The same (key, category, value) always yields the
664    /// same token, on any handle.
665    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    /// The accumulated placeholder → value map (pseudonym spans only).
688    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    /// The keyed round-trip handle over the current mapping state (D11).
705    pub fn mapping_id(&self, key: Option<&[u8]>) -> Result<String> {
706        derive_mapping_id(key, &self.policy, &self.mapping)
707    }
708
709    /// Detect + apply actions over one text; returns (transformed, spans
710    /// replaced). Tokens already literally present in the text are reserved
711    /// so minted tokens renumber around them.
712    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    /// [`Self::transform_text`] with host detector backends (fail-closed on
721    /// a demanded-but-missing kind — see [`scan_with`]).
722    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    /// Structural single-value transform: a whole field value whose category
766    /// the schema already knows (a `subject` is a `person` by construction).
767    /// Applies the category's action to the entire value.
768    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    /// The token already assigned to `(category, value)`, if any — exact
779    /// lookup, no detection. Lets callers keep bare entity-term lists
780    /// (graph reads) consistent with values pseudonymized elsewhere.
781    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    /// Bound the session table, evicting oldest-first. An evicted value
788    /// loses its stable token (and its mapping entry — old responses citing
789    /// it stop rehydrating); the next sighting mints a fresh one. That is
790    /// the deliberate cost of bounding a long-lived re-identification table.
791    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    /// New (token, value) pairs minted since the last drain — the vault
801    /// write-behind hook (proposal §7). Seeded entries never appear here.
802    pub fn take_pending(&mut self) -> Vec<(String, String)> {
803        std::mem::take(&mut self.pending)
804    }
805
806    /// Seed the session from persisted vault rows so tokens continue across
807    /// process restarts instead of colliding. Counter-based sessions bump
808    /// their counters past every seeded numeric id.
809    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            // Category is recoverable from the token for bookkeeping; use
821            // the uppercase form lowercased as the by_value key's category.
822            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    /// Drop every entry whose value matches one of `identities` — the
853    /// in-memory half of REQ-ANON-1 (an erased subject must not survive in
854    /// any live mapping).
855    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
874/// Parse `(CATEGORY, ID)` pairs a token exposes under `template` — used by
875/// vault seeding to restore counters and category bookkeeping.
876fn 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(&regex::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(&regex::escape(&rest[idx..idx + 1]));
889            rest = &rest[idx + 1..];
890        }
891    }
892    pattern.push_str(&regex::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
902/// The value-derived pseudonym token: `{ID}` is an 8-hex HMAC fragment over
903/// (category, value) under `key`. Pure — erasure and DSAR reads use this to
904/// recompute an ingress-stored pseudonym from the real identity
905/// (REQ-ANON-7) without any session state.
906pub 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
917/// Replace exact placeholder tokens with their mapped originals, reporting
918/// leftovers in the DEFAULT `[CATEGORY_ID]` silhouette.
919///
920/// Prefer [`rehydrate_with_template`] when the policy's `placeholder` is
921/// known: a custom template's leftovers are invisible to this one, and a
922/// caller that fails closed on `unmatched` would fail open instead.
923pub fn rehydrate(text: &str, mapping: &BTreeMap<String, String>) -> Result<RehydrateOutcome> {
924    rehydrate_with_template(text, mapping, &default_placeholder())
925}
926
927/// Replace exact placeholder tokens with their mapped originals. Tokens the
928/// mapping does not cover are left intact and reported in `unmatched`; the
929/// codec never guesses (proposal §6).
930///
931/// `template` is the policy's own `placeholder`, so leftovers are recognized
932/// in the shape this memory actually mints rather than only the default one.
933pub 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    // One left-to-right pass over the original text: collect every occurrence
948    // of every key, prefer the longest key at a position, and never rescan
949    // spliced-in values (a mapped value that happens to contain a
950    // placeholder-shaped string must come back verbatim, not recurse).
951    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; // overlapped by an earlier (longer) key
965        }
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    // Leftovers in the caller's own template shape. Callers that fail closed
974    // on `unmatched` depend on this being the minting shape, not a guess.
975    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
985/// Parse a mapping serialized as a JSON object of placeholder → value.
986pub 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
991// ---- internals -------------------------------------------------------------
992
993fn 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
1017/// Every literal in `text` that matches the template's token silhouette.
1018fn 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(&regex::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(&regex::escape(&rest[idx..idx + 1]));
1031            rest = &rest[idx + 1..];
1032        }
1033    }
1034    pattern.push_str(&regex::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
1063/// Overlap resolution (proposal §5): repeatedly take the best remaining span
1064/// by (action severity, length, specificity, earliest start, category),
1065/// evicting whatever it overlaps. O(n²) on the per-text detection count,
1066/// which is small.
1067///
1068/// The specificity rank (#281) breaks the tie a same-span collision creates:
1069/// `123-45-6789` matches both the dashed-phone shape and the structurally
1070/// validated `us_ssn`, at identical length and — under the default policy —
1071/// identical severity. Without the rank the alphabetically-first category
1072/// wins, which is `phone`, and a policy that treats business contact numbers
1073/// as allowable would leak the SSN. The loser is only evicted, never
1074/// suppressed at detection time, so a policy that redacts `phone` and allows
1075/// everything else still redacts the span.
1076fn 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
1103/// The keyed round-trip handle (D11): truncated HMAC-SHA256 over the
1104/// canonicalized policy, the scope, and the sorted placeholder→value pairs.
1105/// Keyed, never a bare digest — an unkeyed hash over the values would hand
1106/// the egress channel an offline-guessing oracle for low-entropy values.
1107fn 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
1129/// RFC 2104 HMAC-SHA256, hand-rolled over the sha2 dependency the crate
1130/// already carries (dependency-light: no hmac crate for twenty lines).
1131pub 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        // RFC 4231 test case 2: key "Jefe", data "what do ya want for nothing?"
1158        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()); // built in P1
1171        assert!(AnonPolicy::from_json(r#"{"scope": "memory"}"#).is_ok()); // built in P2
1172        // ...but memory scope is keyed by design: the unkeyed paths refuse it.
1173        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]"); // stable across texts
1187        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); // oldest evicted
1195        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        // Unparseable values degrade to the redaction form, never the raw value.
1205        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]")); // the literal survives
1232        assert!(out.mapping.contains_key("[EMAIL_2]")); // we minted around it
1233        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); // key changes the id
1243        assert_eq!(b.mapping_id, c.mapping_id); // same inputs + key → same id
1244    }
1245}
1246
1247#[cfg(test)]
1248mod co_occurrence_tests {
1249    use super::*;
1250
1251    /// A name alone is fine; the same name beside a condition is health data.
1252    /// No per-category action can express that, because the sensitivity is a
1253    /// property of the PAIR, not of either detection.
1254    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        // `person` is mapped to allow, so on its own the name survives.
1288        let alone = run("Jane Doe called about the invoice.");
1289        assert!(alone.contains("Jane Doe"), "bare name stays: {alone}");
1290
1291        // Same name, a condition within the window: escalated to `phi`, which
1292        // the policy pseudonymizes. The placeholder names the ESCALATED
1293        // category, so the reason is visible in the output.
1294        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        // Same two categories, far apart: not the same claim about a patient.
1308        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}