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/// The declarative anonymization policy (proposal §8.1). Serialized as the
172/// JSON value of an `anon:<ns>` meta row from P1 on; in P0 it is supplied
173/// explicitly to the text APIs.
174///
175/// `deny_unknown_fields` is the fail-closed half of D3: a policy field this
176/// build does not understand could be the field that *strengthens* the
177/// policy, so refusing it loudly beats silently ignoring it.
178#[derive(Debug, Clone, Serialize, Deserialize)]
179#[serde(deny_unknown_fields)]
180pub struct AnonPolicy {
181    #[serde(default = "default_mode")]
182    pub mode: String,
183    /// category → action ("pseudonym" | "mask" | "redact" | "allow").
184    #[serde(default)]
185    pub categories: BTreeMap<String, String>,
186    /// Action for categories a detector emits but the map omits — the chain
187    /// fails closed on categories it didn't anticipate, not open.
188    #[serde(default = "default_action")]
189    pub default_action: String,
190    /// User dictionary; matches become category `custom`.
191    #[serde(default)]
192    pub custom_terms: Vec<String>,
193    /// Pseudonym stability scope. P0 supports `context` only (per-call
194    /// numbering); `session`/`memory` arrive with the store boundary.
195    #[serde(default = "default_scope")]
196    pub scope: String,
197    /// Placeholder template; must contain `{CATEGORY}` and `{ID}`.
198    #[serde(default = "default_placeholder")]
199    pub placeholder: String,
200    #[serde(default = "default_min_confidence")]
201    pub min_confidence: f32,
202    /// Which chain links this policy demands (proposal §5.4): "tier0" runs
203    /// in-tree; "ner"/"llm" require a host-installed [`DetectorBackend`] of
204    /// that kind and FAIL CLOSED without one (D6).
205    #[serde(default = "default_detectors")]
206    pub detectors: Vec<String>,
207    /// Persist the pseudonym mapping to the file's sealed vault (proposal
208    /// §7). Requires scope `session`/`memory` and an encrypted memory.
209    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
210    pub vault: bool,
211    /// Storage limitation for vault rows (REQ-ANON-6), in days.
212    #[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    /// Parse and validate a policy from JSON. Parse or validation failure is a
242    /// hard `VAL` error (D3): a policy this build cannot read must not
243    /// silently mean "no policy".
244    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            // Validated in `validate()`; unreachable fallback keeps this total.
350            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/// Scan outcome: the NFC-normalized text the offsets refer to, plus the
357/// surviving detections after overlap resolution (sorted by start).
358#[derive(Debug, Clone, Serialize)]
359pub struct ScanOutcome {
360    pub text: String,
361    pub detections: Vec<Detection>,
362}
363
364/// Anonymize outcome. `mapping` holds pseudonym spans only — `mask` and
365/// `redact` are one-way by definition (proposal §6) — keyed by placeholder
366/// token. `mapping_id` is the keyed round-trip handle (D11).
367#[derive(Debug, Clone, Serialize)]
368pub struct AnonOutcome {
369    pub text: String,
370    pub mapping: BTreeMap<String, String>,
371    pub mapping_id: String,
372    /// Spans replaced (all actions except allow).
373    pub replaced: usize,
374}
375
376/// Rehydrate outcome. `unmatched` lists placeholder-shaped tokens left in the
377/// text that the mapping did not cover — reported, never guessed.
378#[derive(Debug, Clone, Serialize)]
379pub struct RehydrateOutcome {
380    pub text: String,
381    pub replaced: usize,
382    pub unmatched: Vec<String>,
383}
384
385/// NFC-normalize without allocating when the input already is.
386pub 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
394/// Run the Tier-0 detector chain and resolve overlaps (proposal §5).
395///
396/// `known_identities` are identities the caller already holds (e.g. interned
397/// subjects like `caller:john`); each matches as category `person` — the
398/// schema-aware detector a text-only proxy cannot have. Detections below
399/// `min_confidence` are dropped; overlapping spans coalesce by action
400/// severity first, span length second, then (start, category) as the
401/// deterministic tiebreak; `allow` spans are resolved and then discarded.
402pub fn scan(text: &str, policy: &AnonPolicy, known_identities: &[String]) -> Result<ScanOutcome> {
403    scan_with(text, policy, known_identities, &[])
404}
405
406/// [`scan`] with host-installed detector backends. Fail-closed (D6): a
407/// policy demanding "ner"/"llm" with no matching backend installed errors —
408/// it never silently degrades to Tier 0 alone. Backend spans are validated
409/// (in-bounds, on char boundaries) and garbage is an error, not a skip.
410pub 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
457/// Detect, then apply the policy's actions, producing prompt-safe text plus
458/// the mapping for the pseudonym spans. One-shot (`context`-scope) form of
459/// [`SessionAnonymizer`]: numbering and mapping start fresh per call.
460///
461/// `key` keys the `mapping_id` derivation (D11). Callers on trusted
462/// in-process surfaces may pass `None` (the id is then derived under an
463/// all-zero key and MUST NOT be shipped to untrusted surfaces without the
464/// mapping — from P1 on, the store boundary always supplies a real key).
465pub 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/// Stateful pseudonym assignment shared across texts: the same
489/// (category, value) pair yields the same token for the lifetime of the
490/// session, which is what keeps tokens consistent across the grains of one
491/// recall and across the calls of one process session (`session` scope).
492/// Long-lived holders bound it with [`SessionAnonymizer::evict_to`] — an
493/// unbounded in-process re-identification table is exactly what D5 forbids.
494#[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    /// (token, value) pairs minted since the last [`Self::take_pending`]
503    /// drain — the vault write-behind queue.
504    pending: Vec<(String, String)>,
505    /// When set, pseudonym token ids are value-derived HMAC fragments —
506    /// stable across handles and processes — instead of appearance-order
507    /// counters. Required for `memory` scope and for every ingress
508    /// transform (D8: the same raw text must always transform identically).
509    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    /// A session whose pseudonym ids derive from `key` — the `memory`-scope
527    /// and ingress form. The same (key, category, value) always yields the
528    /// same token, on any handle.
529    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    /// The accumulated placeholder → value map (pseudonym spans only).
552    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    /// The keyed round-trip handle over the current mapping state (D11).
569    pub fn mapping_id(&self, key: Option<&[u8]>) -> Result<String> {
570        derive_mapping_id(key, &self.policy, &self.mapping)
571    }
572
573    /// Detect + apply actions over one text; returns (transformed, spans
574    /// replaced). Tokens already literally present in the text are reserved
575    /// so minted tokens renumber around them.
576    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    /// [`Self::transform_text`] with host detector backends (fail-closed on
585    /// a demanded-but-missing kind — see [`scan_with`]).
586    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    /// Structural single-value transform: a whole field value whose category
630    /// the schema already knows (a `subject` is a `person` by construction).
631    /// Applies the category's action to the entire value.
632    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    /// The token already assigned to `(category, value)`, if any — exact
643    /// lookup, no detection. Lets callers keep bare entity-term lists
644    /// (graph reads) consistent with values pseudonymized elsewhere.
645    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    /// Bound the session table, evicting oldest-first. An evicted value
652    /// loses its stable token (and its mapping entry — old responses citing
653    /// it stop rehydrating); the next sighting mints a fresh one. That is
654    /// the deliberate cost of bounding a long-lived re-identification table.
655    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    /// New (token, value) pairs minted since the last drain — the vault
665    /// write-behind hook (proposal §7). Seeded entries never appear here.
666    pub fn take_pending(&mut self) -> Vec<(String, String)> {
667        std::mem::take(&mut self.pending)
668    }
669
670    /// Seed the session from persisted vault rows so tokens continue across
671    /// process restarts instead of colliding. Counter-based sessions bump
672    /// their counters past every seeded numeric id.
673    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            // Category is recoverable from the token for bookkeeping; use
685            // the uppercase form lowercased as the by_value key's category.
686            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    /// Drop every entry whose value matches one of `identities` — the
717    /// in-memory half of REQ-ANON-1 (an erased subject must not survive in
718    /// any live mapping).
719    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
738/// Parse `(CATEGORY, ID)` pairs a token exposes under `template` — used by
739/// vault seeding to restore counters and category bookkeeping.
740fn 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(&regex::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(&regex::escape(&rest[idx..idx + 1]));
753            rest = &rest[idx + 1..];
754        }
755    }
756    pattern.push_str(&regex::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
766/// The value-derived pseudonym token: `{ID}` is an 8-hex HMAC fragment over
767/// (category, value) under `key`. Pure — erasure and DSAR reads use this to
768/// recompute an ingress-stored pseudonym from the real identity
769/// (REQ-ANON-7) without any session state.
770pub 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
781/// Replace exact placeholder tokens with their mapped originals. Tokens the
782/// mapping does not cover are left intact and reported in `unmatched`; the
783/// codec never guesses (proposal §6).
784pub 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    // One left-to-right pass over the original text: collect every occurrence
795    // of every key, prefer the longest key at a position, and never rescan
796    // spliced-in values (a mapped value that happens to contain a
797    // placeholder-shaped string must come back verbatim, not recurse).
798    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; // overlapped by an earlier (longer) key
812        }
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    // Best-effort report of leftover tokens in the *default* shape; a custom
821    // template's leftovers are only recognized when they share the bracketed
822    // CATEGORY_ID silhouette.
823    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
833/// Parse a mapping serialized as a JSON object of placeholder → value.
834pub 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
839// ---- internals -------------------------------------------------------------
840
841fn 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
865/// Every literal in `text` that matches the template's token silhouette.
866fn 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(&regex::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(&regex::escape(&rest[idx..idx + 1]));
879            rest = &rest[idx + 1..];
880        }
881    }
882    pattern.push_str(&regex::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
911/// Overlap resolution (proposal §5): repeatedly take the best remaining span
912/// by (action severity, length, earliest start, category), evicting whatever
913/// it overlaps. O(n²) on the per-text detection count, which is small.
914fn 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
938/// The keyed round-trip handle (D11): truncated HMAC-SHA256 over the
939/// canonicalized policy, the scope, and the sorted placeholder→value pairs.
940/// Keyed, never a bare digest — an unkeyed hash over the values would hand
941/// the egress channel an offline-guessing oracle for low-entropy values.
942fn 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
964/// RFC 2104 HMAC-SHA256, hand-rolled over the sha2 dependency the crate
965/// already carries (dependency-light: no hmac crate for twenty lines).
966fn 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        // RFC 4231 test case 2: key "Jefe", data "what do ya want for nothing?"
993        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()); // built in P1
1006        assert!(AnonPolicy::from_json(r#"{"scope": "memory"}"#).is_ok()); // built in P2
1007        // ...but memory scope is keyed by design: the unkeyed paths refuse it.
1008        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]"); // stable across texts
1022        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); // oldest evicted
1030        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        // Unparseable values degrade to the redaction form, never the raw value.
1040        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]")); // the literal survives
1067        assert!(out.mapping.contains_key("[EMAIL_2]")); // we minted around it
1068        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); // key changes the id
1078        assert_eq!(b.mapping_id, c.mapping_id); // same inputs + key → same id
1079    }
1080}