mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(cli): accept Buzz message links for thread reads (#6359)
## Summary - let `buzz messages thread` accept a canonical `buzz://message` link directly - treat the selected message ID as authoritative, verify its channel, and derive its containing thread root from the event - reject stale or forged `thread` hints that disagree with the selected message - preserve the existing `--channel` / `--event` form while allowing it to target any message in a thread ## Why Agent skills should be able to hand first-class Buzz links to the public Buzz CLI instead of carrying their own URL parsing wrappers. Link handling belongs with the CLI that owns the protocol; identity and relay selection remain explicit through the existing Buzz CLI environment and do not read Buzz Desktop private state. ## Verification - `cargo test -p buzz-cli` — 360 passed - `cargo clippy -p buzz-cli -- -D warnings` - `cargo fmt --all -- --check` - repository pre-push gates, including 5,070 Desktop tests - final architecture/code review — no must-fix findings A relay-backed manual smoke test was not run because this session did not have `BUZZ_RELAY_URL` and `BUZZ_PRIVATE_KEY` configured; the change has focused parser, CLI argument, channel/root binding, and mismatch tests. --------- Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
11
AGENTS.md
11
AGENTS.md
@@ -202,15 +202,16 @@ or invoke with the full path.
|
||||
### Deep Links
|
||||
|
||||
`buzz://message?channel=<uuid>&id=<hex>` links reference a specific message
|
||||
thread. To read the linked thread:
|
||||
thread. Pass the link directly to the CLI:
|
||||
|
||||
```bash
|
||||
buzz --format compact messages thread --channel <uuid> --event <hex>
|
||||
buzz --format compact messages thread --link '<buzz://message?...>'
|
||||
```
|
||||
|
||||
Extract `channel` and `id` from the URL query parameters. The optional
|
||||
`thread` parameter (root event ID) can be ignored — `messages thread` resolves
|
||||
the full thread from the event ID alone.
|
||||
The selected message ID is authoritative: `messages thread` verifies its
|
||||
channel and derives its containing root. An optional `thread` parameter is
|
||||
accepted only when it matches that derived root. The explicit
|
||||
`--channel <uuid> --event <hex>` form remains available.
|
||||
|
||||
All reads return sig-stripped JSON arrays; all writes return
|
||||
`{event_id, accepted, message}`; creates add the entity ID. Exit codes:
|
||||
|
||||
@@ -34,6 +34,7 @@ buzz messages send --channel <uuid> --content "Reply" --reply-to <event-id> --br
|
||||
buzz messages send --channel <uuid> --content - < message.md # read body from stdin
|
||||
buzz messages get --channel <uuid> --limit 20
|
||||
buzz messages thread --channel <uuid> --event <event-id>
|
||||
buzz messages thread --link 'buzz://message?channel=<uuid>&id=<event-id>&thread=<root-id>'
|
||||
buzz messages search --query "architecture"
|
||||
buzz messages search --author <pubkey|npub|name> --since <unix-ts>
|
||||
buzz messages edit --event <event-id> --content "Updated text"
|
||||
|
||||
@@ -216,8 +216,11 @@ echo 'Body with `backticks` and $vars stays literal.' \
|
||||
buzz messages get --channel "$CHANNEL_ID" | jq .
|
||||
buzz messages get --channel "$CHANNEL_ID" --limit 5 | jq .
|
||||
|
||||
# messages thread
|
||||
# messages thread from the root, a reply, and a canonical link
|
||||
buzz messages thread --channel "$CHANNEL_ID" --event "$EVENT_ID" | jq .
|
||||
buzz messages thread --channel "$CHANNEL_ID" --event "$REPLY_ID" | jq .
|
||||
buzz messages thread \
|
||||
--link "buzz://message?channel=$CHANNEL_ID&id=$REPLY_ID&thread=$EVENT_ID" | jq .
|
||||
|
||||
# messages search
|
||||
buzz messages search --query "Hello" | jq .
|
||||
|
||||
@@ -54,68 +54,69 @@ fn find_root_from_tags(tags: &serde_json::Value) -> Option<String> {
|
||||
/// - Nested reply: `root` is the parent's own root marker; `parent` is unchanged.
|
||||
///
|
||||
/// Ensures CLI-sent replies thread correctly using the same NIP-10 logic.
|
||||
async fn fetch_event(client: &BuzzClient, event_id: &str) -> Result<serde_json::Value, CliError> {
|
||||
let filter = serde_json::json!({ "ids": [event_id], "limit": 1 });
|
||||
let raw = client.query(&filter).await?;
|
||||
let events: serde_json::Value = serde_json::from_str(&raw)
|
||||
.map_err(|e| CliError::Other(format!("failed to parse query response: {e}")))?;
|
||||
events
|
||||
.as_array()
|
||||
.and_then(|events| events.first())
|
||||
.cloned()
|
||||
.ok_or_else(|| CliError::NotFound(format!("event {event_id} not found")))
|
||||
}
|
||||
|
||||
async fn resolve_thread_ref(
|
||||
client: &BuzzClient,
|
||||
parent_event_id: &str,
|
||||
) -> Result<ThreadRef, CliError> {
|
||||
let parent_eid = parse_event_id(parent_event_id)?;
|
||||
let filter = serde_json::json!({ "ids": [parent_event_id], "limit": 1 });
|
||||
let raw = client.query(&filter).await?;
|
||||
let events: serde_json::Value = serde_json::from_str(&raw)
|
||||
.map_err(|e| CliError::Other(format!("failed to parse query response: {e}")))?;
|
||||
let event = events
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.ok_or_else(|| CliError::Other(format!("parent event {parent_event_id} not found")))?;
|
||||
let event = fetch_event(client, parent_event_id).await?;
|
||||
thread_ref_from_event(parent_event_id, &event)
|
||||
}
|
||||
|
||||
fn thread_ref_from_event(event_id: &str, event: &serde_json::Value) -> Result<ThreadRef, CliError> {
|
||||
let parent_eid = parse_event_id(event_id)?;
|
||||
let tags = event
|
||||
.get("tags")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
|
||||
let root_eid = match find_root_from_tags(&tags) {
|
||||
Some(root_hex) if root_hex != parent_event_id => parse_event_id(&root_hex)?,
|
||||
let root_event_id = match find_root_from_tags(&tags) {
|
||||
Some(root_hex) if root_hex != event_id => parse_event_id(&root_hex)?,
|
||||
_ => parent_eid,
|
||||
};
|
||||
|
||||
Ok(ThreadRef {
|
||||
root_event_id: root_eid,
|
||||
root_event_id,
|
||||
parent_event_id: parent_eid,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve the channel UUID for an event by querying for it via POST /query.
|
||||
/// Extracts the `h` tag value from the returned event's tags.
|
||||
async fn resolve_channel_id(client: &BuzzClient, event_id: &str) -> Result<Uuid, CliError> {
|
||||
let filter = serde_json::json!({
|
||||
"ids": [event_id]
|
||||
});
|
||||
let raw = client.query(&filter).await?;
|
||||
let events: serde_json::Value = serde_json::from_str(&raw)
|
||||
.map_err(|e| CliError::Other(format!("failed to parse query response: {e}")))?;
|
||||
let arr = events
|
||||
.as_array()
|
||||
.ok_or_else(|| CliError::Other("query response is not an array".into()))?;
|
||||
let event = arr
|
||||
.first()
|
||||
.ok_or_else(|| CliError::Other(format!("event {event_id} not found")))?;
|
||||
fn channel_id_from_event(event_id: &str, event: &serde_json::Value) -> Result<Uuid, CliError> {
|
||||
let tags = event
|
||||
.get("tags")
|
||||
.and_then(|t| t.as_array())
|
||||
.and_then(|tags| tags.as_array())
|
||||
.ok_or_else(|| CliError::Other("event missing 'tags' field".into()))?;
|
||||
for tag in tags {
|
||||
if let Some(arr) = tag.as_array() {
|
||||
if arr.first().and_then(|v| v.as_str()) == Some("h") {
|
||||
if let Some(uuid_str) = arr.get(1).and_then(|v| v.as_str()) {
|
||||
return Uuid::parse_str(uuid_str).map_err(|_| {
|
||||
CliError::Other(format!("event h-tag is not a valid UUID: {uuid_str}"))
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(CliError::Other(format!(
|
||||
"event {event_id} has no h-tag — cannot determine channel"
|
||||
)))
|
||||
tags.iter()
|
||||
.filter_map(|tag| tag.as_array())
|
||||
.find(|tag| tag.first().and_then(|value| value.as_str()) == Some("h"))
|
||||
.and_then(|tag| tag.get(1))
|
||||
.and_then(|value| value.as_str())
|
||||
.ok_or_else(|| {
|
||||
CliError::Other(format!(
|
||||
"event {event_id} has no h-tag — cannot determine channel"
|
||||
))
|
||||
})
|
||||
.and_then(|channel_id| {
|
||||
Uuid::parse_str(channel_id).map_err(|_| {
|
||||
CliError::Other(format!("event h-tag is not a valid UUID: {channel_id}"))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async fn resolve_channel_id(client: &BuzzClient, event_id: &str) -> Result<Uuid, CliError> {
|
||||
let event = fetch_event(client, event_id).await?;
|
||||
channel_id_from_event(event_id, &event)
|
||||
}
|
||||
|
||||
fn resolve_names_to_pubkeys(
|
||||
@@ -391,37 +392,71 @@ pub async fn cmd_get_messages(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn resolve_thread_target(
|
||||
expected_channel_id: Uuid,
|
||||
event_id: &str,
|
||||
expected_root_id: Option<&str>,
|
||||
selected_event: &serde_json::Value,
|
||||
) -> Result<String, CliError> {
|
||||
let actual_channel_id = channel_id_from_event(event_id, selected_event)?;
|
||||
if actual_channel_id != expected_channel_id {
|
||||
return Err(CliError::Usage(format!(
|
||||
"event {event_id} does not belong to channel {expected_channel_id}"
|
||||
)));
|
||||
}
|
||||
let root_event_id = thread_ref_from_event(event_id, selected_event)?
|
||||
.root_event_id
|
||||
.to_hex();
|
||||
if expected_root_id.is_some_and(|expected| expected != root_event_id) {
|
||||
return Err(CliError::Usage(
|
||||
"Buzz message link thread root does not match the selected message".into(),
|
||||
));
|
||||
}
|
||||
Ok(root_event_id)
|
||||
}
|
||||
|
||||
pub async fn cmd_get_thread(
|
||||
client: &BuzzClient,
|
||||
channel_id: &str,
|
||||
event_id: &str,
|
||||
expected_root_id: Option<&str>,
|
||||
limit: Option<u32>,
|
||||
depth_limit: Option<u32>,
|
||||
format: &crate::OutputFormat,
|
||||
) -> Result<(), CliError> {
|
||||
validate_uuid(channel_id)?;
|
||||
let expected_channel_id = parse_uuid(channel_id)?;
|
||||
validate_hex64(event_id)?;
|
||||
let selected_event = fetch_event(client, event_id).await?;
|
||||
let root_event_id = resolve_thread_target(
|
||||
expected_channel_id,
|
||||
event_id,
|
||||
expected_root_id,
|
||||
&selected_event,
|
||||
)?;
|
||||
let limit = limit.unwrap_or(100).min(500);
|
||||
|
||||
// Two filters ORed in a single HTTP call:
|
||||
// 1. Replies referencing this event via e-tag (no kind restriction)
|
||||
// 2. The root event itself by ID
|
||||
let mut reply_filter = serde_json::json!({
|
||||
"kinds": [9, 40002, 40003, 40008, 45003],
|
||||
"#h": [channel_id],
|
||||
"#e": [event_id],
|
||||
"#e": [root_event_id.as_str()],
|
||||
"limit": limit
|
||||
});
|
||||
if let Some(d) = depth_limit {
|
||||
reply_filter["depth_limit"] = serde_json::json!(d);
|
||||
}
|
||||
let root_filter = serde_json::json!({
|
||||
"ids": [event_id],
|
||||
"ids": [root_event_id.as_str()],
|
||||
"#h": [channel_id],
|
||||
"limit": 1
|
||||
});
|
||||
let resp = client.query_multi(&[reply_filter, root_filter]).await?;
|
||||
let mut events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
|
||||
events.sort_by_key(|e| e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0));
|
||||
events.sort_by_key(|event| {
|
||||
event
|
||||
.get("created_at")
|
||||
.and_then(|value| value.as_u64())
|
||||
.unwrap_or(0)
|
||||
});
|
||||
let normalized = normalize_events(&events);
|
||||
println!("{}", format_events(&normalized, format));
|
||||
Ok(())
|
||||
@@ -965,9 +1000,35 @@ pub async fn dispatch(
|
||||
MessagesCmd::Thread {
|
||||
channel,
|
||||
event,
|
||||
link,
|
||||
limit,
|
||||
depth_limit,
|
||||
} => cmd_get_thread(client, &channel, &event, limit, depth_limit, format).await,
|
||||
} => {
|
||||
let (channel, event, expected_root) =
|
||||
match link {
|
||||
Some(link) => {
|
||||
let parsed = crate::links::parse_message_link(&link)?;
|
||||
(parsed.channel_id, parsed.message_id, parsed.thread_root_id)
|
||||
}
|
||||
None => match (channel, event) {
|
||||
(Some(channel), Some(event)) => (channel, event, None),
|
||||
_ => return Err(CliError::Usage(
|
||||
"messages thread requires either --link or both --channel and --event"
|
||||
.into(),
|
||||
)),
|
||||
},
|
||||
};
|
||||
cmd_get_thread(
|
||||
client,
|
||||
&channel,
|
||||
&event,
|
||||
expected_root.as_deref(),
|
||||
limit,
|
||||
depth_limit,
|
||||
format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
MessagesCmd::Search {
|
||||
query,
|
||||
author,
|
||||
@@ -993,13 +1054,15 @@ pub async fn dispatch(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
event_mention_pubkeys, find_root_from_tags, match_profiles_by_name, merge_message_mentions,
|
||||
missing_members, normalize_explicit_mentions, parse_member_pubkeys,
|
||||
resolve_names_to_pubkeys,
|
||||
channel_id_from_event, cmd_get_thread, event_mention_pubkeys, find_root_from_tags,
|
||||
match_profiles_by_name, merge_message_mentions, missing_members,
|
||||
normalize_explicit_mentions, parse_member_pubkeys, resolve_names_to_pubkeys,
|
||||
resolve_thread_target, thread_ref_from_event, BuzzClient, CliError, Uuid,
|
||||
};
|
||||
use buzz_sdk::mentions::{
|
||||
extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile,
|
||||
};
|
||||
use nostr::Keys;
|
||||
use serde_json::json;
|
||||
|
||||
const ID_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
@@ -1012,6 +1075,92 @@ mod tests {
|
||||
const PK_VALID_B: &str = "c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05";
|
||||
const PK_VALID_C: &str = "f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68";
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_channel_is_rejected_before_thread_fetch() {
|
||||
let client =
|
||||
BuzzClient::new("http://127.0.0.1:1".into(), Keys::generate(), None, None).unwrap();
|
||||
let error = cmd_get_thread(
|
||||
&client,
|
||||
"not-a-uuid",
|
||||
ID_A,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&crate::OutputFormat::Json,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, CliError::Usage(_)));
|
||||
assert!(error.to_string().contains("invalid UUID"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_event_derives_authoritative_channel_and_root() {
|
||||
let channel = "123e4567-e89b-12d3-a456-426614174000";
|
||||
let event = json!({
|
||||
"tags": [
|
||||
["h", channel],
|
||||
["e", ID_A, "", "root"],
|
||||
["e", ID_B, "", "reply"],
|
||||
]
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
channel_id_from_event(ID_B, &event).unwrap().to_string(),
|
||||
channel
|
||||
);
|
||||
assert_eq!(
|
||||
thread_ref_from_event(ID_B, &event)
|
||||
.unwrap()
|
||||
.root_event_id
|
||||
.to_hex(),
|
||||
ID_A
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_event_requires_a_valid_channel_tag() {
|
||||
let missing = json!({"tags": []});
|
||||
let malformed = json!({"tags": [["h", "not-a-uuid"]]});
|
||||
assert!(channel_id_from_event(ID_A, &missing).is_err());
|
||||
assert!(channel_id_from_event(ID_A, &malformed).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_target_rejects_wrong_channel_or_root_hint() {
|
||||
let channel = "123e4567-e89b-12d3-a456-426614174000";
|
||||
let other_channel = "123e4567-e89b-12d3-a456-426614174001";
|
||||
let selected = json!({
|
||||
"tags": [["h", channel], ["e", ID_A, "", "root"]]
|
||||
});
|
||||
|
||||
assert!(resolve_thread_target(
|
||||
Uuid::parse_str(other_channel).unwrap(),
|
||||
ID_B,
|
||||
Some(ID_A),
|
||||
&selected,
|
||||
)
|
||||
.is_err());
|
||||
assert!(resolve_thread_target(
|
||||
Uuid::parse_str(channel).unwrap(),
|
||||
ID_B,
|
||||
Some(ID_B),
|
||||
&selected,
|
||||
)
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
resolve_thread_target(
|
||||
Uuid::parse_str(channel).unwrap(),
|
||||
ID_B,
|
||||
Some(ID_A),
|
||||
&selected,
|
||||
)
|
||||
.unwrap(),
|
||||
ID_A
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_marker_wins_over_reply_marker() {
|
||||
let tags = json!([
|
||||
|
||||
@@ -480,14 +480,20 @@ pub enum MessagesCmd {
|
||||
#[arg(long)]
|
||||
kinds: Option<String>,
|
||||
},
|
||||
/// Get a message thread (replies to a root message)
|
||||
/// Get the containing thread for a message or Buzz message link
|
||||
#[command(
|
||||
after_help = "Examples:\n buzz messages thread --channel <UUID> --event <EVENT_ID>\n buzz messages thread --link 'buzz://message?channel=<UUID>&id=<EVENT_ID>&thread=<ROOT_ID>'"
|
||||
)]
|
||||
Thread {
|
||||
/// Channel UUID
|
||||
#[arg(long)]
|
||||
channel: String,
|
||||
/// Root message event ID (64-char hex)
|
||||
#[arg(long)]
|
||||
event: String,
|
||||
/// Channel UUID; required unless --link is supplied
|
||||
#[arg(long, required_unless_present = "link", conflicts_with = "link")]
|
||||
channel: Option<String>,
|
||||
/// Message event ID (64-char hex); required unless --link is supplied
|
||||
#[arg(long, required_unless_present = "link", conflicts_with = "link")]
|
||||
event: Option<String>,
|
||||
/// Canonical buzz://message deep link; uses the configured relay and identity
|
||||
#[arg(long, conflicts_with_all = ["channel", "event"])]
|
||||
link: Option<String>,
|
||||
/// Maximum number of results to return
|
||||
#[arg(long)]
|
||||
limit: Option<u32>,
|
||||
@@ -2120,6 +2126,47 @@ mod tests {
|
||||
Cli::command().debug_assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_thread_accepts_link_or_explicit_identifiers() {
|
||||
let channel = "123e4567-e89b-12d3-a456-426614174000";
|
||||
let event = "a".repeat(64);
|
||||
let link = format!("buzz://message?channel={channel}&id={event}");
|
||||
|
||||
assert!(
|
||||
Cli::try_parse_from(["buzz", "messages", "thread", "--link", link.as_str(),]).is_ok()
|
||||
);
|
||||
assert!(Cli::try_parse_from([
|
||||
"buzz",
|
||||
"messages",
|
||||
"thread",
|
||||
"--channel",
|
||||
channel,
|
||||
"--event",
|
||||
event.as_str(),
|
||||
])
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_thread_rejects_partial_or_mixed_targets() {
|
||||
let channel = "123e4567-e89b-12d3-a456-426614174000";
|
||||
let event = "a".repeat(64);
|
||||
let link = format!("buzz://message?channel={channel}&id={event}");
|
||||
|
||||
assert!(Cli::try_parse_from(["buzz", "messages", "thread"]).is_err());
|
||||
assert!(Cli::try_parse_from(["buzz", "messages", "thread", "--channel", channel]).is_err());
|
||||
assert!(Cli::try_parse_from([
|
||||
"buzz",
|
||||
"messages",
|
||||
"thread",
|
||||
"--link",
|
||||
link.as_str(),
|
||||
"--event",
|
||||
event.as_str(),
|
||||
])
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_status_clear_rejects_text_and_emoji() {
|
||||
for extra in [["--text", "busy"], ["--emoji", "🎶"]] {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! Canonical `buzz://` deep links for Buzz-hosted git entities.
|
||||
//! Canonical `buzz://` deep links for Buzz entities.
|
||||
//!
|
||||
//! Buzz Desktop renders these links as rich preview cards in chat and
|
||||
//! navigates in-app when they are clicked. The desktop parser lives in
|
||||
//! `desktop/src/shared/lib/entityLink.ts` — the two implementations must
|
||||
//! stay format-compatible (see `golden_format_matches_desktop` below and
|
||||
//! the mirror test in `entityLink.test.mjs`).
|
||||
//! `desktop/src/shared/lib/entityLink.ts` for git entities and
|
||||
//! `desktop/src/features/messages/lib/messageLink.ts` for messages. The
|
||||
//! implementations must stay format-compatible.
|
||||
//!
|
||||
//! Callers are expected to validate inputs first (`validate_hex64`,
|
||||
//! `validate_repo_id`); the identifier charsets need no URL encoding.
|
||||
@@ -15,6 +15,94 @@
|
||||
//! (overview); the parameter exists for the desktop's tab-aware copy-link
|
||||
//! button.
|
||||
|
||||
use crate::error::CliError;
|
||||
|
||||
/// A validated `buzz://message` deep link.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct MessageLink {
|
||||
pub channel_id: String,
|
||||
pub message_id: String,
|
||||
pub thread_root_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse a `buzz://message?channel=<uuid>&id=<event>[&thread=<root>]` link.
|
||||
///
|
||||
/// The link chooses only the channel and event within the relay already
|
||||
/// configured for this CLI process. It cannot override the relay or identity.
|
||||
pub fn parse_message_link(input: &str) -> Result<MessageLink, CliError> {
|
||||
let url = url::Url::parse(input.trim())
|
||||
.map_err(|_| CliError::Usage("invalid Buzz message link".into()))?;
|
||||
|
||||
if url.scheme() != "buzz"
|
||||
|| url.host_str() != Some("message")
|
||||
|| !matches!(url.path(), "" | "/")
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
|| url.fragment().is_some()
|
||||
{
|
||||
return Err(CliError::Usage(
|
||||
"expected a buzz://message link without credentials or a fragment".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut channel = None;
|
||||
let mut message = None;
|
||||
let mut thread = None;
|
||||
for (key, value) in url.query_pairs() {
|
||||
let slot = match key.as_ref() {
|
||||
"channel" => &mut channel,
|
||||
"id" => &mut message,
|
||||
"thread" => &mut thread,
|
||||
_ => {
|
||||
return Err(CliError::Usage(
|
||||
"Buzz message link contains an unsupported query parameter".into(),
|
||||
))
|
||||
}
|
||||
};
|
||||
if slot.replace(value.into_owned()).is_some() {
|
||||
return Err(CliError::Usage(format!(
|
||||
"Buzz message link contains more than one {key} parameter"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let channel = channel
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| CliError::Usage("Buzz message link is missing channel".into()))?;
|
||||
let message = message
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| CliError::Usage("Buzz message link is missing id".into()))?;
|
||||
if thread.as_deref() == Some("") {
|
||||
return Err(CliError::Usage(
|
||||
"Buzz message link contains an empty thread parameter".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let channel_id = uuid::Uuid::parse_str(&channel)
|
||||
.map_err(|_| CliError::Usage("Buzz message link contains an invalid channel UUID".into()))?
|
||||
.to_string();
|
||||
let message_id = canonical_event_id(&message, "id")?;
|
||||
let thread_root_id = thread
|
||||
.as_deref()
|
||||
.map(|value| canonical_event_id(value, "thread"))
|
||||
.transpose()?;
|
||||
|
||||
Ok(MessageLink {
|
||||
channel_id,
|
||||
message_id,
|
||||
thread_root_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn canonical_event_id(value: &str, parameter: &str) -> Result<String, CliError> {
|
||||
if value.len() != 64 || !value.chars().all(|character| character.is_ascii_hexdigit()) {
|
||||
return Err(CliError::Usage(format!(
|
||||
"Buzz message link contains an invalid {parameter} event ID"
|
||||
)));
|
||||
}
|
||||
Ok(value.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// Whether a d-tag can be expressed in a `buzz://` link.
|
||||
///
|
||||
/// Project slugs accept up to 1024 bytes of arbitrary UTF-8, but the link
|
||||
@@ -58,6 +146,10 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::Value;
|
||||
|
||||
const CHANNEL: &str = "123e4567-e89b-12d3-a456-426614174000";
|
||||
const MESSAGE: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
const THREAD: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
|
||||
fn golden() -> Value {
|
||||
serde_json::from_str(include_str!("../../../test-fixtures/entity-links.json"))
|
||||
.expect("valid entity-links golden fixture")
|
||||
@@ -101,4 +193,64 @@ mod tests {
|
||||
}
|
||||
assert!(!is_linkable_dtag(&"a".repeat(65)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_message_link_with_thread_root() {
|
||||
let parsed = parse_message_link(&format!(
|
||||
"buzz://message?channel={CHANNEL}&id={MESSAGE}&thread={THREAD}"
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
MessageLink {
|
||||
channel_id: CHANNEL.into(),
|
||||
message_id: MESSAGE.into(),
|
||||
thread_root_id: Some(THREAD.into()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_message_link_without_thread_root() {
|
||||
let parsed =
|
||||
parse_message_link(&format!("buzz://message?channel={CHANNEL}&id={MESSAGE}")).unwrap();
|
||||
assert_eq!(parsed.thread_root_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_message_link_identifiers() {
|
||||
let parsed = parse_message_link(&format!(
|
||||
"buzz://message?channel={}&id={}",
|
||||
CHANNEL.to_ascii_uppercase(),
|
||||
MESSAGE.to_ascii_uppercase()
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(parsed.channel_id, CHANNEL);
|
||||
assert_eq!(parsed.message_id, MESSAGE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_message_link_that_could_change_connection_context() {
|
||||
for link in [
|
||||
format!("buzz://message?channel={CHANNEL}&id={MESSAGE}&relay=other"),
|
||||
format!("buzz://user:secret@message?channel={CHANNEL}&id={MESSAGE}"),
|
||||
format!("buzz://message?channel={CHANNEL}&id={MESSAGE}#fragment"),
|
||||
] {
|
||||
assert!(parse_message_link(&link).is_err(), "accepted {link}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_or_malformed_message_link_identifiers() {
|
||||
for link in [
|
||||
format!("buzz://message?channel={CHANNEL}&channel={CHANNEL}&id={MESSAGE}"),
|
||||
format!("buzz://message?channel=not-a-uuid&id={MESSAGE}"),
|
||||
format!("buzz://message?channel={CHANNEL}&id=not-an-event"),
|
||||
format!("buzz://message?channel={CHANNEL}&id={MESSAGE}&thread="),
|
||||
] {
|
||||
assert!(parse_message_link(&link).is_err(), "accepted {link}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user