use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[cfg(feature = "wasm")]
use wasm_bindgen::prelude::*;
pub const ARCHIVE_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ExportArchive {
pub archive_version: u32,
pub exported_at: String,
pub user_id: String,
pub user_name: String,
pub user_email: String,
pub user_created_at: String,
pub documents: Vec<ExportDocument>,
pub api_key_hashes: Vec<ExportApiKey>,
pub audit_log: Vec<ExportAuditEntry>,
pub webhooks: Vec<ExportWebhook>,
pub content_hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ExportDocument {
pub id: String,
pub slug: String,
pub title: Option<String>,
pub state: String,
pub format: String,
pub created_at: String,
pub updated_at: Option<String>,
pub content: String,
pub versions: Vec<ExportVersion>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ExportVersion {
pub version_number: u32,
pub content_hash: String,
pub created_at: String,
pub created_by: Option<String>,
pub changelog: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ExportApiKey {
pub id: String,
pub name: String,
pub key_prefix: String,
pub key_hash: String,
pub created_at: String,
pub expires_at: Option<String>,
pub revoked: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ExportAuditEntry {
pub id: String,
pub action: String,
pub resource_type: String,
pub resource_id: Option<String>,
pub timestamp: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ExportWebhook {
pub id: String,
pub url: String,
pub events: String,
pub document_slug: Option<String>,
pub active: bool,
pub created_at: String,
}
fn compute_content_hash(archive: &ExportArchive) -> String {
let mut scratch = archive.clone();
scratch.content_hash = String::new();
#[allow(clippy::expect_used)]
let canonical = serde_json::to_vec(&scratch)
.expect("ExportArchive serialisation must not fail — no unbounded types");
let mut hasher = Sha256::new();
hasher.update(&canonical);
hex::encode(hasher.finalize())
}
pub fn serialize_export_archive(archive: &ExportArchive) -> String {
let mut stamped = archive.clone();
stamped.content_hash = compute_content_hash(archive);
#[allow(clippy::expect_used)]
serde_json::to_string(&stamped).expect("ExportArchive serialisation must not fail")
}
pub fn deserialize_export_archive(json: &str) -> Result<ExportArchive, String> {
let archive: ExportArchive = serde_json::from_str(json)
.map_err(|e| format!("deserialize_export_archive: JSON parse error: {e}"))?;
if archive.archive_version > ARCHIVE_VERSION {
return Err(format!(
"deserialize_export_archive: unsupported archive_version {}; max supported: {}",
archive.archive_version, ARCHIVE_VERSION
));
}
let expected_hash = compute_content_hash(&archive);
if archive.content_hash != expected_hash {
return Err(format!(
"deserialize_export_archive: content_hash mismatch — \
expected {expected_hash}, got {}",
archive.content_hash
));
}
Ok(archive)
}
#[cfg_attr(feature = "wasm", wasm_bindgen)]
pub fn serialize_export_archive_wasm(archive_json: &str) -> String {
let archive: ExportArchive = match serde_json::from_str(archive_json) {
Ok(a) => a,
Err(e) => {
return format!(r#"{{"error":"serialize_export_archive_wasm parse error: {e}"}}"#);
}
};
serialize_export_archive(&archive)
}
#[cfg_attr(feature = "wasm", wasm_bindgen)]
pub fn deserialize_export_archive_wasm(archive_json: &str) -> String {
match deserialize_export_archive(archive_json) {
Ok(archive) => serde_json::to_string(&archive)
.unwrap_or_else(|e| format!(r#"{{"error":"re-serialise failed: {e}"}}"#)),
Err(e) => format!(r#"{{"error":{}}}"#, serde_json::json!(e)),
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RetentionPolicy {
pub policy_version: u32,
pub audit_log_hot_days: u32,
pub audit_log_total_days: u32,
pub soft_deleted_docs_days: u32,
pub anonymous_doc_days: u32,
pub revoked_api_key_days: u32,
pub agent_inbox_days: u32,
}
impl Default for RetentionPolicy {
fn default() -> Self {
Self {
policy_version: 1,
audit_log_hot_days: 90,
audit_log_total_days: 2555,
soft_deleted_docs_days: 30,
anonymous_doc_days: 1,
revoked_api_key_days: 90,
agent_inbox_days: 2,
}
}
}
pub fn serialize_retention_policy(policy: &RetentionPolicy) -> String {
#[allow(clippy::expect_used)]
serde_json::to_string(policy).expect("RetentionPolicy serialisation must not fail")
}
pub fn deserialize_retention_policy(json: &str) -> Result<RetentionPolicy, String> {
serde_json::from_str(json).map_err(|e| format!("deserialize_retention_policy: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_archive() -> ExportArchive {
ExportArchive {
archive_version: ARCHIVE_VERSION,
exported_at: "2026-04-18T00:00:00Z".to_string(),
user_id: "usr_test".to_string(),
user_name: "Test User".to_string(),
user_email: "test@example.com".to_string(),
user_created_at: "2026-01-01T00:00:00Z".to_string(),
documents: vec![ExportDocument {
id: "doc_1".to_string(),
slug: "abcd1234".to_string(),
title: Some("My Document".to_string()),
state: "DRAFT".to_string(),
format: "markdown".to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: Some("2026-01-02T00:00:00Z".to_string()),
content: "# My Document\n\nHello world.".to_string(),
versions: vec![ExportVersion {
version_number: 0,
content_hash: "abc123".to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(),
created_by: Some("agent_1".to_string()),
changelog: None,
}],
}],
api_key_hashes: vec![ExportApiKey {
id: "key_1".to_string(),
name: "CI Bot".to_string(),
key_prefix: "llmtxt_abc".to_string(),
key_hash: "deadbeef".to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(),
expires_at: None,
revoked: false,
}],
audit_log: vec![ExportAuditEntry {
id: "evt_1".to_string(),
action: "document.create".to_string(),
resource_type: "document".to_string(),
resource_id: Some("abcd1234".to_string()),
timestamp: 1_700_000_000_000,
}],
webhooks: vec![ExportWebhook {
id: "wh_1".to_string(),
url: "https://example.com/hook".to_string(),
events: r#"["version.created"]"#.to_string(),
document_slug: None,
active: true,
created_at: "2026-01-01T00:00:00Z".to_string(),
}],
content_hash: String::new(), }
}
#[test]
fn test_serialize_sets_content_hash() {
let archive = sample_archive();
let json = serialize_export_archive(&archive);
let parsed: ExportArchive = serde_json::from_str(&json).unwrap();
assert!(
!parsed.content_hash.is_empty(),
"content_hash should be set"
);
assert_eq!(parsed.content_hash.len(), 64, "SHA-256 hex is 64 chars");
}
#[test]
fn test_deserialize_verifies_hash() {
let json = serialize_export_archive(&sample_archive());
let result = deserialize_export_archive(&json);
assert!(
result.is_ok(),
"valid archive should deserialise: {:?}",
result
);
}
#[test]
fn test_tampered_archive_rejected() {
let json = serialize_export_archive(&sample_archive());
let tampered = json.replace("Hello world.", "Hello tamper.");
let result = deserialize_export_archive(&tampered);
assert!(result.is_err(), "tampered archive must be rejected");
assert!(
result.unwrap_err().contains("content_hash mismatch"),
"error should mention hash mismatch"
);
}
#[test]
fn test_roundtrip_byte_identical() {
let archive = sample_archive();
let json1 = serialize_export_archive(&archive);
let recovered = deserialize_export_archive(&json1).unwrap();
let json2 = serialize_export_archive(&recovered);
assert_eq!(json1, json2, "round-trip must be byte-identical");
}
#[test]
fn test_unsupported_version_rejected() {
let mut archive = sample_archive();
archive.archive_version = ARCHIVE_VERSION + 1;
archive.content_hash = String::new();
let hash = {
let canonical = serde_json::to_vec(&archive).unwrap();
let mut hasher = sha2::Sha256::new();
hasher.update(&canonical);
hex::encode(hasher.finalize())
};
archive.content_hash = hash;
let json = serde_json::to_string(&archive).unwrap();
let result = deserialize_export_archive(&json);
assert!(result.is_err());
assert!(
result.unwrap_err().contains("unsupported archive_version"),
"error should mention unsupported version"
);
}
#[test]
fn test_retention_policy_default_roundtrip() {
let policy = RetentionPolicy::default();
let json = serialize_retention_policy(&policy);
let recovered = deserialize_retention_policy(&json).unwrap();
assert_eq!(policy, recovered);
}
#[test]
fn test_retention_policy_defaults() {
let p = RetentionPolicy::default();
assert_eq!(p.audit_log_hot_days, 90);
assert_eq!(p.audit_log_total_days, 2555);
assert_eq!(p.soft_deleted_docs_days, 30);
assert_eq!(p.agent_inbox_days, 2);
}
}