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}