diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 652199ce..93ce0367 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ env: CARGO_TERM_COLOR: always CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse # Keep in sync with version in `rust-toolchain.toml` and `xtask/src/ci.rs` - NIGHTLY: nightly-2024-05-09 + NIGHTLY: nightly-2024-07-29 on: push: @@ -181,6 +181,11 @@ jobs: cmd: clippy-all components: clippy + - name: Clippy WASM + cmd: clippy-wasm + targets: wasm32-unknown-unknown + components: clippy + steps: - name: Checkout repo uses: actions/checkout@v4 @@ -190,6 +195,7 @@ jobs: with: toolchain: ${{ env.NIGHTLY }} components: ${{ matrix.components }} + targets: ${{ matrix.targets }} - uses: Swatinem/rust-cache@v2 diff --git a/.wasm/.clippy.toml b/.wasm/.clippy.toml new file mode 100644 index 00000000..f31de1d1 --- /dev/null +++ b/.wasm/.clippy.toml @@ -0,0 +1,25 @@ +avoid-breaking-exported-api = false +disallowed-methods = [ + # https://github.com/serde-rs/json/issues/160 + "serde_json::from_reader", +] +disallowed-types = [ + "std::collections::HashMap", + "std::collections::HashSet", + { path = "std::time::UNIX_EPOCH", reason = "Use web-time to return a UNIX_EPOCH that works under WASM" }, + { path = "std::time::SystemTime", reason = "Use web-time to return a SystemTime that works under WASM" }, + { path = "std::time::Instant", reason = "Use web-time to return an Instant that works under WASM" }, +] +enforced-import-renames = [ + { path = "serde_json::from_slice", rename = "from_json_slice" }, + { path = "serde_json::from_str", rename = "from_json_str" }, + { path = "serde_json::from_value", rename = "from_json_value" }, + { path = "serde_json::to_value", rename = "to_json_value" }, + { path = "serde_json::value::to_raw_value", rename = "to_raw_json_value" }, + { path = "serde_json::value::RawValue", rename = "RawJsonValue" }, + { path = "serde_json::Value", rename = "JsonValue" }, +] +standard-macro-braces = [ + { name = "quote", brace = "{" }, + { name = "quote::quote", brace = "{" }, +] diff --git a/Cargo.toml b/Cargo.toml index 3f00977f..6445ef4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,10 +12,12 @@ as_variant = "1.2.0" assert_matches2 = "0.1.0" assign = "1.1.1" base64 = "0.22.0" +bytes = "1.0.1" criterion = "0.5.0" http = "1.1.0" js_int = "0.2.2" maplit = "1.0.2" +rand = "0.8.5" ruma-appservice-api = { version = "0.10.0", path = "crates/ruma-appservice-api" } ruma-common = { version = "0.13.0", path = "crates/ruma-common" } ruma-client = { version = "0.13.0", path = "crates/ruma-client" } diff --git a/crates/ruma-client-api/CHANGELOG.md b/crates/ruma-client-api/CHANGELOG.md index d92b1040..3dc851b5 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: @@ -31,6 +34,8 @@ Bug fixes: - `user_id` of `SlidingSyncRoomHero` is now mandatory - Make authentication with access token optional for the `change_password` and `deactivate` endpoints. +- Do not send a request body for the `logout` and `logout_all` endpoints, due + to a clarification in the spec. # 0.18.0 diff --git a/crates/ruma-client-api/Cargo.toml b/crates/ruma-client-api/Cargo.toml index 2814a038..01927db0 100644 --- a/crates/ruma-client-api/Cargo.toml +++ b/crates/ruma-client-api/Cargo.toml @@ -55,7 +55,7 @@ unstable-msc4140 = [] [dependencies] as_variant = { workspace = true } assign = { workspace = true } -bytes = "1.0.1" +bytes = { workspace = true } date_header = "1.0.5" http = { workspace = true } js_int = { workspace = true, features = ["serde"] } 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..75296d04 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, }; @@ -43,8 +44,8 @@ pub mod v1 { #[ruma_api(query)] #[serde( with = "ruma_common::serde::duration::ms", - default = "crate::media::default_download_timeout", - skip_serializing_if = "crate::media::is_default_download_timeout" + default = "ruma_common::media::default_download_timeout", + skip_serializing_if = "ruma_common::media::is_default_download_timeout" )] pub timeout_ms: Duration, } @@ -62,18 +63,18 @@ 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 { /// Creates a new `Request` with the given media ID and server name. pub fn new(media_id: String, server_name: OwnedServerName) -> Self { - Self { media_id, server_name, timeout_ms: crate::media::default_download_timeout() } + Self { + media_id, + server_name, + timeout_ms: ruma_common::media::default_download_timeout(), + } } /// Creates a new `Request` with the given URI. 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..ba142484 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, }; @@ -47,8 +48,8 @@ pub mod v1 { #[ruma_api(query)] #[serde( with = "ruma_common::serde::duration::ms", - default = "crate::media::default_download_timeout", - skip_serializing_if = "crate::media::is_default_download_timeout" + default = "ruma_common::media::default_download_timeout", + skip_serializing_if = "ruma_common::media::is_default_download_timeout" )] pub timeout_ms: Duration, } @@ -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 { @@ -81,7 +78,7 @@ pub mod v1 { media_id, server_name, filename, - timeout_ms: crate::media::default_download_timeout(), + timeout_ms: ruma_common::media::default_download_timeout(), } } diff --git a/crates/ruma-client-api/src/authenticated_media/get_content_thumbnail.rs b/crates/ruma-client-api/src/authenticated_media/get_content_thumbnail.rs index 8e575120..2c5bb518 100644 --- a/crates/ruma-client-api/src/authenticated_media/get_content_thumbnail.rs +++ b/crates/ruma-client-api/src/authenticated_media/get_content_thumbnail.rs @@ -13,11 +13,10 @@ pub mod v1 { use js_int::UInt; use ruma_common::{ api::{request, response, Metadata}, + media::Method, metadata, IdParseError, MxcUri, OwnedServerName, }; - use crate::media::get_content_thumbnail::v3::Method; - const METADATA: Metadata = metadata! { method: GET, rate_limited: true, @@ -63,8 +62,8 @@ pub mod v1 { #[ruma_api(query)] #[serde( with = "ruma_common::serde::duration::ms", - default = "crate::media::default_download_timeout", - skip_serializing_if = "crate::media::is_default_download_timeout" + default = "ruma_common::media::default_download_timeout", + skip_serializing_if = "ruma_common::media::is_default_download_timeout" )] pub timeout_ms: Duration, @@ -105,7 +104,7 @@ pub mod v1 { method: None, width, height, - timeout_ms: crate::media::default_download_timeout(), + timeout_ms: ruma_common::media::default_download_timeout(), animated: None, } } diff --git a/crates/ruma-client-api/src/delayed_events.rs b/crates/ruma-client-api/src/delayed_events.rs new file mode 100644 index 00000000..b5b9e0c8 --- /dev/null +++ b/crates/ruma-client-api/src/delayed_events.rs @@ -0,0 +1,37 @@ +//! Endpoints for sending and interacting with delayed events. + +pub mod delayed_message_event; +pub mod delayed_state_event; +pub mod update_delayed_event; + +use serde::{Deserialize, Serialize}; +use web_time::Duration; + +/// The query parameters for a delayed event request. +/// It contains the `timeout` configuration for a delayed event. +/// +/// ### Note: +/// +/// This is an Enum since the following properties might be added: +/// +/// The **Timeout** case might get an additional optional `delay_parent_id` property. +/// The optional parent id is used to create a secondary timeout. +/// In a delay group with two timeouts only one of them will ever be sent. +/// +/// The **Action** case might be added: +/// Adds an additional action to a delay event without a timeout but requires a `delay_id` (of the +/// parent delay event). A possible matrix event that can be send as an alternative to the parent +/// delay. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +#[serde(untagged)] +pub enum DelayParameters { + /// Sending a delayed event with a timeout. The response will contain a (server + /// generated) `delay_id` instead of an `event_id`. + Timeout { + /// The timeout duration for this delayed event. + #[serde(with = "ruma_common::serde::duration::ms")] + #[serde(rename = "org.matrix.msc4140.delay")] + timeout: Duration, + }, +} diff --git a/crates/ruma-client-api/src/future/send_future_message_event.rs b/crates/ruma-client-api/src/delayed_events/delayed_message_event.rs similarity index 61% rename from crates/ruma-client-api/src/future/send_future_message_event.rs rename to crates/ruma-client-api/src/delayed_events/delayed_message_event.rs index 402a1725..841ead10 100644 --- a/crates/ruma-client-api/src/future/send_future_message_event.rs +++ b/crates/ruma-client-api/src/delayed_events/delayed_message_event.rs @@ -1,6 +1,6 @@ -//! `PUT /_matrix/client/*/rooms/{roomId}/send_future/{eventType}/{txnId}` +//! `PUT /_matrix/client/*/rooms/{roomId}/send/{eventType}/{txnId}` //! -//! Send a future (a scheduled message) to a room. [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) +//! Send a delayed event (a scheduled message) to a room. [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) pub mod unstable { //! `msc4140` ([MSC]) @@ -16,17 +16,18 @@ pub mod unstable { use ruma_events::{AnyMessageLikeEventContent, MessageLikeEventContent, MessageLikeEventType}; use serde_json::value::to_raw_value as to_raw_json_value; - use crate::future::FutureParameters; + use crate::delayed_events::DelayParameters; const METADATA: Metadata = metadata! { method: PUT, rate_limited: false, authentication: AccessToken, history: { - unstable => "/_matrix/client/unstable/org.matrix.msc4140/rooms/:room_id/send_future/:event_type/:txn_id", + // We use the unstable prefix for the delay query parameter but the stable v3 endpoint. + unstable => "/_matrix/client/v3/rooms/:room_id/send/:event_type/:txn_id", } }; - /// Request type for the [`send_future_message_event`](crate::future::send_future_message_event) + /// Request type for the [`delayed_message_event`](crate::delayed_events::delayed_message_event) /// endpoint. #[request(error = crate::Error)] pub struct Request { @@ -50,12 +51,9 @@ pub mod unstable { #[ruma_api(path)] pub txn_id: OwnedTransactionId, - /// Additional parameters to describe sending a future. - /// - /// Only three combinations for `future_timeout` and `future_group_id` are possible. - /// The enum [`FutureParameters`] enforces this. + /// The timeout duration for this delayed event. #[ruma_api(query_all)] - pub future_parameters: FutureParameters, + pub delay_parameters: DelayParameters, /// The event content to send. #[ruma_api(body)] @@ -63,26 +61,15 @@ pub mod unstable { } /// Response type for the - /// [`send_future_message_event`](crate::future::send_future_message_event) endpoint. + /// [`delayed_message_event`](crate::delayed_events::delayed_message_event) endpoint. #[response(error = crate::Error)] pub struct Response { - /// A token to send/insert the future into the DAG. - pub send_token: String, - /// A token to cancel this future. It will never be send if this is called. - pub cancel_token: String, - /// The `future_group_id` generated for this future. Used to connect multiple futures - /// only one of the connected futures will be sent and inserted into the DAG. - pub future_group_id: String, - /// A token used to refresh the timer of the future. This allows - /// to implement heartbeat like capabilities. An event is only sent once - /// a refresh in the timeout interval is missed. - /// - /// If the future does not have a timeout this will be `None`. - pub refresh_token: Option, + /// The `delay_id` generated for this delayed event. Used to interact with delayed events. + pub delay_id: String, } impl Request { - /// Creates a new `Request` with the given room id, transaction id future_parameters and + /// Creates a new `Request` with the given room id, transaction id, `delay_parameters` and /// event content. /// /// # Errors @@ -92,7 +79,7 @@ pub mod unstable { pub fn new( room_id: OwnedRoomId, txn_id: OwnedTransactionId, - future_parameters: FutureParameters, + delay_parameters: DelayParameters, content: &T, ) -> serde_json::Result where @@ -102,34 +89,29 @@ pub mod unstable { room_id, txn_id, event_type: content.event_type(), - future_parameters, + delay_parameters, body: Raw::from_json(to_raw_json_value(content)?), }) } /// Creates a new `Request` with the given room id, transaction id, event type, - /// future parameters and raw event content. + /// `delay_parameters` and raw event content. pub fn new_raw( room_id: OwnedRoomId, txn_id: OwnedTransactionId, event_type: MessageLikeEventType, - future_parameters: FutureParameters, + delay_parameters: DelayParameters, body: Raw, ) -> Self { - Self { room_id, event_type, txn_id, future_parameters, body } + Self { room_id, event_type, txn_id, delay_parameters, body } } } impl Response { - /// Creates a new `Response` with the tokens required to control the future using the - /// [`crate::future::update_future::unstable::Request`] request. - pub fn new( - send_token: String, - cancel_token: String, - future_group_id: String, - refresh_token: Option, - ) -> Self { - Self { send_token, cancel_token, future_group_id, refresh_token } + /// Creates a new `Response` with the tokens required to control the delayed event using the + /// [`crate::delayed_events::update_delayed_event::unstable::Request`] request. + pub fn new(delay_id: String) -> Self { + Self { delay_id } } } @@ -144,19 +126,16 @@ pub mod unstable { use web_time::Duration; use super::Request; - use crate::future::send_future_message_event::unstable::FutureParameters; + use crate::delayed_events::delayed_message_event::unstable::DelayParameters; #[test] - fn serialize_message_future_request() { + fn serialize_delayed_message_request() { let room_id = owned_room_id!("!roomid:example.org"); let req = Request::new( room_id, "1234".into(), - FutureParameters::Timeout { - timeout: Duration::from_millis(103), - group_id: Some("testId".to_owned()), - }, + DelayParameters::Timeout { timeout: Duration::from_millis(103) }, &RoomMessageEventContent::text_plain("test"), ) .unwrap(); @@ -169,7 +148,7 @@ pub mod unstable { .unwrap(); let (parts, body) = request.into_parts(); assert_eq!( - "https://homeserver.tld/_matrix/client/unstable/org.matrix.msc4140/rooms/!roomid:example.org/send_future/m.room.message/1234?future_timeout=103&future_group_id=testId", + "https://homeserver.tld/_matrix/client/v3/rooms/!roomid:example.org/send/m.room.message/1234?org.matrix.msc4140.delay=103", parts.uri.to_string() ); assert_eq!("PUT", parts.method.to_string()); diff --git a/crates/ruma-client-api/src/delayed_events/delayed_state_event.rs b/crates/ruma-client-api/src/delayed_events/delayed_state_event.rs new file mode 100644 index 00000000..6ea3d100 --- /dev/null +++ b/crates/ruma-client-api/src/delayed_events/delayed_state_event.rs @@ -0,0 +1,162 @@ +//! `PUT /_matrix/client/*/rooms/{roomId}/state/{eventType}/{txnId}` +//! +//! Send a delayed state event (a scheduled state event) to a room. [MSC](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) + +pub mod unstable { + //! `msc4140` ([MSC]) + //! + //! [MSC]: https://github.com/matrix-org/matrix-spec-proposals/pull/4140 + + use ruma_common::{ + api::{request, response, Metadata}, + metadata, + serde::Raw, + OwnedRoomId, + }; + use ruma_events::{AnyStateEventContent, StateEventContent, StateEventType}; + use serde_json::value::to_raw_value as to_raw_json_value; + + use crate::delayed_events::DelayParameters; + + const METADATA: Metadata = metadata! { + method: PUT, + rate_limited: false, + authentication: AccessToken, + history: { + // We use the unstable prefix for the delay query parameter but the stable v3 endpoint. + unstable => "/_matrix/client/v3/rooms/:room_id/state/:event_type/:state_key", + } + }; + + /// Request type for the [`delayed_state_event`](crate::delayed_events::delayed_state_event) + /// endpoint. + #[request(error = crate::Error)] + pub struct Request { + /// The room to send the event to. + #[ruma_api(path)] + pub room_id: OwnedRoomId, + + /// The type of event to send. + #[ruma_api(path)] + pub event_type: StateEventType, + + /// The state_key for the state to send. + #[ruma_api(path)] + pub state_key: String, + + /// Additional parameters to describe sending a delayed event. + /// + /// Only three combinations for `timeout` and `delay_parent_id` are possible. + /// The enum [`DelayParameters`] enforces this. + #[ruma_api(query_all)] + pub delay_parameters: DelayParameters, + + /// The event content to send. + #[ruma_api(body)] + pub body: Raw, + } + + /// Response type for the [`delayed_state_event`](crate::delayed_events::delayed_state_event) + /// endpoint. + #[response(error = crate::Error)] + pub struct Response { + /// The `delay_id` generated for this delayed event. Used to interact with delayed events. + pub delay_id: String, + } + + impl Request { + /// Creates a new `Request` with the given room id, state_key delay_parameters and + /// event content. + /// + /// # Errors + /// + /// Since `Request` stores the request body in serialized form, this function can fail if + /// `T`s [`::serde::Serialize`] implementation can fail. + pub fn new( + room_id: OwnedRoomId, + state_key: String, + delay_parameters: DelayParameters, + content: &T, + ) -> serde_json::Result + where + T: StateEventContent, + { + Ok(Self { + room_id, + state_key, + event_type: content.event_type(), + delay_parameters, + body: Raw::from_json(to_raw_json_value(content)?), + }) + } + + /// Creates a new `Request` with the given room id, event type, state key, + /// delay parameters and raw event content. + pub fn new_raw( + room_id: OwnedRoomId, + state_key: String, + event_type: StateEventType, + delay_parameters: DelayParameters, + body: Raw, + ) -> Self { + Self { room_id, event_type, state_key, body, delay_parameters } + } + } + + impl Response { + /// Creates a new `Response` with the tokens required to control the delayed event using the + /// [`crate::delayed_events::update_delayed_event::unstable::Request`] request. + pub fn new(delay_id: String) -> Self { + Self { delay_id } + } + } + + #[cfg(all(test, feature = "client"))] + mod tests { + use ruma_common::{ + api::{MatrixVersion, OutgoingRequest, SendAccessToken}, + owned_room_id, + }; + use ruma_events::room::topic::RoomTopicEventContent; + use serde_json::{json, Value as JsonValue}; + use web_time::Duration; + + use super::Request; + use crate::delayed_events::DelayParameters; + + fn create_delayed_event_request( + delay_parameters: DelayParameters, + ) -> (http::request::Parts, Vec) { + Request::new( + owned_room_id!("!roomid:example.org"), + "@userAsStateKey:example.org".to_owned(), + delay_parameters, + &RoomTopicEventContent::new("my_topic".to_owned()), + ) + .unwrap() + .try_into_http_request( + "https://homeserver.tld", + SendAccessToken::IfRequired("auth_tok"), + &[MatrixVersion::V1_1], + ) + .unwrap() + .into_parts() + } + + #[test] + fn serialize_delayed_state_request() { + let (parts, body) = create_delayed_event_request(DelayParameters::Timeout { + timeout: Duration::from_millis(1_234_321), + }); + assert_eq!( + "https://homeserver.tld/_matrix/client/v3/rooms/!roomid:example.org/state/m.room.topic/@userAsStateKey:example.org?org.matrix.msc4140.delay=1234321", + parts.uri.to_string() + ); + assert_eq!("PUT", parts.method.to_string()); + assert_eq!( + json!({"topic": "my_topic"}), + serde_json::from_str::(std::str::from_utf8(&body).unwrap()).unwrap() + ); + } + } +} diff --git a/crates/ruma-client-api/src/delayed_events/update_delayed_event.rs b/crates/ruma-client-api/src/delayed_events/update_delayed_event.rs new file mode 100644 index 00000000..17d966e4 --- /dev/null +++ b/crates/ruma-client-api/src/delayed_events/update_delayed_event.rs @@ -0,0 +1,104 @@ +//! `POST /_matrix/client/*/delayed_events/{delayed_id}` +//! +//! Send a delayed event update. This can be a updateing/canceling/sending the associated delayed +//! event. + +pub mod unstable { + //! `msc3814` ([MSC]) + //! + //! [MSC]: https://github.com/matrix-org/matrix-spec-proposals/pull/4140 + + use ruma_common::{ + api::{request, response, Metadata}, + metadata, + serde::StringEnum, + }; + + use crate::PrivOwnedStr; + + const METADATA: Metadata = metadata! { + method: POST, + rate_limited: true, + authentication: AccessToken, + history: { + unstable => "/_matrix/client/unstable/org.matrix.msc4140/delayed_events/:delay_id", + } + }; + + /// The possible update actions we can do for updating a delayed event. + #[derive(Clone, StringEnum)] + #[ruma_enum(rename_all = "lowercase")] + #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] + pub enum UpdateAction { + /// Restart the delayed event timeout. (heartbeat ping) + Restart, + /// Send the delayed event immediately independent of the timeout state. (deletes all + /// timers) + Send, + /// Delete the delayed event and never send it. (deletes all timers) + Cancel, + + #[doc(hidden)] + _Custom(PrivOwnedStr), + } + /// Request type for the [`update_delayed_event`](crate::delayed_events::update_delayed_event) + /// endpoint. + #[request(error = crate::Error)] + pub struct Request { + /// The delay id that we want to update. + #[ruma_api(path)] + pub delay_id: String, + /// Which kind of update we want to request for the delayed event. + pub action: UpdateAction, + } + + impl Request { + /// Creates a new `Request` to update a delayed event. + pub fn new(delay_id: String, action: UpdateAction) -> Self { + Self { delay_id, action } + } + } + + /// Response type for the [`update_delayed_event`](crate::delayed_events::update_delayed_event) + /// endpoint. + #[response(error = crate::Error)] + pub struct Response {} + impl Response { + /// Creates a new empty response for the + /// [`update_delayed_event`](crate::delayed_events::update_delayed_event) endpoint. + pub fn new() -> Self { + Self {} + } + } + + #[cfg(all(test, feature = "client"))] + mod tests { + use ruma_common::api::{MatrixVersion, OutgoingRequest, SendAccessToken}; + use serde_json::{json, Value as JsonValue}; + + use super::{Request, UpdateAction}; + #[test] + fn serialize_update_delayed_event_request() { + let request: http::Request> = + Request::new("1234".to_owned(), UpdateAction::Cancel) + .try_into_http_request( + "https://homeserver.tld", + SendAccessToken::IfRequired("auth_tok"), + &[MatrixVersion::V1_1], + ) + .unwrap(); + + let (parts, body) = request.into_parts(); + + assert_eq!( + "https://homeserver.tld/_matrix/client/unstable/org.matrix.msc4140/delayed_events/1234", + parts.uri.to_string() + ); + assert_eq!("POST", parts.method.to_string()); + assert_eq!( + json!({"action": "cancel"}), + serde_json::from_str::(std::str::from_utf8(&body).unwrap()).unwrap() + ); + } + } +} diff --git a/crates/ruma-client-api/src/future.rs b/crates/ruma-client-api/src/future.rs deleted file mode 100644 index e2ee06ab..00000000 --- a/crates/ruma-client-api/src/future.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Endpoints for sending and receiving futures - -pub mod send_future_message_event; -pub mod send_future_state_event; -pub mod update_future; - -use serde::{Deserialize, Serialize}; -use web_time::Duration; - -/// The query parameters for a future request. -/// It can contain the possible timeout and the future_group_id combinations. -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] -#[serde(untagged)] -pub enum FutureParameters { - /// Only sending the timeout creates a timeout future with a new (server generated) - /// group id. The optional group id is used to create a secondary timeout. - /// In a future group with two timeouts only one of them will ever be sent. - Timeout { - /// The timeout duration for this Future. - #[serde(with = "ruma_common::serde::duration::ms")] - #[serde(rename = "future_timeout")] - timeout: Duration, - /// The associated group for this Future. - #[serde(skip_serializing_if = "Option::is_none")] - #[serde(rename = "future_group_id")] - group_id: Option, - }, - - /// Adds an additional action to a future without a timeout but requires a future group_id. - /// A possible matrix event that this future group can resolve to. It can be sent by using the - /// send_token as an alternative to the timeout future of an already existing group. - Action { - /// The associated group for this Future. - #[serde(rename = "future_group_id")] - group_id: String, - }, -} diff --git a/crates/ruma-client-api/src/future/send_future_state_event.rs b/crates/ruma-client-api/src/future/send_future_state_event.rs deleted file mode 100644 index 0d439be3..00000000 --- a/crates/ruma-client-api/src/future/send_future_state_event.rs +++ /dev/null @@ -1,175 +0,0 @@ -//! `PUT /_matrix/client/*/rooms/{roomId}/state_future/{eventType}/{txnId}` -//! -//! Send a future state (a scheduled state event) to a room. [MSC](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) - -pub mod unstable { - //! `msc4140` ([MSC]) - //! - //! [MSC]: https://github.com/matrix-org/matrix-spec-proposals/pull/4140 - - use ruma_common::{ - api::{request, response, Metadata}, - metadata, - serde::Raw, - OwnedRoomId, - }; - use ruma_events::{AnyStateEventContent, StateEventContent, StateEventType}; - use serde_json::value::to_raw_value as to_raw_json_value; - - use crate::future::FutureParameters; - - const METADATA: Metadata = metadata! { - method: PUT, - rate_limited: false, - authentication: AccessToken, - history: { - unstable => "/_matrix/client/unstable/org.matrix.msc4140/rooms/:room_id/state_future/:event_type/:state_key", - } - }; - - /// Request type for the [`send_future_state_event`](crate::future::send_future_state_event) - /// endpoint. - #[request(error = crate::Error)] - pub struct Request { - /// The room to send the event to. - #[ruma_api(path)] - pub room_id: OwnedRoomId, - - /// The type of event to send. - #[ruma_api(path)] - pub event_type: StateEventType, - - /// The state_key for the state to send. - #[ruma_api(path)] - pub state_key: String, - - /// Additional parameters to describe sending a future. - /// - /// Only three combinations for `future_timeout` and `future_group_id` are possible. - /// The enum [`FutureParameters`] enforces this. - #[ruma_api(query_all)] - pub future_parameters: FutureParameters, - - /// The event content to send. - #[ruma_api(body)] - pub body: Raw, - } - - /// Response type for the [`send_future_state_event`](crate::future::send_future_state_event) - /// endpoint. - #[response(error = crate::Error)] - pub struct Response { - /// A token to send/insert the future into the DAG. - pub send_token: String, - /// A token to cancel this future. It will never be send if this is called. - pub cancel_token: String, - /// The `future_group_id` generated for this future. Used to connect multiple futures - /// only one of the connected futures will be sent and inserted into the DAG. - pub future_group_id: String, - /// A token used to refresh the timer of the future. This allows - /// to implement heardbeat like capabilities. An event is only send once - /// a refresh in the timeout interval is missed. - /// - /// If the future does not have a timeout this will be `None`. - pub refresh_token: Option, - } - - impl Request { - /// Creates a new `Request` with the given room id, state_key future_parameters and - /// event content. - /// - /// # Errors - /// - /// Since `Request` stores the request body in serialized form, this function can fail if - /// `T`s [`::serde::Serialize`] implementation can fail. - pub fn new( - room_id: OwnedRoomId, - state_key: String, - future_parameters: FutureParameters, - content: &T, - ) -> serde_json::Result - where - T: StateEventContent, - { - Ok(Self { - room_id, - state_key, - event_type: content.event_type(), - future_parameters, - body: Raw::from_json(to_raw_json_value(content)?), - }) - } - - /// Creates a new `Request` with the given room id, event type, state key, - /// future parameters and raw event content. - pub fn new_raw( - room_id: OwnedRoomId, - state_key: String, - event_type: StateEventType, - future_parameters: FutureParameters, - body: Raw, - ) -> Self { - Self { room_id, event_type, state_key, body, future_parameters } - } - } - - impl Response { - /// Creates a new `Response` with the tokens required to control the future using the - /// [`crate::future::update_future::unstable::Request`] request. - pub fn new( - send_token: String, - cancel_token: String, - future_group_id: String, - refresh_token: Option, - ) -> Self { - Self { send_token, cancel_token, future_group_id, refresh_token } - } - } - - #[cfg(all(test, feature = "client"))] - mod tests { - use ruma_common::{ - api::{MatrixVersion, OutgoingRequest, SendAccessToken}, - owned_room_id, - }; - use ruma_events::room::topic::RoomTopicEventContent; - use serde_json::{json, Value as JsonValue}; - use web_time::Duration; - - use super::Request; - use crate::future::FutureParameters; - - #[test] - fn serialize_state_future_request() { - let room_id = owned_room_id!("!roomid:example.org"); - - let req = Request::new( - room_id, - "@userAsStateKey:example.org".to_owned(), - FutureParameters::Timeout { - timeout: Duration::from_millis(1_234_321), - group_id: Some("abs1abs1abs1abs1".to_owned()), - }, - &RoomTopicEventContent::new("my_topic".to_owned()), - ) - .unwrap(); - let request: http::Request> = req - .try_into_http_request( - "https://homeserver.tld", - SendAccessToken::IfRequired("auth_tok"), - &[MatrixVersion::V1_1], - ) - .unwrap(); - let (parts, body) = request.into_parts(); - assert_eq!( - "https://homeserver.tld/_matrix/client/unstable/org.matrix.msc4140/rooms/!roomid:example.org/state_future/m.room.topic/@userAsStateKey:example.org?future_timeout=1234321&future_group_id=abs1abs1abs1abs1", - parts.uri.to_string() - ); - assert_eq!("PUT", parts.method.to_string()); - assert_eq!( - json!({"topic": "my_topic"}), - serde_json::from_str::(std::str::from_utf8(&body).unwrap()).unwrap() - ); - } - } -} diff --git a/crates/ruma-client-api/src/future/update_future.rs b/crates/ruma-client-api/src/future/update_future.rs deleted file mode 100644 index 419214b6..00000000 --- a/crates/ruma-client-api/src/future/update_future.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! `POST /_matrix/client/*/futures/{token}` -//! -//! Send a future token to update/cancel/send the associated future event. - -pub mod unstable { - //! `msc3814` ([MSC]) - //! - //! [MSC]: https://github.com/matrix-org/matrix-spec-proposals/pull/4140 - - use ruma_common::{ - api::{request, response, Metadata}, - metadata, - }; - - const METADATA: Metadata = metadata! { - method: POST, - rate_limited: true, - authentication: None, - history: { - unstable => "/_matrix/client/unstable/org.matrix.msc4140/future/:token", - } - }; - - /// Request type for the [`update_future`](crate::future::update_future) endpoint. - #[request(error = crate::Error)] - pub struct Request { - /// The token. - #[ruma_api(path)] - pub token: String, - } - - impl Request { - /// Creates a new `Request` to update a future. This is an unauthenticated request and only - /// requires the future token. - pub fn new(token: String) -> serde_json::Result { - Ok(Self { token }) - } - } - - /// Response type for the [`update_future`](crate::future::update_future) endpoint. - #[response(error = crate::Error)] - pub struct Response {} - impl Response { - /// Creates a new response for the [`update_future`](crate::future::update_future) endpoint. - pub fn new() -> Self { - Response {} - } - } -} 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/lib.rs b/crates/ruma-client-api/src/lib.rs index 71ba4812..594e4be5 100644 --- a/crates/ruma-client-api/src/lib.rs +++ b/crates/ruma-client-api/src/lib.rs @@ -18,13 +18,13 @@ pub mod config; pub mod context; #[cfg(feature = "unstable-msc3814")] pub mod dehydrated_device; +#[cfg(feature = "unstable-msc4140")] +pub mod delayed_events; pub mod device; pub mod directory; pub mod discovery; pub mod error; pub mod filter; -#[cfg(feature = "unstable-msc4140")] -pub mod future; pub mod http_headers; pub mod keys; pub mod knock; diff --git a/crates/ruma-client-api/src/media.rs b/crates/ruma-client-api/src/media.rs index 78729a7e..c1a48657 100644 --- a/crates/ruma-client-api/src/media.rs +++ b/crates/ruma-client-api/src/media.rs @@ -1,7 +1,5 @@ //! Endpoints for the media repository. -use std::time::Duration; - pub mod create_content; pub mod create_content_async; pub mod create_mxc_uri; @@ -10,14 +8,3 @@ pub mod get_content_as_filename; pub mod get_content_thumbnail; pub mod get_media_config; pub mod get_media_preview; - -/// The default duration that the client should be willing to wait to start receiving data. -pub(crate) fn default_download_timeout() -> Duration { - Duration::from_secs(20) -} - -/// Whether the given duration is the default duration that the client should be willing to wait to -/// start receiving data. -pub(crate) fn is_default_download_timeout(timeout: &Duration) -> bool { - timeout.as_secs() == 20 -} diff --git a/crates/ruma-client-api/src/media/get_content.rs b/crates/ruma-client-api/src/media/get_content.rs index fe1066db..cb4787d3 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::{CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_TYPE}; use ruma_common::{ api::{request, response, Metadata}, + http_headers::ContentDisposition, metadata, IdParseError, MxcUri, OwnedServerName, }; @@ -60,8 +61,8 @@ pub mod v3 { #[ruma_api(query)] #[serde( with = "ruma_common::serde::duration::ms", - default = "crate::media::default_download_timeout", - skip_serializing_if = "crate::media::is_default_download_timeout" + default = "ruma_common::media::default_download_timeout", + skip_serializing_if = "ruma_common::media::is_default_download_timeout" )] pub timeout_ms: Duration, @@ -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. /// @@ -119,7 +116,7 @@ pub mod v3 { media_id, server_name, allow_remote: true, - timeout_ms: crate::media::default_download_timeout(), + timeout_ms: ruma_common::media::default_download_timeout(), allow_redirect: false, } } 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 409184bc..af919d66 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::{CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_TYPE}; use ruma_common::{ api::{request, response, Metadata}, + http_headers::ContentDisposition, metadata, IdParseError, MxcUri, OwnedServerName, }; @@ -64,8 +65,8 @@ pub mod v3 { #[ruma_api(query)] #[serde( with = "ruma_common::serde::duration::ms", - default = "crate::media::default_download_timeout", - skip_serializing_if = "crate::media::is_default_download_timeout" + default = "ruma_common::media::default_download_timeout", + skip_serializing_if = "ruma_common::media::is_default_download_timeout" )] pub timeout_ms: Duration, @@ -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. /// @@ -124,7 +121,7 @@ pub mod v3 { server_name, filename, allow_remote: true, - timeout_ms: crate::media::default_download_timeout(), + timeout_ms: ruma_common::media::default_download_timeout(), allow_redirect: false, } } diff --git a/crates/ruma-client-api/src/media/get_content_thumbnail.rs b/crates/ruma-client-api/src/media/get_content_thumbnail.rs index 3a004603..bcbd12a3 100644 --- a/crates/ruma-client-api/src/media/get_content_thumbnail.rs +++ b/crates/ruma-client-api/src/media/get_content_thumbnail.rs @@ -11,14 +11,13 @@ pub mod v3 { use http::header::{CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_TYPE}; use js_int::UInt; + pub use ruma_common::media::Method; use ruma_common::{ api::{request, response, Metadata}, - metadata, - serde::StringEnum, - IdParseError, MxcUri, OwnedServerName, + metadata, IdParseError, MxcUri, OwnedServerName, }; - use crate::{http_headers::CROSS_ORIGIN_RESOURCE_POLICY, PrivOwnedStr}; + use crate::http_headers::CROSS_ORIGIN_RESOURCE_POLICY; const METADATA: Metadata = metadata! { method: GET, @@ -80,8 +79,8 @@ pub mod v3 { #[ruma_api(query)] #[serde( with = "ruma_common::serde::duration::ms", - default = "crate::media::default_download_timeout", - skip_serializing_if = "crate::media::is_default_download_timeout" + default = "ruma_common::media::default_download_timeout", + skip_serializing_if = "ruma_common::media::is_default_download_timeout" )] pub timeout_ms: Duration, @@ -157,7 +156,7 @@ pub mod v3 { width, height, allow_remote: true, - timeout_ms: crate::media::default_download_timeout(), + timeout_ms: ruma_common::media::default_download_timeout(), allow_redirect: false, animated: None, } @@ -186,20 +185,4 @@ pub mod v3 { } } } - - /// The desired resizing method. - #[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))] - #[derive(Clone, StringEnum)] - #[ruma_enum(rename_all = "snake_case")] - #[non_exhaustive] - pub enum Method { - /// Crop the original to produce the requested image dimensions. - Crop, - - /// Maintain the original aspect ratio of the source image. - Scale, - - #[doc(hidden)] - _Custom(PrivOwnedStr), - } } diff --git a/crates/ruma-client-api/src/session/logout.rs b/crates/ruma-client-api/src/session/logout.rs index 02c3a0ce..aae53243 100644 --- a/crates/ruma-client-api/src/session/logout.rs +++ b/crates/ruma-client-api/src/session/logout.rs @@ -8,7 +8,7 @@ pub mod v3 { //! [spec]: https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3logout use ruma_common::{ - api::{request, response, Metadata}, + api::{response, Metadata}, metadata, }; @@ -23,15 +23,10 @@ pub mod v3 { }; /// Request type for the `logout` endpoint. - #[request(error = crate::Error)] - #[derive(Default)] + #[derive(Debug, Clone, Default)] + #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] pub struct Request {} - /// Response type for the `logout` endpoint. - #[response(error = crate::Error)] - #[derive(Default)] - pub struct Response {} - impl Request { /// Creates an empty `Request`. pub fn new() -> Self { @@ -39,6 +34,69 @@ pub mod v3 { } } + #[cfg(feature = "client")] + impl ruma_common::api::OutgoingRequest for Request { + type EndpointError = crate::Error; + type IncomingResponse = Response; + + const METADATA: Metadata = METADATA; + + fn try_into_http_request( + self, + base_url: &str, + access_token: ruma_common::api::SendAccessToken<'_>, + considering_versions: &'_ [ruma_common::api::MatrixVersion], + ) -> Result, ruma_common::api::error::IntoHttpError> { + let url = METADATA.make_endpoint_url(considering_versions, base_url, &[], "")?; + + http::Request::builder() + .method(METADATA.method) + .uri(url) + .header( + http::header::AUTHORIZATION, + format!( + "Bearer {}", + access_token + .get_required_for_endpoint() + .ok_or(ruma_common::api::error::IntoHttpError::NeedsAuthentication)?, + ), + ) + .body(T::default()) + .map_err(Into::into) + } + } + + #[cfg(feature = "server")] + impl ruma_common::api::IncomingRequest for Request { + type EndpointError = crate::Error; + type OutgoingResponse = Response; + + const METADATA: Metadata = METADATA; + + fn try_from_http_request( + request: http::Request, + _path_args: &[S], + ) -> Result + where + B: AsRef<[u8]>, + S: AsRef, + { + if request.method() != METADATA.method { + return Err(ruma_common::api::error::FromHttpRequestError::MethodMismatch { + expected: METADATA.method, + received: request.method().clone(), + }); + } + + Ok(Self {}) + } + } + + /// Response type for the `logout` endpoint. + #[response(error = crate::Error)] + #[derive(Default)] + pub struct Response {} + impl Response { /// Creates an empty `Response`. pub fn new() -> Self { diff --git a/crates/ruma-client-api/src/session/logout_all.rs b/crates/ruma-client-api/src/session/logout_all.rs index 794f1e08..abbc7d63 100644 --- a/crates/ruma-client-api/src/session/logout_all.rs +++ b/crates/ruma-client-api/src/session/logout_all.rs @@ -8,7 +8,7 @@ pub mod v3 { //! [spec]: https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3logoutall use ruma_common::{ - api::{request, response, Metadata}, + api::{response, Metadata}, metadata, }; @@ -23,15 +23,10 @@ pub mod v3 { }; /// Request type for the `logout_all` endpoint. - #[request(error = crate::Error)] - #[derive(Default)] + #[derive(Debug, Clone, Default)] + #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] pub struct Request {} - /// Response type for the `logout_all` endpoint. - #[response(error = crate::Error)] - #[derive(Default)] - pub struct Response {} - impl Request { /// Creates an empty `Request`. pub fn new() -> Self { @@ -39,6 +34,69 @@ pub mod v3 { } } + #[cfg(feature = "client")] + impl ruma_common::api::OutgoingRequest for Request { + type EndpointError = crate::Error; + type IncomingResponse = Response; + + const METADATA: Metadata = METADATA; + + fn try_into_http_request( + self, + base_url: &str, + access_token: ruma_common::api::SendAccessToken<'_>, + considering_versions: &'_ [ruma_common::api::MatrixVersion], + ) -> Result, ruma_common::api::error::IntoHttpError> { + let url = METADATA.make_endpoint_url(considering_versions, base_url, &[], "")?; + + http::Request::builder() + .method(METADATA.method) + .uri(url) + .header( + http::header::AUTHORIZATION, + format!( + "Bearer {}", + access_token + .get_required_for_endpoint() + .ok_or(ruma_common::api::error::IntoHttpError::NeedsAuthentication)?, + ), + ) + .body(T::default()) + .map_err(Into::into) + } + } + + #[cfg(feature = "server")] + impl ruma_common::api::IncomingRequest for Request { + type EndpointError = crate::Error; + type OutgoingResponse = Response; + + const METADATA: Metadata = METADATA; + + fn try_from_http_request( + request: http::Request, + _path_args: &[S], + ) -> Result + where + B: AsRef<[u8]>, + S: AsRef, + { + if request.method() != METADATA.method { + return Err(ruma_common::api::error::FromHttpRequestError::MethodMismatch { + expected: METADATA.method, + received: request.method().clone(), + }); + } + + Ok(Self {}) + } + } + + /// Response type for the `logout_all` endpoint. + #[response(error = crate::Error)] + #[derive(Default)] + pub struct Response {} + impl Response { /// Creates an empty `Response`. pub fn new() -> Self { diff --git a/crates/ruma-client/Cargo.toml b/crates/ruma-client/Cargo.toml index 74358c9f..80c0f1ce 100644 --- a/crates/ruma-client/Cargo.toml +++ b/crates/ruma-client/Cargo.toml @@ -33,7 +33,7 @@ reqwest-rustls-native-roots = ["reqwest", "reqwest?/rustls-tls-native-roots"] as_variant = { workspace = true, optional = true } assign = { workspace = true } async-stream = "0.3.0" -bytes = "1.0.1" +bytes = { workspace = true } futures-core = "0.3.8" http = { workspace = true } http-body-util = { version = "0.1.1", optional = true } diff --git a/crates/ruma-common/CHANGELOG.md b/crates/ruma-common/CHANGELOG.md index 0fb96ca7..4f979bda 100644 --- a/crates/ruma-common/CHANGELOG.md +++ b/crates/ruma-common/CHANGELOG.md @@ -13,6 +13,8 @@ Breaking changes: This allows to use a struct or enum as well as a map to represent the list of query parameters. Note that the (de)serialization of the type used must work with `serde_html_form`. +- The `header` attribute for the `request` and `response` macros accepts any + type that implements `ToString` and `FromStr`. Improvements: @@ -25,6 +27,7 @@ Improvements: parameter is deprecated, according to MSC4126 / Matrix 1.11. - Constructing a Matrix URI for an event with a room alias is deprecated, according to MSC4132 / Matrix 1.11 +- Implement `Eq` and `PartialEq` for `Metadata` # 0.13.0 diff --git a/crates/ruma-common/Cargo.toml b/crates/ruma-common/Cargo.toml index 5639a760..2da01aa4 100644 --- a/crates/ruma-common/Cargo.toml +++ b/crates/ruma-common/Cargo.toml @@ -59,7 +59,7 @@ compat-optional = [] [dependencies] as_variant = { workspace = true } base64 = { workspace = true } -bytes = "1.0.1" +bytes = { workspace = true } form_urlencoded = "1.0.0" getrandom = { version = "0.2.6", optional = true } http = { workspace = true, optional = true } @@ -71,11 +71,8 @@ konst = { version = "0.3.5", default-features = false, features = [ "parsing", ], optional = true } percent-encoding = "2.1.0" -rand = { version = "0.8.3", optional = true } -regex = { version = "1.5.6", default-features = false, features = [ - "std", - "perf", -] } +rand = { workspace = true, optional = true } +regex = { version = "1.5.6", default-features = false, features = ["std", "perf"] } ruma-identifiers-validation = { workspace = true } ruma-macros = { workspace = true } serde = { workspace = true } diff --git a/crates/ruma-common/src/api.rs b/crates/ruma-common/src/api.rs index c9a2ecdc..672f0759 100644 --- a/crates/ruma-common/src/api.rs +++ b/crates/ruma-common/src/api.rs @@ -113,10 +113,12 @@ macro_rules! metadata { /// To declare which part of the request a field belongs to: /// /// * `#[ruma_api(header = HEADER_NAME)]`: Fields with this attribute will be treated as HTTP -/// headers on the request. The value must implement `Display`. Generally this is a `String`. -/// The attribute value shown above as `HEADER_NAME` must be a `const` expression of the type -/// `http::header::HeaderName`, like one of the constants from `http::header`, e.g. -/// `CONTENT_TYPE`. +/// headers on the request. The value must implement `ToString` and `FromStr`. Generally this +/// is a `String`. The attribute value shown above as `HEADER_NAME` must be a `const` +/// expression of the type `http::header::HeaderName`, like one of the constants from +/// `http::header`, e.g. `CONTENT_TYPE`. During deserialization of the request, if the field +/// is an `Option` and parsing the header fails, the error will be ignored and the value will +/// be `None`. /// * `#[ruma_api(path)]`: Fields with this attribute will be inserted into the matching path /// component of the request URL. If there are multiple of these fields, the order in which /// they are declared must match the order in which they occur in the request path. @@ -230,9 +232,11 @@ pub use ruma_macros::request; /// To declare which part of the response a field belongs to: /// /// * `#[ruma_api(header = HEADER_NAME)]`: Fields with this attribute will be treated as HTTP -/// headers on the response. The value must implement `Display`. Generally this is a -/// `String`. The attribute value shown above as `HEADER_NAME` must be a header name constant -/// from `http::header`, e.g. `CONTENT_TYPE`. +/// headers on the response. The value must implement `ToString` and `FromStr`. Generally +/// this is a `String`. The attribute value shown above as `HEADER_NAME` must be a header +/// name constant from `http::header`, e.g. `CONTENT_TYPE`. During deserialization of the +/// response, if the field is an `Option` and parsing the header fails, the error will be +/// ignored and the value will be `None`. /// * No attribute: Fields without an attribute are part of the body. They can use `#[serde]` /// attributes to customize (de)serialization. /// * `#[ruma_api(body)]`: Use this if multiple endpoints should share a response body type, or diff --git a/crates/ruma-common/src/api/error.rs b/crates/ruma-common/src/api/error.rs index f60052fb..d10190ab 100644 --- a/crates/ruma-common/src/api/error.rs +++ b/crates/ruma-common/src/api/error.rs @@ -243,6 +243,10 @@ pub enum DeserializationError { /// Header value deserialization failed. #[error(transparent)] Header(#[from] HeaderDeserializationError), + + /// Deserialization of `multipart/mixed` response failed. + #[error(transparent)] + MultipartMixed(#[from] MultipartMixedDeserializationError), } impl From for DeserializationError { @@ -277,6 +281,10 @@ pub enum HeaderDeserializationError { #[error("missing header `{0}`")] MissingHeader(String), + /// The given header failed to parse. + #[error("invalid header: {0}")] + InvalidHeader(Box), + /// A header was received with a unexpected value. #[error( "The {header} header was received with an unexpected value, \ @@ -290,6 +298,42 @@ pub enum HeaderDeserializationError { /// The value we instead received and rejected. unexpected: String, }, + + /// The `Content-Type` header for a `multipart/mixed` response is missing the `boundary` + /// attribute. + #[error( + "The `Content-Type` header for a `multipart/mixed` response is missing the `boundary` attribute" + )] + MissingMultipartBoundary, +} + +/// An error when deserializing a `multipart/mixed` response. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum MultipartMixedDeserializationError { + /// There were not the number of body parts that were expected. + #[error( + "multipart/mixed response does not have enough body parts, \ + expected {expected}, found {found}" + )] + MissingBodyParts { + /// The number of body parts expected in the response. + expected: usize, + /// The number of body parts found in the received response. + found: usize, + }, + + /// The separator between the headers and the content of a body part is missing. + #[error("multipart/mixed body part is missing separator between headers and content")] + MissingBodyPartInnerSeparator, + + /// The separator between a header's name and value is missing. + #[error("multipart/mixed body part header is missing separator between name and value")] + MissingHeaderSeparator, + + /// A header failed to parse. + #[error("invalid multipart/mixed header: {0}")] + InvalidHeader(Box), } /// An error that happens when Ruma cannot understand a Matrix version. diff --git a/crates/ruma-common/src/api/metadata.rs b/crates/ruma-common/src/api/metadata.rs index 1a877aca..398b8b46 100644 --- a/crates/ruma-common/src/api/metadata.rs +++ b/crates/ruma-common/src/api/metadata.rs @@ -19,7 +19,7 @@ use super::{ use crate::{percent_encode::PATH_PERCENT_ENCODE_SET, serde::slice_to_buf, RoomVersionId}; /// Metadata about an API endpoint. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] #[allow(clippy::exhaustive_structs)] pub struct Metadata { /// The HTTP method used by this endpoint. @@ -141,7 +141,7 @@ impl Metadata { /// versions stable and unstable. /// /// The amount and positioning of path variables are the same over all path variants. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] #[allow(clippy::exhaustive_structs)] pub struct VersionHistory { /// A list of unstable paths over this endpoint's history. 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..c0b9a684 100644 --- a/crates/ruma-common/src/lib.rs +++ b/crates/ruma-common/src/lib.rs @@ -24,7 +24,9 @@ pub mod authentication; pub mod canonical_json; pub mod directory; pub mod encryption; +pub mod http_headers; mod identifiers; +pub mod media; mod percent_encode; pub mod power_levels; pub mod presence; diff --git a/crates/ruma-common/src/media.rs b/crates/ruma-common/src/media.rs new file mode 100644 index 00000000..ff163d56 --- /dev/null +++ b/crates/ruma-common/src/media.rs @@ -0,0 +1,34 @@ +//! Common types and functions for the [content repository]. +//! +//! [content repository]: https://spec.matrix.org/latest/client-server-api/#content-repository + +use std::time::Duration; + +use crate::{serde::StringEnum, PrivOwnedStr}; + +/// The desired resizing method for a thumbnail. +#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))] +#[derive(Clone, StringEnum)] +#[ruma_enum(rename_all = "snake_case")] +#[non_exhaustive] +pub enum Method { + /// Crop the original to produce the requested image dimensions. + Crop, + + /// Maintain the original aspect ratio of the source image. + Scale, + + #[doc(hidden)] + _Custom(PrivOwnedStr), +} + +/// The default duration that the client should be willing to wait to start receiving data. +pub fn default_download_timeout() -> Duration { + Duration::from_secs(20) +} + +/// Whether the given duration is the default duration that the client should be willing to wait to +/// start receiving data. +pub fn is_default_download_timeout(timeout: &Duration) -> bool { + timeout.as_secs() == 20 +} diff --git a/crates/ruma-common/src/push/action.rs b/crates/ruma-common/src/push/action.rs index ea57cc8d..cf4c0b99 100644 --- a/crates/ruma-common/src/push/action.rs +++ b/crates/ruma-common/src/push/action.rs @@ -188,13 +188,13 @@ mod tests { #[test] fn serialize_string() { - assert_eq!(to_json_value(&Action::Notify).unwrap(), json!("notify")); + assert_eq!(to_json_value(Action::Notify).unwrap(), json!("notify")); } #[test] fn serialize_tweak_sound() { assert_eq!( - to_json_value(&Action::SetTweak(Tweak::Sound("default".into()))).unwrap(), + to_json_value(Action::SetTweak(Tweak::Sound("default".into()))).unwrap(), json!({ "set_tweak": "sound", "value": "default" }) ); } @@ -202,12 +202,12 @@ mod tests { #[test] fn serialize_tweak_highlight() { assert_eq!( - to_json_value(&Action::SetTweak(Tweak::Highlight(true))).unwrap(), + to_json_value(Action::SetTweak(Tweak::Highlight(true))).unwrap(), json!({ "set_tweak": "highlight" }) ); assert_eq!( - to_json_value(&Action::SetTweak(Tweak::Highlight(false))).unwrap(), + to_json_value(Action::SetTweak(Tweak::Highlight(false))).unwrap(), json!({ "set_tweak": "highlight", "value": false }) ); } diff --git a/crates/ruma-common/src/push/condition.rs b/crates/ruma-common/src/push/condition.rs index 08df971c..9f0910aa 100644 --- a/crates/ruma-common/src/push/condition.rs +++ b/crates/ruma-common/src/push/condition.rs @@ -364,7 +364,7 @@ impl StrExt for str { return false; } - let has_wildcards = pattern.contains(|c| matches!(c, '?' | '*')); + let has_wildcards = pattern.contains(['?', '*']); if has_wildcards { let mut chunks: Vec = vec![]; diff --git a/crates/ruma-common/src/serde.rs b/crates/ruma-common/src/serde.rs index 705f2355..99a27db6 100644 --- a/crates/ruma-common/src/serde.rs +++ b/crates/ruma-common/src/serde.rs @@ -74,6 +74,7 @@ where } pub use ruma_macros::{ - AsRefStr, DebugAsRefStr, DeserializeFromCowStr, DisplayAsRefStr, FromString, OrdAsRefStr, - PartialEqAsRefStr, PartialOrdAsRefStr, SerializeAsRefStr, StringEnum, _FakeDeriveSerde, + AsRefStr, AsStrAsRefStr, DebugAsRefStr, DeserializeFromCowStr, DisplayAsRefStr, FromString, + OrdAsRefStr, PartialEqAsRefStr, PartialOrdAsRefStr, SerializeAsRefStr, StringEnum, + _FakeDeriveSerde, }; 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-events/Cargo.toml b/crates/ruma-events/Cargo.toml index 18b6ae01..210e251b 100644 --- a/crates/ruma-events/Cargo.toml +++ b/crates/ruma-events/Cargo.toml @@ -76,6 +76,7 @@ serde_json = { workspace = true, features = ["raw_value"] } thiserror = { workspace = true } tracing = { workspace = true, features = ["attributes"] } url = { workspace = true } +web-time = { workspace = true } wildmatch = "2.0.0" # dev-dependencies can't be optional, so this is a regular dependency diff --git a/crates/ruma-events/src/beacon_info.rs b/crates/ruma-events/src/beacon_info.rs index 5c7a2947..87e8204f 100644 --- a/crates/ruma-events/src/beacon_info.rs +++ b/crates/ruma-events/src/beacon_info.rs @@ -3,11 +3,10 @@ //! //! [MSC3489]: https://github.com/matrix-org/matrix-spec-proposals/pull/3489 -use std::time::{Duration, SystemTime}; - use ruma_common::{MilliSecondsSinceUnixEpoch, OwnedUserId}; use ruma_macros::EventContent; use serde::{Deserialize, Serialize}; +use web_time::{Duration, SystemTime}; use crate::location::AssetContent; diff --git a/crates/ruma-events/src/call/member.rs b/crates/ruma-events/src/call/member.rs index 35751851..bb05b74f 100644 --- a/crates/ruma-events/src/call/member.rs +++ b/crates/ruma-events/src/call/member.rs @@ -9,7 +9,7 @@ mod member_data; pub use focus::*; pub use member_data::*; -use ruma_common::{MilliSecondsSinceUnixEpoch, OwnedUserId}; +use ruma_common::MilliSecondsSinceUnixEpoch; use ruma_macros::{EventContent, StringEnum}; use serde::{Deserialize, Serialize}; @@ -29,7 +29,7 @@ use crate::{ /// /// This struct also exposes allows to call the methods from [`CallMemberEventContent`]. #[derive(Clone, Debug, Serialize, Deserialize, EventContent, PartialEq)] -#[ruma_event(type = "org.matrix.msc3401.call.member", kind = State, state_key_type = OwnedUserId, custom_redacted, custom_possibly_redacted)] +#[ruma_event(type = "org.matrix.msc3401.call.member", kind = State, state_key_type = String, custom_redacted, custom_possibly_redacted)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] #[serde(untagged)] pub enum CallMemberEventContent { @@ -174,7 +174,7 @@ impl RedactContent for CallMemberEventContent { pub type PossiblyRedactedCallMemberEventContent = CallMemberEventContent; impl PossiblyRedactedStateEventContent for PossiblyRedactedCallMemberEventContent { - type StateKey = OwnedUserId; + type StateKey = String; } /// The Redacted version of [`CallMemberEventContent`]. @@ -190,7 +190,7 @@ impl ruma_events::content::EventContent for RedactedCallMemberEventContent { } impl RedactedStateEventContent for RedactedCallMemberEventContent { - type StateKey = OwnedUserId; + type StateKey = String; } /// Legacy content with an array of memberships. See also: [`CallMemberEventContent`] @@ -463,8 +463,8 @@ mod tests { serde_json::to_string(&call_member_ev).unwrap() ); } - #[test] - fn deserialize_member_event() { + + fn deserialize_member_event_helper(state_key: &str) { let ev = json!({ "content":{ "application": "m.call", @@ -488,7 +488,7 @@ mod tests { "event_id": "$3qfxjGYSu4sL25FtR0ep6vePOc", "room_id": "!1234:example.org", "sender": "@user:example.org", - "state_key":"@user:example.org", + "state_key": state_key, "unsigned":{ "age":10, "prev_content": {}, @@ -504,7 +504,7 @@ mod tests { let event_id = OwnedEventId::try_from("$3qfxjGYSu4sL25FtR0ep6vePOc").unwrap(); let sender = OwnedUserId::try_from("@user:example.org").unwrap(); let room_id = OwnedRoomId::try_from("!1234:example.org").unwrap(); - assert_eq!(member_event.state_key, sender); + assert_eq!(member_event.state_key, state_key); assert_eq!(member_event.event_id, event_id); assert_eq!(member_event.sender, sender); assert_eq!(member_event.room_id, room_id); @@ -539,6 +539,21 @@ mod tests { // CallMemberEventContent::Empty { leave_reason: None }, relations: None }) } + #[test] + fn deserialize_member_event() { + deserialize_member_event_helper("@user:example.org"); + } + + #[test] + fn deserialize_member_event_with_scoped_state_key_prefixed() { + deserialize_member_event_helper("_@user:example.org:THIS_DEVICE"); + } + + #[test] + fn deserialize_member_event_with_scoped_state_key_unprefixed() { + deserialize_member_event_helper("@user:example.org:THIS_DEVICE"); + } + fn timestamps() -> (TS, TS, TS) { let now = TS::now(); let one_second_ago = diff --git a/crates/ruma-events/tests/it/redacted.rs b/crates/ruma-events/tests/it/redacted.rs index 57c4baa9..9dbc5b1d 100644 --- a/crates/ruma-events/tests/it/redacted.rs +++ b/crates/ruma-events/tests/it/redacted.rs @@ -36,13 +36,13 @@ fn serialize_redacted_message_event_content() { #[test] fn serialize_empty_redacted_aliases_event_content() { - assert_eq!(to_json_value(&RedactedRoomAliasesEventContent::default()).unwrap(), json!({})); + assert_eq!(to_json_value(RedactedRoomAliasesEventContent::default()).unwrap(), json!({})); } #[test] fn redacted_aliases_event_serialize_with_content() { let expected = json!({ "aliases": [] }); - let actual = to_json_value(&RedactedRoomAliasesEventContent::new_v1(vec![])).unwrap(); + let actual = to_json_value(RedactedRoomAliasesEventContent::new_v1(vec![])).unwrap(); assert_eq!(actual, expected); } diff --git a/crates/ruma-federation-api/CHANGELOG.md b/crates/ruma-federation-api/CHANGELOG.md index 7cf199f8..edd0899d 100644 --- a/crates/ruma-federation-api/CHANGELOG.md +++ b/crates/ruma-federation-api/CHANGELOG.md @@ -1,5 +1,9 @@ # [unreleased] +Improvements: + +- Add support for authenticated media endpoints, according to MSC3916 / Matrix 1.11 + # 0.9.0 Breaking changes: diff --git a/crates/ruma-federation-api/Cargo.toml b/crates/ruma-federation-api/Cargo.toml index 7e225b92..11a671d9 100644 --- a/crates/ruma-federation-api/Cargo.toml +++ b/crates/ruma-federation-api/Cargo.toml @@ -19,8 +19,8 @@ all-features = true # them to an empty string in deserialization. compat-empty-string-null = [] -client = [] -server = [] +client = ["dep:httparse", "dep:memchr"] +server = ["dep:bytes", "dep:rand"] unstable-exhaustive-types = [] unstable-msc2448 = [] unstable-msc3618 = [] @@ -30,7 +30,13 @@ unstable-msc4125 = [] unstable-unspecified = [] [dependencies] +bytes = { workspace = true, optional = true } +http = { workspace = true } +httparse = { version = "1.9.0", optional = true } js_int = { workspace = true, features = ["serde"] } +memchr = { version = "2.7.0", optional = true } +mime = { version = "0.3.0" } +rand = { workspace = true, optional = true } ruma-common = { workspace = true, features = ["api"] } ruma-events = { workspace = true } serde = { workspace = true } diff --git a/crates/ruma-federation-api/src/authenticated_media.rs b/crates/ruma-federation-api/src/authenticated_media.rs new file mode 100644 index 00000000..135d3424 --- /dev/null +++ b/crates/ruma-federation-api/src/authenticated_media.rs @@ -0,0 +1,511 @@ +//! Authenticated endpoints for the content repository, according to [MSC3916]. +//! +//! [MSC3916]: https://github.com/matrix-org/matrix-spec-proposals/pull/3916 + +use ruma_common::http_headers::ContentDisposition; +use serde::{Deserialize, Serialize}; + +pub mod get_content; +pub mod get_content_thumbnail; + +/// The `multipart/mixed` mime "essence". +const MULTIPART_MIXED: &str = "multipart/mixed"; +/// The maximum number of headers to parse in a body part. +const MAX_HEADERS_COUNT: usize = 32; +/// The length of the generated boundary. +const GENERATED_BOUNDARY_LENGTH: usize = 30; + +/// The metadata of a file from the content repository. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub struct ContentMetadata {} + +impl ContentMetadata { + /// Creates a new empty `ContentMetadata`. + pub fn new() -> Self { + Self {} + } +} + +/// A file from the content repository or the location where it can be found. +#[derive(Debug, Clone)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub enum FileOrLocation { + /// The content of the file. + File(Content), + + /// The file is at the given URL. + Location(String), +} + +/// The content of a file from the content repository. +#[derive(Debug, Clone)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub struct Content { + /// The content of the file as bytes. + pub file: Vec, + + /// The content type of the file that was previously uploaded. + pub content_type: Option, + + /// The value of the `Content-Disposition` HTTP header, possibly containing the name of the + /// file that was previously uploaded. + pub content_disposition: Option, +} + +impl Content { + /// Creates a new `Content` with the given bytes. + pub fn new(file: Vec) -> Self { + Self { file, content_type: None, content_disposition: None } + } +} + +/// Serialize the given metadata and content into a `http::Response` `multipart/mixed` body. +/// +/// Returns a tuple containing the boundary used +#[cfg(feature = "server")] +fn try_into_multipart_mixed_response( + metadata: &ContentMetadata, + content: &FileOrLocation, +) -> Result, ruma_common::api::error::IntoHttpError> { + use std::io::Write as _; + + use rand::Rng as _; + + let boundary = rand::thread_rng() + .sample_iter(&rand::distributions::Alphanumeric) + .map(char::from) + .take(GENERATED_BOUNDARY_LENGTH) + .collect::(); + + let mut body_writer = T::default().writer(); + + // Add first boundary separator and header for the metadata. + let _ = write!( + body_writer, + "\r\n--{boundary}\r\n{}: {}\r\n\r\n", + http::header::CONTENT_TYPE, + mime::APPLICATION_JSON + ); + + // Add serialized metadata. + serde_json::to_writer(&mut body_writer, metadata)?; + + // Add second boundary separator. + let _ = write!(body_writer, "\r\n--{boundary}\r\n"); + + // Add content. + match content { + FileOrLocation::File(content) => { + // Add headers. + let content_type = + content.content_type.as_deref().unwrap_or(mime::APPLICATION_OCTET_STREAM.as_ref()); + let _ = write!(body_writer, "{}: {content_type}\r\n", http::header::CONTENT_TYPE); + + if let Some(content_disposition) = &content.content_disposition { + let _ = write!( + body_writer, + "{}: {content_disposition}\r\n", + http::header::CONTENT_DISPOSITION + ); + } + + // Add empty line separator after headers. + let _ = body_writer.write_all(b"\r\n"); + + // Add bytes. + let _ = body_writer.write_all(&content.file); + } + FileOrLocation::Location(location) => { + // Only add location header and empty line separator. + let _ = write!(body_writer, "{}: {location}\r\n\r\n", http::header::LOCATION); + } + } + + // Add final boundary. + let _ = write!(body_writer, "\r\n--{boundary}--"); + + let content_type = format!("{MULTIPART_MIXED}; boundary={boundary}"); + let body = body_writer.into_inner(); + + Ok(http::Response::builder().header(http::header::CONTENT_TYPE, content_type).body(body)?) +} + +/// Deserialize the given metadata and content from a `http::Response` with a `multipart/mixed` +/// body. +#[cfg(feature = "client")] +fn try_from_multipart_mixed_response>( + http_response: http::Response, +) -> Result< + (ContentMetadata, FileOrLocation), + ruma_common::api::error::FromHttpResponseError, +> { + use ruma_common::api::error::{HeaderDeserializationError, MultipartMixedDeserializationError}; + + // First, get the boundary from the content type header. + let body_content_type = http_response + .headers() + .get(http::header::CONTENT_TYPE) + .ok_or_else(|| HeaderDeserializationError::MissingHeader("Content-Type".to_owned()))? + .to_str()? + .parse::() + .map_err(|e| HeaderDeserializationError::InvalidHeader(e.into()))?; + + if !body_content_type.essence_str().eq_ignore_ascii_case(MULTIPART_MIXED) { + return Err(HeaderDeserializationError::InvalidHeaderValue { + header: "Content-Type".to_owned(), + expected: MULTIPART_MIXED.to_owned(), + unexpected: body_content_type.essence_str().to_owned(), + } + .into()); + } + + let boundary = body_content_type + .get_param("boundary") + .ok_or(HeaderDeserializationError::MissingMultipartBoundary)? + .as_str() + .as_bytes(); + + // Split the body with the boundary. + let body = http_response.body().as_ref(); + + let mut full_boundary = Vec::with_capacity(boundary.len() + 4); + full_boundary.extend_from_slice(b"\r\n--"); + full_boundary.extend_from_slice(boundary); + + let mut boundaries = memchr::memmem::find_iter(body, &full_boundary); + + let metadata_start = boundaries.next().ok_or_else(|| { + MultipartMixedDeserializationError::MissingBodyParts { expected: 2, found: 0 } + })? + full_boundary.len(); + let metadata_end = boundaries.next().ok_or_else(|| { + MultipartMixedDeserializationError::MissingBodyParts { expected: 2, found: 0 } + })?; + + let (_raw_metadata_headers, serialized_metadata) = + parse_multipart_body_part(body, metadata_start, metadata_end)?; + + // Don't search for anything in the headers, just deserialize the content that should be JSON. + let metadata = serde_json::from_slice(serialized_metadata)?; + + // Look at the part containing the media content now. + let content_start = metadata_end + full_boundary.len(); + let content_end = boundaries.next().ok_or_else(|| { + MultipartMixedDeserializationError::MissingBodyParts { expected: 2, found: 1 } + })?; + + let (raw_content_headers, file) = parse_multipart_body_part(body, content_start, content_end)?; + + // Parse the headers to retrieve the content type and content disposition. + let mut content_headers = [httparse::EMPTY_HEADER; MAX_HEADERS_COUNT]; + httparse::parse_headers(raw_content_headers, &mut content_headers) + .map_err(|e| MultipartMixedDeserializationError::InvalidHeader(e.into()))?; + + let mut location = None; + let mut content_type = None; + let mut content_disposition = None; + for header in content_headers { + if header.name.is_empty() { + // This is a empty header, we have reached the end of the parsed headers. + break; + } + + if header.name == http::header::LOCATION { + location = Some( + String::from_utf8(header.value.to_vec()) + .map_err(|e| MultipartMixedDeserializationError::InvalidHeader(e.into()))?, + ); + + // This is the only header we need, stop parsing. + break; + } else if header.name == http::header::CONTENT_TYPE { + content_type = Some( + String::from_utf8(header.value.to_vec()) + .map_err(|e| MultipartMixedDeserializationError::InvalidHeader(e.into()))?, + ); + } else if header.name == http::header::CONTENT_DISPOSITION { + content_disposition = Some( + ContentDisposition::try_from(header.value) + .map_err(|e| MultipartMixedDeserializationError::InvalidHeader(e.into()))?, + ); + } + } + + let content = if let Some(location) = location { + FileOrLocation::Location(location) + } else { + FileOrLocation::File(Content { file: file.to_owned(), content_type, content_disposition }) + }; + + Ok((metadata, content)) +} + +/// Parse the multipart body part in the given bytes, starting and ending at the given positions. +/// +/// Returns a `(headers_bytes, content_bytes)` tuple. Returns an error if the separation between the +/// headers and the content could not be found. +#[cfg(feature = "client")] +fn parse_multipart_body_part( + bytes: &[u8], + start: usize, + end: usize, +) -> Result<(&[u8], &[u8]), ruma_common::api::error::MultipartMixedDeserializationError> { + use ruma_common::api::error::MultipartMixedDeserializationError; + + // The part should start with a newline after the boundary. We need to ignore characters before + // it in case of extra whitespaces, and for compatibility it might not have a CR. + let headers_start = memchr::memchr(b'\n', &bytes[start..end]) + .expect("the end boundary contains a newline") + + start + + 1; + + // Let's find an empty line now. + let mut line_start = headers_start; + let mut line_end; + + loop { + line_end = memchr::memchr(b'\n', &bytes[line_start..end]) + .ok_or(MultipartMixedDeserializationError::MissingBodyPartInnerSeparator)? + + line_start + + 1; + + if matches!(&bytes[line_start..line_end], b"\r\n" | b"\n") { + break; + } + + line_start = line_end; + } + + Ok((&bytes[headers_start..line_start], &bytes[line_end..end])) +} + +#[cfg(all(test, feature = "client", feature = "server"))] +mod tests { + use assert_matches2::assert_matches; + use ruma_common::http_headers::{ContentDisposition, ContentDispositionType}; + + use super::{ + try_from_multipart_mixed_response, try_into_multipart_mixed_response, Content, + ContentMetadata, FileOrLocation, + }; + + #[test] + fn multipart_mixed_content_ascii_filename_conversions() { + let file = "s⌽me UTF-8 Ťext".as_bytes(); + let content_type = "text/plain"; + let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment) + .with_filename(Some("filename.txt".to_owned())); + + let outgoing_metadata = ContentMetadata::new(); + let outgoing_content = FileOrLocation::File(Content { + file: file.to_vec(), + content_type: Some(content_type.to_owned()), + content_disposition: Some(content_disposition.clone()), + }); + + let response = + try_into_multipart_mixed_response::>(&outgoing_metadata, &outgoing_content) + .unwrap(); + + let (_incoming_metadata, incoming_content) = + try_from_multipart_mixed_response(response).unwrap(); + + assert_matches!(incoming_content, FileOrLocation::File(incoming_content)); + assert_eq!(incoming_content.file, file); + assert_eq!(incoming_content.content_type.unwrap(), content_type); + assert_eq!(incoming_content.content_disposition, Some(content_disposition)); + } + + #[test] + fn multipart_mixed_content_utf8_filename_conversions() { + let file = "s⌽me UTF-8 Ťext".as_bytes(); + let content_type = "text/plain"; + let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment) + .with_filename(Some("fȈlƩnąmǝ.txt".to_owned())); + + let outgoing_metadata = ContentMetadata::new(); + let outgoing_content = FileOrLocation::File(Content { + file: file.to_vec(), + content_type: Some(content_type.to_owned()), + content_disposition: Some(content_disposition.clone()), + }); + + let response = + try_into_multipart_mixed_response::>(&outgoing_metadata, &outgoing_content) + .unwrap(); + + let (_incoming_metadata, incoming_content) = + try_from_multipart_mixed_response(response).unwrap(); + + assert_matches!(incoming_content, FileOrLocation::File(incoming_content)); + assert_eq!(incoming_content.file, file); + assert_eq!(incoming_content.content_type.unwrap(), content_type); + assert_eq!(incoming_content.content_disposition, Some(content_disposition)); + } + + #[test] + fn multipart_mixed_location_conversions() { + let location = "https://server.local/media/filename.txt"; + + let outgoing_metadata = ContentMetadata::new(); + let outgoing_content = FileOrLocation::Location(location.to_owned()); + + let response = + try_into_multipart_mixed_response::>(&outgoing_metadata, &outgoing_content) + .unwrap(); + + let (_incoming_metadata, incoming_content) = + try_from_multipart_mixed_response(response).unwrap(); + + assert_matches!(incoming_content, FileOrLocation::Location(incoming_location)); + assert_eq!(incoming_location, location); + } + + #[test] + fn multipart_mixed_deserialize_invalid() { + // Missing boundary in headers. + let body = "\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\nContent-Type: text/plain\r\n\r\nsome plain text\r\n--abcdef--"; + let response = http::Response::builder() + .header(http::header::CONTENT_TYPE, "multipart/mixed") + .body(body) + .unwrap(); + + try_from_multipart_mixed_response(response).unwrap_err(); + + // Wrong boundary. + let body = + "\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\nContent-Type: text/plain\r\n\r\nsome plain text\r\n--abcdef--"; + let response = http::Response::builder() + .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=012345") + .body(body) + .unwrap(); + + try_from_multipart_mixed_response(response).unwrap_err(); + + // Missing boundary in body. + let body = + "\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\nContent-Type: text/plain\r\n\r\nsome plain text"; + let response = http::Response::builder() + .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef") + .body(body) + .unwrap(); + + try_from_multipart_mixed_response(response).unwrap_err(); + + // Missing header and content empty line separator in body part. + let body = + "\r\n--abcdef\r\n{}\r\n--abcdef\r\nContent-Type: text/plain\r\n\r\nsome plain text\r\n--abcdef--"; + let response = http::Response::builder() + .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef") + .body(body) + .unwrap(); + + try_from_multipart_mixed_response(response).unwrap_err(); + + // Control character in header. + let body = + "\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\nContent-Type: text/plain\r\nContent-Disposition: inline; filename=\"my\nfile\"\r\nsome plain text\r\n--abcdef--"; + let response = http::Response::builder() + .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef") + .body(body) + .unwrap(); + + try_from_multipart_mixed_response(response).unwrap_err(); + } + + #[test] + fn multipart_mixed_deserialize_valid() { + // Simple. + let body = + "\r\n--abcdef\r\ncontent-type: application/json\r\n\r\n{}\r\n--abcdef\r\ncontent-type: text/plain\r\n\r\nsome plain text\r\n--abcdef--"; + let response = http::Response::builder() + .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef") + .body(body) + .unwrap(); + + let (_metadata, content) = try_from_multipart_mixed_response(response).unwrap(); + + assert_matches!(content, FileOrLocation::File(file_content)); + assert_eq!(file_content.file, b"some plain text"); + assert_eq!(file_content.content_type.unwrap(), "text/plain"); + assert_eq!(file_content.content_disposition, None); + + // Case-insensitive headers. + let body = + "\r\n--abcdef\r\nCONTENT-type: application/json\r\n\r\n{}\r\n--abcdef\r\nCONTENT-TYPE: text/plain\r\ncoNtenT-disPosItioN: attachment; filename=my_file.txt\r\n\r\nsome plain text\r\n--abcdef--"; + let response = http::Response::builder() + .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef") + .body(body) + .unwrap(); + + let (_metadata, content) = try_from_multipart_mixed_response(response).unwrap(); + + assert_matches!(content, FileOrLocation::File(file_content)); + assert_eq!(file_content.file, b"some plain text"); + assert_eq!(file_content.content_type.unwrap(), "text/plain"); + let content_disposition = file_content.content_disposition.unwrap(); + assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment); + assert_eq!(content_disposition.filename.unwrap(), "my_file.txt"); + + // Extra whitespace. + let body = + " \r\n--abcdef\r\ncontent-type: application/json \r\n\r\n {} \r\n--abcdef\r\ncontent-type: text/plain \r\n\r\nsome plain text\r\n--abcdef-- "; + let response = http::Response::builder() + .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef") + .body(body) + .unwrap(); + + let (_metadata, content) = try_from_multipart_mixed_response(response).unwrap(); + + assert_matches!(content, FileOrLocation::File(file_content)); + assert_eq!(file_content.file, b"some plain text"); + assert_eq!(file_content.content_type.unwrap(), "text/plain"); + assert_eq!(file_content.content_disposition, None); + + // Missing CR except in boundaries. + let body = + "\r\n--abcdef\ncontent-type: application/json\n\n{}\r\n--abcdef\ncontent-type: text/plain \n\nsome plain text\r\n--abcdef--"; + let response = http::Response::builder() + .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef") + .body(body) + .unwrap(); + + let (_metadata, content) = try_from_multipart_mixed_response(response).unwrap(); + + assert_matches!(content, FileOrLocation::File(file_content)); + assert_eq!(file_content.file, b"some plain text"); + assert_eq!(file_content.content_type.unwrap(), "text/plain"); + assert_eq!(file_content.content_disposition, None); + + // No body part headers. + let body = "\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\n\r\nsome plain text\r\n--abcdef--"; + let response = http::Response::builder() + .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef") + .body(body) + .unwrap(); + + let (_metadata, content) = try_from_multipart_mixed_response(response).unwrap(); + + assert_matches!(content, FileOrLocation::File(file_content)); + assert_eq!(file_content.file, b"some plain text"); + assert_eq!(file_content.content_type, None); + assert_eq!(file_content.content_disposition, None); + + // Raw UTF-8 filename (some kind of compatibility with multipart/form-data). + let body = + "\r\n--abcdef\r\ncontent-type: application/json\r\n\r\n{}\r\n--abcdef\r\ncontent-type: text/plain\r\ncontent-disposition: inline; filename=\"ȵ⌾Ⱦԩ💈Ňɠ\"\r\n\r\nsome plain text\r\n--abcdef--"; + let response = http::Response::builder() + .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef") + .body(body) + .unwrap(); + + let (_metadata, content) = try_from_multipart_mixed_response(response).unwrap(); + + assert_matches!(content, FileOrLocation::File(file_content)); + assert_eq!(file_content.file, b"some plain text"); + assert_eq!(file_content.content_type.unwrap(), "text/plain"); + let content_disposition = file_content.content_disposition.unwrap(); + assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline); + assert_eq!(content_disposition.filename.unwrap(), "ȵ⌾Ⱦԩ💈Ňɠ"); + } +} diff --git a/crates/ruma-federation-api/src/authenticated_media/get_content.rs b/crates/ruma-federation-api/src/authenticated_media/get_content.rs new file mode 100644 index 00000000..5df6ba1c --- /dev/null +++ b/crates/ruma-federation-api/src/authenticated_media/get_content.rs @@ -0,0 +1,107 @@ +//! `GET /_matrix/federation/*/media/download/{mediaId}` +//! +//! Retrieve content from the media store. + +pub mod v1 { + //! `/v1/` ([spec]) + //! + //! [spec]: https://spec.matrix.org/latest/server-server-api/#get_matrixfederationv1mediadownloadmediaid + + use std::time::Duration; + + use ruma_common::{ + api::{request, Metadata}, + metadata, + }; + + use crate::authenticated_media::{ContentMetadata, FileOrLocation}; + + const METADATA: Metadata = metadata! { + method: GET, + rate_limited: true, + authentication: ServerSignatures, + history: { + unstable => "/_matrix/federation/unstable/org.matrix.msc3916.v2/media/download/:media_id", + 1.11 => "/_matrix/federation/v1/media/download/:media_id", + } + }; + + /// Request type for the `get_content` endpoint. + #[request] + pub struct Request { + /// The media ID from the `mxc://` URI (the path component). + #[ruma_api(path)] + pub media_id: String, + + /// The maximum duration that the client is willing to wait to start receiving data, in the + /// case that the content has not yet been uploaded. + /// + /// The default value is 20 seconds. + #[ruma_api(query)] + #[serde( + with = "ruma_common::serde::duration::ms", + default = "ruma_common::media::default_download_timeout", + skip_serializing_if = "ruma_common::media::is_default_download_timeout" + )] + pub timeout_ms: Duration, + } + + impl Request { + /// Creates a new `Request` with the given media ID. + pub fn new(media_id: String) -> Self { + Self { media_id, timeout_ms: ruma_common::media::default_download_timeout() } + } + } + + /// Response type for the `get_content` endpoint. + #[derive(Debug, Clone)] + #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] + pub struct Response { + /// The metadata of the media. + pub metadata: ContentMetadata, + + /// The content of the media. + pub content: FileOrLocation, + } + + impl Response { + /// Creates a new `Response` with the given metadata and content. + pub fn new(metadata: ContentMetadata, content: FileOrLocation) -> Self { + Self { metadata, content } + } + } + + #[cfg(feature = "client")] + impl ruma_common::api::IncomingResponse for Response { + type EndpointError = ruma_common::api::error::MatrixError; + + fn try_from_http_response>( + http_response: http::Response, + ) -> Result> + { + use ruma_common::api::EndpointError; + + if http_response.status().as_u16() < 400 { + let (metadata, content) = + crate::authenticated_media::try_from_multipart_mixed_response(http_response)?; + Ok(Self { metadata, content }) + } else { + Err(ruma_common::api::error::FromHttpResponseError::Server( + ruma_common::api::error::MatrixError::from_http_response(http_response), + )) + } + } + } + + #[cfg(feature = "server")] + impl ruma_common::api::OutgoingResponse for Response { + fn try_into_http_response( + self, + ) -> Result, ruma_common::api::error::IntoHttpError> { + crate::authenticated_media::try_into_multipart_mixed_response( + &self.metadata, + &self.content, + ) + } + } +} diff --git a/crates/ruma-federation-api/src/authenticated_media/get_content_thumbnail.rs b/crates/ruma-federation-api/src/authenticated_media/get_content_thumbnail.rs new file mode 100644 index 00000000..d373db1a --- /dev/null +++ b/crates/ruma-federation-api/src/authenticated_media/get_content_thumbnail.rs @@ -0,0 +1,143 @@ +//! `GET /_matrix/federation/*/media/thumbnail/{mediaId}` +//! +//! Get a thumbnail of content from the media repository. + +pub mod v1 { + //! `/v1/` ([spec]) + //! + //! [spec]: https://spec.matrix.org/latest/server-server-api/#get_matrixfederationv1mediathumbnailmediaid + + use std::time::Duration; + + use js_int::UInt; + use ruma_common::{ + api::{request, Metadata}, + media::Method, + metadata, + }; + + use crate::authenticated_media::{ContentMetadata, FileOrLocation}; + + const METADATA: Metadata = metadata! { + method: GET, + rate_limited: true, + authentication: ServerSignatures, + history: { + unstable => "/_matrix/federation/unstable/org.matrix.msc3916.v2/media/thumbnail/:media_id", + 1.11 => "/_matrix/federation/v1/media/thumbnail/:media_id", + } + }; + + /// Request type for the `get_content_thumbnail` endpoint. + #[request] + pub struct Request { + /// The media ID from the `mxc://` URI (the path component). + #[ruma_api(path)] + pub media_id: String, + + /// The desired resizing method. + #[ruma_api(query)] + #[serde(skip_serializing_if = "Option::is_none")] + pub method: Option, + + /// The *desired* width of the thumbnail. + /// + /// The actual thumbnail may not match the size specified. + #[ruma_api(query)] + pub width: UInt, + + /// The *desired* height of the thumbnail. + /// + /// The actual thumbnail may not match the size specified. + #[ruma_api(query)] + pub height: UInt, + + /// The maximum duration that the client is willing to wait to start receiving data, in the + /// case that the content has not yet been uploaded. + /// + /// The default value is 20 seconds. + #[ruma_api(query)] + #[serde( + with = "ruma_common::serde::duration::ms", + default = "ruma_common::media::default_download_timeout", + skip_serializing_if = "ruma_common::media::is_default_download_timeout" + )] + pub timeout_ms: Duration, + + /// Whether the server should return an animated thumbnail. + /// + /// When `Some(true)`, the server should return an animated thumbnail if possible and + /// supported. When `Some(false)`, the server must not return an animated + /// thumbnail. When `None`, the server should not return an animated thumbnail. + #[ruma_api(query)] + #[serde(skip_serializing_if = "Option::is_none")] + pub animated: Option, + } + + impl Request { + /// Creates a new `Request` with the given media ID, desired thumbnail width + /// and desired thumbnail height. + pub fn new(media_id: String, width: UInt, height: UInt) -> Self { + Self { + media_id, + method: None, + width, + height, + timeout_ms: ruma_common::media::default_download_timeout(), + animated: None, + } + } + } + + /// Response type for the `get_content_thumbnail` endpoint. + #[derive(Debug, Clone)] + #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] + pub struct Response { + /// The metadata of the thumbnail. + pub metadata: ContentMetadata, + + /// The content of the thumbnail. + pub content: FileOrLocation, + } + + impl Response { + /// Creates a new `Response` with the given metadata and content. + pub fn new(metadata: ContentMetadata, content: FileOrLocation) -> Self { + Self { metadata, content } + } + } + + #[cfg(feature = "client")] + impl ruma_common::api::IncomingResponse for Response { + type EndpointError = ruma_common::api::error::MatrixError; + + fn try_from_http_response>( + http_response: http::Response, + ) -> Result> + { + use ruma_common::api::EndpointError; + + if http_response.status().as_u16() < 400 { + let (metadata, content) = + crate::authenticated_media::try_from_multipart_mixed_response(http_response)?; + Ok(Self { metadata, content }) + } else { + Err(ruma_common::api::error::FromHttpResponseError::Server( + ruma_common::api::error::MatrixError::from_http_response(http_response), + )) + } + } + } + + #[cfg(feature = "server")] + impl ruma_common::api::OutgoingResponse for Response { + fn try_into_http_response( + self, + ) -> Result, ruma_common::api::error::IntoHttpError> { + crate::authenticated_media::try_into_multipart_mixed_response( + &self.metadata, + &self.content, + ) + } + } +} diff --git a/crates/ruma-federation-api/src/lib.rs b/crates/ruma-federation-api/src/lib.rs index 4330b9f0..c48cdd25 100644 --- a/crates/ruma-federation-api/src/lib.rs +++ b/crates/ruma-federation-api/src/lib.rs @@ -12,6 +12,7 @@ use std::fmt; mod serde; +pub mod authenticated_media; pub mod authorization; pub mod backfill; pub mod device; diff --git a/crates/ruma-macros/src/api/request/incoming.rs b/crates/ruma-macros/src/api/request/incoming.rs index 5f81bdce..44b7db90 100644 --- a/crates/ruma-macros/src/api/request/incoming.rs +++ b/crates/ruma-macros/src/api/request/incoming.rs @@ -80,18 +80,38 @@ impl Request { syn::Type::Path(syn::TypePath { path: syn::Path { segments, .. }, .. }) if segments.last().unwrap().ident == "Option" => { - (quote! { Some(str_value.to_owned()) }, quote! { None }) + let syn::PathArguments::AngleBracketed(syn::AngleBracketedGenericArguments { + args: option_args, .. + }) = &segments.last().unwrap().arguments else { + panic!("Option should use angle brackets"); + }; + let syn::GenericArgument::Type(field_type) = option_args.first().unwrap() else { + panic!("Option brackets should contain type"); + }; + ( + quote! { + str_value.parse::<#field_type>().ok() + }, + quote! { None } + ) + } + _ => { + let field_type = &field.ty; + ( + quote! { + str_value + .parse::<#field_type>() + .map_err(|e| #ruma_common::api::error::HeaderDeserializationError::InvalidHeader(e.into()))? + }, + quote! { + return Err( + #ruma_common::api::error::HeaderDeserializationError::MissingHeader( + #header_name_string.into() + ).into(), + ) + }, + ) } - _ => ( - quote! { str_value.to_owned() }, - quote! { - return Err( - #ruma_common::api::error::HeaderDeserializationError::MissingHeader( - #header_name_string.into() - ).into(), - ) - }, - ), }; let decl = quote! { diff --git a/crates/ruma-macros/src/api/request/outgoing.rs b/crates/ruma-macros/src/api/request/outgoing.rs index 6555d58a..6bac6d2c 100644 --- a/crates/ruma-macros/src/api/request/outgoing.rs +++ b/crates/ruma-macros/src/api/request/outgoing.rs @@ -66,7 +66,7 @@ impl Request { if let Some(header_val) = self.#field_name.as_ref() { req_headers.insert( #header_name, - #http::header::HeaderValue::from_str(header_val)?, + #http::header::HeaderValue::from_str(&header_val.to_string())?, ); } } @@ -74,7 +74,7 @@ impl Request { _ => quote! { req_headers.insert( #header_name, - #http::header::HeaderValue::from_str(self.#field_name.as_ref())?, + #http::header::HeaderValue::from_str(&self.#field_name.to_string())?, ); }, } diff --git a/crates/ruma-macros/src/api/response/incoming.rs b/crates/ruma-macros/src/api/response/incoming.rs index a37bfa5d..8be16793 100644 --- a/crates/ruma-macros/src/api/response/incoming.rs +++ b/crates/ruma-macros/src/api/response/incoming.rs @@ -56,24 +56,37 @@ impl Response { Type::Path(syn::TypePath { path: syn::Path { segments, .. }, .. }) if segments.last().unwrap().ident == "Option" => { + let syn::PathArguments::AngleBracketed(syn::AngleBracketedGenericArguments { + args: option_args, .. + }) = &segments.last().unwrap().arguments else { + panic!("Option should use angle brackets"); + }; + let syn::GenericArgument::Type(field_type) = option_args.first().unwrap() else { + panic!("Option brackets should contain type"); + }; quote! { #( #cfg_attrs )* #field_name: { headers.remove(#header_name) - .map(|h| h.to_str().map(|s| s.to_owned())) - .transpose()? + .and_then(|h| { h.to_str().ok()?.parse::<#field_type>().ok() }) } } } - _ => quote! { - #( #cfg_attrs )* - #field_name: { - headers.remove(#header_name) - .expect("response missing expected header") - .to_str()? - .to_owned() + _ => { + let field_type = &field.ty; + quote! { + #( #cfg_attrs )* + #field_name: { + headers.remove(#header_name) + .ok_or_else(|| #ruma_common::api::error::HeaderDeserializationError::MissingHeader( + #header_name.to_string() + ))? + .to_str()? + .parse::<#field_type>() + .map_err(|e| #ruma_common::api::error::HeaderDeserializationError::InvalidHeader(e.into()))? + } } - }, + } }; quote! { #optional_header } } diff --git a/crates/ruma-macros/src/api/response/outgoing.rs b/crates/ruma-macros/src/api/response/outgoing.rs index ea24aa17..114d95d8 100644 --- a/crates/ruma-macros/src/api/response/outgoing.rs +++ b/crates/ruma-macros/src/api/response/outgoing.rs @@ -22,7 +22,7 @@ impl Response { if let Some(header) = self.#field_name { headers.insert( #header_name, - header.parse()?, + header.to_string().parse()?, ); } } @@ -30,7 +30,7 @@ impl Response { _ => quote! { headers.insert( #header_name, - self.#field_name.parse()?, + self.#field_name.to_string().parse()?, ); }, } diff --git a/crates/ruma-push-gateway-api/src/send_event_notification.rs b/crates/ruma-push-gateway-api/src/send_event_notification.rs index 3d800a68..b349447b 100644 --- a/crates/ruma-push-gateway-api/src/send_event_notification.rs +++ b/crates/ruma-push-gateway-api/src/send_event_notification.rs @@ -342,11 +342,7 @@ pub mod v1 { // If a highlight tweak is given with no value, its value is defined to be // true. "highlight" => { - let highlight = if let Ok(highlight) = access.next_value() { - highlight - } else { - true - }; + let highlight = access.next_value().unwrap_or(true); tweaks.push(Tweak::Highlight(highlight)); } 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},"#)?; } diff --git a/crates/ruma-signatures/Cargo.toml b/crates/ruma-signatures/Cargo.toml index d165514d..103875ef 100644 --- a/crates/ruma-signatures/Cargo.toml +++ b/crates/ruma-signatures/Cargo.toml @@ -23,7 +23,7 @@ unstable-exhaustive-types = [] base64 = { workspace = true } ed25519-dalek = { version = "2.0.0", features = ["pkcs8", "rand_core"] } pkcs8 = { version = "0.10.0", features = ["alloc"] } -rand = { version = "0.8.5", features = ["getrandom"] } +rand = { workspace = true, features = ["getrandom"] } ruma-common = { workspace = true, features = ["canonical-json"] } serde_json = { workspace = true } sha2 = "0.10.6" diff --git a/crates/ruma-signatures/src/functions.rs b/crates/ruma-signatures/src/functions.rs index 6cff34d3..a4bea08a 100644 --- a/crates/ruma-signatures/src/functions.rs +++ b/crates/ruma-signatures/src/functions.rs @@ -40,7 +40,7 @@ static REFERENCE_HASH_FIELDS_TO_REMOVE: &[&str] = &["signatures", "unsigned"]; /// # Parameters /// /// * entity_id: The identifier of the entity creating the signature. Generally this means a -/// homeserver, e.g. "example.com". +/// homeserver, e.g. "example.com". /// * key_pair: A cryptographic key pair used to sign the JSON. /// * object: A JSON object to sign according and append a signature to. /// @@ -167,9 +167,9 @@ pub fn canonical_json(object: &CanonicalJsonObject) -> Result { /// # Parameters /// /// * public_key_map: A map from entity identifiers to a map from key identifiers to public keys. -/// Generally, entity identifiers are server names — the host/IP/port of a homeserver (e.g. -/// "example.com") for which a signature must be verified. Key identifiers for each server (e.g. -/// "ed25519:1") then map to their respective public keys. +/// Generally, entity identifiers are server names — the host/IP/port of a homeserver (e.g. +/// "example.com") for which a signature must be verified. Key identifiers for each server (e.g. +/// "ed25519:1") then map to their respective public keys. /// * object: The JSON object that was signed. /// /// # Errors @@ -355,7 +355,7 @@ pub fn reference_hash( /// # Parameters /// /// * entity_id: The identifier of the entity creating the signature. Generally this means a -/// homeserver, e.g. "example.com". +/// homeserver, e.g. "example.com". /// * key_pair: A cryptographic key pair used to sign the event. /// * object: A JSON object to be hashed and signed according to the Matrix specification. /// @@ -486,9 +486,9 @@ where /// # Parameters /// /// * public_key_map: A map from entity identifiers to a map from key identifiers to public keys. -/// Generally, entity identifiers are server names—the host/IP/port of a homeserver (e.g. -/// "example.com") for which a signature must be verified. Key identifiers for each server (e.g. -/// "ed25519:1") then map to their respective public keys. +/// Generally, entity identifiers are server names—the host/IP/port of a homeserver (e.g. +/// "example.com") for which a signature must be verified. Key identifiers for each server (e.g. +/// "ed25519:1") then map to their respective public keys. /// * object: The JSON object of the event that was signed. /// * version: Room version of the given event /// diff --git a/crates/ruma-state-res/Cargo.toml b/crates/ruma-state-res/Cargo.toml index f4ee9f10..a97ac7a1 100644 --- a/crates/ruma-state-res/Cargo.toml +++ b/crates/ruma-state-res/Cargo.toml @@ -32,7 +32,7 @@ criterion = { workspace = true, optional = true } [dev-dependencies] maplit = { workspace = true } -rand = "0.8.3" +rand = { workspace = true } ruma-events = { workspace = true, features = ["unstable-pdu"] } tracing-subscriber = "0.3.16" diff --git a/crates/ruma/Cargo.toml b/crates/ruma/Cargo.toml index cefb5449..e175fc39 100644 --- a/crates/ruma/Cargo.toml +++ b/crates/ruma/Cargo.toml @@ -276,11 +276,8 @@ unstable-unspecified = [ "ruma-push-gateway-api?/unstable-unspecified", ] -# Private feature, only used in test / benchmarking code -__ci = [ - "full", - "compat-upload-signatures", - "unstable-unspecified", +# Private features, only used in test / benchmarking code +__unstable-mscs = [ "unstable-msc1767", "unstable-msc2409", "unstable-msc2448", @@ -322,7 +319,13 @@ __ci = [ "unstable-msc4108", "unstable-msc4121", "unstable-msc4125", - "unstable-msc4140" + "unstable-msc4140", +] +__ci = [ + "full", + "compat-upload-signatures", + "__unstable-mscs", + "unstable-unspecified", ] [dependencies] diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 647f9a1c..a2ab784c 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] # Keep in sync with version in `xtask/src/ci.rs` and `.github/workflows/ci.yml` -channel = "nightly-2024-05-09" +channel = "nightly-2024-07-29" components = ["rustfmt", "clippy"] diff --git a/xtask/src/ci.rs b/xtask/src/ci.rs index 0ff57663..058c563d 100644 --- a/xtask/src/ci.rs +++ b/xtask/src/ci.rs @@ -60,7 +60,7 @@ pub enum CiCmd { NightlyAll, /// Lint default features with clippy (nightly) ClippyDefault, - /// Lint ruma-common with clippy on a wasm target (nightly) + /// Lint client features with clippy on a wasm target (nightly) ClippyWasm, /// Lint almost all features with clippy (nightly) ClippyAll, @@ -289,17 +289,15 @@ impl CiTask { .map_err(Into::into) } - /// Lint ruma-common with clippy with the nightly version and wasm target. - /// - /// ruma-common is currently the only crate with wasm-specific code. If that changes, this - /// method should be updated. + /// Lint ruma with clippy with the nightly version and wasm target. fn clippy_wasm(&self) -> Result<()> { cmd!( " - rustup run {NIGHTLY} cargo clippy --target wasm32-unknown-unknown - -p ruma-common --features api,js,rand + rustup run {NIGHTLY} cargo clippy --target wasm32-unknown-unknown -p ruma --features + __unstable-mscs,api,canonical-json,client-api,events,html-matrix,identity-service-api,js,markdown,rand,signatures,unstable-unspecified -- -D warnings " ) + .env("CLIPPY_CONF_DIR", ".wasm") .run() .map_err(Into::into) } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index c917863d..057bc5e4 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -15,7 +15,7 @@ use serde::Deserialize; use serde_json::from_str as from_json_str; // Keep in sync with version in `rust-toolchain.toml` and `.github/workflows/ci.yml` -const NIGHTLY: &str = "nightly-2024-05-09"; +const NIGHTLY: &str = "nightly-2024-07-29"; mod cargo; mod ci;