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..9ef55b69f0 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 { @@ -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/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/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 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); @@ -148,7 +150,7 @@ fn profile_content(event: &serde_json::Value) -> serde_json::Map Vec { - let lower_query = query.to_ascii_lowercase(); + let lower_query = fold_name(query); events .iter() .filter_map(|event| { @@ -161,8 +163,8 @@ fn name_search_profiles(events: &[serde_json::Value], query: &str) -> Vec Result<(), CliError> { Ok(()) } +/// Case-fold a user-facing name for matching. +/// +/// 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> { if s.is_empty() || s.len() > 64 { @@ -504,3 +511,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(""), ""); + } +} 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.