From 27b763353e7ae688733282ff8dbe62c9e521a692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20M=C3=A1rques?= Date: Sun, 30 Aug 2026 09:39:21 +0200 Subject: [PATCH] fix(cli): reject empty messages without attachments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rubén Márques --- crates/buzz-cli/src/commands/messages.rs | 6 + crates/buzz-cli/tests/messages_send_empty.rs | 202 +++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 crates/buzz-cli/tests/messages_send_empty.rs diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 9f41fbf751c..3faa1d4665f 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -617,6 +617,12 @@ 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)?; + if p.content.trim().is_empty() && p.files.is_empty() { + return Err(CliError::Usage( + "message cannot be empty; provide content via --content or stdin, or attach at least one --file" + .into(), + )); + } validate_content_size(&p.content)?; if let Some(ref r) = p.reply_to { validate_hex64(r)?; diff --git a/crates/buzz-cli/tests/messages_send_empty.rs b/crates/buzz-cli/tests/messages_send_empty.rs new file mode 100644 index 00000000000..51f587a230f --- /dev/null +++ b/crates/buzz-cli/tests/messages_send_empty.rs @@ -0,0 +1,202 @@ +use std::process::{Command, Output, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use axum::body::Bytes; +use axum::extract::State; +use axum::http::StatusCode; +use axum::routing::{any, post, put}; +use axum::{Json, Router}; +use serde_json::{json, Value}; +use tempfile::NamedTempFile; +use tokio::net::TcpListener; + +const CHANNEL_ID: &str = "123e4567-e89b-12d3-a456-426614174000"; +const PRIVATE_KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001"; +const EMPTY_MESSAGE_ERROR: &str = + "message cannot be empty; provide content via --content or stdin, or attach at least one --file"; + +#[derive(Clone, Default)] +struct RelayDouble { + request_count: Arc, + upload_count: Arc, + submitted_events: Arc>>, +} + +impl RelayDouble { + fn request_count(&self) -> usize { + self.request_count.load(Ordering::SeqCst) + } + + fn upload_count(&self) -> usize { + self.upload_count.load(Ordering::SeqCst) + } + + fn submitted_events(&self) -> Vec { + self.submitted_events.lock().unwrap().clone() + } +} + +async fn upload(State(state): State, _body: Bytes) -> Json { + state.request_count.fetch_add(1, Ordering::SeqCst); + state.upload_count.fetch_add(1, Ordering::SeqCst); + Json(json!({ + "url": "https://relay.test/media/fixture.png", + "sha256": "fixture-sha256", + "size": 8, + "type": "image/png", + "uploaded": 0 + })) +} + +async fn submit_event(State(state): State, body: Bytes) -> Json { + state.request_count.fetch_add(1, Ordering::SeqCst); + let event = serde_json::from_slice(&body).unwrap(); + state.submitted_events.lock().unwrap().push(event); + Json(json!({ + "event_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "accepted": true, + "message": "" + })) +} + +async fn unexpected_request(State(state): State) -> StatusCode { + state.request_count.fetch_add(1, Ordering::SeqCst); + StatusCode::NOT_FOUND +} + +async fn spawn_relay_double() -> (String, RelayDouble) { + let state = RelayDouble::default(); + let app = Router::new() + .route("/upload", put(upload)) + .route("/events", post(submit_event)) + .fallback(any(unexpected_request)) + .with_state(state.clone()); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{address}"), state) +} + +fn buzz_command(relay_url: &str) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_buzz")); + command.args([ + "--relay", + relay_url, + "--private-key", + PRIVATE_KEY, + "messages", + "send", + "--channel", + CHANNEL_ID, + ]); + command +} + +fn assert_empty_message_rejected(output: &Output) { + assert_eq!( + output.status.code(), + Some(1), + "unexpected status; stdout: {}; stderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let error: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(error["error"], "user_error"); + assert_eq!(error["message"], EMPTY_MESSAGE_ERROR); +} + +#[tokio::test(flavor = "multi_thread")] +async fn empty_content_without_file_is_rejected_before_external_effects() { + let (relay_url, relay) = spawn_relay_double().await; + let output = buzz_command(&relay_url) + .args(["--content", ""]) + .output() + .unwrap(); + + assert_empty_message_rejected(&output); + assert_eq!(relay.request_count(), 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn immediate_stdin_eof_without_file_is_rejected_before_external_effects() { + let (relay_url, relay) = spawn_relay_double().await; + let output = buzz_command(&relay_url) + .args(["--content", "-"]) + .stdin(Stdio::null()) + .output() + .unwrap(); + + assert_empty_message_rejected(&output); + assert_eq!(relay.request_count(), 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn whitespace_only_content_without_file_is_rejected_before_external_effects() { + for content in [" ", "\t\t", "\n\n", "\r\n\r\n"] { + let (relay_url, relay) = spawn_relay_double().await; + let output = buzz_command(&relay_url) + .args(["--content", content]) + .output() + .unwrap(); + + assert_empty_message_rejected(&output); + assert_eq!(relay.request_count(), 0, "content: {content:?}"); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn empty_content_with_file_reaches_upload_double_and_message_building() { + use std::io::Write; + + let (relay_url, relay) = spawn_relay_double().await; + let mut file = NamedTempFile::new().unwrap(); + file.write_all(b"\x89PNG\r\n\x1a\n").unwrap(); + + let output = buzz_command(&relay_url) + .args(["--content", "", "--file", file.path().to_str().unwrap()]) + .output() + .unwrap(); + + assert_eq!( + output.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(relay.upload_count(), 1); + let events = relay.submitted_events(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0]["content"], + "\n![image](https://relay.test/media/fixture.png)" + ); + assert!(events[0]["tags"].as_array().unwrap().iter().any(|tag| { + tag.as_array().is_some_and(|parts| { + parts.first() == Some(&json!("imeta")) + && parts + .iter() + .any(|part| part == "url https://relay.test/media/fixture.png") + }) + })); +} + +#[tokio::test(flavor = "multi_thread")] +async fn text_without_file_keeps_the_existing_send_path() { + let (relay_url, relay) = spawn_relay_double().await; + let output = buzz_command(&relay_url) + .args(["--content", "hola"]) + .output() + .unwrap(); + + assert_eq!( + output.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(relay.upload_count(), 0); + let events = relay.submitted_events(); + assert_eq!(events.len(), 1); + assert_eq!(events[0]["content"], "hola"); +}