use std::io::{Read, Write};
use flate2::read::GzDecoder;
use flate2::write::GzEncoder;
use flate2::Compression;
use serde::{Deserialize, Serialize};
use super::encryption::{decrypt_data, encrypt_data};
use super::SyncError;
use crate::storage::models::{Annotation, Message, Session, SessionLink, Summary, Tag, Tombstone};
#[derive(Clone, Serialize, Deserialize)]
pub struct SessionRecord {
pub session: Session,
pub messages: Vec<Message>,
pub links: Vec<SessionLink>,
pub tags: Vec<Tag>,
pub annotations: Vec<Annotation>,
pub summary: Option<Summary>,
}
impl std::fmt::Debug for SessionRecord {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SessionRecord")
.field("session_id", &self.session.id)
.field("tool", &self.session.tool)
.field("message_count", &self.messages.len())
.field("link_count", &self.links.len())
.field("tag_count", &self.tags.len())
.field("annotation_count", &self.annotations.len())
.field("has_summary", &self.summary.is_some())
.finish()
}
}
pub fn encrypt_session_record(record: &SessionRecord, key: &[u8]) -> Result<Vec<u8>, SyncError> {
let json = serde_json::to_vec(record)
.map_err(|e| SyncError::Serialization(format!("Failed to serialize record: {e}")))?;
let compressed = gzip_compress(&json)?;
encrypt_data(&compressed, key)
}
pub fn decrypt_session_record(blob: &[u8], key: &[u8]) -> Result<SessionRecord, SyncError> {
let compressed = decrypt_data(blob, key)?;
let json = gzip_decompress(&compressed)?;
serde_json::from_slice(&json)
.map_err(|e| SyncError::Serialization(format!("Failed to deserialize record: {e}")))
}
pub fn encrypt_tombstones(tombstones: &[Tombstone], key: &[u8]) -> Result<Vec<u8>, SyncError> {
let json = serde_json::to_vec(tombstones)
.map_err(|e| SyncError::Serialization(format!("Failed to serialize tombstones: {e}")))?;
let compressed = gzip_compress(&json)?;
encrypt_data(&compressed, key)
}
pub fn decrypt_tombstones(blob: &[u8], key: &[u8]) -> Result<Vec<Tombstone>, SyncError> {
let compressed = decrypt_data(blob, key)?;
let json = gzip_decompress(&compressed)?;
serde_json::from_slice(&json)
.map_err(|e| SyncError::Serialization(format!("Failed to deserialize tombstones: {e}")))
}
fn gzip_compress(data: &[u8]) -> Result<Vec<u8>, SyncError> {
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder
.write_all(data)
.map_err(|e| SyncError::Compression(format!("Gzip write failed: {e}")))?;
encoder
.finish()
.map_err(|e| SyncError::Compression(format!("Gzip finish failed: {e}")))
}
fn gzip_decompress(data: &[u8]) -> Result<Vec<u8>, SyncError> {
let mut decoder = GzDecoder::new(data);
let mut out = Vec::new();
decoder
.read_to_end(&mut out)
.map_err(|e| SyncError::Compression(format!("Gzip read failed: {e}")))?;
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::models::{LinkCreator, LinkType, MessageContent, MessageRole};
use crate::sync::encryption::{derive_key, generate_salt};
use chrono::Utc;
use uuid::Uuid;
fn sample_record() -> SessionRecord {
let session_id = Uuid::new_v4();
let session = Session {
id: session_id,
tool: "claude-code".to_string(),
tool_version: Some("2.0.0".to_string()),
started_at: Utc::now(),
ended_at: Some(Utc::now()),
model: Some("claude-opus".to_string()),
working_directory: "/home/user/project".to_string(),
git_branch: Some("main".to_string()),
source_path: Some("/sessions/a.jsonl".to_string()),
message_count: 2,
machine_id: Some("machine-1".to_string()),
};
let messages = vec![
Message {
id: Uuid::new_v4(),
session_id,
parent_id: None,
index: 0,
timestamp: Utc::now(),
role: MessageRole::User,
content: MessageContent::Text("Fix the bug".to_string()),
model: None,
git_branch: Some("main".to_string()),
cwd: Some("/home/user/project".to_string()),
},
Message {
id: Uuid::new_v4(),
session_id,
parent_id: None,
index: 1,
timestamp: Utc::now(),
role: MessageRole::Assistant,
content: MessageContent::Text("Done.".to_string()),
model: Some("claude-opus".to_string()),
git_branch: Some("main".to_string()),
cwd: Some("/home/user/project".to_string()),
},
];
let links = vec![SessionLink {
id: Uuid::new_v4(),
session_id,
link_type: LinkType::Commit,
commit_sha: Some("abc123".to_string()),
branch: Some("main".to_string()),
remote: Some("origin".to_string()),
created_at: Utc::now(),
created_by: LinkCreator::User,
confidence: Some(0.95),
}];
let tags = vec![Tag {
id: Uuid::new_v4(),
session_id,
label: "bug-fix".to_string(),
created_at: Utc::now(),
}];
let annotations = vec![Annotation {
id: Uuid::new_v4(),
session_id,
content: "Important fix".to_string(),
created_at: Utc::now(),
}];
let summary = Some(Summary {
id: Uuid::new_v4(),
session_id,
content: "Fixed a bug in the parser".to_string(),
generated_at: Utc::now(),
});
SessionRecord {
session,
messages,
links,
tags,
annotations,
summary,
}
}
#[test]
fn test_gzip_roundtrip() {
let data = b"the quick brown fox jumps over the lazy dog".repeat(100);
let compressed = gzip_compress(&data).unwrap();
let decompressed = gzip_decompress(&compressed).unwrap();
assert_eq!(decompressed, data);
}
#[test]
fn test_gzip_compresses_repetitive_data() {
let data = vec![b'a'; 10_000];
let compressed = gzip_compress(&data).unwrap();
assert!(compressed.len() < data.len());
}
#[test]
fn test_encrypt_decrypt_record_roundtrip() {
let salt = generate_salt();
let key = derive_key("test passphrase", &salt).unwrap();
let record = sample_record();
let blob = encrypt_session_record(&record, &key).unwrap();
let restored = decrypt_session_record(&blob, &key).unwrap();
assert_eq!(restored.session.id, record.session.id);
assert_eq!(restored.messages.len(), record.messages.len());
assert_eq!(restored.messages[0].content.text(), "Fix the bug");
assert_eq!(restored.links.len(), 1);
assert_eq!(restored.links[0].commit_sha, Some("abc123".to_string()));
assert_eq!(restored.tags[0].label, "bug-fix");
assert_eq!(restored.annotations[0].content, "Important fix");
assert_eq!(
restored.summary.unwrap().content,
"Fixed a bug in the parser"
);
}
#[test]
fn test_full_record_serialization_preserves_all_fields() {
let record = sample_record();
let json = serde_json::to_vec(&record).unwrap();
let restored: SessionRecord = serde_json::from_slice(&json).unwrap();
assert_eq!(restored.session.tool, "claude-code");
assert_eq!(restored.messages.len(), 2);
assert_eq!(restored.links.len(), 1);
assert_eq!(restored.tags.len(), 1);
assert_eq!(restored.annotations.len(), 1);
assert!(restored.summary.is_some());
}
#[test]
fn test_decrypt_record_wrong_key_fails() {
let salt = generate_salt();
let key = derive_key("passphrase1", &salt).unwrap();
let wrong_key = derive_key("passphrase2", &salt).unwrap();
let record = sample_record();
let blob = encrypt_session_record(&record, &key).unwrap();
let result = decrypt_session_record(&blob, &wrong_key);
assert!(result.is_err());
}
#[test]
fn test_debug_does_not_leak_plaintext() {
let record = sample_record();
let debug = format!("{record:?}");
assert!(!debug.contains("Fix the bug"));
assert!(!debug.contains("Important fix"));
assert!(!debug.contains("Fixed a bug in the parser"));
assert!(debug.contains("SessionRecord"));
assert!(debug.contains("claude-code"));
assert!(debug.contains("message_count"));
assert!(debug.contains("has_summary"));
}
fn init_test_repo(repo: &std::path::Path) {
for args in [
vec!["init", "-q"],
vec!["config", "user.name", "Lore Test"],
vec!["config", "user.email", "test@example.com"],
] {
let status = std::process::Command::new("git")
.current_dir(repo)
.args(&args)
.status()
.expect("failed to spawn git");
assert!(status.success(), "git {args:?} failed");
}
}
#[test]
fn test_cross_module_round_trip_through_git_blob() {
use crate::sync::gitref;
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
init_test_repo(repo);
let salt = generate_salt();
let key = derive_key("cross module passphrase", &salt).unwrap();
let record = sample_record();
let blob = encrypt_session_record(&record, &key).unwrap();
let sha = gitref::write_blob(repo, &blob).unwrap();
let read_back = gitref::read_blob(repo, &sha).unwrap();
assert_eq!(read_back, blob, "git blob round-trip must be byte-exact");
let restored = decrypt_session_record(&read_back, &key).unwrap();
assert_eq!(restored.session.id, record.session.id);
assert_eq!(restored.messages.len(), record.messages.len());
assert_eq!(restored.messages[0].content.text(), "Fix the bug");
assert_eq!(restored.links[0].commit_sha, Some("abc123".to_string()));
assert_eq!(
restored.summary.unwrap().content,
"Fixed a bug in the parser"
);
}
#[test]
fn test_binary_blob_round_trip_through_git() {
use crate::sync::gitref;
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
init_test_repo(repo);
let fixture: Vec<u8> = vec![
0x00, 0x0a, 0x0d, 0xff, 0x80, b'a', 0x00, 0x81, 0xfe, 0x0a, 0x20, 0x00,
];
let sha = gitref::write_blob(repo, &fixture).unwrap();
let read_back = gitref::read_blob(repo, &sha).unwrap();
assert_eq!(read_back, fixture, "binary blob must survive byte-for-byte");
}
#[test]
fn test_encrypt_decrypt_tombstones_roundtrip() {
let salt = generate_salt();
let key = derive_key("tombstone passphrase", &salt).unwrap();
let tombstones = vec![
Tombstone {
child_id: Uuid::new_v4().to_string(),
kind: "link".to_string(),
session_id: Some(Uuid::new_v4().to_string()),
deleted_at: Utc::now(),
},
Tombstone {
child_id: Uuid::new_v4().to_string(),
kind: "summary".to_string(),
session_id: None,
deleted_at: Utc::now(),
},
];
let blob = encrypt_tombstones(&tombstones, &key).unwrap();
let restored = decrypt_tombstones(&blob, &key).unwrap();
assert_eq!(restored, tombstones);
}
#[test]
fn test_decrypt_tombstones_wrong_key_fails() {
let salt = generate_salt();
let key = derive_key("right", &salt).unwrap();
let wrong = derive_key("wrong", &salt).unwrap();
let tombstones = vec![Tombstone {
child_id: Uuid::new_v4().to_string(),
kind: "tag".to_string(),
session_id: None,
deleted_at: Utc::now(),
}];
let blob = encrypt_tombstones(&tombstones, &key).unwrap();
assert!(decrypt_tombstones(&blob, &wrong).is_err());
}
#[test]
fn test_record_with_no_summary() {
let salt = generate_salt();
let key = derive_key("passphrase", &salt).unwrap();
let mut record = sample_record();
record.summary = None;
record.links.clear();
record.tags.clear();
record.annotations.clear();
let blob = encrypt_session_record(&record, &key).unwrap();
let restored = decrypt_session_record(&blob, &key).unwrap();
assert!(restored.summary.is_none());
assert!(restored.links.is_empty());
assert!(restored.tags.is_empty());
assert!(restored.annotations.is_empty());
}
}