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
5 changes: 5 additions & 0 deletions crates/buzz-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ 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, use a PowerShell literal here-string to avoid backtick expansion:
# buzz messages send --channel <uuid> --content - <<'EOF'
# Content with `backticks` and $variables — preserved literally.
# EOF
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
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_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
69 changes: 69 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 containing NUL bytes (0x00).
///
/// Rust strings and JSON can represent NUL, so this is not a Rust-level
/// truncation. The real boundary is the OS command line: on Windows, a NUL
/// byte in `--content` terminates the native command line before `buzz.exe`
/// starts, so Clap receives only the prefix and the validator never sees the
/// NUL. This validator is a defense for NULs that reach Buzz through other
/// paths (stdin, file, or platforms where the NUL survives into the process).
///
/// The actionable workaround for the Windows `--content` case is to use a
/// PowerShell literal here-string (single-quoted, so backtick escapes do not
/// expand) and pipe it through stdin.
pub fn validate_no_nul_bytes(content: &str) -> Result<(), CliError> {
if content.bytes().any(|b| b == 0) {
return Err(CliError::Usage(
"content contains a NUL byte (0x00), which can cause silent truncation — \
remove the NUL byte. On Windows, pass content via stdin using a \
PowerShell literal here-string (single-quoted) to avoid backtick expansion"
.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 +300,51 @@ 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() {
let content = "before\0after";
let err = validate_no_nul_bytes(content).unwrap_err();
assert!(matches!(err, CliError::Usage(_)));
assert!(
err.to_string().contains("NUL"),
"error must mention NUL: {err}"
);
}

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

#[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_mentions_stdin_workaround() {
// The error must steer the operator toward stdin as the workaround,
// not toward --content (which would hit the same validator).
let err = validate_no_nul_bytes("hello\0world").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("stdin"), "error should mention stdin: {msg}");
assert!(
!msg.contains("--content -"),
"error should not recommend --content - (it hits the same validator): {msg}"
);
}

// --- percent_encode ---

#[test]
Expand Down