Skip to main content

webdav_server/storage/
service.rs

1use bytes::Bytes;
2use futures::Stream;
3use std::error::Error as StdErr;
4use std::io::ErrorKind::{self};
5use tokio::fs::{self, File};
6
7use crate::error::internal::Error::Message;
8use crate::info;
9use crate::logger::Module;
10use crate::storage::file::Metadata;
11use crate::storage::transaction::Transaction;
12use crate::storage::{
13    Error::{FileAlreadyExists, Internal, InvalidFileName, NotFound},
14    Payload, Result,
15};
16use std::path::PathBuf;
17
18#[derive(Default, Clone)]
19/// The high-level interface to the storage vault.
20///
21/// `Service` is the entry point for all filesystem operations in this application.
22/// It owns the path to the vault directory and exposes a small, focused API for
23/// saving, retrieving, and deleting blobs.
24///
25/// Internally, writes go through an internal transaction which guarantees atomicity:
26/// a failed write can never leave a corrupted or partial file in the vault.
27pub struct Service {
28    pub(crate) vault_path: PathBuf,
29}
30
31impl Service {
32    /// Creates a new `Service` rooted at the given vault directory.
33    ///
34    /// The path is not validated here — if the directory does not exist when
35    /// a transaction is attempted, a [`crate::storage::Error::VaultNotFound`] will be
36    /// returned at that point.
37    pub fn new<P>(vault_path: P) -> Self
38    where
39        P: Into<PathBuf>,
40    {
41        Self {
42            vault_path: vault_path.into(),
43        }
44    }
45
46    /// Streams a payload to disk and commits it atomically to the vault.
47    ///
48    /// This is the main entry point for all uploads. Internally it creates an
49    /// internal transaction, streams all bytes to a temporary staging file, then
50    /// performs an atomic `rename(2)` to the final CAS path (the BLAKE3 hash
51    /// of the content). Returns the file [`Metadata`] on success.
52    ///
53    /// If anything fails mid-stream, the temporary file is cleaned up before
54    /// the error is returned.
55    ///
56    /// # Errors
57    /// - Returns [`crate::storage::Error::InvalidFileName`] if `file_name` contains `/`, `\`, or is empty.
58    /// - Returns [`crate::storage::Error::FileAlreadyExists`] if a file with that name already exists in the vault.
59    /// - Returns [`crate::storage::Error`] if a disk I/O failure occurs during streaming or the atomic rename.
60    pub async fn try_save<S, E>(
61        &self,
62        file_name: &str,
63        payload: Payload<S, E>,
64    ) -> Result<Metadata>
65    where
66        S: Stream<Item = std::result::Result<Bytes, E>> + Unpin,
67        E: Into<Box<dyn StdErr + Send + Sync>>,
68    {
69        let transaction = self.begin_transaction(&file_name)?;
70
71        let file_metadata = transaction.commit(payload).await?;
72
73        info!(Module::Storage, "Comitted CAS blob for {file_name} to disk");
74        Ok(file_metadata)
75    }
76
77    /// Validates the filename and creates a new internal transaction ready for streaming.
78    ///
79    /// This is an internal helper used by [`Service::try_save`]. It enforces the
80    /// filename rules (no path separators, no empty names) before any disk I/O
81    /// is attempted.
82    fn begin_transaction<T>(&self, file: &T) -> Result<Transaction>
83    where
84        T: AsRef<str>,
85    {
86        let file_name = file.as_ref();
87        if file_name.is_empty()
88            || file_name.contains('/')
89            || file_name.contains('\\')
90        {
91            return Err(InvalidFileName);
92        }
93
94        Ok(Transaction::new(&self.vault_path))
95    }
96}
97
98impl Service {
99    /// Deletes the blob at the given hash string from the vault.
100    ///
101    /// This is intentionally idempotent: if the file does not exist, `Ok(())` is
102    /// returned without error. This makes it safe to call even when the physical
103    /// file has already been removed or was never committed.
104    ///
105    /// # Errors
106    /// - Returns [`crate::storage::Error::Internal`] if the filesystem returns any error other than
107    ///   `NotFound` (e.g., permission denied).
108    pub async fn delete_blob(&self, hash_str: &str) -> Result<()> {
109        let file_path = self.vault_path.join(hash_str);
110
111        match fs::remove_file(file_path).await {
112            Ok(()) => Ok(()),
113            Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
114            Err(x) => {
115                Err(Internal(Message(format!("Failed to delete file : {x}"))))
116            }
117        }
118    }
119
120    /// Opens and returns the blob file at the given hash string for reading.
121    ///
122    /// The returned [`tokio::fs::File`] handle can be wrapped in a [`ReaderStream`]
123    /// for direct streaming to an HTTP response body without loading the file into memory.
124    ///
125    /// # Errors
126    /// - Returns [`crate::storage::Error::NotFound`] if no blob with that hash exists in the vault.
127    ///
128    /// [`ReaderStream`]: tokio_util::io::ReaderStream
129    pub async fn get_blob(&self, hash_str: &str) -> Result<File> {
130        let file_path = self.vault_path.join(hash_str);
131
132        File::open(file_path).await.map_err(|_| NotFound)
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use core::result::Result;
139    use std::io::Error as IoErr;
140
141    use tokio::fs::File;
142
143    use super::*;
144
145    use crate::storage::tests::with_temp_service;
146
147    #[tokio::test]
148    async fn reject_invalid_filename() -> crate::storage::Result<()> {
149        with_temp_service(|service| async move {
150            // Reject for any occurrence of forward slash
151            let result = service.begin_transaction(&"o///reo/hiuh//i");
152            assert!(result.is_err());
153            assert!(matches!(result, Err(InvalidFileName)));
154
155            let result = service.begin_transaction(&"");
156            assert!(result.is_err());
157
158            assert!(matches!(result, Err(InvalidFileName)));
159            let result = service.begin_transaction(&"../../../../etc/passwd");
160            assert!(result.is_err());
161            assert!(matches!(result, Err(InvalidFileName)))
162        })
163        .await;
164
165        Ok(())
166    }
167
168    #[tokio::test]
169    async fn concurrent_write_collisions_dont_panic() {
170        with_temp_service(|service| async move {
171            let service_a = service.clone();
172            let service_b = service.clone();
173
174            let task_a = tokio::spawn(async move {
175                let chunks: Vec<Result<Bytes, IoErr>> =
176                    vec![Ok(Bytes::from("some_data"))];
177                let stream = futures::stream::iter(chunks);
178
179                service_a
180                    .try_save("dev1_upload.rs", Payload::new(9u64, stream))
181                    .await
182            });
183
184            let task_b = tokio::spawn(async move {
185                let payload: Vec<Result<Bytes, IoErr>> =
186                    vec![Ok(Bytes::from("some_data"))];
187                let stream = futures::stream::iter(payload);
188
189                service_b
190                    .try_save("some_other_file.rs", Payload::new(9u64, stream))
191                    .await
192            });
193
194            let (result_a, result_b) = tokio::join!(task_a, task_b);
195
196            // Test : Writing to same file doesn't fail
197            let metadata_a = result_a.unwrap().expect("task_a failed");
198            let metadata_b = result_b.unwrap().expect("task_b failed");
199
200            // Test: Both files wrote exact same data
201            assert_eq!(
202                metadata_a.hash.to_string(),
203                metadata_b.hash.to_string()
204            );
205
206            let expected_path =
207                service.vault_path.join(metadata_a.hash.to_string());
208            // Test: Expected path exists
209            assert!(expected_path.exists());
210        })
211        .await;
212    }
213
214    #[tokio::test]
215    async fn validation_success() {
216        with_temp_service(async move |service| {
217            // Valid name rules
218            let result = service.begin_transaction(&"oreo.tmp.jks");
219            assert!(result.is_ok());
220            assert!(matches!(result, Ok(_)));
221        })
222        .await
223    }
224}