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
3 changes: 2 additions & 1 deletion 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, validate_message_content, validate_uuid, MAX_DIFF_BYTES,
};
use buzz_sdk::mentions::{
extract_at_mentions_with_known, extract_nostr_uris, strip_code_regions, MENTION_CAP,
Expand Down Expand Up @@ -580,6 +580,7 @@ pub async fn cmd_send_message(
// quoting — the source of countless self-inflicted command-substitution
// bugs for agent and human users alike.
p.content = read_or_stdin(&p.content)?;
validate_message_content(&p.content, !p.files.is_empty())?;
validate_content_size(&p.content)?;
if let Some(ref r) = p.reply_to {
validate_hex64(r)?;
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 @@ -72,6 +72,25 @@ pub fn validate_content_size(content: &str) -> Result<(), CliError> {
Ok(())
}

/// Reject a message whose content is empty or whitespace-only, unless it
/// attaches files (attachment-only messages are legitimate).
///
/// This closes a silent-failure mode for agent callers: `--content -` with a
/// broken or missing stdin producer reads 0 bytes, the relay accepts the
/// event, mentions still notify, and every layer reports success while the
/// recipient sees a blank message.
pub fn validate_message_content(content: &str, has_files: bool) -> Result<(), CliError> {
if content.trim().is_empty() && !has_files {
return Err(CliError::Usage(
"message content is empty; pass non-empty --content or attach --file \
(with `--content -`, an empty read usually means the stdin producer \
sent nothing)"
.to_string(),
));
}
Ok(())
}

/// Percent-encode for URL path segments and query parameter values.
/// Encodes all bytes except RFC 3986 unreserved: A-Z a-z 0-9 - _ . ~
#[cfg(test)]
Expand Down Expand Up @@ -276,6 +295,30 @@ mod tests {
assert!(validate_content_size("").is_ok());
}

// --- validate_message_content ---

#[test]
fn validate_message_content_nonempty_ok() {
assert!(validate_message_content("hello", false).is_ok());
}

#[test]
fn validate_message_content_empty_rejected() {
let err = validate_message_content("", false).unwrap_err();
assert!(matches!(err, CliError::Usage(_)));
}

#[test]
fn validate_message_content_whitespace_only_rejected() {
let err = validate_message_content(" \n\t ", false).unwrap_err();
assert!(matches!(err, CliError::Usage(_)));
}

#[test]
fn validate_message_content_empty_with_files_ok() {
assert!(validate_message_content("", true).is_ok());
}

// --- percent_encode ---

#[test]
Expand Down