use serde::{Deserialize, Serialize};
#[derive(Debug, thiserror::Error)]
pub enum BackupError {
#[error("database error: {0}")]
Db(#[from] rusqlite::Error),
#[error("pool error: {0}")]
Pool(#[from] r2d2::Error),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("snapshot not found: {0}")]
SnapshotNotFound(String),
#[error("invalid config: {0}")]
InvalidConfig(String),
#[error("restore failed: {0}")]
RestoreFailed(String),
}
pub type BackupResult<T> = Result<T, BackupError>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetentionRule {
pub table: String,
pub timestamp_column: String,
pub max_age_days: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotRecord {
pub id: String,
pub path: String,
pub size_bytes: i64,
pub checksum: String,
pub created_at: String,
pub node: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrgExportMeta {
pub org_id: String,
pub org_name: String,
pub exported_at: String,
pub node: String,
pub tables: Vec<String>,
pub row_counts: Vec<(String, i64)>,
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PurgeEvent {
pub table: String,
pub rows_deleted: i64,
pub cutoff_date: String,
pub executed_at: String,
}
pub fn validate_sql_identifier(name: &str) -> BackupResult<()> {
if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(BackupError::InvalidConfig(format!(
"invalid SQL identifier: {name:?}"
)));
}
Ok(())
}
pub fn default_retention_rules() -> Vec<RetentionRule> {
vec![
RetentionRule {
table: "audit_log".into(),
timestamp_column: "created_at".into(),
max_age_days: 365,
},
RetentionRule {
table: "ipc_messages".into(),
timestamp_column: "created_at".into(),
max_age_days: 30,
},
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_rules_cover_required_tables() {
let rules = default_retention_rules();
assert_eq!(rules.len(), 2);
assert_eq!(rules[0].table, "audit_log");
assert_eq!(rules[0].max_age_days, 365);
assert_eq!(rules[1].table, "ipc_messages");
assert_eq!(rules[1].max_age_days, 30);
}
#[test]
fn snapshot_record_serializes() {
let rec = SnapshotRecord {
id: "snap-001".into(),
path: "/tmp/backup.db".into(),
size_bytes: 1024,
checksum: "abc123".into(),
created_at: "2026-04-03T00:00:00Z".into(),
node: "m5max".into(),
};
let json = serde_json::to_string(&rec).unwrap();
assert!(json.contains("snap-001"));
}
#[test]
fn purge_event_serializes() {
let ev = PurgeEvent {
table: "audit_log".into(),
rows_deleted: 42,
cutoff_date: "2025-04-03".into(),
executed_at: "2026-04-03T00:00:00Z".into(),
};
let json = serde_json::to_string(&ev).unwrap();
assert!(json.contains("audit_log"));
assert!(json.contains("42"));
}
#[test]
fn validate_sql_identifier_accepts_valid() {
assert!(validate_sql_identifier("audit_log").is_ok());
assert!(validate_sql_identifier("table1").is_ok());
assert!(validate_sql_identifier("created_at").is_ok());
}
#[test]
fn validate_sql_identifier_rejects_injection() {
assert!(validate_sql_identifier("").is_err());
assert!(validate_sql_identifier("table; DROP TABLE x").is_err());
assert!(validate_sql_identifier("audit-log").is_err());
assert!(validate_sql_identifier("../etc/passwd").is_err());
assert!(validate_sql_identifier("table\0name").is_err());
}
}