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
7 changes: 7 additions & 0 deletions crates/buzz-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ export BUZZ_RELAY_URL="https://relay.example.com"
buzz messages send --channel <uuid> --content "Hello"
buzz messages send --channel <uuid> --content "Reply" --reply-to <event-id> --broadcast
buzz messages send --channel <uuid> --content - < message.md # read body from stdin

# On Windows, pass the body through stdin with a PowerShell literal
# here-string so backtick escapes (e.g. `0) do not expand to NUL and
# silently truncate the message before buzz.exe starts:
# @'
# ...content with `backticks` and $variables -- preserved literally.
# '@ | buzz messages send --channel <uuid> --content -
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>'
Expand Down
4 changes: 3 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_no_nul_bytes, 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 @@ -618,6 +618,7 @@ pub async fn cmd_send_message(
// bugs for agent and human users alike.
p.content = read_or_stdin(&p.content)?;
validate_content_size(&p.content)?;
validate_no_nul_bytes(&p.content)?;
if let Some(ref r) = p.reply_to {
validate_hex64(r)?;
}
Expand Down Expand Up @@ -856,6 +857,7 @@ pub async fn cmd_edit_message(
) -> Result<(), CliError> {
validate_hex64(event_id)?;
validate_content_size(content)?;
validate_no_nul_bytes(content)?;

// Resolve channel_id from the event's h-tag
let channel_uuid = resolve_channel_id(client, event_id).await?;
Expand Down
80 changes: 80 additions & 0 deletions crates/buzz-cli/src/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,30 @@ pub fn validate_content_size(content: &str) -> Result<(), CliError> {
Ok(())
}

/// Reject content that contains a NUL byte (`0x00`).
///
/// Rust strings and JSON can represent NUL, so this validator is not a Rust-
/// level guard. It is a defense for NUL bytes that reach the `buzz` process
/// through paths other than `--content` argv on Windows: stdin, file, paste,
/// or non-Windows argv where the byte survives. On Windows, a NUL inside
/// `--content` is dropped by the OS command-line boundary before `buzz.exe`
/// starts, so this validator cannot detect that class of truncation — the
/// actionable workaround there is to send the message body through stdin
/// using a PowerShell literal here-string (`@'...'@`) so backtick escapes
/// do not expand to NUL.
pub fn validate_no_nul_bytes(content: &str) -> Result<(), CliError> {
if let Some(idx) = content.bytes().position(|b| b == 0) {
return Err(CliError::Usage(format!(
"content contains a NUL byte (0x00) at byte offset {idx}, \
which can cause silent truncation on some transports — \
remove the NUL byte. On Windows, pipe the body through stdin \
using a PowerShell literal here-string (single-quoted) to avoid \
backtick expansion"
)));
}
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 +300,62 @@ mod tests {
assert!(validate_content_size("").is_ok());
}

// --- validate_no_nul_bytes ---

#[test]
fn validate_no_nul_bytes_accepts_normal_content() {
assert!(validate_no_nul_bytes("hello world").is_ok());
assert!(validate_no_nul_bytes("unicode: café ☕ — em dash").is_ok());
assert!(validate_no_nul_bytes("").is_ok());
}

#[test]
fn validate_no_nul_bytes_rejects_embedded_nul_with_offset() {
let content = "before\0after";
let err = validate_no_nul_bytes(content).unwrap_err();
assert!(matches!(err, CliError::Usage(_)));
let msg = err.to_string();
assert!(msg.contains("NUL"), "error must mention NUL: {msg}");
assert!(
msg.contains("byte offset 6"),
"error must name the byte offset of the NUL: {msg}"
);
}

#[test]
fn validate_no_nul_bytes_rejects_leading_nul() {
let err = validate_no_nul_bytes("\0hello").unwrap_err();
assert!(matches!(err, CliError::Usage(_)));
assert!(err.to_string().contains("byte offset 0"));
}

#[test]
fn validate_no_nul_bytes_rejects_trailing_nul() {
let err = validate_no_nul_bytes("hello\0").unwrap_err();
assert!(matches!(err, CliError::Usage(_)));
}

#[test]
fn validate_no_nul_bytes_error_names_stdin_workaround() {
// The error must steer the operator toward stdin as a workaround,
// not toward --content argv (which the OS command line truncates).
let err = validate_no_nul_bytes("hello\0world").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("stdin"), "error should mention stdin: {msg}");
}

#[test]
fn validate_no_nul_bytes_uses_byte_index_not_char_index() {
// 'é' is 2 UTF-8 bytes; an embedded NUL after it should report
// byte offset 2 (not char offset 1).
let content = "é\0x";
let err = validate_no_nul_bytes(content).unwrap_err();
assert!(
err.to_string().contains("byte offset 2"),
"expected byte offset 2, got: {err}"
);
}

// --- percent_encode ---

#[test]
Expand Down