Skip to content

Commit db85c81

Browse files
committed
fix: binary-safe multipart parsing and CLI deploy status HTTP timeout
1 parent fe8679d commit db85c81

2 files changed

Lines changed: 148 additions & 82 deletions

File tree

crates/cargo-rustapi/src/commands/deploy.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,7 @@ async fn deploy_cloud(args: CloudArgs) -> Result<()> {
416416
// Upload
417417
println!(" ☁️ Uploading to RustAPI Cloud...");
418418

419-
let client = reqwest::Client::new();
419+
let client = cloud_http_client()?;
420420
let form = reqwest::multipart::Form::new()
421421
.text("project_name", project_name.clone())
422422
.part(
@@ -483,13 +483,21 @@ async fn deploy_cloud(args: CloudArgs) -> Result<()> {
483483
Ok(())
484484
}
485485

486+
#[cfg(feature = "cloud")]
487+
fn cloud_http_client() -> Result<reqwest::Client> {
488+
reqwest::Client::builder()
489+
.timeout(Duration::from_secs(10))
490+
.build()
491+
.context("Failed to build HTTP client")
492+
}
493+
486494
#[cfg(feature = "cloud")]
487495
async fn fetch_deploy_status(
488496
cloud_url: &str,
489497
token: &str,
490498
deploy_id: &str,
491499
) -> Result<DeployResponse> {
492-
let client = reqwest::Client::new();
500+
let client = cloud_http_client()?;
493501
let response = client
494502
.get(format!("{}/deploy/{}/status", cloud_url, deploy_id))
495503
.header("Authorization", format!("Bearer {}", token))

crates/rustapi-core/src/multipart.rs

Lines changed: 138 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,9 @@ impl FromRequest for Multipart {
449449
let boundary = extract_boundary(content_type)
450450
.ok_or_else(|| ApiError::bad_request("Missing boundary in Content-Type"))?;
451451

452-
// Get body
452+
// Buffer streaming bodies from live HTTP connections before parsing.
453+
req.load_body().await?;
454+
453455
let body = req
454456
.take_body()
455457
.ok_or_else(|| ApiError::internal("Body already consumed"))?;
@@ -562,96 +564,131 @@ fn extract_boundary(content_type: &str) -> Option<String> {
562564
})
563565
}
564566

565-
/// Parse multipart form data
566-
fn parse_multipart(body: &Bytes, boundary: &str) -> Result<Vec<MultipartField>> {
567-
let mut fields = Vec::new();
568-
let delimiter = format!("--{}", boundary);
569-
let end_delimiter = format!("--{}--", boundary);
570-
571-
// Convert body to string for easier parsing
572-
// Note: This is a simplified parser. For production, consider using multer crate.
573-
let body_str = String::from_utf8_lossy(body);
567+
fn find_subsequence(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
568+
haystack[from..]
569+
.windows(needle.len())
570+
.position(|window| window == needle)
571+
.map(|pos| from + pos)
572+
}
574573

575-
// Split by delimiter
576-
let parts: Vec<&str> = body_str.split(&delimiter).collect();
574+
fn trim_trailing_crlf(mut data: Vec<u8>) -> Vec<u8> {
575+
while data.ends_with(b"\r\n") {
576+
data.truncate(data.len().saturating_sub(2));
577+
}
578+
while data.ends_with(b"\n") {
579+
data.truncate(data.len().saturating_sub(1));
580+
}
581+
data
582+
}
577583

578-
for part in parts.iter().skip(1) {
579-
// Skip empty parts and end delimiter
580-
let part = part.trim_start_matches("\r\n").trim_start_matches('\n');
581-
if part.is_empty() || part.starts_with("--") {
584+
fn parse_multipart_part(part: &[u8]) -> Option<MultipartField> {
585+
let (header_end, body_start) = if let Some(pos) = find_subsequence(part, b"\r\n\r\n", 0) {
586+
(pos, pos + 4)
587+
} else if let Some(pos) = find_subsequence(part, b"\n\n", 0) {
588+
(pos, pos + 2)
589+
} else {
590+
return None;
591+
};
592+
593+
let headers_section = String::from_utf8_lossy(&part[..header_end]);
594+
let body_section = trim_trailing_crlf(part[body_start..].to_vec());
595+
596+
let mut name = None;
597+
let mut filename = None;
598+
let mut content_type = None;
599+
600+
for header_line in headers_section.lines() {
601+
let header_line = header_line.trim();
602+
if header_line.is_empty() {
582603
continue;
583604
}
584605

585-
// Find header/body separator (blank line)
586-
let header_body_split = if let Some(pos) = part.find("\r\n\r\n") {
587-
pos
588-
} else if let Some(pos) = part.find("\n\n") {
589-
pos
590-
} else {
591-
continue;
592-
};
593-
594-
let headers_section = &part[..header_body_split];
595-
let body_section = &part[header_body_split..]
596-
.trim_start_matches("\r\n\r\n")
597-
.trim_start_matches("\n\n");
598-
599-
// Remove trailing boundary markers from body
600-
let body_section = body_section
601-
.trim_end_matches(&end_delimiter)
602-
.trim_end_matches(&delimiter)
603-
.trim_end_matches("\r\n")
604-
.trim_end_matches('\n');
605-
606-
// Parse headers
607-
let mut name = None;
608-
let mut filename = None;
609-
let mut content_type = None;
610-
611-
for header_line in headers_section.lines() {
612-
let header_line = header_line.trim();
613-
if header_line.is_empty() {
614-
continue;
615-
}
616-
617-
if let Some((key, value)) = header_line.split_once(':') {
618-
let key = key.trim().to_lowercase();
619-
let value = value.trim();
620-
621-
match key.as_str() {
622-
"content-disposition" => {
623-
// Parse name and filename from Content-Disposition
624-
for part in value.split(';') {
625-
let part = part.trim();
626-
if part.starts_with("name=") {
627-
name = Some(
628-
part.trim_start_matches("name=")
629-
.trim_matches('"')
630-
.to_string(),
631-
);
632-
} else if part.starts_with("filename=") {
633-
filename = Some(
634-
part.trim_start_matches("filename=")
635-
.trim_matches('"')
636-
.to_string(),
637-
);
638-
}
606+
if let Some((key, value)) = header_line.split_once(':') {
607+
let key = key.trim().to_lowercase();
608+
let value = value.trim();
609+
610+
match key.as_str() {
611+
"content-disposition" => {
612+
for segment in value.split(';') {
613+
let segment = segment.trim();
614+
if segment.starts_with("name=") {
615+
name = Some(
616+
segment
617+
.trim_start_matches("name=")
618+
.trim_matches('"')
619+
.to_string(),
620+
);
621+
} else if segment.starts_with("filename=") {
622+
filename = Some(
623+
segment
624+
.trim_start_matches("filename=")
625+
.trim_matches('"')
626+
.to_string(),
627+
);
639628
}
640629
}
641-
"content-type" => {
642-
content_type = Some(value.to_string());
643-
}
644-
_ => {}
645630
}
631+
"content-type" => {
632+
content_type = Some(value.to_string());
633+
}
634+
_ => {}
646635
}
647636
}
637+
}
648638

649-
fields.push(MultipartField::new(
650-
name,
651-
filename,
652-
content_type,
653-
Bytes::copy_from_slice(body_section.as_bytes()),
654-
));
639+
Some(MultipartField::new(
640+
name,
641+
filename,
642+
content_type,
643+
Bytes::from(body_section),
644+
))
645+
}
646+
647+
/// Parse multipart form data from raw bytes (binary-safe).
648+
fn parse_multipart(body: &Bytes, boundary: &str) -> Result<Vec<MultipartField>> {
649+
let delimiter = format!("--{}", boundary);
650+
let delim = delimiter.as_bytes();
651+
652+
let first = find_subsequence(body, delim, 0)
653+
.ok_or_else(|| ApiError::bad_request("No multipart boundary found"))?;
654+
655+
let mut fields = Vec::new();
656+
let mut cursor = first + delim.len();
657+
658+
if body[cursor..].starts_with(b"--") {
659+
return Ok(fields);
660+
}
661+
662+
if body[cursor..].starts_with(b"\r\n") {
663+
cursor += 2;
664+
} else if body.get(cursor) == Some(&b'\n') {
665+
cursor += 1;
666+
}
667+
668+
while cursor < body.len() {
669+
let next = find_subsequence(body, delim, cursor);
670+
let part_end = next.unwrap_or(body.len());
671+
let part = &body[cursor..part_end];
672+
673+
if !part.is_empty() {
674+
if let Some(field) = parse_multipart_part(part) {
675+
fields.push(field);
676+
}
677+
}
678+
679+
let Some(next_pos) = next else {
680+
break;
681+
};
682+
683+
cursor = next_pos + delim.len();
684+
if body[cursor..].starts_with(b"--") {
685+
break;
686+
}
687+
if body[cursor..].starts_with(b"\r\n") {
688+
cursor += 2;
689+
} else if body.get(cursor) == Some(&b'\n') {
690+
cursor += 1;
691+
}
655692
}
656693

657694
Ok(fields)
@@ -866,6 +903,27 @@ mod tests {
866903
assert!(fields[1].is_file());
867904
}
868905

906+
#[tokio::test]
907+
async fn test_parse_multipart_preserves_binary_payload() {
908+
let boundary = "test-boundary";
909+
let binary = vec![0x4D, 0x5A, 0x90, 0x00, 0xFF, 0xFE, 0x00, 0x00];
910+
let mut body = format!(
911+
"--{boundary}\r\nContent-Disposition: form-data; name=\"project_name\"\r\n\r\napp\r\n\
912+
--{boundary}\r\nContent-Disposition: form-data; name=\"binary\"; filename=\"app.bin\"\r\n\
913+
Content-Type: application/octet-stream\r\n\r\n"
914+
)
915+
.into_bytes();
916+
body.extend_from_slice(&binary);
917+
body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
918+
919+
let fields = parse_multipart(&Bytes::from(body), boundary).unwrap();
920+
assert_eq!(fields.len(), 2);
921+
assert_eq!(
922+
fields[1].bytes().await.expect("binary field"),
923+
binary.as_slice()
924+
);
925+
}
926+
869927
#[test]
870928
fn test_multipart_config() {
871929
let config = MultipartConfig::new()

0 commit comments

Comments
 (0)