Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions crates/buzz-cli/src/commands/channels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!({
Expand Down Expand Up @@ -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],
Expand All @@ -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],
Expand All @@ -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]
Expand Down
21 changes: 19 additions & 2 deletions crates/buzz-cli/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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!({
Expand Down Expand Up @@ -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(
Expand Down
43 changes: 43 additions & 0 deletions crates/buzz-cli/src/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down