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#[derive(Debug, Clone)]
84#[non_exhaustive]
85pub enum Scope {
86 Ephemeral,
87 Conversation(String),
88 Persistent { ttl: Duration },
89}
90
91#[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
179pub 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 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 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(®ex)));
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 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 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 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 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}