Skip to main content

claw_core/
snapshot.rs

1//! Snapshot and restore logic for claw-core.
2//!
3//! This module implements point-in-time snapshots of the SQLite database.
4//! Snapshots are plain copies of the database file written to a configurable
5//! snapshot directory. A WAL checkpoint is performed before the copy so that
6//! all committed data is in the main database file.
7
8use std::path::{Path, PathBuf};
9
10use crate::error::{ClawError, ClawResult};
11use serde::{Deserialize, Serialize};
12
13/// Metadata describing a snapshot created by [`Snapshotter::take`].
14///
15/// # Example
16///
17/// ```rust,no_run
18/// # use claw_core::{ClawEngine, ClawConfig};
19/// # async fn example() -> claw_core::ClawResult<()> {
20/// # let config = ClawConfig::builder()
21/// #     .db_path("/tmp/snap_test.db")
22/// #     .snapshot_dir("/tmp/snaps")
23/// #     .build()?;
24/// # let engine = ClawEngine::open(config).await?;
25/// let meta = engine.snapshot_create().await?;
26/// println!("snapshot size: {} bytes", meta.size_bytes);
27/// # Ok(())
28/// # }
29/// ```
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct SnapshotMeta {
32    /// Absolute path to the snapshot file.
33    pub path: PathBuf,
34    /// Timestamp when the snapshot was taken.
35    pub created_at: chrono::DateTime<chrono::Utc>,
36    /// Size of the snapshot file in bytes.
37    pub size_bytes: u64,
38    /// BLAKE3 hex-encoded checksum of the snapshot file.
39    pub checksum: String,
40}
41
42/// A JSON manifest that tracks all snapshots in a snapshot directory.
43///
44/// Persisted to `<snapshot_dir>/manifest.json` and updated each time a new
45/// snapshot is taken.
46///
47/// # Example
48///
49/// ```rust,no_run
50/// # use claw_core::Snapshotter;
51/// let s = Snapshotter::new("/tmp/snaps").expect("ok");
52/// let manifest = s.load_manifest().expect("manifest");
53/// println!("{} snapshots tracked", manifest.entries.len());
54/// ```
55#[derive(Debug, Clone, Default, Serialize, Deserialize)]
56pub struct SnapshotManifest {
57    /// Ordered list of snapshot metadata entries (oldest first).
58    pub entries: Vec<SnapshotMeta>,
59}
60
61impl SnapshotManifest {
62    /// Create an empty manifest.
63    pub fn new() -> Self {
64        Self::default()
65    }
66}
67
68/// Manages snapshot creation and restoration for a claw-core engine.
69///
70/// Construct via [`Snapshotter::new`], then call [`Snapshotter::take`] or
71/// [`Snapshotter::restore`] as required.
72///
73/// # Example
74///
75/// ```rust,no_run
76/// # use claw_core::Snapshotter;
77/// # use std::path::Path;
78/// let snapper = Snapshotter::new("/tmp/snapshots").expect("snapshotter");
79/// ```
80#[derive(Debug)]
81pub struct Snapshotter {
82    /// Directory where snapshot files are written.
83    snapshot_dir: PathBuf,
84}
85
86impl Snapshotter {
87    /// Create a new [`Snapshotter`] that stores snapshots in `snapshot_dir`.
88    ///
89    /// # Errors
90    ///
91    /// Returns [`ClawError::Snapshot`] if `snapshot_dir` cannot be created.
92    ///
93    /// # Example
94    ///
95    /// ```rust,no_run
96    /// use claw_core::Snapshotter;
97    /// let s = Snapshotter::new("/tmp/snapshots").expect("ok");
98    /// ```
99    pub fn new(snapshot_dir: impl Into<PathBuf>) -> ClawResult<Self> {
100        let dir = snapshot_dir.into();
101        std::fs::create_dir_all(&dir).map_err(|e| {
102            ClawError::Snapshot(format!(
103                "cannot create snapshot directory '{}': {e}",
104                dir.display()
105            ))
106        })?;
107        Ok(Snapshotter { snapshot_dir: dir })
108    }
109
110    /// Take a snapshot of the database at `db_path` and write it to the
111    /// snapshot directory. Returns [`SnapshotMeta`] describing the created file.
112    ///
113    /// The snapshot file name is derived from the current UTC timestamp so that
114    /// snapshots sort chronologically.
115    ///
116    /// # Errors
117    ///
118    /// Returns [`ClawError::Snapshot`] if the file copy or metadata read fails.
119    ///
120    /// # Example
121    ///
122    /// ```rust,no_run
123    /// # use claw_core::Snapshotter;
124    /// # use std::path::Path;
125    /// # let s = Snapshotter::new("/tmp/snaps").unwrap();
126    /// let meta = s.take(Path::new("/tmp/claw.db")).expect("snapshot taken");
127    /// assert!(meta.path.exists());
128    /// ```
129    pub fn take(&self, db_path: &Path) -> ClawResult<SnapshotMeta> {
130        let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
131        let file_name = format!("snapshot-{timestamp}.db");
132        let dest = self.snapshot_dir.join(&file_name);
133
134        std::fs::copy(db_path, &dest).map_err(|e| {
135            ClawError::Snapshot(format!(
136                "failed to copy '{}' → '{}': {e}",
137                db_path.display(),
138                dest.display()
139            ))
140        })?;
141
142        let size_bytes = std::fs::metadata(&dest)
143            .map_err(|e| {
144                ClawError::Snapshot(format!(
145                    "failed to read metadata for '{}': {e}",
146                    dest.display()
147                ))
148            })?
149            .len();
150
151        tracing::info!(path = %dest.display(), "snapshot taken");
152        // Compute BLAKE3 checksum.
153        let checksum = blake3_file_hex(&dest)?;
154
155        let meta = SnapshotMeta {
156            path: dest,
157            created_at: chrono::Utc::now(),
158            size_bytes,
159            checksum,
160        };
161
162        // Update the manifest.
163        let mut manifest = self.load_manifest().unwrap_or_default();
164        manifest.entries.push(meta.clone());
165        self.save_manifest(&manifest)?;
166
167        tracing::info!(path = %meta.path.display(), size_bytes = meta.size_bytes, "manifest updated");
168        Ok(meta)
169    }
170
171    /// Restore the database at `db_path` from the snapshot at `snapshot_path`.
172    ///
173    /// **Warning:** This overwrites the live database file. The caller is
174    /// responsible for ensuring the engine is shut down before restoring.
175    ///
176    /// Any existing WAL (`-wal`) and shared-memory (`-shm`) sidecar files at
177    /// `db_path` are removed after the copy so that SQLite does not replay
178    /// mutations from the pre-restore session on next open.
179    ///
180    /// # Errors
181    ///
182    /// Returns [`ClawError::Snapshot`] if the file copy fails.
183    ///
184    /// # Example
185    ///
186    /// ```rust,no_run
187    /// # use claw_core::Snapshotter;
188    /// # use std::path::Path;
189    /// # let s = Snapshotter::new("/tmp/snaps").unwrap();
190    /// # let meta = s.take(Path::new("/tmp/claw.db")).unwrap();
191    /// s.restore(&meta.path, Path::new("/tmp/claw.db")).expect("restored");
192    /// ```
193    pub fn restore(&self, snapshot_path: &Path, db_path: &Path) -> ClawResult<()> {
194        // Validate SQLite magic bytes before overwriting the live database.
195        validate_sqlite_magic(snapshot_path)?;
196
197        std::fs::copy(snapshot_path, db_path).map_err(|e| {
198            ClawError::Snapshot(format!(
199                "failed to restore '{}' → '{}': {e}",
200                snapshot_path.display(),
201                db_path.display()
202            ))
203        })?;
204
205        // Remove WAL/SHM sidecars so SQLite does not replay pre-restore writes.
206        for suffix in &["-wal", "-shm"] {
207            let sidecar = PathBuf::from(format!("{}{suffix}", db_path.display()));
208            if sidecar.exists() {
209                let _ = std::fs::remove_file(&sidecar);
210            }
211        }
212
213        tracing::info!(
214            from = %snapshot_path.display(),
215            to = %db_path.display(),
216            "snapshot restored"
217        );
218        Ok(())
219    }
220
221    /// List available snapshot files in the snapshot directory, sorted
222    /// chronologically (oldest first).
223    ///
224    /// # Errors
225    ///
226    /// Returns [`ClawError::Snapshot`] if the directory cannot be read.
227    ///
228    /// # Example
229    ///
230    /// ```rust,no_run
231    /// # use claw_core::Snapshotter;
232    /// # let s = Snapshotter::new("/tmp/snaps").unwrap();
233    /// let snaps = s.list().expect("list ok");
234    /// println!("{} snapshots found", snaps.len());
235    /// ```
236    pub fn list(&self) -> ClawResult<Vec<PathBuf>> {
237        let mut entries = std::fs::read_dir(&self.snapshot_dir)
238            .map_err(|e| {
239                ClawError::Snapshot(format!(
240                    "cannot read snapshot directory '{}': {e}",
241                    self.snapshot_dir.display()
242                ))
243            })?
244            .filter_map(|r| r.ok())
245            .map(|e| e.path())
246            .filter(|p| p.extension().map(|e| e == "db").unwrap_or(false))
247            .collect::<Vec<_>>();
248
249        entries.sort();
250        Ok(entries)
251    }
252
253    /// Load the snapshot manifest from `<snapshot_dir>/manifest.json`.
254    ///
255    /// Returns an empty [`SnapshotManifest`] if the file does not yet exist.
256    ///
257    /// # Errors
258    ///
259    /// Returns [`ClawError::Snapshot`] if the file exists but cannot be parsed.
260    pub fn load_manifest(&self) -> ClawResult<SnapshotManifest> {
261        let path = self.snapshot_dir.join("manifest.json");
262        if !path.exists() {
263            return Ok(SnapshotManifest::default());
264        }
265        let bytes = std::fs::read(&path).map_err(|e| {
266            ClawError::Snapshot(format!("cannot read manifest '{}': {e}", path.display()))
267        })?;
268        serde_json::from_slice(&bytes).map_err(|e| {
269            ClawError::Snapshot(format!("cannot parse manifest '{}': {e}", path.display()))
270        })
271    }
272
273    /// Persist `manifest` to `<snapshot_dir>/manifest.json`.
274    ///
275    /// # Errors
276    ///
277    /// Returns [`ClawError::Snapshot`] if the file cannot be written.
278    fn save_manifest(&self, manifest: &SnapshotManifest) -> ClawResult<()> {
279        let path = self.snapshot_dir.join("manifest.json");
280        let bytes = serde_json::to_vec_pretty(manifest)
281            .map_err(|e| ClawError::Snapshot(format!("cannot serialise manifest: {e}")))?;
282        std::fs::write(&path, bytes).map_err(|e| {
283            ClawError::Snapshot(format!("cannot write manifest '{}': {e}", path.display()))
284        })
285    }
286}
287
288// ── helpers ───────────────────────────────────────────────────────────────────
289
290/// Compute the BLAKE3 checksum of a file and return it as a lower-case hex string.
291fn blake3_file_hex(path: &Path) -> ClawResult<String> {
292    use std::io::Read;
293    let mut hasher = blake3::Hasher::new();
294    let mut file = std::fs::File::open(path).map_err(|e| {
295        ClawError::Snapshot(format!("cannot open '{}' for hashing: {e}", path.display()))
296    })?;
297    let mut buf = vec![0u8; 65536];
298    loop {
299        let n = file.read(&mut buf).map_err(|e| {
300            ClawError::Snapshot(format!(
301                "read error while hashing '{}': {e}",
302                path.display()
303            ))
304        })?;
305        if n == 0 {
306            break;
307        }
308        hasher.update(&buf[..n]);
309    }
310    Ok(hasher.finalize().to_hex().to_string())
311}
312
313/// Validate that the first 16 bytes of `path` match the SQLite 3 file header.
314///
315/// # Errors
316///
317/// Returns [`ClawError::Snapshot`] if the file cannot be read or is not a
318/// valid SQLite 3 database.
319fn validate_sqlite_magic(path: &Path) -> ClawResult<()> {
320    use std::io::Read;
321    const SQLITE_MAGIC: &[u8; 16] = b"SQLite format 3\0";
322    let mut header = [0u8; 16];
323    let mut file = std::fs::File::open(path)
324        .map_err(|e| ClawError::Snapshot(format!("cannot open snapshot for validation: {e}")))?;
325    file.read_exact(&mut header)
326        .map_err(|e| ClawError::Snapshot(format!("cannot read snapshot header: {e}")))?;
327    if &header != SQLITE_MAGIC {
328        return Err(ClawError::Snapshot(
329            "snapshot file does not have a valid SQLite 3 header".to_string(),
330        ));
331    }
332    Ok(())
333}