Skip to main content

reddb_server/storage/backend/
local.rs

1//! Local filesystem backend (default).
2
3use super::{
4    AtomicRemoteBackend, BackendError, BackendObjectVersion, ConditionalDelete, ConditionalPut,
5    RemoteBackend,
6};
7use crate::crypto;
8use fs2::FileExt;
9use std::fs;
10use std::fs::OpenOptions;
11use std::path::{Path, PathBuf};
12use std::sync::atomic::{AtomicU64, Ordering};
13
14/// Local filesystem backend. Copies files between paths.
15/// This is the default backend -- operates entirely on local disk.
16pub struct LocalBackend;
17
18static LOCAL_UPLOAD_TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
19
20/// When set, every key this backend touches must resolve under this
21/// directory. Relative keys are joined onto it; absolute keys must already
22/// be inside it.
23const ROOT_ENV: &str = "REDDB_LOCAL_BACKEND_ROOT";
24
25/// Resolve a backend key to the filesystem path it names.
26///
27/// Keys are paths by design on this backend, but they arrive from backup
28/// manifests and request bodies as well as from configuration, so a key is
29/// data, not an instruction: a `..` component is refused outright, and when
30/// [`ROOT_ENV`] is set the resolved path must stay under that root.
31fn confined_path(remote_key: &str) -> Result<PathBuf, BackendError> {
32    confine(
33        remote_key,
34        std::env::var_os(ROOT_ENV).map(PathBuf::from).as_deref(),
35    )
36}
37
38fn confine(remote_key: &str, root: Option<&Path>) -> Result<PathBuf, BackendError> {
39    use std::path::Component;
40    if remote_key.is_empty() {
41        return Err(BackendError::Transport(
42            "local backend: empty key".to_string(),
43        ));
44    }
45    let path = Path::new(remote_key);
46    if path
47        .components()
48        .any(|component| matches!(component, Component::ParentDir))
49    {
50        return Err(BackendError::Transport(format!(
51            "local backend: key {remote_key:?} contains a parent-directory component"
52        )));
53    }
54    let Some(root) = root else {
55        return Ok(path.to_path_buf());
56    };
57    let full = if path.is_absolute() {
58        path.to_path_buf()
59    } else {
60        root.join(path)
61    };
62    if !full.starts_with(root) {
63        return Err(BackendError::Transport(format!(
64            "local backend: key {remote_key:?} resolves outside {ROOT_ENV}"
65        )));
66    }
67    Ok(full)
68}
69
70/// [`confined_path`] rendered back to the string form the file-level
71/// helpers take.
72fn confined_key(remote_key: &str) -> Result<String, BackendError> {
73    let path = confined_path(remote_key)?;
74    path.to_str().map(str::to_string).ok_or_else(|| {
75        BackendError::Transport(format!(
76            "local backend: key {remote_key:?} is not valid UTF-8"
77        ))
78    })
79}
80
81fn local_version_for(path: &Path) -> Result<Option<BackendObjectVersion>, BackendError> {
82    if !path.exists() {
83        return Ok(None);
84    }
85    let bytes = fs::read(path)
86        .map_err(|e| BackendError::Transport(format!("read for version failed: {e}")))?;
87    let hash = hex::encode(crypto::sha256::sha256(&bytes));
88    Ok(Some(BackendObjectVersion::new(format!(
89        "sha256:{}:len:{}",
90        hash,
91        bytes.len()
92    ))))
93}
94
95fn lock_path_for(dest: &Path) -> PathBuf {
96    reddb_file::layout::local_cas_lock_path(dest)
97}
98
99pub(crate) fn local_cas_lock_path_for(dest: &Path) -> PathBuf {
100    lock_path_for(dest)
101}
102
103impl RemoteBackend for LocalBackend {
104    fn name(&self) -> &str {
105        "local"
106    }
107
108    fn download(&self, remote_key: &str, local_path: &Path) -> Result<bool, BackendError> {
109        reddb_file::local_backend_download(&confined_key(remote_key)?, local_path)
110            .map_err(|e| BackendError::Transport(format!("local download failed: {e}")))
111    }
112
113    fn upload(&self, local_path: &Path, remote_key: &str) -> Result<(), BackendError> {
114        let unique = LOCAL_UPLOAD_TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
115        reddb_file::local_backend_atomic_upload(
116            local_path,
117            &confined_key(remote_key)?,
118            std::process::id(),
119            unique,
120        )
121        .map_err(|e| BackendError::Transport(format!("local upload failed: {e}")))
122    }
123
124    fn exists(&self, remote_key: &str) -> Result<bool, BackendError> {
125        Ok(confined_path(remote_key)?.exists())
126    }
127
128    fn delete(&self, remote_key: &str) -> Result<(), BackendError> {
129        let path = confined_path(remote_key)?;
130        if path.exists() {
131            fs::remove_file(path)
132                .map_err(|e| BackendError::Transport(format!("delete failed: {e}")))?;
133        }
134        Ok(())
135    }
136
137    fn list(&self, prefix: &str) -> Result<Vec<String>, BackendError> {
138        let prefix_buf = confined_path(prefix)?;
139        let prefix_path = prefix_buf.as_path();
140        let mut results = Vec::new();
141
142        if prefix_path.is_dir() {
143            fn walk(dir: &Path, out: &mut Vec<String>) -> Result<(), BackendError> {
144                for entry in fs::read_dir(dir)
145                    .map_err(|e| BackendError::Transport(format!("read_dir failed: {e}")))?
146                {
147                    let entry = entry
148                        .map_err(|e| BackendError::Transport(format!("dir entry failed: {e}")))?;
149                    let path = entry.path();
150                    if path.is_dir() {
151                        walk(&path, out)?;
152                    } else if path.is_file() {
153                        out.push(path.to_string_lossy().to_string());
154                    }
155                }
156                Ok(())
157            }
158
159            walk(prefix_path, &mut results)?;
160        } else {
161            let parent = prefix_path.parent().unwrap_or_else(|| Path::new("."));
162            let needle = prefix_path.to_string_lossy().to_string();
163            if parent.exists() {
164                for entry in fs::read_dir(parent)
165                    .map_err(|e| BackendError::Transport(format!("read_dir failed: {e}")))?
166                {
167                    let entry = entry
168                        .map_err(|e| BackendError::Transport(format!("dir entry failed: {e}")))?;
169                    let path = entry.path();
170                    if path.is_file() {
171                        let candidate = path.to_string_lossy().to_string();
172                        if candidate.starts_with(&needle) {
173                            results.push(candidate);
174                        }
175                    }
176                }
177            }
178        }
179
180        results.sort();
181        Ok(results)
182    }
183}
184
185impl AtomicRemoteBackend for LocalBackend {
186    fn object_version(
187        &self,
188        remote_key: &str,
189    ) -> Result<Option<BackendObjectVersion>, BackendError> {
190        local_version_for(&confined_path(remote_key)?)
191    }
192
193    fn upload_conditional(
194        &self,
195        local_path: &Path,
196        remote_key: &str,
197        condition: ConditionalPut,
198    ) -> Result<BackendObjectVersion, BackendError> {
199        let dest_buf = confined_path(remote_key)?;
200        let dest = dest_buf.as_path();
201        if let Some(parent) = dest.parent() {
202            fs::create_dir_all(parent)
203                .map_err(|e| BackendError::Transport(format!("mkdir failed: {e}")))?;
204        }
205        let lock_path = lock_path_for(dest);
206        let lock_file = OpenOptions::new()
207            .create(true)
208            .truncate(false)
209            .read(true)
210            .write(true)
211            .open(&lock_path)
212            .map_err(|e| BackendError::Transport(format!("open CAS lock failed: {e}")))?;
213        lock_file
214            .lock_exclusive()
215            .map_err(|e| BackendError::Transport(format!("lock CAS file failed: {e}")))?;
216
217        let observed = local_version_for(dest)?;
218        let allowed = match (&condition, &observed) {
219            (ConditionalPut::IfAbsent, None) => true,
220            (ConditionalPut::IfAbsent, Some(_)) => false,
221            (ConditionalPut::IfVersion(expected), Some(actual)) => expected == actual,
222            (ConditionalPut::IfVersion(_), None) => false,
223        };
224        if !allowed {
225            let _ = lock_file.unlock();
226            return Err(BackendError::PreconditionFailed(format!(
227                "local object '{}' changed before conditional upload",
228                remote_key
229            )));
230        }
231
232        let upload_result = self.upload(local_path, remote_key);
233        let version_result = upload_result.and_then(|_| {
234            local_version_for(dest)?.ok_or_else(|| {
235                BackendError::Internal(format!(
236                    "local object '{}' missing after conditional upload",
237                    remote_key
238                ))
239            })
240        });
241        let unlock_result = lock_file
242            .unlock()
243            .map_err(|e| BackendError::Transport(format!("unlock CAS file failed: {e}")));
244        match (version_result, unlock_result) {
245            (Ok(version), Ok(())) => Ok(version),
246            (Err(err), _) => Err(err),
247            (Ok(_), Err(err)) => Err(err),
248        }
249    }
250
251    fn delete_conditional(
252        &self,
253        remote_key: &str,
254        condition: ConditionalDelete,
255    ) -> Result<(), BackendError> {
256        let dest_buf = confined_path(remote_key)?;
257        let dest = dest_buf.as_path();
258        if let Some(parent) = dest.parent() {
259            fs::create_dir_all(parent)
260                .map_err(|e| BackendError::Transport(format!("mkdir failed: {e}")))?;
261        }
262        let lock_path = lock_path_for(dest);
263        let lock_file = OpenOptions::new()
264            .create(true)
265            .truncate(false)
266            .read(true)
267            .write(true)
268            .open(&lock_path)
269            .map_err(|e| BackendError::Transport(format!("open CAS lock failed: {e}")))?;
270        lock_file
271            .lock_exclusive()
272            .map_err(|e| BackendError::Transport(format!("lock CAS file failed: {e}")))?;
273
274        let observed = local_version_for(dest)?;
275        let allowed = match (&condition, &observed) {
276            (ConditionalDelete::IfVersion(expected), Some(actual)) => expected == actual,
277            (ConditionalDelete::IfVersion(_), None) => false,
278        };
279        if !allowed {
280            let _ = lock_file.unlock();
281            return Err(BackendError::PreconditionFailed(format!(
282                "local object '{}' changed before conditional delete",
283                remote_key
284            )));
285        }
286
287        let delete_result = self.delete(remote_key);
288        let unlock_result = lock_file
289            .unlock()
290            .map_err(|e| BackendError::Transport(format!("unlock CAS file failed: {e}")));
291        match (delete_result, unlock_result) {
292            (Ok(()), Ok(())) => Ok(()),
293            (Err(err), _) => Err(err),
294            (Ok(()), Err(err)) => Err(err),
295        }
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    fn temp_file(name: &str) -> PathBuf {
304        std::env::temp_dir().join(format!(
305            "reddb-local-backend-test-{}-{}-{}",
306            name,
307            std::process::id(),
308            crate::utils::now_unix_nanos()
309        ))
310    }
311
312    #[test]
313    fn conditional_create_if_absent_rejects_existing_object() {
314        let backend = LocalBackend;
315        let src = temp_file("src");
316        let remote = temp_file("remote");
317        fs::write(&src, b"first").unwrap();
318
319        let first = backend
320            .upload_conditional(&src, remote.to_str().unwrap(), ConditionalPut::IfAbsent)
321            .unwrap();
322        assert!(first.token.contains("sha256:"));
323
324        fs::write(&src, b"second").unwrap();
325        let err = backend
326            .upload_conditional(&src, remote.to_str().unwrap(), ConditionalPut::IfAbsent)
327            .unwrap_err();
328        assert!(matches!(err, BackendError::PreconditionFailed(_)));
329
330        let _ = fs::remove_file(src);
331        let _ = fs::remove_file(remote);
332    }
333
334    #[test]
335    fn conditional_replace_rejects_stale_version() {
336        let backend = LocalBackend;
337        let src = temp_file("src");
338        let remote = temp_file("remote");
339        fs::write(&src, b"first").unwrap();
340        let stale = backend
341            .upload_conditional(&src, remote.to_str().unwrap(), ConditionalPut::IfAbsent)
342            .unwrap();
343
344        fs::write(&src, b"second").unwrap();
345        let fresh = backend
346            .upload_conditional(
347                &src,
348                remote.to_str().unwrap(),
349                ConditionalPut::IfVersion(stale.clone()),
350            )
351            .unwrap();
352
353        fs::write(&src, b"third").unwrap();
354        let err = backend
355            .upload_conditional(
356                &src,
357                remote.to_str().unwrap(),
358                ConditionalPut::IfVersion(stale),
359            )
360            .unwrap_err();
361        assert!(matches!(err, BackendError::PreconditionFailed(_)));
362        assert_eq!(
363            fresh.token,
364            backend
365                .object_version(remote.to_str().unwrap())
366                .unwrap()
367                .unwrap()
368                .token
369        );
370
371        let _ = fs::remove_file(src);
372        let _ = fs::remove_file(remote);
373    }
374
375    #[test]
376    fn conditional_delete_rejects_stale_version() {
377        let backend = LocalBackend;
378        let src = temp_file("src");
379        let remote = temp_file("remote");
380        fs::write(&src, b"first").unwrap();
381        let stale = backend
382            .upload_conditional(&src, remote.to_str().unwrap(), ConditionalPut::IfAbsent)
383            .unwrap();
384
385        fs::write(&src, b"second").unwrap();
386        let fresh = backend
387            .upload_conditional(
388                &src,
389                remote.to_str().unwrap(),
390                ConditionalPut::IfVersion(stale.clone()),
391            )
392            .unwrap();
393
394        let err = backend
395            .delete_conditional(
396                remote.to_str().unwrap(),
397                ConditionalDelete::IfVersion(stale),
398            )
399            .unwrap_err();
400        assert!(matches!(err, BackendError::PreconditionFailed(_)));
401        backend
402            .delete_conditional(
403                remote.to_str().unwrap(),
404                ConditionalDelete::IfVersion(fresh),
405            )
406            .unwrap();
407        assert!(!remote.exists());
408
409        let _ = fs::remove_file(src);
410    }
411}
412
413#[cfg(test)]
414mod key_confinement_tests {
415    use super::*;
416
417    #[test]
418    fn parent_directory_components_are_refused() {
419        for key in [
420            "../secrets.rdb",
421            "backups/../../etc/passwd",
422            "/var/backups/../x",
423        ] {
424            let err = confine(key, None).expect_err(key);
425            assert!(err.to_string().contains("parent-directory"), "{key}: {err}");
426        }
427        assert!(confine("", None).is_err());
428    }
429
430    #[test]
431    fn keys_stay_under_the_configured_root() {
432        let root = Path::new("/var/lib/reddb/backups");
433        assert_eq!(
434            confine("head.json", Some(root)).unwrap(),
435            root.join("head.json")
436        );
437        assert_eq!(
438            confine("/var/lib/reddb/backups/wal/000001", Some(root)).unwrap(),
439            Path::new("/var/lib/reddb/backups/wal/000001")
440        );
441        let err = confine("/etc/shadow", Some(root)).expect_err("outside root");
442        assert!(err.to_string().contains(ROOT_ENV), "{err}");
443    }
444
445    #[test]
446    fn without_a_root_absolute_keys_pass_through() {
447        assert_eq!(
448            confine("/tmp/reddb-backup.bin", None).unwrap(),
449            Path::new("/tmp/reddb-backup.bin")
450        );
451    }
452}