diff --git a/crates/ruma-client-api/CHANGELOG.md b/crates/ruma-client-api/CHANGELOG.md index d92b1040..2606a168 100644 --- a/crates/ruma-client-api/CHANGELOG.md +++ b/crates/ruma-client-api/CHANGELOG.md @@ -7,6 +7,9 @@ Breaking changes: - Change type of `client_secret` field in `ThirdpartyIdCredentials` from `Box` 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: diff --git a/crates/ruma-client-api/src/authenticated_media/get_content.rs b/crates/ruma-client-api/src/authenticated_media/get_content.rs index e17b067f..efe12093 100644 --- a/crates/ruma-client-api/src/authenticated_media/get_content.rs +++ b/crates/ruma-client-api/src/authenticated_media/get_content.rs @@ -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, + pub content_disposition: Option, } impl Request { diff --git a/crates/ruma-client-api/src/authenticated_media/get_content_as_filename.rs b/crates/ruma-client-api/src/authenticated_media/get_content_as_filename.rs index 543dcf88..ff9f6f64 100644 --- a/crates/ruma-client-api/src/authenticated_media/get_content_as_filename.rs +++ b/crates/ruma-client-api/src/authenticated_media/get_content_as_filename.rs @@ -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, + pub content_disposition: Option, } impl Request { diff --git a/crates/ruma-client-api/src/http_headers.rs b/crates/ruma-client-api/src/http_headers.rs index ae963764..af24afcd 100644 --- a/crates/ruma-client-api/src/http_headers.rs +++ b/crates/ruma-client-api/src/http_headers.rs @@ -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. diff --git a/crates/ruma-client-api/src/media/get_content.rs b/crates/ruma-client-api/src/media/get_content.rs index 30dbfcf5..dc79a7b6 100644 --- a/crates/ruma-client-api/src/media/get_content.rs +++ b/crates/ruma-client-api/src/media/get_content.rs @@ -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, + pub content_disposition: Option, /// The value of the `Cross-Origin-Resource-Policy` HTTP header. /// diff --git a/crates/ruma-client-api/src/media/get_content_as_filename.rs b/crates/ruma-client-api/src/media/get_content_as_filename.rs index 8bbbbf62..63f3ca11 100644 --- a/crates/ruma-client-api/src/media/get_content_as_filename.rs +++ b/crates/ruma-client-api/src/media/get_content_as_filename.rs @@ -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, + pub content_disposition: Option, /// The value of the `Cross-Origin-Resource-Policy` HTTP header. /// diff --git a/crates/ruma-common/src/http_headers.rs b/crates/ruma-common/src/http_headers.rs new file mode 100644 index 00000000..c7d9ece2 --- /dev/null +++ b/crates/ruma-common/src/http_headers.rs @@ -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() +} diff --git a/crates/ruma-common/src/http_headers/content_disposition.rs b/crates/ruma-common/src/http_headers/content_disposition.rs new file mode 100644 index 00000000..b90dacb5 --- /dev/null +++ b/crates/ruma-common/src/http_headers/content_disposition.rs @@ -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, +} + +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) -> 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 { + 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 { + 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 { + 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 { + 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::from_str(s) + } +} + +impl From 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 { + 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 { + s.as_bytes().try_into() + } +} + +impl PartialEq for ContentDispositionType { + fn eq(&self, other: &ContentDispositionType) -> bool { + self.as_str().eq_ignore_ascii_case(other.as_str()) + } +} + +impl Eq for ContentDispositionType {} + +impl PartialEq 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); + +impl TokenString { + /// Try parsing a `&str` into a `TokenString`. + pub fn parse(s: &str) -> Result { + Self::from_str(s) + } +} + +impl Deref for TokenString { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.as_ref() + } +} + +impl AsRef 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 { + 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 { + 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"#); + } +} diff --git a/crates/ruma-common/src/http_headers/rfc8187.rs b/crates/ruma-common/src/http_headers/rfc8187.rs new file mode 100644 index 00000000..0311f075 --- /dev/null +++ b/crates/ruma-common/src/http_headers/rfc8187.rs @@ -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, 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, +} diff --git a/crates/ruma-common/src/lib.rs b/crates/ruma-common/src/lib.rs index 488088c4..8a188c50 100644 --- a/crates/ruma-common/src/lib.rs +++ b/crates/ruma-common/src/lib.rs @@ -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; diff --git a/crates/ruma-common/tests/api/mod.rs b/crates/ruma-common/tests/api/mod.rs index 80e4fb08..15ceba74 100644 --- a/crates/ruma-common/tests/api/mod.rs +++ b/crates/ruma-common/tests/api/mod.rs @@ -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; diff --git a/crates/ruma-common/tests/api/optional_headers.rs b/crates/ruma-common/tests/api/optional_headers.rs index b81b5c57..78fc0746 100644 --- a/crates/ruma-common/tests/api/optional_headers.rs +++ b/crates/ruma-common/tests/api/optional_headers.rs @@ -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, + #[ruma_api(header = CONTENT_DISPOSITION)] + pub content_disposition: Option, } -/// 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, + #[ruma_api(header = CONTENT_DISPOSITION)] + pub content_disposition: Option, +} + +#[test] +fn request_serde_no_header() { + let req = Request { location: None, content_disposition: None }; + + let http_req = req + .clone() + .try_into_http_request::>( + "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::>( + "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::>().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::>().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); } diff --git a/crates/ruma-common/tests/api/required_headers.rs b/crates/ruma-common/tests/api/required_headers.rs new file mode 100644 index 00000000..1d8576d6 --- /dev/null +++ b/crates/ruma-common/tests/api/required_headers.rs @@ -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::>( + "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::>().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(_) + )) + ); +} diff --git a/crates/ruma-server-util/src/authorization.rs b/crates/ruma-server-util/src/authorization.rs index 8c7f02e6..c7645f57 100644 --- a/crates/ruma-server-util/src/authorization.rs +++ b/crates/ruma-server-util/src/authorization.rs @@ -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},"#)?; }