diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 7ad051ef9fc..15ad4039001 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::{parse_uuid, read_or_stdin, validate_hex64}; fn extract_channel_metadata(e: &serde_json::Value) -> serde_json::Value { serde_json::json!({ @@ -226,7 +226,14 @@ fn name_matches(name: &str, needle_lower: &str, exact: bool) -> bool { } pub async fn cmd_get_channel(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> { - validate_uuid(channel_id)?; + // Canonicalize before filtering. `validate_uuid` only checks that the input + // parses and throws the result away, but `Uuid::parse_str` accepts + // uppercase, the unhyphenated 32-character form, braces, and a `urn:uuid:` + // prefix. Every `d`/`h` tag in the tree is written in the canonical + // lowercase hyphenated form and a NIP-01 generic tag filter compares tag + // values byte for byte, so any other spelling matches nothing and the + // command prints an empty result instead of an error. + let channel_id = parse_uuid(channel_id)?.hyphenated().to_string(); let filter = serde_json::json!({ "kinds": [39000], "#d": [channel_id], @@ -249,7 +256,8 @@ pub async fn cmd_list_channel_members( client: &BuzzClient, channel_id: &str, ) -> Result<(), CliError> { - validate_uuid(channel_id)?; + // Same canonicalization as `cmd_get_channel`; see the note there. + let channel_id = parse_uuid(channel_id)?.hyphenated().to_string(); let filter = serde_json::json!({ "kinds": [39002], "#d": [channel_id], @@ -264,7 +272,8 @@ pub async fn cmd_list_channel_members( } pub async fn cmd_get_canvas(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> { - validate_uuid(channel_id)?; + // Same canonicalization as `cmd_get_channel`; see the note there. + let channel_id = parse_uuid(channel_id)?.hyphenated().to_string(); let filter = serde_json::json!({ "kinds": [40100], "#h": [channel_id] diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index ea273336e38..a5325efbfa2 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -6,7 +6,7 @@ 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, - validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES, + validate_content_size, validate_hex64, MAX_DIFF_BYTES, }; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_nostr_uris, strip_code_regions, MENTION_CAP, @@ -168,6 +168,13 @@ async fn resolve_content_mentions( return Ok((vec![], vec![])); } + // `cmd_send_message` hands us the raw `--channel` argument. The send path + // itself is safe (the SDK builders take a parsed `Uuid` and write the + // canonical form), but this preflight filters on the string, so a + // non-canonical spelling loads no roster and the send fails with + // "could not load channel membership" rather than with the parse error the + // input deserves. + let channel_id = parse_uuid(channel_id)?.hyphenated().to_string(); let members_filter = serde_json::json!({ "kinds": [39002], "#d": [channel_id], @@ -362,7 +369,14 @@ pub async fn cmd_get_messages( kinds: Option<&str>, format: &crate::OutputFormat, ) -> Result<(), CliError> { - validate_uuid(channel_id)?; + // Canonicalize before filtering. `validate_uuid` only checks that the input + // parses and throws the result away, but `Uuid::parse_str` accepts + // uppercase, the unhyphenated 32-character form, braces, and a `urn:uuid:` + // prefix. Every `h` tag in the tree is written in the canonical lowercase + // hyphenated form and a NIP-01 generic tag filter compares tag values byte + // for byte, so any other spelling matches nothing and the command prints an + // empty list instead of an error. + let channel_id = parse_uuid(channel_id)?.hyphenated().to_string(); let limit = limit.unwrap_or(50).min(200); let mut filter = serde_json::json!({ @@ -427,6 +441,9 @@ pub async fn cmd_get_thread( format: &crate::OutputFormat, ) -> Result<(), CliError> { let expected_channel_id = parse_uuid(channel_id)?; + // The parsed value already exists here; the filters below were still built + // from the raw argument. See `cmd_get_messages` for why that matters. + let channel_id = expected_channel_id.hyphenated().to_string(); validate_hex64(event_id)?; let selected_event = fetch_event(client, event_id).await?; let root_event_id = resolve_thread_target( diff --git a/crates/buzz-cli/src/validate.rs b/crates/buzz-cli/src/validate.rs index 4985b441417..dfeaa70b04d 100644 --- a/crates/buzz-cli/src/validate.rs +++ b/crates/buzz-cli/src/validate.rs @@ -220,6 +220,49 @@ mod tests { assert!(matches!(err, CliError::Usage(_))); } + /// `validate_uuid` passes four spellings of the same id, and only one of + /// them is what the tree writes into an `h` or `d` tag. A caller that + /// validates and then filters on the raw argument therefore sends a tag + /// value that a NIP-01 generic tag filter — which compares byte for byte — + /// matches nothing against, and prints an empty result instead of an error. + #[test] + fn validate_uuid_accepts_non_canonical_spellings() { + for spelling in [ + "550E8400-E29B-41D4-A716-446655440000", + "550e8400e29b41d4a716446655440000", + "{550e8400-e29b-41d4-a716-446655440000}", + "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + ] { + assert!( + validate_uuid(spelling).is_ok(), + "validate_uuid rejected {spelling}, so the canonicalization below is unnecessary" + ); + assert_ne!( + spelling, "550e8400-e29b-41d4-a716-446655440000", + "test data error: {spelling} is already canonical" + ); + } + } + + /// The canonicalization the query paths apply: parse, then render the one + /// form the relay stores. Every spelling above collapses onto it. + #[test] + fn parse_uuid_hyphenated_is_the_canonical_tag_value() { + for spelling in [ + "550e8400-e29b-41d4-a716-446655440000", + "550E8400-E29B-41D4-A716-446655440000", + "550e8400e29b41d4a716446655440000", + "{550e8400-e29b-41d4-a716-446655440000}", + "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + ] { + assert_eq!( + parse_uuid(spelling).unwrap().hyphenated().to_string(), + "550e8400-e29b-41d4-a716-446655440000", + "{spelling} did not canonicalize" + ); + } + } + // --- validate_hex64 --- #[test]