Skip to main content

gaze/
session.rs

1use std::time::{Duration, SystemTime, UNIX_EPOCH};
2
3use dashmap::mapref::entry::Entry;
4use dashmap::DashMap;
5use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
6use rand::RngCore;
7use secrecy::{ExposeSecret, SecretBox};
8use serde::{Deserialize, Deserializer, Serialize};
9use serde_json::Value as JsonValue;
10
11use crate::detector::PiiClass;
12use crate::policy::{Policy, SessionScope};
13use crate::{Error, Result};
14use gaze_types::DocumentExtension;
15
16const DEFAULT_PERSISTENT_TTL_SECS: u64 = 86_400;
17const DEFAULT_COUNTER_FAMILY: &str = "counter";
18const SNAPSHOT_VERSION_V2: u8 = 2;
19const SNAPSHOT_VERSION_V3: u8 = 3;
20const SNAPSHOT_VERSION_V4: u8 = 4;
21const SNAPSHOT_VERSION_V5: u8 = 5;
22
23/// Lifetime scope of a [`Session`]'s token manifest.
24///
25/// | Variant | Use case | `export()` allowed |
26/// |---------|----------|--------------------|
27/// | `Ephemeral` | Single-pass sanitization, no restore needed | No |
28/// | `Conversation(id)` | Multi-turn LLM sessions | Yes |
29/// | `Persistent { ttl: Duration }` | Long-lived sessions across restarts | Yes |
30///
31/// `Persistent`'s `ttl` is a [`std::time::Duration`]. `SensitiveSnapshot`s exported from a
32/// persistent session carry the TTL; [`Session::import`] returns `Error::BlobExpired { .. }` once
33/// the deadline has elapsed.
34#[derive(Debug, Clone)]
35#[non_exhaustive]
36pub enum Scope {
37    Ephemeral,
38    Conversation(String),
39    Persistent { ttl: Duration },
40}
41
42/// Serializable snapshot of a [`Session`]'s token manifest.
43///
44/// Produced by [`Session::export`] and consumed by [`Session::import`]. Carries the full
45/// token-to-PII mapping plus a session signing key - treat it as sensitive as the original
46/// document.
47///
48/// **Storage:** the snapshot is delivered as raw bytes via [`Self::into_bytes`] and reconstructed
49/// via `SensitiveSnapshot::from(Vec<u8>)`. There is no `Serialize`/`Deserialize` impl - bytes are
50/// the wire format. Encrypt at rest, bind to a conversation/user, enforce a TTL.
51///
52/// **Must not:** send to the LLM, analytics, browser clients, logs, or support tickets.
53///
54/// The audit log (`gaze-audit`) is **not** a restore source; only this snapshot can reconstruct
55/// original PII values.
56#[derive(Debug, Clone)]
57pub struct SensitiveSnapshot(Vec<u8>);
58
59impl SensitiveSnapshot {
60    pub fn into_bytes(self) -> Vec<u8> {
61        self.0
62    }
63}
64
65impl From<Vec<u8>> for SensitiveSnapshot {
66    fn from(value: Vec<u8>) -> Self {
67        Self(value)
68    }
69}
70
71#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
72struct TokenKey {
73    family: String,
74    class: PiiClass,
75    raw: String,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79struct SnapshotEntry {
80    class: PiiClass,
81    raw: String,
82    token: String,
83    #[serde(default = "default_counter_family")]
84    family: String,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88enum SnapshotScope {
89    Ephemeral,
90    Conversation(String),
91    Persistent { ttl_secs: u64 },
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95struct SnapshotPayload {
96    scope: SnapshotScope,
97    #[serde(deserialize_with = "deserialize_session_hex")]
98    session_hex: String,
99    entries: Vec<SnapshotEntry>,
100    #[serde(default)]
101    issued_at: u64,
102    #[serde(default)]
103    next_by_class: Vec<(PiiClass, usize)>,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    document: Option<DocumentExtension>,
106}
107
108/// Owns the token manifest for one conversation or request.
109///
110/// A `Session` holds the bidirectional map between PII values and their pseudonymous tokens.
111/// Create one per conversation and thread it through every [`Pipeline::redact`] call.
112///
113/// # Restore workflow
114///
115/// 1. Call [`Pipeline::redact`] - returns a [`gaze_types::CleanDocument`] and updates the session
116///    map.
117/// 2. Call [`Session::export`] to produce a [`SensitiveSnapshot`].
118/// 3. Persist the raw bytes (`snapshot.into_bytes()`) **encrypted at rest** - they contain
119///    original PII.
120/// 4. Send only the cleaned text to the LLM.
121/// 5. After the LLM responds, call [`Session::import`] with `SensitiveSnapshot::from(bytes)`.
122/// 6. For each token in the LLM response, call [`Session::restore_strict`] (or
123///    [`Session::restore`]).
124///
125/// There is no `Pipeline::restore_text` method - full-text restore is performed by scanning tokens
126/// with [`crate::token_shape::pattern`] and calling `restore_strict` per token.
127///
128/// Document workflows use the same restore root. Call [`Session::export_with_extension`] only when
129/// writing a `gaze-document` bundle that needs signed integrity hashes and codec provenance; plain
130/// text adopters should keep using [`Session::export`].
131///
132/// # Round-trip example
133///
134/// ```rust
135/// use gaze::{
136///     token_shape, Action, ClassRule, CleanDocument, DefaultRule, Detection, Detector, PiiClass,
137///     Pipeline, RawDocument, Scope, SensitiveSnapshot, Session,
138/// };
139///
140/// struct ExampleEmailDetector;
141///
142/// impl Detector for ExampleEmailDetector {
143///     fn detect(&self, input: &str) -> Vec<Detection> {
144///         let email = "alice@example.invalid"; // fixture-cited(crates/gaze/src/session.rs:session::tests::snapshot_round_trip_two_families_same_class_raw_preserved_under_shared_counter)
145///         input
146///             .find(email)
147///             .map(|start| Detection::new(start..start + email.len(), PiiClass::Email, "docs"))
148///             .into_iter()
149///             .collect()
150///     }
151/// }
152///
153/// let pipeline = Pipeline::builder()
154///     .detector(ExampleEmailDetector)
155///     .rule(ClassRule::new(PiiClass::Email, Action::Tokenize))
156///     .rule(DefaultRule::new(Action::Preserve))
157///     .build()?;
158/// let session = Session::new(Scope::Conversation("conv-1".into()))?;
159///
160/// let CleanDocument::Text(clean) = pipeline.redact(
161///     &session,
162///     RawDocument::Text("alice@example.invalid".into()), // fixture-cited(crates/gaze/src/session.rs:session::tests::snapshot_round_trip_two_families_same_class_raw_preserved_under_shared_counter)
163/// )? else {
164///     panic!("text variant expected");
165/// };
166///
167/// // Export before sending clean text to the LLM. Persist `blob` encrypted at rest.
168/// let snapshot = session.export()?;
169/// let blob: Vec<u8> = snapshot.into_bytes();
170///
171/// // Restore on the owner side after the LLM responds.
172/// let restored_session = Session::import(SensitiveSnapshot::from(blob))?;
173/// let mut restored = String::new();
174/// let mut last = 0;
175/// for m in token_shape::pattern().find_iter(&clean) {
176///     restored.push_str(&clean[last..m.start()]);
177///     restored.push_str(&restored_session.restore_strict(m.as_str())?);
178///     last = m.end();
179/// }
180/// restored.push_str(&clean[last..]);
181/// assert_eq!(restored, "alice@example.invalid"); // fixture-cited(crates/gaze/src/session.rs:session::tests::snapshot_round_trip_two_families_same_class_raw_preserved_under_shared_counter)
182/// # Ok::<(), Box<dyn std::error::Error>>(())
183/// ```
184///
185/// [`Pipeline::redact`]: crate::Pipeline::redact
186// intentionally not Debug: contains session signing key and token manifest
187pub struct Session {
188    scope: Scope,
189    session_hex: [u8; 4],
190    audit_session_id: String,
191    next_by_class: DashMap<PiiClass, usize>,
192    token_by_value: DashMap<TokenKey, String>,
193    value_by_token: DashMap<String, String>,
194    signing_key: SessionKey,
195}
196
197impl Session {
198    pub fn new(scope: Scope) -> Result<Self> {
199        Ok(Self {
200            scope,
201            session_hex: random_session_hex(),
202            audit_session_id: new_audit_session_id(),
203            next_by_class: DashMap::new(),
204            token_by_value: DashMap::new(),
205            value_by_token: DashMap::new(),
206            signing_key: SessionKey::generate()?,
207        })
208    }
209
210    pub fn from_policy(policy: &Policy) -> Result<Self> {
211        Self::from_policy_with_ttl_override(policy, None)
212    }
213
214    pub fn from_policy_with_ttl_override(
215        policy: &Policy,
216        ttl_secs_override: Option<u64>,
217    ) -> Result<Self> {
218        let scope = match policy.session.scope {
219            SessionScope::Ephemeral => Scope::Ephemeral,
220            SessionScope::Conversation => Scope::Conversation("cli".to_string()),
221            SessionScope::Persistent => {
222                let ttl_secs = ttl_secs_override
223                    .or(policy.session.ttl_secs)
224                    .unwrap_or(DEFAULT_PERSISTENT_TTL_SECS);
225                Scope::Persistent {
226                    ttl: Duration::from_secs(ttl_secs),
227                }
228            }
229        };
230
231        Self::new(scope)
232    }
233
234    pub fn tokenize(&self, class: &PiiClass, raw: &str) -> Result<String> {
235        self.tokenize_with_family(DEFAULT_COUNTER_FAMILY, class, raw)
236    }
237
238    pub fn tokenize_with_family(
239        &self,
240        family: &str,
241        class: &PiiClass,
242        raw: &str,
243    ) -> Result<String> {
244        self.intern_mapping(Some(family), class, raw, |index| {
245            format!("<{}:{}_{}>", self.session_hex(), class.class_name(), index)
246        })
247    }
248
249    pub fn format_preserving_fake(&self, class: &PiiClass, raw: &str) -> Result<String> {
250        self.intern_mapping(None, class, raw, |index| match class {
251            PiiClass::Email => format!("email{index}.{}@gaze-fake.invalid", self.session_hex()),
252            PiiClass::Name | PiiClass::Location | PiiClass::Organization => format!(
253                "{}:{}_{}",
254                self.session_hex(),
255                class.class_name().to_ascii_lowercase(),
256                index
257            ),
258            // Lowercasing preserves the dedicated `custom:` sentinel namespace
259            // for format-preserving fakes, so restore can detect them too.
260            PiiClass::Custom(name) => format!("{}:custom:{name}_{index}", self.session_hex()),
261        })
262    }
263
264    fn intern_mapping<F>(
265        &self,
266        family: Option<&str>,
267        class: &PiiClass,
268        raw: &str,
269        build: F,
270    ) -> Result<String>
271    where
272        F: FnOnce(usize) -> String,
273    {
274        let family_key = family.unwrap_or(DEFAULT_COUNTER_FAMILY);
275        let key = TokenKey {
276            family: family_key.to_string(),
277            class: class.clone(),
278            raw: raw.to_string(),
279        };
280        match self.token_by_value.entry(key) {
281            Entry::Occupied(existing) => Ok(existing.get().clone()),
282            Entry::Vacant(vacant) => {
283                let token = {
284                    let mut next = self.next_by_class.entry(class.clone()).or_insert(0);
285                    *next += 1;
286                    build(*next)
287                };
288
289                vacant.insert(token.clone());
290                self.value_by_token.insert(token.clone(), raw.to_string());
291                Ok(token)
292            }
293        }
294    }
295
296    /// Enumerate every live token string emitted by this session.
297    ///
298    /// Intended for restore-side callers that need to build an exact-literal
299    /// alternation regex over the session map (Pass 1 of the two-pass restore
300    /// strategy): replacing token-shaped strings via
301    /// a class-shape regex alone is unsafe because it either (a) straddles
302    /// word boundaries into adjacent text, or (b) misses lowercase
303    /// FormatPreserve shapes like `location_1`. Feeding these exact strings
304    /// into `regex::escape` and sorting longest-first avoids both pitfalls.
305    ///
306    /// Returned order is unspecified — callers that rely on longest-first
307    /// matching must sort the returned vector themselves.
308    pub fn tokens(&self) -> Vec<String> {
309        self.value_by_token
310            .iter()
311            .map(|entry| entry.key().clone())
312            .collect()
313    }
314
315    pub fn contains_token(&self, token: &str) -> bool {
316        self.value_by_token.contains_key(token)
317    }
318
319    pub fn session_hex(&self) -> String {
320        hex::encode(self.session_hex)
321    }
322
323    pub fn audit_session_id(&self) -> &str {
324        &self.audit_session_id
325    }
326
327    // Original byte spans are preserved by recognizer normalizers per
328    // research-855 §Rulepack > Normalization (axis-2 invariant).
329    pub fn restore_strict(&self, token: &str) -> Result<String> {
330        self.value_by_token
331            .get(token)
332            .map(|value| value.value().clone())
333            .ok_or_else(|| Error::UnknownToken(token.to_string()))
334    }
335
336    pub fn restore(&self, token: &str) -> Option<String> {
337        self.value_by_token
338            .get(token)
339            .map(|value| value.value().clone())
340    }
341
342    pub fn export(&self) -> Result<SensitiveSnapshot> {
343        self.export_payload(None)
344    }
345
346    /// Export a document-extended snapshot for `gaze-document` bundle manifests.
347    ///
348    /// Use this instead of [`Session::export`] when writing a document bundle with
349    /// `<base>-agent/` files (`clean.md`, `layout.json`, `report.json`, optional
350    /// `preview-redacted.png`) plus owner-only `<base>-owner/manifest.bin`. The supplied
351    /// [`DocumentExtension`] is serialized inside the signed snapshot payload, so its hashes and
352    /// codec audit rows become the integrity root for the agent-facing files. Text-only adopters
353    /// should use [`Session::export`]. Current snapshots emit v5 so older readers fail closed
354    /// before restore while the signature binds the emitted envelope bytes.
355    ///
356    /// ```rust
357    /// use gaze::{
358    ///     CodecAuditRow, CodecCapabilitySet, DocumentExtension, ExtractionDensityPolicy, Scope,
359    ///     Session, TextOrigin,
360    /// };
361    ///
362    /// let session = Session::new(Scope::Conversation("doc-1".to_string()))?;
363    /// let mut codec = CodecAuditRow::new(
364    ///     "gaze.codec.pdf",
365    ///     "0.7.0",
366    ///     "application/pdf",
367    ///     TextOrigin::Hybrid,
368    /// );
369    /// codec.advertised = CodecCapabilitySet::new(true, true, true, false);
370    /// codec.delivered = CodecCapabilitySet::new(true, true, false, false);
371    /// codec.extraction_density_policy = ExtractionDensityPolicy::Required(1.0);
372    /// let extension = DocumentExtension::builder(1)
373    ///     .clean_md_sha256([1; 32])
374    ///     .layout_json_sha256([2; 32])
375    ///     .report_json_sha256([3; 32])
376    ///     .page_count(4)
377    ///     .audit_session_id(session.audit_session_id())
378    ///     .codec_audit(vec![codec])
379    ///     .build()?;
380    ///
381    /// let manifest_bin = session.export_with_extension(extension)?.into_bytes();
382    /// # assert_eq!(manifest_bin[0], 5);
383    /// # Ok::<(), Box<dyn std::error::Error>>(())
384    /// ```
385    ///
386    /// Failure modes match [`Session::export`]: ephemeral sessions return
387    /// [`Error::ExportForbidden`], JSON encoding failures return [`Error::SnapshotDecode`], and
388    /// empty integrity-binding hashes or audit session ids return
389    /// [`Error::EmptyDocumentIntegrity`]. `page_count == 0` is allowed because text-only or
390    /// degenerate bundles may have no pages while still binding file hashes. Callers must treat the
391    /// returned [`SensitiveSnapshot`] as owner-only because it carries the full token-to-PII restore
392    /// map. Bundle layout details live in `docs/architecture/document-extension.md`.
393    pub fn export_with_extension(&self, extension: DocumentExtension) -> Result<SensitiveSnapshot> {
394        if extension.clean_md_sha256 == [0; 32]
395            || extension.layout_json_sha256 == [0; 32]
396            || extension.report_json_sha256 == [0; 32]
397            || extension.audit_session_id.is_empty()
398        {
399            return Err(Error::EmptyDocumentIntegrity);
400        }
401        self.export_payload(Some(extension))
402    }
403
404    fn export_payload(&self, document: Option<DocumentExtension>) -> Result<SensitiveSnapshot> {
405        if matches!(self.scope, Scope::Ephemeral) {
406            return Err(Error::ExportForbidden);
407        }
408
409        // If the host clock is before the Unix epoch, preserve compatibility by
410        // exporting `issued_at = 0` rather than failing snapshot export.
411        let issued_at = SystemTime::now()
412            .duration_since(UNIX_EPOCH)
413            .map(|duration| duration.as_secs())
414            .unwrap_or(0);
415
416        let payload = SnapshotPayload {
417            scope: snapshot_scope(&self.scope),
418            session_hex: self.session_hex(),
419            entries: self
420                .token_by_value
421                .iter()
422                .map(|entry| SnapshotEntry {
423                    family: entry.key().family.clone(),
424                    class: entry.key().class.clone(),
425                    raw: entry.key().raw.clone(),
426                    token: entry.value().clone(),
427                })
428                .collect(),
429            next_by_class: self
430                .next_by_class
431                .iter()
432                .map(|entry| (entry.key().clone(), *entry.value()))
433                .collect(),
434            issued_at,
435            document,
436        };
437        let payload_bytes = serde_json::to_vec(&payload).map_err(Error::SnapshotDecode)?;
438        let version = SNAPSHOT_VERSION_V5;
439        let signing_key = self.signing_key.signing_key();
440        let verifying_key = signing_key.verifying_key();
441        let verifying_key_bytes = verifying_key.to_bytes();
442        let signing_preimage =
443            snapshot_signing_preimage(version, &verifying_key_bytes, &payload_bytes);
444        let signature = signing_key.sign(&signing_preimage);
445
446        let mut snapshot = Vec::with_capacity(1 + 32 + 64 + payload_bytes.len());
447        snapshot.push(version);
448        snapshot.extend_from_slice(&verifying_key_bytes);
449        snapshot.extend_from_slice(&signature.to_bytes());
450        snapshot.extend_from_slice(&payload_bytes);
451        Ok(SensitiveSnapshot(snapshot))
452    }
453
454    pub fn import(snapshot: SensitiveSnapshot) -> Result<Self> {
455        let bytes = snapshot.0;
456        if bytes.len() < 97 {
457            return Err(Error::InvalidSnapshotSignature);
458        }
459        let version = bytes[0];
460        if version != SNAPSHOT_VERSION_V2
461            && version != SNAPSHOT_VERSION_V3
462            && version != SNAPSHOT_VERSION_V4
463            && version != SNAPSHOT_VERSION_V5
464        {
465            return Err(Error::InvalidSnapshotVersion(version));
466        }
467
468        let verifying_key = VerifyingKey::from_bytes(
469            bytes[1..33]
470                .try_into()
471                .map_err(|_| Error::InvalidSnapshotSignature)?,
472        )
473        .map_err(|_| Error::InvalidSnapshotSignature)?;
474        let signature = Signature::from_bytes(
475            bytes[33..97]
476                .try_into()
477                .map_err(|_| Error::InvalidSnapshotSignature)?,
478        );
479        let payload_bytes = &bytes[97..];
480        let verify_preimage;
481        let signed_bytes = if version >= SNAPSHOT_VERSION_V5 {
482            let verifying_key_bytes: [u8; 32] = bytes[1..33]
483                .try_into()
484                .map_err(|_| Error::InvalidSnapshotSignature)?;
485            verify_preimage =
486                snapshot_signing_preimage(version, &verifying_key_bytes, payload_bytes);
487            verify_preimage.as_slice()
488        } else {
489            payload_bytes
490        };
491        verifying_key
492            .verify(signed_bytes, &signature)
493            .map_err(|_| Error::InvalidSnapshotSignature)?;
494
495        let payload = decode_snapshot_payload(payload_bytes)?;
496        validate_entry_prefixes(&payload)?;
497        let session_hex = session_hex_bytes(&payload.session_hex)?;
498        let scope = scope_from_snapshot(payload.scope);
499        let issued_at = payload.issued_at;
500        if let Scope::Persistent { ttl } = &scope {
501            let ttl_secs = ttl.as_secs();
502            if issued_at > 0 {
503                let now = SystemTime::now()
504                    .duration_since(UNIX_EPOCH)
505                    .map(|duration| duration.as_secs())
506                    .unwrap_or(0);
507                if issued_at > now.saturating_add(60) {
508                    return Err(Error::InvalidSnapshotSignature);
509                }
510                if now.saturating_sub(issued_at) > ttl_secs {
511                    return Err(Error::BlobExpired {
512                        issued_at,
513                        ttl_secs,
514                    });
515                }
516            }
517        }
518
519        let session = Self {
520            scope,
521            session_hex,
522            audit_session_id: new_audit_session_id(),
523            next_by_class: DashMap::new(),
524            token_by_value: DashMap::new(),
525            value_by_token: DashMap::new(),
526            signing_key: SessionKey::generate()?,
527        };
528        for entry in payload.entries {
529            session.token_by_value.insert(
530                TokenKey {
531                    family: entry.family,
532                    class: entry.class.clone(),
533                    raw: entry.raw.clone(),
534                },
535                entry.token.clone(),
536            );
537            session
538                .value_by_token
539                .insert(entry.token.clone(), entry.raw);
540            if let Some(index) = parse_token_index(&entry.token) {
541                let mut next = session.next_by_class.entry(entry.class).or_insert(0);
542                if *next < index {
543                    *next = index;
544                }
545            }
546        }
547        // Authoritative counter state from the exporter. Overrides any
548        // index we reconstructed from parseable token suffixes above so
549        // that format-preserving tokens (e.g. `email1.<session>@gaze-fake.invalid`)
550        // also round-trip safely.
551        for (class, index) in payload.next_by_class {
552            let mut next = session.next_by_class.entry(class).or_insert(0);
553            if *next < index {
554                *next = index;
555            }
556        }
557        Ok(session)
558    }
559}
560
561fn default_counter_family() -> String {
562    DEFAULT_COUNTER_FAMILY.to_string()
563}
564
565fn new_audit_session_id() -> String {
566    let millis = SystemTime::now()
567        .duration_since(UNIX_EPOCH)
568        .map(|duration| duration.as_millis().min(0xffff_ffff_ffff) as u64)
569        .unwrap_or(0);
570    let mut bytes = [0_u8; 16];
571    rand::thread_rng().fill_bytes(&mut bytes[6..]);
572    bytes[0] = (millis >> 40) as u8;
573    bytes[1] = (millis >> 32) as u8;
574    bytes[2] = (millis >> 24) as u8;
575    bytes[3] = (millis >> 16) as u8;
576    bytes[4] = (millis >> 8) as u8;
577    bytes[5] = millis as u8;
578    bytes[6] = (bytes[6] & 0x0f) | 0x70;
579    bytes[8] = (bytes[8] & 0x3f) | 0x80;
580    format!(
581        "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
582        bytes[0],
583        bytes[1],
584        bytes[2],
585        bytes[3],
586        bytes[4],
587        bytes[5],
588        bytes[6],
589        bytes[7],
590        bytes[8],
591        bytes[9],
592        bytes[10],
593        bytes[11],
594        bytes[12],
595        bytes[13],
596        bytes[14],
597        bytes[15],
598    )
599}
600
601struct SessionKey {
602    secret: SecretBox<[u8; 32]>,
603    protection: MemoryProtection,
604}
605
606impl SessionKey {
607    fn generate() -> Result<Self> {
608        let secret = SecretBox::init_with_mut(|bytes: &mut [u8; 32]| {
609            rand::thread_rng().fill_bytes(bytes);
610        });
611        let protection = MemoryProtection::best_effort(secret.expose_secret().as_ptr(), 32);
612        Ok(Self { secret, protection })
613    }
614
615    fn signing_key(&self) -> SigningKey {
616        SigningKey::from_bytes(self.secret.expose_secret())
617    }
618}
619
620impl Drop for SessionKey {
621    fn drop(&mut self) {
622        self.protection.unlock();
623    }
624}
625
626struct MemoryProtection {
627    addr: usize,
628    len: usize,
629    locked: bool,
630}
631
632impl MemoryProtection {
633    fn best_effort(ptr: *const u8, len: usize) -> Self {
634        let locked = lock_memory(ptr, len);
635        advise_dontdump(ptr, len);
636        Self {
637            addr: ptr as usize,
638            len,
639            locked,
640        }
641    }
642
643    fn unlock(&mut self) {
644        if self.locked {
645            unlock_memory(self.addr as *const u8, self.len);
646            self.locked = false;
647        }
648    }
649}
650
651fn lock_memory(ptr: *const u8, len: usize) -> bool {
652    #[cfg(unix)]
653    unsafe {
654        if libc::mlock(ptr.cast(), len) == 0 {
655            return true;
656        }
657        tracing::warn!(
658            error = %std::io::Error::last_os_error(),
659            "session key mlock failed; continuing with unlocked key material"
660        );
661    }
662
663    false
664}
665
666fn unlock_memory(ptr: *const u8, len: usize) {
667    #[cfg(unix)]
668    unsafe {
669        let _ = libc::munlock(ptr.cast(), len);
670    }
671}
672
673fn advise_dontdump(_ptr: *const u8, _len: usize) {
674    #[cfg(any(target_os = "linux", target_os = "android"))]
675    unsafe {
676        let ptr = _ptr;
677        let len = _len;
678        let page_size = libc::sysconf(libc::_SC_PAGESIZE);
679        if page_size <= 0 {
680            return;
681        }
682        let page_size = page_size as usize;
683        let start = (ptr as usize) & !(page_size - 1);
684        let end = (ptr as usize + len).div_ceil(page_size) * page_size;
685        let aligned_len = end.saturating_sub(start);
686        if aligned_len == 0 {
687            return;
688        }
689        let _ = libc::madvise(start as *mut libc::c_void, aligned_len, libc::MADV_DONTDUMP);
690    }
691}
692
693fn snapshot_scope(scope: &Scope) -> SnapshotScope {
694    match scope {
695        Scope::Ephemeral => SnapshotScope::Ephemeral,
696        Scope::Conversation(id) => SnapshotScope::Conversation(id.clone()),
697        Scope::Persistent { ttl } => SnapshotScope::Persistent {
698            ttl_secs: ttl.as_secs(),
699        },
700    }
701}
702
703fn scope_from_snapshot(scope: SnapshotScope) -> Scope {
704    match scope {
705        SnapshotScope::Ephemeral => Scope::Ephemeral,
706        SnapshotScope::Conversation(id) => Scope::Conversation(id),
707        SnapshotScope::Persistent { ttl_secs } => Scope::Persistent {
708            ttl: Duration::from_secs(ttl_secs),
709        },
710    }
711}
712
713fn random_session_hex() -> [u8; 4] {
714    let mut bytes = [0u8; 4];
715    rand::thread_rng().fill_bytes(&mut bytes);
716    bytes
717}
718
719fn deserialize_session_hex<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
720where
721    D: Deserializer<'de>,
722{
723    let value = String::deserialize(deserializer)?;
724    if is_session_hex(&value) {
725        Ok(value)
726    } else {
727        Err(serde::de::Error::custom(
728            "session_hex must be 8 lowercase hex chars",
729        ))
730    }
731}
732
733fn is_session_hex(value: &str) -> bool {
734    value.len() == 8
735        && value
736            .bytes()
737            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
738}
739
740fn session_hex_bytes(value: &str) -> Result<[u8; 4]> {
741    if !is_session_hex(value) {
742        return Err(Error::InvalidSnapshotPayload);
743    }
744    let decoded = hex::decode(value).map_err(|_| Error::InvalidSnapshotPayload)?;
745    decoded
746        .try_into()
747        .map_err(|_| Error::InvalidSnapshotPayload)
748}
749
750fn decode_snapshot_payload(payload_bytes: &[u8]) -> Result<SnapshotPayload> {
751    let value: JsonValue = serde_json::from_slice(payload_bytes).map_err(Error::SnapshotDecode)?;
752    let Some(session_hex) = value.get("session_hex").and_then(JsonValue::as_str) else {
753        return Err(Error::InvalidSnapshotPayload);
754    };
755    if !is_session_hex(session_hex) {
756        return Err(Error::InvalidSnapshotPayload);
757    }
758    serde_json::from_value(value).map_err(Error::SnapshotDecode)
759}
760
761fn validate_entry_prefixes(payload: &SnapshotPayload) -> Result<()> {
762    for entry in &payload.entries {
763        if !entry_token_matches_session(&entry.token, &payload.session_hex)
764            || !crate::token_shape::starts_with_session_prefix(&entry.token)
765        {
766            return Err(Error::InvalidSnapshotPayload);
767        }
768    }
769    Ok(())
770}
771
772fn entry_token_matches_session(token: &str, session_hex: &str) -> bool {
773    token.starts_with(&format!("<{session_hex}:"))
774        || token.starts_with(&format!("{session_hex}:"))
775        || (token.starts_with("email")
776            && token
777                .split_once('.')
778                .and_then(|(_, rest)| rest.strip_suffix("@gaze-fake.invalid"))
779                == Some(session_hex))
780}
781
782fn parse_token_index(token: &str) -> Option<usize> {
783    if let Some(local) = token
784        .strip_prefix("email")
785        .and_then(|rest| rest.split_once('.').map(|(index, _)| index))
786    {
787        return local.parse().ok();
788    }
789    let suffix = token
790        .rsplit_once('_')?
791        .1
792        .strip_suffix('>')
793        .unwrap_or(token.rsplit_once('_')?.1);
794    suffix.parse().ok()
795}
796
797fn snapshot_signing_preimage(
798    version: u8,
799    verifying_key_bytes: &[u8; 32],
800    payload_bytes: &[u8],
801) -> Vec<u8> {
802    let mut preimage = Vec::with_capacity(1 + verifying_key_bytes.len() + payload_bytes.len());
803    preimage.push(version);
804    preimage.extend_from_slice(verifying_key_bytes);
805    preimage.extend_from_slice(payload_bytes);
806    preimage
807}
808
809#[cfg(test)]
810mod tests {
811    use super::*;
812
813    fn signed_snapshot_v03(payload: SnapshotPayload) -> SensitiveSnapshot {
814        let payload_bytes = serde_json::to_vec(&payload).expect("serialize payload");
815        signed_snapshot_bytes(1, &payload_bytes)
816    }
817
818    fn signed_snapshot_bytes(version: u8, payload_bytes: &[u8]) -> SensitiveSnapshot {
819        let key = SessionKey::generate().expect("session key");
820        let signing_key = key.signing_key();
821        let signature = signing_key.sign(payload_bytes);
822        let verifying_key = signing_key.verifying_key();
823
824        let mut snapshot = Vec::with_capacity(1 + 32 + 64 + payload_bytes.len());
825        snapshot.push(version);
826        snapshot.extend_from_slice(&verifying_key.to_bytes());
827        snapshot.extend_from_slice(&signature.to_bytes());
828        snapshot.extend_from_slice(payload_bytes);
829        SensitiveSnapshot::from(snapshot)
830    }
831
832    fn signed_snapshot_v2(payload: SnapshotPayload) -> SensitiveSnapshot {
833        let mut snapshot = signed_snapshot_v03(payload).into_bytes();
834        snapshot[0] = SNAPSHOT_VERSION_V2;
835        SensitiveSnapshot::from(snapshot)
836    }
837
838    fn signed_snapshot(payload: SnapshotPayload) -> SensitiveSnapshot {
839        let mut snapshot = signed_snapshot_v03(payload).into_bytes();
840        snapshot[0] = SNAPSHOT_VERSION_V3;
841        SensitiveSnapshot::from(snapshot)
842    }
843
844    fn snapshot_payload_json(snapshot: &SensitiveSnapshot) -> JsonValue {
845        serde_json::from_slice(&snapshot.0[97..]).expect("snapshot payload json")
846    }
847
848    fn document_extension(session: &Session) -> DocumentExtension {
849        DocumentExtension::builder(1)
850            .clean_md_sha256([1; 32])
851            .layout_json_sha256([2; 32])
852            .report_json_sha256([3; 32])
853            .page_count(1)
854            .audit_session_id(session.audit_session_id())
855            .build()
856            .expect("document extension")
857    }
858
859    fn legacy_v0_4_0_accepts_only_v2(snapshot: &SensitiveSnapshot) -> Result<()> {
860        let bytes = &snapshot.0;
861        if bytes.len() < 97 {
862            return Err(Error::InvalidSnapshotSignature);
863        }
864        let version = bytes[0];
865        if version != SNAPSHOT_VERSION_V2 {
866            return Err(Error::InvalidSnapshotVersion(version));
867        }
868        Ok(())
869    }
870
871    #[test]
872    fn session_key_produces_valid_signatures() {
873        let key = SessionKey::generate().expect("session key");
874        let signing_key = key.signing_key();
875        let message = b"gaze";
876        let signature = signing_key.sign(message);
877
878        assert!(signing_key
879            .verifying_key()
880            .verify(message, &signature)
881            .is_ok());
882    }
883
884    #[test]
885    fn import_accepts_persistent_snapshot_within_ttl() {
886        let now = SystemTime::now()
887            .duration_since(UNIX_EPOCH)
888            .map(|duration| duration.as_secs())
889            .unwrap_or(0);
890        let snapshot = signed_snapshot(SnapshotPayload {
891            scope: SnapshotScope::Persistent { ttl_secs: 300 },
892            session_hex: "a7f3b8e2".to_string(),
893            entries: Vec::new(),
894            issued_at: now,
895            next_by_class: Vec::new(),
896            document: None,
897        });
898
899        assert!(Session::import(snapshot).is_ok());
900    }
901
902    #[test]
903    fn import_rejects_v03_envelope_byte() {
904        let snapshot = signed_snapshot_v03(SnapshotPayload {
905            scope: SnapshotScope::Persistent { ttl_secs: 300 },
906            session_hex: "a7f3b8e2".to_string(),
907            entries: Vec::new(),
908            issued_at: 0,
909            next_by_class: Vec::new(),
910            document: None,
911        });
912
913        assert!(matches!(
914            Session::import(snapshot),
915            Err(Error::InvalidSnapshotVersion(1))
916        ));
917    }
918
919    #[test]
920    fn import_rejects_invalid_session_hex() {
921        let snapshot = signed_snapshot(SnapshotPayload {
922            scope: SnapshotScope::Persistent { ttl_secs: 300 },
923            session_hex: "A7F3B8E2".to_string(),
924            entries: Vec::new(),
925            issued_at: 0,
926            next_by_class: Vec::new(),
927            document: None,
928        });
929
930        assert!(matches!(
931            Session::import(snapshot),
932            Err(Error::InvalidSnapshotPayload)
933        ));
934    }
935
936    #[test]
937    fn import_rejects_manifest_entry_prefix_mismatch() {
938        let snapshot = signed_snapshot(SnapshotPayload {
939            scope: SnapshotScope::Persistent { ttl_secs: 300 },
940            session_hex: "a7f3b8e2".to_string(),
941            entries: vec![SnapshotEntry {
942                family: DEFAULT_COUNTER_FAMILY.to_string(),
943                class: PiiClass::Name,
944                raw: "Dr. Schmidt".to_string(),
945                token: "deadbeef:name_1".to_string(),
946            }],
947            issued_at: 0,
948            next_by_class: Vec::new(),
949            document: None,
950        });
951
952        assert!(matches!(
953            Session::import(snapshot),
954            Err(Error::InvalidSnapshotPayload)
955        ));
956    }
957
958    #[test]
959    fn import_rejects_expired_persistent_snapshot() {
960        let now = SystemTime::now()
961            .duration_since(UNIX_EPOCH)
962            .map(|duration| duration.as_secs())
963            .unwrap_or(0);
964        let snapshot = signed_snapshot(SnapshotPayload {
965            scope: SnapshotScope::Persistent { ttl_secs: 10 },
966            session_hex: "a7f3b8e2".to_string(),
967            entries: Vec::new(),
968            issued_at: now.saturating_sub(11),
969            next_by_class: Vec::new(),
970            document: None,
971        });
972
973        assert!(matches!(
974            Session::import(snapshot),
975            Err(Error::BlobExpired {
976                issued_at,
977                ttl_secs: 10,
978            }) if issued_at == now.saturating_sub(11)
979        ));
980    }
981
982    #[test]
983    fn import_accepts_legacy_persistent_snapshot_without_issued_at() {
984        let snapshot = signed_snapshot_v2(SnapshotPayload {
985            scope: SnapshotScope::Persistent { ttl_secs: 1 },
986            session_hex: "a7f3b8e2".to_string(),
987            entries: Vec::new(),
988            issued_at: 0,
989            next_by_class: Vec::new(),
990            document: None,
991        });
992
993        assert!(Session::import(snapshot).is_ok());
994    }
995
996    #[test]
997    fn import_v0_4_0_snapshot_version_2_succeeds_with_default_family() {
998        let payload = serde_json::json!({
999            "scope": { "Persistent": { "ttl_secs": 300 } },
1000            "session_hex": "a7f3b8e2",
1001            "entries": [{
1002                "class": "Name",
1003                "raw": "Dr. Schmidt",
1004                "token": "<a7f3b8e2:Name_1>"
1005            }],
1006            "issued_at": 0,
1007            "next_by_class": [["Name", 1]]
1008        });
1009        let payload_bytes = serde_json::to_vec(&payload).expect("payload");
1010        let snapshot = signed_snapshot_bytes(SNAPSHOT_VERSION_V2, &payload_bytes);
1011
1012        let session = Session::import(snapshot).expect("import v2 snapshot");
1013        assert_eq!(
1014            session.restore("<a7f3b8e2:Name_1>").as_deref(),
1015            Some("Dr. Schmidt")
1016        );
1017        let next = session
1018            .tokenize_with_family("alpha", &PiiClass::Name, "Prof. Weber")
1019            .expect("next token");
1020        assert_eq!(next, "<a7f3b8e2:Name_2>");
1021    }
1022
1023    #[test]
1024    fn v0_4_0_rejects_v0_4_1_snapshot_version_3_cleanly() {
1025        let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1026        let _ = session
1027            .tokenize_with_family("alpha", &PiiClass::Name, "Dr. Schmidt")
1028            .expect("token");
1029        let snapshot = session.export().expect("snapshot");
1030
1031        assert!(matches!(
1032            legacy_v0_4_0_accepts_only_v2(&snapshot),
1033            Err(Error::InvalidSnapshotVersion(SNAPSHOT_VERSION_V5))
1034        ));
1035    }
1036
1037    #[test]
1038    fn snapshot_signature_binds_emitted_envelope_version() {
1039        let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1040        session
1041            .tokenize(&PiiClass::Name, "Dr. Schmidt")
1042            .expect("token");
1043
1044        let mut bytes = session.export().expect("snapshot").into_bytes();
1045        assert_eq!(bytes[0], SNAPSHOT_VERSION_V5);
1046        bytes[0] = SNAPSHOT_VERSION_V3;
1047
1048        assert!(matches!(
1049            Session::import(SensitiveSnapshot::from(bytes)),
1050            Err(Error::InvalidSnapshotSignature)
1051        ));
1052    }
1053
1054    #[test]
1055    fn snapshot_signature_uses_final_envelope_preimage() {
1056        let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1057        session
1058            .tokenize(&PiiClass::Email, "alice@example.invalid")
1059            .expect("token");
1060
1061        let bytes = session.export().expect("snapshot").into_bytes();
1062        let version = bytes[0];
1063        let verifying_key_bytes: [u8; 32] = bytes[1..33].try_into().expect("verifying key bytes");
1064        let verifying_key = VerifyingKey::from_bytes(&verifying_key_bytes).expect("verifying key");
1065        let signature = Signature::from_bytes(&bytes[33..97].try_into().expect("signature bytes"));
1066        let payload_bytes = &bytes[97..];
1067        let preimage = snapshot_signing_preimage(version, &verifying_key_bytes, payload_bytes);
1068
1069        assert!(verifying_key.verify(&preimage, &signature).is_ok());
1070        assert!(verifying_key.verify(payload_bytes, &signature).is_err());
1071    }
1072
1073    #[test]
1074    fn snapshot_import_rejects_signature_slot_mutation() {
1075        let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1076        session
1077            .tokenize(&PiiClass::Name, "Dr. Schmidt")
1078            .expect("token");
1079
1080        let mut bytes = session.export().expect("snapshot").into_bytes();
1081        bytes[33] ^= 0x01;
1082
1083        assert!(matches!(
1084            Session::import(SensitiveSnapshot::from(bytes)),
1085            Err(Error::InvalidSnapshotSignature)
1086        ));
1087    }
1088
1089    #[test]
1090    fn export_with_extension_round_trips_clean() {
1091        let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1092        let token = session
1093            .tokenize(&PiiClass::Name, "Dr. Schmidt")
1094            .expect("token");
1095        let extension = document_extension(&session);
1096
1097        let snapshot = session
1098            .export_with_extension(extension.clone())
1099            .expect("export with extension");
1100        let payload = snapshot_payload_json(&snapshot);
1101
1102        let document: DocumentExtension =
1103            serde_json::from_value(payload["document"].clone()).expect("document extension");
1104        assert_eq!(document, extension);
1105
1106        let imported = Session::import(snapshot).expect("import extended snapshot");
1107        assert_eq!(imported.restore(&token).as_deref(), Some("Dr. Schmidt"));
1108    }
1109
1110    #[test]
1111    fn export_with_extension_no_pii_leak() {
1112        let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1113        let _ = session
1114            .tokenize(&PiiClass::Email, "alice@example.invalid")
1115            .expect("token");
1116
1117        let snapshot = session
1118            .export_with_extension(document_extension(&session))
1119            .expect("export with extension");
1120        let payload = snapshot_payload_json(&snapshot);
1121        let document_json = serde_json::to_string(&payload["document"]).expect("document json");
1122
1123        assert!(!document_json.contains("alice@example.invalid"));
1124        assert!(!document_json.contains("\"raw\""));
1125    }
1126
1127    #[test]
1128    fn document_extension_zero_clean_md_sha256_rejected() {
1129        let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1130        let mut extension = document_extension(&session);
1131        extension.clean_md_sha256 = [0; 32];
1132
1133        assert!(matches!(
1134            session.export_with_extension(extension),
1135            Err(Error::EmptyDocumentIntegrity)
1136        ));
1137    }
1138
1139    #[test]
1140    fn document_extension_zero_layout_json_sha256_rejected() {
1141        let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1142        let mut extension = document_extension(&session);
1143        extension.layout_json_sha256 = [0; 32];
1144
1145        assert!(matches!(
1146            session.export_with_extension(extension),
1147            Err(Error::EmptyDocumentIntegrity)
1148        ));
1149    }
1150
1151    #[test]
1152    fn document_extension_zero_report_json_sha256_rejected() {
1153        let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1154        let mut extension = document_extension(&session);
1155        extension.report_json_sha256 = [0; 32];
1156
1157        assert!(matches!(
1158            session.export_with_extension(extension),
1159            Err(Error::EmptyDocumentIntegrity)
1160        ));
1161    }
1162
1163    #[test]
1164    fn document_extension_empty_audit_session_id_rejected() {
1165        let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1166        let mut extension = document_extension(&session);
1167        extension.audit_session_id.clear();
1168
1169        assert!(matches!(
1170            session.export_with_extension(extension),
1171            Err(Error::EmptyDocumentIntegrity)
1172        ));
1173    }
1174
1175    #[test]
1176    fn document_extension_with_full_integrity_signs_successfully() {
1177        let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1178        let snapshot = session
1179            .export_with_extension(document_extension(&session))
1180            .expect("export with full integrity");
1181        let payload = snapshot_payload_json(&snapshot);
1182
1183        assert_eq!(payload["document"]["schema_version"], 1);
1184        assert!(Session::import(snapshot).is_ok());
1185    }
1186
1187    #[test]
1188    fn import_rejects_forward_dated_persistent_snapshot() {
1189        let now = SystemTime::now()
1190            .duration_since(UNIX_EPOCH)
1191            .map(|duration| duration.as_secs())
1192            .unwrap_or(0);
1193        let snapshot = signed_snapshot(SnapshotPayload {
1194            scope: SnapshotScope::Persistent { ttl_secs: 300 },
1195            session_hex: "a7f3b8e2".to_string(),
1196            entries: Vec::new(),
1197            issued_at: now.saturating_add(3_600),
1198            next_by_class: Vec::new(),
1199            document: None,
1200        });
1201
1202        assert!(matches!(
1203            Session::import(snapshot),
1204            Err(Error::InvalidSnapshotSignature)
1205        ));
1206    }
1207
1208    #[test]
1209    fn tokenize_distinguishes_builtin_and_custom_class_names() {
1210        let session = Session::new(Scope::Ephemeral).expect("session");
1211        for (builtin, name) in [
1212            (PiiClass::Email, "email"),
1213            (PiiClass::Name, "name"),
1214            (PiiClass::Location, "location"),
1215            (PiiClass::Organization, "organization"),
1216        ] {
1217            let builtin_value = format!("{name}-builtin");
1218            let custom_value = format!("{name}-custom");
1219
1220            let builtin_token = session
1221                .tokenize(&builtin, &builtin_value)
1222                .expect("builtin token");
1223            let custom_class = PiiClass::custom(name);
1224            let custom_token = session
1225                .tokenize(&custom_class, &custom_value)
1226                .expect("custom token");
1227
1228            assert!(builtin_token.ends_with(&format!(":{}_1>", builtin.class_name())));
1229            assert!(custom_token.ends_with(&format!(":Custom:{name}_1>")));
1230            assert_ne!(builtin_token, custom_token);
1231            assert_eq!(
1232                session.restore(&builtin_token).as_deref(),
1233                Some(builtin_value.as_str())
1234            );
1235            assert_eq!(
1236                session.restore(&custom_token).as_deref(),
1237                Some(custom_value.as_str())
1238            );
1239        }
1240    }
1241
1242    #[test]
1243    fn tokenize_distinguishes_custom_classes_with_matching_pascal_case() {
1244        let session = Session::new(Scope::Ephemeral).expect("session");
1245        let first_class = PiiClass::custom("email");
1246        let second_class = PiiClass::custom("custom_email");
1247
1248        let first_token = session
1249            .tokenize(&first_class, "alice@corp.com")
1250            .expect("first custom token");
1251        let second_token = session
1252            .tokenize(&second_class, "hello")
1253            .expect("second custom token");
1254
1255        assert!(first_token.ends_with(":Custom:email_1>"));
1256        assert!(second_token.ends_with(":Custom:custom_email_1>"));
1257        assert_ne!(first_token, second_token);
1258        assert_eq!(
1259            session.restore(&first_token).as_deref(),
1260            Some("alice@corp.com")
1261        );
1262        assert_eq!(session.restore(&second_token).as_deref(), Some("hello"));
1263    }
1264
1265    #[test]
1266    fn snapshot_round_trip_two_families_same_class_raw_preserved_under_shared_counter() {
1267        let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1268        let alpha = session
1269            .tokenize_with_family("alpha", &PiiClass::Name, "Dr. Schmidt")
1270            .expect("alpha token");
1271        let beta = session
1272            .tokenize_with_family("beta", &PiiClass::Name, "Dr. Schmidt")
1273            .expect("beta token");
1274
1275        assert_ne!(alpha, beta);
1276        assert!(alpha.ends_with(":Name_1>"));
1277        assert!(beta.ends_with(":Name_2>"));
1278
1279        let snapshot = session.export().expect("snapshot");
1280        assert_eq!(snapshot.0[0], SNAPSHOT_VERSION_V5);
1281        let imported = Session::import(snapshot).expect("import");
1282
1283        assert_eq!(imported.restore(&alpha).as_deref(), Some("Dr. Schmidt"));
1284        assert_eq!(imported.restore(&beta).as_deref(), Some("Dr. Schmidt"));
1285        assert_eq!(
1286            imported
1287                .tokenize_with_family("alpha", &PiiClass::Name, "Dr. Schmidt")
1288                .expect("alpha stable"),
1289            alpha
1290        );
1291        assert_eq!(
1292            imported
1293                .tokenize_with_family("beta", &PiiClass::Name, "Dr. Schmidt")
1294                .expect("beta stable"),
1295            beta
1296        );
1297    }
1298}