Skip to main content

truffle_core/synced_store/
backend.rs

1//! Persistence backends for SyncedStore.
2
3use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7/// Backend for persisting store data across restarts.
8///
9/// Methods are synchronous — file I/O for small JSON payloads is fast enough
10/// that async overhead isn't warranted. Apps needing async persistence (e.g.,
11/// database-backed) can spawn a blocking task internally.
12pub trait StoreBackend: Send + Sync + 'static {
13    /// Load a device's slice. Returns `(serialized_data, version)` or `None`.
14    fn load(&self, store_id: &str, device_id: &str) -> Option<(Vec<u8>, u64)>;
15
16    /// Save a device's slice.
17    fn save(&self, store_id: &str, device_id: &str, data: &[u8], version: u64);
18
19    /// Remove a device's slice.
20    fn remove(&self, store_id: &str, device_id: &str);
21}
22
23/// In-memory backend (no persistence). Default.
24///
25/// Data lives only in the `SyncedStore`'s in-memory state and is lost on
26/// process exit. Suitable for ephemeral stores like presence or typing
27/// indicators.
28#[derive(Debug, Default)]
29pub struct MemoryBackend;
30
31impl StoreBackend for MemoryBackend {
32    fn load(&self, _store_id: &str, _device_id: &str) -> Option<(Vec<u8>, u64)> {
33        None
34    }
35
36    fn save(&self, _store_id: &str, _device_id: &str, _data: &[u8], _version: u64) {}
37
38    fn remove(&self, _store_id: &str, _device_id: &str) {}
39}
40
41// ---------------------------------------------------------------------------
42// FileBackend — JSON file persistence
43// ---------------------------------------------------------------------------
44
45/// On-disk JSON file for a single device slice.
46#[derive(Serialize, Deserialize)]
47struct StoredSlice {
48    data: Vec<u8>,
49    version: u64,
50}
51
52/// File-backed persistence. Each device slice is stored as a JSON file at
53/// `{base_dir}/{store_id}/{device_id}.json`.
54///
55/// Writes are atomic (write to `.tmp`, then rename) so a crash mid-write
56/// never corrupts the persisted data.
57pub struct FileBackend {
58    base_dir: PathBuf,
59}
60
61impl FileBackend {
62    /// Create a new `FileBackend` rooted at `base_dir`.
63    pub fn new(base_dir: impl Into<PathBuf>) -> Self {
64        Self {
65            base_dir: base_dir.into(),
66        }
67    }
68
69    /// Sanitized path: `{base_dir}/{store_id}/{device_id}.json`.
70    ///
71    /// Replaces `/`, `\`, and `..` segments with `_` to prevent path traversal.
72    fn path(&self, store_id: &str, device_id: &str) -> PathBuf {
73        let safe_store = sanitize(store_id);
74        let safe_device = sanitize(device_id);
75        self.base_dir.join(safe_store).join(format!("{safe_device}.json"))
76    }
77}
78
79/// Replace characters that could cause path traversal or filesystem issues.
80fn sanitize(s: &str) -> String {
81    s.replace('/', "_")
82        .replace('\\', "_")
83        .replace("..", "_")
84}
85
86impl StoreBackend for FileBackend {
87    fn load(&self, store_id: &str, device_id: &str) -> Option<(Vec<u8>, u64)> {
88        let path = self.path(store_id, device_id);
89        let contents = std::fs::read_to_string(&path).ok()?;
90        let stored: StoredSlice = serde_json::from_str(&contents).ok()?;
91        Some((stored.data, stored.version))
92    }
93
94    fn save(&self, store_id: &str, device_id: &str, data: &[u8], version: u64) {
95        let path = self.path(store_id, device_id);
96
97        // Ensure parent directory exists.
98        if let Some(parent) = path.parent() {
99            if let Err(e) = std::fs::create_dir_all(parent) {
100                tracing::error!(
101                    path = %parent.display(),
102                    "file_backend: failed to create directory: {e}"
103                );
104                return;
105            }
106        }
107
108        let stored = StoredSlice {
109            data: data.to_vec(),
110            version,
111        };
112
113        let json = match serde_json::to_string(&stored) {
114            Ok(j) => j,
115            Err(e) => {
116                tracing::error!("file_backend: failed to serialize: {e}");
117                return;
118            }
119        };
120
121        // Atomic write: write to .tmp then rename.
122        let tmp_path = path.with_extension("json.tmp");
123        if let Err(e) = std::fs::write(&tmp_path, json.as_bytes()) {
124            tracing::error!(
125                path = %tmp_path.display(),
126                "file_backend: failed to write tmp file: {e}"
127            );
128            return;
129        }
130        if let Err(e) = std::fs::rename(&tmp_path, &path) {
131            tracing::error!(
132                from = %tmp_path.display(),
133                to = %path.display(),
134                "file_backend: failed to rename tmp file: {e}"
135            );
136        }
137    }
138
139    fn remove(&self, store_id: &str, device_id: &str) {
140        let path = self.path(store_id, device_id);
141        let _ = std::fs::remove_file(&path);
142    }
143}