Skip to main content

lanflare_core/storage/
writer.rs

1//! Streaming disk writer with real-time SHA-256 calculation.
2
3use std::path::{Path, PathBuf};
4use sha2::{Digest, Sha256};
5use thiserror::Error;
6use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt, BufWriter};
7
8use super::path::PathSanitizeError;
9
10/// Temporary file extension for ongoing downloads.
11pub const TEMP_FILE_EXTENSION: &str = "lanflare-part";
12
13/// Default buffer capacity for the async disk writer (128 KB for high-throughput LAN transfers).
14pub const DEFAULT_BUFFER_CAPACITY: usize = 128 * 1024;
15
16/// Errors that can occur during streaming storage operations.
17#[derive(Debug, Error)]
18pub enum StorageError {
19    #[error("Path sanitization error: {0}")]
20    Path(#[from] PathSanitizeError),
21
22    #[error("IO error: {0}")]
23    Io(#[from] std::io::Error),
24
25    #[error("Size mismatch: expected {expected} bytes, received {actual} bytes")]
26    SizeMismatch { expected: u64, actual: u64 },
27
28    #[error("Size limit exceeded: received {actual} bytes, exceeding maximum expected {expected} bytes")]
29    SizeExceeded { expected: u64, actual: u64 },
30
31    #[error("Checksum mismatch: expected {expected}, computed {actual}")]
32    ChecksumMismatch { expected: String, actual: String },
33
34    #[error("Destination file already exists: {0}")]
35    AlreadyExists(PathBuf),
36
37    #[error("Transfer was aborted or already completed")]
38    Aborted,
39
40    #[error("Stream error: {0}")]
41    StreamError(String),
42}
43
44/// Metadata result returned after a file is successfully verified and committed.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct StorageCommitResult {
47    /// Final destination path of the file.
48    pub target_path: PathBuf,
49    /// Total number of bytes written.
50    pub bytes_written: u64,
51    /// Computed SHA-256 hash in lowercase hex format.
52    pub sha256: String,
53}
54
55/// Async buffered disk writer that streams data directly to a temporary `.lanflare-part` file
56/// while concurrently calculating the SHA-256 hash incrementally in memory.
57#[derive(Debug)]
58pub struct StreamingFileWriter {
59    target_path: PathBuf,
60    temp_path: PathBuf,
61    writer: Option<BufWriter<tokio::fs::File>>,
62    hasher: Sha256,
63    bytes_written: u64,
64    expected_size: Option<u64>,
65    expected_sha256: Option<String>,
66    committed: bool,
67}
68
69impl StreamingFileWriter {
70    /// Creates a new streaming file writer. Overwrites target file if it already exists.
71    pub async fn create(
72        target_path: impl Into<PathBuf>,
73        expected_size: Option<u64>,
74        expected_sha256: Option<String>,
75    ) -> Result<Self, StorageError> {
76        Self::create_with_overwrite(target_path, expected_size, expected_sha256, true).await
77    }
78
79    /// Creates a new streaming file writer with optional overwrite protection.
80    pub async fn create_with_overwrite(
81        target_path: impl Into<PathBuf>,
82        expected_size: Option<u64>,
83        expected_sha256: Option<String>,
84        allow_overwrite: bool,
85    ) -> Result<Self, StorageError> {
86        Self::create_internal(
87            target_path,
88            expected_size,
89            expected_sha256,
90            allow_overwrite,
91            DEFAULT_BUFFER_CAPACITY,
92        )
93        .await
94    }
95
96    /// Creates a new streaming file writer with custom buffer capacity.
97    pub async fn create_with_capacity(
98        target_path: impl Into<PathBuf>,
99        expected_size: Option<u64>,
100        expected_sha256: Option<String>,
101        buffer_capacity: usize,
102    ) -> Result<Self, StorageError> {
103        Self::create_internal(
104            target_path,
105            expected_size,
106            expected_sha256,
107            true,
108            buffer_capacity,
109        )
110        .await
111    }
112
113    async fn create_internal(
114        target_path: impl Into<PathBuf>,
115        expected_size: Option<u64>,
116        expected_sha256: Option<String>,
117        allow_overwrite: bool,
118        buffer_capacity: usize,
119    ) -> Result<Self, StorageError> {
120        let target_path = target_path.into();
121
122        if !allow_overwrite && target_path.exists() {
123            return Err(StorageError::AlreadyExists(target_path));
124        }
125
126        let parent = target_path
127            .parent()
128            .unwrap_or_else(|| Path::new("."));
129        if !parent.as_os_str().is_empty() {
130            tokio::fs::create_dir_all(parent).await?;
131        }
132
133        let file_name = target_path
134            .file_name()
135            .and_then(|s| s.to_str())
136            .unwrap_or("transfer");
137        let random_suffix = uuid::Uuid::new_v4().simple().to_string();
138        let temp_name = format!(".{file_name}.{random_suffix}.{TEMP_FILE_EXTENSION}");
139        let temp_path = parent.join(temp_name);
140
141        let file = tokio::fs::File::create(&temp_path).await?;
142        let writer = BufWriter::with_capacity(buffer_capacity, file);
143
144        Ok(Self {
145            target_path,
146            temp_path,
147            writer: Some(writer),
148            hasher: Sha256::new(),
149            bytes_written: 0,
150            expected_size,
151            expected_sha256,
152            committed: false,
153        })
154    }
155
156    /// Returns the target destination path.
157    pub fn target_path(&self) -> &Path {
158        &self.target_path
159    }
160
161    /// Returns the temporary file path currently being written to.
162    pub fn temp_path(&self) -> &Path {
163        &self.temp_path
164    }
165
166    /// Returns the total number of bytes written so far.
167    pub fn bytes_written(&self) -> u64 {
168        self.bytes_written
169    }
170
171    /// Returns the expected size in bytes, if specified.
172    pub fn expected_size(&self) -> Option<u64> {
173        self.expected_size
174    }
175
176    /// Returns the expected SHA-256 hash, if specified.
177    pub fn expected_sha256(&self) -> Option<&str> {
178        self.expected_sha256.as_deref()
179    }
180
181    /// Writes a single chunk to disk and updates the running SHA-256 digest.
182    pub async fn write_chunk(&mut self, chunk: &[u8]) -> Result<(), StorageError> {
183        if self.committed {
184            return Err(StorageError::Aborted);
185        }
186
187        if let Some(expected) = self.expected_size {
188            let next_total = self.bytes_written + chunk.len() as u64;
189            if next_total > expected {
190                // Abort early to prevent disk space exhaustion attacks
191                self.cleanup_temp_file().await;
192                return Err(StorageError::SizeExceeded {
193                    expected,
194                    actual: next_total,
195                });
196            }
197        }
198
199        self.hasher.update(chunk);
200
201        if let Some(writer) = &mut self.writer {
202            writer.write_all(chunk).await?;
203            self.bytes_written += chunk.len() as u64;
204            Ok(())
205        } else {
206            Err(StorageError::Aborted)
207        }
208    }
209
210    /// Streams chunks from an asynchronous stream and writes them directly to disk.
211    pub async fn write_all_from_stream<S, B, E>(&mut self, mut stream: S) -> Result<(), StorageError>
212    where
213        S: futures_util::Stream<Item = Result<B, E>> + Unpin,
214        B: AsRef<[u8]>,
215        E: std::fmt::Display,
216    {
217        use futures_util::StreamExt;
218        while let Some(item) = stream.next().await {
219            match item {
220                Ok(chunk) => self.write_chunk(chunk.as_ref()).await?,
221                Err(err) => {
222                    self.cleanup_temp_file().await;
223                    return Err(StorageError::StreamError(err.to_string()));
224                }
225            }
226        }
227        Ok(())
228    }
229
230    /// Reads all data from an `AsyncRead` source and writes it to disk.
231    pub async fn write_all_from_reader<R>(&mut self, mut reader: R) -> Result<(), StorageError>
232    where
233        R: AsyncRead + Unpin,
234    {
235        let mut buffer = vec![0u8; DEFAULT_BUFFER_CAPACITY];
236        loop {
237            let n = reader.read(&mut buffer).await?;
238            if n == 0 {
239                break;
240            }
241            self.write_chunk(&buffer[..n]).await?;
242        }
243        Ok(())
244    }
245
246    /// Flushes data, verifies size and SHA-256 checksum, and atomically renames
247    /// the temporary `.lanflare-part` file to the final destination path.
248    pub async fn commit(mut self) -> Result<StorageCommitResult, StorageError> {
249        if self.committed {
250            return Err(StorageError::Aborted);
251        }
252
253        // Flush and close the writer to release file handles before renaming
254        if let Some(mut writer) = self.writer.take() {
255            writer.flush().await?;
256            drop(writer);
257        } else {
258            return Err(StorageError::Aborted);
259        }
260
261        // Finalize SHA-256 hash
262        let hasher = std::mem::take(&mut self.hasher);
263        let computed_hash = format!("{:x}", hasher.finalize());
264
265        // Validate size if an expectation was set
266        if let Some(expected) = self.expected_size
267            && self.bytes_written != expected
268        {
269            let _ = tokio::fs::remove_file(&self.temp_path).await;
270            self.committed = true;
271            return Err(StorageError::SizeMismatch {
272                expected,
273                actual: self.bytes_written,
274            });
275        }
276
277        // Validate checksum if an expectation was set
278        if let Some(ref expected) = self.expected_sha256
279            && !expected.eq_ignore_ascii_case(&computed_hash)
280        {
281            let _ = tokio::fs::remove_file(&self.temp_path).await;
282            self.committed = true;
283            return Err(StorageError::ChecksumMismatch {
284                expected: expected.clone(),
285                actual: computed_hash,
286            });
287        }
288
289        // Atomically rename temporary file to destination path
290        tokio::fs::rename(&self.temp_path, &self.target_path).await?;
291        self.committed = true;
292
293        Ok(StorageCommitResult {
294            target_path: self.target_path.clone(),
295            bytes_written: self.bytes_written,
296            sha256: computed_hash,
297        })
298    }
299
300    /// Aborts writing and immediately removes the temporary file from disk.
301    pub async fn abort(mut self) -> Result<(), StorageError> {
302        self.cleanup_temp_file().await;
303        Ok(())
304    }
305
306    /// Internal cleanup helper that drops the open file handle and deletes the temp file.
307    async fn cleanup_temp_file(&mut self) {
308        if let Some(writer) = self.writer.take() {
309            drop(writer);
310        }
311        if self.temp_path.exists() {
312            let _ = tokio::fs::remove_file(&self.temp_path).await;
313        }
314        self.committed = true;
315    }
316}
317
318impl Drop for StreamingFileWriter {
319    fn drop(&mut self) {
320        if !self.committed {
321            // Close file handle first so file locks are released
322            self.writer.take();
323            let temp_path = self.temp_path.clone();
324            if temp_path.exists() {
325                let _ = std::fs::remove_file(temp_path);
326            }
327        }
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[tokio::test]
336    async fn test_streaming_write_and_commit_success() {
337        let temp_dir = std::env::temp_dir().join(format!("lanflare_writer_test_{}", uuid::Uuid::new_v4()));
338        let dest_file = temp_dir.join("sample.bin");
339
340        let payload = b"Hello LocalSend and LanFlare transfer engine!";
341        let expected_hash = format!("{:x}", Sha256::digest(payload));
342
343        let mut writer = StreamingFileWriter::create(
344            &dest_file,
345            Some(payload.len() as u64),
346            Some(expected_hash.clone()),
347        )
348        .await
349        .unwrap();
350
351        assert!(writer.temp_path().exists());
352        assert!(!dest_file.exists());
353
354        // Write in two chunks
355        writer.write_chunk(&payload[..15]).await.unwrap();
356        writer.write_chunk(&payload[15..]).await.unwrap();
357
358        assert_eq!(writer.bytes_written(), payload.len() as u64);
359
360        let result = writer.commit().await.unwrap();
361        assert_eq!(result.target_path, dest_file);
362        assert_eq!(result.bytes_written, payload.len() as u64);
363        assert_eq!(result.sha256, expected_hash);
364
365        // Target file exists with exact bytes
366        let on_disk = tokio::fs::read(&dest_file).await.unwrap();
367        assert_eq!(on_disk, payload);
368
369        // Temp file no longer exists
370        let _ = tokio::fs::remove_dir_all(&temp_dir).await;
371    }
372
373    #[tokio::test]
374    async fn test_streaming_checksum_mismatch_1bit_corruption() {
375        let temp_dir = std::env::temp_dir().join(format!("lanflare_writer_corrupt_{}", uuid::Uuid::new_v4()));
376        let dest_file = temp_dir.join("corrupted.bin");
377
378        let payload = b"Original uncorrupted bytes data";
379        let valid_hash = format!("{:x}", Sha256::digest(payload));
380
381        let mut writer = StreamingFileWriter::create(
382            &dest_file,
383            Some(payload.len() as u64),
384            Some(valid_hash),
385        )
386        .await
387        .unwrap();
388
389        let temp_path = writer.temp_path().to_path_buf();
390        assert!(temp_path.exists());
391
392        // Introduce 1-bit corruption in payload
393        let mut corrupted_payload = payload.to_vec();
394        corrupted_payload[0] ^= 0x01; // flip 1 bit
395
396        writer.write_chunk(&corrupted_payload).await.unwrap();
397
398        // Commit must detect mismatch and reject
399        let err = writer.commit().await.unwrap_err();
400        match err {
401            StorageError::ChecksumMismatch { expected, actual } => {
402                assert_ne!(expected, actual);
403            }
404            other => panic!("Expected ChecksumMismatch, got: {other:?}"),
405        }
406
407        // Target file must not exist
408        assert!(!dest_file.exists());
409        // Temp file must have been cleaned up
410        assert!(!temp_path.exists());
411
412        let _ = tokio::fs::remove_dir_all(&temp_dir).await;
413    }
414
415    #[tokio::test]
416    async fn test_streaming_size_mismatch_on_commit() {
417        let temp_dir = std::env::temp_dir().join(format!("lanflare_writer_size_{}", uuid::Uuid::new_v4()));
418        let dest_file = temp_dir.join("incomplete.bin");
419
420        let mut writer = StreamingFileWriter::create(&dest_file, Some(100), None).await.unwrap();
421        let temp_path = writer.temp_path().to_path_buf();
422
423        writer.write_chunk(b"only 25 bytes here.......").await.unwrap();
424        let err = writer.commit().await.unwrap_err();
425
426        match err {
427            StorageError::SizeMismatch { expected, actual } => {
428                assert_eq!(expected, 100);
429                assert_eq!(actual, 25);
430            }
431            other => panic!("Expected SizeMismatch, got: {other:?}"),
432        }
433
434        assert!(!dest_file.exists());
435        assert!(!temp_path.exists());
436
437        let _ = tokio::fs::remove_dir_all(&temp_dir).await;
438    }
439
440    #[tokio::test]
441    async fn test_streaming_size_exceeded_early_abort() {
442        let temp_dir = std::env::temp_dir().join(format!("lanflare_writer_exceed_{}", uuid::Uuid::new_v4()));
443        let dest_file = temp_dir.join("bomb.bin");
444
445        let mut writer = StreamingFileWriter::create(&dest_file, Some(20), None).await.unwrap();
446        let temp_path = writer.temp_path().to_path_buf();
447
448        let err = writer.write_chunk(&[0u8; 30]).await.unwrap_err();
449        match err {
450            StorageError::SizeExceeded { expected, actual } => {
451                assert_eq!(expected, 20);
452                assert_eq!(actual, 30);
453            }
454            other => panic!("Expected SizeExceeded, got: {other:?}"),
455        }
456
457        // Temp file is immediately cleaned up
458        assert!(!temp_path.exists());
459        assert!(!dest_file.exists());
460
461        let _ = tokio::fs::remove_dir_all(&temp_dir).await;
462    }
463
464    #[tokio::test]
465    async fn test_streaming_manual_abort() {
466        let temp_dir = std::env::temp_dir().join(format!("lanflare_writer_abort_{}", uuid::Uuid::new_v4()));
467        let dest_file = temp_dir.join("aborted.bin");
468
469        let mut writer = StreamingFileWriter::create(&dest_file, None, None).await.unwrap();
470        let temp_path = writer.temp_path().to_path_buf();
471        writer.write_chunk(b"partial content").await.unwrap();
472
473        assert!(temp_path.exists());
474        writer.abort().await.unwrap();
475
476        assert!(!temp_path.exists());
477        assert!(!dest_file.exists());
478
479        let _ = tokio::fs::remove_dir_all(&temp_dir).await;
480    }
481
482    #[tokio::test]
483    async fn test_streaming_implicit_drop_cleanup() {
484        let temp_dir = std::env::temp_dir().join(format!("lanflare_writer_drop_{}", uuid::Uuid::new_v4()));
485        let dest_file = temp_dir.join("dropped.bin");
486
487        let temp_path;
488        {
489            let mut writer = StreamingFileWriter::create(&dest_file, None, None).await.unwrap();
490            temp_path = writer.temp_path().to_path_buf();
491            writer.write_chunk(b"will be dropped").await.unwrap();
492            assert!(temp_path.exists());
493            // writer dropped here without commit
494        }
495
496        assert!(!temp_path.exists());
497        assert!(!dest_file.exists());
498
499        let _ = tokio::fs::remove_dir_all(&temp_dir).await;
500    }
501
502    #[tokio::test]
503    async fn test_streaming_write_from_reader() {
504        let temp_dir = std::env::temp_dir().join(format!("lanflare_writer_reader_{}", uuid::Uuid::new_v4()));
505        let dest_file = temp_dir.join("streamed.txt");
506
507        let text = "Streamed via AsyncRead reader correctly!";
508        let cursor = std::io::Cursor::new(text.as_bytes());
509
510        let mut writer = StreamingFileWriter::create(&dest_file, None, None).await.unwrap();
511        writer.write_all_from_reader(cursor).await.unwrap();
512        let res = writer.commit().await.unwrap();
513
514        assert_eq!(res.bytes_written, text.len() as u64);
515        let content = tokio::fs::read_to_string(&dest_file).await.unwrap();
516        assert_eq!(content, text);
517
518        let _ = tokio::fs::remove_dir_all(&temp_dir).await;
519    }
520
521    #[tokio::test]
522    async fn test_target_already_exists_rejection() {
523        let temp_dir = std::env::temp_dir().join(format!("lanflare_writer_exists_{}", uuid::Uuid::new_v4()));
524        tokio::fs::create_dir_all(&temp_dir).await.unwrap();
525        let dest_file = temp_dir.join("existing.txt");
526        tokio::fs::write(&dest_file, b"already here").await.unwrap();
527
528        let err = StreamingFileWriter::create_with_overwrite(&dest_file, None, None, false)
529            .await
530            .unwrap_err();
531
532        match err {
533            StorageError::AlreadyExists(p) => assert_eq!(p, dest_file),
534            other => panic!("Expected AlreadyExists, got: {other:?}"),
535        }
536
537        let _ = tokio::fs::remove_dir_all(&temp_dir).await;
538    }
539
540    #[tokio::test]
541    async fn test_streaming_write_from_stream() {
542        let temp_dir = std::env::temp_dir().join(format!("lanflare_writer_stream_{}", uuid::Uuid::new_v4()));
543        let dest_file = temp_dir.join("streamed_chunks.txt");
544
545        let chunks: Vec<Result<Vec<u8>, std::io::Error>> = vec![
546            Ok(b"Chunk 1, ".to_vec()),
547            Ok(b"Chunk 2, ".to_vec()),
548            Ok(b"Chunk 3.".to_vec()),
549        ];
550        let stream = futures_util::stream::iter(chunks);
551
552        let mut writer = StreamingFileWriter::create(&dest_file, None, None).await.unwrap();
553        writer.write_all_from_stream(stream).await.unwrap();
554        let res = writer.commit().await.unwrap();
555
556        assert_eq!(res.bytes_written, 26);
557        let content = tokio::fs::read_to_string(&dest_file).await.unwrap();
558        assert_eq!(content, "Chunk 1, Chunk 2, Chunk 3.");
559
560        let _ = tokio::fs::remove_dir_all(&temp_dir).await;
561    }
562}