Skip to main content

gaze/
session.rs

1use std::collections::hash_map::DefaultHasher;
2use std::collections::BTreeSet;
3use std::hash::{Hash, Hasher};
4use std::ops::Range;
5use std::sync::{Arc, OnceLock, RwLock, RwLockReadGuard, RwLockWriteGuard};
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8use dashmap::mapref::entry::Entry;
9use dashmap::DashMap;
10use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
11use rand::RngCore;
12use regex::Regex;
13use secrecy::{ExposeSecret, SecretBox};
14use serde::{Deserialize, Deserializer, Serialize};
15use serde_json::Value as JsonValue;
16use sha2::{Digest, Sha256};
17
18use crate::detector::PiiClass;
19use crate::policy::{Policy, SessionScope};
20use crate::{Error, Result};
21use gaze_types::DocumentExtension;
22
23const DEFAULT_PERSISTENT_TTL_SECS: u64 = 86_400;
24const DEFAULT_COUNTER_FAMILY: &str = "counter";
25const SNAPSHOT_VERSION_V2: u8 = 2;
26const SNAPSHOT_VERSION_V3: u8 = 3;
27const SNAPSHOT_VERSION_V4: u8 = 4;
28const SNAPSHOT_VERSION_V5: u8 = 5;
29
30pub type RestoreError = Error;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33#[non_exhaustive]
34pub enum RestoreEventKind {
35    ManifestBypass,
36    FreshPiiDetected,
37}
38
39impl RestoreEventKind {
40    pub fn as_str(self) -> &'static str {
41        match self {
42            Self::ManifestBypass => "manifest_bypass",
43            Self::FreshPiiDetected => "fresh_pii_detected",
44        }
45    }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49#[non_exhaustive]
50pub struct RestoreEvent {
51    pub kind: RestoreEventKind,
52    pub class: PiiClass,
53    pub raw_sha256: String,
54    pub location: Range<usize>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub(crate) struct ParsedRestoreToken {
59    pub class: PiiClass,
60    pub ordinal: u32,
61    pub raw: String,
62}
63
64/// Persistence scope of a [`Session`]'s token manifest.
65///
66/// `Scope` chooses *persistence*, not *isolation*. A [`Session`] instance is the
67/// pseudonym namespace boundary; two Sessions never share counters or value-keyed
68/// lookups, regardless of which `Scope` variant they use.
69///
70/// | Variant | Use case | `export()` allowed |
71/// |---------|----------|--------------------|
72/// | `Ephemeral` | Process-bound one-off redaction; namespace lives until the Session is dropped | No |
73/// | `Conversation(id)` | Keyed multi-turn LLM sessions that can be re-opened across process restarts, storage backend-dependent | Yes |
74/// | `Persistent { ttl: Duration }` | Long-lived sessions across restarts | Yes |
75///
76/// Do not share one `Scope::Ephemeral` Session across logical conversations if
77/// each conversation should start with independent `Email_1` / `Person_1`
78/// counters. Use one [`Session`] per logical isolation boundary.
79///
80/// `Persistent`'s `ttl` is a [`std::time::Duration`]. `SensitiveSnapshot`s exported from a
81/// persistent session carry the TTL; [`Session::import`] returns `Error::BlobExpired { .. }` once
82/// the deadline has elapsed.
83#[derive(Debug, Clone)]
84#[non_exhaustive]
85pub enum Scope {
86    Ephemeral,
87    Conversation(String),
88    Persistent { ttl: Duration },
89}
90
91/// Serializable snapshot of a [`Session`]'s token manifest.
92///
93/// Produced by [`Session::export`] and consumed by [`Session::import`]. Carries the full
94/// token-to-PII mapping plus a session signing key - treat it as sensitive as the original
95/// document.
96///
97/// **Storage:** the snapshot is delivered as raw bytes via [`Self::into_bytes`] and reconstructed
98/// via `SensitiveSnapshot::from(Vec<u8>)`. There is no `Serialize`/`Deserialize` impl - bytes are
99/// the wire format. Encrypt at rest, bind to a conversation/user, enforce a TTL.
100///
101/// **Must not:** send to the LLM, analytics, browser clients, logs, or support tickets.
102///
103/// The audit log (`gaze-audit`) is **not** a restore source; only this snapshot can reconstruct
104/// original PII values.
105#[derive(Debug, Clone)]
106pub struct SensitiveSnapshot(Vec<u8>);
107
108impl SensitiveSnapshot {
109    pub fn into_bytes(self) -> Vec<u8> {
110        self.0
111    }
112}
113
114impl From<Vec<u8>> for SensitiveSnapshot {
115    fn from(value: Vec<u8>) -> Self {
116        Self(value)
117    }
118}
119
120#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
121struct TokenKey {
122    family: String,
123    class: PiiClass,
124    raw: String,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
128struct SnapshotEntry {
129    class: PiiClass,
130    raw: String,
131    token: String,
132    #[serde(default = "default_counter_family")]
133    family: String,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct SessionSnapshotEntry {
138    pub class: PiiClass,
139    pub raw: String,
140    pub token: String,
141    pub family: String,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
145enum SnapshotScope {
146    Ephemeral,
147    Conversation(String),
148    Persistent { ttl_secs: u64 },
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
152struct SnapshotPayload {
153    scope: SnapshotScope,
154    #[serde(deserialize_with = "deserialize_session_hex")]
155    session_hex: String,
156    entries: Vec<SnapshotEntry>,
157    #[serde(default)]
158    issued_at: u64,
159    #[serde(default)]
160    next_by_class: Vec<(PiiClass, usize)>,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    document: Option<DocumentExtension>,
163}
164
165#[derive(Debug, Clone)]
166pub(crate) struct PrefixCacheHit {
167    pub raw_len: usize,
168    pub clean_text: String,
169    pub manifest: Vec<gaze_types::EmittedTokenSpan>,
170}
171
172#[derive(Debug, Clone)]
173struct PrefixCacheEntry {
174    raw: String,
175    clean_text: String,
176    manifest: Vec<gaze_types::EmittedTokenSpan>,
177}
178
179/// Owns the token manifest for one pseudonym namespace.
180///
181/// A `Session` holds the bidirectional map between PII values and their pseudonymous tokens.
182/// Create one per conversation and thread it through every [`Pipeline::redact`] call.
183///
184/// A `Session` is the boundary of a pseudonym namespace. Each new `Session`
185/// starts with fresh per-class counters (`Person_1`, `Email_1`, etc.) and a
186/// fresh `session_hex` prefix. Two Sessions never share `next_by_class`,
187/// `token_by_value`, or `value_by_token`, regardless of `Scope` variant.
188///
189/// The [`Scope`] variant chooses *persistence*, not *isolation*:
190///
191/// - `Scope::Ephemeral` is process-bound. Its namespace lives until the
192///   Session is dropped. Use it for ad-hoc one-off redaction. Do not share one
193///   `Ephemeral` Session across logical conversations if those conversations
194///   should have independent counters and value-to-token mappings.
195/// - `Scope::Conversation(String)` is a keyed namespace that can be re-opened
196///   across process restarts when the adopter stores and imports the session
197///   snapshot. Use it for per-conversation chat or per-thread agents.
198/// - `Scope::Persistent { .. }` is a long-lived namespace with a TTL encoded in
199///   exported snapshots.
200///
201/// Picking the wrong variant does not break single-call redaction correctness,
202/// but it can produce cross-call linkability: identical raw values fed into one
203/// Session always map to the same pseudonym, so downstream consumers can link
204/// mentions across contexts that the adopter intended to keep independent.
205///
206/// # Restore workflow
207///
208/// 1. Call [`Pipeline::redact`] - returns a [`gaze_types::CleanDocument`] and updates the session
209///    map.
210/// 2. Call [`Session::export`] to produce a [`SensitiveSnapshot`].
211/// 3. Persist the raw bytes (`snapshot.into_bytes()`) **encrypted at rest** - they contain
212///    original PII.
213/// 4. Send only the cleaned text to the LLM.
214/// 5. After the LLM responds, call [`Session::import`] with `SensitiveSnapshot::from(bytes)`.
215/// 6. For each token in the LLM response, call [`Session::restore_strict`] (or
216///    [`Session::restore`]).
217///
218/// There is no `Pipeline::restore_text` method - full-text restore is performed by scanning tokens
219/// with [`crate::token_shape::pattern`] and calling `restore_strict` per token.
220///
221/// Document workflows use the same restore root. Call [`Session::export_with_extension`] only when
222/// writing a `gaze-document` bundle that needs signed integrity hashes and codec provenance; plain
223/// text adopters should keep using [`Session::export`].
224///
225/// # Round-trip example
226///
227/// ```rust
228/// use gaze::{
229///     token_shape, Action, ClassRule, CleanDocument, DefaultRule, Detection, Detector, PiiClass,
230///     Pipeline, RawDocument, Scope, SensitiveSnapshot, Session,
231/// };
232///
233/// struct ExampleEmailDetector;
234///
235/// impl Detector for ExampleEmailDetector {
236///     fn detect(&self, input: &str) -> Vec<Detection> {
237///         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)
238///         input
239///             .find(email)
240///             .map(|start| Detection::new(start..start + email.len(), PiiClass::Email, "docs"))
241///             .into_iter()
242///             .collect()
243///     }
244/// }
245///
246/// let pipeline = Pipeline::builder()
247///     .detector(ExampleEmailDetector)
248///     .rule(ClassRule::new(PiiClass::Email, Action::Tokenize))
249///     .rule(DefaultRule::new(Action::Preserve))
250///     .build()?;
251/// let session = Session::new(Scope::Conversation("conv-1".into()))?;
252///
253/// let CleanDocument::Text(clean) = pipeline.redact(
254///     &session,
255///     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)
256/// )? else {
257///     panic!("text variant expected");
258/// };
259///
260/// // Export before sending clean text to the LLM. Persist `blob` encrypted at rest.
261/// let snapshot = session.export()?;
262/// let blob: Vec<u8> = snapshot.into_bytes();
263///
264/// // Restore on the owner side after the LLM responds.
265/// let restored_session = Session::import(SensitiveSnapshot::from(blob))?;
266/// let mut restored = String::new();
267/// let mut last = 0;
268/// for m in token_shape::pattern().find_iter(&clean) {
269///     restored.push_str(&clean[last..m.start()]);
270///     restored.push_str(&restored_session.restore_strict(m.as_str())?);
271///     last = m.end();
272/// }
273/// restored.push_str(&clean[last..]);
274/// 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)
275/// # Ok::<(), Box<dyn std::error::Error>>(())
276/// ```
277///
278/// See: `docs/explanation/core/session-contract.md` for the full architecture contract.
279///
280/// [`Pipeline::redact`]: crate::Pipeline::redact
281// intentionally not Debug: contains session signing key and token manifest
282pub struct Session {
283    scope: Scope,
284    session_hex: [u8; 4],
285    audit_session_id: String,
286    next_by_class: DashMap<PiiClass, usize>,
287    token_by_value: DashMap<TokenKey, String>,
288    value_by_token: DashMap<String, String>,
289    prefix_cache: DashMap<u64, PrefixCacheEntry>,
290    restore_regex_cache: RwLock<Option<(usize, Arc<Regex>)>>,
291    signing_key: SessionKey,
292}
293
294impl Session {
295    pub fn new(scope: Scope) -> Result<Self> {
296        Ok(Self {
297            scope,
298            session_hex: random_session_hex(),
299            audit_session_id: new_audit_session_id(),
300            next_by_class: DashMap::new(),
301            token_by_value: DashMap::new(),
302            value_by_token: DashMap::new(),
303            prefix_cache: DashMap::new(),
304            restore_regex_cache: RwLock::new(None),
305            signing_key: SessionKey::generate()?,
306        })
307    }
308
309    pub fn from_policy(policy: &Policy) -> Result<Self> {
310        Self::from_policy_with_ttl_override(policy, None)
311    }
312
313    pub fn from_policy_with_ttl_override(
314        policy: &Policy,
315        ttl_secs_override: Option<u64>,
316    ) -> Result<Self> {
317        let scope = match policy.session.scope {
318            SessionScope::Ephemeral => Scope::Ephemeral,
319            SessionScope::Conversation => Scope::Conversation("cli".to_string()),
320            SessionScope::Persistent => {
321                let ttl_secs = ttl_secs_override
322                    .or(policy.session.ttl_secs)
323                    .unwrap_or(DEFAULT_PERSISTENT_TTL_SECS);
324                Scope::Persistent {
325                    ttl: Duration::from_secs(ttl_secs),
326                }
327            }
328        };
329
330        Self::new(scope)
331    }
332
333    pub fn tokenize(&self, class: &PiiClass, raw: &str) -> Result<String> {
334        self.tokenize_with_family(DEFAULT_COUNTER_FAMILY, class, raw)
335    }
336
337    pub fn tokenize_with_family(
338        &self,
339        family: &str,
340        class: &PiiClass,
341        raw: &str,
342    ) -> Result<String> {
343        self.intern_mapping(Some(family), class, raw, |index| {
344            format!("<{}:{}_{}>", self.session_hex(), class.class_name(), index)
345        })
346    }
347
348    pub fn format_preserving_fake(&self, class: &PiiClass, raw: &str) -> Result<String> {
349        self.intern_mapping(None, class, raw, |index| match class {
350            PiiClass::Email => format!("email{index}.{}@gaze-fake.invalid", self.session_hex()),
351            PiiClass::Name | PiiClass::Location | PiiClass::Organization => format!(
352                "{}:{}_{}",
353                self.session_hex(),
354                class.class_name().to_ascii_lowercase(),
355                index
356            ),
357            // Lowercasing preserves the dedicated `custom:` sentinel namespace
358            // for format-preserving fakes, so restore can detect them too.
359            PiiClass::Custom(name) => format!("{}:custom:{name}_{index}", self.session_hex()),
360        })
361    }
362
363    fn intern_mapping<F>(
364        &self,
365        family: Option<&str>,
366        class: &PiiClass,
367        raw: &str,
368        build: F,
369    ) -> Result<String>
370    where
371        F: FnOnce(usize) -> String,
372    {
373        let family_key = family.unwrap_or(DEFAULT_COUNTER_FAMILY);
374        let key = TokenKey {
375            family: family_key.to_string(),
376            class: class.clone(),
377            raw: raw.to_string(),
378        };
379        match self.token_by_value.entry(key) {
380            Entry::Occupied(existing) => Ok(existing.get().clone()),
381            Entry::Vacant(vacant) => {
382                let token = {
383                    let mut next = self.next_by_class.entry(class.clone()).or_insert(0);
384                    *next += 1;
385                    build(*next)
386                };
387
388                vacant.insert(token.clone());
389                self.value_by_token.insert(token.clone(), raw.to_string());
390                Ok(token)
391            }
392        }
393    }
394
395    /// Enumerate every live token string emitted by this session.
396    ///
397    /// Intended for restore-side callers that need to build an exact-literal
398    /// alternation regex over the session map (Pass 1 of the two-pass restore
399    /// strategy): replacing token-shaped strings via
400    /// a class-shape regex alone is unsafe because it either (a) straddles
401    /// word boundaries into adjacent text, or (b) misses lowercase
402    /// FormatPreserve shapes like `location_1`. Feeding these exact strings
403    /// into `regex::escape` and sorting longest-first avoids both pitfalls.
404    ///
405    /// Returned order is unspecified — callers that rely on longest-first
406    /// matching must sort the returned vector themselves.
407    pub fn tokens(&self) -> Vec<String> {
408        self.value_by_token
409            .iter()
410            .map(|entry| entry.key().clone())
411            .collect()
412    }
413
414    pub(crate) fn restore_regex(&self) -> Result<Option<Arc<Regex>>> {
415        let token_count = self.value_by_token.len();
416        if token_count == 0 {
417            return Ok(None);
418        }
419
420        {
421            let cache = self.restore_regex_cache_read();
422            if let Some((cached_count, cached_regex)) = cache.as_ref() {
423                if *cached_count == token_count {
424                    return Ok(Some(Arc::clone(cached_regex)));
425                }
426            }
427        }
428
429        let mut tokens = self.tokens();
430        if tokens.is_empty() {
431            return Ok(None);
432        }
433        tokens.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b)));
434        let pattern = tokens
435            .iter()
436            .map(|token| {
437                let escaped = regex::escape(token);
438                if token.starts_with('<') && token.ends_with('>') {
439                    escaped
440                } else {
441                    format!(r"\b(?:{escaped})\b")
442                }
443            })
444            .collect::<Vec<_>>()
445            .join("|");
446        let regex = Arc::new(Regex::new(&pattern).map_err(Error::InvalidRegex)?);
447        let cached_count = tokens.len();
448        *self.restore_regex_cache_write() = Some((cached_count, Arc::clone(&regex)));
449        Ok(Some(regex))
450    }
451
452    fn restore_regex_cache_read(&self) -> RwLockReadGuard<'_, Option<(usize, Arc<Regex>)>> {
453        self.restore_regex_cache
454            .read()
455            .unwrap_or_else(|poisoned| poisoned.into_inner())
456    }
457
458    fn restore_regex_cache_write(&self) -> RwLockWriteGuard<'_, Option<(usize, Arc<Regex>)>> {
459        self.restore_regex_cache
460            .write()
461            .unwrap_or_else(|poisoned| poisoned.into_inner())
462    }
463
464    pub fn contains_token(&self, token: &str) -> bool {
465        self.value_by_token.contains_key(token)
466    }
467
468    pub fn session_hex(&self) -> String {
469        hex::encode(self.session_hex)
470    }
471
472    pub fn audit_session_id(&self) -> &str {
473        &self.audit_session_id
474    }
475
476    pub(crate) fn lookup_prefix_cache(&self, text: &str) -> Option<PrefixCacheHit> {
477        self.prefix_cache
478            .iter()
479            .filter_map(|entry| {
480                let cached = entry.value();
481                (text.starts_with(&cached.raw) && text.is_char_boundary(cached.raw.len())).then(
482                    || PrefixCacheHit {
483                        raw_len: cached.raw.len(),
484                        clean_text: cached.clean_text.clone(),
485                        manifest: cached.manifest.clone(),
486                    },
487                )
488            })
489            .max_by_key(|hit| hit.raw_len)
490    }
491
492    pub(crate) fn store_prefix_cache(
493        &self,
494        raw: &str,
495        clean_text: &str,
496        manifest: &[gaze_types::EmittedTokenSpan],
497    ) {
498        if raw.is_empty() {
499            return;
500        }
501        if self.prefix_cache.len() >= 64 {
502            self.prefix_cache.clear();
503        }
504        self.prefix_cache.insert(
505            prefix_cache_hash(raw),
506            PrefixCacheEntry {
507                raw: raw.to_string(),
508                clean_text: clean_text.to_string(),
509                manifest: manifest.to_vec(),
510            },
511        );
512    }
513
514    pub fn snapshot_entries(&self) -> Vec<SessionSnapshotEntry> {
515        self.token_by_value
516            .iter()
517            .map(|entry| SessionSnapshotEntry {
518                family: entry.key().family.clone(),
519                class: entry.key().class.clone(),
520                raw: entry.key().raw.clone(),
521                token: entry.value().clone(),
522            })
523            .collect()
524    }
525
526    // Original byte spans are preserved by recognizer normalizers per
527    // research-855 §Rulepack > Normalization (axis-2 invariant).
528    pub fn restore_strict(&self, token: &str) -> Result<String> {
529        self.value_by_token
530            .get(token)
531            .map(|value| value.value().clone())
532            .ok_or_else(|| unknown_token_error(token))
533    }
534
535    pub fn restore_strict_text(&self, text: &str) -> std::result::Result<String, RestoreError> {
536        let tokens = strict_restore_tokens(text)?;
537        for token in &tokens {
538            if !self.value_by_token.contains_key(token.parsed.raw.as_str()) {
539                return Err(unknown_token_error(token.parsed.raw.as_str()));
540            }
541        }
542
543        let mut restored = String::with_capacity(text.len());
544        let mut cursor = 0usize;
545        for token in tokens {
546            restored.push_str(&text[cursor..token.start]);
547            let raw = self
548                .value_by_token
549                .get(token.parsed.raw.as_str())
550                .expect("validated manifest token must remain present");
551            restored.push_str(raw.value());
552            cursor = token.end;
553        }
554        restored.push_str(&text[cursor..]);
555        Ok(restored)
556    }
557
558    pub fn restore_strict_text_with_events(
559        &self,
560        text: &str,
561    ) -> std::result::Result<(String, Vec<RestoreEvent>), RestoreError> {
562        let events = self.restore_boundary_events(text);
563        let restored = self.restore_strict_text(text)?;
564        Ok((restored, events))
565    }
566
567    pub fn restore_boundary_events(&self, text: &str) -> Vec<RestoreEvent> {
568        restore_boundary_events(text, &authorized_structural_values(self))
569    }
570
571    pub fn restore(&self, token: &str) -> Option<String> {
572        self.value_by_token
573            .get(token)
574            .map(|value| value.value().clone())
575    }
576
577    pub fn export(&self) -> Result<SensitiveSnapshot> {
578        self.export_payload(None)
579    }
580
581    /// Export a document-extended snapshot for `gaze-document` bundle manifests.
582    ///
583    /// Use this instead of [`Session::export`] when writing a document bundle with
584    /// `<base>-agent/` files (`clean.md`, `layout.json`, `report.json`, optional
585    /// `preview-redacted.png`) plus owner-only `<base>-owner/manifest.bin`. The supplied
586    /// [`DocumentExtension`] is serialized inside the signed snapshot payload, so its hashes and
587    /// codec audit rows become the integrity root for the agent-facing files. Text-only adopters
588    /// should use [`Session::export`]. Current snapshots emit v5 so older readers fail closed
589    /// before restore while the signature binds the emitted envelope bytes.
590    ///
591    /// ```rust
592    /// use gaze::{
593    ///     CodecAuditRow, CodecCapabilitySet, DocumentExtension, ExtractionDensityPolicy, Scope,
594    ///     Session, TextOrigin,
595    /// };
596    ///
597    /// let session = Session::new(Scope::Conversation("doc-1".to_string()))?;
598    /// let mut codec = CodecAuditRow::new(
599    ///     "gaze.codec.pdf",
600    ///     "0.7.0",
601    ///     "application/pdf",
602    ///     TextOrigin::Hybrid,
603    /// );
604    /// codec.advertised = CodecCapabilitySet::new(true, true, true, false);
605    /// codec.delivered = CodecCapabilitySet::new(true, true, false, false);
606    /// codec.extraction_density_policy = ExtractionDensityPolicy::Required(1.0);
607    /// let extension = DocumentExtension::builder(1)
608    ///     .clean_md_sha256([1; 32])
609    ///     .layout_json_sha256([2; 32])
610    ///     .report_json_sha256([3; 32])
611    ///     .page_count(4)
612    ///     .audit_session_id(session.audit_session_id())
613    ///     .codec_audit(vec![codec])
614    ///     .build()?;
615    ///
616    /// let manifest_bin = session.export_with_extension(extension)?.into_bytes();
617    /// # assert_eq!(manifest_bin[0], 5);
618    /// # Ok::<(), Box<dyn std::error::Error>>(())
619    /// ```
620    ///
621    /// Failure modes match [`Session::export`]: ephemeral sessions return
622    /// [`Error::ExportForbidden`], JSON encoding failures return [`Error::SnapshotDecode`], and
623    /// empty integrity-binding hashes or audit session ids return
624    /// [`Error::EmptyDocumentIntegrity`]. `page_count == 0` is allowed because text-only or
625    /// degenerate bundles may have no pages while still binding file hashes. Callers must treat the
626    /// returned [`SensitiveSnapshot`] as owner-only because it carries the full token-to-PII restore
627    /// map. Bundle layout details live in `docs/explanation/document/document-extension.md`.
628    pub fn export_with_extension(&self, extension: DocumentExtension) -> Result<SensitiveSnapshot> {
629        if extension.clean_md_sha256 == [0; 32]
630            || extension.layout_json_sha256 == [0; 32]
631            || extension.report_json_sha256 == [0; 32]
632            || extension.audit_session_id.is_empty()
633        {
634            return Err(Error::EmptyDocumentIntegrity);
635        }
636        self.export_payload(Some(extension))
637    }
638
639    fn export_payload(&self, document: Option<DocumentExtension>) -> Result<SensitiveSnapshot> {
640        if matches!(self.scope, Scope::Ephemeral) {
641            return Err(Error::ExportForbidden);
642        }
643
644        // If the host clock is before the Unix epoch, preserve compatibility by
645        // exporting `issued_at = 0` rather than failing snapshot export.
646        let issued_at = SystemTime::now()
647            .duration_since(UNIX_EPOCH)
648            .map(|duration| duration.as_secs())
649            .unwrap_or(0);
650
651        let payload = SnapshotPayload {
652            scope: snapshot_scope(&self.scope),
653            session_hex: self.session_hex(),
654            entries: self
655                .snapshot_entries()
656                .into_iter()
657                .map(|entry| SnapshotEntry {
658                    family: entry.family,
659                    class: entry.class,
660                    raw: entry.raw,
661                    token: entry.token,
662                })
663                .collect(),
664            next_by_class: self
665                .next_by_class
666                .iter()
667                .map(|entry| (entry.key().clone(), *entry.value()))
668                .collect(),
669            issued_at,
670            document,
671        };
672        let payload_bytes = serde_json::to_vec(&payload).map_err(Error::SnapshotDecode)?;
673        let version = SNAPSHOT_VERSION_V5;
674        let signing_key = self.signing_key.signing_key();
675        let verifying_key = signing_key.verifying_key();
676        let verifying_key_bytes = verifying_key.to_bytes();
677        let signing_preimage =
678            snapshot_signing_preimage(version, &verifying_key_bytes, &payload_bytes);
679        let signature = signing_key.sign(&signing_preimage);
680
681        let mut snapshot = Vec::with_capacity(1 + 32 + 64 + payload_bytes.len());
682        snapshot.push(version);
683        snapshot.extend_from_slice(&verifying_key_bytes);
684        snapshot.extend_from_slice(&signature.to_bytes());
685        snapshot.extend_from_slice(&payload_bytes);
686        Ok(SensitiveSnapshot(snapshot))
687    }
688
689    pub fn import(snapshot: SensitiveSnapshot) -> Result<Self> {
690        let bytes = snapshot.0;
691        if bytes.len() < 97 {
692            return Err(Error::InvalidSnapshotSignature);
693        }
694        let version = bytes[0];
695        if version != SNAPSHOT_VERSION_V2
696            && version != SNAPSHOT_VERSION_V3
697            && version != SNAPSHOT_VERSION_V4
698            && version != SNAPSHOT_VERSION_V5
699        {
700            return Err(Error::InvalidSnapshotVersion(version));
701        }
702
703        let verifying_key = VerifyingKey::from_bytes(
704            bytes[1..33]
705                .try_into()
706                .map_err(|_| Error::InvalidSnapshotSignature)?,
707        )
708        .map_err(|_| Error::InvalidSnapshotSignature)?;
709        let signature = Signature::from_bytes(
710            bytes[33..97]
711                .try_into()
712                .map_err(|_| Error::InvalidSnapshotSignature)?,
713        );
714        let payload_bytes = &bytes[97..];
715        let verify_preimage;
716        let signed_bytes = if version >= SNAPSHOT_VERSION_V5 {
717            let verifying_key_bytes: [u8; 32] = bytes[1..33]
718                .try_into()
719                .map_err(|_| Error::InvalidSnapshotSignature)?;
720            verify_preimage =
721                snapshot_signing_preimage(version, &verifying_key_bytes, payload_bytes);
722            verify_preimage.as_slice()
723        } else {
724            payload_bytes
725        };
726        verifying_key
727            .verify(signed_bytes, &signature)
728            .map_err(|_| Error::InvalidSnapshotSignature)?;
729
730        let payload = decode_snapshot_payload(payload_bytes)?;
731        validate_entry_prefixes(&payload)?;
732        let session_hex = session_hex_bytes(&payload.session_hex)?;
733        let scope = scope_from_snapshot(payload.scope);
734        let issued_at = payload.issued_at;
735        if let Scope::Persistent { ttl } = &scope {
736            let ttl_secs = ttl.as_secs();
737            if issued_at > 0 {
738                let now = SystemTime::now()
739                    .duration_since(UNIX_EPOCH)
740                    .map(|duration| duration.as_secs())
741                    .unwrap_or(0);
742                if issued_at > now.saturating_add(60) {
743                    return Err(Error::InvalidSnapshotSignature);
744                }
745                if now.saturating_sub(issued_at) > ttl_secs {
746                    return Err(Error::BlobExpired {
747                        issued_at,
748                        ttl_secs,
749                    });
750                }
751            }
752        }
753
754        let session = Self {
755            scope,
756            session_hex,
757            audit_session_id: new_audit_session_id(),
758            next_by_class: DashMap::new(),
759            token_by_value: DashMap::new(),
760            value_by_token: DashMap::new(),
761            prefix_cache: DashMap::new(),
762            restore_regex_cache: RwLock::new(None),
763            signing_key: SessionKey::generate()?,
764        };
765        for entry in payload.entries {
766            session.token_by_value.insert(
767                TokenKey {
768                    family: entry.family,
769                    class: entry.class.clone(),
770                    raw: entry.raw.clone(),
771                },
772                entry.token.clone(),
773            );
774            session
775                .value_by_token
776                .insert(entry.token.clone(), entry.raw);
777            if let Some(index) = parse_token_index(&entry.token) {
778                let mut next = session.next_by_class.entry(entry.class).or_insert(0);
779                if *next < index {
780                    *next = index;
781                }
782            }
783        }
784        // Authoritative counter state from the exporter. Overrides any
785        // index we reconstructed from parseable token suffixes above so
786        // that format-preserving tokens (e.g. `email1.<session>@gaze-fake.invalid`)
787        // also round-trip safely.
788        for (class, index) in payload.next_by_class {
789            let mut next = session.next_by_class.entry(class).or_insert(0);
790            if *next < index {
791                *next = index;
792            }
793        }
794        Ok(session)
795    }
796}
797
798fn default_counter_family() -> String {
799    DEFAULT_COUNTER_FAMILY.to_string()
800}
801
802fn prefix_cache_hash(raw: &str) -> u64 {
803    let mut hasher = DefaultHasher::new();
804    raw.hash(&mut hasher);
805    hasher.finish()
806}
807
808fn new_audit_session_id() -> String {
809    let millis = SystemTime::now()
810        .duration_since(UNIX_EPOCH)
811        .map(|duration| duration.as_millis().min(0xffff_ffff_ffff) as u64)
812        .unwrap_or(0);
813    let mut bytes = [0_u8; 16];
814    rand::thread_rng().fill_bytes(&mut bytes[6..]);
815    bytes[0] = (millis >> 40) as u8;
816    bytes[1] = (millis >> 32) as u8;
817    bytes[2] = (millis >> 24) as u8;
818    bytes[3] = (millis >> 16) as u8;
819    bytes[4] = (millis >> 8) as u8;
820    bytes[5] = millis as u8;
821    bytes[6] = (bytes[6] & 0x0f) | 0x70;
822    bytes[8] = (bytes[8] & 0x3f) | 0x80;
823    format!(
824        "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
825        bytes[0],
826        bytes[1],
827        bytes[2],
828        bytes[3],
829        bytes[4],
830        bytes[5],
831        bytes[6],
832        bytes[7],
833        bytes[8],
834        bytes[9],
835        bytes[10],
836        bytes[11],
837        bytes[12],
838        bytes[13],
839        bytes[14],
840        bytes[15],
841    )
842}
843
844struct SessionKey {
845    secret: SecretBox<[u8; 32]>,
846    protection: MemoryProtection,
847}
848
849impl SessionKey {
850    fn generate() -> Result<Self> {
851        let secret = SecretBox::init_with_mut(|bytes: &mut [u8; 32]| {
852            rand::thread_rng().fill_bytes(bytes);
853        });
854        let protection = MemoryProtection::best_effort(secret.expose_secret().as_ptr(), 32);
855        Ok(Self { secret, protection })
856    }
857
858    fn signing_key(&self) -> SigningKey {
859        SigningKey::from_bytes(self.secret.expose_secret())
860    }
861}
862
863impl Drop for SessionKey {
864    fn drop(&mut self) {
865        self.protection.unlock();
866    }
867}
868
869struct MemoryProtection {
870    addr: usize,
871    len: usize,
872    locked: bool,
873}
874
875impl MemoryProtection {
876    fn best_effort(ptr: *const u8, len: usize) -> Self {
877        let locked = lock_memory(ptr, len);
878        advise_dontdump(ptr, len);
879        Self {
880            addr: ptr as usize,
881            len,
882            locked,
883        }
884    }
885
886    fn unlock(&mut self) {
887        if self.locked {
888            unlock_memory(self.addr as *const u8, self.len);
889            self.locked = false;
890        }
891    }
892}
893
894fn lock_memory(ptr: *const u8, len: usize) -> bool {
895    #[cfg(unix)]
896    unsafe {
897        if libc::mlock(ptr.cast(), len) == 0 {
898            return true;
899        }
900        tracing::warn!(
901            error = %std::io::Error::last_os_error(),
902            "session key mlock failed; continuing with unlocked key material"
903        );
904    }
905
906    false
907}
908
909fn unlock_memory(ptr: *const u8, len: usize) {
910    #[cfg(unix)]
911    unsafe {
912        let _ = libc::munlock(ptr.cast(), len);
913    }
914}
915
916fn advise_dontdump(_ptr: *const u8, _len: usize) {
917    #[cfg(any(target_os = "linux", target_os = "android"))]
918    unsafe {
919        let ptr = _ptr;
920        let len = _len;
921        let page_size = libc::sysconf(libc::_SC_PAGESIZE);
922        if page_size <= 0 {
923            return;
924        }
925        let page_size = page_size as usize;
926        let start = (ptr as usize) & !(page_size - 1);
927        let end = (ptr as usize + len).div_ceil(page_size) * page_size;
928        let aligned_len = end.saturating_sub(start);
929        if aligned_len == 0 {
930            return;
931        }
932        let _ = libc::madvise(start as *mut libc::c_void, aligned_len, libc::MADV_DONTDUMP);
933    }
934}
935
936fn snapshot_scope(scope: &Scope) -> SnapshotScope {
937    match scope {
938        Scope::Ephemeral => SnapshotScope::Ephemeral,
939        Scope::Conversation(id) => SnapshotScope::Conversation(id.clone()),
940        Scope::Persistent { ttl } => SnapshotScope::Persistent {
941            ttl_secs: ttl.as_secs(),
942        },
943    }
944}
945
946fn scope_from_snapshot(scope: SnapshotScope) -> Scope {
947    match scope {
948        SnapshotScope::Ephemeral => Scope::Ephemeral,
949        SnapshotScope::Conversation(id) => Scope::Conversation(id),
950        SnapshotScope::Persistent { ttl_secs } => Scope::Persistent {
951            ttl: Duration::from_secs(ttl_secs),
952        },
953    }
954}
955
956#[derive(Debug, Clone, PartialEq, Eq)]
957struct StructuralFinding {
958    class: PiiClass,
959    raw: String,
960    canonical: String,
961    location: Range<usize>,
962}
963
964fn authorized_structural_values(session: &Session) -> BTreeSet<(PiiClass, String)> {
965    let mut authorized = BTreeSet::new();
966    for entry in session.snapshot_entries() {
967        for finding in structural_findings(&entry.raw) {
968            authorized.insert((finding.class, finding.canonical));
969        }
970    }
971    authorized
972}
973
974fn restore_boundary_events(
975    text: &str,
976    authorized: &BTreeSet<(PiiClass, String)>,
977) -> Vec<RestoreEvent> {
978    structural_findings(text)
979        .into_iter()
980        .map(|finding| RestoreEvent {
981            kind: if authorized.contains(&(finding.class.clone(), finding.canonical)) {
982                RestoreEventKind::ManifestBypass
983            } else {
984                RestoreEventKind::FreshPiiDetected
985            },
986            class: finding.class,
987            raw_sha256: raw_sha256(&finding.raw),
988            location: finding.location,
989        })
990        .collect()
991}
992
993fn structural_findings(text: &str) -> Vec<StructuralFinding> {
994    let mut findings = Vec::new();
995    collect_email_findings(text, &mut findings);
996    collect_phone_findings(text, &mut findings);
997    collect_iban_findings(text, &mut findings);
998    collect_credit_card_findings(text, &mut findings);
999    collect_api_key_findings(text, &mut findings);
1000    findings.sort_by(|left, right| {
1001        left.location
1002            .start
1003            .cmp(&right.location.start)
1004            .then_with(|| {
1005                (right.location.end - right.location.start)
1006                    .cmp(&(left.location.end - left.location.start))
1007            })
1008    });
1009    let mut accepted = Vec::new();
1010    for finding in findings {
1011        if accepted.iter().any(|existing: &StructuralFinding| {
1012            finding.location.start < existing.location.end
1013                && existing.location.start < finding.location.end
1014        }) {
1015            continue;
1016        }
1017        accepted.push(finding);
1018    }
1019    accepted
1020}
1021
1022fn collect_email_findings(text: &str, findings: &mut Vec<StructuralFinding>) {
1023    for matched in email_pattern().find_iter(text) {
1024        if !restore_email_boundary_accepts(text, &matched.range()) {
1025            continue;
1026        }
1027        let raw = matched.as_str();
1028        if !is_basic_restore_email(raw) {
1029            continue;
1030        }
1031        findings.push(StructuralFinding {
1032            class: PiiClass::Email,
1033            raw: raw.to_string(),
1034            canonical: raw.to_ascii_lowercase(),
1035            location: matched.range(),
1036        });
1037    }
1038}
1039
1040fn collect_phone_findings(text: &str, findings: &mut Vec<StructuralFinding>) {
1041    for matched in phone_pattern().find_iter(text) {
1042        let raw = matched.as_str();
1043        findings.push(StructuralFinding {
1044            class: PiiClass::custom("phone"),
1045            raw: raw.to_string(),
1046            canonical: ascii_digits(raw),
1047            location: matched.range(),
1048        });
1049    }
1050}
1051
1052fn collect_iban_findings(text: &str, findings: &mut Vec<StructuralFinding>) {
1053    for matched in iban_pattern().find_iter(text) {
1054        let raw = matched.as_str();
1055        let canonical = iban_canonicalize(raw);
1056        if !iban_mod97_check(&canonical) {
1057            continue;
1058        }
1059        findings.push(StructuralFinding {
1060            class: PiiClass::custom("iban"),
1061            raw: raw.to_string(),
1062            canonical,
1063            location: matched.range(),
1064        });
1065    }
1066}
1067
1068fn collect_credit_card_findings(text: &str, findings: &mut Vec<StructuralFinding>) {
1069    for matched in card_pattern().find_iter(text) {
1070        let raw = matched.as_str();
1071        let canonical = ascii_digits(raw);
1072        if !luhn_check(&canonical) {
1073            continue;
1074        }
1075        findings.push(StructuralFinding {
1076            class: PiiClass::custom("credit_card"),
1077            raw: raw.to_string(),
1078            canonical,
1079            location: matched.range(),
1080        });
1081    }
1082}
1083
1084fn collect_api_key_findings(text: &str, findings: &mut Vec<StructuralFinding>) {
1085    for matched in api_key_pattern().find_iter(text) {
1086        let raw = matched.as_str();
1087        findings.push(StructuralFinding {
1088            class: PiiClass::custom("api_key"),
1089            raw: raw.to_string(),
1090            canonical: raw.to_string(),
1091            location: matched.range(),
1092        });
1093    }
1094}
1095
1096fn email_pattern() -> &'static Regex {
1097    static PATTERN: OnceLock<Regex> = OnceLock::new();
1098    PATTERN.get_or_init(|| {
1099        Regex::new(r"(?i)[a-z0-9_][a-z0-9._%+\-]*@[a-z0-9.\-]+\.[a-z]{2,}")
1100            .expect("email restore DLP regex compiles")
1101    })
1102}
1103
1104fn phone_pattern() -> &'static Regex {
1105    static PATTERN: OnceLock<Regex> = OnceLock::new();
1106    PATTERN.get_or_init(|| {
1107        Regex::new(
1108            r"(?x)(?:
1109                \+?1[\s.-]+555[\s.-]*01\d{2}
1110                |\+44[\s.-]*7700[\s.-]*900\d{3}
1111                |\+49[\s.-]*1555[\s.-]*0112233
1112            )\b",
1113        )
1114        .expect("phone restore DLP regex compiles")
1115    })
1116}
1117
1118fn iban_pattern() -> &'static Regex {
1119    static PATTERN: OnceLock<Regex> = OnceLock::new();
1120    PATTERN.get_or_init(|| {
1121        Regex::new(r"\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){2,7} ?[A-Z0-9]{1,4}\b")
1122            .expect("IBAN restore DLP regex compiles")
1123    })
1124}
1125
1126fn card_pattern() -> &'static Regex {
1127    static PATTERN: OnceLock<Regex> = OnceLock::new();
1128    PATTERN.get_or_init(|| {
1129        Regex::new(r"\b\d(?:[\s-]?\d){12,18}\b").expect("credit-card restore DLP regex compiles")
1130    })
1131}
1132
1133fn api_key_pattern() -> &'static Regex {
1134    static PATTERN: OnceLock<Regex> = OnceLock::new();
1135    PATTERN.get_or_init(|| {
1136        Regex::new(r"\b(?:sk-test-[A-Za-z0-9]{20,}|gaze_test_[A-Za-z0-9]{16,})\b")
1137            .expect("API-key restore DLP regex compiles")
1138    })
1139}
1140
1141fn is_basic_restore_email(input: &str) -> bool {
1142    let Some((local, domain)) = input.split_once('@') else {
1143        return false;
1144    };
1145    !local.is_empty() && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.')
1146}
1147
1148fn restore_email_boundary_accepts(input: &str, span: &std::ops::Range<usize>) -> bool {
1149    let previous_ok = input[..span.start]
1150        .chars()
1151        .next_back()
1152        .is_none_or(|ch| !is_ascii_email_continuation(ch));
1153    let next_ok = input[span.end..]
1154        .chars()
1155        .next()
1156        .is_none_or(|ch| !is_ascii_email_continuation(ch));
1157
1158    previous_ok && next_ok
1159}
1160
1161fn is_ascii_email_continuation(ch: char) -> bool {
1162    ch.is_ascii_alphanumeric() || ch == '_'
1163}
1164
1165fn ascii_digits(input: &str) -> String {
1166    input.chars().filter(char::is_ascii_digit).collect()
1167}
1168
1169fn raw_sha256(input: &str) -> String {
1170    let digest = Sha256::digest(input.as_bytes());
1171    hex::encode(digest)
1172}
1173
1174fn iban_canonicalize(input: &str) -> String {
1175    input
1176        .chars()
1177        .filter(|ch| !ch.is_ascii_whitespace())
1178        .flat_map(char::to_uppercase)
1179        .collect()
1180}
1181
1182fn iban_mod97_check(input: &str) -> bool {
1183    let canonical = iban_canonicalize(input);
1184    if !(15..=34).contains(&canonical.len()) {
1185        return false;
1186    }
1187    if !canonical.chars().all(|ch| ch.is_ascii_alphanumeric()) {
1188        return false;
1189    }
1190
1191    let mut remainder = 0u32;
1192    for ch in canonical[4..].chars().chain(canonical[..4].chars()) {
1193        match ch {
1194            '0'..='9' => {
1195                remainder = (remainder * 10 + ch.to_digit(10).expect("digit")) % 97;
1196            }
1197            'A'..='Z' => {
1198                let value = u32::from(ch) - u32::from('A') + 10;
1199                remainder = (remainder * 10 + value / 10) % 97;
1200                remainder = (remainder * 10 + value % 10) % 97;
1201            }
1202            _ => return false,
1203        }
1204    }
1205    remainder == 1
1206}
1207
1208fn luhn_check(input: &str) -> bool {
1209    let mut digits = Vec::new();
1210    for byte in input.bytes() {
1211        if !byte.is_ascii_digit() {
1212            return false;
1213        }
1214        digits.push(byte - b'0');
1215    }
1216    if !(13..=19).contains(&digits.len()) {
1217        return false;
1218    }
1219
1220    let sum: u32 = digits
1221        .iter()
1222        .rev()
1223        .enumerate()
1224        .map(|(index, digit)| {
1225            let mut value = u32::from(*digit);
1226            if index % 2 == 1 {
1227                value *= 2;
1228                if value > 9 {
1229                    value -= 9;
1230                }
1231            }
1232            value
1233        })
1234        .sum();
1235    sum.is_multiple_of(10)
1236}
1237
1238#[derive(Debug, Clone)]
1239pub(crate) struct StrictRestoreToken {
1240    pub start: usize,
1241    pub end: usize,
1242    pub parsed: ParsedRestoreToken,
1243}
1244
1245pub(crate) fn strict_restore_tokens(text: &str) -> Result<Vec<StrictRestoreToken>> {
1246    let mut tokens = Vec::new();
1247    for matched in crate::token_shape::pattern().find_iter(text) {
1248        tokens.push(StrictRestoreToken {
1249            start: matched.start(),
1250            end: matched.end(),
1251            parsed: parsed_restore_token(matched.as_str()),
1252        });
1253    }
1254
1255    for matched in malformed_restore_token_pattern().find_iter(text) {
1256        if tokens
1257            .iter()
1258            .any(|token| matched.start() < token.end && token.start < matched.end())
1259        {
1260            continue;
1261        }
1262        return Err(unknown_token_error(matched.as_str()));
1263    }
1264
1265    tokens.sort_by_key(|token| token.start);
1266    Ok(tokens)
1267}
1268
1269pub(crate) fn unknown_token_error(raw: &str) -> Error {
1270    let parsed = parsed_restore_token(raw);
1271    Error::UnknownToken {
1272        class: parsed.class,
1273        ordinal: parsed.ordinal,
1274        raw: parsed.raw,
1275    }
1276}
1277
1278pub(crate) fn parsed_restore_token(raw: &str) -> ParsedRestoreToken {
1279    let (class, ordinal) = parse_restore_token_parts(raw)
1280        .unwrap_or_else(|| (PiiClass::Custom("unknown".to_string()), 0));
1281    ParsedRestoreToken {
1282        class,
1283        ordinal,
1284        raw: raw.to_string(),
1285    }
1286}
1287
1288fn parse_restore_token_parts(raw: &str) -> Option<(PiiClass, u32)> {
1289    if let Some(rest) = raw.strip_prefix("email") {
1290        let (ordinal, tail) = rest.split_once('.')?;
1291        if tail.ends_with("@gaze-fake.invalid") || tail.ends_with("@example.test") {
1292            return Some((PiiClass::Email, parse_ascii_ordinal(ordinal)?));
1293        }
1294    }
1295
1296    let mut body = raw.strip_prefix('<').unwrap_or(raw);
1297    body = body.strip_suffix('>').unwrap_or(body);
1298    if body.len() > 9 && body.as_bytes().get(8) == Some(&b':') && is_session_hex(&body[..8]) {
1299        body = &body[9..];
1300    }
1301
1302    if let Some(rest) = body.strip_prefix("Custom:") {
1303        let (name, ordinal) = rest.rsplit_once('_')?;
1304        return Some((
1305            PiiClass::Custom(name.to_string()),
1306            parse_ascii_ordinal(ordinal)?,
1307        ));
1308    }
1309    if let Some(rest) = body.strip_prefix("custom:") {
1310        let (name, ordinal) = rest.rsplit_once('_')?;
1311        return Some((
1312            PiiClass::Custom(name.to_string()),
1313            parse_ascii_ordinal(ordinal)?,
1314        ));
1315    }
1316
1317    let (class, ordinal) = body.rsplit_once('_')?;
1318    let ordinal = parse_ascii_ordinal(ordinal)?;
1319    let class = PiiClass::from_canonical_str(class).unwrap_or_else(|| PiiClass::custom(class));
1320    Some((class, ordinal))
1321}
1322
1323fn parse_ascii_ordinal(raw: &str) -> Option<u32> {
1324    if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
1325        return None;
1326    }
1327    raw.parse().ok()
1328}
1329
1330fn malformed_restore_token_pattern() -> &'static Regex {
1331    static PATTERN: OnceLock<Regex> = OnceLock::new();
1332    PATTERN.get_or_init(|| {
1333        Regex::new(r"<(?:[0-9a-f]{8}:)?(?:Email|Name|Location|Organization|email|name|location|organization|Custom:[a-z0-9_]*|custom:[a-z0-9_]*)_?>")
1334            .expect("malformed restore token regex must compile")
1335    })
1336}
1337
1338fn random_session_hex() -> [u8; 4] {
1339    let mut bytes = [0u8; 4];
1340    rand::thread_rng().fill_bytes(&mut bytes);
1341    bytes
1342}
1343
1344fn deserialize_session_hex<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
1345where
1346    D: Deserializer<'de>,
1347{
1348    let value = String::deserialize(deserializer)?;
1349    if is_session_hex(&value) {
1350        Ok(value)
1351    } else {
1352        Err(serde::de::Error::custom(
1353            "session_hex must be 8 lowercase hex chars",
1354        ))
1355    }
1356}
1357
1358fn is_session_hex(value: &str) -> bool {
1359    value.len() == 8
1360        && value
1361            .bytes()
1362            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1363}
1364
1365fn session_hex_bytes(value: &str) -> Result<[u8; 4]> {
1366    if !is_session_hex(value) {
1367        return Err(Error::InvalidSnapshotPayload);
1368    }
1369    let decoded = hex::decode(value).map_err(|_| Error::InvalidSnapshotPayload)?;
1370    decoded
1371        .try_into()
1372        .map_err(|_| Error::InvalidSnapshotPayload)
1373}
1374
1375fn decode_snapshot_payload(payload_bytes: &[u8]) -> Result<SnapshotPayload> {
1376    let value: JsonValue = serde_json::from_slice(payload_bytes).map_err(Error::SnapshotDecode)?;
1377    let Some(session_hex) = value.get("session_hex").and_then(JsonValue::as_str) else {
1378        return Err(Error::InvalidSnapshotPayload);
1379    };
1380    if !is_session_hex(session_hex) {
1381        return Err(Error::InvalidSnapshotPayload);
1382    }
1383    serde_json::from_value(value).map_err(Error::SnapshotDecode)
1384}
1385
1386fn validate_entry_prefixes(payload: &SnapshotPayload) -> Result<()> {
1387    for entry in &payload.entries {
1388        if !entry_token_matches_session(&entry.token, &payload.session_hex)
1389            || !crate::token_shape::starts_with_session_prefix(&entry.token)
1390        {
1391            return Err(Error::InvalidSnapshotPayload);
1392        }
1393    }
1394    Ok(())
1395}
1396
1397fn entry_token_matches_session(token: &str, session_hex: &str) -> bool {
1398    token.starts_with(&format!("<{session_hex}:"))
1399        || token.starts_with(&format!("{session_hex}:"))
1400        || (token.starts_with("email")
1401            && token
1402                .split_once('.')
1403                .and_then(|(_, rest)| rest.strip_suffix("@gaze-fake.invalid"))
1404                == Some(session_hex))
1405}
1406
1407fn parse_token_index(token: &str) -> Option<usize> {
1408    if let Some(local) = token
1409        .strip_prefix("email")
1410        .and_then(|rest| rest.split_once('.').map(|(index, _)| index))
1411    {
1412        return parse_ascii_index(local);
1413    }
1414    let suffix = token
1415        .rsplit_once('_')?
1416        .1
1417        .strip_suffix('>')
1418        .unwrap_or(token.rsplit_once('_')?.1);
1419    parse_ascii_index(suffix)
1420}
1421
1422fn parse_ascii_index(raw: &str) -> Option<usize> {
1423    if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
1424        return None;
1425    }
1426    raw.parse().ok()
1427}
1428
1429fn snapshot_signing_preimage(
1430    version: u8,
1431    verifying_key_bytes: &[u8; 32],
1432    payload_bytes: &[u8],
1433) -> Vec<u8> {
1434    let mut preimage = Vec::with_capacity(1 + verifying_key_bytes.len() + payload_bytes.len());
1435    preimage.push(version);
1436    preimage.extend_from_slice(verifying_key_bytes);
1437    preimage.extend_from_slice(payload_bytes);
1438    preimage
1439}
1440
1441#[cfg(test)]
1442mod tests {
1443    use super::*;
1444
1445    fn signed_snapshot_v03(payload: SnapshotPayload) -> SensitiveSnapshot {
1446        let payload_bytes = serde_json::to_vec(&payload).expect("serialize payload");
1447        signed_snapshot_bytes(1, &payload_bytes)
1448    }
1449
1450    fn signed_snapshot_bytes(version: u8, payload_bytes: &[u8]) -> SensitiveSnapshot {
1451        let key = SessionKey::generate().expect("session key");
1452        let signing_key = key.signing_key();
1453        let signature = signing_key.sign(payload_bytes);
1454        let verifying_key = signing_key.verifying_key();
1455
1456        let mut snapshot = Vec::with_capacity(1 + 32 + 64 + payload_bytes.len());
1457        snapshot.push(version);
1458        snapshot.extend_from_slice(&verifying_key.to_bytes());
1459        snapshot.extend_from_slice(&signature.to_bytes());
1460        snapshot.extend_from_slice(payload_bytes);
1461        SensitiveSnapshot::from(snapshot)
1462    }
1463
1464    fn signed_snapshot_v2(payload: SnapshotPayload) -> SensitiveSnapshot {
1465        let mut snapshot = signed_snapshot_v03(payload).into_bytes();
1466        snapshot[0] = SNAPSHOT_VERSION_V2;
1467        SensitiveSnapshot::from(snapshot)
1468    }
1469
1470    fn signed_snapshot(payload: SnapshotPayload) -> SensitiveSnapshot {
1471        let mut snapshot = signed_snapshot_v03(payload).into_bytes();
1472        snapshot[0] = SNAPSHOT_VERSION_V3;
1473        SensitiveSnapshot::from(snapshot)
1474    }
1475
1476    fn snapshot_payload_json(snapshot: &SensitiveSnapshot) -> JsonValue {
1477        serde_json::from_slice(&snapshot.0[97..]).expect("snapshot payload json")
1478    }
1479
1480    fn document_extension(session: &Session) -> DocumentExtension {
1481        DocumentExtension::builder(1)
1482            .clean_md_sha256([1; 32])
1483            .layout_json_sha256([2; 32])
1484            .report_json_sha256([3; 32])
1485            .page_count(1)
1486            .audit_session_id(session.audit_session_id())
1487            .build()
1488            .expect("document extension")
1489    }
1490
1491    fn legacy_v0_4_0_accepts_only_v2(snapshot: &SensitiveSnapshot) -> Result<()> {
1492        let bytes = &snapshot.0;
1493        if bytes.len() < 97 {
1494            return Err(Error::InvalidSnapshotSignature);
1495        }
1496        let version = bytes[0];
1497        if version != SNAPSHOT_VERSION_V2 {
1498            return Err(Error::InvalidSnapshotVersion(version));
1499        }
1500        Ok(())
1501    }
1502
1503    #[test]
1504    fn session_key_produces_valid_signatures() {
1505        let key = SessionKey::generate().expect("session key");
1506        let signing_key = key.signing_key();
1507        let message = b"gaze";
1508        let signature = signing_key.sign(message);
1509
1510        assert!(signing_key
1511            .verifying_key()
1512            .verify(message, &signature)
1513            .is_ok());
1514    }
1515
1516    #[test]
1517    fn import_accepts_persistent_snapshot_within_ttl() {
1518        let now = SystemTime::now()
1519            .duration_since(UNIX_EPOCH)
1520            .map(|duration| duration.as_secs())
1521            .unwrap_or(0);
1522        let snapshot = signed_snapshot(SnapshotPayload {
1523            scope: SnapshotScope::Persistent { ttl_secs: 300 },
1524            session_hex: "a7f3b8e2".to_string(),
1525            entries: Vec::new(),
1526            issued_at: now,
1527            next_by_class: Vec::new(),
1528            document: None,
1529        });
1530
1531        assert!(Session::import(snapshot).is_ok());
1532    }
1533
1534    #[test]
1535    fn import_rejects_v03_envelope_byte() {
1536        let snapshot = signed_snapshot_v03(SnapshotPayload {
1537            scope: SnapshotScope::Persistent { ttl_secs: 300 },
1538            session_hex: "a7f3b8e2".to_string(),
1539            entries: Vec::new(),
1540            issued_at: 0,
1541            next_by_class: Vec::new(),
1542            document: None,
1543        });
1544
1545        assert!(matches!(
1546            Session::import(snapshot),
1547            Err(Error::InvalidSnapshotVersion(1))
1548        ));
1549    }
1550
1551    #[test]
1552    fn import_rejects_invalid_session_hex() {
1553        let snapshot = signed_snapshot(SnapshotPayload {
1554            scope: SnapshotScope::Persistent { ttl_secs: 300 },
1555            session_hex: "A7F3B8E2".to_string(),
1556            entries: Vec::new(),
1557            issued_at: 0,
1558            next_by_class: Vec::new(),
1559            document: None,
1560        });
1561
1562        assert!(matches!(
1563            Session::import(snapshot),
1564            Err(Error::InvalidSnapshotPayload)
1565        ));
1566    }
1567
1568    #[test]
1569    fn import_rejects_manifest_entry_prefix_mismatch() {
1570        let snapshot = signed_snapshot(SnapshotPayload {
1571            scope: SnapshotScope::Persistent { ttl_secs: 300 },
1572            session_hex: "a7f3b8e2".to_string(),
1573            entries: vec![SnapshotEntry {
1574                family: DEFAULT_COUNTER_FAMILY.to_string(),
1575                class: PiiClass::Name,
1576                raw: "Dr. Schmidt".to_string(),
1577                token: "deadbeef:name_1".to_string(),
1578            }],
1579            issued_at: 0,
1580            next_by_class: Vec::new(),
1581            document: None,
1582        });
1583
1584        assert!(matches!(
1585            Session::import(snapshot),
1586            Err(Error::InvalidSnapshotPayload)
1587        ));
1588    }
1589
1590    #[test]
1591    fn import_rejects_expired_persistent_snapshot() {
1592        let now = SystemTime::now()
1593            .duration_since(UNIX_EPOCH)
1594            .map(|duration| duration.as_secs())
1595            .unwrap_or(0);
1596        let snapshot = signed_snapshot(SnapshotPayload {
1597            scope: SnapshotScope::Persistent { ttl_secs: 10 },
1598            session_hex: "a7f3b8e2".to_string(),
1599            entries: Vec::new(),
1600            issued_at: now.saturating_sub(11),
1601            next_by_class: Vec::new(),
1602            document: None,
1603        });
1604
1605        assert!(matches!(
1606            Session::import(snapshot),
1607            Err(Error::BlobExpired {
1608                issued_at,
1609                ttl_secs: 10,
1610            }) if issued_at == now.saturating_sub(11)
1611        ));
1612    }
1613
1614    #[test]
1615    fn import_accepts_legacy_persistent_snapshot_without_issued_at() {
1616        let snapshot = signed_snapshot_v2(SnapshotPayload {
1617            scope: SnapshotScope::Persistent { ttl_secs: 1 },
1618            session_hex: "a7f3b8e2".to_string(),
1619            entries: Vec::new(),
1620            issued_at: 0,
1621            next_by_class: Vec::new(),
1622            document: None,
1623        });
1624
1625        assert!(Session::import(snapshot).is_ok());
1626    }
1627
1628    #[test]
1629    fn import_v0_4_0_snapshot_version_2_succeeds_with_default_family() {
1630        let payload = serde_json::json!({
1631            "scope": { "Persistent": { "ttl_secs": 300 } },
1632            "session_hex": "a7f3b8e2",
1633            "entries": [{
1634                "class": "Name",
1635                "raw": "Dr. Schmidt",
1636                "token": "<a7f3b8e2:Name_1>"
1637            }],
1638            "issued_at": 0,
1639            "next_by_class": [["Name", 1]]
1640        });
1641        let payload_bytes = serde_json::to_vec(&payload).expect("payload");
1642        let snapshot = signed_snapshot_bytes(SNAPSHOT_VERSION_V2, &payload_bytes);
1643
1644        let session = Session::import(snapshot).expect("import v2 snapshot");
1645        assert_eq!(
1646            session.restore("<a7f3b8e2:Name_1>").as_deref(),
1647            Some("Dr. Schmidt")
1648        );
1649        let next = session
1650            .tokenize_with_family("alpha", &PiiClass::Name, "Prof. Weber")
1651            .expect("next token");
1652        assert_eq!(next, "<a7f3b8e2:Name_2>");
1653    }
1654
1655    #[test]
1656    fn v0_4_0_rejects_v0_4_1_snapshot_version_3_cleanly() {
1657        let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1658        let _ = session
1659            .tokenize_with_family("alpha", &PiiClass::Name, "Dr. Schmidt")
1660            .expect("token");
1661        let snapshot = session.export().expect("snapshot");
1662
1663        assert!(matches!(
1664            legacy_v0_4_0_accepts_only_v2(&snapshot),
1665            Err(Error::InvalidSnapshotVersion(SNAPSHOT_VERSION_V5))
1666        ));
1667    }
1668
1669    #[test]
1670    fn snapshot_signature_binds_emitted_envelope_version() {
1671        let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1672        session
1673            .tokenize(&PiiClass::Name, "Dr. Schmidt")
1674            .expect("token");
1675
1676        let mut bytes = session.export().expect("snapshot").into_bytes();
1677        assert_eq!(bytes[0], SNAPSHOT_VERSION_V5);
1678        bytes[0] = SNAPSHOT_VERSION_V3;
1679
1680        assert!(matches!(
1681            Session::import(SensitiveSnapshot::from(bytes)),
1682            Err(Error::InvalidSnapshotSignature)
1683        ));
1684    }
1685
1686    #[test]
1687    fn snapshot_signature_uses_final_envelope_preimage() {
1688        let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1689        session
1690            .tokenize(&PiiClass::Email, "alice@example.invalid")
1691            .expect("token");
1692
1693        let bytes = session.export().expect("snapshot").into_bytes();
1694        let version = bytes[0];
1695        let verifying_key_bytes: [u8; 32] = bytes[1..33].try_into().expect("verifying key bytes");
1696        let verifying_key = VerifyingKey::from_bytes(&verifying_key_bytes).expect("verifying key");
1697        let signature = Signature::from_bytes(&bytes[33..97].try_into().expect("signature bytes"));
1698        let payload_bytes = &bytes[97..];
1699        let preimage = snapshot_signing_preimage(version, &verifying_key_bytes, payload_bytes);
1700
1701        assert!(verifying_key.verify(&preimage, &signature).is_ok());
1702        assert!(verifying_key.verify(payload_bytes, &signature).is_err());
1703    }
1704
1705    #[test]
1706    fn snapshot_import_rejects_signature_slot_mutation() {
1707        let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1708        session
1709            .tokenize(&PiiClass::Name, "Dr. Schmidt")
1710            .expect("token");
1711
1712        let mut bytes = session.export().expect("snapshot").into_bytes();
1713        bytes[33] ^= 0x01;
1714
1715        assert!(matches!(
1716            Session::import(SensitiveSnapshot::from(bytes)),
1717            Err(Error::InvalidSnapshotSignature)
1718        ));
1719    }
1720
1721    #[test]
1722    fn export_with_extension_round_trips_clean() {
1723        let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1724        let token = session
1725            .tokenize(&PiiClass::Name, "Dr. Schmidt")
1726            .expect("token");
1727        let extension = document_extension(&session);
1728
1729        let snapshot = session
1730            .export_with_extension(extension.clone())
1731            .expect("export with extension");
1732        let payload = snapshot_payload_json(&snapshot);
1733
1734        let document: DocumentExtension =
1735            serde_json::from_value(payload["document"].clone()).expect("document extension");
1736        assert_eq!(document, extension);
1737
1738        let imported = Session::import(snapshot).expect("import extended snapshot");
1739        assert_eq!(imported.restore(&token).as_deref(), Some("Dr. Schmidt"));
1740    }
1741
1742    #[test]
1743    fn export_with_extension_no_pii_leak() {
1744        let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1745        let _ = session
1746            .tokenize(&PiiClass::Email, "alice@example.invalid")
1747            .expect("token");
1748
1749        let snapshot = session
1750            .export_with_extension(document_extension(&session))
1751            .expect("export with extension");
1752        let payload = snapshot_payload_json(&snapshot);
1753        let document_json = serde_json::to_string(&payload["document"]).expect("document json");
1754
1755        assert!(!document_json.contains("alice@example.invalid"));
1756        assert!(!document_json.contains("\"raw\""));
1757    }
1758
1759    #[test]
1760    fn document_extension_zero_clean_md_sha256_rejected() {
1761        let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1762        let mut extension = document_extension(&session);
1763        extension.clean_md_sha256 = [0; 32];
1764
1765        assert!(matches!(
1766            session.export_with_extension(extension),
1767            Err(Error::EmptyDocumentIntegrity)
1768        ));
1769    }
1770
1771    #[test]
1772    fn document_extension_zero_layout_json_sha256_rejected() {
1773        let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1774        let mut extension = document_extension(&session);
1775        extension.layout_json_sha256 = [0; 32];
1776
1777        assert!(matches!(
1778            session.export_with_extension(extension),
1779            Err(Error::EmptyDocumentIntegrity)
1780        ));
1781    }
1782
1783    #[test]
1784    fn document_extension_zero_report_json_sha256_rejected() {
1785        let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1786        let mut extension = document_extension(&session);
1787        extension.report_json_sha256 = [0; 32];
1788
1789        assert!(matches!(
1790            session.export_with_extension(extension),
1791            Err(Error::EmptyDocumentIntegrity)
1792        ));
1793    }
1794
1795    #[test]
1796    fn document_extension_empty_audit_session_id_rejected() {
1797        let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1798        let mut extension = document_extension(&session);
1799        extension.audit_session_id.clear();
1800
1801        assert!(matches!(
1802            session.export_with_extension(extension),
1803            Err(Error::EmptyDocumentIntegrity)
1804        ));
1805    }
1806
1807    #[test]
1808    fn document_extension_with_full_integrity_signs_successfully() {
1809        let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1810        let snapshot = session
1811            .export_with_extension(document_extension(&session))
1812            .expect("export with full integrity");
1813        let payload = snapshot_payload_json(&snapshot);
1814
1815        assert_eq!(payload["document"]["schema_version"], 1);
1816        assert!(Session::import(snapshot).is_ok());
1817    }
1818
1819    #[test]
1820    fn import_rejects_forward_dated_persistent_snapshot() {
1821        let now = SystemTime::now()
1822            .duration_since(UNIX_EPOCH)
1823            .map(|duration| duration.as_secs())
1824            .unwrap_or(0);
1825        let snapshot = signed_snapshot(SnapshotPayload {
1826            scope: SnapshotScope::Persistent { ttl_secs: 300 },
1827            session_hex: "a7f3b8e2".to_string(),
1828            entries: Vec::new(),
1829            issued_at: now.saturating_add(3_600),
1830            next_by_class: Vec::new(),
1831            document: None,
1832        });
1833
1834        assert!(matches!(
1835            Session::import(snapshot),
1836            Err(Error::InvalidSnapshotSignature)
1837        ));
1838    }
1839
1840    #[test]
1841    fn tokenize_distinguishes_builtin_and_custom_class_names() {
1842        let session = Session::new(Scope::Ephemeral).expect("session");
1843        for (builtin, name) in [
1844            (PiiClass::Email, "email"),
1845            (PiiClass::Name, "name"),
1846            (PiiClass::Location, "location"),
1847            (PiiClass::Organization, "organization"),
1848        ] {
1849            let builtin_value = format!("{name}-builtin");
1850            let custom_value = format!("{name}-custom");
1851
1852            let builtin_token = session
1853                .tokenize(&builtin, &builtin_value)
1854                .expect("builtin token");
1855            let custom_class = PiiClass::custom(name);
1856            let custom_token = session
1857                .tokenize(&custom_class, &custom_value)
1858                .expect("custom token");
1859
1860            assert!(builtin_token.ends_with(&format!(":{}_1>", builtin.class_name())));
1861            assert!(custom_token.ends_with(&format!(":Custom:{name}_1>")));
1862            assert_ne!(builtin_token, custom_token);
1863            assert_eq!(
1864                session.restore(&builtin_token).as_deref(),
1865                Some(builtin_value.as_str())
1866            );
1867            assert_eq!(
1868                session.restore(&custom_token).as_deref(),
1869                Some(custom_value.as_str())
1870            );
1871        }
1872    }
1873
1874    #[test]
1875    fn tokenize_distinguishes_custom_classes_with_matching_pascal_case() {
1876        let session = Session::new(Scope::Ephemeral).expect("session");
1877        let first_class = PiiClass::custom("email");
1878        let second_class = PiiClass::custom("custom_email");
1879
1880        let first_token = session
1881            .tokenize(&first_class, "alice@corp.com")
1882            .expect("first custom token");
1883        let second_token = session
1884            .tokenize(&second_class, "hello")
1885            .expect("second custom token");
1886
1887        assert!(first_token.ends_with(":Custom:email_1>"));
1888        assert!(second_token.ends_with(":Custom:custom_email_1>"));
1889        assert_ne!(first_token, second_token);
1890        assert_eq!(
1891            session.restore(&first_token).as_deref(),
1892            Some("alice@corp.com")
1893        );
1894        assert_eq!(session.restore(&second_token).as_deref(), Some("hello"));
1895    }
1896
1897    #[test]
1898    fn snapshot_round_trip_two_families_same_class_raw_preserved_under_shared_counter() {
1899        let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1900        let alpha = session
1901            .tokenize_with_family("alpha", &PiiClass::Name, "Dr. Schmidt")
1902            .expect("alpha token");
1903        let beta = session
1904            .tokenize_with_family("beta", &PiiClass::Name, "Dr. Schmidt")
1905            .expect("beta token");
1906
1907        assert_ne!(alpha, beta);
1908        assert!(alpha.ends_with(":Name_1>"));
1909        assert!(beta.ends_with(":Name_2>"));
1910
1911        let snapshot = session.export().expect("snapshot");
1912        assert_eq!(snapshot.0[0], SNAPSHOT_VERSION_V5);
1913        let imported = Session::import(snapshot).expect("import");
1914
1915        assert_eq!(imported.restore(&alpha).as_deref(), Some("Dr. Schmidt"));
1916        assert_eq!(imported.restore(&beta).as_deref(), Some("Dr. Schmidt"));
1917        assert_eq!(
1918            imported
1919                .tokenize_with_family("alpha", &PiiClass::Name, "Dr. Schmidt")
1920                .expect("alpha stable"),
1921            alpha
1922        );
1923        assert_eq!(
1924            imported
1925                .tokenize_with_family("beta", &PiiClass::Name, "Dr. Schmidt")
1926                .expect("beta stable"),
1927            beta
1928        );
1929    }
1930
1931    #[test]
1932    fn restore_strict_text_rejects_unknown_token() {
1933        let session = Session::new(Scope::Ephemeral).expect("session");
1934        let err = session
1935            .restore_strict_text("Reply to <deadbeef:Email_999>.")
1936            .expect_err("unknown token must fail closed");
1937
1938        assert!(matches!(
1939            err,
1940            Error::UnknownToken {
1941                class: PiiClass::Email,
1942                ordinal: 999,
1943                raw,
1944            } if raw == "<deadbeef:Email_999>"
1945        ));
1946    }
1947
1948    #[test]
1949    fn restore_strict_text_rejects_malformed_token() {
1950        let session = Session::new(Scope::Ephemeral).expect("session");
1951        let err = session
1952            .restore_strict_text("Reply to <Email_>.")
1953            .expect_err("malformed token must fail closed");
1954
1955        assert!(matches!(
1956            err,
1957            Error::UnknownToken {
1958                class: PiiClass::Custom(name),
1959                ordinal: 0,
1960                raw,
1961            } if name == "unknown" && raw == "<Email_>"
1962        ));
1963    }
1964
1965    #[test]
1966    fn restore_strict_text_rejects_cross_session_token_as_unknown() {
1967        let source = Session::new(Scope::Ephemeral).expect("source session");
1968        let token = source
1969            .tokenize(&PiiClass::Email, "alice@example.invalid")
1970            .expect("token");
1971        let target = Session::new(Scope::Ephemeral).expect("target session");
1972
1973        let err = target
1974            .restore_strict_text(&format!("Reply to {token}."))
1975            .expect_err("wrong-session token must fail closed");
1976
1977        assert!(matches!(
1978            err,
1979            Error::UnknownToken {
1980                class: PiiClass::Email,
1981                ordinal: 1,
1982                raw,
1983            } if raw == token
1984        ));
1985    }
1986
1987    #[test]
1988    fn restore_strict_text_is_atomic_on_failure() {
1989        let session = Session::new(Scope::Ephemeral).expect("session");
1990        let token = session
1991            .tokenize(&PiiClass::Name, "Dr. Schmidt")
1992            .expect("token");
1993
1994        let err = session
1995            .restore_strict_text(&format!("{token} then <deadbeef:Email_9>"))
1996            .expect_err("unknown token must prevent partial restore output");
1997
1998        assert!(matches!(
1999            err,
2000            Error::UnknownToken {
2001                class: PiiClass::Email,
2002                ordinal: 9,
2003                raw,
2004            } if raw == "<deadbeef:Email_9>"
2005        ));
2006    }
2007
2008    #[test]
2009    fn restore_strict_text_preserves_path_adjacency_byte_exact() {
2010        let session = Session::new(Scope::Ephemeral).expect("session");
2011        let workspace = session
2012            .tokenize(&PiiClass::Organization, "Workspace")
2013            .expect("workspace token");
2014        let artist = session
2015            .tokenize(&PiiClass::Organization, "Artist")
2016            .expect("artist token");
2017        let email = session
2018            .tokenize(&PiiClass::Email, "alice@example.invalid")
2019            .expect("email token");
2020        let cases = [
2021            (
2022                format!("list all folders in ~/{workspace}"),
2023                "list all folders in ~/Workspace".to_string(),
2024            ),
2025            (format!("{workspace}*"), "Workspace*".to_string()),
2026            (
2027                format!("~/{workspace}/{artist}"),
2028                "~/Workspace/Artist".to_string(),
2029            ),
2030            (
2031                format!("owner={email};path=~/{workspace}"),
2032                "owner=alice@example.invalid;path=~/Workspace".to_string(),
2033            ),
2034        ];
2035
2036        for (clean, expected) in cases {
2037            let restored = session
2038                .restore_strict_text(&clean)
2039                .expect("restore must succeed");
2040
2041            assert_eq!(restored, expected);
2042            assert!(!restored.contains("~/ Workspace"));
2043        }
2044    }
2045
2046    #[test]
2047    fn restore_boundary_events_distinguish_manifest_bypass_from_fresh_pii() {
2048        let session = Session::new(Scope::Ephemeral).expect("session");
2049        let token = session
2050            .tokenize(&PiiClass::Email, "alice@example.invalid")
2051            .expect("token");
2052
2053        let (restored, events) = session
2054            .restore_strict_text_with_events(&format!(
2055                "{token} raw alice@example.invalid fresh bob@example.invalid"
2056            ))
2057            .expect("restore continues in audit-only mode");
2058
2059        assert!(restored.contains("alice@example.invalid"));
2060        assert_eq!(events.len(), 2);
2061        assert_eq!(events[0].kind, RestoreEventKind::ManifestBypass);
2062        assert_eq!(events[0].class, PiiClass::Email);
2063        assert_eq!(events[0].raw_sha256.len(), 64);
2064        assert_ne!(events[0].raw_sha256, "alice@example.invalid");
2065        assert_eq!(events[1].kind, RestoreEventKind::FreshPiiDetected);
2066        assert_eq!(events[1].class, PiiClass::Email);
2067    }
2068
2069    #[test]
2070    fn restore_dlp_email_boundary_accepts_non_ascii_and_punctuation_delimiters() {
2071        for (input, location) in [
2072            ("a@example.invalidø", 0..17),
2073            ("a@example.invalid. Thanks", 0..17),
2074            ("a@example.invalid-", 0..17),
2075            ("a@example.invalid+", 0..17),
2076            ("a@example.invalid%", 0..17),
2077            (".a@example.invalid", 1..18),
2078        ] {
2079            let findings = structural_findings(input);
2080            let finding = findings
2081                .iter()
2082                .find(|finding| finding.class == PiiClass::Email)
2083                .unwrap_or_else(|| panic!("missing email finding for {input}"));
2084
2085            assert_eq!(finding.raw, "a@example.invalid", "{input}");
2086            assert_eq!(finding.location, location, "{input}");
2087        }
2088
2089        let adjacent = structural_findings("a@example.invalid,b@example.invalid")
2090            .into_iter()
2091            .filter(|finding| finding.class == PiiClass::Email)
2092            .map(|finding| (finding.raw, finding.location))
2093            .collect::<Vec<_>>();
2094        assert_eq!(
2095            adjacent,
2096            vec![
2097                ("a@example.invalid".to_string(), 0..17),
2098                ("b@example.invalid".to_string(), 18..35)
2099            ]
2100        );
2101
2102        for input in ["a@example.invalid1", "a@example.invalid_"] {
2103            assert!(
2104                structural_findings(input)
2105                    .iter()
2106                    .all(|finding| finding.class != PiiClass::Email),
2107                "{input}"
2108            );
2109        }
2110    }
2111
2112    #[test]
2113    fn restore_boundary_events_cover_structural_identifier_scope() {
2114        let session = Session::new(Scope::Ephemeral).expect("session");
2115        session
2116            .tokenize(&PiiClass::custom("phone"), "+1-555-0101")
2117            .expect("phone token");
2118        session
2119            .tokenize(&PiiClass::custom("iban"), "DE89 3704 0044 0532 0130 00")
2120            .expect("iban token");
2121        session
2122            .tokenize(&PiiClass::custom("credit_card"), "4111 1111 1111 1111")
2123            .expect("card token");
2124        session
2125            .tokenize(&PiiClass::custom("api_key"), "sk-test-00000000000000000000")
2126            .expect("api key token");
2127
2128        let events = session.restore_boundary_events(
2129            "known +1-555-0101 UK +44 7700 900123 DE +49 1555 0112233 \
2130             known DE89370400440532013000 fresh GB82 WEST 1234 5698 7654 32 \
2131             known 4111111111111111 fresh 4012-8888-8888-1881 \
2132             known sk-test-00000000000000000000 fresh gaze_test_0000000000000000",
2133        );
2134
2135        assert!(events.iter().any(|event| {
2136            event.kind == RestoreEventKind::ManifestBypass
2137                && event.class == PiiClass::custom("phone")
2138        }));
2139        assert!(events.iter().any(|event| {
2140            event.kind == RestoreEventKind::FreshPiiDetected
2141                && event.class == PiiClass::custom("phone")
2142        }));
2143        assert!(events.iter().any(|event| {
2144            event.kind == RestoreEventKind::ManifestBypass
2145                && event.class == PiiClass::custom("iban")
2146        }));
2147        assert!(events.iter().any(|event| {
2148            event.kind == RestoreEventKind::FreshPiiDetected
2149                && event.class == PiiClass::custom("iban")
2150        }));
2151        assert!(events.iter().any(|event| {
2152            event.kind == RestoreEventKind::ManifestBypass
2153                && event.class == PiiClass::custom("credit_card")
2154        }));
2155        assert!(events.iter().any(|event| {
2156            event.kind == RestoreEventKind::FreshPiiDetected
2157                && event.class == PiiClass::custom("credit_card")
2158        }));
2159        assert!(events.iter().any(|event| {
2160            event.kind == RestoreEventKind::ManifestBypass
2161                && event.class == PiiClass::custom("api_key")
2162        }));
2163        assert!(events.iter().any(|event| {
2164            event.kind == RestoreEventKind::FreshPiiDetected
2165                && event.class == PiiClass::custom("api_key")
2166        }));
2167    }
2168}