Skip to content
Open
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
122 changes: 99 additions & 23 deletions crates/buzz-cli/src/commands/mem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,43 @@ use nostr::PublicKey;
use crate::client::BuzzClient;
use crate::error::CliError;

const PATCH_INPUT_MAX: usize = 4 * 1024 * 1024;

fn read_patch_input<R: std::io::Read>(reader: R, limit: usize) -> Result<String, CliError> {
let mut buf = String::new();
reader
.take(limit as u64 + 1)
.read_to_string(&mut buf)
.map_err(|e| CliError::Other(format!("patch read failed: {e}")))?;
if buf.len() > limit {
return Err(CliError::Usage(format!(
"patch input exceeds {} bytes; split the change into sequential patches, chaining --base-hash through each result",
limit
)));
}
Ok(buf)
}

fn read_patch_source(patch_path: Option<&str>, limit: usize) -> Result<String, CliError> {
match patch_path {
Some(path) => {
let file = std::fs::File::open(path)
.map_err(|e| CliError::Usage(format!("failed to read --patch-file {path}: {e}")))?;
let s = read_patch_input(file, limit)?;
Ok(s) // empty file is allowed to propagate; caller decides
}
None => {
let s = read_patch_input(std::io::stdin(), limit)?;
if s.is_empty() {
return Err(CliError::Usage(
"refusing to apply empty patch from stdin (an upstream pipeline step likely failed)".into(),
));
}
Ok(s)
}
}
}

/// Resolve the agent's owner pubkey: explicit `--owner` flag wins, otherwise
/// fall back to the NIP-OA `auth_tag` (which carries owner pubkey in slot 1).
fn resolve_owner(client: &BuzzClient, owner_flag: Option<&str>) -> Result<PublicKey, CliError> {
Expand Down Expand Up @@ -573,29 +610,7 @@ pub async fn cmd_patch(
}
}

// Read the diff (stdin or file). We bound it the same as `set` since the
// resulting value can't exceed the NIP-44 cap anyway — and we don't want
// a 4 GB malformed patch to OOM us.
let limit = engram::NIP44_PLAINTEXT_MAX + 1;
let diff_text = match patch_path {
Some(path) => std::fs::read_to_string(path)
.map_err(|e| CliError::Usage(format!("failed to read --patch-file {path}: {e}")))?,
None => {
let mut buf = String::new();
std::io::stdin()
.take(limit as u64)
.read_to_string(&mut buf)
.map_err(|e| CliError::Other(format!("stdin read failed: {e}")))?;
if buf.is_empty() {
return Err(CliError::Usage(
"refusing to apply empty patch from stdin (an upstream pipeline step likely \
failed)"
.into(),
));
}
buf
}
};
let diff_text = read_patch_source(patch_path, PATCH_INPUT_MAX)?;

let owner = resolve_owner(client, owner_flag)?;
let agent_pubkey = client.keys().public_key();
Expand Down Expand Up @@ -1042,4 +1057,65 @@ mod tests {
--- a/y\n+++ b/y\n@@ -1 +1 @@\n-c\n+d\n";
assert_eq!(multi.lines().filter(|l| l.starts_with("--- ")).count(), 2);
}

#[test]
fn read_patch_input_exact_limit_ok() {
let payload = "a".repeat(PATCH_INPUT_MAX);
let out = read_patch_input(payload.as_bytes(), PATCH_INPUT_MAX).unwrap();
assert_eq!(out, payload);
}

#[test]
fn read_patch_input_over_limit_err_contains_limit() {
let payload = "a".repeat(PATCH_INPUT_MAX + 1);
let err = read_patch_input(payload.as_bytes(), PATCH_INPUT_MAX).unwrap_err();
let msg = err.to_string();
assert!(msg.contains(&PATCH_INPUT_MAX.to_string()), "got: {msg}");
}

#[test]
fn read_patch_input_large_diff_above_nip44_survives() {
let len = engram::NIP44_PLAINTEXT_MAX + 5000;
assert!(len < PATCH_INPUT_MAX);
let payload = "x".repeat(len);
let out = read_patch_input(payload.as_bytes(), PATCH_INPUT_MAX).unwrap();
assert_eq!(out.len(), payload.len());
assert_eq!(out, payload);
}

#[test]
fn read_patch_input_never_truncates() {
for n in [0, 1, 10, 100, PATCH_INPUT_MAX - 1, PATCH_INPUT_MAX] {
let payload = "b".repeat(n);
let out = read_patch_input(payload.as_bytes(), PATCH_INPUT_MAX).unwrap();
assert_eq!(out.len(), n, "truncated at n={n}");
assert_eq!(out, payload);
}
let payload = "b".repeat(PATCH_INPUT_MAX + 1);
assert!(read_patch_input(payload.as_bytes(), PATCH_INPUT_MAX).is_err());
}

#[test]
fn read_patch_source_file_over_limit_err() {
let mut f = tempfile::NamedTempFile::new().unwrap();
let payload = "a".repeat(PATCH_INPUT_MAX + 1);
std::io::Write::write_all(&mut f, payload.as_bytes()).unwrap();
let err = read_patch_source(Some(f.path().to_str().unwrap()), PATCH_INPUT_MAX).unwrap_err();
assert!(
err.to_string().contains(&PATCH_INPUT_MAX.to_string()),
"got: {err}"
);
}

#[test]
fn read_patch_source_file_large_above_nip44_survives() {
let mut f = tempfile::NamedTempFile::new().unwrap();
let len = engram::NIP44_PLAINTEXT_MAX + 5000;
assert!(len < PATCH_INPUT_MAX);
let payload = "x".repeat(len);
std::io::Write::write_all(&mut f, payload.as_bytes()).unwrap();
let out = read_patch_source(Some(f.path().to_str().unwrap()), PATCH_INPUT_MAX).unwrap();
assert_eq!(out.len(), len);
assert_eq!(out, payload);
}
}