From 73d4e2aaafa16156a40b986256038779277cb360 Mon Sep 17 00:00:00 2001 From: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz> Date: Mon, 24 Aug 2026 17:18:43 -0400 Subject: [PATCH] fix(projects): verify fallback repository winner Co-authored-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz> Signed-off-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz> --- crates/buzz-acp/src/pool.rs | 2 +- .../buzz-cli/src/commands/project_channel.rs | 72 ++++++++++++++++++- crates/buzz-cli/src/commands/projects.rs | 2 +- 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 27d92209bb..d2ec62a982 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -8634,7 +8634,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" }) }) .collect(); - let responses = vec![ + let responses = [ serde_json::Value::Array(first_page), json!([{ "id": "f".repeat(64), "created_at": 1, "kind": 30621, "pubkey": owner, diff --git a/crates/buzz-cli/src/commands/project_channel.rs b/crates/buzz-cli/src/commands/project_channel.rs index 39392efdc5..887239f704 100644 --- a/crates/buzz-cli/src/commands/project_channel.rs +++ b/crates/buzz-cli/src/commands/project_channel.rs @@ -10,7 +10,8 @@ use nostr::Event; use crate::client::BuzzClient; use crate::commands::projects::{ - fetch_projects_for_channel, try_add_own_repo_to_channel_project, PROJECT_QUERY_EVENT_BOUND, + fetch_projects_for_channel, try_add_own_repo_to_channel_project, verify_default_repo_write, + PROJECT_QUERY_EVENT_BOUND, }; use crate::error::CliError; use crate::validate::{validate_repo_id, validate_uuid}; @@ -223,7 +224,9 @@ async fn ensure_default_repo( Some(channel), )?; let event = client.sign_event(builder)?; - client.submit_event(event).await?; + let raw = client.submit_event(event).await?; + let winner = crate::commands::repos::fetch_own_repo_announcement(client, &repo_id).await?; + verify_default_repo_write(&raw, winner.as_ref(), channel)?; let _ = try_add_own_repo_to_channel_project(client, channel, &repo_id).await; Ok(ChannelProjectRepo { repo_owner: caller, @@ -419,6 +422,71 @@ mod tests { )); } + #[tokio::test] + async fn ensure_default_repo_rejects_dominated_foreign_winning_head() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let requested = "11111111-1111-4111-8111-111111111111"; + let foreign = "22222222-2222-4222-8222-222222222222"; + let keys = nostr::Keys::generate(); + let project = signed_event( + &keys, + buzz_core::kind::KIND_PROJECT as u16, + vec![tag(&["d", "game"]), tag(&["buzz-channel", requested])], + ); + let winner = crate::commands::repos::build_create_announcement( + "game", + Some("game"), + None, + &[], + None, + &[], + Some(foreign), + ) + .unwrap() + .sign_with_keys(&keys) + .unwrap(); + let requests = Arc::new(AtomicUsize::new(0)); + let server_requests = requests.clone(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let mut buf = vec![0; 65_536]; + let read = socket.read(&mut buf).await.unwrap_or(0); + let request = String::from_utf8_lossy(&buf[..read]); + let index = server_requests.fetch_add(1, Ordering::SeqCst); + let body = match index { + 0 => "[]".to_string(), + 1 if request.starts_with("POST /events ") => serde_json::json!({ + "accepted": true, "message": "duplicate" + }) + .to_string(), + 2 => serde_json::json!([winner]).to_string(), + _ => panic!("unexpected request {index}: {request}"), + }; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + socket.write_all(response.as_bytes()).await.unwrap(); + } + }); + let client = crate::client::BuzzClient::new(base_url, keys, None, None).unwrap(); + + let result = ensure_default_repo(&client, requested, &project).await; + + assert!(matches!(result, Err(CliError::Conflict(_)))); + assert_eq!( + requests.load(Ordering::SeqCst), + 3, + "verification must fail before trying to update the project" + ); + server.abort(); + } + #[test] fn later_maintainer_value_authorizes_project() { let owner = nostr::Keys::generate(); diff --git a/crates/buzz-cli/src/commands/projects.rs b/crates/buzz-cli/src/commands/projects.rs index 6d9080e0f6..d58c7aa18f 100644 --- a/crates/buzz-cli/src/commands/projects.rs +++ b/crates/buzz-cli/src/commands/projects.rs @@ -696,7 +696,7 @@ async fn ensure_default_create_repo( Ok(repo_id) } -fn verify_default_repo_write( +pub(crate) fn verify_default_repo_write( raw: &str, winner: Option<&Event>, channel: &str,