From 731aa20df5f464770a6ec7d16bd859e60d5b98de Mon Sep 17 00:00:00 2001 From: Jason Volk Date: Wed, 27 May 2026 01:59:46 +0000 Subject: [PATCH] feat(sdk): MSC4383 server info on /versions. Prefer the MSC4383 `server` object on `GET /_matrix/client/versions` for homeserver-implementation identification, falling back to the federation `/version` endpoint for servers that have not yet adopted MSC4383. The new `unstable-msc4383` Cargo feature gates the C-S read path independently from `federation-api`: a consumer can drop `federation-api` once MSC4383 is widespread, leaving only the C-S path and the `ruma-federation-api` dependency that ships with it behind. `unstable-msc4383` is enabled by default in `matrix-sdk-ffi` so FFI consumers see the field automatically. Signed-off-by: Jason Volk --- bindings/matrix-sdk-ffi/Cargo.toml | 3 +- bindings/matrix-sdk-ffi/src/client.rs | 13 ++- crates/matrix-sdk/Cargo.toml | 7 ++ .../matrix-sdk/changelog.d/+msc4383.added.md | 7 ++ crates/matrix-sdk/src/client/mod.rs | 99 ++++++++++++++--- crates/matrix-sdk/src/test_utils/mocks/mod.rs | 49 ++++++++- crates/matrix-sdk/tests/integration/client.rs | 103 +++++++++++++++++- 7 files changed, 256 insertions(+), 25 deletions(-) create mode 100644 crates/matrix-sdk/changelog.d/+msc4383.added.md diff --git a/bindings/matrix-sdk-ffi/Cargo.toml b/bindings/matrix-sdk-ffi/Cargo.toml index 62b3d1db974..dc7b18e3e3b 100644 --- a/bindings/matrix-sdk-ffi/Cargo.toml +++ b/bindings/matrix-sdk-ffi/Cargo.toml @@ -24,7 +24,7 @@ crate-type = [ ] [features] -default = ["bundled-sqlite", "unstable-msc4274", "experimental-element-recent-emojis", "experimental-push-secrets", "experimental-search"] +default = ["bundled-sqlite", "unstable-msc4274", "unstable-msc4383", "experimental-element-recent-emojis", "experimental-push-secrets", "experimental-search"] experimental-search = ["matrix-sdk/experimental-search"] # Use SQLite for the session storage. sqlite = ["matrix-sdk/sqlite"] @@ -33,6 +33,7 @@ bundled-sqlite = ["sqlite", "matrix-sdk/bundled-sqlite"] # Use IndexedDB for the session storage. indexeddb = ["matrix-sdk/indexeddb"] unstable-msc4274 = ["matrix-sdk-ui/unstable-msc4274"] +unstable-msc4383 = ["matrix-sdk/unstable-msc4383"] # Required when targeting a Javascript environment, like Wasm in a browser. js = ["matrix-sdk-ui/js"] # Enable sentry error monitoring, not compatible with Wasm platforms. diff --git a/bindings/matrix-sdk-ffi/src/client.rs b/bindings/matrix-sdk-ffi/src/client.rs index 6acb9ef0feb..33ec68dbeae 100644 --- a/bindings/matrix-sdk-ffi/src/client.rs +++ b/bindings/matrix-sdk-ffi/src/client.rs @@ -2079,10 +2079,15 @@ impl Client { && self.inner.unstable_features().await?.contains(&ruma::api::FeatureFlag::Msc4108)) } - /// Get server vendor information from the federation API. - /// - /// This method retrieves information about the server's name and version - /// by calling the `/_matrix/federation/v1/version` endpoint. + /// Get information about the homeserver implementation. + /// + /// Reads the MSC4383 `server` object on `GET /_matrix/client/versions` + /// when available, and falls back to the federation + /// `/_matrix/federation/v1/version` endpoint for homeservers that have + /// not yet adopted MSC4383. Missing `name` or `version` fields are + /// reported as the literal string `"unknown"`; callers that need to + /// distinguish "advertised" from "not advertised" must compare against + /// this sentinel. pub async fn server_vendor_info(&self) -> Result { Ok(self.inner.server_vendor_info(None).await?) } diff --git a/crates/matrix-sdk/Cargo.toml b/crates/matrix-sdk/Cargo.toml index 97e4ea36afe..336e703085b 100644 --- a/crates/matrix-sdk/Cargo.toml +++ b/crates/matrix-sdk/Cargo.toml @@ -87,6 +87,13 @@ docsrs = ["e2e-encryption", "sqlite", "indexeddb", "sso-login", "qrcode", "feder # Add support for inline media galleries via msgtypes unstable-msc4274 = ["ruma/unstable-msc4274", "matrix-sdk-base/unstable-msc4274"] +# Read server implementation identification from the client-server `/versions` +# endpoint, per MSC4383. Decoupled from `federation-api` on purpose: a consumer +# that only wants the C-S read path can drop `federation-api` and skip the +# `ruma-federation-api` dependency entirely, at the cost of the legacy +# `/_matrix/federation/v1/version` fallback. +unstable-msc4383 = ["ruma/unstable-msc4383"] + experimental-search = ["matrix-sdk-search"] experimental-element-recent-emojis = ["matrix-sdk-base/experimental-element-recent-emojis"] diff --git a/crates/matrix-sdk/changelog.d/+msc4383.added.md b/crates/matrix-sdk/changelog.d/+msc4383.added.md new file mode 100644 index 00000000000..17ee296b446 --- /dev/null +++ b/crates/matrix-sdk/changelog.d/+msc4383.added.md @@ -0,0 +1,7 @@ +Read homeserver implementation identification from the client-server +`GET /_matrix/client/versions` response per [MSC4383], behind the new +`unstable-msc4383` Cargo feature. `Client::server_vendor_info` now prefers +the MSC4383 `server` object and falls back to `GET /_matrix/federation/v1/version` +for homeservers that have not yet adopted MSC4383. + +[MSC4383]: https://github.com/matrix-org/matrix-spec-proposals/pull/4383 diff --git a/crates/matrix-sdk/src/client/mod.rs b/crates/matrix-sdk/src/client/mod.rs index 543126f1371..0d6f79f62f9 100644 --- a/crates/matrix-sdk/src/client/mod.rs +++ b/crates/matrix-sdk/src/client/mod.rs @@ -170,7 +170,11 @@ pub enum SessionChange { TokensRefreshed, } -/// Information about the server vendor obtained from the federation API. +/// Information about the homeserver implementation. +/// +/// Preferred source is the MSC4383 `server` object on +/// `GET /_matrix/client/versions`. When that object is absent (older servers), +/// the data falls back to `GET /_matrix/federation/v1/version`. #[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] pub struct ServerVendorInfo { @@ -180,6 +184,16 @@ pub struct ServerVendorInfo { pub version: String, } +impl ServerVendorInfo { + /// A `ServerVendorInfo` whose fields read `"unknown"`. Returned when no + /// source advertised the homeserver identification; see + /// [`Client::server_vendor_info`] for the dispatch policy. + #[cfg(all(feature = "unstable-msc4383", not(feature = "federation-api")))] + pub(crate) fn unknown() -> Self { + Self { server_name: "unknown".to_owned(), version: "unknown".to_owned() } + } +} + /// Information about a map tile server advertised by the homeserver through the /// `tile_server` field of the matrix client well-known (MSC3488). #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -588,10 +602,24 @@ impl Client { HomeserverCapabilities::new(self.clone()) } - /// Get the server vendor information from the federation API. + /// Get information about the homeserver implementation. /// - /// This method calls the `/_matrix/federation/v1/version` endpoint to get - /// both the server's software name and version. + /// Under the `unstable-msc4383` feature this reads the `server` object on + /// `GET /_matrix/client/versions` + /// ([MSC4383](https://github.com/matrix-org/matrix-spec-proposals/pull/4383)). + /// When that object is absent (the homeserver has not adopted MSC4383) + /// and the `federation-api` feature is also enabled, the method falls + /// back to `GET /_matrix/federation/v1/version`. With only + /// `unstable-msc4383` enabled, an absent object yields a + /// `ServerVendorInfo` whose fields both read `"unknown"`. + /// + /// Transport-layer errors on the chosen endpoint are propagated; only an + /// absent `server` object triggers the fallback. A present-but-empty + /// `server` object is treated as a successful response from a MSC4383 + /// homeserver that has nothing to advertise and does not fall back to + /// federation. Missing `name` or `version` within an otherwise successful + /// response are reported as `"unknown"`. The same `request_config` is + /// forwarded to both endpoints when the federation fallback is taken. /// /// # Examples /// @@ -602,17 +630,59 @@ impl Client { /// # let homeserver = Url::parse("http://example.com")?; /// let client = Client::new(homeserver).await?; /// - /// let server_info = client.server_vendor_info(None).await?; - /// println!( - /// "Server: {}, Version: {}", - /// server_info.server_name, server_info.version - /// ); + /// let info = client.server_vendor_info(None).await?; + /// if info.server_name == "unknown" { + /// println!("homeserver did not advertise its identification"); + /// } else { + /// println!("homeserver is {} {}", info.server_name, info.version); + /// } /// # anyhow::Ok(()) }; /// ``` - #[cfg(feature = "federation-api")] + #[cfg(any(feature = "federation-api", feature = "unstable-msc4383"))] pub async fn server_vendor_info( &self, request_config: Option, + ) -> HttpResult { + #[cfg(feature = "unstable-msc4383")] + if let Some(info) = self.read_msc4383_server_info(request_config).await? { + return Ok(info); + } + + #[cfg(feature = "federation-api")] + { + return self.read_federation_server_info(request_config).await; + } + + #[cfg(all(feature = "unstable-msc4383", not(feature = "federation-api")))] + Ok(ServerVendorInfo::unknown()) + } + + /// Read the MSC4383 `server` object from `GET /_matrix/client/versions`. + /// + /// Returns `Ok(Some(_))` when the homeserver advertised the object, + /// `Ok(None)` when the object is absent, and propagates transport-layer + /// errors. + #[cfg(feature = "unstable-msc4383")] + async fn read_msc4383_server_info( + &self, + request_config: Option, + ) -> HttpResult> { + let resp = self.fetch_server_versions_inner(false, request_config).await?; + Ok(resp.server.map(|server| ServerVendorInfo { + server_name: server.name.unwrap_or_else(|| "unknown".to_owned()), + version: server.version.unwrap_or_else(|| "unknown".to_owned()), + })) + } + + /// Read homeserver identification from `GET + /// /_matrix/federation/v1/version`. + /// + /// This is the pre-MSC4383 cross-interface path; see + /// [`Self::server_vendor_info`] for the dispatch policy. + #[cfg(feature = "federation-api")] + async fn read_federation_server_info( + &self, + request_config: Option, ) -> HttpResult { use ruma::api::federation::discovery::get_server_version; @@ -620,12 +690,11 @@ impl Client { .send_inner(get_server_version::v1::Request::new(), request_config, Default::default()) .await?; - // Extract server info, using defaults if fields are missing. let server = res.server.unwrap_or_default(); - let server_name_str = server.name.unwrap_or_else(|| "unknown".to_owned()); - let version = server.version.unwrap_or_else(|| "unknown".to_owned()); - - Ok(ServerVendorInfo { server_name: server_name_str, version }) + Ok(ServerVendorInfo { + server_name: server.name.unwrap_or_else(|| "unknown".to_owned()), + version: server.version.unwrap_or_else(|| "unknown".to_owned()), + }) } /// Get a copy of the default request config. diff --git a/crates/matrix-sdk/src/test_utils/mocks/mod.rs b/crates/matrix-sdk/src/test_utils/mocks/mod.rs index dd835bb07e9..82781715a19 100644 --- a/crates/matrix-sdk/src/test_utils/mocks/mod.rs +++ b/crates/matrix-sdk/src/test_utils/mocks/mod.rs @@ -3549,6 +3549,7 @@ impl<'a> MockEndpoint<'a, BanUserEndpoint> { pub struct VersionsEndpoint { versions: Vec<&'static str>, features: BTreeMap<&'static str, bool>, + server_info: Option, } impl VersionsEndpoint { @@ -3563,7 +3564,11 @@ impl VersionsEndpoint { impl Default for VersionsEndpoint { fn default() -> Self { - Self { versions: Self::commonly_supported_versions(), features: BTreeMap::new() } + Self { + versions: Self::commonly_supported_versions(), + features: BTreeMap::new(), + server_info: None, + } } } @@ -3574,10 +3579,17 @@ impl<'a> MockEndpoint<'a, VersionsEndpoint> { pub fn ok(mut self) -> MatrixMock<'a> { let features = std::mem::take(&mut self.endpoint.features); let versions = std::mem::take(&mut self.endpoint.versions); - self.respond_with(ResponseTemplate::new(200).set_body_json(json!({ + let server_info = self.endpoint.server_info.take(); + let mut body = json!({ "unstable_features": features, - "versions": versions - }))) + "versions": versions, + }); + if let Some(info) = server_info + && let Some(obj) = body.as_object_mut() + { + obj.insert("net.zemos.msc4383.server".to_owned(), info); + } + self.respond_with(ResponseTemplate::new(200).set_body_json(body)) } /// Set the supported flag for the given unstable feature in the response of @@ -3607,6 +3619,35 @@ impl<'a> MockEndpoint<'a, VersionsEndpoint> { self.endpoint.versions = versions; self } + + /// Populate the MSC4383 `server` object on the response. + /// + /// The object is serialised under the unstable wire key + /// `net.zemos.msc4383.server`. When MSC4383 stabilises this helper must + /// also emit the stable `server` key alongside (or in place of) the + /// unstable one; matrix-sdk reads only the unstable key today. For + /// partial-field shapes (only `name` or only `version` advertised), use + /// [`Self::with_server_info_partial`]. + pub fn with_server_info(self, name: &str, version: &str) -> Self { + self.with_server_info_partial(Some(name), Some(version)) + } + + /// Populate the MSC4383 `server` object with optionally-absent fields. + /// + /// `None` for a field omits that key from the wire object. Passing + /// `(None, None)` still emits an empty `server` object, which exercises + /// the "object present but both fields missing" case. + pub fn with_server_info_partial(mut self, name: Option<&str>, version: Option<&str>) -> Self { + let mut obj = serde_json::Map::new(); + if let Some(n) = name { + obj.insert("name".to_owned(), json!(n)); + } + if let Some(v) = version { + obj.insert("version".to_owned(), json!(v)); + } + self.endpoint.server_info = Some(serde_json::Value::Object(obj)); + self + } } /// A prebuilt mock for the room summary endpoint. diff --git a/crates/matrix-sdk/tests/integration/client.rs b/crates/matrix-sdk/tests/integration/client.rs index 625ba42c803..3987dadf95c 100644 --- a/crates/matrix-sdk/tests/integration/client.rs +++ b/crates/matrix-sdk/tests/integration/client.rs @@ -1916,7 +1916,12 @@ async fn test_server_vendor_info() { let server = MatrixMockServer::new().await; let client = server.client_builder().build().await; - // Mock the federation version endpoint + // With unstable-msc4383 on, server_vendor_info reads /versions first; mock + // it as Ok without the MSC4383 server object so the federation fallback + // path is the one under test. + #[cfg(feature = "unstable-msc4383")] + server.mock_versions().ok().mount().await; + server.mock_federation_version().ok("Synapse", "1.70.0").mount().await; let server_info = client.server_vendor_info(None).await.unwrap(); @@ -1957,6 +1962,11 @@ async fn test_server_vendor_info_with_missing_fields() { let server = MatrixMockServer::new().await; let client = server.client_builder().build().await; + // With unstable-msc4383 on, /versions is the primary path; mock it as Ok + // without the MSC4383 server object so we fall back to federation. + #[cfg(feature = "unstable-msc4383")] + server.mock_versions().ok().mount().await; + // Mock the federation version endpoint with missing fields server.mock_federation_version().ok_empty().mount().await; @@ -1967,6 +1977,97 @@ async fn test_server_vendor_info_with_missing_fields() { assert_eq!(server_info.version, "unknown"); } +#[cfg(feature = "unstable-msc4383")] +#[async_test] +async fn test_server_vendor_info_uses_versions_when_present() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + server.mock_versions().with_server_info("Tuwunel", "1.6.2").ok().mount().await; + + let server_info = client.server_vendor_info(None).await.unwrap(); + + assert_eq!(server_info.server_name, "Tuwunel"); + assert_eq!(server_info.version, "1.6.2"); +} + +#[cfg(all(feature = "unstable-msc4383", feature = "federation-api"))] +#[async_test] +async fn test_server_vendor_info_falls_back_to_federation() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + server.mock_versions().ok().mount().await; + server.mock_federation_version().ok("Synapse", "1.70.0").mount().await; + + let server_info = client.server_vendor_info(None).await.unwrap(); + + assert_eq!(server_info.server_name, "Synapse"); + assert_eq!(server_info.version, "1.70.0"); +} + +#[cfg(feature = "unstable-msc4383")] +#[async_test] +async fn test_server_vendor_info_with_partial_msc4383_fields() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + server.mock_versions().with_server_info_partial(Some("Tuwunel"), None).ok().mount().await; + + let server_info = client.server_vendor_info(None).await.unwrap(); + + assert_eq!(server_info.server_name, "Tuwunel"); + assert_eq!(server_info.version, "unknown"); +} + +#[cfg(feature = "unstable-msc4383")] +#[async_test] +async fn test_server_vendor_info_with_empty_msc4383_object() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + // Empty `server: {}` is treated as a present-but-empty advertisement, not + // as absent; the federation fallback (if compiled in) is not consulted. + server.mock_versions().with_server_info_partial(None, None).ok().mount().await; + + let server_info = client.server_vendor_info(None).await.unwrap(); + + assert_eq!(server_info.server_name, "unknown"); + assert_eq!(server_info.version, "unknown"); +} + +#[cfg(feature = "unstable-msc4383")] +#[async_test] +async fn test_server_vendor_info_propagates_versions_error() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + server.mock_versions().error500().mount().await; + + // Even with the federation fallback compiled in, an error on the + // MSC4383 read path must not be silently swallowed. + client + .server_vendor_info(Some(RequestConfig::new().disable_retry())) + .await + .expect_err("server_vendor_info should propagate transport errors on /versions"); +} + +#[cfg(all(feature = "unstable-msc4383", not(feature = "federation-api")))] +#[async_test] +async fn test_server_vendor_info_unknown_without_federation_fallback() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + // /versions returns Ok but no MSC4383 server object; with federation-api + // disabled there is no fallback, so the result is the sentinel "unknown". + server.mock_versions().ok().mount().await; + + let server_info = client.server_vendor_info(None).await.unwrap(); + + assert_eq!(server_info.server_name, "unknown"); + assert_eq!(server_info.version, "unknown"); +} + #[async_test] async fn test_fetch_thread_subscriptions() { let server = MatrixMockServer::new().await;