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