Skip to content

Commit f7e20c8

Browse files
committed
Implement memory-efficient file upload streaming with comprehensive tests
1 parent 0b65566 commit f7e20c8

5 files changed

Lines changed: 462 additions & 93 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ glob = "0.3.1"
1414
log = "0.4.20"
1515
env_logger = "0.11.3"
1616
base64 = "0.22.1"
17+
rand = "0.8.5"
1718

1819
[dev-dependencies]
1920
reqwest = { version = "0.12.22", features = ["blocking", "json"] }

src/multipart.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,9 @@ pub struct MultipartPart<R> {
544544

545545
impl<R: Read> MultipartPart<R> {
546546
/// Read the entire part content as bytes
547+
///
548+
/// Note: This loads the entire part content into memory. For large files,
549+
/// consider using the reader directly with a buffer to stream the content.
547550
pub fn read_to_bytes(&mut self) -> Result<Vec<u8>, AppError> {
548551
let mut buffer = Vec::new();
549552
self.reader.read_to_end(&mut buffer).map_err(|e| {
@@ -556,6 +559,39 @@ impl<R: Read> MultipartPart<R> {
556559
Ok(buffer)
557560
}
558561

562+
/// Stream content to a writer using a buffer of specified size
563+
///
564+
/// This is more memory-efficient than read_to_bytes() for large files
565+
pub fn stream_to<W: std::io::Write>(
566+
&mut self,
567+
writer: &mut W,
568+
buffer_size: usize,
569+
) -> Result<u64, AppError> {
570+
let mut buffer = vec![0; buffer_size];
571+
let mut total_bytes = 0;
572+
573+
loop {
574+
let bytes_read = self.reader.read(&mut buffer).map_err(|e| {
575+
if e.to_string().contains("size limit") {
576+
AppError::PayloadTooLarge(self.reader.max_size)
577+
} else {
578+
AppError::Io(e)
579+
}
580+
})?;
581+
582+
if bytes_read == 0 {
583+
break;
584+
}
585+
586+
writer
587+
.write_all(&buffer[..bytes_read])
588+
.map_err(AppError::Io)?;
589+
total_bytes += bytes_read as u64;
590+
}
591+
592+
Ok(total_bytes)
593+
}
594+
559595
/// Read the entire part content as a UTF-8 string
560596
pub fn read_to_string(&mut self) -> Result<String, AppError> {
561597
let bytes = self.read_to_bytes()?;

src/upload.rs

Lines changed: 88 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,12 @@ use crate::multipart::{MultipartConfig, MultipartParser};
5151
use crate::response::{get_mime_type, HttpResponse};
5252
use crate::templates::TemplateEngine;
5353
use glob::Pattern;
54-
use log::{debug, error, info, warn};
54+
use log::{error, info, warn};
55+
use rand;
5556
use std::collections::HashMap;
5657
use std::env;
5758
use std::fs::{self, File, OpenOptions};
58-
use std::io::{Cursor, Write};
59+
use std::io::{Cursor, Read, Write};
5960
use std::path::{Path, PathBuf};
6061

6162
/// Temporary file prefix for atomic operations
@@ -406,13 +407,79 @@ impl UploadHandler {
406407
// Validate filename
407408
self.validate_filename(&original_filename)?;
408409

409-
// Read file content
410-
let file_content = part.read_to_bytes()?;
411-
total_bytes += file_content.len() as u64;
410+
// Get file size from Content-Length header if available
411+
let content_length = part
412+
.headers
413+
.headers
414+
.get("content-length")
415+
.and_then(|len| len.parse::<u64>().ok());
416+
417+
// Check file size limit early if we know the size
418+
if let Some(length) = content_length {
419+
if length > self.max_file_size {
420+
return Err(AppError::payload_too_large(self.max_file_size));
421+
}
422+
total_bytes += length;
423+
}
424+
425+
// Create a temporary file for streaming the upload
426+
let temp_filename = format!(
427+
"{}{}_{}_{:x}.tmp",
428+
TEMP_FILE_PREFIX,
429+
std::process::id(),
430+
std::time::SystemTime::now()
431+
.duration_since(std::time::UNIX_EPOCH)
432+
.unwrap_or_default()
433+
.as_nanos(),
434+
rand::random::<u32>() // Add randomness for uniqueness
435+
);
436+
let temp_path = self.target_dir.join(&temp_filename);
412437

413-
// Additional size check per file
414-
if file_content.len() as u64 > self.max_file_size {
415-
return Err(AppError::payload_too_large(self.max_file_size));
438+
// Stream the file content directly to disk instead of loading it into memory
439+
let mut temp_file = File::create(&temp_path).map_err(|e| {
440+
error!("Failed to create temporary file {temp_path:?}: {e}");
441+
AppError::from(e)
442+
})?;
443+
444+
// Use a reasonably sized buffer for streaming
445+
let mut buffer = [0u8; 64 * 1024]; // 64KB buffer
446+
let mut file_size: u64 = 0;
447+
448+
// Stream data from part reader to file
449+
loop {
450+
match part.reader.read(&mut buffer) {
451+
Ok(0) => break, // End of file
452+
Ok(bytes_read) => {
453+
temp_file.write_all(&buffer[..bytes_read]).map_err(|e| {
454+
error!("Failed to write to temporary file {temp_path:?}: {e}");
455+
let _ = fs::remove_file(&temp_path); // Cleanup on error
456+
AppError::from(e)
457+
})?;
458+
file_size += bytes_read as u64;
459+
460+
// Check size limit during streaming
461+
if file_size > self.max_file_size {
462+
let _ = fs::remove_file(&temp_path); // Cleanup
463+
return Err(AppError::payload_too_large(self.max_file_size));
464+
}
465+
}
466+
Err(e) => {
467+
let _ = fs::remove_file(&temp_path); // Cleanup on error
468+
return Err(AppError::Io(e));
469+
}
470+
}
471+
}
472+
473+
// Sync file to ensure data is written
474+
temp_file.sync_all().map_err(|e| {
475+
error!("Failed to sync temporary file {temp_path:?}: {e}");
476+
let _ = fs::remove_file(&temp_path); // Cleanup on error
477+
AppError::from(e)
478+
})?;
479+
480+
// Update total bytes if we didn't have Content-Length
481+
if content_length.is_none() {
482+
total_bytes += file_size;
416483
}
417484

418485
// Validate file extension
@@ -426,8 +493,14 @@ impl UploadHandler {
426493
self.generate_unique_filename(&original_filename)?;
427494
let target_path = self.target_dir.join(&final_filename);
428495

429-
// Write file atomically
430-
let saved_path = self.write_file_atomically(&target_path, &file_content)?;
496+
// Rename the temporary file to the target path (atomic operation)
497+
let saved_path = fs::rename(&temp_path, &target_path)
498+
.map(|_| target_path.clone())
499+
.map_err(|e| {
500+
error!("Failed to rename {temp_path:?} to {target_path:?}: {e}");
501+
let _ = fs::remove_file(&temp_path); // Cleanup on error
502+
AppError::from(e)
503+
})?;
431504

432505
if was_renamed {
433506
warnings.push(format!(
@@ -439,15 +512,15 @@ impl UploadHandler {
439512
original_name: original_filename,
440513
saved_name: final_filename,
441514
saved_path,
442-
size: file_content.len() as u64,
515+
size: file_size,
443516
mime_type,
444517
renamed: was_renamed,
445518
});
446519

447520
info!(
448521
"Successfully uploaded file: {} ({} bytes)",
449522
uploaded_files.last().unwrap().saved_name,
450-
file_content.len()
523+
file_size
451524
);
452525
}
453526

@@ -596,56 +669,9 @@ impl UploadHandler {
596669
))
597670
}
598671

599-
/// Write file atomically using temporary file and rename
600-
fn write_file_atomically(
601-
&self,
602-
target_path: &Path,
603-
content: &[u8],
604-
) -> Result<PathBuf, AppError> {
605-
// Create temporary file in same directory with unique name
606-
let nanos = std::time::SystemTime::now()
607-
.duration_since(std::time::UNIX_EPOCH)
608-
.unwrap_or_default()
609-
.as_nanos();
610-
let temp_filename = format!(
611-
"{}{}_{}_{:x}.tmp",
612-
TEMP_FILE_PREFIX,
613-
std::process::id(),
614-
nanos,
615-
nanos.wrapping_mul(7919) // Simple hash to add more uniqueness
616-
);
617-
let temp_path = self.target_dir.join(temp_filename);
618-
619-
// Write to temporary file
620-
{
621-
let mut temp_file = File::create(&temp_path).map_err(|e| {
622-
error!("Failed to create temporary file {temp_path:?}: {e}");
623-
AppError::from(e)
624-
})?;
625-
626-
temp_file.write_all(content).map_err(|e| {
627-
error!("Failed to write to temporary file {temp_path:?}: {e}");
628-
let _ = fs::remove_file(&temp_path); // Cleanup on error
629-
AppError::from(e)
630-
})?;
631-
632-
temp_file.sync_all().map_err(|e| {
633-
error!("Failed to sync temporary file {temp_path:?}: {e}");
634-
let _ = fs::remove_file(&temp_path); // Cleanup on error
635-
AppError::from(e)
636-
})?;
637-
}
638-
639-
// Atomically rename temporary file to target
640-
fs::rename(&temp_path, target_path).map_err(|e| {
641-
error!("Failed to rename {temp_path:?} to {target_path:?}: {e}");
642-
let _ = fs::remove_file(&temp_path); // Cleanup on error
643-
AppError::from(e)
644-
})?;
645-
646-
debug!("Successfully wrote file atomically to {target_path:?}");
647-
Ok(target_path.to_path_buf())
648-
}
672+
// The write_file_atomically method has been removed as it's no longer used.
673+
// File uploads now use direct streaming to disk with atomic rename operations
674+
// to avoid loading entire files into memory.
649675

650676
/// Generate appropriate response based on request Accept header
651677
fn generate_upload_response(

0 commit comments

Comments
 (0)