refactor: drop speculative prose markers, keep only backtick extraction
Per operator review on the PR: the prose marker list ('new password is:',
' to:', 'changed to:', etc.) was speculative — built from a misread
screenshot, not a real reply. The conduit admin bot always code-spans the
password, so the backtick anchor is the verified, complete format. Removing
the markers leaves a ~15-line function that's honest about what it parses.
If a non-code-span format ever appears, extract_new_password returns None and
the diagnostic logging in admin_room_send_and_poll records the raw body, so a
real format change is visible — far better than a speculative marker silently
mis-parsing it. Tests now cover the live format, symbol passwords, the
code-spanned-user-id error guard, and the no-codespan / non-password cases.
This commit is contained in:
parent
2d8a68e4d1
commit
3858740488
1 changed files with 56 additions and 173 deletions
|
|
@ -350,189 +350,50 @@ async fn discover_admin_room_id(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try to parse the new password from an admin-room bot response.
|
/// Extract the new password from a conduit/tuwunel admin-room reset reply.
|
||||||
/// Conduwuit/tuwunel responds with a message like:
|
|
||||||
/// "Done: Password of user @user:server has been reset. The new password is: <password>"
|
|
||||||
///
|
///
|
||||||
/// Handles several format variants emitted by different tuwunel / conduwuit
|
/// The admin bot always renders the new password as a backtick code span.
|
||||||
/// versions — "new password is:", "password is:", "changed to:", etc.
|
/// The live reply observed in the `#admins` room is:
|
||||||
|
/// "Successfully reset the password for user @x:server: `<password>`"
|
||||||
|
/// 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.
|
||||||
///
|
///
|
||||||
/// Uses [`str::to_ascii_lowercase`] for case folding — unlike `to_lowercase`,
|
/// Guard: an error reply can also code-span the *user id* ("@x:server"); a
|
||||||
/// ASCII lowercasing is guaranteed to produce a same-byte-length string, so the
|
/// real password has no whitespace and isn't a `@localpart:server` id, so we
|
||||||
/// byte offset from `find` is always a valid index into the original `bot_message`
|
/// reject that shape and return `None`. On `None` the caller surfaces the
|
||||||
/// and we never slice at a non-char boundary.
|
/// timeout and `admin_room_send_and_poll` logs the unparsed body — so a
|
||||||
|
/// future format change is visible rather than silently mis-parsed.
|
||||||
fn extract_new_password(bot_message: &str) -> Option<String> {
|
fn extract_new_password(bot_message: &str) -> Option<String> {
|
||||||
// ASCII lowercase: same byte length as the original, so positions from
|
// Only consider password-reset success replies.
|
||||||
// `lower.find(marker)` are valid byte indices into `bot_message`.
|
if !bot_message.to_ascii_lowercase().contains("password") {
|
||||||
let lower = bot_message.to_ascii_lowercase();
|
return None;
|
||||||
|
}
|
||||||
// Primary strategy: the conduit/tuwunel admin bot always renders the new
|
// Content of the first backtick code span.
|
||||||
// password as a backtick code span. The live reply observed in the admin
|
let open = bot_message.find('`')?;
|
||||||
// room is:
|
let after = &bot_message[open + 1..];
|
||||||
// "Successfully reset the password for user @x:server: `<password>`"
|
let close = after.find('`')?;
|
||||||
// (note the delimiter is ": " after the user id, NOT " to:" — the prose
|
let pw = &after[..close];
|
||||||
// wording varies between builds, so anchoring on the code span is the
|
// Reject a code-spanned matrix user id from an error reply, and any
|
||||||
// robust extraction). Take the content of the first backtick pair when the
|
// multi-token span — generated passwords are a single whitespace-free run.
|
||||||
// message is a password-reset success. Guard against grabbing a code-spanned
|
if pw.is_empty()
|
||||||
// matrix user id ("@x:server") from an error message: a real password has
|
|| pw.contains(char::is_whitespace)
|
||||||
// no whitespace and isn't a `@localpart:server` id.
|
|| (pw.starts_with('@') && pw.contains(':'))
|
||||||
if lower.contains("password")
|
|
||||||
&& let Some(open) = bot_message.find('`')
|
|
||||||
{
|
{
|
||||||
let after = &bot_message[open + 1..];
|
return None;
|
||||||
if let Some(close) = after.find('`') {
|
|
||||||
let pw = &after[..close];
|
|
||||||
if !pw.is_empty()
|
|
||||||
&& !pw.contains(char::is_whitespace)
|
|
||||||
&& !(pw.starts_with('@') && pw.contains(':'))
|
|
||||||
{
|
|
||||||
return Some(pw.to_owned());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Some(pw.to_owned())
|
||||||
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: <pw>" form. tuwunel's actual reset-password reply is
|
|
||||||
// "Successfully reset password for @user:server to: <password>" — 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: `<pw>`").
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod extract_new_password_tests {
|
mod extract_new_password_tests {
|
||||||
use super::extract_new_password;
|
use super::extract_new_password;
|
||||||
|
|
||||||
#[test]
|
|
||||||
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 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 shorter_marker_variant() {
|
|
||||||
let msg = "Your password is: Tr0ub4dor&3";
|
|
||||||
assert_eq!(extract_new_password(msg).as_deref(), Some("Tr0ub4dor&3"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_match_returns_none() {
|
|
||||||
let msg = "Command not recognised. Please try again.";
|
|
||||||
assert_eq!(extract_new_password(msg), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
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]
|
#[test]
|
||||||
fn conduit_live_admin_room_format() {
|
fn conduit_live_admin_room_format() {
|
||||||
// The ACTUAL reply observed in the live #admins room — the delimiter
|
// The exact reply observed in the live #admins room: ": " after the
|
||||||
// is ": " after the user id (no " to:"), password in a code span.
|
// user id, password in a backtick code span.
|
||||||
let msg = "Successfully reset the password for user @triage:pr1ma.darkest.space: `hVfa6TpvIKnADoEJNWn9saHoI`";
|
let msg = "Successfully reset the password for user @triage:pr1ma.darkest.space: `hVfa6TpvIKnADoEJNWn9saHoI`";
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
extract_new_password(msg).as_deref(),
|
extract_new_password(msg).as_deref(),
|
||||||
|
|
@ -540,6 +401,20 @@ mod extract_new_password_tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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)";
|
||||||
|
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!"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn codespan_userid_in_error_not_mistaken_for_password() {
|
fn codespan_userid_in_error_not_mistaken_for_password() {
|
||||||
// An error that code-spans the user id must not yield it as a password.
|
// An error that code-spans the user id must not yield it as a password.
|
||||||
|
|
@ -548,15 +423,23 @@ mod extract_new_password_tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bare_new_password_colon() {
|
fn no_codespan_returns_none() {
|
||||||
let msg = "New password: P@ssword1";
|
// No backtick span → unparseable here; the caller logs the raw body
|
||||||
assert_eq!(extract_new_password(msg).as_deref(), Some("P@ssword1"));
|
// 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]
|
#[test]
|
||||||
fn bare_password_colon_last_resort() {
|
fn non_password_message_returns_none() {
|
||||||
let msg = "Your account password: S3cr3t";
|
let msg = "Command not recognised. Please try again.";
|
||||||
assert_eq!(extract_new_password(msg).as_deref(), Some("S3cr3t"));
|
assert_eq!(extract_new_password(msg), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_codespan_returns_none() {
|
||||||
|
let msg = "Successfully reset the password for user @x:server: ``";
|
||||||
|
assert_eq!(extract_new_password(msg), None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue