diff --git a/Cargo.lock b/Cargo.lock index b6674478..00b94f85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1796,6 +1796,7 @@ dependencies = [ "serde_json", "sha2 0.11.0", "strum", + "swarm-matrix-client", "swarm-queue-client", "swarm-secret-client", "tempfile", @@ -1945,6 +1946,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "swarm-matrix-client", "swarm-secret-client", "tokio", "tracing", diff --git a/hive-c0re/Cargo.toml b/hive-c0re/Cargo.toml index a5fe5e1b..578a267c 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -61,6 +61,7 @@ swarm-queue-client = { workspace = true, features = [ "agent-permissions", "agent-archetype", ] } +swarm-matrix-client.workspace = true swarm-secret-client.workspace = true tokio.workspace = true tokio-stream.workspace = true diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 7f4846ac..16897c22 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -10,7 +10,6 @@ use std::path::PathBuf; use anyhow::{Context, Result}; -use reqwest::StatusCode; /// Client-server API base this daemon provisions against, from /// `HIVE_MATRIX_API_URL` (set by the hyperhive NixOS module from @@ -46,8 +45,6 @@ fn matrix_base() -> Result<&'static str> { this path should have been gated on matrix::is_present()", ) } -/// HTTP timeout for the sweep's homeserver round-trips. -const HTTP_TIMEOUT_SECS: u64 = 10; /// Matrix localpart this hive acts as. Not an agent; has no state dir. /// @@ -130,12 +127,6 @@ pub fn is_present() -> bool { matrix_http().is_some() } -/// Percent-encode a matrix room ID for use in a URL path segment. -/// Only `:` needs encoding; `!` and alphanumerics are path-safe. -fn encode_room_id_for_url(room_id: &str) -> String { - room_id.replace(':', "%3A") -} - /// Bring the hive's `@hive-:` sender token at [`sender_token_path()`] in /// line with the swarm secret store. /// @@ -318,57 +309,43 @@ async fn find_joined_room( want_space: bool, name: &str, ) -> Option { - let joined_url = format!("{base}/_matrix/client/v3/joined_rooms"); - let joined: serde_json::Value = client - .get(&joined_url) - .bearer_auth(sender_token) - .send() - .await - .ok()? - .json() + let rooms = swarm_matrix_client::joined_rooms(client, base, sender_token) .await .ok()?; - let rooms = joined["joined_rooms"].as_array()?; - for room in rooms { - let Some(room_id) = room.as_str() else { - continue; - }; - let encoded = encode_room_id_for_url(room_id); + for room_id in rooms { // m.room.create `type` says whether this is a Space. - let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/"); - let is_space = match client - .get(&create_url) - .bearer_auth(sender_token) - .send() + let is_space = keyless_state(client, base, sender_token, &room_id, "m.room.create") .await - { - Ok(r) if r.status().is_success() => r - .json::() - .await - .ok() - .is_some_and(|c| c["type"].as_str() == Some("m.space")), - _ => false, - }; + .is_some_and(|c| c["type"].as_str() == Some("m.space")); if is_space != want_space { continue; } // …and named `name` (m.room.name `name`). - let name_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/"); - let name_matches = match client.get(&name_url).bearer_auth(sender_token).send().await { - Ok(r) if r.status().is_success() => r - .json::() - .await - .ok() - .is_some_and(|n| n["name"].as_str() == Some(name)), - _ => false, - }; + let name_matches = keyless_state(client, base, sender_token, &room_id, "m.room.name") + .await + .is_some_and(|n| n["name"].as_str() == Some(name)); if name_matches { - return Some(room_id.to_owned()); + return Some(room_id); } } None } +/// The content of a state event with an empty state key, or `None` when the +/// room has none or the read failed. +async fn keyless_state( + client: &reqwest::Client, + base: &str, + sender_token: &str, + room_id: &str, + event_type: &str, +) -> Option { + swarm_matrix_client::room_state(client, base, sender_token, room_id, event_type, "") + .await + .ok() + .flatten() +} + /// Scan the admin account's joined rooms for the canonical hive Space: the /// `m.space` whose name is [`HIVE_SPACE_NAME`]. Returns the first match /// (deterministic per the homeserver's joined-rooms order) so a lost @@ -417,32 +394,15 @@ pub async fn ensure_hive_space(client: &reqwest::Client, sender_token: &str) -> } // 3. Create the space (plain hardcoded name, no alias). - let url = format!("{base}/_matrix/client/v3/createRoom"); let body = serde_json::json!({ "name": HIVE_SPACE_NAME, "creation_content": { "type": "m.space" }, "preset": "private_chat", "visibility": "private", }); - let resp = client - .post(&url) - .bearer_auth(sender_token) - .json(&body) - .send() + let room_id = swarm_matrix_client::create_room(client, base, sender_token, &body) .await - .context("matrix: POST /createRoom (hive space)")?; - let status = resp.status(); - let json = resp - .json::() - .await - .context("matrix: parse /createRoom response")?; - if !status.is_success() { - anyhow::bail!("matrix: createRoom HTTP {status}, body: {json}"); - } - let room_id = json["room_id"] - .as_str() - .with_context(|| format!("matrix: createRoom missing room_id: {json}"))? - .to_owned(); + .context("matrix: create hive space")?; persist_space_room_id(&room_id)?; tracing::info!(%room_id, "matrix: created hive space"); Ok(room_id) @@ -485,43 +445,40 @@ fn state_needs_write(current: Option<&serde_json::Value>, desired: &serde_json:: } /// Read the room's current content for one state event. `None` on any -/// failure — absent state, transport error, or an unparseable body are -/// all "we don't know", and [`state_needs_write`] turns that into a write. +/// failure — absent state, transport error, a refusal, or an unparseable body +/// are all "we don't know", and [`state_needs_write`] turns that into a write. /// -/// One failure is louder than the rest, and it is the only one that hides: -/// **a non-404 HTTP failure means the read broke while the write still -/// works**, so the guard degrades to writing every time and the sweep -/// resumes waking the hive with nothing else to show for it. A transport -/// error needs no warning of its own — the PUT immediately after it fails -/// too, loudly — and a 404 is the expected first-setup case. +/// A failure other than absent state is logged at `warn`: **a read that breaks +/// while the write still works** makes the guard degrade to writing every +/// time, and the sweep resumes waking the hive with nothing else to show for +/// it. An absent state event (404) is the expected first-setup case. async fn current_room_state( client: &reqwest::Client, + base: &str, sender_token: &str, - url: &str, + room_id: &str, + event_type: &str, + state_key: &str, ) -> Option { - let resp = match client.get(url).bearer_auth(sender_token).send().await { - Ok(resp) => resp, + match swarm_matrix_client::room_state( + client, + base, + sender_token, + room_id, + event_type, + state_key, + ) + .await + { + Ok(content) => content, Err(e) => { - tracing::debug!(error = ?e, url, "matrix: state read unreachable; writing"); - return None; - } - }; - let status = resp.status(); - if !status.is_success() { - if status != reqwest::StatusCode::NOT_FOUND { tracing::warn!( - %status, - url, + error = %format!("{e:#}"), + %room_id, + event_type, "matrix: state read failed while writes still work — the \ skip-if-unchanged guard is off and every sweep will re-emit" ); - } - return None; - } - match resp.json::().await { - Ok(body) => Some(body), - Err(e) => { - tracing::debug!(error = ?e, url, "matrix: state read body unparseable; writing"); None } } @@ -549,11 +506,8 @@ async fn set_room_state( content: &serde_json::Value, ) -> Result<()> { let base = matrix_base()?; - let encoded_room = encode_room_id_for_url(room_id); - let encoded_key = encode_room_id_for_url(state_key); - let url = - format!("{base}/_matrix/client/v3/rooms/{encoded_room}/state/{event_type}/{encoded_key}"); - let current = current_room_state(client, sender_token, &url).await; + let current = + current_room_state(client, base, sender_token, room_id, event_type, state_key).await; if !state_needs_write(current.as_ref(), content) { tracing::debug!( %room_id, @@ -562,19 +516,17 @@ async fn set_room_state( ); return Ok(()); } - let resp = client - .put(&url) - .bearer_auth(sender_token) - .json(content) - .send() - .await - .with_context(|| format!("matrix: PUT state {event_type} into {room_id}"))?; - let status = resp.status(); - if status.is_success() { - return Ok(()); - } - let body = resp.json::().await.unwrap_or_default(); - anyhow::bail!("matrix: set state {event_type} in {room_id}: HTTP {status}, body: {body}") + swarm_matrix_client::put_state( + client, + base, + sender_token, + room_id, + event_type, + state_key, + content, + ) + .await + .context("matrix: set room state") } /// Create (or recover) the default hive chat room and wire it as a child of @@ -622,7 +574,6 @@ pub async fn ensure_hive_chat_room( } else { // `initial_state` is applied after the preset-derived state, so the // restricted join rule overrides private_chat's invite-only default. - let url = format!("{base}/_matrix/client/v3/createRoom"); let body = serde_json::json!({ "name": HIVE_CHAT_ROOM_NAME, "topic": HIVE_CHAT_ROOM_TOPIC, @@ -653,25 +604,9 @@ pub async fn ensure_hive_chat_room( } ] }); - let resp = client - .post(&url) - .bearer_auth(sender_token) - .json(&body) - .send() + let id = swarm_matrix_client::create_room(client, base, sender_token, &body) .await - .context("matrix: POST /createRoom (hive chat room)")?; - let status = resp.status(); - let json = resp - .json::() - .await - .context("matrix: parse /createRoom response (chat room)")?; - if !status.is_success() { - anyhow::bail!("matrix: createRoom (chat) HTTP {status}, body: {json}"); - } - let id = json["room_id"] - .as_str() - .with_context(|| format!("matrix: createRoom (chat) missing room_id: {json}"))? - .to_owned(); + .context("matrix: create hive chat room")?; persist_chat_room_id(&id)?; tracing::info!(room_id = %id, "matrix: created hive chat room"); id @@ -719,36 +654,30 @@ async fn invite_to_room( async fn room_membership( client: &reqwest::Client, sender_token: &str, - encoded_room_id: &str, + room_id: &str, user_id: &str, ) -> Option { let base = matrix_http()?; - // `:` must be percent-encoded in both the room-id and user-id path - // segments; `@` and `!` are permitted path characters per RFC 3986. - let encoded_user = user_id.replace(':', "%3A"); - let url = format!( - "{base}/_matrix/client/v3/rooms/{encoded_room_id}/state/m.room.member/{encoded_user}" - ); - let resp = client - .get(&url) - .bearer_auth(sender_token) - .send() - .await - .ok()?; - if !resp.status().is_success() { - // 404 = no membership event yet; anything else we treat as "unknown" - // and let the caller fall through to the invite attempt. - return None; - } - let body = resp.json::().await.ok()?; - body["membership"].as_str().map(ToOwned::to_owned) + // A refused or failed read is "unknown": the caller falls through to the + // invite attempt. + let member = swarm_matrix_client::room_state( + client, + base, + sender_token, + room_id, + "m.room.member", + user_id, + ) + .await + .ok()??; + member["membership"].as_str().map(ToOwned::to_owned) } /// Invite a fully-qualified Matrix user id (`@user:server`) to `room_id` /// using the sender token. Idempotent: a user who is already a member or /// already has a pending invite is left untouched (no fresh invite is sent, /// so they are not re-notified). A refused invite is an error unless the -/// user turns out to be invited or joined anyway — see [`refused_invite`]. +/// user turns out to be invited or joined anyway — see [`forbidden_invite`]. async fn invite_user_id( client: &reqwest::Client, sender_token: &str, @@ -756,61 +685,45 @@ async fn invite_user_id( user_id: &str, ) -> Result<()> { let base = matrix_base()?; - // `:` must be percent-encoded in the room-id path segment; `!` is - // permitted in URL path characters per RFC 3986. - let encoded_room_id = room_id.replace(':', "%3A"); // Skip the invite entirely when the user is already invited or joined. // Re-POSTing an invite to a pending member re-sends the invite event, // which re-notifies the agent on every provisioning sweep. - if let Some(membership) = room_membership(client, sender_token, &encoded_room_id, user_id).await + if let Some(membership) = room_membership(client, sender_token, room_id, user_id).await && matches!(membership.as_str(), "invite" | "join") { tracing::debug!(%user_id, %room_id, %membership, "matrix: invite skipped (already a member/invited)"); return Ok(()); } - let url = format!("{base}/_matrix/client/v3/rooms/{encoded_room_id}/invite"); - let resp = client - .post(&url) - .bearer_auth(sender_token) - .json(&serde_json::json!({ "user_id": user_id })) - .send() + match swarm_matrix_client::invite(client, base, sender_token, room_id, user_id) .await - .with_context(|| format!("matrix: POST /rooms/.../invite for {user_id}"))?; - let status = resp.status(); - if status.is_success() { - tracing::debug!(%user_id, %room_id, "matrix: invited to room"); - return Ok(()); + .context("matrix: invite")? + { + swarm_matrix_client::Invited::Sent => { + tracing::debug!(%user_id, %room_id, "matrix: invited to room"); + Ok(()) + } + swarm_matrix_client::Invited::Forbidden => { + // The pre-check misses a member when its read failed or a + // concurrent invite landed after it, so a refusal re-reads the + // membership. + let membership = room_membership(client, sender_token, room_id, user_id).await; + forbidden_invite(membership.as_deref(), user_id, room_id) + } } - // The pre-check misses a member when its read failed or a concurrent - // invite landed after it, so a refusal re-reads the membership. - let membership = if status == StatusCode::FORBIDDEN { - room_membership(client, sender_token, &encoded_room_id, user_id).await - } else { - None - }; - refused_invite(resp, membership.as_deref(), user_id, room_id).await } -/// Outcome of an invite POST the homeserver did not accept. A 403 is success -/// only when `membership`, re-read after the refusal, shows the user invited -/// or joined: `M_FORBIDDEN` covers both "already in the room" and real -/// refusals (banned target, sender lacks power), so the errcode can't tell -/// them apart. -async fn refused_invite( - resp: reqwest::Response, - membership: Option<&str>, - user_id: &str, - room_id: &str, -) -> Result<()> { - let status = resp.status(); - if status == StatusCode::FORBIDDEN && matches!(membership, Some("invite" | "join")) { +/// Outcome of an invite the homeserver refused with 403. That is success only +/// when `membership`, re-read after the refusal, shows the user invited or +/// joined: `M_FORBIDDEN` covers both "already in the room" and real refusals +/// (banned target, sender lacks power), so the errcode can't tell them apart. +fn forbidden_invite(membership: Option<&str>, user_id: &str, room_id: &str) -> Result<()> { + if matches!(membership, Some("invite" | "join")) { tracing::debug!(%user_id, %room_id, "matrix: invite refused, user already member/invited"); return Ok(()); } - let body = resp.json::().await.unwrap_or_default(); - anyhow::bail!("matrix: invite {user_id} to {room_id}: HTTP {status}, body: {body}") + anyhow::bail!("matrix: invite {user_id} to {room_id}: HTTP 403 and the user is not invited or joined") } /// Invite an arbitrary Matrix user to the hive Space (default) or an @@ -841,7 +754,9 @@ pub async fn invite_user( // Resolve the room: explicit override (id or #alias) wins; otherwise // the persisted hive Space. let room_id = match room_override { - Some(r) if r.starts_with('#') => resolve_room_alias(client, sender_token, r).await?, + Some(r) if r.starts_with('#') => { + swarm_matrix_client::resolve_alias(client, matrix_base()?, sender_token, r).await? + } Some(r) if r.starts_with('!') => r.to_owned(), Some(r) => anyhow::bail!( "matrix: --room {r:?} is neither a room id nor an alias; \ @@ -859,32 +774,6 @@ pub async fn invite_user( Ok(room_id) } -/// Resolve a `#alias:server` to its room id via the directory API. -async fn resolve_room_alias( - client: &reqwest::Client, - sender_token: &str, - alias: &str, -) -> Result { - let base = matrix_base()?; - let encoded = alias.replace('#', "%23").replace(':', "%3A"); - let url = format!("{base}/_matrix/client/v3/directory/room/{encoded}"); - let resp = client - .get(&url) - .bearer_auth(sender_token) - .send() - .await - .with_context(|| format!("matrix: GET directory for {alias}"))?; - let status = resp.status(); - let json = resp.json::().await.unwrap_or_default(); - if !status.is_success() { - anyhow::bail!("matrix: resolve alias {alias}: HTTP {status}, body: {json}"); - } - json["room_id"] - .as_str() - .map(str::to_owned) - .ok_or_else(|| anyhow::anyhow!("matrix: alias {alias} response missing room_id: {json}")) -} - /// Make sure the hive's own `@hive-:` account exists, then provision /// the hive Space and chat room and invite every agent container to both. /// Agents' own accounts are not created here: `swarm-controller` mints them @@ -905,10 +794,7 @@ pub async fn ensure_all() -> bool { } let mut ok = true; // One HTTP client for the whole sweep. - let client = match reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS)) - .build() - { + let client = match swarm_matrix_client::client() { Ok(c) => c, Err(e) => { tracing::warn!(error = ?e, "matrix: build HTTP client failed; skipping sweep"); @@ -1105,54 +991,60 @@ mod tests { assert!(state_needs_write(None, &desired)); } - /// A homeserver response built in memory, so no client (and no TLS - /// roots) is needed to drive the response-handling halves. - fn response(status: u16, body: &'static str) -> reqwest::Response { - axum::http::Response::builder() - .status(status) - .body(body) - .expect("valid mock response") - .into() - } - - const FORBIDDEN_BODY: &str = r#"{"errcode":"M_FORBIDDEN","error":"refused"}"#; - - /// A banned target or a sender without power gets exactly this 403; - /// with no membership behind it, the invite did not happen. - #[tokio::test] - async fn a_refused_invite_without_membership_is_an_error() { + /// A banned target or a sender without power gets a 403; with no + /// membership behind it, the invite did not happen. + #[test] + fn a_refused_invite_without_membership_is_an_error() { for membership in [None, Some("ban"), Some("leave")] { - let outcome = - refused_invite(response(403, FORBIDDEN_BODY), membership, "@a:x", "!r:x").await; + let outcome = forbidden_invite(membership, "@a:x", "!r:x"); assert!(outcome.is_err(), "membership {membership:?} must not pass"); } } - #[tokio::test] - async fn a_refused_invite_for_a_member_is_success() { + #[test] + fn a_refused_invite_for_a_member_is_success() { for membership in ["invite", "join"] { - refused_invite( - response(403, FORBIDDEN_BODY), - Some(membership), - "@a:x", - "!r:x", - ) - .await - .expect("already invited/joined is the goal state"); + forbidden_invite(Some(membership), "@a:x", "!r:x") + .expect("already invited/joined is the goal state"); } } - /// Membership only excuses a 403; any other failure stays a failure. + /// The homeserver's reply body reaches no error: `invite_user_id` is + /// driven against a stub whose 500 and 403 bodies carry a marker. #[tokio::test] - async fn a_non_403_invite_failure_or_garbage_body_is_an_error() { - for (status, body) in [(500, "not json"), (403, "not json"), (429, "{}")] { - let outcome = refused_invite(response(status, body), None, "@a:x", "!r:x").await; - assert!(outcome.is_err(), "HTTP {status} {body:?} must fail"); + async fn an_invite_refusal_error_does_not_quote_the_response_body() { + const BODY: &str = r#"{"errcode":"M_UNKNOWN","error":"free-form-marker"}"#; + for status in [500_u16, 403] { + 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("bind"); + let base = format!("http://{}", listener.local_addr().expect("local addr")); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve"); + }); + let client = swarm_matrix_client::client().expect("client"); + let outcome = swarm_matrix_client::invite(&client, &base, "tok", "!r:x", "@a:x").await; + server.abort(); + let message = match (status, outcome) { + (403, Ok(swarm_matrix_client::Invited::Forbidden)) => { + format!("{:#}", forbidden_invite(None, "@a:x", "!r:x").unwrap_err()) + } + (500, Err(e)) => format!("{e:#}"), + (status, other) => panic!("HTTP {status}: unexpected {other:?}"), + }; + assert!(message.contains(&status.to_string()), "{message}"); + assert!(!message.contains("free-form-marker"), "{message}"); } - let outcome = refused_invite(response(500, "{}"), Some("join"), "@a:x", "!r:x").await; - assert!(outcome.is_err(), "a 500 is not excused by membership"); } + /// Serve a fake homeserver on loopback: `!space` is an `m.space` and /// `!chat` a plain room, both named "shared"; `!decoy` is a plain room /// named "other". Returns the base URL and the server task. diff --git a/hive-matrix-mcp/Cargo.toml b/hive-matrix-mcp/Cargo.toml index 9857502d..31f3a9b3 100644 --- a/hive-matrix-mcp/Cargo.toml +++ b/hive-matrix-mcp/Cargo.toml @@ -21,6 +21,7 @@ rmcp.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true +swarm-matrix-client.workspace = true swarm-secret-client.workspace = true tokio.workspace = true tracing.workspace = true diff --git a/swarm-matrix-client/src/lib.rs b/swarm-matrix-client/src/lib.rs index ecc3ff68..4533f4b9 100644 --- a/swarm-matrix-client/src/lib.rs +++ b/swarm-matrix-client/src/lib.rs @@ -429,6 +429,237 @@ pub async fn send_text( }) } +/// 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 { @@ -882,4 +1113,153 @@ mod tests { 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 + ); + } }