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
183 let body = problem.to_json().unwrap_or_else(|_| "{}".to_string());
184 return Err((
185 StatusCode::UNSUPPORTED_MEDIA_TYPE,
186 [(axum::http::header::CONTENT_TYPE, CONTENT_TYPE_PROBLEM_JSON)],
187 body,
188 )
189 .into_response());
190 }
191 }
192 Ok(())
193}
194
195#[allow(clippy::result_large_err, clippy::collapsible_if)]
197pub fn validate_content_length(headers: &HeaderMap, actual_size: usize) -> Result<(), Response> {
198 if let Some(content_length_header) = headers.get(axum::http::header::CONTENT_LENGTH) {
199 let Some(declared_length) = parse_ascii_usize(content_length_header.as_bytes()) else {
200 return Ok(());
201 };
202 if declared_length != actual_size {
203 let problem = ProblemDetails::bad_request(format!(
204 "Content-Length header ({}) does not match actual body size ({})",
205 declared_length, actual_size
206 ));
207
208 let body = problem.to_json().unwrap_or_else(|_| "{}".to_string());
209 return Err((
210 StatusCode::BAD_REQUEST,
211 [(axum::http::header::CONTENT_TYPE, CONTENT_TYPE_PROBLEM_JSON)],
212 body,
213 )
214 .into_response());
215 }
216 }
217 Ok(())
218}
219
220fn parse_ascii_usize(bytes: &[u8]) -> Option<usize> {
221 if bytes.is_empty() {
222 return None;
223 }
224 let mut value: usize = 0;
225 for &b in bytes {
226 if !b.is_ascii_digit() {
227 return None;
228 }
229 value = value.saturating_mul(10).saturating_add((b - b'0') as usize);
230 }
231 Some(value)
232}
233
234#[allow(clippy::result_large_err)]
236pub fn validate_content_type_headers(headers: &HeaderMap, _declared_body_size: usize) -> Result<(), Response> {
237 validate_content_type_headers_and_classify(headers, _declared_body_size).map(|_| ())
238}
239
240#[allow(clippy::result_large_err)]
242pub fn validate_content_type_headers_and_classify(
243 headers: &HeaderMap,
244 _declared_body_size: usize,
245) -> Result<Option<ContentTypeKind>, Response> {
246 let Some(content_type) = headers.get(axum::http::header::CONTENT_TYPE) else {
247 return Ok(None);
248 };
249
250 if !content_type.as_bytes().is_ascii() && content_type.to_str().is_err() {
251 let error_body = json!({
253 "error": "Invalid Content-Type header"
254 });
255 return Err((StatusCode::BAD_REQUEST, axum::Json(error_body)).into_response());
256 }
257
258 let token = token_before_semicolon(content_type.as_bytes());
259 if !is_valid_content_type_token(token) {
260 let error_body = json!({
261 "error": "Invalid Content-Type header"
262 });
263 return Err((StatusCode::BAD_REQUEST, axum::Json(error_body)).into_response());
264 }
265
266 let is_json = is_json_like_token(token);
267 let is_multipart = is_multipart_form_data_token(token);
268 let is_form = is_form_urlencoded_token(token);
269
270 if is_multipart && !multipart_has_boundary(content_type) {
271 let error_body = json!({
272 "error": "multipart/form-data requires 'boundary' parameter"
273 });
274 return Err((StatusCode::BAD_REQUEST, axum::Json(error_body)).into_response());
275 }
276
277 if is_json
278 && let Some(charset) = json_charset_value(content_type)
279 && !charset.eq_ignore_ascii_case(b"utf-8")
280 && !charset.eq_ignore_ascii_case(b"utf8")
281 {
282 let charset_str = String::from_utf8_lossy(charset);
283 let problem = ProblemDetails::new(
284 "https://spikard.dev/errors/unsupported-charset",
285 "Unsupported Charset",
286 StatusCode::UNSUPPORTED_MEDIA_TYPE,
287 )
288 .with_detail(format!(
289 "Unsupported charset '{}' for JSON. Only UTF-8 is supported.",
290 charset_str
291 ));
292
293 let body = problem.to_json().unwrap_or_else(|_| "{}".to_string());
294 return Err((
295 StatusCode::UNSUPPORTED_MEDIA_TYPE,
296 [(axum::http::header::CONTENT_TYPE, CONTENT_TYPE_PROBLEM_JSON)],
297 body,
298 )
299 .into_response());
300 }
301
302 let kind = if is_json {
303 ContentTypeKind::Json
304 } else if is_multipart {
305 ContentTypeKind::Multipart
306 } else if is_form {
307 ContentTypeKind::FormUrlencoded
308 } else {
309 ContentTypeKind::Other
310 };
311
312 Ok(Some(kind))
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use axum::http::HeaderValue;
319
320 #[test]
321 fn validate_content_length_accepts_matching_sizes() {
322 let mut headers = HeaderMap::new();
323 headers.insert(axum::http::header::CONTENT_LENGTH, HeaderValue::from_static("5"));
324
325 assert!(validate_content_length(&headers, 5).is_ok());
326 }
327
328 #[test]
329 fn validate_content_length_rejects_mismatched_sizes() {
330 let mut headers = HeaderMap::new();
331 headers.insert(axum::http::header::CONTENT_LENGTH, HeaderValue::from_static("10"));
332
333 let err = validate_content_length(&headers, 4).expect_err("expected mismatch");
334 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
335 assert_eq!(
336 err.headers()
337 .get(axum::http::header::CONTENT_TYPE)
338 .and_then(|value| value.to_str().ok()),
339 Some(CONTENT_TYPE_PROBLEM_JSON)
340 );
341 }
342
343 #[test]
344 fn test_multipart_without_boundary() {
345 let mut headers = HeaderMap::new();
346 headers.insert(
347 axum::http::header::CONTENT_TYPE,
348 HeaderValue::from_static("multipart/form-data"),
349 );
350
351 let result = validate_content_type_headers(&headers, 0);
352 assert!(result.is_err());
353 }
354
355 #[test]
356 fn test_multipart_with_boundary() {
357 let mut headers = HeaderMap::new();
358 headers.insert(
359 axum::http::header::CONTENT_TYPE,
360 HeaderValue::from_static("multipart/form-data; boundary=----WebKitFormBoundary"),
361 );
362
363 let result = validate_content_type_headers(&headers, 0);
364 assert!(result.is_ok());
365 }
366
367 #[test]
368 fn test_json_with_utf16_charset() {
369 let mut headers = HeaderMap::new();
370 headers.insert(
371 axum::http::header::CONTENT_TYPE,
372 HeaderValue::from_static("application/json; charset=utf-16"),
373 );
374
375 let result = validate_content_type_headers(&headers, 0);
376 assert!(result.is_err());
377 }
378
379 #[test]
380 fn test_json_with_utf8_charset() {
381 let mut headers = HeaderMap::new();
382 headers.insert(
383 axum::http::header::CONTENT_TYPE,
384 HeaderValue::from_static("application/json; charset=utf-8"),
385 );
386
387 let result = validate_content_type_headers(&headers, 0);
388 assert!(result.is_ok());
389 }
390
391 #[test]
392 fn test_json_without_charset() {
393 let mut headers = HeaderMap::new();
394 headers.insert(
395 axum::http::header::CONTENT_TYPE,
396 HeaderValue::from_static("application/json"),
397 );
398
399 let result = validate_content_type_headers(&headers, 0);
400 assert!(result.is_ok());
401 }
402
403 #[test]
404 fn test_vendor_json_accepted() {
405 let mut headers = HeaderMap::new();
406 headers.insert(
407 axum::http::header::CONTENT_TYPE,
408 HeaderValue::from_static("application/vnd.api+json"),
409 );
410
411 let result = validate_content_type_headers(&headers, 0);
412 assert!(result.is_ok());
413 }
414
415 #[test]
416 fn test_problem_json_accepted() {
417 let mut headers = HeaderMap::new();
418 headers.insert(
419 axum::http::header::CONTENT_TYPE,
420 HeaderValue::from_static("application/problem+json"),
421 );
422
423 let result = validate_content_type_headers(&headers, 0);
424 assert!(result.is_ok());
425 }
426
427 #[test]
428 fn test_vendor_json_with_utf16_charset_rejected() {
429 let mut headers = HeaderMap::new();
430 headers.insert(
431 axum::http::header::CONTENT_TYPE,
432 HeaderValue::from_static("application/vnd.api+json; charset=utf-16"),
433 );
434
435 let result = validate_content_type_headers(&headers, 0);
436 assert!(result.is_err());
437 }
438
439 #[test]
440 fn test_vendor_json_with_utf8_charset_accepted() {
441 let mut headers = HeaderMap::new();
442 headers.insert(
443 axum::http::header::CONTENT_TYPE,
444 HeaderValue::from_static("application/vnd.api+json; charset=utf-8"),
445 );
446
447 let result = validate_content_type_headers(&headers, 0);
448 assert!(result.is_ok());
449 }
450
451 #[test]
452 fn test_is_json_content_type() {
453 let mime = "application/json".parse::<mime::Mime>().unwrap();
454 assert!(is_json_content_type(&mime));
455
456 let mime = "application/vnd.api+json".parse::<mime::Mime>().unwrap();
457 assert!(is_json_content_type(&mime));
458
459 let mime = "application/problem+json".parse::<mime::Mime>().unwrap();
460 assert!(is_json_content_type(&mime));
461
462 let mime = "application/hal+json".parse::<mime::Mime>().unwrap();
463 assert!(is_json_content_type(&mime));
464
465 let mime = "text/plain".parse::<mime::Mime>().unwrap();
466 assert!(!is_json_content_type(&mime));
467
468 let mime = "application/xml".parse::<mime::Mime>().unwrap();
469 assert!(!is_json_content_type(&mime));
470
471 let mime = "application/x-www-form-urlencoded".parse::<mime::Mime>().unwrap();
472 assert!(!is_json_content_type(&mime));
473 }
474
475 #[test]
476 fn test_json_with_utf8_uppercase_charset() {
477 let mut headers = HeaderMap::new();
478 headers.insert(
479 axum::http::header::CONTENT_TYPE,
480 HeaderValue::from_static("application/json; charset=UTF-8"),
481 );
482
483 let result = validate_content_type_headers(&headers, 0);
484 assert!(result.is_ok(), "UTF-8 in uppercase should be accepted");
485 }
486
487 #[test]
488 fn test_json_with_utf8_no_hyphen_charset() {
489 let mut headers = HeaderMap::new();
490 headers.insert(
491 axum::http::header::CONTENT_TYPE,
492 HeaderValue::from_static("application/json; charset=utf8"),
493 );
494
495 let result = validate_content_type_headers(&headers, 0);
496 assert!(result.is_ok(), "utf8 without hyphen should be accepted");
497 }
498
499 #[test]
500 fn test_json_with_iso88591_charset_rejected() {
501 let mut headers = HeaderMap::new();
502 headers.insert(
503 axum::http::header::CONTENT_TYPE,
504 HeaderValue::from_static("application/json; charset=iso-8859-1"),
505 );
506
507 let result = validate_content_type_headers(&headers, 0);
508 assert!(result.is_err(), "iso-8859-1 should be rejected for JSON");
509 }
510
511 #[test]
512 fn test_json_with_utf32_charset_rejected() {
513 let mut headers = HeaderMap::new();
514 headers.insert(
515 axum::http::header::CONTENT_TYPE,
516 HeaderValue::from_static("application/json; charset=utf-32"),
517 );
518
519 let result = validate_content_type_headers(&headers, 0);
520 assert!(result.is_err(), "UTF-32 should be rejected for JSON");
521 }
522
523 #[test]
524 fn test_multipart_with_boundary_and_charset() {
525 let mut headers = HeaderMap::new();
526 headers.insert(
527 axum::http::header::CONTENT_TYPE,
528 HeaderValue::from_static("multipart/form-data; boundary=abc123; charset=utf-8"),
529 );
530
531 let result = validate_content_type_headers(&headers, 0);
532 assert!(
533 result.is_ok(),
534 "multipart with boundary should accept charset parameter"
535 );
536 }
537
538 #[test]
539 fn test_validate_content_length_no_header() {
540 let headers = HeaderMap::new();
541
542 let result = validate_content_length(&headers, 1024);
543 assert!(result.is_ok(), "Missing Content-Length header should pass");
544 }
545
546 #[test]
547 fn test_validate_content_length_zero_bytes() {
548 let mut headers = HeaderMap::new();
549 headers.insert(axum::http::header::CONTENT_LENGTH, HeaderValue::from_static("0"));
550
551 assert!(validate_content_length(&headers, 0).is_ok());
552 }
553
554 #[test]
555 fn test_validate_content_length_large_body() {
556 let mut headers = HeaderMap::new();
557 let large_size = 1024 * 1024 * 100;
558 headers.insert(
559 axum::http::header::CONTENT_LENGTH,
560 HeaderValue::from_str(&large_size.to_string()).unwrap(),
561 );
562
563 assert!(validate_content_length(&headers, large_size).is_ok());
564 }
565
566 #[test]
567 fn test_validate_content_length_invalid_header_format() {
568 let mut headers = HeaderMap::new();
569 headers.insert(
570 axum::http::header::CONTENT_LENGTH,
571 HeaderValue::from_static("not-a-number"),
572 );
573
574 let result = validate_content_length(&headers, 100);
575 assert!(
576 result.is_ok(),
577 "Invalid Content-Length format should be skipped gracefully"
578 );
579 }
580
581 #[test]
582 fn test_invalid_content_type_format() {
583 let mut headers = HeaderMap::new();
584 headers.insert(
585 axum::http::header::CONTENT_TYPE,
586 HeaderValue::from_static("not/a/valid/type"),
587 );
588
589 let result = validate_content_type_headers(&headers, 0);
590 assert!(result.is_err(), "Invalid mime type format should be rejected");
591 }
592
593 #[test]
594 fn test_unsupported_content_type_xml() {
595 let mut headers = HeaderMap::new();
596 headers.insert(
597 axum::http::header::CONTENT_TYPE,
598 HeaderValue::from_static("application/xml"),
599 );
600
601 let result = validate_content_type_headers(&headers, 0);
602 assert!(
603 result.is_ok(),
604 "XML should pass header validation (routing layer rejects if needed)"
605 );
606 }
607
608 #[test]
609 fn test_unsupported_content_type_plain_text() {
610 let mut headers = HeaderMap::new();
611 headers.insert(axum::http::header::CONTENT_TYPE, HeaderValue::from_static("text/plain"));
612
613 let result = validate_content_type_headers(&headers, 0);
614 assert!(result.is_ok(), "Plain text should pass header validation");
615 }
616
617 #[test]
618 fn test_content_type_with_boundary_missing_boundary_param() {
619 let mut headers = HeaderMap::new();
620 headers.insert(
621 axum::http::header::CONTENT_TYPE,
622 HeaderValue::from_static("multipart/form-data; charset=utf-8"),
623 );
624
625 let result = validate_content_type_headers(&headers, 0);
626 assert!(
627 result.is_err(),
628 "multipart/form-data without boundary parameter should be rejected"
629 );
630 }
631
632 #[test]
633 fn test_content_type_form_urlencoded() {
634 let mut headers = HeaderMap::new();
635 headers.insert(
636 axum::http::header::CONTENT_TYPE,
637 HeaderValue::from_static("application/x-www-form-urlencoded"),
638 );
639
640 let result = validate_content_type_headers(&headers, 0);
641 assert!(result.is_ok(), "form-urlencoded should be accepted");
642 }
643
644 #[test]
645 fn test_is_json_content_type_with_hal_json() {
646 let mime = "application/hal+json".parse::<mime::Mime>().unwrap();
647 assert!(is_json_content_type(&mime), "HAL+JSON should be recognized as JSON");
648 }
649
650 #[test]
651 fn test_is_json_content_type_with_ld_json() {
652 let mime = "application/ld+json".parse::<mime::Mime>().unwrap();
653 assert!(is_json_content_type(&mime), "LD+JSON should be recognized as JSON");
654 }
655
656 #[test]
657 fn test_is_json_content_type_rejects_json_patch() {
658 let mime = "application/json-patch+json".parse::<mime::Mime>().unwrap();
659 assert!(is_json_content_type(&mime), "JSON-Patch should be recognized as JSON");
660 }
661
662 #[test]
663 fn test_is_json_content_type_rejects_html() {
664 let mime = "text/html".parse::<mime::Mime>().unwrap();
665 assert!(!is_json_content_type(&mime), "HTML should not be JSON");
666 }
667
668 #[test]
669 fn test_is_json_content_type_rejects_csv() {
670 let mime = "text/csv".parse::<mime::Mime>().unwrap();
671 assert!(!is_json_content_type(&mime), "CSV should not be JSON");
672 }
673
674 #[test]
675 fn test_is_json_content_type_rejects_image_png() {
676 let mime = "image/png".parse::<mime::Mime>().unwrap();
677 assert!(!is_json_content_type(&mime), "PNG should not be JSON");
678 }
679
680 #[test]
681 fn test_validate_json_content_type_missing_header() {
682 let headers = HeaderMap::new();
683 let result = validate_json_content_type(&headers);
684 assert!(
685 result.is_ok(),
686 "Missing Content-Type for JSON route should be OK (routing layer handles)"
687 );
688 }
689
690 #[test]
691 fn test_validate_json_content_type_accepts_form_urlencoded() {
692 let mut headers = HeaderMap::new();
693 headers.insert(
694 axum::http::header::CONTENT_TYPE,
695 HeaderValue::from_static("application/x-www-form-urlencoded"),
696 );
697
698 let result = validate_json_content_type(&headers);
699 assert!(result.is_ok(), "Form-urlencoded should be accepted for JSON routes");
700 }
701
702 #[test]
703 fn test_validate_json_content_type_accepts_multipart() {
704 let mut headers = HeaderMap::new();
705 headers.insert(
706 axum::http::header::CONTENT_TYPE,
707 HeaderValue::from_static("multipart/form-data; boundary=abc123"),
708 );
709
710 let result = validate_json_content_type(&headers);
711 assert!(result.is_ok(), "Multipart should be accepted for JSON routes");
712 }
713
714 #[test]
715 fn test_validate_json_content_type_rejects_xml() {
716 let mut headers = HeaderMap::new();
717 headers.insert(
718 axum::http::header::CONTENT_TYPE,
719 HeaderValue::from_static("application/xml"),
720 );
721
722 let result = validate_json_content_type(&headers);
723 assert!(result.is_err(), "XML should be rejected for JSON-expecting routes");
724 assert_eq!(
725 result.unwrap_err().status(),
726 StatusCode::UNSUPPORTED_MEDIA_TYPE,
727 "Should return 415 Unsupported Media Type"
728 );
729 }
730
731 #[test]
732 fn test_content_type_with_multiple_parameters() {
733 let mut headers = HeaderMap::new();
734 headers.insert(
735 axum::http::header::CONTENT_TYPE,
736 HeaderValue::from_static("application/json; charset=utf-8; boundary=xyz"),
737 );
738
739 let result = validate_content_type_headers(&headers, 0);
740 assert!(result.is_ok(), "Multiple parameters should be parsed correctly");
741 }
742
743 #[test]
744 fn test_content_type_with_quoted_parameter() {
745 let mut headers = HeaderMap::new();
746 headers.insert(
747 axum::http::header::CONTENT_TYPE,
748 HeaderValue::from_static(r#"multipart/form-data; boundary="----WebKitFormBoundary""#),
749 );
750
751 let result = validate_content_type_headers(&headers, 0);
752 assert!(result.is_ok(), "Quoted boundary parameter should be handled");
753 }
754
755 #[test]
756 fn test_content_type_case_insensitive_type() {
757 let mut headers = HeaderMap::new();
758 headers.insert(
759 axum::http::header::CONTENT_TYPE,
760 HeaderValue::from_static("Application/JSON"),
761 );
762
763 let result = validate_content_type_headers(&headers, 0);
764 assert!(result.is_ok(), "Content-Type type/subtype should be case-insensitive");
765 }
766}