1use axum::http::HeaderValue;
4use axum::http::{HeaderMap, StatusCode};
5use axum::response::{IntoResponse, Response};
6use serde_json::json;
7use spikard_core::problem::{CONTENT_TYPE_PROBLEM_JSON, ProblemDetails};
8
9pub fn is_json_content_type(mime: &mime::Mime) -> bool {
11 (mime.type_() == mime::APPLICATION && mime.subtype() == mime::JSON) || mime.suffix() == Some(mime::JSON)
12}
13
14fn trim_ascii_whitespace(bytes: &[u8]) -> &[u8] {
15 let mut start = 0usize;
16 let mut end = bytes.len();
17 while start < end && (bytes[start] == b' ' || bytes[start] == b'\t') {
18 start += 1;
19 }
20 while end > start && (bytes[end - 1] == b' ' || bytes[end - 1] == b'\t') {
21 end -= 1;
22 }
23 &bytes[start..end]
24}
25
26fn token_before_semicolon(bytes: &[u8]) -> &[u8] {
27 let mut i = 0usize;
28 while i < bytes.len() {
29 let b = bytes[i];
30 if b == b';' {
31 break;
32 }
33 i += 1;
34 }
35 trim_ascii_whitespace(&bytes[..i])
36}
37
38#[inline]
39fn is_json_like_token(token: &[u8]) -> bool {
40 if token.eq_ignore_ascii_case(b"application/json") {
41 return true;
42 }
43 token.len() >= 5 && token[token.len() - 5..].eq_ignore_ascii_case(b"+json")
45}
46
47#[inline]
48fn is_multipart_form_data_token(token: &[u8]) -> bool {
49 token.eq_ignore_ascii_case(b"multipart/form-data")
50}
51
52#[inline]
53fn is_form_urlencoded_token(token: &[u8]) -> bool {
54 token.eq_ignore_ascii_case(b"application/x-www-form-urlencoded")
55}
56
57fn is_valid_content_type_token(token: &[u8]) -> bool {
58 if token.is_empty() {
63 return false;
64 }
65 let mut slash_pos: Option<usize> = None;
66 for (idx, &b) in token.iter().enumerate() {
67 if b == b' ' || b == b'\t' {
68 return false;
69 }
70 if b == b'/' {
71 if slash_pos.is_some() {
72 return false;
73 }
74 slash_pos = Some(idx);
75 }
76 }
77 match slash_pos {
78 None => false,
79 Some(0) => false,
80 Some(pos) if pos + 1 >= token.len() => false,
81 Some(_) => true,
82 }
83}
84
85fn ascii_contains_ignore_case(haystack: &[u8], needle: &[u8]) -> bool {
86 if needle.is_empty() {
87 return true;
88 }
89 if haystack.len() < needle.len() {
90 return false;
91 }
92 haystack.windows(needle.len()).any(|w| w.eq_ignore_ascii_case(needle))
93}
94
95pub fn is_json_like(content_type: &HeaderValue) -> bool {
97 let token = token_before_semicolon(content_type.as_bytes());
98 is_json_like_token(token)
99}
100
101pub fn is_json_like_str(content_type: &str) -> bool {
105 let token = token_before_semicolon(content_type.as_bytes());
106 is_json_like_token(token)
107}
108
109pub fn is_multipart_form_data(content_type: &HeaderValue) -> bool {
111 let token = token_before_semicolon(content_type.as_bytes());
112 is_multipart_form_data_token(token)
113}
114
115pub fn is_form_urlencoded(content_type: &HeaderValue) -> bool {
117 let token = token_before_semicolon(content_type.as_bytes());
118 is_form_urlencoded_token(token)
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum ContentTypeKind {
124 Json,
125 Multipart,
126 FormUrlencoded,
127 Other,
128}
129
130fn multipart_has_boundary(content_type: &HeaderValue) -> bool {
131 ascii_contains_ignore_case(content_type.as_bytes(), b"boundary=")
132}
133
134fn json_charset_value(content_type: &HeaderValue) -> Option<&[u8]> {
135 let bytes = content_type.as_bytes();
136 if !ascii_contains_ignore_case(bytes, b"charset=") {
137 return None;
138 }
139
140 let mut i = 0usize;
142 while i + 8 <= bytes.len() {
143 if bytes[i..i + 8].eq_ignore_ascii_case(b"charset=") {
144 let mut j = i + 8;
145 while j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t') {
146 j += 1;
147 }
148 let start = j;
149 while j < bytes.len() {
150 let b = bytes[j];
151 if b == b';' || b == b' ' || b == b'\t' {
152 break;
153 }
154 j += 1;
155 }
156 return Some(&bytes[start..j]);
157 }
158 i += 1;
159 }
160 None
161}
162
163#[allow(clippy::result_large_err)]
165pub fn validate_json_content_type(headers: &HeaderMap) -> Result<(), Response> {
166 if let Some(content_type_header) = headers.get(axum::http::header::CONTENT_TYPE) {
167 if content_type_header.to_str().is_err() {
168 return Ok(());
169 }
170
171 let token = token_before_semicolon(content_type_header.as_bytes());
172 let is_json = is_json_like_token(token);
173 let is_form = is_form_urlencoded_token(token) || is_multipart_form_data_token(token);
174
175 if !is_json && !is_form {
176 let problem = ProblemDetails::new(
177 "https://spikard.dev/errors/unsupported-media-type",
178 "Unsupported Media Type",
179 StatusCode::UNSUPPORTED_MEDIA_TYPE,
180 )
181 .with_detail("Unsupported media type");
182 let body = problem.to_json().unwrap_or_else(|_| "{}".to_string());
183 return Err((
184 StatusCode::UNSUPPORTED_MEDIA_TYPE,
185 [(axum::http::header::CONTENT_TYPE, CONTENT_TYPE_PROBLEM_JSON)],
186 body,
187 )
188 .into_response());
189 }
190 }
191 Ok(())
192}
193
194#[allow(clippy::result_large_err, clippy::collapsible_if)]
196pub fn validate_content_length(headers: &HeaderMap, actual_size: usize) -> Result<(), Response> {
197 if let Some(content_length_header) = headers.get(axum::http::header::CONTENT_LENGTH) {
198 let Some(declared_length) = parse_ascii_usize(content_length_header.as_bytes()) else {
199 return Ok(());
200 };
201 if declared_length != actual_size {
202 let problem = ProblemDetails::new(
203 "https://spikard.dev/errors/content-length-mismatch",
204 "Content-Length header mismatch",
205 StatusCode::BAD_REQUEST,
206 )
207 .with_detail("Content-Length header does not match actual body size");
208 let body = problem.to_json().unwrap_or_else(|_| {
209 json!({"error": "Content-Length header does not match actual body size"}).to_string()
210 });
211 return Err((
212 StatusCode::BAD_REQUEST,
213 [(axum::http::header::CONTENT_TYPE, CONTENT_TYPE_PROBLEM_JSON)],
214 body,
215 )
216 .into_response());
217 }
218 }
219 Ok(())
220}
221
222fn parse_ascii_usize(bytes: &[u8]) -> Option<usize> {
223 if bytes.is_empty() {
224 return None;
225 }
226 let mut value: usize = 0;
227 for &b in bytes {
228 if !b.is_ascii_digit() {
229 return None;
230 }
231 value = value.saturating_mul(10).saturating_add((b - b'0') as usize);
232 }
233 Some(value)
234}
235
236#[allow(clippy::result_large_err)]
238pub fn validate_content_type_headers(headers: &HeaderMap, _declared_body_size: usize) -> Result<(), Response> {
239 validate_content_type_headers_and_classify(headers, _declared_body_size).map(|_| ())
240}
241
242#[allow(clippy::result_large_err)]
244pub fn validate_content_type_headers_and_classify(
245 headers: &HeaderMap,
246 _declared_body_size: usize,
247) -> Result<Option<ContentTypeKind>, Response> {
248 let Some(content_type) = headers.get(axum::http::header::CONTENT_TYPE) else {
249 return Ok(None);
250 };
251
252 if !content_type.as_bytes().is_ascii() && content_type.to_str().is_err() {
253 let error_body = json!({
255 "error": "Invalid Content-Type header"
256 });
257 return Err((StatusCode::BAD_REQUEST, axum::Json(error_body)).into_response());
258 }
259
260 let token = token_before_semicolon(content_type.as_bytes());
261 if !is_valid_content_type_token(token) {
262 let error_body = json!({
263 "error": "Invalid Content-Type header"
264 });
265 return Err((StatusCode::BAD_REQUEST, axum::Json(error_body)).into_response());
266 }
267
268 let is_json = is_json_like_token(token);
269 let is_multipart = is_multipart_form_data_token(token);
270 let is_form = is_form_urlencoded_token(token);
271
272 if is_multipart && !multipart_has_boundary(content_type) {
273 let error_body = json!({
274 "error": "multipart/form-data requires 'boundary' parameter"
275 });
276 return Err((StatusCode::BAD_REQUEST, axum::Json(error_body)).into_response());
277 }
278
279 if is_json
280 && let Some(charset) = json_charset_value(content_type)
281 && !charset.eq_ignore_ascii_case(b"utf-8")
282 && !charset.eq_ignore_ascii_case(b"utf8")
283 {
284 let charset_str = String::from_utf8_lossy(charset);
285 let problem = ProblemDetails::new(
286 "https://spikard.dev/errors/unsupported-charset",
287 "Unsupported Charset",
288 StatusCode::UNSUPPORTED_MEDIA_TYPE,
289 )
290 .with_detail(format!(
291 "Unsupported charset '{}' for JSON. Only UTF-8 is supported.",
292 charset_str
293 ));
294
295 let body = problem.to_json().unwrap_or_else(|_| "{}".to_string());
296 return Err((
297 StatusCode::UNSUPPORTED_MEDIA_TYPE,
298 [(axum::http::header::CONTENT_TYPE, CONTENT_TYPE_PROBLEM_JSON)],
299 body,
300 )
301 .into_response());
302 }
303
304 let kind = if is_json {
305 ContentTypeKind::Json
306 } else if is_multipart {
307 ContentTypeKind::Multipart
308 } else if is_form {
309 ContentTypeKind::FormUrlencoded
310 } else {
311 ContentTypeKind::Other
312 };
313
314 Ok(Some(kind))
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use axum::http::HeaderValue;
321
322 #[test]
323 fn validate_content_length_accepts_matching_sizes() {
324 let mut headers = HeaderMap::new();
325 headers.insert(axum::http::header::CONTENT_LENGTH, HeaderValue::from_static("5"));
326
327 assert!(validate_content_length(&headers, 5).is_ok());
328 }
329
330 #[test]
331 fn validate_content_length_rejects_mismatched_sizes() {
332 let mut headers = HeaderMap::new();
333 headers.insert(axum::http::header::CONTENT_LENGTH, HeaderValue::from_static("10"));
334
335 let err = validate_content_length(&headers, 4).expect_err("expected mismatch");
336 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
337 assert_eq!(
338 err.headers()
339 .get(axum::http::header::CONTENT_TYPE)
340 .and_then(|value| value.to_str().ok()),
341 Some(CONTENT_TYPE_PROBLEM_JSON)
342 );
343 }
344
345 #[test]
346 fn test_multipart_without_boundary() {
347 let mut headers = HeaderMap::new();
348 headers.insert(
349 axum::http::header::CONTENT_TYPE,
350 HeaderValue::from_static("multipart/form-data"),
351 );
352
353 let result = validate_content_type_headers(&headers, 0);
354 assert!(result.is_err());
355 }
356
357 #[test]
358 fn test_multipart_with_boundary() {
359 let mut headers = HeaderMap::new();
360 headers.insert(
361 axum::http::header::CONTENT_TYPE,
362 HeaderValue::from_static("multipart/form-data; boundary=----WebKitFormBoundary"),
363 );
364
365 let result = validate_content_type_headers(&headers, 0);
366 assert!(result.is_ok());
367 }
368
369 #[test]
370 fn test_json_with_utf16_charset() {
371 let mut headers = HeaderMap::new();
372 headers.insert(
373 axum::http::header::CONTENT_TYPE,
374 HeaderValue::from_static("application/json; charset=utf-16"),
375 );
376
377 let result = validate_content_type_headers(&headers, 0);
378 assert!(result.is_err());
379 }
380
381 #[test]
382 fn test_json_with_utf8_charset() {
383 let mut headers = HeaderMap::new();
384 headers.insert(
385 axum::http::header::CONTENT_TYPE,
386 HeaderValue::from_static("application/json; charset=utf-8"),
387 );
388
389 let result = validate_content_type_headers(&headers, 0);
390 assert!(result.is_ok());
391 }
392
393 #[test]
394 fn test_json_without_charset() {
395 let mut headers = HeaderMap::new();
396 headers.insert(
397 axum::http::header::CONTENT_TYPE,
398 HeaderValue::from_static("application/json"),
399 );
400
401 let result = validate_content_type_headers(&headers, 0);
402 assert!(result.is_ok());
403 }
404
405 #[test]
406 fn test_vendor_json_accepted() {
407 let mut headers = HeaderMap::new();
408 headers.insert(
409 axum::http::header::CONTENT_TYPE,
410 HeaderValue::from_static("application/vnd.api+json"),
411 );
412
413 let result = validate_content_type_headers(&headers, 0);
414 assert!(result.is_ok());
415 }
416
417 #[test]
418 fn test_problem_json_accepted() {
419 let mut headers = HeaderMap::new();
420 headers.insert(
421 axum::http::header::CONTENT_TYPE,
422 HeaderValue::from_static("application/problem+json"),
423 );
424
425 let result = validate_content_type_headers(&headers, 0);
426 assert!(result.is_ok());
427 }
428
429 #[test]
430 fn test_vendor_json_with_utf16_charset_rejected() {
431 let mut headers = HeaderMap::new();
432 headers.insert(
433 axum::http::header::CONTENT_TYPE,
434 HeaderValue::from_static("application/vnd.api+json; charset=utf-16"),
435 );
436
437 let result = validate_content_type_headers(&headers, 0);
438 assert!(result.is_err());
439 }
440
441 #[test]
442 fn test_vendor_json_with_utf8_charset_accepted() {
443 let mut headers = HeaderMap::new();
444 headers.insert(
445 axum::http::header::CONTENT_TYPE,
446 HeaderValue::from_static("application/vnd.api+json; charset=utf-8"),
447 );
448
449 let result = validate_content_type_headers(&headers, 0);
450 assert!(result.is_ok());
451 }
452
453 #[test]
454 fn test_is_json_content_type() {
455 let mime = "application/json".parse::<mime::Mime>().unwrap();
456 assert!(is_json_content_type(&mime));
457
458 let mime = "application/vnd.api+json".parse::<mime::Mime>().unwrap();
459 assert!(is_json_content_type(&mime));
460
461 let mime = "application/problem+json".parse::<mime::Mime>().unwrap();
462 assert!(is_json_content_type(&mime));
463
464 let mime = "application/hal+json".parse::<mime::Mime>().unwrap();
465 assert!(is_json_content_type(&mime));
466
467 let mime = "text/plain".parse::<mime::Mime>().unwrap();
468 assert!(!is_json_content_type(&mime));
469
470 let mime = "application/xml".parse::<mime::Mime>().unwrap();
471 assert!(!is_json_content_type(&mime));
472
473 let mime = "application/x-www-form-urlencoded".parse::<mime::Mime>().unwrap();
474 assert!(!is_json_content_type(&mime));
475 }
476
477 #[test]
478 fn test_json_with_utf8_uppercase_charset() {
479 let mut headers = HeaderMap::new();
480 headers.insert(
481 axum::http::header::CONTENT_TYPE,
482 HeaderValue::from_static("application/json; charset=UTF-8"),
483 );
484
485 let result = validate_content_type_headers(&headers, 0);
486 assert!(result.is_ok(), "UTF-8 in uppercase should be accepted");
487 }
488
489 #[test]
490 fn test_json_with_utf8_no_hyphen_charset() {
491 let mut headers = HeaderMap::new();
492 headers.insert(
493 axum::http::header::CONTENT_TYPE,
494 HeaderValue::from_static("application/json; charset=utf8"),
495 );
496
497 let result = validate_content_type_headers(&headers, 0);
498 assert!(result.is_ok(), "utf8 without hyphen should be accepted");
499 }
500
501 #[test]
502 fn test_json_with_iso88591_charset_rejected() {
503 let mut headers = HeaderMap::new();
504 headers.insert(
505 axum::http::header::CONTENT_TYPE,
506 HeaderValue::from_static("application/json; charset=iso-8859-1"),
507 );
508
509 let result = validate_content_type_headers(&headers, 0);
510 assert!(result.is_err(), "iso-8859-1 should be rejected for JSON");
511 }
512
513 #[test]
514 fn test_json_with_utf32_charset_rejected() {
515 let mut headers = HeaderMap::new();
516 headers.insert(
517 axum::http::header::CONTENT_TYPE,
518 HeaderValue::from_static("application/json; charset=utf-32"),
519 );
520
521 let result = validate_content_type_headers(&headers, 0);
522 assert!(result.is_err(), "UTF-32 should be rejected for JSON");
523 }
524
525 #[test]
526 fn test_multipart_with_boundary_and_charset() {
527 let mut headers = HeaderMap::new();
528 headers.insert(
529 axum::http::header::CONTENT_TYPE,
530 HeaderValue::from_static("multipart/form-data; boundary=abc123; charset=utf-8"),
531 );
532
533 let result = validate_content_type_headers(&headers, 0);
534 assert!(
535 result.is_ok(),
536 "multipart with boundary should accept charset parameter"
537 );
538 }
539
540 #[test]
541 fn test_validate_content_length_no_header() {
542 let headers = HeaderMap::new();
543
544 let result = validate_content_length(&headers, 1024);
545 assert!(result.is_ok(), "Missing Content-Length header should pass");
546 }
547
548 #[test]
549 fn test_validate_content_length_zero_bytes() {
550 let mut headers = HeaderMap::new();
551 headers.insert(axum::http::header::CONTENT_LENGTH, HeaderValue::from_static("0"));
552
553 assert!(validate_content_length(&headers, 0).is_ok());
554 }
555
556 #[test]
557 fn test_validate_content_length_large_body() {
558 let mut headers = HeaderMap::new();
559 let large_size = 1024 * 1024 * 100;
560 headers.insert(
561 axum::http::header::CONTENT_LENGTH,
562 HeaderValue::from_str(&large_size.to_string()).unwrap(),
563 );
564
565 assert!(validate_content_length(&headers, large_size).is_ok());
566 }
567
568 #[test]
569 fn test_validate_content_length_invalid_header_format() {
570 let mut headers = HeaderMap::new();
571 headers.insert(
572 axum::http::header::CONTENT_LENGTH,
573 HeaderValue::from_static("not-a-number"),
574 );
575
576 let result = validate_content_length(&headers, 100);
577 assert!(
578 result.is_ok(),
579 "Invalid Content-Length format should be skipped gracefully"
580 );
581 }
582
583 #[test]
584 fn test_invalid_content_type_format() {
585 let mut headers = HeaderMap::new();
586 headers.insert(
587 axum::http::header::CONTENT_TYPE,
588 HeaderValue::from_static("not/a/valid/type"),
589 );
590
591 let result = validate_content_type_headers(&headers, 0);
592 assert!(result.is_err(), "Invalid mime type format should be rejected");
593 }
594
595 #[test]
596 fn test_unsupported_content_type_xml() {
597 let mut headers = HeaderMap::new();
598 headers.insert(
599 axum::http::header::CONTENT_TYPE,
600 HeaderValue::from_static("application/xml"),
601 );
602
603 let result = validate_content_type_headers(&headers, 0);
604 assert!(
605 result.is_ok(),
606 "XML should pass header validation (routing layer rejects if needed)"
607 );
608 }
609
610 #[test]
611 fn test_unsupported_content_type_plain_text() {
612 let mut headers = HeaderMap::new();
613 headers.insert(axum::http::header::CONTENT_TYPE, HeaderValue::from_static("text/plain"));
614
615 let result = validate_content_type_headers(&headers, 0);
616 assert!(result.is_ok(), "Plain text should pass header validation");
617 }
618
619 #[test]
620 fn test_content_type_with_boundary_missing_boundary_param() {
621 let mut headers = HeaderMap::new();
622 headers.insert(
623 axum::http::header::CONTENT_TYPE,
624 HeaderValue::from_static("multipart/form-data; charset=utf-8"),
625 );
626
627 let result = validate_content_type_headers(&headers, 0);
628 assert!(
629 result.is_err(),
630 "multipart/form-data without boundary parameter should be rejected"
631 );
632 }
633
634 #[test]
635 fn test_content_type_form_urlencoded() {
636 let mut headers = HeaderMap::new();
637 headers.insert(
638 axum::http::header::CONTENT_TYPE,
639 HeaderValue::from_static("application/x-www-form-urlencoded"),
640 );
641
642 let result = validate_content_type_headers(&headers, 0);
643 assert!(result.is_ok(), "form-urlencoded should be accepted");
644 }
645
646 #[test]
647 fn test_is_json_content_type_with_hal_json() {
648 let mime = "application/hal+json".parse::<mime::Mime>().unwrap();
649 assert!(is_json_content_type(&mime), "HAL+JSON should be recognized as JSON");
650 }
651
652 #[test]
653 fn test_is_json_content_type_with_ld_json() {
654 let mime = "application/ld+json".parse::<mime::Mime>().unwrap();
655 assert!(is_json_content_type(&mime), "LD+JSON should be recognized as JSON");
656 }
657
658 #[test]
659 fn test_is_json_content_type_rejects_json_patch() {
660 let mime = "application/json-patch+json".parse::<mime::Mime>().unwrap();
661 assert!(is_json_content_type(&mime), "JSON-Patch should be recognized as JSON");
662 }
663
664 #[test]
665 fn test_is_json_content_type_rejects_html() {
666 let mime = "text/html".parse::<mime::Mime>().unwrap();
667 assert!(!is_json_content_type(&mime), "HTML should not be JSON");
668 }
669
670 #[test]
671 fn test_is_json_content_type_rejects_csv() {
672 let mime = "text/csv".parse::<mime::Mime>().unwrap();
673 assert!(!is_json_content_type(&mime), "CSV should not be JSON");
674 }
675
676 #[test]
677 fn test_is_json_content_type_rejects_image_png() {
678 let mime = "image/png".parse::<mime::Mime>().unwrap();
679 assert!(!is_json_content_type(&mime), "PNG should not be JSON");
680 }
681
682 #[test]
683 fn test_validate_json_content_type_missing_header() {
684 let headers = HeaderMap::new();
685 let result = validate_json_content_type(&headers);
686 assert!(
687 result.is_ok(),
688 "Missing Content-Type for JSON route should be OK (routing layer handles)"
689 );
690 }
691
692 #[test]
693 fn test_validate_json_content_type_accepts_form_urlencoded() {
694 let mut headers = HeaderMap::new();
695 headers.insert(
696 axum::http::header::CONTENT_TYPE,
697 HeaderValue::from_static("application/x-www-form-urlencoded"),
698 );
699
700 let result = validate_json_content_type(&headers);
701 assert!(result.is_ok(), "Form-urlencoded should be accepted for JSON routes");
702 }
703
704 #[test]
705 fn test_validate_json_content_type_accepts_multipart() {
706 let mut headers = HeaderMap::new();
707 headers.insert(
708 axum::http::header::CONTENT_TYPE,
709 HeaderValue::from_static("multipart/form-data; boundary=abc123"),
710 );
711
712 let result = validate_json_content_type(&headers);
713 assert!(result.is_ok(), "Multipart should be accepted for JSON routes");
714 }
715
716 #[test]
717 fn test_validate_json_content_type_rejects_xml() {
718 let mut headers = HeaderMap::new();
719 headers.insert(
720 axum::http::header::CONTENT_TYPE,
721 HeaderValue::from_static("application/xml"),
722 );
723
724 let result = validate_json_content_type(&headers);
725 assert!(result.is_err(), "XML should be rejected for JSON-expecting routes");
726 assert_eq!(
727 result.unwrap_err().status(),
728 StatusCode::UNSUPPORTED_MEDIA_TYPE,
729 "Should return 415 Unsupported Media Type"
730 );
731 }
732
733 #[test]
734 fn test_content_type_with_multiple_parameters() {
735 let mut headers = HeaderMap::new();
736 headers.insert(
737 axum::http::header::CONTENT_TYPE,
738 HeaderValue::from_static("application/json; charset=utf-8; boundary=xyz"),
739 );
740
741 let result = validate_content_type_headers(&headers, 0);
742 assert!(result.is_ok(), "Multiple parameters should be parsed correctly");
743 }
744
745 #[test]
746 fn test_content_type_with_quoted_parameter() {
747 let mut headers = HeaderMap::new();
748 headers.insert(
749 axum::http::header::CONTENT_TYPE,
750 HeaderValue::from_static(r#"multipart/form-data; boundary="----WebKitFormBoundary""#),
751 );
752
753 let result = validate_content_type_headers(&headers, 0);
754 assert!(result.is_ok(), "Quoted boundary parameter should be handled");
755 }
756
757 #[test]
758 fn test_content_type_case_insensitive_type() {
759 let mut headers = HeaderMap::new();
760 headers.insert(
761 axum::http::header::CONTENT_TYPE,
762 HeaderValue::from_static("Application/JSON"),
763 );
764
765 let result = validate_content_type_headers(&headers, 0);
766 assert!(result.is_ok(), "Content-Type type/subtype should be case-insensitive");
767 }
768}