1use std::time::{Duration, SystemTime, UNIX_EPOCH};
2
3use dashmap::mapref::entry::Entry;
4use dashmap::DashMap;
5use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
6use rand::RngCore;
7use secrecy::{ExposeSecret, SecretBox};
8use serde::{Deserialize, Deserializer, Serialize};
9use serde_json::Value as JsonValue;
10
11use crate::detector::PiiClass;
12use crate::policy::{Policy, SessionScope};
13use crate::{Error, Result};
14use gaze_types::DocumentExtension;
15
16const DEFAULT_PERSISTENT_TTL_SECS: u64 = 86_400;
17const DEFAULT_COUNTER_FAMILY: &str = "counter";
18const SNAPSHOT_VERSION_V2: u8 = 2;
19const SNAPSHOT_VERSION_V3: u8 = 3;
20const SNAPSHOT_VERSION_V4: u8 = 4;
21const SNAPSHOT_VERSION_V5: u8 = 5;
22
23#[derive(Debug, Clone)]
35#[non_exhaustive]
36pub enum Scope {
37 Ephemeral,
38 Conversation(String),
39 Persistent { ttl: Duration },
40}
41
42#[derive(Debug, Clone)]
57pub struct SensitiveSnapshot(Vec<u8>);
58
59impl SensitiveSnapshot {
60 pub fn into_bytes(self) -> Vec<u8> {
61 self.0
62 }
63}
64
65impl From<Vec<u8>> for SensitiveSnapshot {
66 fn from(value: Vec<u8>) -> Self {
67 Self(value)
68 }
69}
70
71#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
72struct TokenKey {
73 family: String,
74 class: PiiClass,
75 raw: String,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79struct SnapshotEntry {
80 class: PiiClass,
81 raw: String,
82 token: String,
83 #[serde(default = "default_counter_family")]
84 family: String,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88enum SnapshotScope {
89 Ephemeral,
90 Conversation(String),
91 Persistent { ttl_secs: u64 },
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95struct SnapshotPayload {
96 scope: SnapshotScope,
97 #[serde(deserialize_with = "deserialize_session_hex")]
98 session_hex: String,
99 entries: Vec<SnapshotEntry>,
100 #[serde(default)]
101 issued_at: u64,
102 #[serde(default)]
103 next_by_class: Vec<(PiiClass, usize)>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 document: Option<DocumentExtension>,
106}
107
108pub struct Session {
188 scope: Scope,
189 session_hex: [u8; 4],
190 audit_session_id: String,
191 next_by_class: DashMap<PiiClass, usize>,
192 token_by_value: DashMap<TokenKey, String>,
193 value_by_token: DashMap<String, String>,
194 signing_key: SessionKey,
195}
196
197impl Session {
198 pub fn new(scope: Scope) -> Result<Self> {
199 Ok(Self {
200 scope,
201 session_hex: random_session_hex(),
202 audit_session_id: new_audit_session_id(),
203 next_by_class: DashMap::new(),
204 token_by_value: DashMap::new(),
205 value_by_token: DashMap::new(),
206 signing_key: SessionKey::generate()?,
207 })
208 }
209
210 pub fn from_policy(policy: &Policy) -> Result<Self> {
211 Self::from_policy_with_ttl_override(policy, None)
212 }
213
214 pub fn from_policy_with_ttl_override(
215 policy: &Policy,
216 ttl_secs_override: Option<u64>,
217 ) -> Result<Self> {
218 let scope = match policy.session.scope {
219 SessionScope::Ephemeral => Scope::Ephemeral,
220 SessionScope::Conversation => Scope::Conversation("cli".to_string()),
221 SessionScope::Persistent => {
222 let ttl_secs = ttl_secs_override
223 .or(policy.session.ttl_secs)
224 .unwrap_or(DEFAULT_PERSISTENT_TTL_SECS);
225 Scope::Persistent {
226 ttl: Duration::from_secs(ttl_secs),
227 }
228 }
229 };
230
231 Self::new(scope)
232 }
233
234 pub fn tokenize(&self, class: &PiiClass, raw: &str) -> Result<String> {
235 self.tokenize_with_family(DEFAULT_COUNTER_FAMILY, class, raw)
236 }
237
238 pub fn tokenize_with_family(
239 &self,
240 family: &str,
241 class: &PiiClass,
242 raw: &str,
243 ) -> Result<String> {
244 self.intern_mapping(Some(family), class, raw, |index| {
245 format!("<{}:{}_{}>", self.session_hex(), class.class_name(), index)
246 })
247 }
248
249 pub fn format_preserving_fake(&self, class: &PiiClass, raw: &str) -> Result<String> {
250 self.intern_mapping(None, class, raw, |index| match class {
251 PiiClass::Email => format!("email{index}.{}@gaze-fake.invalid", self.session_hex()),
252 PiiClass::Name | PiiClass::Location | PiiClass::Organization => format!(
253 "{}:{}_{}",
254 self.session_hex(),
255 class.class_name().to_ascii_lowercase(),
256 index
257 ),
258 PiiClass::Custom(name) => format!("{}:custom:{name}_{index}", self.session_hex()),
261 })
262 }
263
264 fn intern_mapping<F>(
265 &self,
266 family: Option<&str>,
267 class: &PiiClass,
268 raw: &str,
269 build: F,
270 ) -> Result<String>
271 where
272 F: FnOnce(usize) -> String,
273 {
274 let family_key = family.unwrap_or(DEFAULT_COUNTER_FAMILY);
275 let key = TokenKey {
276 family: family_key.to_string(),
277 class: class.clone(),
278 raw: raw.to_string(),
279 };
280 match self.token_by_value.entry(key) {
281 Entry::Occupied(existing) => Ok(existing.get().clone()),
282 Entry::Vacant(vacant) => {
283 let token = {
284 let mut next = self.next_by_class.entry(class.clone()).or_insert(0);
285 *next += 1;
286 build(*next)
287 };
288
289 vacant.insert(token.clone());
290 self.value_by_token.insert(token.clone(), raw.to_string());
291 Ok(token)
292 }
293 }
294 }
295
296 pub fn tokens(&self) -> Vec<String> {
309 self.value_by_token
310 .iter()
311 .map(|entry| entry.key().clone())
312 .collect()
313 }
314
315 pub fn contains_token(&self, token: &str) -> bool {
316 self.value_by_token.contains_key(token)
317 }
318
319 pub fn session_hex(&self) -> String {
320 hex::encode(self.session_hex)
321 }
322
323 pub fn audit_session_id(&self) -> &str {
324 &self.audit_session_id
325 }
326
327 pub fn restore_strict(&self, token: &str) -> Result<String> {
330 self.value_by_token
331 .get(token)
332 .map(|value| value.value().clone())
333 .ok_or_else(|| Error::UnknownToken(token.to_string()))
334 }
335
336 pub fn restore(&self, token: &str) -> Option<String> {
337 self.value_by_token
338 .get(token)
339 .map(|value| value.value().clone())
340 }
341
342 pub fn export(&self) -> Result<SensitiveSnapshot> {
343 self.export_payload(None)
344 }
345
346 pub fn export_with_extension(&self, extension: DocumentExtension) -> Result<SensitiveSnapshot> {
394 if extension.clean_md_sha256 == [0; 32]
395 || extension.layout_json_sha256 == [0; 32]
396 || extension.report_json_sha256 == [0; 32]
397 || extension.audit_session_id.is_empty()
398 {
399 return Err(Error::EmptyDocumentIntegrity);
400 }
401 self.export_payload(Some(extension))
402 }
403
404 fn export_payload(&self, document: Option<DocumentExtension>) -> Result<SensitiveSnapshot> {
405 if matches!(self.scope, Scope::Ephemeral) {
406 return Err(Error::ExportForbidden);
407 }
408
409 let issued_at = SystemTime::now()
412 .duration_since(UNIX_EPOCH)
413 .map(|duration| duration.as_secs())
414 .unwrap_or(0);
415
416 let payload = SnapshotPayload {
417 scope: snapshot_scope(&self.scope),
418 session_hex: self.session_hex(),
419 entries: self
420 .token_by_value
421 .iter()
422 .map(|entry| SnapshotEntry {
423 family: entry.key().family.clone(),
424 class: entry.key().class.clone(),
425 raw: entry.key().raw.clone(),
426 token: entry.value().clone(),
427 })
428 .collect(),
429 next_by_class: self
430 .next_by_class
431 .iter()
432 .map(|entry| (entry.key().clone(), *entry.value()))
433 .collect(),
434 issued_at,
435 document,
436 };
437 let payload_bytes = serde_json::to_vec(&payload).map_err(Error::SnapshotDecode)?;
438 let version = SNAPSHOT_VERSION_V5;
439 let signing_key = self.signing_key.signing_key();
440 let verifying_key = signing_key.verifying_key();
441 let verifying_key_bytes = verifying_key.to_bytes();
442 let signing_preimage =
443 snapshot_signing_preimage(version, &verifying_key_bytes, &payload_bytes);
444 let signature = signing_key.sign(&signing_preimage);
445
446 let mut snapshot = Vec::with_capacity(1 + 32 + 64 + payload_bytes.len());
447 snapshot.push(version);
448 snapshot.extend_from_slice(&verifying_key_bytes);
449 snapshot.extend_from_slice(&signature.to_bytes());
450 snapshot.extend_from_slice(&payload_bytes);
451 Ok(SensitiveSnapshot(snapshot))
452 }
453
454 pub fn import(snapshot: SensitiveSnapshot) -> Result<Self> {
455 let bytes = snapshot.0;
456 if bytes.len() < 97 {
457 return Err(Error::InvalidSnapshotSignature);
458 }
459 let version = bytes[0];
460 if version != SNAPSHOT_VERSION_V2
461 && version != SNAPSHOT_VERSION_V3
462 && version != SNAPSHOT_VERSION_V4
463 && version != SNAPSHOT_VERSION_V5
464 {
465 return Err(Error::InvalidSnapshotVersion(version));
466 }
467
468 let verifying_key = VerifyingKey::from_bytes(
469 bytes[1..33]
470 .try_into()
471 .map_err(|_| Error::InvalidSnapshotSignature)?,
472 )
473 .map_err(|_| Error::InvalidSnapshotSignature)?;
474 let signature = Signature::from_bytes(
475 bytes[33..97]
476 .try_into()
477 .map_err(|_| Error::InvalidSnapshotSignature)?,
478 );
479 let payload_bytes = &bytes[97..];
480 let verify_preimage;
481 let signed_bytes = if version >= SNAPSHOT_VERSION_V5 {
482 let verifying_key_bytes: [u8; 32] = bytes[1..33]
483 .try_into()
484 .map_err(|_| Error::InvalidSnapshotSignature)?;
485 verify_preimage =
486 snapshot_signing_preimage(version, &verifying_key_bytes, payload_bytes);
487 verify_preimage.as_slice()
488 } else {
489 payload_bytes
490 };
491 verifying_key
492 .verify(signed_bytes, &signature)
493 .map_err(|_| Error::InvalidSnapshotSignature)?;
494
495 let payload = decode_snapshot_payload(payload_bytes)?;
496 validate_entry_prefixes(&payload)?;
497 let session_hex = session_hex_bytes(&payload.session_hex)?;
498 let scope = scope_from_snapshot(payload.scope);
499 let issued_at = payload.issued_at;
500 if let Scope::Persistent { ttl } = &scope {
501 let ttl_secs = ttl.as_secs();
502 if issued_at > 0 {
503 let now = SystemTime::now()
504 .duration_since(UNIX_EPOCH)
505 .map(|duration| duration.as_secs())
506 .unwrap_or(0);
507 if issued_at > now.saturating_add(60) {
508 return Err(Error::InvalidSnapshotSignature);
509 }
510 if now.saturating_sub(issued_at) > ttl_secs {
511 return Err(Error::BlobExpired {
512 issued_at,
513 ttl_secs,
514 });
515 }
516 }
517 }
518
519 let session = Self {
520 scope,
521 session_hex,
522 audit_session_id: new_audit_session_id(),
523 next_by_class: DashMap::new(),
524 token_by_value: DashMap::new(),
525 value_by_token: DashMap::new(),
526 signing_key: SessionKey::generate()?,
527 };
528 for entry in payload.entries {
529 session.token_by_value.insert(
530 TokenKey {
531 family: entry.family,
532 class: entry.class.clone(),
533 raw: entry.raw.clone(),
534 },
535 entry.token.clone(),
536 );
537 session
538 .value_by_token
539 .insert(entry.token.clone(), entry.raw);
540 if let Some(index) = parse_token_index(&entry.token) {
541 let mut next = session.next_by_class.entry(entry.class).or_insert(0);
542 if *next < index {
543 *next = index;
544 }
545 }
546 }
547 for (class, index) in payload.next_by_class {
552 let mut next = session.next_by_class.entry(class).or_insert(0);
553 if *next < index {
554 *next = index;
555 }
556 }
557 Ok(session)
558 }
559}
560
561fn default_counter_family() -> String {
562 DEFAULT_COUNTER_FAMILY.to_string()
563}
564
565fn new_audit_session_id() -> String {
566 let millis = SystemTime::now()
567 .duration_since(UNIX_EPOCH)
568 .map(|duration| duration.as_millis().min(0xffff_ffff_ffff) as u64)
569 .unwrap_or(0);
570 let mut bytes = [0_u8; 16];
571 rand::thread_rng().fill_bytes(&mut bytes[6..]);
572 bytes[0] = (millis >> 40) as u8;
573 bytes[1] = (millis >> 32) as u8;
574 bytes[2] = (millis >> 24) as u8;
575 bytes[3] = (millis >> 16) as u8;
576 bytes[4] = (millis >> 8) as u8;
577 bytes[5] = millis as u8;
578 bytes[6] = (bytes[6] & 0x0f) | 0x70;
579 bytes[8] = (bytes[8] & 0x3f) | 0x80;
580 format!(
581 "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
582 bytes[0],
583 bytes[1],
584 bytes[2],
585 bytes[3],
586 bytes[4],
587 bytes[5],
588 bytes[6],
589 bytes[7],
590 bytes[8],
591 bytes[9],
592 bytes[10],
593 bytes[11],
594 bytes[12],
595 bytes[13],
596 bytes[14],
597 bytes[15],
598 )
599}
600
601struct SessionKey {
602 secret: SecretBox<[u8; 32]>,
603 protection: MemoryProtection,
604}
605
606impl SessionKey {
607 fn generate() -> Result<Self> {
608 let secret = SecretBox::init_with_mut(|bytes: &mut [u8; 32]| {
609 rand::thread_rng().fill_bytes(bytes);
610 });
611 let protection = MemoryProtection::best_effort(secret.expose_secret().as_ptr(), 32);
612 Ok(Self { secret, protection })
613 }
614
615 fn signing_key(&self) -> SigningKey {
616 SigningKey::from_bytes(self.secret.expose_secret())
617 }
618}
619
620impl Drop for SessionKey {
621 fn drop(&mut self) {
622 self.protection.unlock();
623 }
624}
625
626struct MemoryProtection {
627 addr: usize,
628 len: usize,
629 locked: bool,
630}
631
632impl MemoryProtection {
633 fn best_effort(ptr: *const u8, len: usize) -> Self {
634 let locked = lock_memory(ptr, len);
635 advise_dontdump(ptr, len);
636 Self {
637 addr: ptr as usize,
638 len,
639 locked,
640 }
641 }
642
643 fn unlock(&mut self) {
644 if self.locked {
645 unlock_memory(self.addr as *const u8, self.len);
646 self.locked = false;
647 }
648 }
649}
650
651fn lock_memory(ptr: *const u8, len: usize) -> bool {
652 #[cfg(unix)]
653 unsafe {
654 if libc::mlock(ptr.cast(), len) == 0 {
655 return true;
656 }
657 tracing::warn!(
658 error = %std::io::Error::last_os_error(),
659 "session key mlock failed; continuing with unlocked key material"
660 );
661 }
662
663 false
664}
665
666fn unlock_memory(ptr: *const u8, len: usize) {
667 #[cfg(unix)]
668 unsafe {
669 let _ = libc::munlock(ptr.cast(), len);
670 }
671}
672
673fn advise_dontdump(_ptr: *const u8, _len: usize) {
674 #[cfg(any(target_os = "linux", target_os = "android"))]
675 unsafe {
676 let ptr = _ptr;
677 let len = _len;
678 let page_size = libc::sysconf(libc::_SC_PAGESIZE);
679 if page_size <= 0 {
680 return;
681 }
682 let page_size = page_size as usize;
683 let start = (ptr as usize) & !(page_size - 1);
684 let end = (ptr as usize + len).div_ceil(page_size) * page_size;
685 let aligned_len = end.saturating_sub(start);
686 if aligned_len == 0 {
687 return;
688 }
689 let _ = libc::madvise(start as *mut libc::c_void, aligned_len, libc::MADV_DONTDUMP);
690 }
691}
692
693fn snapshot_scope(scope: &Scope) -> SnapshotScope {
694 match scope {
695 Scope::Ephemeral => SnapshotScope::Ephemeral,
696 Scope::Conversation(id) => SnapshotScope::Conversation(id.clone()),
697 Scope::Persistent { ttl } => SnapshotScope::Persistent {
698 ttl_secs: ttl.as_secs(),
699 },
700 }
701}
702
703fn scope_from_snapshot(scope: SnapshotScope) -> Scope {
704 match scope {
705 SnapshotScope::Ephemeral => Scope::Ephemeral,
706 SnapshotScope::Conversation(id) => Scope::Conversation(id),
707 SnapshotScope::Persistent { ttl_secs } => Scope::Persistent {
708 ttl: Duration::from_secs(ttl_secs),
709 },
710 }
711}
712
713fn random_session_hex() -> [u8; 4] {
714 let mut bytes = [0u8; 4];
715 rand::thread_rng().fill_bytes(&mut bytes);
716 bytes
717}
718
719fn deserialize_session_hex<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
720where
721 D: Deserializer<'de>,
722{
723 let value = String::deserialize(deserializer)?;
724 if is_session_hex(&value) {
725 Ok(value)
726 } else {
727 Err(serde::de::Error::custom(
728 "session_hex must be 8 lowercase hex chars",
729 ))
730 }
731}
732
733fn is_session_hex(value: &str) -> bool {
734 value.len() == 8
735 && value
736 .bytes()
737 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
738}
739
740fn session_hex_bytes(value: &str) -> Result<[u8; 4]> {
741 if !is_session_hex(value) {
742 return Err(Error::InvalidSnapshotPayload);
743 }
744 let decoded = hex::decode(value).map_err(|_| Error::InvalidSnapshotPayload)?;
745 decoded
746 .try_into()
747 .map_err(|_| Error::InvalidSnapshotPayload)
748}
749
750fn decode_snapshot_payload(payload_bytes: &[u8]) -> Result<SnapshotPayload> {
751 let value: JsonValue = serde_json::from_slice(payload_bytes).map_err(Error::SnapshotDecode)?;
752 let Some(session_hex) = value.get("session_hex").and_then(JsonValue::as_str) else {
753 return Err(Error::InvalidSnapshotPayload);
754 };
755 if !is_session_hex(session_hex) {
756 return Err(Error::InvalidSnapshotPayload);
757 }
758 serde_json::from_value(value).map_err(Error::SnapshotDecode)
759}
760
761fn validate_entry_prefixes(payload: &SnapshotPayload) -> Result<()> {
762 for entry in &payload.entries {
763 if !entry_token_matches_session(&entry.token, &payload.session_hex)
764 || !crate::token_shape::starts_with_session_prefix(&entry.token)
765 {
766 return Err(Error::InvalidSnapshotPayload);
767 }
768 }
769 Ok(())
770}
771
772fn entry_token_matches_session(token: &str, session_hex: &str) -> bool {
773 token.starts_with(&format!("<{session_hex}:"))
774 || token.starts_with(&format!("{session_hex}:"))
775 || (token.starts_with("email")
776 && token
777 .split_once('.')
778 .and_then(|(_, rest)| rest.strip_suffix("@gaze-fake.invalid"))
779 == Some(session_hex))
780}
781
782fn parse_token_index(token: &str) -> Option<usize> {
783 if let Some(local) = token
784 .strip_prefix("email")
785 .and_then(|rest| rest.split_once('.').map(|(index, _)| index))
786 {
787 return local.parse().ok();
788 }
789 let suffix = token
790 .rsplit_once('_')?
791 .1
792 .strip_suffix('>')
793 .unwrap_or(token.rsplit_once('_')?.1);
794 suffix.parse().ok()
795}
796
797fn snapshot_signing_preimage(
798 version: u8,
799 verifying_key_bytes: &[u8; 32],
800 payload_bytes: &[u8],
801) -> Vec<u8> {
802 let mut preimage = Vec::with_capacity(1 + verifying_key_bytes.len() + payload_bytes.len());
803 preimage.push(version);
804 preimage.extend_from_slice(verifying_key_bytes);
805 preimage.extend_from_slice(payload_bytes);
806 preimage
807}
808
809#[cfg(test)]
810mod tests {
811 use super::*;
812
813 fn signed_snapshot_v03(payload: SnapshotPayload) -> SensitiveSnapshot {
814 let payload_bytes = serde_json::to_vec(&payload).expect("serialize payload");
815 signed_snapshot_bytes(1, &payload_bytes)
816 }
817
818 fn signed_snapshot_bytes(version: u8, payload_bytes: &[u8]) -> SensitiveSnapshot {
819 let key = SessionKey::generate().expect("session key");
820 let signing_key = key.signing_key();
821 let signature = signing_key.sign(payload_bytes);
822 let verifying_key = signing_key.verifying_key();
823
824 let mut snapshot = Vec::with_capacity(1 + 32 + 64 + payload_bytes.len());
825 snapshot.push(version);
826 snapshot.extend_from_slice(&verifying_key.to_bytes());
827 snapshot.extend_from_slice(&signature.to_bytes());
828 snapshot.extend_from_slice(payload_bytes);
829 SensitiveSnapshot::from(snapshot)
830 }
831
832 fn signed_snapshot_v2(payload: SnapshotPayload) -> SensitiveSnapshot {
833 let mut snapshot = signed_snapshot_v03(payload).into_bytes();
834 snapshot[0] = SNAPSHOT_VERSION_V2;
835 SensitiveSnapshot::from(snapshot)
836 }
837
838 fn signed_snapshot(payload: SnapshotPayload) -> SensitiveSnapshot {
839 let mut snapshot = signed_snapshot_v03(payload).into_bytes();
840 snapshot[0] = SNAPSHOT_VERSION_V3;
841 SensitiveSnapshot::from(snapshot)
842 }
843
844 fn snapshot_payload_json(snapshot: &SensitiveSnapshot) -> JsonValue {
845 serde_json::from_slice(&snapshot.0[97..]).expect("snapshot payload json")
846 }
847
848 fn document_extension(session: &Session) -> DocumentExtension {
849 DocumentExtension::builder(1)
850 .clean_md_sha256([1; 32])
851 .layout_json_sha256([2; 32])
852 .report_json_sha256([3; 32])
853 .page_count(1)
854 .audit_session_id(session.audit_session_id())
855 .build()
856 .expect("document extension")
857 }
858
859 fn legacy_v0_4_0_accepts_only_v2(snapshot: &SensitiveSnapshot) -> Result<()> {
860 let bytes = &snapshot.0;
861 if bytes.len() < 97 {
862 return Err(Error::InvalidSnapshotSignature);
863 }
864 let version = bytes[0];
865 if version != SNAPSHOT_VERSION_V2 {
866 return Err(Error::InvalidSnapshotVersion(version));
867 }
868 Ok(())
869 }
870
871 #[test]
872 fn session_key_produces_valid_signatures() {
873 let key = SessionKey::generate().expect("session key");
874 let signing_key = key.signing_key();
875 let message = b"gaze";
876 let signature = signing_key.sign(message);
877
878 assert!(signing_key
879 .verifying_key()
880 .verify(message, &signature)
881 .is_ok());
882 }
883
884 #[test]
885 fn import_accepts_persistent_snapshot_within_ttl() {
886 let now = SystemTime::now()
887 .duration_since(UNIX_EPOCH)
888 .map(|duration| duration.as_secs())
889 .unwrap_or(0);
890 let snapshot = signed_snapshot(SnapshotPayload {
891 scope: SnapshotScope::Persistent { ttl_secs: 300 },
892 session_hex: "a7f3b8e2".to_string(),
893 entries: Vec::new(),
894 issued_at: now,
895 next_by_class: Vec::new(),
896 document: None,
897 });
898
899 assert!(Session::import(snapshot).is_ok());
900 }
901
902 #[test]
903 fn import_rejects_v03_envelope_byte() {
904 let snapshot = signed_snapshot_v03(SnapshotPayload {
905 scope: SnapshotScope::Persistent { ttl_secs: 300 },
906 session_hex: "a7f3b8e2".to_string(),
907 entries: Vec::new(),
908 issued_at: 0,
909 next_by_class: Vec::new(),
910 document: None,
911 });
912
913 assert!(matches!(
914 Session::import(snapshot),
915 Err(Error::InvalidSnapshotVersion(1))
916 ));
917 }
918
919 #[test]
920 fn import_rejects_invalid_session_hex() {
921 let snapshot = signed_snapshot(SnapshotPayload {
922 scope: SnapshotScope::Persistent { ttl_secs: 300 },
923 session_hex: "A7F3B8E2".to_string(),
924 entries: Vec::new(),
925 issued_at: 0,
926 next_by_class: Vec::new(),
927 document: None,
928 });
929
930 assert!(matches!(
931 Session::import(snapshot),
932 Err(Error::InvalidSnapshotPayload)
933 ));
934 }
935
936 #[test]
937 fn import_rejects_manifest_entry_prefix_mismatch() {
938 let snapshot = signed_snapshot(SnapshotPayload {
939 scope: SnapshotScope::Persistent { ttl_secs: 300 },
940 session_hex: "a7f3b8e2".to_string(),
941 entries: vec![SnapshotEntry {
942 family: DEFAULT_COUNTER_FAMILY.to_string(),
943 class: PiiClass::Name,
944 raw: "Dr. Schmidt".to_string(),
945 token: "deadbeef:name_1".to_string(),
946 }],
947 issued_at: 0,
948 next_by_class: Vec::new(),
949 document: None,
950 });
951
952 assert!(matches!(
953 Session::import(snapshot),
954 Err(Error::InvalidSnapshotPayload)
955 ));
956 }
957
958 #[test]
959 fn import_rejects_expired_persistent_snapshot() {
960 let now = SystemTime::now()
961 .duration_since(UNIX_EPOCH)
962 .map(|duration| duration.as_secs())
963 .unwrap_or(0);
964 let snapshot = signed_snapshot(SnapshotPayload {
965 scope: SnapshotScope::Persistent { ttl_secs: 10 },
966 session_hex: "a7f3b8e2".to_string(),
967 entries: Vec::new(),
968 issued_at: now.saturating_sub(11),
969 next_by_class: Vec::new(),
970 document: None,
971 });
972
973 assert!(matches!(
974 Session::import(snapshot),
975 Err(Error::BlobExpired {
976 issued_at,
977 ttl_secs: 10,
978 }) if issued_at == now.saturating_sub(11)
979 ));
980 }
981
982 #[test]
983 fn import_accepts_legacy_persistent_snapshot_without_issued_at() {
984 let snapshot = signed_snapshot_v2(SnapshotPayload {
985 scope: SnapshotScope::Persistent { ttl_secs: 1 },
986 session_hex: "a7f3b8e2".to_string(),
987 entries: Vec::new(),
988 issued_at: 0,
989 next_by_class: Vec::new(),
990 document: None,
991 });
992
993 assert!(Session::import(snapshot).is_ok());
994 }
995
996 #[test]
997 fn import_v0_4_0_snapshot_version_2_succeeds_with_default_family() {
998 let payload = serde_json::json!({
999 "scope": { "Persistent": { "ttl_secs": 300 } },
1000 "session_hex": "a7f3b8e2",
1001 "entries": [{
1002 "class": "Name",
1003 "raw": "Dr. Schmidt",
1004 "token": "<a7f3b8e2:Name_1>"
1005 }],
1006 "issued_at": 0,
1007 "next_by_class": [["Name", 1]]
1008 });
1009 let payload_bytes = serde_json::to_vec(&payload).expect("payload");
1010 let snapshot = signed_snapshot_bytes(SNAPSHOT_VERSION_V2, &payload_bytes);
1011
1012 let session = Session::import(snapshot).expect("import v2 snapshot");
1013 assert_eq!(
1014 session.restore("<a7f3b8e2:Name_1>").as_deref(),
1015 Some("Dr. Schmidt")
1016 );
1017 let next = session
1018 .tokenize_with_family("alpha", &PiiClass::Name, "Prof. Weber")
1019 .expect("next token");
1020 assert_eq!(next, "<a7f3b8e2:Name_2>");
1021 }
1022
1023 #[test]
1024 fn v0_4_0_rejects_v0_4_1_snapshot_version_3_cleanly() {
1025 let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1026 let _ = session
1027 .tokenize_with_family("alpha", &PiiClass::Name, "Dr. Schmidt")
1028 .expect("token");
1029 let snapshot = session.export().expect("snapshot");
1030
1031 assert!(matches!(
1032 legacy_v0_4_0_accepts_only_v2(&snapshot),
1033 Err(Error::InvalidSnapshotVersion(SNAPSHOT_VERSION_V5))
1034 ));
1035 }
1036
1037 #[test]
1038 fn snapshot_signature_binds_emitted_envelope_version() {
1039 let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1040 session
1041 .tokenize(&PiiClass::Name, "Dr. Schmidt")
1042 .expect("token");
1043
1044 let mut bytes = session.export().expect("snapshot").into_bytes();
1045 assert_eq!(bytes[0], SNAPSHOT_VERSION_V5);
1046 bytes[0] = SNAPSHOT_VERSION_V3;
1047
1048 assert!(matches!(
1049 Session::import(SensitiveSnapshot::from(bytes)),
1050 Err(Error::InvalidSnapshotSignature)
1051 ));
1052 }
1053
1054 #[test]
1055 fn snapshot_signature_uses_final_envelope_preimage() {
1056 let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1057 session
1058 .tokenize(&PiiClass::Email, "alice@example.invalid")
1059 .expect("token");
1060
1061 let bytes = session.export().expect("snapshot").into_bytes();
1062 let version = bytes[0];
1063 let verifying_key_bytes: [u8; 32] = bytes[1..33].try_into().expect("verifying key bytes");
1064 let verifying_key = VerifyingKey::from_bytes(&verifying_key_bytes).expect("verifying key");
1065 let signature = Signature::from_bytes(&bytes[33..97].try_into().expect("signature bytes"));
1066 let payload_bytes = &bytes[97..];
1067 let preimage = snapshot_signing_preimage(version, &verifying_key_bytes, payload_bytes);
1068
1069 assert!(verifying_key.verify(&preimage, &signature).is_ok());
1070 assert!(verifying_key.verify(payload_bytes, &signature).is_err());
1071 }
1072
1073 #[test]
1074 fn snapshot_import_rejects_signature_slot_mutation() {
1075 let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1076 session
1077 .tokenize(&PiiClass::Name, "Dr. Schmidt")
1078 .expect("token");
1079
1080 let mut bytes = session.export().expect("snapshot").into_bytes();
1081 bytes[33] ^= 0x01;
1082
1083 assert!(matches!(
1084 Session::import(SensitiveSnapshot::from(bytes)),
1085 Err(Error::InvalidSnapshotSignature)
1086 ));
1087 }
1088
1089 #[test]
1090 fn export_with_extension_round_trips_clean() {
1091 let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1092 let token = session
1093 .tokenize(&PiiClass::Name, "Dr. Schmidt")
1094 .expect("token");
1095 let extension = document_extension(&session);
1096
1097 let snapshot = session
1098 .export_with_extension(extension.clone())
1099 .expect("export with extension");
1100 let payload = snapshot_payload_json(&snapshot);
1101
1102 let document: DocumentExtension =
1103 serde_json::from_value(payload["document"].clone()).expect("document extension");
1104 assert_eq!(document, extension);
1105
1106 let imported = Session::import(snapshot).expect("import extended snapshot");
1107 assert_eq!(imported.restore(&token).as_deref(), Some("Dr. Schmidt"));
1108 }
1109
1110 #[test]
1111 fn export_with_extension_no_pii_leak() {
1112 let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1113 let _ = session
1114 .tokenize(&PiiClass::Email, "alice@example.invalid")
1115 .expect("token");
1116
1117 let snapshot = session
1118 .export_with_extension(document_extension(&session))
1119 .expect("export with extension");
1120 let payload = snapshot_payload_json(&snapshot);
1121 let document_json = serde_json::to_string(&payload["document"]).expect("document json");
1122
1123 assert!(!document_json.contains("alice@example.invalid"));
1124 assert!(!document_json.contains("\"raw\""));
1125 }
1126
1127 #[test]
1128 fn document_extension_zero_clean_md_sha256_rejected() {
1129 let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1130 let mut extension = document_extension(&session);
1131 extension.clean_md_sha256 = [0; 32];
1132
1133 assert!(matches!(
1134 session.export_with_extension(extension),
1135 Err(Error::EmptyDocumentIntegrity)
1136 ));
1137 }
1138
1139 #[test]
1140 fn document_extension_zero_layout_json_sha256_rejected() {
1141 let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1142 let mut extension = document_extension(&session);
1143 extension.layout_json_sha256 = [0; 32];
1144
1145 assert!(matches!(
1146 session.export_with_extension(extension),
1147 Err(Error::EmptyDocumentIntegrity)
1148 ));
1149 }
1150
1151 #[test]
1152 fn document_extension_zero_report_json_sha256_rejected() {
1153 let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1154 let mut extension = document_extension(&session);
1155 extension.report_json_sha256 = [0; 32];
1156
1157 assert!(matches!(
1158 session.export_with_extension(extension),
1159 Err(Error::EmptyDocumentIntegrity)
1160 ));
1161 }
1162
1163 #[test]
1164 fn document_extension_empty_audit_session_id_rejected() {
1165 let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1166 let mut extension = document_extension(&session);
1167 extension.audit_session_id.clear();
1168
1169 assert!(matches!(
1170 session.export_with_extension(extension),
1171 Err(Error::EmptyDocumentIntegrity)
1172 ));
1173 }
1174
1175 #[test]
1176 fn document_extension_with_full_integrity_signs_successfully() {
1177 let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1178 let snapshot = session
1179 .export_with_extension(document_extension(&session))
1180 .expect("export with full integrity");
1181 let payload = snapshot_payload_json(&snapshot);
1182
1183 assert_eq!(payload["document"]["schema_version"], 1);
1184 assert!(Session::import(snapshot).is_ok());
1185 }
1186
1187 #[test]
1188 fn import_rejects_forward_dated_persistent_snapshot() {
1189 let now = SystemTime::now()
1190 .duration_since(UNIX_EPOCH)
1191 .map(|duration| duration.as_secs())
1192 .unwrap_or(0);
1193 let snapshot = signed_snapshot(SnapshotPayload {
1194 scope: SnapshotScope::Persistent { ttl_secs: 300 },
1195 session_hex: "a7f3b8e2".to_string(),
1196 entries: Vec::new(),
1197 issued_at: now.saturating_add(3_600),
1198 next_by_class: Vec::new(),
1199 document: None,
1200 });
1201
1202 assert!(matches!(
1203 Session::import(snapshot),
1204 Err(Error::InvalidSnapshotSignature)
1205 ));
1206 }
1207
1208 #[test]
1209 fn tokenize_distinguishes_builtin_and_custom_class_names() {
1210 let session = Session::new(Scope::Ephemeral).expect("session");
1211 for (builtin, name) in [
1212 (PiiClass::Email, "email"),
1213 (PiiClass::Name, "name"),
1214 (PiiClass::Location, "location"),
1215 (PiiClass::Organization, "organization"),
1216 ] {
1217 let builtin_value = format!("{name}-builtin");
1218 let custom_value = format!("{name}-custom");
1219
1220 let builtin_token = session
1221 .tokenize(&builtin, &builtin_value)
1222 .expect("builtin token");
1223 let custom_class = PiiClass::custom(name);
1224 let custom_token = session
1225 .tokenize(&custom_class, &custom_value)
1226 .expect("custom token");
1227
1228 assert!(builtin_token.ends_with(&format!(":{}_1>", builtin.class_name())));
1229 assert!(custom_token.ends_with(&format!(":Custom:{name}_1>")));
1230 assert_ne!(builtin_token, custom_token);
1231 assert_eq!(
1232 session.restore(&builtin_token).as_deref(),
1233 Some(builtin_value.as_str())
1234 );
1235 assert_eq!(
1236 session.restore(&custom_token).as_deref(),
1237 Some(custom_value.as_str())
1238 );
1239 }
1240 }
1241
1242 #[test]
1243 fn tokenize_distinguishes_custom_classes_with_matching_pascal_case() {
1244 let session = Session::new(Scope::Ephemeral).expect("session");
1245 let first_class = PiiClass::custom("email");
1246 let second_class = PiiClass::custom("custom_email");
1247
1248 let first_token = session
1249 .tokenize(&first_class, "alice@corp.com")
1250 .expect("first custom token");
1251 let second_token = session
1252 .tokenize(&second_class, "hello")
1253 .expect("second custom token");
1254
1255 assert!(first_token.ends_with(":Custom:email_1>"));
1256 assert!(second_token.ends_with(":Custom:custom_email_1>"));
1257 assert_ne!(first_token, second_token);
1258 assert_eq!(
1259 session.restore(&first_token).as_deref(),
1260 Some("alice@corp.com")
1261 );
1262 assert_eq!(session.restore(&second_token).as_deref(), Some("hello"));
1263 }
1264
1265 #[test]
1266 fn snapshot_round_trip_two_families_same_class_raw_preserved_under_shared_counter() {
1267 let session = Session::new(Scope::Conversation("test".to_string())).expect("session");
1268 let alpha = session
1269 .tokenize_with_family("alpha", &PiiClass::Name, "Dr. Schmidt")
1270 .expect("alpha token");
1271 let beta = session
1272 .tokenize_with_family("beta", &PiiClass::Name, "Dr. Schmidt")
1273 .expect("beta token");
1274
1275 assert_ne!(alpha, beta);
1276 assert!(alpha.ends_with(":Name_1>"));
1277 assert!(beta.ends_with(":Name_2>"));
1278
1279 let snapshot = session.export().expect("snapshot");
1280 assert_eq!(snapshot.0[0], SNAPSHOT_VERSION_V5);
1281 let imported = Session::import(snapshot).expect("import");
1282
1283 assert_eq!(imported.restore(&alpha).as_deref(), Some("Dr. Schmidt"));
1284 assert_eq!(imported.restore(&beta).as_deref(), Some("Dr. Schmidt"));
1285 assert_eq!(
1286 imported
1287 .tokenize_with_family("alpha", &PiiClass::Name, "Dr. Schmidt")
1288 .expect("alpha stable"),
1289 alpha
1290 );
1291 assert_eq!(
1292 imported
1293 .tokenize_with_family("beta", &PiiClass::Name, "Dr. Schmidt")
1294 .expect("beta stable"),
1295 beta
1296 );
1297 }
1298}