From 6bfccc51cbb09d0ba2bf934004571f806c0d5d1a Mon Sep 17 00:00:00 2001 From: Taksh Date: Sun, 16 Aug 2026 23:58:42 +0530 Subject: [PATCH 1/3] fix(cli): fold non-ASCII case when matching names Four user-facing name matchers lowercased with to_ascii_lowercase, which leaves every non-ASCII letter untouched. So a channel named EQUIPE with an accented E was unfindable by the obvious query, and buzz notes ls --author with an accented display name failed outright with "no user found": - channels list --name (substring and --exact) - users search (display_name and name) - notes ls --author (name to pubkey resolution) - channel templates (lookup by name) Desktop matches the same names with JavaScript's toLowerCase, which is Unicode-aware, so this was also a disagreement between the CLI and the app about what a query finds. All four now go through validate::fold_name. Hex, pubkeys and UUIDs keep to_ascii_lowercase: they are ASCII by construction, and a Unicode fold there would only invite a dotless-i surprise. Signed-off-by: Taksh --- crates/buzz-cli/src/commands/channel_templates.rs | 8 +++----- crates/buzz-cli/src/commands/channels.rs | 6 +++--- crates/buzz-cli/src/commands/notes.rs | 6 +++--- crates/buzz-cli/src/commands/users.rs | 8 ++++---- crates/buzz-cli/src/validate.rs | 15 +++++++++++++++ 5 files changed, 28 insertions(+), 15 deletions(-) diff --git a/crates/buzz-cli/src/commands/channel_templates.rs b/crates/buzz-cli/src/commands/channel_templates.rs index 25a5e6e3e9..dcda13717a 100644 --- a/crates/buzz-cli/src/commands/channel_templates.rs +++ b/crates/buzz-cli/src/commands/channel_templates.rs @@ -11,6 +11,7 @@ use std::path::{Path, PathBuf}; use serde::Deserialize; use crate::error::CliError; +use crate::validate::fold_name; /// Tauri bundle identifier for the production desktop app. `dirs::data_dir()` /// joined with this segment matches `app.path().app_data_dir()` exactly @@ -98,11 +99,8 @@ fn load_templates(path: &Path) -> Result, CliError> { /// (case-insensitive, exact match). Errors list available names if not found. pub fn find_template(path: &Path, name: &str) -> Result { let templates = load_templates(path)?; - let needle = name.to_ascii_lowercase(); - if let Some(t) = templates - .into_iter() - .find(|t| t.name.to_ascii_lowercase() == needle) - { + let needle = fold_name(name); + if let Some(t) = templates.into_iter().find(|t| fold_name(&t.name) == needle) { return Ok(t); } Err(CliError::NotFound(format!( diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 5cc745d7b9..ee072040db 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -11,7 +11,7 @@ use crate::client::{ use crate::commands::agents::fetch_archived_snapshot; use crate::commands::channel_templates::{self, ChannelTemplateRecord, TemplateAgentRoster}; use crate::error::CliError; -use crate::validate::{parse_uuid, read_or_stdin, validate_hex64, validate_uuid}; +use crate::validate::{fold_name, parse_uuid, read_or_stdin, validate_hex64, validate_uuid}; fn extract_channel_metadata(e: &serde_json::Value) -> serde_json::Value { serde_json::json!({ @@ -132,7 +132,7 @@ pub async fn cmd_search_channels( }); let arr = client.query_paginated(filter, limit).await?; - let needle = query.to_ascii_lowercase(); + let needle = fold_name(query); let mut matches: Vec = arr .iter() .filter_map(ChannelSummary::from_event) @@ -213,7 +213,7 @@ impl ChannelSummary { } fn name_matches(name: &str, needle_lower: &str, exact: bool) -> bool { - let hay = name.to_ascii_lowercase(); + let hay = fold_name(name); if exact { hay == needle_lower } else { diff --git a/crates/buzz-cli/src/commands/notes.rs b/crates/buzz-cli/src/commands/notes.rs index 08ef345be1..b210950eff 100644 --- a/crates/buzz-cli/src/commands/notes.rs +++ b/crates/buzz-cli/src/commands/notes.rs @@ -32,7 +32,7 @@ use nostr::{Event, EventBuilder, Kind, PublicKey, Tag, Timestamp, ToBech32}; use crate::client::BuzzClient; use crate::error::CliError; -use crate::validate::validate_hex64; +use crate::validate::{fold_name, validate_hex64}; /// NIP-23 long-form content kind. pub const KIND_LONG_FORM: u16 = 30023; @@ -217,7 +217,7 @@ pub async fn resolve_author(client: &BuzzClient, author_flag: &str) -> Result = events .iter() .filter(|e| { @@ -229,7 +229,7 @@ pub async fn resolve_author(client: &BuzzClient, author_flag: &str) -> Result serde_json::Map Vec { - let lower_query = query.to_ascii_lowercase(); + let lower_query = fold_name(query); events .iter() .filter_map(|event| { @@ -161,8 +161,8 @@ fn name_search_profiles(events: &[serde_json::Value], query: &str) -> Vec Result<(), CliError> { Ok(()) } +/// Case-fold a user-facing name for matching. +/// +/// Names are arbitrary Unicode — channel names, display names, template +/// names — so folding them with `to_ascii_lowercase` leaves every non-ASCII +/// letter untouched and `ÉQUIPE` never matches `équipe`. Desktop uses +/// JavaScript's `toLowerCase`, which is Unicode-aware, so an ASCII-only fold +/// here also means the CLI and the app disagree about what a query finds. +/// +/// Use this for names only. Hex, pubkeys and UUIDs are ASCII by construction +/// and stay on `to_ascii_lowercase`, which cannot be surprised by a Turkish +/// dotless i. +pub fn fold_name(name: &str) -> String { + name.to_lowercase() +} + /// Validate a git repo identifier: `[a-zA-Z0-9._-]{1,64}`, no leading dots, no `..`. pub fn validate_repo_id(s: &str) -> Result<(), CliError> { if s.is_empty() || s.len() > 64 { From 012aee8b184ee7fd5de1c95bc99e533ebcec0a7e Mon Sep 17 00:00:00 2001 From: Taksh Date: Sun, 16 Aug 2026 23:59:16 +0530 Subject: [PATCH 2/3] test(cli): pin name folding against JavaScript's toLowerCase Every expectation in fold_name_tests is node's toLowerCase output for the same input, because Desktop is the other implementation of this rule and the two have to agree about what a query finds. Covers Latin accents, Cyrillic, the German eszett, a titlecase digraph, and the Turkish dotted capital I, whose fold adds a combining dot. name_matches gains the substring and exact cases that were unreachable before, plus a non-match so the fold has not made matching sloppy. Reverting fold_name to to_ascii_lowercase turns both new tests red. Signed-off-by: Taksh --- crates/buzz-cli/src/commands/channels.rs | 15 ++++++++++++ crates/buzz-cli/src/validate.rs | 30 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index ee072040db..9ef55b69f0 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -1198,6 +1198,7 @@ mod tests { ResolvedAgent, RosterResolution, SkippedSlug, }; use crate::client::BuzzClient; + use crate::validate::fold_name; use crate::CliError; use serde_json::json; @@ -1283,6 +1284,20 @@ mod tests { assert!(!name_matches("design", "composer", false)); } + #[test] + fn name_matches_folds_non_ascii_case() { + // `to_ascii_lowercase` left these untouched, so a channel named in any + // language with cased non-ASCII letters could not be found by the + // obvious query — while Desktop, on JavaScript's `toLowerCase`, found + // it. The needle is pre-folded by the caller, as the comment above says. + assert!(name_matches("ÉQUIPE", &fold_name("équipe"), true)); + assert!(name_matches("ÉQUIPE Produit", &fold_name("équipe"), false)); + assert!(name_matches("ОБЩИЙ", &fold_name("общий"), true)); + assert!(name_matches("Ünnepek", &fold_name("ünnepek"), true)); + // Still a non-match, so the fold has not made matching sloppy. + assert!(!name_matches("ÉQUIPE", &fold_name("equipe"), true)); + } + #[test] fn name_matches_exact_case_insensitive() { assert!(name_matches("Buzz", "buzz", true)); diff --git a/crates/buzz-cli/src/validate.rs b/crates/buzz-cli/src/validate.rs index fd640824dc..93718590f0 100644 --- a/crates/buzz-cli/src/validate.rs +++ b/crates/buzz-cli/src/validate.rs @@ -519,3 +519,33 @@ mod tests { assert!(matches!(err, CliError::Usage(_))); } } + +#[cfg(test)] +mod fold_name_tests { + use super::fold_name; + + /// Every expectation is JavaScript's `toLowerCase` output for the same + /// input, taken from node, because Desktop is the other implementation of + /// this rule and the two have to agree about what a query finds. + #[test] + fn folds_the_same_way_javascript_does() { + for (input, expected) in [ + ("ÉQUIPE", "équipe"), + ("ОБЩИЙ", "общий"), + ("Ünnepek", "ünnepek"), + ("JOSÉ", "josé"), + ("Straße", "straße"), + ("Džungla", "džungla"), + ("İstanbul", "i̇stanbul"), + ("ÅNGSTRÖM", "ångström"), + ] { + assert_eq!(fold_name(input), expected, "folding {input}"); + } + } + + #[test] + fn leaves_ascii_alone_as_before() { + assert_eq!(fold_name("Buzz-Chat-Composer"), "buzz-chat-composer"); + assert_eq!(fold_name(""), ""); + } +} From 50ebc2bc5adcfa15046f4e7a7f136505c01ffb3b Mon Sep 17 00:00:00 2001 From: Taksh Date: Mon, 17 Aug 2026 08:46:23 +0530 Subject: [PATCH 3/3] fix(cli): fold the remaining name-resolution paths, and move the rule to the sdk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding (P1, themiguelamador on #6079): three user-facing lookups still matched names ASCII-only. - `buzz messages search --author`: `match_profiles_by_name` folded with `to_ascii_lowercase`, so a non-ASCII display name was reachable only by reproducing its exact case. - Owned-agent lookup: `eq_ignore_ascii_case` is case-blind for ASCII letters alone, so an agent named `Équipe` was unreachable as `équipe`. - Automatic `@Display Name` resolution: buzz-cli keyed its `name → pubkey` map with `to_ascii_lowercase` while `extract_at_mentions_with_known` compared known names with `eq_ignore_ascii_case` and returned ASCII-folded keys — so a member named `ÉQUIPE` was not resolved from `@équipe` at either end. `fold_name` moves to `buzz_sdk::mentions`, which is where it has to live: the SDK returns the folded keys and the CLI looks them up, so one copy of the rule is the only way the two can agree. buzz-cli re-exports it, leaving existing call sites unchanged. Matching a known name also can no longer slice the content at the known name's byte length, because folding can change that length — `İ` is two bytes and folds to `i̇`, which is three. `folded_prefix_len` folds the content one char at a time and reports how many *source* bytes it consumed, and the word- boundary check runs at that offset, so `@équipement` still does not match a member named `ÉQUIPE`. Signed-off-by: Taksh --- crates/buzz-cli/src/commands/messages.rs | 28 +++++- crates/buzz-cli/src/commands/users.rs | 20 ++++- crates/buzz-cli/src/validate.rs | 16 +--- crates/buzz-sdk/src/mentions.rs | 105 +++++++++++++++++++++-- 4 files changed, 147 insertions(+), 22 deletions(-) diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 40a9ae80b5..1582a9b37b 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -5,7 +5,7 @@ use uuid::Uuid; use crate::client::{normalize_events, normalize_write_response, BuzzClient}; use crate::error::CliError; use crate::validate::{ - infer_language, parse_event_id, parse_uuid, read_or_stdin, truncate_diff, + fold_name, infer_language, parse_event_id, parse_uuid, read_or_stdin, truncate_diff, validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES, }; use buzz_sdk::mentions::{ @@ -213,7 +213,9 @@ async fn resolve_content_mentions( continue; }; name_to_pubkeys - .entry(name.to_ascii_lowercase()) + // Folded exactly as `extract_at_mentions_with_known` folds the + // names it returns — these keys are looked up with those. + .entry(fold_name(name)) .or_default() .push(pubkey.to_string()); display_names.push(name.to_string()); @@ -529,7 +531,7 @@ async fn resolve_author(client: &BuzzClient, author: &str) -> Result Vec<(String, String)> { - let lower = name.to_ascii_lowercase(); + let lower = fold_name(name); let mut matches: Vec<(String, String)> = Vec::new(); for e in events { let Some(pubkey) = e.get("pubkey").and_then(|v| v.as_str()) else { @@ -547,7 +549,7 @@ fn match_profiles_by_name(events: &[serde_json::Value], name: &str) -> Vec<(Stri .and_then(|v| v.as_str()) .unwrap_or(""); let plain_name = content.get("name").and_then(|v| v.as_str()).unwrap_or(""); - if display_name.to_ascii_lowercase() == lower || plain_name.to_ascii_lowercase() == lower { + if fold_name(display_name) == lower || fold_name(plain_name) == lower { let shown = if display_name.is_empty() { plain_name } else { @@ -1342,6 +1344,24 @@ mod tests { assert_eq!(matches, vec![(PK_VALID_A.to_string(), "Aaron".to_string())]); } + /// `messages search --author` folded with `to_ascii_lowercase`, so a + /// non-ASCII display name was only reachable by typing its exact case. + #[test] + fn author_name_match_folds_non_ascii_case() { + let events = vec![ + profile_event(PK_VALID_A, Some("ÉQUIPE"), None), + profile_event(PK_VALID_B, None, Some("Общий")), + ]; + assert_eq!( + match_profiles_by_name(&events, "équipe"), + vec![(PK_VALID_A.to_string(), "ÉQUIPE".to_string())] + ); + assert_eq!( + match_profiles_by_name(&events, "ОБЩИЙ"), + vec![(PK_VALID_B.to_string(), "Общий".to_string())] + ); + } + #[test] fn author_name_ambiguity_returns_all_candidates() { let events = vec![ diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index 2d3d066aba..388a8be731 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -113,7 +113,9 @@ fn owned_agent_pubkeys_from_events(events: &[serde_json::Value], query: &str) -> let content: serde_json::Value = serde_json::from_str(event.get("content")?.as_str()?).ok()?; let name = content.get("name")?.as_str()?; - if !name.eq_ignore_ascii_case(query) { + // `eq_ignore_ascii_case` compares only ASCII letters case-blind, so + // an agent named `Équipe` was unreachable as `équipe`. + if fold_name(name) != fold_name(query) { return None; } let pubkey = extract_d_tag(event); @@ -593,6 +595,22 @@ mod tests { ); } + /// The lookup used `eq_ignore_ascii_case`, which is case-blind for ASCII + /// letters only, so a non-ASCII agent name could only be reached by + /// reproducing its exact case. + #[test] + fn owned_agent_lookup_matches_non_ascii_names_across_case() { + let events = vec![ + json!({"content": r#"{"name":"ÉQUIPE"}"#, "tags": [["d", "a"]]}), + json!({"content": r#"{"name":"Общий"}"#, "tags": [["d", "b"]]}), + ]; + assert_eq!( + owned_agent_pubkeys_from_events(&events, "équipe"), + vec!["a"] + ); + assert_eq!(owned_agent_pubkeys_from_events(&events, "ОБЩИЙ"), vec!["b"]); + } + #[test] fn owned_agent_lookup_ignores_malformed_events() { let events = vec![ diff --git a/crates/buzz-cli/src/validate.rs b/crates/buzz-cli/src/validate.rs index 93718590f0..2b305b4a1c 100644 --- a/crates/buzz-cli/src/validate.rs +++ b/crates/buzz-cli/src/validate.rs @@ -37,18 +37,10 @@ pub fn validate_hex64(s: &str) -> Result<(), CliError> { /// Case-fold a user-facing name for matching. /// -/// Names are arbitrary Unicode — channel names, display names, template -/// names — so folding them with `to_ascii_lowercase` leaves every non-ASCII -/// letter untouched and `ÉQUIPE` never matches `équipe`. Desktop uses -/// JavaScript's `toLowerCase`, which is Unicode-aware, so an ASCII-only fold -/// here also means the CLI and the app disagree about what a query finds. -/// -/// Use this for names only. Hex, pubkeys and UUIDs are ASCII by construction -/// and stay on `to_ascii_lowercase`, which cannot be surprised by a Turkish -/// dotless i. -pub fn fold_name(name: &str) -> String { - name.to_lowercase() -} +/// Re-exported from buzz-sdk, which owns the rule: `extract_at_mentions_with_known` +/// returns keys folded with it, so the CLI's `name → pubkey` maps have to be +/// built with the very same function, not a second copy of it. +pub use buzz_sdk::mentions::fold_name; /// Validate a git repo identifier: `[a-zA-Z0-9._-]{1,64}`, no leading dots, no `..`. pub fn validate_repo_id(s: &str) -> Result<(), CliError> { diff --git a/crates/buzz-sdk/src/mentions.rs b/crates/buzz-sdk/src/mentions.rs index e59580c7ae..d085dd9ffb 100644 --- a/crates/buzz-sdk/src/mentions.rs +++ b/crates/buzz-sdk/src/mentions.rs @@ -29,6 +29,50 @@ use std::collections::HashSet; +/// Case-fold a user-facing name for matching. +/// +/// Names are arbitrary Unicode — display names, channel names, agent names — +/// so folding them with `to_ascii_lowercase` leaves every non-ASCII letter +/// untouched and `ÉQUIPE` never matches `équipe`. Desktop uses JavaScript's +/// `toLowerCase`, which is Unicode-aware, so an ASCII-only fold here also +/// means the CLI and the app disagree about who a mention names. +/// +/// Lives here rather than in buzz-cli because both crates resolve names and +/// must fold them identically: buzz-cli builds its `name → pubkey` map with +/// this, and [`extract_at_mentions_with_known`] returns keys folded with it. +/// +/// Use this for names only. Hex, pubkeys and UUIDs are ASCII by construction +/// and stay on `to_ascii_lowercase`, which cannot be surprised by a Turkish +/// dotless i. +pub fn fold_name(name: &str) -> String { + name.to_lowercase() +} + +/// Length in **source** bytes of the prefix of `rest` that folds to +/// `folded_known`, if there is one. +/// +/// A folded prefix cannot be located by byte length, because folding can +/// change it: `İ` is two bytes and folds to `i̇`, which is three. Comparing +/// `rest[..known.len()]` would slice the wrong span — or panic on a non- +/// boundary. So fold `rest` one character at a time and report how much of the +/// original was consumed when the folds agree. +fn folded_prefix_len(rest: &str, folded_known: &str) -> Option { + if folded_known.is_empty() { + return None; + } + let mut folded = String::with_capacity(folded_known.len()); + for (offset, ch) in rest.char_indices() { + folded.extend(ch.to_lowercase()); + if !folded_known.starts_with(&folded) { + return None; + } + if folded == folded_known { + return Some(offset + ch.len_utf8()); + } + } + None +} + use nostr::{FromBech32, PublicKey}; /// Maximum number of mention p-tags allowed on a single message. @@ -109,10 +153,13 @@ pub fn extract_at_mentions_with_known(content: &str, known_names: &[&str]) -> Ve return vec![]; } - let mut sorted: Vec<&str> = known_names + // Folded once up front: the fold is what both the comparison and the + // returned key are made of, and it is the same for every `@` in the body. + let mut sorted: Vec = known_names .iter() .copied() .filter(|n| !n.trim().is_empty()) + .map(fold_name) .collect(); sorted.sort_by_key(|k| std::cmp::Reverse(k.len())); @@ -129,11 +176,11 @@ pub fn extract_at_mentions_with_known(content: &str, known_names: &[&str]) -> Ve continue; } - let lower = if let Some(&known) = sorted.iter().find(|&&k| { - rest.get(..k.len()) - .is_some_and(|s| s.eq_ignore_ascii_case(k) && is_word_boundary(&rest[k.len()..])) + let lower = if let Some(known) = sorted.iter().find_map(|k| { + let consumed = folded_prefix_len(rest, k)?; + is_word_boundary(&rest[consumed..]).then(|| k.clone()) }) { - known.to_ascii_lowercase() + known } else { let end = rest .find(|c: char| !c.is_ascii_alphanumeric() && !matches!(c, '.' | '-' | '_')) @@ -535,6 +582,54 @@ mod tests { assert_eq!(result, vec!["日本"]); } + /// The known-name comparison was `eq_ignore_ascii_case`, which folds only + /// ASCII letters, so a member named `ÉQUIPE` was not found from `@équipe` + /// and the mention resolved to nobody. + #[test] + fn known_name_matches_non_ascii_across_case() { + assert_eq!( + extract_at_mentions_with_known("cc @équipe please", &["ÉQUIPE"]), + vec!["équipe"] + ); + assert_eq!( + extract_at_mentions_with_known("cc @ОБЩИЙ please", &["Общий"]), + vec!["общий"] + ); + // Multi-word display names keep working across case too. + assert_eq!( + extract_at_mentions_with_known("cc @josé garcía!", &["José García"]), + vec!["josé garcía"] + ); + } + + /// Folding can change a name's byte length — `İ` is two bytes and folds to + /// `i̇`, which is three — so the match cannot be located by slicing the + /// content at the known name's length. It has to report how much of the + /// *source* it consumed. + #[test] + fn known_name_match_survives_a_length_changing_fold() { + assert_eq!( + extract_at_mentions_with_known("hi @İstanbul team", &["İstanbul"]), + vec![fold_name("İstanbul")] + ); + assert_ne!( + fold_name("İstanbul").len(), + "İstanbul".len(), + "the premise: this fold changes the byte length" + ); + } + + /// The boundary check runs on the source slice after the consumed prefix, + /// so a longer name must not be matched by its prefix. + #[test] + fn known_name_still_requires_a_word_boundary() { + assert_eq!( + extract_at_mentions_with_known("cc @équipement", &["ÉQUIPE"]), + Vec::::new(), + "a longer word must not match the shorter known name" + ); + } + #[test] fn unicode_known_name_with_ascii_content_no_panic() { // Reverse case: multi-byte known name against ASCII content.