1use std::path::{Path, PathBuf};
4use sha2::{Digest, Sha256};
5use thiserror::Error;
6use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt, BufWriter};
7
8use super::path::PathSanitizeError;
9
10pub const TEMP_FILE_EXTENSION: &str = "lanflare-part";
12
13pub const DEFAULT_BUFFER_CAPACITY: usize = 128 * 1024;
15
16#[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#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct StorageCommitResult {
47 pub target_path: PathBuf,
49 pub bytes_written: u64,
51 pub sha256: String,
53}
54
55#[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 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 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 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 pub fn target_path(&self) -> &Path {
158 &self.target_path
159 }
160
161 pub fn temp_path(&self) -> &Path {
163 &self.temp_path
164 }
165
166 pub fn bytes_written(&self) -> u64 {
168 self.bytes_written
169 }
170
171 pub fn expected_size(&self) -> Option<u64> {
173 self.expected_size
174 }
175
176 pub fn expected_sha256(&self) -> Option<&str> {
178 self.expected_sha256.as_deref()
179 }
180
181 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 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 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 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 pub async fn commit(mut self) -> Result<StorageCommitResult, StorageError> {
249 if self.committed {
250 return Err(StorageError::Aborted);
251 }
252
253 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 let hasher = std::mem::take(&mut self.hasher);
263 let computed_hash = format!("{:x}", hasher.finalize());
264
265 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 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 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 pub async fn abort(mut self) -> Result<(), StorageError> {
302 self.cleanup_temp_file().await;
303 Ok(())
304 }
305
306 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 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 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 let on_disk = tokio::fs::read(&dest_file).await.unwrap();
367 assert_eq!(on_disk, payload);
368
369 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 let mut corrupted_payload = payload.to_vec();
394 corrupted_payload[0] ^= 0x01; writer.write_chunk(&corrupted_payload).await.unwrap();
397
398 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 assert!(!dest_file.exists());
409 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 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 }
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}