Skip to main content

convergio_backup/
types.rs

1//! Core types for the backup module.
2
3use serde::{Deserialize, Serialize};
4
5/// Errors produced by the backup module.
6#[derive(Debug, thiserror::Error)]
7pub enum BackupError {
8    #[error("database error: {0}")]
9    Db(#[from] rusqlite::Error),
10
11    #[error("pool error: {0}")]
12    Pool(#[from] r2d2::Error),
13
14    #[error("io error: {0}")]
15    Io(#[from] std::io::Error),
16
17    #[error("json error: {0}")]
18    Json(#[from] serde_json::Error),
19
20    #[error("snapshot not found: {0}")]
21    SnapshotNotFound(String),
22
23    #[error("invalid config: {0}")]
24    InvalidConfig(String),
25
26    #[error("restore failed: {0}")]
27    RestoreFailed(String),
28}
29
30pub type BackupResult<T> = Result<T, BackupError>;
31
32/// Retention policy for a single table.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct RetentionRule {
35    /// Table name to apply the policy to.
36    pub table: String,
37    /// Column containing the timestamp (e.g. "created_at").
38    pub timestamp_column: String,
39    /// Maximum age in days. Rows older than this are purged.
40    pub max_age_days: u32,
41}
42
43/// A completed snapshot record.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct SnapshotRecord {
46    pub id: String,
47    pub path: String,
48    pub size_bytes: i64,
49    pub checksum: String,
50    pub created_at: String,
51    pub node: String,
52}
53
54/// Org export package metadata.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct OrgExportMeta {
57    pub org_id: String,
58    pub org_name: String,
59    pub exported_at: String,
60    pub node: String,
61    pub tables: Vec<String>,
62    pub row_counts: Vec<(String, i64)>,
63    pub version: String,
64}
65
66/// Purge event — emitted after auto-purge runs.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct PurgeEvent {
69    pub table: String,
70    pub rows_deleted: i64,
71    pub cutoff_date: String,
72    pub executed_at: String,
73}
74
75/// Validate that a SQL identifier (table or column name) contains only
76/// safe characters: `[a-zA-Z0-9_]`. Prevents SQL injection when identifiers
77/// must be interpolated into queries (SQLite does not support parameterised
78/// identifiers).
79pub fn validate_sql_identifier(name: &str) -> BackupResult<()> {
80    if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
81        return Err(BackupError::InvalidConfig(format!(
82            "invalid SQL identifier: {name:?}"
83        )));
84    }
85    Ok(())
86}
87
88/// Default retention rules per spec.
89pub fn default_retention_rules() -> Vec<RetentionRule> {
90    vec![
91        RetentionRule {
92            table: "audit_log".into(),
93            timestamp_column: "created_at".into(),
94            max_age_days: 365,
95        },
96        RetentionRule {
97            table: "ipc_messages".into(),
98            timestamp_column: "created_at".into(),
99            max_age_days: 30,
100        },
101    ]
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn default_rules_cover_required_tables() {
110        let rules = default_retention_rules();
111        assert_eq!(rules.len(), 2);
112        assert_eq!(rules[0].table, "audit_log");
113        assert_eq!(rules[0].max_age_days, 365);
114        assert_eq!(rules[1].table, "ipc_messages");
115        assert_eq!(rules[1].max_age_days, 30);
116    }
117
118    #[test]
119    fn snapshot_record_serializes() {
120        let rec = SnapshotRecord {
121            id: "snap-001".into(),
122            path: "/tmp/backup.db".into(),
123            size_bytes: 1024,
124            checksum: "abc123".into(),
125            created_at: "2026-04-03T00:00:00Z".into(),
126            node: "m5max".into(),
127        };
128        let json = serde_json::to_string(&rec).unwrap();
129        assert!(json.contains("snap-001"));
130    }
131
132    #[test]
133    fn purge_event_serializes() {
134        let ev = PurgeEvent {
135            table: "audit_log".into(),
136            rows_deleted: 42,
137            cutoff_date: "2025-04-03".into(),
138            executed_at: "2026-04-03T00:00:00Z".into(),
139        };
140        let json = serde_json::to_string(&ev).unwrap();
141        assert!(json.contains("audit_log"));
142        assert!(json.contains("42"));
143    }
144
145    #[test]
146    fn validate_sql_identifier_accepts_valid() {
147        assert!(validate_sql_identifier("audit_log").is_ok());
148        assert!(validate_sql_identifier("table1").is_ok());
149        assert!(validate_sql_identifier("created_at").is_ok());
150    }
151
152    #[test]
153    fn validate_sql_identifier_rejects_injection() {
154        assert!(validate_sql_identifier("").is_err());
155        assert!(validate_sql_identifier("table; DROP TABLE x").is_err());
156        assert!(validate_sql_identifier("audit-log").is_err());
157        assert!(validate_sql_identifier("../etc/passwd").is_err());
158        assert!(validate_sql_identifier("table\0name").is_err());
159    }
160}