Skip to main content

rws_storage/storage/
local.rs

1use super::{Storage, StorageError};
2use std::fs;
3use std::path::PathBuf;
4
5/// Stores objects as files under a root directory on local disk.
6///
7/// Keys are treated as relative paths under `root`; parent directories are
8/// created automatically on `put`. A key containing a `..` segment is
9/// rejected to prevent escaping `root` — the same defense used by the
10/// config-driven proxy's static-file action.
11///
12/// Content type is not persisted — plain files on disk have no metadata
13/// slot for it. Pair this with a static-file route (which detects MIME type
14/// from the file extension) if you need to serve uploaded files back over
15/// HTTP; see [`with_base_url`](Self::with_base_url).
16pub struct LocalStorage {
17    root: PathBuf,
18    base_url: String,
19}
20
21impl LocalStorage {
22    /// Create a store rooted at `root`. The directory itself does not need
23    /// to exist yet — it (and any key subdirectories) are created on `put`.
24    pub fn new(root: impl Into<String>) -> Self {
25        LocalStorage { root: PathBuf::from(root.into()), base_url: String::new() }
26    }
27
28    /// Set the URL prefix returned by [`Storage::url`] — e.g. `/uploads` if
29    /// `root` is also served as a static directory under that path. Without
30    /// a base URL, `url()` falls back to the object's filesystem path.
31    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
32        self.base_url = base_url.into();
33        self
34    }
35
36    fn resolve(&self, key: &str) -> Result<PathBuf, StorageError> {
37        if key.split('/').any(|segment| segment == "..") {
38            return Err(StorageError::new(format!("key '{key}' must not contain '..' segments")));
39        }
40        Ok(self.root.join(key.trim_start_matches('/')))
41    }
42}
43
44impl Storage for LocalStorage {
45    fn put(&self, key: &str, data: &[u8], _content_type: &str) -> Result<String, StorageError> {
46        let path = self.resolve(key)?;
47        if let Some(parent) = path.parent() {
48            fs::create_dir_all(parent)
49                .map_err(|e| StorageError::new(format!("failed to create '{}': {e}", parent.display())))?;
50        }
51        fs::write(&path, data).map_err(|e| StorageError::new(format!("failed to write '{key}': {e}")))?;
52        Ok(key.to_string())
53    }
54
55    /// Writes to a temporary file beside the target as it reads, then
56    /// renames it into place: a failed or partial upload never replaces an
57    /// existing object.
58    fn put_stream(&self, key: &str, reader: &mut dyn std::io::Read, _content_type: &str) -> Result<String, StorageError> {
59        let path = self.resolve(key)?;
60        if let Some(parent) = path.parent() {
61            fs::create_dir_all(parent)
62                .map_err(|e| StorageError::new(format!("failed to create '{}': {e}", parent.display())))?;
63        }
64        let partial = path.with_file_name(format!(
65            ".{}.partial-{}",
66            path.file_name().and_then(|n| n.to_str()).unwrap_or("upload"),
67            std::process::id()
68        ));
69        let written = fs::File::create(&partial).and_then(|mut file| {
70            std::io::copy(reader, &mut file)?;
71            file.sync_all()
72        });
73        if let Err(e) = written.and_then(|_| fs::rename(&partial, &path)) {
74            let _ = fs::remove_file(&partial);
75            return Err(StorageError::new(format!("failed to write '{key}': {e}")));
76        }
77        Ok(key.to_string())
78    }
79
80    fn get(&self, key: &str) -> Result<Vec<u8>, StorageError> {
81        let path = self.resolve(key)?;
82        fs::read(&path).map_err(|e| StorageError::new(format!("failed to read '{key}': {e}")))
83    }
84
85    fn delete(&self, key: &str) -> Result<(), StorageError> {
86        let path = self.resolve(key)?;
87        match fs::remove_file(&path) {
88            Ok(()) => Ok(()),
89            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
90            Err(e) => Err(StorageError::new(format!("failed to delete '{key}': {e}"))),
91        }
92    }
93
94    fn url(&self, key: &str) -> String {
95        if self.base_url.is_empty() {
96            match self.resolve(key) {
97                Ok(path) => path.to_string_lossy().to_string(),
98                Err(_) => key.to_string(),
99            }
100        } else {
101            format!("{}/{}", self.base_url.trim_end_matches('/'), key.trim_start_matches('/'))
102        }
103    }
104}
105
106#[cfg(test)]
107mod tests;