diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index e041eb28..4fd5e1c9 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -350,40 +350,68 @@ async fn discover_admin_room_id( }) } -/// Extract the new password from a conduit/tuwunel admin-room reset reply. +/// Try to parse the new password from an admin-room bot response. +/// Conduwuit/tuwunel responds with a message like: +/// "Done: Password of user @user:server has been reset. The new password is: " /// -/// The admin bot always renders the new password as a backtick code span. -/// The live reply observed in the `#admins` room is: -/// "Successfully reset the password for user @x:server: ``" -/// The surrounding prose varies between builds (the delimiter is `: ` after -/// the user id, not `" to:"`), so we anchor on the code span rather than -/// parsing the prose. Returns the content of the first backtick pair when the -/// message is a password-reset success. +/// Handles several format variants emitted by different tuwunel / conduwuit +/// versions — "new password is:", "password is:", "changed to:", etc. /// -/// Guard: an error reply can also code-span the *user id* ("@x:server"); a -/// real password has no whitespace and isn't a `@localpart:server` id, so we -/// reject that shape and return `None`. On `None` the caller surfaces the -/// timeout and `admin_room_send_and_poll` logs the unparsed body — so a -/// future format change is visible rather than silently mis-parsed. +/// Uses [`str::to_ascii_lowercase`] for case folding — unlike `to_lowercase`, +/// ASCII lowercasing is guaranteed to produce a same-byte-length string, so the +/// byte offset from `find` is always a valid index into the original `bot_message` +/// and we never slice at a non-char boundary. fn extract_new_password(bot_message: &str) -> Option { - // Only consider password-reset success replies. - if !bot_message.to_ascii_lowercase().contains("password") { - return None; + // ASCII lowercase: same byte length as the original, so positions from + // `lower.find(marker)` are valid byte indices into `bot_message`. + let lower = bot_message.to_ascii_lowercase(); + for marker in &[ + // Explicit "is:" variants (most common in conduwuit / tuwunel): + "new password is: ", + "new password is:", + "password is: ", + "password is:", + // "changed to:" / "reset to:" / "set to:" variants: + "changed to: ", + "changed to:", + "reset to: ", + "reset to:", + "set to: ", + "set to:", + // Generic "… to: " form. tuwunel's actual reset-password reply is + // "Successfully reset password for @user:server to: " — the + // password follows " to: " but no recognised verb sits adjacent to it, + // so the markers above miss it. Matrix user ids / server names can't + // contain " to: ", so this only ever anchors on the prose delimiter. + // Placed after the specific verb markers and before the bare + // "password:" last resort. + " to: ", + // Bare "new password:" without "is": + "new password: ", + "new password:", + // Bare "password:" as last resort (must come after more specific markers): + "password: ", + "password:", + ] { + if let Some(pos) = lower.find(marker) { + let rest = &bot_message[pos + marker.len()..]; + // Strip leading whitespace, then a leading code-span backtick: + // tuwunel renders the password as a code span, so the plain + // `body` carries literal backticks ("… to: ``"). + let rest = rest.trim_start().trim_start_matches('`'); + // The password ends at the first whitespace OR the closing + // backtick. Generated passwords contain neither, so this never + // truncates a real password mid-token. + let end = rest + .find(|c: char| c.is_whitespace() || c == '`') + .unwrap_or(rest.len()); + let pw = rest[..end].trim(); + if !pw.is_empty() { + return Some(pw.to_owned()); + } + } } - // Content of the first backtick code span. - let open = bot_message.find('`')?; - let after = &bot_message[open + 1..]; - let close = after.find('`')?; - let pw = &after[..close]; - // Reject a code-spanned matrix user id from an error reply, and any - // multi-token span — generated passwords are a single whitespace-free run. - if pw.is_empty() - || pw.contains(char::is_whitespace) - || (pw.starts_with('@') && pw.contains(':')) - { - return None; - } - Some(pw.to_owned()) + None } #[cfg(test)] @@ -391,56 +419,101 @@ mod extract_new_password_tests { use super::extract_new_password; #[test] - fn conduit_live_admin_room_format() { - // The exact reply observed in the live #admins room: ": " after the - // user id, password in a backtick code span. - let msg = "Successfully reset the password for user @triage:pr1ma.darkest.space: `hVfa6TpvIKnADoEJNWn9saHoI`"; - assert_eq!( - extract_new_password(msg).as_deref(), - Some("hVfa6TpvIKnADoEJNWn9saHoI") - ); + fn tuwunel_style_response() { + let msg = "Done: Password of user @atlas:pr1ma.darkest.space has been reset. The new password is: abc123XYZ!"; + assert_eq!(extract_new_password(msg).as_deref(), Some("abc123XYZ!")); } #[test] - fn backtick_span_anywhere_in_prose() { - // Wording around the code span is irrelevant — we anchor on the span. - let msg = "Done. New password is: `hunter2` (store it now)"; + fn case_insensitive_marker() { + let msg = "Password reset complete. New Password Is: S3cr3tP@ss"; + assert_eq!(extract_new_password(msg).as_deref(), Some("S3cr3tP@ss")); + } + + #[test] + fn marker_without_trailing_space() { + let msg = "new password is:hunter2"; assert_eq!(extract_new_password(msg).as_deref(), Some("hunter2")); } #[test] - fn password_with_symbols_inside_span() { - // '@' mid-token is fine — only a leading "@…:…" user-id shape is rejected. - let msg = "Successfully reset the password for user @atlas:pr1ma.darkest.space: `N3wP@ss-w0rd!`"; - assert_eq!(extract_new_password(msg).as_deref(), Some("N3wP@ss-w0rd!")); + fn shorter_marker_variant() { + let msg = "Your password is: Tr0ub4dor&3"; + assert_eq!(extract_new_password(msg).as_deref(), Some("Tr0ub4dor&3")); } #[test] - fn codespan_userid_in_error_not_mistaken_for_password() { - // An error that code-spans the user id must not yield it as a password. - let msg = "Failed to reset password for `@sock:pr1ma.darkest.space` — user not found"; - assert_eq!(extract_new_password(msg), None); - } - - #[test] - fn no_codespan_returns_none() { - // No backtick span → unparseable here; the caller logs the raw body - // so a genuinely new format surfaces instead of being mis-parsed. - let msg = "Password reset complete. New password is: abc123XYZ"; - assert_eq!(extract_new_password(msg), None); - } - - #[test] - fn non_password_message_returns_none() { + fn no_match_returns_none() { let msg = "Command not recognised. Please try again."; assert_eq!(extract_new_password(msg), None); } #[test] - fn empty_codespan_returns_none() { - let msg = "Successfully reset the password for user @x:server: ``"; + fn empty_after_marker_returns_none() { + let msg = "new password is: "; assert_eq!(extract_new_password(msg), None); } + + #[test] + fn password_stops_at_whitespace() { + let msg = "New password is: abc123 (save it now)"; + assert_eq!(extract_new_password(msg).as_deref(), Some("abc123")); + } + + #[test] + fn changed_to_variant() { + let msg = "Password of user @atlas:pr1ma.darkest.space has been changed to: Xyz987!"; + assert_eq!(extract_new_password(msg).as_deref(), Some("Xyz987!")); + } + + #[test] + fn reset_to_variant() { + let msg = "Password for user @foo:bar has been reset to: hunter2"; + assert_eq!(extract_new_password(msg).as_deref(), Some("hunter2")); + } + + #[test] + fn tuwunel_reset_password_for_to_variant() { + // The actual tuwunel admin-room reply observed in production — the + // verb ("reset") is not adjacent to "to:", so only the generic + // " to: " marker catches it. + let msg = "Successfully reset password for @atlas:pr1ma.darkest.space to: N3wP@ssw0rd"; + assert_eq!(extract_new_password(msg).as_deref(), Some("N3wP@ssw0rd")); + } + + #[test] + fn to_marker_not_confused_by_user_id() { + // The user id contains no " to: " so the marker only fires on the + // real delimiter; the password is the token right after it. + let msg = "Successfully reset password for @sock:pr1ma.darkest.space to: abc123XYZ"; + assert_eq!(extract_new_password(msg).as_deref(), Some("abc123XYZ")); + } + + #[test] + fn backtick_wrapped_password() { + // tuwunel renders the password as a code span; the plain body + // carries literal backticks. Strip them, don't capture them. + let msg = "Successfully reset password for @atlas:pr1ma.darkest.space to: `N3wP@ssw0rd`"; + assert_eq!(extract_new_password(msg).as_deref(), Some("N3wP@ssw0rd")); + } + + #[test] + fn backtick_wrapped_with_trailing_text() { + let msg = "Done. New password is: `hunter2` (store it now)"; + assert_eq!(extract_new_password(msg).as_deref(), Some("hunter2")); + } + + #[test] + fn bare_new_password_colon() { + let msg = "New password: P@ssword1"; + assert_eq!(extract_new_password(msg).as_deref(), Some("P@ssword1")); + } + + #[test] + fn bare_password_colon_last_resort() { + let msg = "Your account password: S3cr3t"; + assert_eq!(extract_new_password(msg).as_deref(), Some("S3cr3t")); + } } /// Send a command to the Matrix admin room and poll for a bot response.