client-api: Add a type for the Content-Disposition HTTP header
This commit is contained in:
		
							parent
							
								
									f73ba5556c
								
							
						
					
					
						commit
						463f89b0eb
					
				@ -7,6 +7,9 @@ Breaking changes:
 | 
			
		||||
- Change type of `client_secret` field in `ThirdpartyIdCredentials`
 | 
			
		||||
  from `Box<ClientSecret>` to `OwnedClientSecret`
 | 
			
		||||
- Make `id_server` and `id_access_token` in `ThirdpartyIdCredentials` optional
 | 
			
		||||
- The `content_disposition` fields of `media::get_content::v3::Response` and
 | 
			
		||||
  `media::get_content_as_filename::v3::Response` use now the strongly typed
 | 
			
		||||
  `ContentDisposition` instead of strings.
 | 
			
		||||
 | 
			
		||||
Improvements:
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -12,6 +12,7 @@ pub mod v1 {
 | 
			
		||||
    use http::header::{CONTENT_DISPOSITION, CONTENT_TYPE};
 | 
			
		||||
    use ruma_common::{
 | 
			
		||||
        api::{request, response, Metadata},
 | 
			
		||||
        http_headers::ContentDisposition,
 | 
			
		||||
        metadata, IdParseError, MxcUri, OwnedServerName,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
@ -62,12 +63,8 @@ pub mod v1 {
 | 
			
		||||
 | 
			
		||||
        /// The value of the `Content-Disposition` HTTP header, possibly containing the name of the
 | 
			
		||||
        /// file that was previously uploaded.
 | 
			
		||||
        ///
 | 
			
		||||
        /// See [MDN] for the syntax.
 | 
			
		||||
        ///
 | 
			
		||||
        /// [MDN]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition#Syntax
 | 
			
		||||
        #[ruma_api(header = CONTENT_DISPOSITION)]
 | 
			
		||||
        pub content_disposition: Option<String>,
 | 
			
		||||
        pub content_disposition: Option<ContentDisposition>,
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    impl Request {
 | 
			
		||||
 | 
			
		||||
@ -12,6 +12,7 @@ pub mod v1 {
 | 
			
		||||
    use http::header::{CONTENT_DISPOSITION, CONTENT_TYPE};
 | 
			
		||||
    use ruma_common::{
 | 
			
		||||
        api::{request, response, Metadata},
 | 
			
		||||
        http_headers::ContentDisposition,
 | 
			
		||||
        metadata, IdParseError, MxcUri, OwnedServerName,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
@ -66,12 +67,8 @@ pub mod v1 {
 | 
			
		||||
 | 
			
		||||
        /// The value of the `Content-Disposition` HTTP header, possibly containing the name of the
 | 
			
		||||
        /// file that was previously uploaded.
 | 
			
		||||
        ///
 | 
			
		||||
        /// See [MDN] for the syntax.
 | 
			
		||||
        ///
 | 
			
		||||
        /// [MDN]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition#Syntax
 | 
			
		||||
        #[ruma_api(header = CONTENT_DISPOSITION)]
 | 
			
		||||
        pub content_disposition: Option<String>,
 | 
			
		||||
        pub content_disposition: Option<ContentDisposition>,
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    impl Request {
 | 
			
		||||
 | 
			
		||||
@ -3,6 +3,10 @@
 | 
			
		||||
 | 
			
		||||
use http::{header::HeaderName, HeaderValue};
 | 
			
		||||
use ruma_common::api::error::{HeaderDeserializationError, HeaderSerializationError};
 | 
			
		||||
pub use ruma_common::http_headers::{
 | 
			
		||||
    ContentDisposition, ContentDispositionParseError, ContentDispositionType, TokenString,
 | 
			
		||||
    TokenStringParseError,
 | 
			
		||||
};
 | 
			
		||||
use web_time::{Duration, SystemTime, UNIX_EPOCH};
 | 
			
		||||
 | 
			
		||||
/// The [`Cross-Origin-Resource-Policy`] HTTP response header.
 | 
			
		||||
 | 
			
		||||
@ -12,6 +12,7 @@ pub mod v3 {
 | 
			
		||||
    use http::header::{CONTENT_DISPOSITION, CONTENT_TYPE};
 | 
			
		||||
    use ruma_common::{
 | 
			
		||||
        api::{request, response, Metadata},
 | 
			
		||||
        http_headers::ContentDisposition,
 | 
			
		||||
        metadata, IdParseError, MxcUri, OwnedServerName,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
@ -87,12 +88,8 @@ pub mod v3 {
 | 
			
		||||
 | 
			
		||||
        /// The value of the `Content-Disposition` HTTP header, possibly containing the name of the
 | 
			
		||||
        /// file that was previously uploaded.
 | 
			
		||||
        ///
 | 
			
		||||
        /// See [MDN] for the syntax.
 | 
			
		||||
        ///
 | 
			
		||||
        /// [MDN]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition#Syntax
 | 
			
		||||
        #[ruma_api(header = CONTENT_DISPOSITION)]
 | 
			
		||||
        pub content_disposition: Option<String>,
 | 
			
		||||
        pub content_disposition: Option<ContentDisposition>,
 | 
			
		||||
 | 
			
		||||
        /// The value of the `Cross-Origin-Resource-Policy` HTTP header.
 | 
			
		||||
        ///
 | 
			
		||||
 | 
			
		||||
@ -12,6 +12,7 @@ pub mod v3 {
 | 
			
		||||
    use http::header::{CONTENT_DISPOSITION, CONTENT_TYPE};
 | 
			
		||||
    use ruma_common::{
 | 
			
		||||
        api::{request, response, Metadata},
 | 
			
		||||
        http_headers::ContentDisposition,
 | 
			
		||||
        metadata, IdParseError, MxcUri, OwnedServerName,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
@ -91,12 +92,8 @@ pub mod v3 {
 | 
			
		||||
 | 
			
		||||
        /// The value of the `Content-Disposition` HTTP header, possibly containing the name of the
 | 
			
		||||
        /// file that was previously uploaded.
 | 
			
		||||
        ///
 | 
			
		||||
        /// See [MDN] for the syntax.
 | 
			
		||||
        ///
 | 
			
		||||
        /// [MDN]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition#Syntax
 | 
			
		||||
        #[ruma_api(header = CONTENT_DISPOSITION)]
 | 
			
		||||
        pub content_disposition: Option<String>,
 | 
			
		||||
        pub content_disposition: Option<ContentDisposition>,
 | 
			
		||||
 | 
			
		||||
        /// The value of the `Cross-Origin-Resource-Policy` HTTP header.
 | 
			
		||||
        ///
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										104
									
								
								crates/ruma-common/src/http_headers.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								crates/ruma-common/src/http_headers.rs
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,104 @@
 | 
			
		||||
//! Helpers for HTTP headers.
 | 
			
		||||
 | 
			
		||||
use std::borrow::Cow;
 | 
			
		||||
 | 
			
		||||
mod content_disposition;
 | 
			
		||||
mod rfc8187;
 | 
			
		||||
 | 
			
		||||
pub use self::content_disposition::{
 | 
			
		||||
    ContentDisposition, ContentDispositionParseError, ContentDispositionType, TokenString,
 | 
			
		||||
    TokenStringParseError,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/// Whether the given byte is a [`token` char].
 | 
			
		||||
///
 | 
			
		||||
/// [`token` char]: https://datatracker.ietf.org/doc/html/rfc9110#section-5.6.2
 | 
			
		||||
pub const fn is_tchar(b: u8) -> bool {
 | 
			
		||||
    b.is_ascii_alphanumeric()
 | 
			
		||||
        || matches!(
 | 
			
		||||
            b,
 | 
			
		||||
            b'!' | b'#'
 | 
			
		||||
                | b'$'
 | 
			
		||||
                | b'%'
 | 
			
		||||
                | b'&'
 | 
			
		||||
                | b'\''
 | 
			
		||||
                | b'*'
 | 
			
		||||
                | b'+'
 | 
			
		||||
                | b'-'
 | 
			
		||||
                | b'.'
 | 
			
		||||
                | b'^'
 | 
			
		||||
                | b'_'
 | 
			
		||||
                | b'`'
 | 
			
		||||
                | b'|'
 | 
			
		||||
                | b'~'
 | 
			
		||||
        )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Whether the given bytes slice is a [`token`].
 | 
			
		||||
///
 | 
			
		||||
/// [`token`]: https://datatracker.ietf.org/doc/html/rfc9110#section-5.6.2
 | 
			
		||||
pub fn is_token(bytes: &[u8]) -> bool {
 | 
			
		||||
    bytes.iter().all(|b| is_tchar(*b))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Whether the given string is a [`token`].
 | 
			
		||||
///
 | 
			
		||||
/// [`token`]: https://datatracker.ietf.org/doc/html/rfc9110#section-5.6.2
 | 
			
		||||
pub fn is_token_string(s: &str) -> bool {
 | 
			
		||||
    is_token(s.as_bytes())
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Whether the given char is a [visible US-ASCII char].
 | 
			
		||||
///
 | 
			
		||||
/// [visible US-ASCII char]: https://datatracker.ietf.org/doc/html/rfc5234#appendix-B.1
 | 
			
		||||
pub const fn is_vchar(c: char) -> bool {
 | 
			
		||||
    matches!(c, '\x21'..='\x7E')
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Whether the given char is in the US-ASCII character set and allowed inside a [quoted string].
 | 
			
		||||
///
 | 
			
		||||
/// Contrary to the definition of quoted strings, this doesn't allow `obs-text` characters, i.e.
 | 
			
		||||
/// non-US-ASCII characters, as we usually deal with UTF-8 strings rather than ISO-8859-1 strings.
 | 
			
		||||
///
 | 
			
		||||
/// [quoted string]: https://datatracker.ietf.org/doc/html/rfc9110#section-5.6.4
 | 
			
		||||
pub const fn is_ascii_string_quotable(c: char) -> bool {
 | 
			
		||||
    is_vchar(c) || matches!(c, '\x09' | '\x20')
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Remove characters that do not pass [`is_ascii_string_quotable()`] from the given string.
 | 
			
		||||
///
 | 
			
		||||
/// [quoted string]: https://datatracker.ietf.org/doc/html/rfc9110#section-5.6.4
 | 
			
		||||
pub fn sanitize_for_ascii_quoted_string(value: &str) -> Cow<'_, str> {
 | 
			
		||||
    if value.chars().all(is_ascii_string_quotable) {
 | 
			
		||||
        return Cow::Borrowed(value);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Cow::Owned(value.chars().filter(|c| is_ascii_string_quotable(*c)).collect())
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// If the US-ASCII field value does not contain only token chars, convert it to a [quoted string].
 | 
			
		||||
///
 | 
			
		||||
/// The string should be sanitized with [`sanitize_for_ascii_quoted_string()`] or should only
 | 
			
		||||
/// contain characters that pass [`is_ascii_string_quotable()`].
 | 
			
		||||
///
 | 
			
		||||
/// [quoted string]: https://datatracker.ietf.org/doc/html/rfc9110#section-5.6.4
 | 
			
		||||
pub fn quote_ascii_string_if_required(value: &str) -> Cow<'_, str> {
 | 
			
		||||
    if !value.is_empty() && is_token_string(value) {
 | 
			
		||||
        return Cow::Borrowed(value);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let value = value.replace('\\', r#"\\"#).replace('"', r#"\""#);
 | 
			
		||||
    Cow::Owned(format!("\"{value}\""))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Removes the escape backslashes in the given string.
 | 
			
		||||
pub fn unescape_string(s: &str) -> String {
 | 
			
		||||
    let mut is_escaped = false;
 | 
			
		||||
 | 
			
		||||
    s.chars()
 | 
			
		||||
        .filter(|c| {
 | 
			
		||||
            is_escaped = *c == '\\' && !is_escaped;
 | 
			
		||||
            !is_escaped
 | 
			
		||||
        })
 | 
			
		||||
        .collect()
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										736
									
								
								crates/ruma-common/src/http_headers/content_disposition.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										736
									
								
								crates/ruma-common/src/http_headers/content_disposition.rs
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,736 @@
 | 
			
		||||
//! Types to (de)serialize the `Content-Disposition` HTTP header.
 | 
			
		||||
 | 
			
		||||
use std::{fmt, ops::Deref, str::FromStr};
 | 
			
		||||
 | 
			
		||||
use ruma_macros::{
 | 
			
		||||
    AsRefStr, AsStrAsRefStr, DebugAsRefStr, DisplayAsRefStr, OrdAsRefStr, PartialOrdAsRefStr,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
use super::{
 | 
			
		||||
    is_tchar, is_token, quote_ascii_string_if_required, rfc8187, sanitize_for_ascii_quoted_string,
 | 
			
		||||
    unescape_string,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/// The value of a `Content-Disposition` HTTP header.
 | 
			
		||||
///
 | 
			
		||||
/// This implementation supports the `Content-Disposition` header format as defined for HTTP in [RFC
 | 
			
		||||
/// 6266].
 | 
			
		||||
///
 | 
			
		||||
/// The only supported parameter is `filename`. It is encoded or decoded as needed, using a quoted
 | 
			
		||||
/// string or the `ext-token = ext-value` format, with the encoding defined in [RFC 8187].
 | 
			
		||||
///
 | 
			
		||||
/// This implementation does not support serializing to the format defined for the
 | 
			
		||||
/// `multipart/form-data` content type in [RFC 7578]. It should however manage to parse the
 | 
			
		||||
/// disposition type and filename parameter of the body parts.
 | 
			
		||||
///
 | 
			
		||||
/// [RFC 6266]: https://datatracker.ietf.org/doc/html/rfc6266
 | 
			
		||||
/// [RFC 8187]: https://datatracker.ietf.org/doc/html/rfc8187
 | 
			
		||||
/// [RFC 7578]: https://datatracker.ietf.org/doc/html/rfc7578
 | 
			
		||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
 | 
			
		||||
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
 | 
			
		||||
pub struct ContentDisposition {
 | 
			
		||||
    /// The disposition type.
 | 
			
		||||
    pub disposition_type: ContentDispositionType,
 | 
			
		||||
 | 
			
		||||
    /// The filename of the content.
 | 
			
		||||
    pub filename: Option<String>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl ContentDisposition {
 | 
			
		||||
    /// Creates a new `ContentDisposition` with the given disposition type.
 | 
			
		||||
    pub fn new(disposition_type: ContentDispositionType) -> Self {
 | 
			
		||||
        Self { disposition_type, filename: None }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Add the given filename to this `ContentDisposition`.
 | 
			
		||||
    pub fn with_filename(mut self, filename: Option<String>) -> Self {
 | 
			
		||||
        self.filename = filename;
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl fmt::Display for ContentDisposition {
 | 
			
		||||
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
 | 
			
		||||
        write!(f, "{}", self.disposition_type)?;
 | 
			
		||||
 | 
			
		||||
        if let Some(filename) = &self.filename {
 | 
			
		||||
            if filename.is_ascii() {
 | 
			
		||||
                // First, remove all non-quotable characters, that is control characters.
 | 
			
		||||
                let filename = sanitize_for_ascii_quoted_string(filename);
 | 
			
		||||
 | 
			
		||||
                // We can use the filename parameter.
 | 
			
		||||
                write!(f, "; filename={}", quote_ascii_string_if_required(&filename))?;
 | 
			
		||||
            } else {
 | 
			
		||||
                // We need to use RFC 8187 encoding.
 | 
			
		||||
                write!(f, "; filename*={}", rfc8187::encode(filename))?;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        Ok(())
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl TryFrom<&[u8]> for ContentDisposition {
 | 
			
		||||
    type Error = ContentDispositionParseError;
 | 
			
		||||
 | 
			
		||||
    fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
 | 
			
		||||
        let mut pos = 0;
 | 
			
		||||
 | 
			
		||||
        skip_ascii_whitespaces(value, &mut pos);
 | 
			
		||||
 | 
			
		||||
        if pos == value.len() {
 | 
			
		||||
            return Err(ContentDispositionParseError::MissingDispositionType);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let disposition_type_start = pos;
 | 
			
		||||
 | 
			
		||||
        // Find the next whitespace or `;`.
 | 
			
		||||
        while let Some(byte) = value.get(pos) {
 | 
			
		||||
            if byte.is_ascii_whitespace() || *byte == b';' {
 | 
			
		||||
                break;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            pos += 1;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let disposition_type =
 | 
			
		||||
            ContentDispositionType::try_from(&value[disposition_type_start..pos])?;
 | 
			
		||||
 | 
			
		||||
        // The `filename*` parameter (`filename_ext` here) using UTF-8 encoding should be used, but
 | 
			
		||||
        // it is likely to be after the `filename` parameter containing only ASCII
 | 
			
		||||
        // characters if both are present.
 | 
			
		||||
        let mut filename_ext = None;
 | 
			
		||||
        let mut filename = None;
 | 
			
		||||
 | 
			
		||||
        // Parse the parameters. We ignore parameters that fail to parse for maximum compatibility.
 | 
			
		||||
        while pos != value.len() {
 | 
			
		||||
            if let Some(param) = RawParam::parse_next(value, &mut pos) {
 | 
			
		||||
                if param.name.eq_ignore_ascii_case(b"filename*") {
 | 
			
		||||
                    if let Some(value) = param.decode_value() {
 | 
			
		||||
                        filename_ext = Some(value);
 | 
			
		||||
                        // We can stop parsing, this is the only parameter that we need.
 | 
			
		||||
                        break;
 | 
			
		||||
                    }
 | 
			
		||||
                } else if param.name.eq_ignore_ascii_case(b"filename") {
 | 
			
		||||
                    if let Some(value) = param.decode_value() {
 | 
			
		||||
                        filename = Some(value);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        Ok(Self { disposition_type, filename: filename_ext.or(filename) })
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl FromStr for ContentDisposition {
 | 
			
		||||
    type Err = ContentDispositionParseError;
 | 
			
		||||
 | 
			
		||||
    fn from_str(s: &str) -> Result<Self, Self::Err> {
 | 
			
		||||
        s.as_bytes().try_into()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// A raw parameter in a `Content-Disposition` HTTP header.
 | 
			
		||||
struct RawParam<'a> {
 | 
			
		||||
    name: &'a [u8],
 | 
			
		||||
    value: &'a [u8],
 | 
			
		||||
    is_quoted_string: bool,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl<'a> RawParam<'a> {
 | 
			
		||||
    /// Parse the next `RawParam` in the given bytes, starting at the given position.
 | 
			
		||||
    ///
 | 
			
		||||
    /// The position is updated during the parsing.
 | 
			
		||||
    ///
 | 
			
		||||
    /// Returns `None` if no parameter was found or if an error occurred when parsing the
 | 
			
		||||
    /// parameter.
 | 
			
		||||
    fn parse_next(bytes: &'a [u8], pos: &mut usize) -> Option<Self> {
 | 
			
		||||
        let name = parse_param_name(bytes, pos)?;
 | 
			
		||||
 | 
			
		||||
        skip_ascii_whitespaces(bytes, pos);
 | 
			
		||||
 | 
			
		||||
        if *pos == bytes.len() {
 | 
			
		||||
            // We are at the end of the bytes and only have the parameter name.
 | 
			
		||||
            return None;
 | 
			
		||||
        }
 | 
			
		||||
        if bytes[*pos] != b'=' {
 | 
			
		||||
            // We should have an equal sign, there is a problem with the bytes and we can't recover
 | 
			
		||||
            // from it.
 | 
			
		||||
            // Skip to the end to stop the parsing.
 | 
			
		||||
            *pos = bytes.len();
 | 
			
		||||
            return None;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Skip the equal sign.
 | 
			
		||||
        *pos += 1;
 | 
			
		||||
 | 
			
		||||
        skip_ascii_whitespaces(bytes, pos);
 | 
			
		||||
 | 
			
		||||
        let (value, is_quoted_string) = parse_param_value(bytes, pos)?;
 | 
			
		||||
 | 
			
		||||
        Some(Self { name, value, is_quoted_string })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Decode the value of this `RawParam`.
 | 
			
		||||
    ///
 | 
			
		||||
    /// Returns `None` if decoding the param failed.
 | 
			
		||||
    fn decode_value(&self) -> Option<String> {
 | 
			
		||||
        if self.name.ends_with(b"*") {
 | 
			
		||||
            rfc8187::decode(self.value).ok().map(|s| s.into_owned())
 | 
			
		||||
        } else {
 | 
			
		||||
            let s = String::from_utf8_lossy(self.value);
 | 
			
		||||
 | 
			
		||||
            if self.is_quoted_string {
 | 
			
		||||
                Some(unescape_string(&s))
 | 
			
		||||
            } else {
 | 
			
		||||
                Some(s.into_owned())
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Skip ASCII whitespaces in the given bytes, starting at the given position.
 | 
			
		||||
///
 | 
			
		||||
/// The position is updated to after the whitespaces.
 | 
			
		||||
fn skip_ascii_whitespaces(bytes: &[u8], pos: &mut usize) {
 | 
			
		||||
    while let Some(byte) = bytes.get(*pos) {
 | 
			
		||||
        if !byte.is_ascii_whitespace() {
 | 
			
		||||
            break;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        *pos += 1;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Parse a parameter name in the given bytes, starting at the given position.
 | 
			
		||||
///
 | 
			
		||||
/// The position is updated while parsing.
 | 
			
		||||
///
 | 
			
		||||
/// Returns `None` if the end of the bytes was reached, or if an error was encountered.
 | 
			
		||||
fn parse_param_name<'a>(bytes: &'a [u8], pos: &mut usize) -> Option<&'a [u8]> {
 | 
			
		||||
    skip_ascii_whitespaces(bytes, pos);
 | 
			
		||||
 | 
			
		||||
    if *pos == bytes.len() {
 | 
			
		||||
        // We are at the end of the bytes and didn't find anything.
 | 
			
		||||
        return None;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let name_start = *pos;
 | 
			
		||||
 | 
			
		||||
    // Find the end of the parameter name. The name can only contain token chars.
 | 
			
		||||
    while let Some(byte) = bytes.get(*pos) {
 | 
			
		||||
        if !is_tchar(*byte) {
 | 
			
		||||
            break;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        *pos += 1;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if *pos == bytes.len() {
 | 
			
		||||
        // We are at the end of the bytes and only have the parameter name.
 | 
			
		||||
        return None;
 | 
			
		||||
    }
 | 
			
		||||
    if bytes[*pos] == b';' {
 | 
			
		||||
        // We are at the end of the parameter and only have the parameter name, skip the `;` and
 | 
			
		||||
        // parse the next parameter.
 | 
			
		||||
        *pos += 1;
 | 
			
		||||
        return None;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let name = &bytes[name_start..*pos];
 | 
			
		||||
 | 
			
		||||
    if name.is_empty() {
 | 
			
		||||
        // It's probably a syntax error, we cannot recover from it.
 | 
			
		||||
        *pos = bytes.len();
 | 
			
		||||
        return None;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Some(name)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Parse a parameter value in the given bytes, starting at the given position.
 | 
			
		||||
///
 | 
			
		||||
/// The position is updated while parsing.
 | 
			
		||||
///
 | 
			
		||||
/// Returns a `(value, is_quoted_string)` tuple if parsing succeeded.
 | 
			
		||||
/// Returns `None` if the end of the bytes was reached, or if an error was encountered.
 | 
			
		||||
fn parse_param_value<'a>(bytes: &'a [u8], pos: &mut usize) -> Option<(&'a [u8], bool)> {
 | 
			
		||||
    skip_ascii_whitespaces(bytes, pos);
 | 
			
		||||
 | 
			
		||||
    if *pos == bytes.len() {
 | 
			
		||||
        // We are at the end of the bytes and didn't find anything.
 | 
			
		||||
        return None;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let is_quoted_string = bytes[*pos] == b'"';
 | 
			
		||||
    if is_quoted_string {
 | 
			
		||||
        // Skip the start double quote.
 | 
			
		||||
        *pos += 1;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let value_start = *pos;
 | 
			
		||||
 | 
			
		||||
    // Keep track of whether the next byte is escaped with a backslash.
 | 
			
		||||
    let mut escape_next = false;
 | 
			
		||||
 | 
			
		||||
    // Find the end of the value, it's a whitespace or a semi-colon, or a double quote if the string
 | 
			
		||||
    // is quoted.
 | 
			
		||||
    while let Some(byte) = bytes.get(*pos) {
 | 
			
		||||
        if !is_quoted_string && (byte.is_ascii_whitespace() || *byte == b';') {
 | 
			
		||||
            break;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if is_quoted_string && *byte == b'"' && !escape_next {
 | 
			
		||||
            break;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        escape_next = *byte == b'\\' && !escape_next;
 | 
			
		||||
 | 
			
		||||
        *pos += 1;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let value = &bytes[value_start..*pos];
 | 
			
		||||
 | 
			
		||||
    if is_quoted_string && *pos != bytes.len() {
 | 
			
		||||
        // Skip the end double quote.
 | 
			
		||||
        *pos += 1;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    skip_ascii_whitespaces(bytes, pos);
 | 
			
		||||
 | 
			
		||||
    // Check for parameters separator if we are not at the end of the string.
 | 
			
		||||
    if *pos != bytes.len() {
 | 
			
		||||
        if bytes[*pos] == b';' {
 | 
			
		||||
            // Skip the `;` at the end of the parameter.
 | 
			
		||||
            *pos += 1;
 | 
			
		||||
        } else {
 | 
			
		||||
            // We should have a `;`, there is a problem with the bytes and we can't recover
 | 
			
		||||
            // from it.
 | 
			
		||||
            // Skip to the end to stop the parsing.
 | 
			
		||||
            *pos = bytes.len();
 | 
			
		||||
            return None;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Some((value, is_quoted_string))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// An error encountered when trying to parse an invalid [`ContentDisposition`].
 | 
			
		||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
 | 
			
		||||
#[non_exhaustive]
 | 
			
		||||
pub enum ContentDispositionParseError {
 | 
			
		||||
    /// The disposition type is missing.
 | 
			
		||||
    #[error("disposition type is missing")]
 | 
			
		||||
    MissingDispositionType,
 | 
			
		||||
 | 
			
		||||
    /// The disposition type is invalid.
 | 
			
		||||
    #[error("invalid disposition type: {0}")]
 | 
			
		||||
    InvalidDispositionType(#[from] TokenStringParseError),
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// A disposition type in the `Content-Disposition` HTTP header as defined in [Section 4.2 of RFC
 | 
			
		||||
/// 6266].
 | 
			
		||||
///
 | 
			
		||||
/// This type can hold an arbitrary [`TokenString`]. To build this with a custom value, convert it
 | 
			
		||||
/// from a `TokenString` with `::from()` / `.into()`. To check for values that are not available as
 | 
			
		||||
/// a documented variant here, use its string representation, obtained through
 | 
			
		||||
/// [`.as_str()`](Self::as_str()).
 | 
			
		||||
///
 | 
			
		||||
/// Comparisons with other string types are done case-insensitively.
 | 
			
		||||
///
 | 
			
		||||
/// [Section 4.2 of RFC 6266]: https://datatracker.ietf.org/doc/html/rfc6266#section-4.2
 | 
			
		||||
#[derive(
 | 
			
		||||
    Clone,
 | 
			
		||||
    Default,
 | 
			
		||||
    AsRefStr,
 | 
			
		||||
    DebugAsRefStr,
 | 
			
		||||
    AsStrAsRefStr,
 | 
			
		||||
    DisplayAsRefStr,
 | 
			
		||||
    PartialOrdAsRefStr,
 | 
			
		||||
    OrdAsRefStr,
 | 
			
		||||
)]
 | 
			
		||||
#[ruma_enum(rename_all = "lowercase")]
 | 
			
		||||
#[non_exhaustive]
 | 
			
		||||
pub enum ContentDispositionType {
 | 
			
		||||
    /// The content can be displayed.
 | 
			
		||||
    ///
 | 
			
		||||
    /// This is the default.
 | 
			
		||||
    #[default]
 | 
			
		||||
    Inline,
 | 
			
		||||
 | 
			
		||||
    /// The content should be downloaded instead of displayed.
 | 
			
		||||
    Attachment,
 | 
			
		||||
 | 
			
		||||
    #[doc(hidden)]
 | 
			
		||||
    _Custom(TokenString),
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl ContentDispositionType {
 | 
			
		||||
    /// Try parsing a `&str` into a `ContentDispositionType`.
 | 
			
		||||
    pub fn parse(s: &str) -> Result<Self, TokenStringParseError> {
 | 
			
		||||
        Self::from_str(s)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl From<TokenString> for ContentDispositionType {
 | 
			
		||||
    fn from(value: TokenString) -> Self {
 | 
			
		||||
        if value.eq_ignore_ascii_case("inline") {
 | 
			
		||||
            Self::Inline
 | 
			
		||||
        } else if value.eq_ignore_ascii_case("attachment") {
 | 
			
		||||
            Self::Attachment
 | 
			
		||||
        } else {
 | 
			
		||||
            Self::_Custom(value)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl<'a> TryFrom<&'a [u8]> for ContentDispositionType {
 | 
			
		||||
    type Error = TokenStringParseError;
 | 
			
		||||
 | 
			
		||||
    fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
 | 
			
		||||
        if value.eq_ignore_ascii_case(b"inline") {
 | 
			
		||||
            Ok(Self::Inline)
 | 
			
		||||
        } else if value.eq_ignore_ascii_case(b"attachment") {
 | 
			
		||||
            Ok(Self::Attachment)
 | 
			
		||||
        } else {
 | 
			
		||||
            TokenString::try_from(value).map(Self::_Custom)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl FromStr for ContentDispositionType {
 | 
			
		||||
    type Err = TokenStringParseError;
 | 
			
		||||
 | 
			
		||||
    fn from_str(s: &str) -> Result<Self, Self::Err> {
 | 
			
		||||
        s.as_bytes().try_into()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl PartialEq<ContentDispositionType> for ContentDispositionType {
 | 
			
		||||
    fn eq(&self, other: &ContentDispositionType) -> bool {
 | 
			
		||||
        self.as_str().eq_ignore_ascii_case(other.as_str())
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl Eq for ContentDispositionType {}
 | 
			
		||||
 | 
			
		||||
impl PartialEq<TokenString> for ContentDispositionType {
 | 
			
		||||
    fn eq(&self, other: &TokenString) -> bool {
 | 
			
		||||
        self.as_str().eq_ignore_ascii_case(other.as_str())
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl<'a> PartialEq<&'a str> for ContentDispositionType {
 | 
			
		||||
    fn eq(&self, other: &&'a str) -> bool {
 | 
			
		||||
        self.as_str().eq_ignore_ascii_case(other)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// A non-empty string consisting only of `token`s as defined in [RFC 9110 Section 3.2.6].
 | 
			
		||||
///
 | 
			
		||||
/// This is a string that can only contain a limited character set.
 | 
			
		||||
///
 | 
			
		||||
/// [RFC 7230 Section 3.2.6]: https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6
 | 
			
		||||
#[derive(
 | 
			
		||||
    Clone,
 | 
			
		||||
    PartialEq,
 | 
			
		||||
    Eq,
 | 
			
		||||
    DebugAsRefStr,
 | 
			
		||||
    AsStrAsRefStr,
 | 
			
		||||
    DisplayAsRefStr,
 | 
			
		||||
    PartialOrdAsRefStr,
 | 
			
		||||
    OrdAsRefStr,
 | 
			
		||||
)]
 | 
			
		||||
pub struct TokenString(Box<str>);
 | 
			
		||||
 | 
			
		||||
impl TokenString {
 | 
			
		||||
    /// Try parsing a `&str` into a `TokenString`.
 | 
			
		||||
    pub fn parse(s: &str) -> Result<Self, TokenStringParseError> {
 | 
			
		||||
        Self::from_str(s)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl Deref for TokenString {
 | 
			
		||||
    type Target = str;
 | 
			
		||||
 | 
			
		||||
    fn deref(&self) -> &Self::Target {
 | 
			
		||||
        self.as_ref()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl AsRef<str> for TokenString {
 | 
			
		||||
    fn as_ref(&self) -> &str {
 | 
			
		||||
        &self.0
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl<'a> PartialEq<&'a str> for TokenString {
 | 
			
		||||
    fn eq(&self, other: &&'a str) -> bool {
 | 
			
		||||
        self.as_str().eq(*other)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl<'a> TryFrom<&'a [u8]> for TokenString {
 | 
			
		||||
    type Error = TokenStringParseError;
 | 
			
		||||
 | 
			
		||||
    fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
 | 
			
		||||
        if value.is_empty() {
 | 
			
		||||
            Err(TokenStringParseError::Empty)
 | 
			
		||||
        } else if is_token(value) {
 | 
			
		||||
            let s = std::str::from_utf8(value).expect("ASCII bytes are valid UTF-8");
 | 
			
		||||
            Ok(Self(s.into()))
 | 
			
		||||
        } else {
 | 
			
		||||
            Err(TokenStringParseError::InvalidCharacter)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl FromStr for TokenString {
 | 
			
		||||
    type Err = TokenStringParseError;
 | 
			
		||||
 | 
			
		||||
    fn from_str(s: &str) -> Result<Self, Self::Err> {
 | 
			
		||||
        s.as_bytes().try_into()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// The parsed string contains a character not allowed for a [`TokenString`].
 | 
			
		||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
 | 
			
		||||
#[non_exhaustive]
 | 
			
		||||
pub enum TokenStringParseError {
 | 
			
		||||
    /// The string is empty.
 | 
			
		||||
    #[error("string is empty")]
 | 
			
		||||
    Empty,
 | 
			
		||||
 | 
			
		||||
    /// The string contains an invalid character for a token string.
 | 
			
		||||
    #[error("string contains invalid character")]
 | 
			
		||||
    InvalidCharacter,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[cfg(test)]
 | 
			
		||||
mod tests {
 | 
			
		||||
    use std::str::FromStr;
 | 
			
		||||
 | 
			
		||||
    use super::{ContentDisposition, ContentDispositionType};
 | 
			
		||||
 | 
			
		||||
    #[test]
 | 
			
		||||
    fn parse_content_disposition_valid() {
 | 
			
		||||
        // Only disposition type.
 | 
			
		||||
        let content_disposition = ContentDisposition::from_str("inline").unwrap();
 | 
			
		||||
        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
 | 
			
		||||
        assert_eq!(content_disposition.filename, None);
 | 
			
		||||
 | 
			
		||||
        // Only disposition type with separator.
 | 
			
		||||
        let content_disposition = ContentDisposition::from_str("attachment;").unwrap();
 | 
			
		||||
        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
 | 
			
		||||
        assert_eq!(content_disposition.filename, None);
 | 
			
		||||
 | 
			
		||||
        // Unknown disposition type and parameters.
 | 
			
		||||
        let content_disposition =
 | 
			
		||||
            ContentDisposition::from_str("custom; foo=bar; foo*=utf-8''b%C3%A0r'").unwrap();
 | 
			
		||||
        assert_eq!(content_disposition.disposition_type.as_str(), "custom");
 | 
			
		||||
        assert_eq!(content_disposition.filename, None);
 | 
			
		||||
 | 
			
		||||
        // Disposition type and filename.
 | 
			
		||||
        let content_disposition = ContentDisposition::from_str("inline; filename=my_file").unwrap();
 | 
			
		||||
        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
 | 
			
		||||
        assert_eq!(content_disposition.filename.unwrap(), "my_file");
 | 
			
		||||
 | 
			
		||||
        // Case insensitive.
 | 
			
		||||
        let content_disposition = ContentDisposition::from_str("INLINE; FILENAME=my_file").unwrap();
 | 
			
		||||
        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
 | 
			
		||||
        assert_eq!(content_disposition.filename.unwrap(), "my_file");
 | 
			
		||||
 | 
			
		||||
        // Extra spaces.
 | 
			
		||||
        let content_disposition =
 | 
			
		||||
            ContentDisposition::from_str("  INLINE   ;FILENAME =   my_file   ").unwrap();
 | 
			
		||||
        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
 | 
			
		||||
        assert_eq!(content_disposition.filename.unwrap(), "my_file");
 | 
			
		||||
 | 
			
		||||
        // Unsupported filename* is skipped and falls back to ASCII filename.
 | 
			
		||||
        let content_disposition = ContentDisposition::from_str(
 | 
			
		||||
            r#"attachment; filename*=iso-8859-1''foo-%E4.html; filename="foo-a.html"#,
 | 
			
		||||
        )
 | 
			
		||||
        .unwrap();
 | 
			
		||||
        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
 | 
			
		||||
        assert_eq!(content_disposition.filename.unwrap(), "foo-a.html");
 | 
			
		||||
 | 
			
		||||
        // filename could be UTF-8 for extra compatibility (with `form-data` for example).
 | 
			
		||||
        let content_disposition =
 | 
			
		||||
            ContentDisposition::from_str(r#"form-data; name=upload; filename="文件.webp""#)
 | 
			
		||||
                .unwrap();
 | 
			
		||||
        assert_eq!(content_disposition.disposition_type.as_str(), "form-data");
 | 
			
		||||
        assert_eq!(content_disposition.filename.unwrap(), "文件.webp");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #[test]
 | 
			
		||||
    fn parse_content_disposition_invalid_type() {
 | 
			
		||||
        // Empty.
 | 
			
		||||
        ContentDisposition::from_str("").unwrap_err();
 | 
			
		||||
 | 
			
		||||
        // Missing disposition type.
 | 
			
		||||
        ContentDisposition::from_str("; foo=bar").unwrap_err();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #[test]
 | 
			
		||||
    fn parse_content_disposition_invalid_parameters() {
 | 
			
		||||
        // Unexpected `:` after parameter name, filename parameter is not reached.
 | 
			
		||||
        let content_disposition =
 | 
			
		||||
            ContentDisposition::from_str("inline; foo:bar; filename=my_file").unwrap();
 | 
			
		||||
        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
 | 
			
		||||
        assert_eq!(content_disposition.filename, None);
 | 
			
		||||
 | 
			
		||||
        // Same error, but after filename, so filename was parser.
 | 
			
		||||
        let content_disposition =
 | 
			
		||||
            ContentDisposition::from_str("inline; filename=my_file; foo:bar").unwrap();
 | 
			
		||||
        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
 | 
			
		||||
        assert_eq!(content_disposition.filename.unwrap(), "my_file");
 | 
			
		||||
 | 
			
		||||
        // Missing `;` between parameters, filename parameter is not parsed successfully.
 | 
			
		||||
        let content_disposition =
 | 
			
		||||
            ContentDisposition::from_str("inline; filename=my_file foo=bar").unwrap();
 | 
			
		||||
        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
 | 
			
		||||
        assert_eq!(content_disposition.filename, None);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #[test]
 | 
			
		||||
    fn content_disposition_serialize() {
 | 
			
		||||
        // Only disposition type.
 | 
			
		||||
        let content_disposition = ContentDisposition::new(ContentDispositionType::Inline);
 | 
			
		||||
        let serialized = content_disposition.to_string();
 | 
			
		||||
        assert_eq!(serialized, "inline");
 | 
			
		||||
 | 
			
		||||
        // Disposition type and ASCII filename without space.
 | 
			
		||||
        let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
 | 
			
		||||
            .with_filename(Some("my_file".to_owned()));
 | 
			
		||||
        let serialized = content_disposition.to_string();
 | 
			
		||||
        assert_eq!(serialized, "attachment; filename=my_file");
 | 
			
		||||
 | 
			
		||||
        // Disposition type and ASCII filename with space.
 | 
			
		||||
        let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
 | 
			
		||||
            .with_filename(Some("my file".to_owned()));
 | 
			
		||||
        let serialized = content_disposition.to_string();
 | 
			
		||||
        assert_eq!(serialized, r#"attachment; filename="my file""#);
 | 
			
		||||
 | 
			
		||||
        // Disposition type and ASCII filename with double quote and backslash.
 | 
			
		||||
        let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
 | 
			
		||||
            .with_filename(Some(r#""my"\file"#.to_owned()));
 | 
			
		||||
        let serialized = content_disposition.to_string();
 | 
			
		||||
        assert_eq!(serialized, r#"attachment; filename="\"my\"\\file""#);
 | 
			
		||||
 | 
			
		||||
        // Disposition type and UTF-8 filename.
 | 
			
		||||
        let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
 | 
			
		||||
            .with_filename(Some("Mi Corazón".to_owned()));
 | 
			
		||||
        let serialized = content_disposition.to_string();
 | 
			
		||||
        assert_eq!(serialized, "attachment; filename*=utf-8''Mi%20Coraz%C3%B3n");
 | 
			
		||||
 | 
			
		||||
        // Sanitized filename.
 | 
			
		||||
        let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
 | 
			
		||||
            .with_filename(Some("my\r\nfile".to_owned()));
 | 
			
		||||
        let serialized = content_disposition.to_string();
 | 
			
		||||
        assert_eq!(serialized, "attachment; filename=myfile");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #[test]
 | 
			
		||||
    fn rfc6266_examples() {
 | 
			
		||||
        // Basic syntax with unquoted filename.
 | 
			
		||||
        let unquoted = "Attachment; filename=example.html";
 | 
			
		||||
        let content_disposition = ContentDisposition::from_str(unquoted).unwrap();
 | 
			
		||||
 | 
			
		||||
        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
 | 
			
		||||
        assert_eq!(content_disposition.filename.as_deref().unwrap(), "example.html");
 | 
			
		||||
 | 
			
		||||
        let reserialized = content_disposition.to_string();
 | 
			
		||||
        assert_eq!(reserialized, "attachment; filename=example.html");
 | 
			
		||||
 | 
			
		||||
        // With quoted filename, case insensitivity and extra whitespaces.
 | 
			
		||||
        let quoted = r#"INLINE; FILENAME= "an example.html""#;
 | 
			
		||||
        let content_disposition = ContentDisposition::from_str(quoted).unwrap();
 | 
			
		||||
 | 
			
		||||
        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
 | 
			
		||||
        assert_eq!(content_disposition.filename.as_deref().unwrap(), "an example.html");
 | 
			
		||||
 | 
			
		||||
        let reserialized = content_disposition.to_string();
 | 
			
		||||
        assert_eq!(reserialized, r#"inline; filename="an example.html""#);
 | 
			
		||||
 | 
			
		||||
        // With RFC 8187-encoded UTF-8 filename.
 | 
			
		||||
        let rfc8187 = "attachment; filename*= UTF-8''%e2%82%ac%20rates";
 | 
			
		||||
        let content_disposition = ContentDisposition::from_str(rfc8187).unwrap();
 | 
			
		||||
 | 
			
		||||
        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
 | 
			
		||||
        assert_eq!(content_disposition.filename.as_deref().unwrap(), "€ rates");
 | 
			
		||||
 | 
			
		||||
        let reserialized = content_disposition.to_string();
 | 
			
		||||
        assert_eq!(reserialized, r#"attachment; filename*=utf-8''%E2%82%AC%20rates"#);
 | 
			
		||||
 | 
			
		||||
        // With RFC 8187-encoded UTF-8 filename with fallback ASCII filename.
 | 
			
		||||
        let rfc8187_with_fallback =
 | 
			
		||||
            r#"attachment; filename="EURO rates"; filename*=utf-8''%e2%82%ac%20rates"#;
 | 
			
		||||
        let content_disposition = ContentDisposition::from_str(rfc8187_with_fallback).unwrap();
 | 
			
		||||
 | 
			
		||||
        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
 | 
			
		||||
        assert_eq!(content_disposition.filename.as_deref().unwrap(), "€ rates");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #[test]
 | 
			
		||||
    fn rfc8187_examples() {
 | 
			
		||||
        // Those examples originate from RFC 8187, but are changed to fit the expectations here:
 | 
			
		||||
        //
 | 
			
		||||
        // - A disposition type is added
 | 
			
		||||
        // - The title parameter is renamed to filename
 | 
			
		||||
 | 
			
		||||
        // Basic syntax with unquoted filename.
 | 
			
		||||
        let unquoted = "attachment; foo= bar; filename=Economy";
 | 
			
		||||
        let content_disposition = ContentDisposition::from_str(unquoted).unwrap();
 | 
			
		||||
 | 
			
		||||
        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
 | 
			
		||||
        assert_eq!(content_disposition.filename.as_deref().unwrap(), "Economy");
 | 
			
		||||
 | 
			
		||||
        let reserialized = content_disposition.to_string();
 | 
			
		||||
        assert_eq!(reserialized, "attachment; filename=Economy");
 | 
			
		||||
 | 
			
		||||
        // With quoted filename.
 | 
			
		||||
        let quoted = r#"attachment; foo=bar; filename="US-$ rates""#;
 | 
			
		||||
        let content_disposition = ContentDisposition::from_str(quoted).unwrap();
 | 
			
		||||
 | 
			
		||||
        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
 | 
			
		||||
        assert_eq!(content_disposition.filename.as_deref().unwrap(), "US-$ rates");
 | 
			
		||||
 | 
			
		||||
        let reserialized = content_disposition.to_string();
 | 
			
		||||
        assert_eq!(reserialized, r#"attachment; filename="US-$ rates""#);
 | 
			
		||||
 | 
			
		||||
        // With RFC 8187-encoded UTF-8 filename.
 | 
			
		||||
        let rfc8187 = "attachment; foo=bar; filename*=utf-8'en'%C2%A3%20rates";
 | 
			
		||||
        let content_disposition = ContentDisposition::from_str(rfc8187).unwrap();
 | 
			
		||||
 | 
			
		||||
        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
 | 
			
		||||
        assert_eq!(content_disposition.filename.as_deref().unwrap(), "£ rates");
 | 
			
		||||
 | 
			
		||||
        let reserialized = content_disposition.to_string();
 | 
			
		||||
        assert_eq!(reserialized, r#"attachment; filename*=utf-8''%C2%A3%20rates"#);
 | 
			
		||||
 | 
			
		||||
        // With RFC 8187-encoded UTF-8 filename again.
 | 
			
		||||
        let rfc8187_other =
 | 
			
		||||
            r#"attachment; foo=bar; filename*=UTF-8''%c2%a3%20and%20%e2%82%ac%20rates"#;
 | 
			
		||||
        let content_disposition = ContentDisposition::from_str(rfc8187_other).unwrap();
 | 
			
		||||
 | 
			
		||||
        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
 | 
			
		||||
        assert_eq!(content_disposition.filename.as_deref().unwrap(), "£ and € rates");
 | 
			
		||||
 | 
			
		||||
        let reserialized = content_disposition.to_string();
 | 
			
		||||
        assert_eq!(
 | 
			
		||||
            reserialized,
 | 
			
		||||
            r#"attachment; filename*=utf-8''%C2%A3%20and%20%E2%82%AC%20rates"#
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        // With RFC 8187-encoded UTF-8 filename with fallback ASCII filename.
 | 
			
		||||
        let rfc8187_with_fallback = r#"attachment; foo=bar; filename="EURO exchange rates"; filename*=utf-8''%e2%82%ac%20exchange%20rates"#;
 | 
			
		||||
        let content_disposition = ContentDisposition::from_str(rfc8187_with_fallback).unwrap();
 | 
			
		||||
 | 
			
		||||
        assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
 | 
			
		||||
        assert_eq!(content_disposition.filename.as_deref().unwrap(), "€ exchange rates");
 | 
			
		||||
 | 
			
		||||
        let reserialized = content_disposition.to_string();
 | 
			
		||||
        assert_eq!(reserialized, r#"attachment; filename*=utf-8''%E2%82%AC%20exchange%20rates"#);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										76
									
								
								crates/ruma-common/src/http_headers/rfc8187.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								crates/ruma-common/src/http_headers/rfc8187.rs
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,76 @@
 | 
			
		||||
//! Encoding and decoding functions according to [RFC 8187].
 | 
			
		||||
//!
 | 
			
		||||
//! [RFC 8187]: https://datatracker.ietf.org/doc/html/rfc8187
 | 
			
		||||
 | 
			
		||||
use std::borrow::Cow;
 | 
			
		||||
 | 
			
		||||
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC};
 | 
			
		||||
 | 
			
		||||
/// The characters to percent-encode according to the `attr-char` set.
 | 
			
		||||
const ATTR_CHAR: AsciiSet = NON_ALPHANUMERIC
 | 
			
		||||
    .remove(b'!')
 | 
			
		||||
    .remove(b'#')
 | 
			
		||||
    .remove(b'$')
 | 
			
		||||
    .remove(b'&')
 | 
			
		||||
    .remove(b'+')
 | 
			
		||||
    .remove(b'-')
 | 
			
		||||
    .remove(b'.')
 | 
			
		||||
    .remove(b'^')
 | 
			
		||||
    .remove(b'_')
 | 
			
		||||
    .remove(b'`')
 | 
			
		||||
    .remove(b'|')
 | 
			
		||||
    .remove(b'~');
 | 
			
		||||
 | 
			
		||||
/// Encode the given string according to [RFC 8187].
 | 
			
		||||
///
 | 
			
		||||
/// [RFC 8187]: https://datatracker.ietf.org/doc/html/rfc8187
 | 
			
		||||
pub(super) fn encode(s: &str) -> String {
 | 
			
		||||
    let encoded = percent_encoding::utf8_percent_encode(s, &ATTR_CHAR);
 | 
			
		||||
    format!("utf-8''{encoded}")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Decode the given bytes according to [RFC 8187].
 | 
			
		||||
///
 | 
			
		||||
/// Only the UTF-8 character set is supported, all other character sets return an error.
 | 
			
		||||
///
 | 
			
		||||
/// [RFC 8187]: https://datatracker.ietf.org/doc/html/rfc8187
 | 
			
		||||
pub(super) fn decode(bytes: &[u8]) -> Result<Cow<'_, str>, Rfc8187DecodeError> {
 | 
			
		||||
    if bytes.is_empty() {
 | 
			
		||||
        return Err(Rfc8187DecodeError::Empty);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let mut parts = bytes.split(|b| *b == b'\'');
 | 
			
		||||
    let charset = parts.next().ok_or(Rfc8187DecodeError::WrongPartsCount)?;
 | 
			
		||||
    let _lang = parts.next().ok_or(Rfc8187DecodeError::WrongPartsCount)?;
 | 
			
		||||
    let encoded = parts.next().ok_or(Rfc8187DecodeError::WrongPartsCount)?;
 | 
			
		||||
 | 
			
		||||
    if parts.next().is_some() {
 | 
			
		||||
        return Err(Rfc8187DecodeError::WrongPartsCount);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if !charset.eq_ignore_ascii_case(b"utf-8") {
 | 
			
		||||
        return Err(Rfc8187DecodeError::NotUtf8);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // For maximum compatibility, do a lossy conversion.
 | 
			
		||||
    Ok(percent_encoding::percent_decode(encoded).decode_utf8_lossy())
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// All errors encountered when trying to decode a string according to [RFC 8187].
 | 
			
		||||
///
 | 
			
		||||
/// [RFC 8187]: https://datatracker.ietf.org/doc/html/rfc8187
 | 
			
		||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
 | 
			
		||||
#[non_exhaustive]
 | 
			
		||||
pub(super) enum Rfc8187DecodeError {
 | 
			
		||||
    /// The string is empty.
 | 
			
		||||
    #[error("string is empty")]
 | 
			
		||||
    Empty,
 | 
			
		||||
 | 
			
		||||
    /// The string does not contain the right number of parts.
 | 
			
		||||
    #[error("string does not contain the right number of parts")]
 | 
			
		||||
    WrongPartsCount,
 | 
			
		||||
 | 
			
		||||
    /// The character set is not UTF-8.
 | 
			
		||||
    #[error("character set is not UTF-8")]
 | 
			
		||||
    NotUtf8,
 | 
			
		||||
}
 | 
			
		||||
@ -24,6 +24,7 @@ pub mod authentication;
 | 
			
		||||
pub mod canonical_json;
 | 
			
		||||
pub mod directory;
 | 
			
		||||
pub mod encryption;
 | 
			
		||||
pub mod http_headers;
 | 
			
		||||
mod identifiers;
 | 
			
		||||
mod percent_encode;
 | 
			
		||||
pub mod power_levels;
 | 
			
		||||
 | 
			
		||||
@ -7,6 +7,7 @@ mod header_override;
 | 
			
		||||
mod manual_endpoint_impl;
 | 
			
		||||
mod no_fields;
 | 
			
		||||
mod optional_headers;
 | 
			
		||||
mod required_headers;
 | 
			
		||||
mod ruma_api;
 | 
			
		||||
mod ruma_api_macros;
 | 
			
		||||
mod status_override;
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,11 @@
 | 
			
		||||
use http::header::LOCATION;
 | 
			
		||||
use assert_matches2::assert_matches;
 | 
			
		||||
use http::header::{CONTENT_DISPOSITION, LOCATION};
 | 
			
		||||
use ruma_common::{
 | 
			
		||||
    api::{request, response, Metadata},
 | 
			
		||||
    api::{
 | 
			
		||||
        request, response, IncomingRequest, IncomingResponse, MatrixVersion, Metadata,
 | 
			
		||||
        OutgoingRequest, OutgoingResponse, SendAccessToken,
 | 
			
		||||
    },
 | 
			
		||||
    http_headers::{ContentDisposition, ContentDispositionType},
 | 
			
		||||
    metadata,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -13,16 +18,128 @@ const METADATA: Metadata = metadata! {
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/// Request type for the `no_fields` endpoint.
 | 
			
		||||
/// Request type for the `optional_headers` endpoint.
 | 
			
		||||
#[request]
 | 
			
		||||
pub struct Request {
 | 
			
		||||
    #[ruma_api(header = LOCATION)]
 | 
			
		||||
    pub location: Option<String>,
 | 
			
		||||
    #[ruma_api(header = CONTENT_DISPOSITION)]
 | 
			
		||||
    pub content_disposition: Option<ContentDisposition>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Response type for the `no_fields` endpoint.
 | 
			
		||||
/// Response type for the `optional_headers` endpoint.
 | 
			
		||||
#[response]
 | 
			
		||||
pub struct Response {
 | 
			
		||||
    #[ruma_api(header = LOCATION)]
 | 
			
		||||
    pub stuff: Option<String>,
 | 
			
		||||
    #[ruma_api(header = CONTENT_DISPOSITION)]
 | 
			
		||||
    pub content_disposition: Option<ContentDisposition>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[test]
 | 
			
		||||
fn request_serde_no_header() {
 | 
			
		||||
    let req = Request { location: None, content_disposition: None };
 | 
			
		||||
 | 
			
		||||
    let http_req = req
 | 
			
		||||
        .clone()
 | 
			
		||||
        .try_into_http_request::<Vec<u8>>(
 | 
			
		||||
            "https://homeserver.tld",
 | 
			
		||||
            SendAccessToken::None,
 | 
			
		||||
            &[MatrixVersion::V1_1],
 | 
			
		||||
        )
 | 
			
		||||
        .unwrap();
 | 
			
		||||
    assert_matches!(http_req.headers().get(LOCATION), None);
 | 
			
		||||
    assert_matches!(http_req.headers().get(CONTENT_DISPOSITION), None);
 | 
			
		||||
 | 
			
		||||
    let req2 = Request::try_from_http_request::<_, &str>(http_req, &[]).unwrap();
 | 
			
		||||
    assert_eq!(req2.location, None);
 | 
			
		||||
    assert_eq!(req2.content_disposition, None);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[test]
 | 
			
		||||
fn request_serde_with_header() {
 | 
			
		||||
    let location = "https://other.tld/page/";
 | 
			
		||||
    let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
 | 
			
		||||
        .with_filename(Some("my_file".to_owned()));
 | 
			
		||||
    let req = Request {
 | 
			
		||||
        location: Some(location.to_owned()),
 | 
			
		||||
        content_disposition: Some(content_disposition.clone()),
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let mut http_req = req
 | 
			
		||||
        .clone()
 | 
			
		||||
        .try_into_http_request::<Vec<u8>>(
 | 
			
		||||
            "https://homeserver.tld",
 | 
			
		||||
            SendAccessToken::None,
 | 
			
		||||
            &[MatrixVersion::V1_1],
 | 
			
		||||
        )
 | 
			
		||||
        .unwrap();
 | 
			
		||||
    assert_matches!(http_req.headers().get(LOCATION), Some(_));
 | 
			
		||||
    assert_matches!(http_req.headers().get(CONTENT_DISPOSITION), Some(_));
 | 
			
		||||
 | 
			
		||||
    let req2 = Request::try_from_http_request::<_, &str>(http_req.clone(), &[]).unwrap();
 | 
			
		||||
    assert_eq!(req2.location.unwrap(), location);
 | 
			
		||||
    assert_eq!(req2.content_disposition.unwrap(), content_disposition);
 | 
			
		||||
 | 
			
		||||
    // Try removing the headers.
 | 
			
		||||
    http_req.headers_mut().remove(LOCATION).unwrap();
 | 
			
		||||
    http_req.headers_mut().remove(CONTENT_DISPOSITION).unwrap();
 | 
			
		||||
 | 
			
		||||
    let req3 = Request::try_from_http_request::<_, &str>(http_req.clone(), &[]).unwrap();
 | 
			
		||||
    assert_eq!(req3.location, None);
 | 
			
		||||
    assert_eq!(req3.content_disposition, None);
 | 
			
		||||
 | 
			
		||||
    // Try setting invalid header.
 | 
			
		||||
    http_req.headers_mut().insert(CONTENT_DISPOSITION, ";".try_into().unwrap());
 | 
			
		||||
 | 
			
		||||
    let req4 = Request::try_from_http_request::<_, &str>(http_req, &[]).unwrap();
 | 
			
		||||
    assert_eq!(req4.location, None);
 | 
			
		||||
    assert_eq!(req4.content_disposition, None);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[test]
 | 
			
		||||
fn response_serde_no_header() {
 | 
			
		||||
    let res = Response { stuff: None, content_disposition: None };
 | 
			
		||||
 | 
			
		||||
    let http_res = res.clone().try_into_http_response::<Vec<u8>>().unwrap();
 | 
			
		||||
    assert_matches!(http_res.headers().get(LOCATION), None);
 | 
			
		||||
    assert_matches!(http_res.headers().get(CONTENT_DISPOSITION), None);
 | 
			
		||||
 | 
			
		||||
    let res2 = Response::try_from_http_response(http_res).unwrap();
 | 
			
		||||
    assert_eq!(res2.stuff, None);
 | 
			
		||||
    assert_eq!(res2.content_disposition, None);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[test]
 | 
			
		||||
fn response_serde_with_header() {
 | 
			
		||||
    let location = "https://other.tld/page/";
 | 
			
		||||
    let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
 | 
			
		||||
        .with_filename(Some("my_file".to_owned()));
 | 
			
		||||
    let res = Response {
 | 
			
		||||
        stuff: Some(location.to_owned()),
 | 
			
		||||
        content_disposition: Some(content_disposition.clone()),
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let mut http_res = res.clone().try_into_http_response::<Vec<u8>>().unwrap();
 | 
			
		||||
    assert_matches!(http_res.headers().get(LOCATION), Some(_));
 | 
			
		||||
    assert_matches!(http_res.headers().get(CONTENT_DISPOSITION), Some(_));
 | 
			
		||||
 | 
			
		||||
    let res2 = Response::try_from_http_response(http_res.clone()).unwrap();
 | 
			
		||||
    assert_eq!(res2.stuff.unwrap(), location);
 | 
			
		||||
    assert_eq!(res2.content_disposition.unwrap(), content_disposition);
 | 
			
		||||
 | 
			
		||||
    // Try removing the headers.
 | 
			
		||||
    http_res.headers_mut().remove(LOCATION).unwrap();
 | 
			
		||||
    http_res.headers_mut().remove(CONTENT_DISPOSITION).unwrap();
 | 
			
		||||
 | 
			
		||||
    let res3 = Response::try_from_http_response(http_res.clone()).unwrap();
 | 
			
		||||
    assert_eq!(res3.stuff, None);
 | 
			
		||||
    assert_eq!(res3.content_disposition, None);
 | 
			
		||||
 | 
			
		||||
    // Try setting invalid header.
 | 
			
		||||
    http_res.headers_mut().insert(CONTENT_DISPOSITION, ";".try_into().unwrap());
 | 
			
		||||
 | 
			
		||||
    let res4 = Response::try_from_http_response(http_res).unwrap();
 | 
			
		||||
    assert_eq!(res4.stuff, None);
 | 
			
		||||
    assert_eq!(res4.content_disposition, None);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										130
									
								
								crates/ruma-common/tests/api/required_headers.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								crates/ruma-common/tests/api/required_headers.rs
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,130 @@
 | 
			
		||||
use assert_matches2::assert_matches;
 | 
			
		||||
use http::header::{CONTENT_DISPOSITION, LOCATION};
 | 
			
		||||
use ruma_common::{
 | 
			
		||||
    api::{
 | 
			
		||||
        error::{
 | 
			
		||||
            DeserializationError, FromHttpRequestError, FromHttpResponseError,
 | 
			
		||||
            HeaderDeserializationError,
 | 
			
		||||
        },
 | 
			
		||||
        request, response, IncomingRequest, IncomingResponse, MatrixVersion, Metadata,
 | 
			
		||||
        OutgoingRequest, OutgoingResponse, SendAccessToken,
 | 
			
		||||
    },
 | 
			
		||||
    http_headers::{ContentDisposition, ContentDispositionType},
 | 
			
		||||
    metadata,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const METADATA: Metadata = metadata! {
 | 
			
		||||
    method: GET,
 | 
			
		||||
    rate_limited: false,
 | 
			
		||||
    authentication: None,
 | 
			
		||||
    history: {
 | 
			
		||||
        unstable => "/_matrix/my/endpoint",
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/// Request type for the `required_headers` endpoint.
 | 
			
		||||
#[request]
 | 
			
		||||
pub struct Request {
 | 
			
		||||
    #[ruma_api(header = LOCATION)]
 | 
			
		||||
    pub location: String,
 | 
			
		||||
    #[ruma_api(header = CONTENT_DISPOSITION)]
 | 
			
		||||
    pub content_disposition: ContentDisposition,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Response type for the `required_headers` endpoint.
 | 
			
		||||
#[response]
 | 
			
		||||
pub struct Response {
 | 
			
		||||
    #[ruma_api(header = LOCATION)]
 | 
			
		||||
    pub stuff: String,
 | 
			
		||||
    #[ruma_api(header = CONTENT_DISPOSITION)]
 | 
			
		||||
    pub content_disposition: ContentDisposition,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[test]
 | 
			
		||||
fn request_serde() {
 | 
			
		||||
    let location = "https://other.tld/page/";
 | 
			
		||||
    let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
 | 
			
		||||
        .with_filename(Some("my_file".to_owned()));
 | 
			
		||||
    let req =
 | 
			
		||||
        Request { location: location.to_owned(), content_disposition: content_disposition.clone() };
 | 
			
		||||
 | 
			
		||||
    let mut http_req = req
 | 
			
		||||
        .clone()
 | 
			
		||||
        .try_into_http_request::<Vec<u8>>(
 | 
			
		||||
            "https://homeserver.tld",
 | 
			
		||||
            SendAccessToken::None,
 | 
			
		||||
            &[MatrixVersion::V1_1],
 | 
			
		||||
        )
 | 
			
		||||
        .unwrap();
 | 
			
		||||
    assert_matches!(http_req.headers().get(LOCATION), Some(_));
 | 
			
		||||
    assert_matches!(http_req.headers().get(CONTENT_DISPOSITION), Some(_));
 | 
			
		||||
 | 
			
		||||
    let req2 = Request::try_from_http_request::<_, &str>(http_req.clone(), &[]).unwrap();
 | 
			
		||||
    assert_eq!(req2.location, location);
 | 
			
		||||
    assert_eq!(req2.content_disposition, content_disposition);
 | 
			
		||||
 | 
			
		||||
    // Try removing the headers.
 | 
			
		||||
    http_req.headers_mut().remove(LOCATION).unwrap();
 | 
			
		||||
    http_req.headers_mut().remove(CONTENT_DISPOSITION).unwrap();
 | 
			
		||||
 | 
			
		||||
    let err = Request::try_from_http_request::<_, &str>(http_req.clone(), &[]).unwrap_err();
 | 
			
		||||
    assert_matches!(
 | 
			
		||||
        err,
 | 
			
		||||
        FromHttpRequestError::Deserialization(DeserializationError::Header(
 | 
			
		||||
            HeaderDeserializationError::MissingHeader(_)
 | 
			
		||||
        ))
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    // Try setting invalid header.
 | 
			
		||||
    http_req.headers_mut().insert(LOCATION, location.try_into().unwrap());
 | 
			
		||||
    http_req.headers_mut().insert(CONTENT_DISPOSITION, ";".try_into().unwrap());
 | 
			
		||||
 | 
			
		||||
    let err = Request::try_from_http_request::<_, &str>(http_req, &[]).unwrap_err();
 | 
			
		||||
    assert_matches!(
 | 
			
		||||
        err,
 | 
			
		||||
        FromHttpRequestError::Deserialization(DeserializationError::Header(
 | 
			
		||||
            HeaderDeserializationError::InvalidHeader(_)
 | 
			
		||||
        ))
 | 
			
		||||
    );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[test]
 | 
			
		||||
fn response_serde() {
 | 
			
		||||
    let location = "https://other.tld/page/";
 | 
			
		||||
    let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
 | 
			
		||||
        .with_filename(Some("my_file".to_owned()));
 | 
			
		||||
    let res =
 | 
			
		||||
        Response { stuff: location.to_owned(), content_disposition: content_disposition.clone() };
 | 
			
		||||
 | 
			
		||||
    let mut http_res = res.clone().try_into_http_response::<Vec<u8>>().unwrap();
 | 
			
		||||
    assert_matches!(http_res.headers().get(LOCATION), Some(_));
 | 
			
		||||
    assert_matches!(http_res.headers().get(CONTENT_DISPOSITION), Some(_));
 | 
			
		||||
 | 
			
		||||
    let res2 = Response::try_from_http_response(http_res.clone()).unwrap();
 | 
			
		||||
    assert_eq!(res2.stuff, location);
 | 
			
		||||
    assert_eq!(res2.content_disposition, content_disposition);
 | 
			
		||||
 | 
			
		||||
    // Try removing the headers.
 | 
			
		||||
    http_res.headers_mut().remove(LOCATION).unwrap();
 | 
			
		||||
    http_res.headers_mut().remove(CONTENT_DISPOSITION).unwrap();
 | 
			
		||||
 | 
			
		||||
    let err = Response::try_from_http_response(http_res.clone()).unwrap_err();
 | 
			
		||||
    assert_matches!(
 | 
			
		||||
        err,
 | 
			
		||||
        FromHttpResponseError::Deserialization(DeserializationError::Header(
 | 
			
		||||
            HeaderDeserializationError::MissingHeader(_)
 | 
			
		||||
        ))
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    // Try setting invalid header.
 | 
			
		||||
    http_res.headers_mut().insert(LOCATION, location.try_into().unwrap());
 | 
			
		||||
    http_res.headers_mut().insert(CONTENT_DISPOSITION, ";".try_into().unwrap());
 | 
			
		||||
 | 
			
		||||
    let err = Response::try_from_http_response(http_res).unwrap_err();
 | 
			
		||||
    assert_matches!(
 | 
			
		||||
        err,
 | 
			
		||||
        FromHttpResponseError::Deserialization(DeserializationError::Header(
 | 
			
		||||
            HeaderDeserializationError::InvalidHeader(_)
 | 
			
		||||
        ))
 | 
			
		||||
    );
 | 
			
		||||
}
 | 
			
		||||
@ -1,11 +1,12 @@
 | 
			
		||||
//! Common types for implementing federation authorization.
 | 
			
		||||
 | 
			
		||||
use std::{borrow::Cow, fmt, str::FromStr};
 | 
			
		||||
use std::{fmt, str::FromStr};
 | 
			
		||||
 | 
			
		||||
use headers::authorization::Credentials;
 | 
			
		||||
use http::HeaderValue;
 | 
			
		||||
use http_auth::ChallengeParser;
 | 
			
		||||
use ruma_common::{
 | 
			
		||||
    http_headers::quote_ascii_string_if_required,
 | 
			
		||||
    serde::{Base64, Base64DecodeError},
 | 
			
		||||
    IdParseError, OwnedServerName, OwnedServerSigningKeyId,
 | 
			
		||||
};
 | 
			
		||||
@ -119,40 +120,19 @@ impl fmt::Debug for XMatrix {
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Whether the given char is a [token char].
 | 
			
		||||
///
 | 
			
		||||
/// [token char]: https://www.rfc-editor.org/rfc/rfc9110#section-5.6.2
 | 
			
		||||
fn is_tchar(c: char) -> bool {
 | 
			
		||||
    const TOKEN_CHARS: [char; 15] =
 | 
			
		||||
        ['!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~'];
 | 
			
		||||
    c.is_ascii_alphanumeric() || TOKEN_CHARS.contains(&c)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// If the field value does not contain only token chars, convert it to a [quoted string].
 | 
			
		||||
///
 | 
			
		||||
/// [quoted string]: https://www.rfc-editor.org/rfc/rfc9110#section-5.6.4
 | 
			
		||||
fn escape_field_value(value: &str) -> Cow<'_, str> {
 | 
			
		||||
    if !value.is_empty() && value.chars().all(is_tchar) {
 | 
			
		||||
        return Cow::Borrowed(value);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let value = value.replace('\\', r#"\\"#).replace('"', r#"\""#);
 | 
			
		||||
    Cow::Owned(format!("\"{value}\""))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl fmt::Display for XMatrix {
 | 
			
		||||
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
 | 
			
		||||
        let Self { origin, destination, key, sig } = self;
 | 
			
		||||
 | 
			
		||||
        let origin = escape_field_value(origin.as_str());
 | 
			
		||||
        let key = escape_field_value(key.as_str());
 | 
			
		||||
        let origin = quote_ascii_string_if_required(origin.as_str());
 | 
			
		||||
        let key = quote_ascii_string_if_required(key.as_str());
 | 
			
		||||
        let sig = sig.encode();
 | 
			
		||||
        let sig = escape_field_value(&sig);
 | 
			
		||||
        let sig = quote_ascii_string_if_required(&sig);
 | 
			
		||||
 | 
			
		||||
        write!(f, r#"{} "#, Self::SCHEME)?;
 | 
			
		||||
 | 
			
		||||
        if let Some(destination) = destination {
 | 
			
		||||
            let destination = escape_field_value(destination.as_str());
 | 
			
		||||
            let destination = quote_ascii_string_if_required(destination.as_str());
 | 
			
		||||
            write!(f, r#"destination={destination},"#)?;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user