Skip to main content

ruma_federation_api/
authenticated_media.rs

1//! Authenticated endpoints for the content repository, according to [MSC3916].
2//!
3//! [MSC3916]: https://github.com/matrix-org/matrix-spec-proposals/pull/3916
4
5use std::ops::Deref;
6
7#[cfg(feature = "server")]
8use ruma_common::api::OutgoingBody;
9#[cfg(feature = "client")]
10use ruma_common::api::error::HeaderDeserializationError;
11use ruma_common::http_headers::ContentDisposition;
12use serde::{Deserialize, Serialize};
13
14pub mod get_content;
15pub mod get_content_thumbnail;
16
17/// The `multipart/mixed` mime "essence".
18const MULTIPART_MIXED: &str = "multipart/mixed";
19/// The maximum number of headers to parse in a body part.
20#[cfg(feature = "client")]
21const MAX_HEADERS_COUNT: usize = 32;
22/// The length of the generated boundary.
23#[cfg(feature = "server")]
24const GENERATED_BOUNDARY_LENGTH: usize = 30;
25
26/// The metadata of a file from the content repository.
27#[derive(Debug, Clone, Default, Serialize, Deserialize)]
28#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
29pub struct ContentMetadata {}
30
31impl ContentMetadata {
32    /// Creates a new empty `ContentMetadata`.
33    pub fn new() -> Self {
34        Self {}
35    }
36}
37
38/// A file from the content repository or the location where it can be found.
39#[derive(Debug, Clone)]
40#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
41pub enum FileOrLocation {
42    /// The content of the file.
43    File(Content),
44
45    /// The file is at the given URL.
46    Location(String),
47}
48
49/// The content of a file from the content repository.
50#[derive(Debug, Clone)]
51#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
52pub struct Content {
53    /// The content of the file as bytes.
54    pub file: Vec<u8>,
55
56    /// The content type of the file that was previously uploaded.
57    pub content_type: Option<String>,
58
59    /// The value of the `Content-Disposition` HTTP header, possibly containing the name of the
60    /// file that was previously uploaded.
61    pub content_disposition: Option<ContentDisposition>,
62}
63
64impl Content {
65    /// Creates a new `Content` with the given bytes.
66    pub fn new(
67        file: Vec<u8>,
68        content_type: String,
69        content_disposition: ContentDisposition,
70    ) -> Self {
71        Self {
72            file,
73            content_type: Some(content_type),
74            content_disposition: Some(content_disposition),
75        }
76    }
77}
78
79/// A boundary in a `multipart/mixed` body.
80#[derive(Debug, Clone)]
81struct MultipartMixedBoundary(String);
82
83#[cfg(feature = "server")]
84impl MultipartMixedBoundary {
85    /// Generate a new random boundary.
86    fn new() -> Self {
87        use rand::RngExt as _;
88
89        Self(
90            rand::rng()
91                .sample_iter(&rand::distr::Alphanumeric)
92                .map(char::from)
93                .take(GENERATED_BOUNDARY_LENGTH)
94                .collect(),
95        )
96    }
97
98    /// Get the value of the `Content-Type` HTTP header for this boundary.
99    fn content_type(&self) -> http::HeaderValue {
100        format!("{MULTIPART_MIXED}; boundary={}", self.0)
101            .try_into()
102            .expect("content type should only contain visible ASCII characters")
103    }
104
105    /// Write this boundary as a separator between parts of the body.
106    fn write_separator(&self, buf: &mut impl std::io::Write) {
107        let _ = write!(buf, "\r\n--{}\r\n", self.0);
108    }
109
110    /// Write this boundary at the end of the body.
111    fn write_end(&self, buf: &mut impl std::io::Write) {
112        let _ = write!(buf, "\r\n--{}", self.0);
113    }
114}
115
116#[cfg(feature = "client")]
117impl MultipartMixedBoundary {
118    /// Parse the boundary in the headers of the given `http::Response`.
119    fn parse_http_response_headers(
120        http_response: &http::Response<&[u8]>,
121    ) -> Result<Self, HeaderDeserializationError> {
122        let body_content_type = http_response
123            .headers()
124            .get(http::header::CONTENT_TYPE)
125            .ok_or_else(|| HeaderDeserializationError::MissingHeader("Content-Type".to_owned()))?
126            .to_str()?
127            .parse::<mime::Mime>()
128            .map_err(|e| HeaderDeserializationError::InvalidHeader(e.into()))?;
129
130        if !body_content_type.essence_str().eq_ignore_ascii_case(MULTIPART_MIXED) {
131            return Err(HeaderDeserializationError::InvalidHeaderValue {
132                header: "Content-Type".to_owned(),
133                expected: MULTIPART_MIXED.to_owned(),
134                unexpected: body_content_type.essence_str().to_owned(),
135            });
136        }
137
138        Ok(Self(
139            body_content_type
140                .get_param("boundary")
141                .ok_or(HeaderDeserializationError::MissingMultipartBoundary)?
142                .as_str()
143                .to_owned(),
144        ))
145    }
146}
147
148impl Deref for MultipartMixedBoundary {
149    type Target = str;
150
151    fn deref(&self) -> &Self::Target {
152        &self.0
153    }
154}
155
156/// A `multipart/mixed` response body.
157#[doc(hidden)]
158#[derive(Debug, Clone)]
159pub struct ResponseBody {
160    metadata: ContentMetadata,
161    content: FileOrLocation,
162    // This field is never read when deserializing.
163    #[cfg_attr(not(feature = "server"), expect(dead_code))]
164    boundary: MultipartMixedBoundary,
165}
166
167#[cfg(feature = "server")]
168impl ResponseBody {
169    /// Construct a `ResponseBody` with the given metadata and content.
170    ///
171    /// The boundary is generated randomly.
172    fn new(metadata: ContentMetadata, content: FileOrLocation) -> Self {
173        Self { metadata, content, boundary: MultipartMixedBoundary::new() }
174    }
175}
176
177#[cfg(feature = "server")]
178impl OutgoingBody for ResponseBody {
179    type Error = ruma_common::api::error::IntoHttpError;
180
181    fn content_type(&self) -> Option<http::HeaderValue> {
182        Some(self.boundary.content_type())
183    }
184
185    fn try_into_buf<T: Default + bytes::BufMut>(self) -> Result<T, Self::Error> {
186        use std::io::Write as _;
187
188        let mut body_writer = T::default().writer();
189        let Self { metadata, content, boundary } = &self;
190
191        // Add first boundary separator.
192        boundary.write_separator(&mut body_writer);
193
194        // Add headers for the metadata.
195        let _ = write!(
196            body_writer,
197            "{}: {}\r\n\r\n",
198            http::header::CONTENT_TYPE,
199            mime::APPLICATION_JSON
200        );
201
202        // Add serialized metadata.
203        serde_json::to_writer(&mut body_writer, metadata)?;
204
205        // Add second boundary separator.
206        boundary.write_separator(&mut body_writer);
207
208        // Add content.
209        match content {
210            FileOrLocation::File(content) => {
211                // Add headers.
212                let content_type = content
213                    .content_type
214                    .as_deref()
215                    .unwrap_or(mime::APPLICATION_OCTET_STREAM.as_ref());
216                let _ = write!(body_writer, "{}: {content_type}\r\n", http::header::CONTENT_TYPE);
217
218                if let Some(content_disposition) = &content.content_disposition {
219                    let _ = write!(
220                        body_writer,
221                        "{}: {content_disposition}\r\n",
222                        http::header::CONTENT_DISPOSITION
223                    );
224                }
225
226                // Add empty line separator after headers.
227                let _ = body_writer.write_all(b"\r\n");
228
229                // Add bytes.
230                let _ = body_writer.write_all(&content.file);
231            }
232            FileOrLocation::Location(location) => {
233                // Only add location header and empty line separator.
234                let _ = write!(body_writer, "{}: {location}\r\n\r\n", http::header::LOCATION);
235            }
236        }
237
238        // Add final boundary.
239        boundary.write_end(&mut body_writer);
240
241        Ok(body_writer.into_inner())
242    }
243}
244
245#[cfg(feature = "client")]
246impl ResponseBody {
247    /// Deserialize a `ResponseBody` from the given `http::Response`.
248    fn try_from_http_response(
249        http_response: http::Response<&[u8]>,
250    ) -> Result<Self, ruma_common::api::error::DeserializationError> {
251        use ruma_common::api::error::MultipartMixedDeserializationError;
252
253        // First, get the boundary.
254        let boundary = MultipartMixedBoundary::parse_http_response_headers(&http_response)?;
255
256        // Split the body with the boundary.
257        let body = http_response.body();
258
259        let mut full_boundary = Vec::with_capacity(boundary.len() + 4);
260        full_boundary.extend_from_slice(b"\r\n--");
261        full_boundary.extend_from_slice(boundary.as_bytes());
262        let full_boundary_no_crlf = full_boundary.strip_prefix(b"\r\n").unwrap();
263
264        let mut boundaries = memchr::memmem::find_iter(body, &full_boundary);
265
266        let metadata_start = if body.starts_with(full_boundary_no_crlf) {
267            // If there is no preamble before the first boundary, it may omit the
268            // preceding CRLF.
269            full_boundary_no_crlf.len()
270        } else {
271            boundaries.next().ok_or_else(|| {
272                MultipartMixedDeserializationError::MissingBodyParts { expected: 2, found: 0 }
273            })? + full_boundary.len()
274        };
275        let metadata_end = boundaries.next().ok_or_else(|| {
276            MultipartMixedDeserializationError::MissingBodyParts { expected: 2, found: 0 }
277        })?;
278
279        let (_raw_metadata_headers, serialized_metadata) =
280            parse_multipart_body_part(body, metadata_start, metadata_end)?;
281
282        // Don't search for anything in the headers, just deserialize the content that should be
283        // JSON.
284        let metadata = serde_json::from_slice(serialized_metadata)?;
285
286        // Look at the part containing the media content now.
287        let content_start = metadata_end + full_boundary.len();
288        let content_end = boundaries.next().ok_or_else(|| {
289            MultipartMixedDeserializationError::MissingBodyParts { expected: 2, found: 1 }
290        })?;
291
292        let (raw_content_headers, file) =
293            parse_multipart_body_part(body, content_start, content_end)?;
294
295        // Parse the headers to retrieve the content type and content disposition.
296        let mut content_headers = [httparse::EMPTY_HEADER; MAX_HEADERS_COUNT];
297        httparse::parse_headers(raw_content_headers, &mut content_headers)
298            .map_err(|e| MultipartMixedDeserializationError::InvalidHeader(e.into()))?;
299
300        let mut location = None;
301        let mut content_type = None;
302        let mut content_disposition = None;
303        for header in content_headers {
304            if header.name.is_empty() {
305                // This is a empty header, we have reached the end of the parsed headers.
306                break;
307            }
308
309            if header.name == http::header::LOCATION {
310                location =
311                    Some(String::from_utf8(header.value.to_vec()).map_err(|e| {
312                        MultipartMixedDeserializationError::InvalidHeader(e.into())
313                    })?);
314
315                // This is the only header we need, stop parsing.
316                break;
317            } else if header.name == http::header::CONTENT_TYPE {
318                content_type =
319                    Some(String::from_utf8(header.value.to_vec()).map_err(|e| {
320                        MultipartMixedDeserializationError::InvalidHeader(e.into())
321                    })?);
322            } else if header.name == http::header::CONTENT_DISPOSITION {
323                content_disposition =
324                    Some(ContentDisposition::try_from(header.value).map_err(|e| {
325                        MultipartMixedDeserializationError::InvalidHeader(e.into())
326                    })?);
327            }
328        }
329
330        let content = if let Some(location) = location {
331            FileOrLocation::Location(location)
332        } else {
333            FileOrLocation::File(Content {
334                file: file.to_owned(),
335                content_type,
336                content_disposition,
337            })
338        };
339
340        Ok(Self { metadata, content, boundary })
341    }
342}
343
344/// Parse the multipart body part in the given bytes, starting and ending at the given positions.
345///
346/// Returns a `(headers_bytes, content_bytes)` tuple. Returns an error if the separation between the
347/// headers and the content could not be found.
348#[cfg(feature = "client")]
349fn parse_multipart_body_part(
350    bytes: &[u8],
351    start: usize,
352    end: usize,
353) -> Result<(&[u8], &[u8]), ruma_common::api::error::MultipartMixedDeserializationError> {
354    use ruma_common::api::error::MultipartMixedDeserializationError;
355
356    // The part should start with a newline after the boundary. We need to ignore characters before
357    // it in case of extra whitespaces, and for compatibility it might not have a CR.
358    let headers_start = memchr::memchr(b'\n', &bytes[start..end])
359        .expect("the end boundary contains a newline")
360        + start
361        + 1;
362
363    // Let's find an empty line now.
364    let mut line_start = headers_start;
365    let mut line_end;
366
367    loop {
368        line_end = memchr::memchr(b'\n', &bytes[line_start..end])
369            .ok_or(MultipartMixedDeserializationError::MissingBodyPartInnerSeparator)?
370            + line_start
371            + 1;
372
373        if matches!(&bytes[line_start..line_end], b"\r\n" | b"\n") {
374            break;
375        }
376
377        line_start = line_end;
378    }
379
380    Ok((&bytes[headers_start..line_start], &bytes[line_end..end]))
381}
382
383#[cfg(all(test, feature = "client", feature = "server"))]
384mod tests {
385    use assert_matches2::assert_matches;
386    use ruma_common::{
387        api::OutgoingBody,
388        http_headers::{ContentDisposition, ContentDispositionType},
389    };
390
391    use super::{Content, ContentMetadata, FileOrLocation, ResponseBody};
392
393    #[test]
394    fn multipart_mixed_content_ascii_filename_conversions() {
395        let file = "s⌽me UTF-8 Ťext".as_bytes();
396        let content_type = "text/plain";
397        let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
398            .with_filename(Some("filename.txt".to_owned()));
399
400        let outgoing_metadata = ContentMetadata::new();
401        let outgoing_content = FileOrLocation::File(Content {
402            file: file.to_vec(),
403            content_type: Some(content_type.to_owned()),
404            content_disposition: Some(content_disposition.clone()),
405        });
406
407        let body = ResponseBody::new(outgoing_metadata, outgoing_content);
408        let multipart_content_type = body.content_type().unwrap();
409        let body = body.try_into_buf::<Vec<u8>>().unwrap();
410
411        let response = http::Response::builder()
412            .header(http::header::CONTENT_TYPE, multipart_content_type)
413            .body(body.as_slice())
414            .unwrap();
415
416        let ResponseBody { content: incoming_content, .. } =
417            ResponseBody::try_from_http_response(response).unwrap();
418
419        assert_matches!(incoming_content, FileOrLocation::File(incoming_content));
420        assert_eq!(incoming_content.file, file);
421        assert_eq!(incoming_content.content_type.unwrap(), content_type);
422        assert_eq!(incoming_content.content_disposition, Some(content_disposition));
423    }
424
425    #[test]
426    fn multipart_mixed_content_utf8_filename_conversions() {
427        let file = "s⌽me UTF-8 Ťext".as_bytes();
428        let content_type = "text/plain";
429        let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
430            .with_filename(Some("fȈlƩnąmǝ.txt".to_owned()));
431
432        let outgoing_metadata = ContentMetadata::new();
433        let outgoing_content = FileOrLocation::File(Content {
434            file: file.to_vec(),
435            content_type: Some(content_type.to_owned()),
436            content_disposition: Some(content_disposition.clone()),
437        });
438
439        let body = ResponseBody::new(outgoing_metadata, outgoing_content);
440        let multipart_content_type = body.content_type().unwrap();
441        let body = body.try_into_buf::<Vec<u8>>().unwrap();
442
443        let response = http::Response::builder()
444            .header(http::header::CONTENT_TYPE, multipart_content_type)
445            .body(body.as_slice())
446            .unwrap();
447
448        let ResponseBody { content: incoming_content, .. } =
449            ResponseBody::try_from_http_response(response).unwrap();
450
451        assert_matches!(incoming_content, FileOrLocation::File(incoming_content));
452        assert_eq!(incoming_content.file, file);
453        assert_eq!(incoming_content.content_type.unwrap(), content_type);
454        assert_eq!(incoming_content.content_disposition, Some(content_disposition));
455    }
456
457    #[test]
458    fn multipart_mixed_location_conversions() {
459        let location = "https://server.local/media/filename.txt";
460
461        let outgoing_metadata = ContentMetadata::new();
462        let outgoing_content = FileOrLocation::Location(location.to_owned());
463
464        let body = ResponseBody::new(outgoing_metadata, outgoing_content);
465        let multipart_content_type = body.content_type().unwrap();
466        let body = body.try_into_buf::<Vec<u8>>().unwrap();
467
468        let response = http::Response::builder()
469            .header(http::header::CONTENT_TYPE, multipart_content_type)
470            .body(body.as_slice())
471            .unwrap();
472
473        let ResponseBody { content: incoming_content, .. } =
474            ResponseBody::try_from_http_response(response).unwrap();
475
476        assert_matches!(incoming_content, FileOrLocation::Location(incoming_location));
477        assert_eq!(incoming_location, location);
478    }
479
480    #[test]
481    fn multipart_mixed_deserialize_invalid() {
482        // Missing boundary in headers.
483        let body = b"\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\nContent-Type: text/plain\r\n\r\nsome plain text\r\n--abcdef--";
484        let response = http::Response::builder()
485            .header(http::header::CONTENT_TYPE, "multipart/mixed")
486            .body(body.as_slice())
487            .unwrap();
488
489        ResponseBody::try_from_http_response(response).unwrap_err();
490
491        // Wrong boundary.
492        let body = b"\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\nContent-Type: text/plain\r\n\r\nsome plain text\r\n--abcdef--";
493        let response = http::Response::builder()
494            .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=012345")
495            .body(body.as_slice())
496            .unwrap();
497
498        ResponseBody::try_from_http_response(response).unwrap_err();
499
500        // Missing boundary in body.
501        let body =
502            b"\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\nContent-Type: text/plain\r\n\r\nsome plain text";
503        let response = http::Response::builder()
504            .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
505            .body(body.as_slice())
506            .unwrap();
507
508        ResponseBody::try_from_http_response(response).unwrap_err();
509
510        // Missing header and content empty line separator in body part.
511        let body = b"\r\n--abcdef\r\n{}\r\n--abcdef\r\nContent-Type: text/plain\r\n\r\nsome plain text\r\n--abcdef--";
512        let response = http::Response::builder()
513            .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
514            .body(body.as_slice())
515            .unwrap();
516
517        ResponseBody::try_from_http_response(response).unwrap_err();
518
519        // Control character in header.
520        let body = b"\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\nContent-Type: text/plain\r\nContent-Disposition: inline; filename=\"my\nfile\"\r\nsome plain text\r\n--abcdef--";
521        let response = http::Response::builder()
522            .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
523            .body(body.as_slice())
524            .unwrap();
525
526        ResponseBody::try_from_http_response(response).unwrap_err();
527
528        // Boundary without CRLF with preamble.
529        let body = b"foo--abcdef\r\n\r\n{}\r\n--abcdef\r\n\r\nsome plain text\r\n--abcdef--";
530        let response = http::Response::builder()
531            .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
532            .body(body.as_slice())
533            .unwrap();
534
535        ResponseBody::try_from_http_response(response).unwrap_err();
536    }
537
538    #[test]
539    fn multipart_mixed_deserialize_valid() {
540        // Simple.
541        let body = b"\r\n--abcdef\r\ncontent-type: application/json\r\n\r\n{}\r\n--abcdef\r\ncontent-type: text/plain\r\n\r\nsome plain text\r\n--abcdef--";
542        let response = http::Response::builder()
543            .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
544            .body(body.as_slice())
545            .unwrap();
546
547        let ResponseBody { content, .. } = ResponseBody::try_from_http_response(response).unwrap();
548
549        assert_matches!(content, FileOrLocation::File(file_content));
550        assert_eq!(file_content.file, b"some plain text");
551        assert_eq!(file_content.content_type.unwrap(), "text/plain");
552        assert_eq!(file_content.content_disposition, None);
553
554        // Case-insensitive headers.
555        let body = b"\r\n--abcdef\r\nCONTENT-type: application/json\r\n\r\n{}\r\n--abcdef\r\nCONTENT-TYPE: text/plain\r\ncoNtenT-disPosItioN: attachment; filename=my_file.txt\r\n\r\nsome plain text\r\n--abcdef--";
556        let response = http::Response::builder()
557            .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
558            .body(body.as_slice())
559            .unwrap();
560
561        let ResponseBody { content, .. } = ResponseBody::try_from_http_response(response).unwrap();
562
563        assert_matches!(content, FileOrLocation::File(file_content));
564        assert_eq!(file_content.file, b"some plain text");
565        assert_eq!(file_content.content_type.unwrap(), "text/plain");
566        let content_disposition = file_content.content_disposition.unwrap();
567        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
568        assert_eq!(content_disposition.filename.unwrap(), "my_file.txt");
569
570        // Extra whitespace.
571        let body = b"   \r\n--abcdef\r\ncontent-type:   application/json   \r\n\r\n {} \r\n--abcdef\r\ncontent-type: text/plain  \r\n\r\nsome plain text\r\n--abcdef--  ";
572        let response = http::Response::builder()
573            .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
574            .body(body.as_slice())
575            .unwrap();
576
577        let ResponseBody { content, .. } = ResponseBody::try_from_http_response(response).unwrap();
578
579        assert_matches!(content, FileOrLocation::File(file_content));
580        assert_eq!(file_content.file, b"some plain text");
581        assert_eq!(file_content.content_type.unwrap(), "text/plain");
582        assert_eq!(file_content.content_disposition, None);
583
584        // Missing CR except in boundaries.
585        let body = b"\r\n--abcdef\ncontent-type: application/json\n\n{}\r\n--abcdef\ncontent-type: text/plain  \n\nsome plain text\r\n--abcdef--";
586        let response = http::Response::builder()
587            .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
588            .body(body.as_slice())
589            .unwrap();
590
591        let ResponseBody { content, .. } = ResponseBody::try_from_http_response(response).unwrap();
592
593        assert_matches!(content, FileOrLocation::File(file_content));
594        assert_eq!(file_content.file, b"some plain text");
595        assert_eq!(file_content.content_type.unwrap(), "text/plain");
596        assert_eq!(file_content.content_disposition, None);
597
598        // No leading CRLF (and no preamble)
599        let body = b"--abcdef\r\n\r\n{}\r\n--abcdef\r\n\r\nsome plain text\r\n--abcdef--";
600        let response = http::Response::builder()
601            .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
602            .body(body.as_slice())
603            .unwrap();
604
605        let ResponseBody { content, .. } = ResponseBody::try_from_http_response(response).unwrap();
606
607        assert_matches!(content, FileOrLocation::File(file_content));
608        assert_eq!(file_content.file, b"some plain text");
609        assert_eq!(file_content.content_type, None);
610        assert_eq!(file_content.content_disposition, None);
611
612        // Boundary text in preamble, but no leading CRLF, so it should be
613        // ignored.
614        let body =
615            b"foo--abcdef\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\n\r\nsome plain text\r\n--abcdef--";
616        let response = http::Response::builder()
617            .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
618            .body(body.as_slice())
619            .unwrap();
620
621        let ResponseBody { content, .. } = ResponseBody::try_from_http_response(response).unwrap();
622
623        assert_matches!(content, FileOrLocation::File(file_content));
624        assert_eq!(file_content.file, b"some plain text");
625        assert_eq!(file_content.content_type, None);
626        assert_eq!(file_content.content_disposition, None);
627
628        // No body part headers.
629        let body = b"\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\n\r\nsome plain text\r\n--abcdef--";
630        let response = http::Response::builder()
631            .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
632            .body(body.as_slice())
633            .unwrap();
634
635        let ResponseBody { content, .. } = ResponseBody::try_from_http_response(response).unwrap();
636
637        assert_matches!(content, FileOrLocation::File(file_content));
638        assert_eq!(file_content.file, b"some plain text");
639        assert_eq!(file_content.content_type, None);
640        assert_eq!(file_content.content_disposition, None);
641
642        // Raw UTF-8 filename (some kind of compatibility with multipart/form-data).
643        let body = "\r\n--abcdef\r\ncontent-type: application/json\r\n\r\n{}\r\n--abcdef\r\ncontent-type: text/plain\r\ncontent-disposition: inline; filename=\"ȵ⌾Ⱦԩ💈Ňɠ\"\r\n\r\nsome plain text\r\n--abcdef--";
644        let response = http::Response::builder()
645            .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
646            .body(body.as_bytes())
647            .unwrap();
648
649        let ResponseBody { content, .. } = ResponseBody::try_from_http_response(response).unwrap();
650
651        assert_matches!(content, FileOrLocation::File(file_content));
652        assert_eq!(file_content.file, b"some plain text");
653        assert_eq!(file_content.content_type.unwrap(), "text/plain");
654        let content_disposition = file_content.content_disposition.unwrap();
655        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
656        assert_eq!(content_disposition.filename.unwrap(), "ȵ⌾Ⱦԩ💈Ňɠ");
657    }
658}