reddb_server/storage/backend/
local.rs1use 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
14pub struct LocalBackend;
17
18static LOCAL_UPLOAD_TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
19
20const ROOT_ENV: &str = "REDDB_LOCAL_BACKEND_ROOT";
24
25fn 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
70fn 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}