Skip to main content

oxios_kernel/
backup.rs

1//! Backup and restore for Oxios state.
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::path::Path;
6
7/// Backup manifest.
8#[derive(Debug, Serialize, Deserialize)]
9pub struct BackupManifest {
10    /// Manifest format version.
11    pub version: u32,
12    /// Timestamp when the backup was created.
13    pub created_at: String,
14    /// Oxios version that created the backup.
15    pub oxios_version: String,
16    /// Sections included in the backup.
17    pub sections: Vec<BackupSection>,
18}
19
20/// A single section in a backup manifest.
21#[derive(Debug, Serialize, Deserialize)]
22pub struct BackupSection {
23    /// Section name (e.g., "sessions", "memory/facts").
24    pub name: String,
25    /// Number of entries in this section.
26    pub entry_count: usize,
27}
28
29/// Create a backup by copying the state directory.
30pub async fn create_backup(
31    state_store: &crate::state_store::StateStore,
32    output_path: &Path,
33) -> Result<BackupManifest> {
34    let mut manifest = BackupManifest {
35        version: 1,
36        created_at: chrono::Utc::now().to_rfc3339(),
37        oxios_version: env!("CARGO_PKG_VERSION").to_string(),
38        sections: Vec::new(),
39    };
40
41    let categories = [
42        "evals",
43        "memory/conversations",
44        "memory/sessions",
45        "memory/facts",
46        "memory/episodes",
47        "memory/knowledge",
48        "sessions",
49        "agent_groups",
50    ];
51
52    for category in &categories {
53        if let Ok(names) = state_store.list_category(category).await
54            && !names.is_empty()
55        {
56            manifest.sections.push(BackupSection {
57                name: category.to_string(),
58                entry_count: names.len(),
59            });
60        }
61    }
62
63    // Copy the entire state directory
64    let src = &state_store.base_path;
65    if output_path.exists() {
66        tokio::fs::remove_dir_all(output_path).await?;
67    }
68    copy_dir_recursive(src, output_path).await?;
69
70    // Write manifest into backup
71    let manifest_json = serde_json::to_string_pretty(&manifest)?;
72    tokio::fs::write(output_path.join("manifest.json"), manifest_json).await?;
73
74    tracing::info!(path = %output_path.display(), sections = manifest.sections.len(), "Backup created");
75    Ok(manifest)
76}
77
78/// Restore state from a backup directory.
79pub async fn restore_backup(
80    state_store: &crate::state_store::StateStore,
81    backup_path: &Path,
82) -> Result<BackupManifest> {
83    let manifest_data = tokio::fs::read_to_string(backup_path.join("manifest.json"))
84        .await
85        .context("Backup missing manifest.json")?;
86    let manifest: BackupManifest = serde_json::from_str(&manifest_data)?;
87
88    // Validate manifest version before touching state — restoring from an
89    // unsupported (potentially malicious or future) backup could inject
90    // incompatible state shapes. We currently only write version 1.
91    const SUPPORTED_MANIFEST_VERSION: u32 = 1;
92    if manifest.version != SUPPORTED_MANIFEST_VERSION {
93        anyhow::bail!(
94            "Unsupported backup manifest version {}: only version {} is supported",
95            manifest.version,
96            SUPPORTED_MANIFEST_VERSION
97        );
98    }
99
100    // Refuse to restore from a backup whose declared sections are empty — this
101    // is almost certainly an empty/corrupt directory and would wipe live state.
102    if manifest.sections.is_empty() {
103        anyhow::bail!(
104            "Backup manifest declares no sections — refusing to restore an empty backup over live state"
105        );
106    }
107
108    // Copy backup into state directory
109    copy_dir_recursive(backup_path, &state_store.base_path).await?;
110
111    tracing::info!(path = %backup_path.display(), sections = manifest.sections.len(), "Backup restored");
112    Ok(manifest)
113}
114
115fn copy_dir_recursive<'a>(
116    src: &'a Path,
117    dest: &'a Path,
118) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
119    Box::pin(async move {
120        tokio::fs::create_dir_all(dest).await?;
121        let mut entries = tokio::fs::read_dir(src).await?;
122        while let Some(entry) = entries.next_entry().await? {
123            let src_path = entry.path();
124            let dest_path = dest.join(entry.file_name());
125            // Use symlink_metadata so we never follow symlinks during restore.
126            // A symlink inside a (possibly untrusted) backup could otherwise
127            // point anywhere on the filesystem and let restore write outside
128            // the state directory.
129            let meta = tokio::fs::symlink_metadata(&src_path).await?;
130            let ft = meta.file_type();
131            if ft.is_symlink() {
132                tracing::warn!(
133                    src = %src_path.display(),
134                    "backup: skipping symlink during copy (will not follow)"
135                );
136                continue;
137            }
138            if ft.is_dir() {
139                copy_dir_recursive(&src_path, &dest_path).await?;
140            } else {
141                tokio::fs::copy(&src_path, &dest_path).await?;
142            }
143        }
144        Ok(())
145    })
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn test_backup_manifest_serialization() {
154        let manifest = BackupManifest {
155            version: 1,
156            created_at: "2025-01-01T00:00:00Z".to_string(),
157            oxios_version: "0.1.0".to_string(),
158            sections: vec![
159                BackupSection {
160                    name: "sessions".to_string(),
161                    entry_count: 42,
162                },
163                BackupSection {
164                    name: "memory/facts".to_string(),
165                    entry_count: 100,
166                },
167            ],
168        };
169
170        let json = serde_json::to_string_pretty(&manifest).unwrap();
171        let restored: BackupManifest = serde_json::from_str(&json).unwrap();
172
173        assert_eq!(restored.version, 1);
174        assert_eq!(restored.oxios_version, "0.1.0");
175        assert_eq!(restored.sections.len(), 2);
176        assert_eq!(restored.sections[0].name, "sessions");
177        assert_eq!(restored.sections[0].entry_count, 42);
178        assert_eq!(restored.sections[1].name, "memory/facts");
179        assert_eq!(restored.sections[1].entry_count, 100);
180    }
181
182    #[test]
183    fn test_backup_section_ordering() {
184        let sections = vec![
185            BackupSection {
186                name: "a".into(),
187                entry_count: 1,
188            },
189            BackupSection {
190                name: "b".into(),
191                entry_count: 2,
192            },
193        ];
194        let manifest = BackupManifest {
195            version: 1,
196            created_at: String::new(),
197            oxios_version: String::new(),
198            sections,
199        };
200        assert_eq!(manifest.sections[0].name, "a");
201        assert_eq!(manifest.sections[1].name, "b");
202    }
203
204    #[test]
205    fn test_backup_manifest_empty_sections() {
206        let manifest = BackupManifest {
207            version: 1,
208            created_at: "2025-01-01T00:00:00Z".to_string(),
209            oxios_version: "0.1.0".to_string(),
210            sections: vec![],
211        };
212        assert!(manifest.sections.is_empty());
213
214        let json = serde_json::to_string(&manifest).unwrap();
215        let restored: BackupManifest = serde_json::from_str(&json).unwrap();
216        assert!(restored.sections.is_empty());
217    }
218
219    #[tokio::test]
220    async fn test_copy_dir_recursive_basic() {
221        let src_dir = tempfile::tempdir().unwrap();
222        let dest_dir = tempfile::tempdir().unwrap();
223
224        // Create source files
225        tokio::fs::write(src_dir.path().join("file1.txt"), "hello")
226            .await
227            .unwrap();
228        tokio::fs::create_dir_all(src_dir.path().join("subdir"))
229            .await
230            .unwrap();
231        tokio::fs::write(src_dir.path().join("subdir/file2.txt"), "world")
232            .await
233            .unwrap();
234
235        copy_dir_recursive(src_dir.path(), dest_dir.path())
236            .await
237            .unwrap();
238
239        assert!(dest_dir.path().join("file1.txt").exists());
240        assert!(dest_dir.path().join("subdir/file2.txt").exists());
241
242        let content = tokio::fs::read_to_string(dest_dir.path().join("file1.txt"))
243            .await
244            .unwrap();
245        assert_eq!(content, "hello");
246    }
247}