diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 803a259a..7f4846ac 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -307,16 +307,17 @@ fn persist_space_room_id(room_id: &str) -> Result<()> { .with_context(|| format!("matrix: write space room_id to {}", path.display())) } -/// 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 -/// room-id file recovers the existing space instead of spawning a -/// duplicate. `None` if the homeserver is unreachable or no match exists. -/// -/// Name-based (not alias-based) rediscovery keeps the Space free of any -/// special-char room alias — the hardcoded plain name is the anchor. -async fn find_space_by_name(client: &reqwest::Client, sender_token: &str) -> Option { - let base = matrix_http()?; +/// Walk the admin account's joined rooms at `base` and return the first room +/// named `name` that is an `m.space` exactly when `want_space` is set (the +/// `m.room.create` `type`). `None` if the homeserver is unreachable or no +/// room matches. +async fn find_joined_room( + client: &reqwest::Client, + base: &str, + sender_token: &str, + 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) @@ -333,7 +334,7 @@ async fn find_space_by_name(client: &reqwest::Client, sender_token: &str) -> Opt continue; }; let encoded = encode_room_id_for_url(room_id); - // Must be an m.space (m.room.create `type`). + // 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) @@ -348,17 +349,17 @@ async fn find_space_by_name(client: &reqwest::Client, sender_token: &str) -> Opt .is_some_and(|c| c["type"].as_str() == Some("m.space")), _ => false, }; - if !is_space { + if is_space != want_space { continue; } - // …and named HIVE_SPACE_NAME (m.room.name `name`). + // …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(HIVE_SPACE_NAME)), + .is_some_and(|n| n["name"].as_str() == Some(name)), _ => false, }; if name_matches { @@ -368,6 +369,18 @@ async fn find_space_by_name(client: &reqwest::Client, sender_token: &str) -> Opt None } +/// 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 +/// room-id file recovers the existing space instead of spawning a +/// duplicate. `None` if the homeserver is unreachable or no match exists. +/// +/// Name-based (not alias-based) rediscovery keeps the Space free of any +/// special-char room alias — the hardcoded plain name is the anchor. +async fn find_space_by_name(client: &reqwest::Client, sender_token: &str) -> Option { + find_joined_room(client, matrix_http()?, sender_token, true, HIVE_SPACE_NAME).await +} + /// Create (or recover) the hive Matrix Space and persist its room ID to /// [`hive_space_room_id_path()`]. The Space is a private `m.space` owned by /// `@hive-:`, identified by its hardcoded name [`HIVE_SPACE_NAME`] (no alias). @@ -448,56 +461,14 @@ fn persist_chat_room_id(room_id: &str) -> Result<()> { /// room instead of spawning a duplicate. `None` if the homeserver is /// unreachable or no match exists. async fn find_chat_room_by_name(client: &reqwest::Client, sender_token: &str) -> Option { - let base = matrix_http()?; - 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() - .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); - // Skip the Space itself (and any other m.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() - .await - { - Ok(r) if r.status().is_success() => r - .json::() - .await - .ok() - .is_some_and(|c| c["type"].as_str() == Some("m.space")), - _ => false, - }; - if is_space { - continue; - } - // …and named HIVE_CHAT_ROOM_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(HIVE_CHAT_ROOM_NAME)), - _ => false, - }; - if name_matches { - return Some(room_id.to_owned()); - } - } - None + find_joined_room( + client, + matrix_http()?, + sender_token, + false, + HIVE_CHAT_ROOM_NAME, + ) + .await } /// Whether the state event still has to be written. @@ -1181,4 +1152,53 @@ mod tests { 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. + async fn fake_homeserver() -> (String, tokio::task::JoinHandle<()>) { + let app = axum::Router::new().fallback(|uri: axum::http::Uri| async move { + let path = uri.path(); + let body = if path.ends_with("/joined_rooms") { + serde_json::json!({"joined_rooms": ["!decoy", "!space", "!chat"]}) + } else if path.ends_with("/m.room.create/") { + if path.contains("space") { + serde_json::json!({"type": "m.space"}) + } else { + serde_json::json!({}) + } + } else if path.ends_with("/m.room.name/") { + let name = if path.contains("decoy") { + "other" + } else { + "shared" + }; + serde_json::json!({"name": name}) + } else { + serde_json::json!({}) + }; + axum::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"); + }); + (base, server) + } + + #[tokio::test] + async fn find_joined_room_matches_a_space_only_when_a_space_is_wanted() { + let (base, server) = fake_homeserver().await; + let client = reqwest::Client::new(); + let want_space = find_joined_room(&client, &base, "tok", true, "shared").await; + let want_chat = find_joined_room(&client, &base, "tok", false, "shared").await; + let no_name = find_joined_room(&client, &base, "tok", true, "absent").await; + server.abort(); + assert_eq!(want_space.as_deref(), Some("!space")); + assert_eq!(want_chat.as_deref(), Some("!chat")); + assert_eq!(no_name, None); + } }