//! The calls against a Matrix homeserver's client-server API shared by the //! swarm and the hive: the appservice calls (register, log in, ask a token who //! it is), the room calls `swarm-controller`'s admin-room commands and //! `hive-c0re`'s Space and chat-room provisioning need (resolve an alias, list //! members and joined rooms, read and set room state, create a room, invite, send //! a message, check an account exists), and the password login and logout //! `swarm-controller` runs for an operator-linked account on any homeserver. //! Shared by `swarm-matrix-ctl`, `swarm-controller`, `hive-c0re` and //! `hive-matrix-mcp`. See the README for why both appservice mints go through //! the same device id. //! //! 🩸 **Every error in this module is built from the response's `status` and //! its `errcode`, never its body.** A successful `/register` or `/login` body //! *is* an access token, and an error body is one malformed response away from //! being the same bytes — so a `body: {json}` in a message here would put the //! sender token in the journal. use anyhow::{Context, Result, bail}; /// Every call here is one client-server API round trip; a slow one is a broken /// one. const TIMEOUT_SECS: u64 = 10; /// Bound on one password login, replacing [`TIMEOUT_SECS`] for that request. /// A password login makes the homeserver hash the password before it answers, /// so it is looser than a plain API call needs. const PASSWORD_LOGIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); /// Bytes of the throwaway password `/register` is given. /// /// Protocol overhead, and stored nowhere: this account authenticates by access /// token, and the recovery path when that token is lost is the appservice login /// below rather than anything a password could open. const PASSWORD_BYTES: usize = 32; /// What the homeserver said about a registration attempt. /// /// An enum rather than a string match on the error text: `M_USER_IN_USE` is an /// *expected* answer — a registration's own `sender_localpart` is created by /// the homeserver at startup, and an agent minted once before already exists — /// and an expected answer should not have to be recovered from a formatted /// message. pub enum Registered { /// A fresh account, and the access token minted with it. Token(String), /// The account is already there; it has to be logged into instead. AlreadyExists, } /// An HTTP client with this module's timeout. /// /// # Errors /// When the TLS backend will not initialise, which is the only way building a /// client fails. pub fn client() -> Result { reqwest::Client::builder() .timeout(std::time::Duration::from_secs(TIMEOUT_SECS)) .build() .context("building the HTTP client for the homeserver") } /// Create `localpart`'s account as the appservice and return its access token. /// /// One round trip: an appservice-typed registration needs no UIAA stage, so /// there is no session to carry and no shared registration secret in the /// picture. The `device_id` is pinned so that a later [`appservice_login`] /// replaces this device rather than accumulating one per run. /// /// # Errors /// When the request cannot be sent, the response will not decode, or the /// homeserver refuses with anything other than `M_USER_IN_USE` — which is /// [`Registered::AlreadyExists`] rather than an error. pub async fn register( client: &reqwest::Client, base: &str, localpart: &str, as_token: &str, ) -> Result { let body = serde_json::json!({ // What makes this an appservice registration rather than an ordinary // one: without it the homeserver asks for a UIAA flow even though the // request carries the as_token. "type": "m.login.application_service", "username": localpart, "password": random_hex(PASSWORD_BYTES)?, "device_id": device_id(localpart), "initial_device_display_name": format!("hyperhive ({localpart})"), "inhibit_login": false, }); let (status, json) = post( client, &format!("{base}/_matrix/client/v3/register?kind=user"), as_token, &body, ) .await .context("POST /register as the appservice")?; if status.is_success() { return Ok(Registered::Token(access_token(&json)?)); } if errcode(&json) == Some("M_USER_IN_USE") { return Ok(Registered::AlreadyExists); } bail!( "the homeserver refused to register @{localpart}: {}", why(status, &json) ); } /// Log in as an **existing** account using the appservice's authority, and /// return a fresh access token for it. /// /// No password: the appservice is authorised for every localpart in its /// namespace, so it mints a session for one without knowing anything about the /// account — which is just as well, since an account the homeserver created for /// its own registration has none. /// /// # Errors /// When the request cannot be sent, the response will not decode, or the /// homeserver refuses. pub async fn appservice_login( client: &reqwest::Client, base: &str, localpart: &str, as_token: &str, ) -> Result { let body = serde_json::json!({ "type": "m.login.application_service", "identifier": { "type": "m.id.user", "user": localpart, }, // Matching `register`'s, so a re-login REPLACES that device's token // rather than leaving a second live device behind. "device_id": device_id(localpart), "initial_device_display_name": format!("hyperhive ({localpart})"), }); let (status, json) = post( client, &format!("{base}/_matrix/client/v3/login"), as_token, &body, ) .await .context("POST /login as the appservice")?; if !status.is_success() { bail!( "the homeserver refused to log in @{localpart}: {}", why(status, &json) ); } access_token(&json) } /// Log in to **any** homeserver as `user` with `password`, and return the /// fresh access token with the full user id it authenticates as. /// /// For an account somewhere else that an operator hands over a password for. /// The appservice authorises nothing here, so the request carries no bearer /// token, and the device id is the homeserver's to choose. /// /// # Errors /// When the request cannot be sent, the response will not decode, the /// homeserver refuses, or its answer carries no `access_token` or `user_id`. pub async fn password_login( client: &reqwest::Client, base: &str, user: &str, password: &str, ) -> Result<(String, String)> { let body = serde_json::json!({ "type": "m.login.password", "identifier": { "type": "m.id.user", "user": user }, "password": password, "initial_device_display_name": "hyperhive", }); let (status, json) = exchange(password_login_request(client, base, &body)) .await .context("POST /login with a password")?; if !status.is_success() { bail!("the homeserver refused to log in: {}", why(status, &json)); } let user_id = json["user_id"] .as_str() .map(str::to_owned) .context("the homeserver's login response carried no `user_id`")?; Ok((access_token(&json)?, user_id)) } /// The `/login` request of [`password_login`], carrying its own /// [`PASSWORD_LOGIN_TIMEOUT`]. fn password_login_request( client: &reqwest::Client, base: &str, body: &serde_json::Value, ) -> reqwest::RequestBuilder { client .post(format!("{base}/_matrix/client/v3/login")) .json(body) .timeout(PASSWORD_LOGIN_TIMEOUT) } /// Invalidate `token` at the homeserver. /// /// `M_UNKNOWN_TOKEN` is success: the homeserver already does not recognise the /// token, which is the state a logout wants. /// /// # Errors /// When the request cannot be sent, or the homeserver refuses with anything /// other than `M_UNKNOWN_TOKEN`. pub async fn logout(client: &reqwest::Client, base: &str, token: &str) -> Result<()> { let resp = client .post(format!("{base}/_matrix/client/v3/logout")) .bearer_auth(token) .json(&serde_json::json!({})) .send() .await .context("POST /logout: sending the request")?; let status = resp.status(); if status.is_success() { return Ok(()); } // A refusal from behind a proxy is not a matrix error object; its status // is still worth reporting. let json = resp .json::() .await .unwrap_or(serde_json::Value::Null); if status == reqwest::StatusCode::UNAUTHORIZED && errcode(&json) == Some("M_UNKNOWN_TOKEN") { return Ok(()); } bail!("the homeserver refused to log out: {}", why(status, &json)) } /// What the homeserver says a token is. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Whoami { /// A live token, and the full user id it authenticates as. User(String), /// `M_UNKNOWN_TOKEN`: the homeserver does not know this token, because a /// later login on the same device replaced it or it was revoked. UnknownToken, } /// Ask the homeserver who `token` is. /// /// # Errors /// When the request cannot be sent, the response will not decode, or the /// homeserver refuses with anything other than `M_UNKNOWN_TOKEN`. That one is /// [`Whoami::UnknownToken`], because it is a verdict about the token and the /// rest are not. pub async fn whoami(client: &reqwest::Client, base: &str, token: &str) -> Result { let resp = client .get(format!("{base}/_matrix/client/v3/account/whoami")) .bearer_auth(token) .send() .await .context("GET /account/whoami: sending the request")?; let status = resp.status(); let json = resp .json::() .await .context("GET /account/whoami: decoding the response as JSON")?; whoami_verdict(status, &json) } /// [`whoami`]'s reading of a response, apart from the IO. fn whoami_verdict(status: reqwest::StatusCode, json: &serde_json::Value) -> Result { if status.is_success() { return json["user_id"] .as_str() .map(|u| Whoami::User(u.to_owned())) .context("the homeserver's whoami response carried no `user_id`"); } if errcode(json) == Some("M_UNKNOWN_TOKEN") { return Ok(Whoami::UnknownToken); } bail!("the homeserver refused whoami: {}", why(status, json)) } /// The room id `alias` names. /// /// # Errors /// When the request cannot be sent, the response will not decode, or the /// homeserver refuses. pub async fn resolve_alias( client: &reqwest::Client, base: &str, token: &str, alias: &str, ) -> Result { let (status, json) = get( client, &format!( "{base}/_matrix/client/v3/directory/room/{}", path_segment(alias) ), token, ) .await .with_context(|| format!("GET /directory/room for {alias}"))?; if !status.is_success() { bail!( "the homeserver refused to resolve {alias}: {}", why(status, &json) ); } json["room_id"] .as_str() .map(str::to_owned) .with_context(|| format!("the homeserver's answer for {alias} carried no `room_id`")) } /// The user ids joined to `room_id`. The token's own user must be one of them. /// /// # Errors /// When the request cannot be sent, the response will not decode, or the /// homeserver refuses. pub async fn joined_members( client: &reqwest::Client, base: &str, token: &str, room_id: &str, ) -> Result> { let (status, json) = get( client, &format!( "{base}/_matrix/client/v3/rooms/{}/joined_members", path_segment(room_id) ), token, ) .await .with_context(|| format!("GET /rooms/{room_id}/joined_members"))?; joined_verdict(status, &json).with_context(|| format!("the members of {room_id}")) } /// [`joined_members`]'s reading of a response, apart from the IO. fn joined_verdict( status: reqwest::StatusCode, json: &serde_json::Value, ) -> Result> { if !status.is_success() { bail!("the homeserver refused to list them: {}", why(status, json)); } json["joined"] .as_object() .map(|joined| joined.keys().cloned().collect()) .context("the homeserver's member list carried no `joined`") } /// Whether `user_id` has an account on this homeserver. /// /// Asked through the profile endpoint, which answers `M_NOT_FOUND` for a /// local user id with no account. /// /// # Errors /// When the request cannot be sent, the response will not decode, or the /// homeserver refuses with anything other than `M_NOT_FOUND`. pub async fn user_exists( client: &reqwest::Client, base: &str, token: &str, user_id: &str, ) -> Result { let (status, json) = get( client, &format!("{base}/_matrix/client/v3/profile/{}", path_segment(user_id)), token, ) .await .with_context(|| format!("GET /profile/{user_id}"))?; profile_verdict(status, &json).with_context(|| format!("{user_id}'s profile")) } /// [`user_exists`]'s reading of a response, apart from the IO. /// /// Only `M_NOT_FOUND` means "no account". Any other refusal says nothing /// about the account, so it is an error rather than a `false` a caller would /// act on. fn profile_verdict(status: reqwest::StatusCode, json: &serde_json::Value) -> Result { if status.is_success() { return Ok(true); } if errcode(json) == Some("M_NOT_FOUND") { return Ok(false); } bail!("the homeserver refused: {}", why(status, json)) } /// Send `body` to `room_id` as an `m.text` message, and return its event id. /// /// # Errors /// When the request cannot be sent, the response will not decode, or the /// homeserver refuses. pub async fn send_text( client: &reqwest::Client, base: &str, token: &str, room_id: &str, body: &str, ) -> Result { let resp = client .put(format!( "{base}/_matrix/client/v3/rooms/{}/send/m.room.message/{}", path_segment(room_id), random_hex(8)? )) .bearer_auth(token) .json(&serde_json::json!({ "msgtype": "m.text", "body": body })) .send() .await .with_context(|| format!("PUT a message into {room_id}: sending the request"))?; let status = resp.status(); let json = resp .json::() .await .with_context(|| format!("PUT a message into {room_id}: decoding the response"))?; if !status.is_success() { bail!( "the homeserver refused a message into {room_id}: {}", why(status, &json) ); } json["event_id"] .as_str() .map(str::to_owned) .with_context(|| { format!("the homeserver's answer to a message into {room_id} carried no `event_id`") }) } /// What the homeserver says a token is, with the device it belongs to. #[derive(Debug, Clone, PartialEq, Eq)] pub enum WhoamiSession { /// A live token: the user id it authenticates as, and its device id, which /// the spec lets a homeserver omit. Session { user_id: String, device_id: Option, }, /// `M_UNKNOWN_TOKEN`, as for [`Whoami::UnknownToken`]. UnknownToken, } /// [`whoami`] for a caller that restores a session and so needs the device id /// too. /// /// # Errors /// As [`whoami`]. pub async fn whoami_session( client: &reqwest::Client, base: &str, token: &str, ) -> Result { let (status, json) = get( client, &format!("{base}/_matrix/client/v3/account/whoami"), token, ) .await .context("GET /account/whoami")?; whoami_session_verdict(status, &json) } /// [`whoami_session`]'s reading of a response, apart from the IO. fn whoami_session_verdict( status: reqwest::StatusCode, json: &serde_json::Value, ) -> Result { match whoami_verdict(status, json)? { Whoami::User(user_id) => Ok(WhoamiSession::Session { user_id, device_id: json["device_id"].as_str().map(str::to_owned), }), Whoami::UnknownToken => Ok(WhoamiSession::UnknownToken), } } /// The ids of the rooms `token`'s user has joined. /// /// # Errors /// When the request cannot be sent, the response will not decode, the /// homeserver refuses, or the answer carries no `joined_rooms`. pub async fn joined_rooms( client: &reqwest::Client, base: &str, token: &str, ) -> Result> { let (status, json) = get( client, &format!("{base}/_matrix/client/v3/joined_rooms"), token, ) .await .context("GET /joined_rooms")?; if !status.is_success() { bail!( "the homeserver refused to list joined rooms: {}", why(status, &json) ); } Ok(json["joined_rooms"] .as_array() .context("the homeserver's answer carried no `joined_rooms`")? .iter() .filter_map(|room| room.as_str().map(str::to_owned)) .collect()) } /// The content of `room_id`'s `event_type` state event under `state_key` /// (empty for most), or `None` when the room has none. /// /// # Errors /// When the request cannot be sent, the response will not decode, or the /// homeserver refuses with anything other than 404. A 404 is the expected /// answer for a state event never set, so it is `None`; every other refusal /// says nothing about the event, so it is an error. pub async fn room_state( client: &reqwest::Client, base: &str, token: &str, room_id: &str, event_type: &str, state_key: &str, ) -> Result> { let (status, json) = get( client, &state_url(base, room_id, event_type, state_key), token, ) .await .with_context(|| format!("GET {event_type} state of {room_id}"))?; if status.is_success() { return Ok(Some(json)); } if status == reqwest::StatusCode::NOT_FOUND { return Ok(None); } bail!( "the homeserver refused to read {event_type} of {room_id}: {}", why(status, &json) ) } /// Set `room_id`'s `event_type` state event under `state_key` to `content`. /// /// # Errors /// When the request cannot be sent, the response will not decode, or the /// homeserver refuses. pub async fn put_state( client: &reqwest::Client, base: &str, token: &str, room_id: &str, event_type: &str, state_key: &str, content: &serde_json::Value, ) -> Result<()> { let (status, json) = exchange( client .put(state_url(base, room_id, event_type, state_key)) .bearer_auth(token) .json(content), ) .await .with_context(|| format!("PUT {event_type} state into {room_id}"))?; if !status.is_success() { bail!( "the homeserver refused to set {event_type} in {room_id}: {}", why(status, &json) ); } Ok(()) } /// The state-event URL for `room_id`, `event_type` and `state_key`. fn state_url(base: &str, room_id: &str, event_type: &str, state_key: &str) -> String { format!( "{base}/_matrix/client/v3/rooms/{}/state/{event_type}/{}", path_segment(room_id), path_segment(state_key) ) } /// Create a room from the `createRoom` request `body` and return its id. /// /// # Errors /// When the request cannot be sent, the response will not decode, the /// homeserver refuses, or the answer carries no `room_id`. pub async fn create_room( client: &reqwest::Client, base: &str, token: &str, body: &serde_json::Value, ) -> Result { let (status, json) = post( client, &format!("{base}/_matrix/client/v3/createRoom"), token, body, ) .await .context("POST /createRoom")?; if !status.is_success() { bail!( "the homeserver refused to create the room: {}", why(status, &json) ); } json["room_id"] .as_str() .map(str::to_owned) .context("the homeserver's createRoom answer carried no `room_id`") } /// What the homeserver did with an invite. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Invited { /// The invite went out. Sent, /// HTTP 403. `M_FORBIDDEN` covers both "already in the room" and a real /// refusal (banned target, sender lacks power), so the caller has to /// tell them apart by the target's membership. Forbidden, } /// Invite `user_id` to `room_id`. /// /// # Errors /// When the request cannot be sent, the response will not decode, or the /// homeserver refuses with anything other than 403, which is /// [`Invited::Forbidden`]. pub async fn invite( client: &reqwest::Client, base: &str, token: &str, room_id: &str, user_id: &str, ) -> Result { let (status, json) = post( client, &format!( "{base}/_matrix/client/v3/rooms/{}/invite", path_segment(room_id) ), token, &serde_json::json!({ "user_id": user_id }), ) .await .with_context(|| format!("POST /rooms/{room_id}/invite for {user_id}"))?; if status.is_success() { return Ok(Invited::Sent); } if status == reqwest::StatusCode::FORBIDDEN { return Ok(Invited::Forbidden); } bail!( "the homeserver refused to invite {user_id} to {room_id}: {}", why(status, &json) ) } /// `value` percent-encoded for one URL path segment: room ids, aliases and /// user ids carry `!`, `#`, `@` and `:`, and `#` would otherwise end the path. fn path_segment(value: &str) -> String { use std::fmt::Write as _; value.bytes().fold(String::new(), |mut acc, b| { if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') { acc.push(char::from(b)); } else { // Infallible, for `random_hex`'s reason. let _ = write!(acc, "%{b:02X}"); } acc }) } /// One authenticated GET, returning the status beside the decoded body. async fn get( client: &reqwest::Client, url: &str, token: &str, ) -> Result<(reqwest::StatusCode, serde_json::Value)> { let resp = client .get(url) .bearer_auth(token) .send() .await .context("sending the request")?; let status = resp.status(); let json = resp .json::() .await .context("decoding the response as JSON")?; Ok((status, json)) } /// The device every token this binary mints is pinned to. fn device_id(localpart: &str) -> String { format!("hyperhive-{localpart}") } /// One authenticated JSON POST, returning the status beside the decoded body. async fn post( client: &reqwest::Client, url: &str, as_token: &str, body: &serde_json::Value, ) -> Result<(reqwest::StatusCode, serde_json::Value)> { exchange(client.post(url).bearer_auth(as_token).json(body)).await } /// Send `request`, returning the status beside the decoded body. async fn exchange( request: reqwest::RequestBuilder, ) -> Result<(reqwest::StatusCode, serde_json::Value)> { let resp = request.send().await.context("sending the request")?; let status = resp.status(); let json = resp .json::() .await .context("decoding the response as JSON")?; Ok((status, json)) } /// Pull `access_token` out of a successful response. fn access_token(json: &serde_json::Value) -> Result { json["access_token"] .as_str() .map(str::to_owned) // Not `{json}`: on the success path this object holds the credential, // and a response missing the field is exactly when a reflex to print it // would fire. .context("the homeserver's response carried no `access_token`") } /// The matrix error code, when the body is a standard error object. fn errcode(json: &serde_json::Value) -> Option<&str> { json["errcode"].as_str() } /// Everything about a refusal that is safe to put in a message. /// /// The `errcode` is a closed vocabulary from the spec and the status is a /// number; between them they say which of the ladder's arms was taken. The /// `error` string beside them is free-form homeserver text, so it stays out. fn why(status: reqwest::StatusCode, json: &serde_json::Value) -> String { match errcode(json) { Some(code) => format!("HTTP {status}, errcode {code}"), None => format!("HTTP {status}, no errcode"), } } /// `bytes` random bytes from `/dev/urandom`, as hex: [`register`]'s /// throwaway password, and the appservice tokens `swarm-matrix-ctl` mints. /// /// From `/dev/urandom` directly rather than through an RNG crate: the kernel is /// already the source any such crate would reach for here. /// /// # Errors /// When `/dev/urandom` cannot be read. pub fn random_hex(bytes: usize) -> Result { use std::io::Read as _; let mut buf = vec![0u8; bytes]; std::fs::File::open("/dev/urandom") .context("opening /dev/urandom")? .read_exact(&mut buf) .context("reading from /dev/urandom")?; Ok(buf.iter().fold(String::new(), |mut acc, b| { use std::fmt::Write as _; // Infallible: `write!` into a `String` only fails if the formatter // does, and `{:02x}` of a `u8` has nothing to fail at. let _ = write!(acc, "{b:02x}"); acc })) } #[cfg(test)] mod tests { use super::*; #[test] fn a_password_login_is_bounded_at_30s_and_a_logout_at_the_clients_10s() { let client = client().expect("client"); let login = password_login_request(&client, "http://hs.invalid", &serde_json::json!({})) .build() .expect("login request"); assert_eq!(login.timeout(), Some(&std::time::Duration::from_secs(30))); // Control: a request that sets no timeout of its own reads as `None`, // so the bound above is the login's, not a default every request has. let logout = client .post("http://hs.invalid/_matrix/client/v3/logout") .build() .expect("logout request"); assert_eq!(logout.timeout(), None); } #[test] fn both_calls_pin_the_same_device_so_a_relogin_replaces_it() { // The whole reason the recovery arm is safe to take repeatedly: an // unpinned login mints a NEW device each time, and a homeserver // accumulating devices for this account is one where revoking the credential // means finding all of them. assert_eq!(device_id("hive"), "hyperhive-hive"); } #[test] fn a_refusal_is_described_by_its_errcode_and_never_by_its_body() { // 🩸 The invariant this module exists to keep. `error` is free-form // homeserver text and `access_token` is the credential itself; a // message built from the body would carry whichever of them the // response happened to hold. let json = serde_json::json!({ "errcode": "M_FORBIDDEN", "error": "some free-form text", "access_token": "syt_the_actual_secret", }); let message = why(reqwest::StatusCode::FORBIDDEN, &json); assert!(message.contains("M_FORBIDDEN"), "{message}"); assert!(!message.contains("syt_the_actual_secret"), "{message}"); assert!(!message.contains("free-form"), "{message}"); } #[test] fn a_refusal_with_no_errcode_still_produces_a_message() { // A homeserver behind a proxy answers with HTML, not a matrix error // object. The status is then the only thing there is to say, and // saying it is better than an empty report. let message = why(reqwest::StatusCode::BAD_GATEWAY, &serde_json::json!({})); assert!(message.contains("502"), "{message}"); } #[test] fn the_expected_already_exists_answer_is_recognised_by_its_errcode() { // Matched on the spec's code rather than on message text, because this // is the arm a healthy homeserver takes every time: an account minted // on an earlier pass already exists. let json = serde_json::json!({ "errcode": "M_USER_IN_USE", "error": "User ID taken" }); assert_eq!(errcode(&json), Some("M_USER_IN_USE")); } #[test] fn a_response_without_a_token_is_an_error_that_does_not_quote_it() { let e = access_token(&serde_json::json!({ "user_id": "@hive:t.local" })) .expect_err("no access_token in this object"); assert!(!format!("{e}").contains("@hive:t.local"), "{e}"); } #[test] fn whoami_reads_the_user_of_a_live_token() { let json = serde_json::json!({ "user_id": "@atlas:t.local", "device_id": "hyperhive-atlas" }); assert_eq!( whoami_verdict(reqwest::StatusCode::OK, &json).expect("a live token"), Whoami::User("@atlas:t.local".to_owned()) ); } #[test] fn a_replaced_token_is_a_verdict_and_not_an_error() { // The arm a mint decides on: this is how a token some other login on // the same device replaced looks. let json = serde_json::json!({ "errcode": "M_UNKNOWN_TOKEN", "error": "Unknown token" }); assert_eq!( whoami_verdict(reqwest::StatusCode::UNAUTHORIZED, &json).expect("a verdict"), Whoami::UnknownToken ); } #[test] fn any_other_refusal_is_an_error_so_nobody_mints_on_it() { // The control for the arm above: a 401 with another code, or a 5xx, // says nothing about the token, and a caller that minted on it would // rotate every token during an outage. for (status, json) in [ ( reqwest::StatusCode::UNAUTHORIZED, serde_json::json!({ "errcode": "M_MISSING_TOKEN" }), ), (reqwest::StatusCode::BAD_GATEWAY, serde_json::json!({})), ] { assert!(whoami_verdict(status, &json).is_err(), "{status}"); } } #[test] fn a_profile_answer_means_the_account_exists() { let json = serde_json::json!({ "displayname": "mara" }); assert!(profile_verdict(reqwest::StatusCode::OK, &json).expect("a verdict")); } #[test] fn not_found_is_no_account_and_not_an_error() { let json = serde_json::json!({ "errcode": "M_NOT_FOUND", "error": "Profile was not found." }); assert!(!profile_verdict(reqwest::StatusCode::NOT_FOUND, &json).expect("a verdict")); } #[test] fn any_other_profile_refusal_is_an_error_and_not_no_account() { // The control for the arm above: a 403 says nothing about whether the // account exists, and reading it as "no account" would skip a grant. for (status, json) in [ ( reqwest::StatusCode::FORBIDDEN, serde_json::json!({ "errcode": "M_FORBIDDEN" }), ), (reqwest::StatusCode::NOT_FOUND, serde_json::json!({})), (reqwest::StatusCode::BAD_GATEWAY, serde_json::json!({})), ] { assert!(profile_verdict(status, &json).is_err(), "{status} {json}"); } } #[test] fn the_joined_members_are_the_keys_of_joined() { let json = serde_json::json!({ "joined": { "@swarm:t.local": { "display_name": "swarm" }, "@mara:t.local": {}, }}); let members = joined_verdict(reqwest::StatusCode::OK, &json).expect("a member list"); assert_eq!( members.into_iter().collect::>(), ["@mara:t.local", "@swarm:t.local"] ); } #[test] fn a_member_list_without_joined_or_a_refusal_is_an_error() { // Never an empty set: that would read as "nobody is admin yet" and // send a grant for every operator. assert!(joined_verdict(reqwest::StatusCode::OK, &serde_json::json!({})).is_err()); assert!( joined_verdict( reqwest::StatusCode::FORBIDDEN, &serde_json::json!({ "errcode": "M_FORBIDDEN" }) ) .is_err() ); } #[test] fn a_path_segment_escapes_what_would_end_or_split_the_path() { assert_eq!(path_segment("#admins:t.local"), "%23admins%3At.local"); assert_eq!(path_segment("!abc:t.local"), "%21abc%3At.local"); assert_eq!(path_segment("@a.b_c-d:t.local"), "%40a.b_c-d%3At.local"); assert_eq!(path_segment("a/b"), "a%2Fb"); } #[test] fn a_minted_password_is_hex_of_the_declared_length() { // The control on the hex fold: a short or non-hex password would be // accepted by the homeserver and only surface much later, if at all. let pw = random_hex(PASSWORD_BYTES).expect("/dev/urandom is readable"); assert_eq!(pw.len(), PASSWORD_BYTES * 2); assert!(pw.bytes().all(|b| b.is_ascii_hexdigit()), "not hex"); } /// The `Authorization` header and JSON body of a request. type Request = (Option, serde_json::Value); /// What a stub homeserver saw of its one request. #[derive(Default, Clone)] struct Seen(std::sync::Arc>>); impl Seen { fn take(&self) -> Request { self.0 .lock() .expect("not poisoned") .take() .expect("the stub saw a request") } } /// A homeserver on a loopback port that answers `path` with `status` and /// `body`, and records the request's `Authorization` header and JSON body. async fn stub(path: &'static str, status: u16, body: &'static str) -> (String, Seen) { let seen = Seen::default(); let recorder = seen.clone(); let app = axum::Router::new().route( path, axum::routing::post( move |headers: axum::http::HeaderMap, axum::Json(sent): axum::Json| async move { let auth = headers .get(axum::http::header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) .map(str::to_owned); *recorder.0.lock().expect("not poisoned") = Some((auth, sent)); ( axum::http::StatusCode::from_u16(status).expect("a status"), [(axum::http::header::CONTENT_TYPE, "application/json")], body, ) }, ), ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("a loopback port"); let addr = listener.local_addr().expect("a bound address"); tokio::spawn(async move { axum::serve(listener, app).await }); (format!("http://{addr}"), seen) } const LOGIN: &str = "/_matrix/client/v3/login"; const LOGOUT: &str = "/_matrix/client/v3/logout"; #[tokio::test] async fn a_password_login_sends_the_password_unauthenticated_and_reads_both_fields() { let (base, seen) = stub( LOGIN, 200, r#"{"access_token":"syt_new","user_id":"@a:t.local"}"#, ) .await; let (token, user_id) = password_login(&client().expect("a client"), &base, "a", "hunter2") .await .expect("the stub accepts"); assert_eq!(token, "syt_new"); assert_eq!(user_id, "@a:t.local"); let (auth, sent) = seen.take(); assert_eq!(auth, None, "a password login carries no bearer token"); assert_eq!( sent, serde_json::json!({ "type": "m.login.password", "identifier": { "type": "m.id.user", "user": "a" }, "password": "hunter2", "initial_device_display_name": "hyperhive", }) ); } #[tokio::test] async fn a_refused_password_login_is_described_by_its_errcode_only() { let (base, _) = stub( LOGIN, 403, r#"{"errcode":"M_FORBIDDEN","error":"free-form text","access_token":"syt_leak"}"#, ) .await; let e = password_login(&client().expect("a client"), &base, "a", "hunter2") .await .expect_err("the stub refuses"); let message = format!("{e:#}"); assert!(message.contains("403"), "{message}"); assert!(message.contains("M_FORBIDDEN"), "{message}"); assert!(!message.contains("free-form"), "{message}"); assert!(!message.contains("syt_leak"), "{message}"); } #[tokio::test] async fn a_login_answer_missing_a_field_is_an_error_naming_the_field() { let (base, _) = stub(LOGIN, 200, r#"{"access_token":"syt_new"}"#).await; let e = password_login(&client().expect("a client"), &base, "a", "hunter2") .await .expect_err("no user_id"); let message = format!("{e:#}"); assert!(message.contains("user_id"), "{message}"); assert!(!message.contains("syt_new"), "{message}"); let (base, _) = stub(LOGIN, 200, r#"{"user_id":"@a:t.local"}"#).await; let e = password_login(&client().expect("a client"), &base, "a", "hunter2") .await .expect_err("no access_token"); assert!(format!("{e:#}").contains("access_token"), "{e:#}"); } #[tokio::test] async fn a_logout_presents_the_token_it_invalidates() { let (base, seen) = stub(LOGOUT, 200, "{}").await; logout(&client().expect("a client"), &base, "t0k3n") .await .expect("the stub accepts"); let (auth, sent) = seen.take(); assert_eq!(auth.as_deref(), Some("Bearer t0k3n")); assert_eq!(sent, serde_json::json!({})); } /// A revoke whose token the homeserver already does not recognise has /// reached logout's end state, not failed it. #[tokio::test] async fn logout_treats_401_m_unknown_token_as_already_revoked() { let (base, _) = stub( LOGOUT, 401, r#"{"errcode":"M_UNKNOWN_TOKEN","error":"Access token invalid"}"#, ) .await; logout(&client().expect("a client"), &base, "t0k3n") .await .expect("already revoked"); } /// The control: a 401 with a different errcode, and a non-JSON refusal /// from a proxy, are still failures, so the fold above is specific to /// `M_UNKNOWN_TOKEN`. #[tokio::test] async fn logout_fails_on_any_other_refusal() { for (status, body) in [ (401, r#"{"errcode":"M_FORBIDDEN","error":"nope"}"#), (403, r#"{"errcode":"M_UNKNOWN_TOKEN"}"#), (502, "bad gateway"), ] { let (base, _) = stub(LOGOUT, status, body).await; let e = logout(&client().expect("a client"), &base, "t0k3n") .await .expect_err("not M_UNKNOWN_TOKEN on a 401"); assert!(format!("{e:#}").contains(&status.to_string()), "{e:#}"); } } /// A homeserver on a loopback port that answers every request with /// `status` and `body`. async fn any_stub(status: u16, body: &'static str) -> String { let app = axum::Router::new().fallback(move || async move { ( axum::http::StatusCode::from_u16(status).expect("a status"), [(axum::http::header::CONTENT_TYPE, "application/json")], body, ) }); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("a loopback port"); let addr = listener.local_addr().expect("a bound address"); tokio::spawn(async move { axum::serve(listener, app).await }); format!("http://{addr}") } const LEAKY: &str = r#"{"errcode":"M_UNKNOWN","error":"free-form text","access_token":"syt_leak"}"#; /// A refusal names the status and errcode and quotes nothing else of the /// body. fn assert_refusal_hides_body(e: &anyhow::Error) { let message = format!("{e:#}"); assert!(message.contains("500"), "{message}"); assert!(message.contains("M_UNKNOWN"), "{message}"); assert!(!message.contains("free-form"), "{message}"); assert!(!message.contains("syt_leak"), "{message}"); } #[tokio::test] async fn every_room_call_refuses_without_quoting_the_body() { let base = any_stub(500, LEAKY).await; let c = client().expect("a client"); let body = serde_json::json!({}); assert_refusal_hides_body(&joined_rooms(&c, &base, "t").await.expect_err("refused")); assert_refusal_hides_body( &room_state(&c, &base, "t", "!r:x", "m.room.name", "") .await .expect_err("refused"), ); assert_refusal_hides_body( &put_state(&c, &base, "t", "!r:x", "m.room.name", "", &body) .await .expect_err("refused"), ); assert_refusal_hides_body( &create_room(&c, &base, "t", &body) .await .expect_err("refused"), ); assert_refusal_hides_body( &invite(&c, &base, "t", "!r:x", "@a:x") .await .expect_err("refused"), ); // Control: the same body behind a 403 is the one verdict `invite` returns. let forbidden = any_stub(403, LEAKY).await; assert_eq!( invite(&c, &forbidden, "t", "!r:x", "@a:x") .await .expect("a verdict"), Invited::Forbidden ); } #[tokio::test] async fn room_state_reads_content_and_treats_404_as_absent() { let c = client().expect("a client"); let base = any_stub(200, r#"{"name":"shared"}"#).await; assert_eq!( room_state(&c, &base, "t", "!r:x", "m.room.name", "") .await .expect("content"), Some(serde_json::json!({"name":"shared"})) ); let base = any_stub(404, r#"{"errcode":"M_NOT_FOUND"}"#).await; assert_eq!( room_state(&c, &base, "t", "!r:x", "m.room.name", "") .await .expect("absent"), None ); } #[tokio::test] async fn joined_rooms_and_create_room_read_their_fields() { let c = client().expect("a client"); let base = any_stub(200, r#"{"joined_rooms":["!a:x","!b:x"]}"#).await; assert_eq!( joined_rooms(&c, &base, "t").await.expect("rooms"), ["!a:x", "!b:x"] ); assert!( create_room(&c, &base, "t", &serde_json::json!({})) .await .is_err() ); let base = any_stub(200, r#"{"room_id":"!new:x"}"#).await; assert_eq!( create_room(&c, &base, "t", &serde_json::json!({})) .await .expect("room"), "!new:x" ); assert!(joined_rooms(&c, &base, "t").await.is_err()); } #[test] fn a_state_url_encodes_room_and_key_and_keeps_an_empty_key_slash() { assert_eq!( state_url("http://h", "!r:x", "m.room.create", ""), "http://h/_matrix/client/v3/rooms/%21r%3Ax/state/m.room.create/" ); assert_eq!( state_url("http://h", "!r:x", "m.space.child", "!c:x"), "http://h/_matrix/client/v3/rooms/%21r%3Ax/state/m.space.child/%21c%3Ax" ); } #[test] fn whoami_session_carries_the_device_id_when_the_homeserver_sends_one() { let ok = reqwest::StatusCode::OK; assert_eq!( whoami_session_verdict(ok, &serde_json::json!({"user_id":"@a:x","device_id":"D"})) .expect("live"), WhoamiSession::Session { user_id: "@a:x".to_owned(), device_id: Some("D".to_owned()) } ); assert_eq!( whoami_session_verdict(ok, &serde_json::json!({"user_id":"@a:x"})).expect("live"), WhoamiSession::Session { user_id: "@a:x".to_owned(), device_id: None } ); assert_eq!( whoami_session_verdict( reqwest::StatusCode::UNAUTHORIZED, &serde_json::json!({"errcode":"M_UNKNOWN_TOKEN"}) ) .expect("a verdict"), WhoamiSession::UnknownToken ); } }