diff --git a/crates/signed_core/src/deletions.rs b/crates/signed_core/src/deletions.rs index 354077a..cc6b407 100644 --- a/crates/signed_core/src/deletions.rs +++ b/crates/signed_core/src/deletions.rs @@ -28,8 +28,6 @@ impl Deletions { .map(|c| (c, event.pubkey, event.created_at)), ); } else if event.kind == Kind::RequestToVanish { - // We can't verify which relay the request targeted, so any - // vanish request is honored for the author's events. vanished.push((event.pubkey, event.created_at)); } } diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index a4ea705..32e2aed 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -21,9 +21,7 @@ use crate::bootstrap::{ }; use crate::git_store::Mirrors; use crate::inbox::Inbox; -use crate::push::{ - PushOutcome, grasp_base_url, grasp_clone_url, push_staged_to_grasps, require_relay_accepted, -}; +use crate::push::{PushOutcome, grasp_base_url, grasp_clone_url, push_staged_to_grasps}; use crate::repos::RepoListStore; pub const USER_KEYRING: &str = "Signed Safe Storage"; @@ -464,7 +462,16 @@ impl Backend { cx.background_spawn(async move { let event = builder.finalize_async(&signer).await?; let output = client.send_event(&event).broadcast().await?; - require_relay_accepted(output, event)?; + + if output.success.is_empty() && !output.failed.is_empty() { + let reasons = output + .failed + .values() + .cloned() + .collect::>() + .join(", "); + bail!("event not accepted by any relay: {reasons}"); + } Ok::<(), anyhow::Error>(()) }) @@ -1202,12 +1209,18 @@ async fn announce_repository_and_push( client.add_relay(url).and_connect().await.ok(); } - let event = { - let builder = announcement.into_event_builder(); - let event = builder.finalize_async(signer).await?; - let output = client.send_event(&event).broadcast().await?; - require_relay_accepted(output, event)? - }; + let event = announcement.finalize_async(signer).await?; + let output = client.send_event(&event).broadcast().await?; + + if output.success.is_empty() && !output.failed.is_empty() { + let reasons = output + .failed + .values() + .cloned() + .collect::>() + .join(", "); + bail!("event not accepted by any relay: {reasons}"); + } // The state event is the push authorization. let outcome = if refs.is_empty() { diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index 5ca064c..bcbd7e1 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -4,6 +4,7 @@ mod checkouts; mod git_store; mod inbox; mod local_repos; +mod patch_series; mod profile; mod push; mod refresh; diff --git a/crates/signed_state/src/patch_series.rs b/crates/signed_state/src/patch_series.rs new file mode 100644 index 0000000..b32c3d4 --- /dev/null +++ b/crates/signed_state/src/patch_series.rs @@ -0,0 +1,50 @@ +use bitcoin_hashes::sha1::Hash as Sha1Hash; +use signed_git::PatchParser; + +/// NIP-34 suggests patches when each event is under 60kb. +pub(crate) const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024; + +pub(crate) struct PatchSeries { + pub(crate) parts: Vec, +} + +impl PatchSeries { + pub(crate) fn parse(patch: &str) -> Self { + Self { + parts: PatchParser::split_patch_series(patch) + .into_iter() + .map(str::to_owned) + .collect(), + } + } + + /// Returns the length of the first part that exceeds the NIP-34 recommended size. + pub(crate) fn oversized_length(&self) -> Option { + self.parts + .iter() + .map(String::len) + .find(|length| *length > MAX_PATCH_EVENT_BYTES) + } + + /// The last commit of the series, which is the tip of the branch. + pub(crate) fn tip_commit(&self) -> Option { + self.parts + .last() + .and_then(|part| patch_current_commit(part)) + .and_then(|hex| hex.parse::().ok()) + } + + pub(crate) fn commit_of(&self, index: usize) -> Option<&str> { + self.parts + .get(index) + .and_then(|part| patch_current_commit(part)) + .filter(|hex| hex.len() == 40) + } +} + +/// The `From ` header on the first line. +fn patch_current_commit(patch: &str) -> Option<&str> { + let line = patch.lines().next()?; + let hex = line.strip_prefix("From ")?; + hex.split_whitespace().next().filter(|hex| hex.len() == 40) +} diff --git a/crates/signed_state/src/push.rs b/crates/signed_state/src/push.rs index 4773e7a..a5471a4 100644 --- a/crates/signed_state/src/push.rs +++ b/crates/signed_state/src/push.rs @@ -1,7 +1,7 @@ use std::path::Path; use std::time::Duration; -use anyhow::{Error, bail}; +use anyhow::Error; use gpui::BackgroundExecutor; use nostr::prelude::Url; use nostr_sdk::prelude::*; @@ -10,7 +10,6 @@ use signed_git::{Repo, RepoRefState}; use signed_nostr::UniversalSigner; const GRASP_PUSH_ATTEMPTS: usize = 3; - const GRASP_RETRY_DELAY: Duration = Duration::from_secs(1); /// `ws://` grasp servers, like ngit, use `http(s)://`. @@ -136,24 +135,6 @@ impl PushOutcome { } } -pub(crate) fn require_relay_accepted( - output: SendEventOutput, - event: Event, -) -> Result { - if output.success.is_empty() && !output.failed.is_empty() { - let reasons = output - .failed - .values() - .cloned() - .collect::>() - .join(", "); - - bail!("event not accepted by any relay: {reasons}"); - } - - Ok(event) -} - /// Signs with a `created_at` strictly newer than `last_created_at`, /// since a relay treats a same-id resend as a duplicate. async fn sign_state_event( diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 2059eb5..b482fb7 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -3,7 +3,6 @@ use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use anyhow::{Error, bail}; -use bitcoin_hashes::sha1::Hash as Sha1Hash; use gpui::{App, AppContext, Context, SharedString, Subscription, Task}; use nostr::event::IntoEventBuilder; use nostr_sdk::prelude::*; @@ -12,21 +11,17 @@ use signed_core::{ Announcement, Deletions, Filters, GitEvent, PullRequest, RepoAddr, RepoState, RepoStatus, filters, }; -use signed_git::{Nip34Binding, PatchParser, Repo}; +use signed_git::{Nip34Binding, Repo}; use signed_nostr::UniversalSigner; use crate::backend::{Backend, BackendEvent}; use crate::bootstrap::user_grasp_list_servers; use crate::checkouts::CheckoutsStore; -use crate::push::{ - PushOutcome, grasp_base_url, grasp06_prs_url, pr_clone_urls, require_relay_accepted, -}; +use crate::patch_series::{MAX_PATCH_EVENT_BYTES, PatchSeries}; +use crate::push::{PushOutcome, grasp_base_url, grasp06_prs_url, pr_clone_urls}; use crate::repos::RepoListStore; use crate::sync_status::SyncStatusStore; -// NIP-34 suggests patches when each event is under 60kb. -const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024; - pub struct RepoStore { addr: Option, // Seeded from the open-time hint, replaced by the database's latest on the @@ -869,7 +864,18 @@ impl RepoStore { let publish_result: Result = async { let output = client.send_event(&event).broadcast().await?; - require_relay_accepted(output, event) + + if output.success.is_empty() && !output.failed.is_empty() { + let reasons = output + .failed + .values() + .cloned() + .collect::>() + .join(", "); + bail!("event not accepted by any relay: {reasons}"); + } + + Ok(event) } .await; @@ -1047,7 +1053,18 @@ impl RepoStore { let publish_result: Result = async { let event = builder.finalize_async(&signer).await?; let output = client.send_event(&event).broadcast().await?; - require_relay_accepted(output, event) + + if output.success.is_empty() && !output.failed.is_empty() { + let reasons = output + .failed + .values() + .cloned() + .collect::>() + .join(", "); + bail!("event not accepted by any relay: {reasons}"); + } + + Ok(event) } .await; @@ -1302,8 +1319,7 @@ impl RepoStore { Task::ready(Err(anyhow::anyhow!("{message}"))) } - // Every one-shot repository event (issue, comment, status) goes through - // this; multi-step flows call the SDK directly instead. + /// Publish an event to the repository's relays, and update the store with any error. fn publish(&mut self, builder: EventBuilder, cx: &mut Context) { self.last_error = None; @@ -1317,7 +1333,18 @@ impl RepoStore { let publish_result: Result = async { let event = builder.finalize_async(&signer).await?; let output = client.send_event(&event).broadcast().await?; - require_relay_accepted(output, event) + + if output.success.is_empty() && !output.failed.is_empty() { + let reasons = output + .failed + .values() + .cloned() + .collect::>() + .join(", "); + bail!("event not accepted by any relay: {reasons}"); + } + + Ok(event) } .await; @@ -1372,53 +1399,7 @@ fn resolve_statuses( .collect() } -// The `From ` header on the first line. -fn patch_current_commit(patch: &str) -> Option<&str> { - let line = patch.lines().next()?; - let hex = line.strip_prefix("From ")?; - hex.split_whitespace().next().filter(|hex| hex.len() == 40) -} - -struct PatchSeries { - parts: Vec, -} - -impl PatchSeries { - fn parse(patch: &str) -> Self { - Self { - parts: PatchParser::split_patch_series(patch) - .into_iter() - .map(str::to_owned) - .collect(), - } - } - - // The byte length of the first part over the NIP-34 size suggestion. - fn oversized_length(&self) -> Option { - self.parts - .iter() - .map(String::len) - .find(|length| *length > MAX_PATCH_EVENT_BYTES) - } - - // The tip of the series is its last commit; `git format-patch` orders - // patches oldest first. - fn tip_commit(&self) -> Option { - self.parts - .last() - .and_then(|part| patch_current_commit(part)) - .and_then(|hex| hex.parse::().ok()) - } - - fn commit_of(&self, index: usize) -> Option<&str> { - self.parts - .get(index) - .and_then(|part| patch_current_commit(part)) - .filter(|hex| hex.len() == 40) - } -} - -// Returns the root event, the one a PR references. +/// Publish a patch series as a sequence of `git/patch` events. #[allow(clippy::too_many_arguments)] async fn publish_patch_series( client: &Client, @@ -1478,7 +1459,16 @@ async fn publish_patch_series( let builder = EventBuilder::new(Kind::GitPatch, part.clone()).tags(tags); let event = builder.finalize_async(signer).await?; let output = client.send_event(&event).broadcast().await?; - let event = require_relay_accepted(output, event)?; + + if output.success.is_empty() && !output.failed.is_empty() { + let reasons = output + .failed + .values() + .cloned() + .collect::>() + .join(", "); + bail!("event not accepted by any relay: {reasons}"); + } if root.is_none() { root = Some(event.clone()); @@ -1513,105 +1503,3 @@ fn comment_builder( .into_event_builder() .tags([Tag::coordinate(addr.clone().into(), None)]) } - -#[cfg(test)] -mod tests { - use std::collections::HashSet; - - use nostr_sdk::prelude::*; - use signed_core::GitEvent; - - use super::{RepoStore, comment_builder, patch_current_commit}; - - #[test] - fn parses_format_patch_header() { - let patch = "From 1f6c0c5f3f1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a Mon Sep 17 00:00:00 2001\nFrom: A \nSubject: [PATCH] fix\n\n---\n"; - assert_eq!( - patch_current_commit(patch), - Some("1f6c0c5f3f1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a") - ); - } - - #[test] - fn comment_builder_follows_nip22() { - let keys = Keys::generate(); - let root = EventBuilder::new(Kind::GitIssue, "issue body") - .finalize(&keys) - .expect("signed event"); - let addr = signed_core::RepoAddr::new(root.pubkey, "my-repo"); - let relay = RelayUrl::parse("wss://relay.example.com").expect("valid relay URL"); - - let event = comment_builder(&root, None, Some(&relay), &addr, "hi".into()) - .finalize(&keys) - .expect("signed event"); - - assert_eq!(event.kind, Kind::Comment); - - let kinds: Vec<&str> = event.tags.iter().map(Tag::kind).collect(); - for expected in ["E", "K", "P", "e", "k", "p", "a"] { - assert!(kinds.contains(&expected), "missing {expected} tag"); - } - - let e = event.tags.iter().find(|t| t.kind() == "E").expect("E tag"); - let slice = e.as_slice(); - assert_eq!(slice[1], root.id.to_hex()); - assert_eq!(slice[2], relay.as_str()); - assert_eq!(slice[3], root.pubkey.to_hex()); - - let e = event.tags.iter().find(|t| t.kind() == "e").expect("e tag"); - assert_eq!(e.as_slice()[1], root.id.to_hex()); - - assert!(event.references_root(&root.id)); - } - - #[test] - fn maintainer_filters_name_owner_and_maintainers() { - let owner = Keys::generate().public_key(); - let maintainer = Keys::generate().public_key(); - let addr = signed_core::RepoAddr::new(owner, "my-repo"); - - let filters = RepoStore::maintainer_filters(&addr, &[maintainer]); - assert_eq!(filters.len(), 4); - - let expected = HashSet::from([owner, maintainer]); - - let named = |filter: &Filter| -> HashSet { - let authors = filter.authors.iter().flatten().copied(); - let p_tag = filter - .generic_tags - .get(&SingleLetterTag::LOWERCASE_P) - .into_iter() - .flatten() - .filter_map(|value| PublicKey::from_hex(value).ok()); - authors.chain(p_tag).collect() - }; - - let announcement = &filters[0]; - assert_eq!(named(announcement), expected); - assert!( - announcement - .generic_tags - .contains_key(&SingleLetterTag::LOWERCASE_D) - ); - - for filter in &filters[1..3] { - assert_eq!(named(filter), expected); - assert!( - filter - .generic_tags - .contains_key(&SingleLetterTag::LOWERCASE_A) - ); - } - - let deletions = &filters[3]; - assert_eq!( - deletions - .authors - .clone() - .unwrap_or_default() - .into_iter() - .collect::>(), - expected - ); - } -} diff --git a/crates/signed_state/src/sync_status.rs b/crates/signed_state/src/sync_status.rs index 43d19f9..c6aba00 100644 --- a/crates/signed_state/src/sync_status.rs +++ b/crates/signed_state/src/sync_status.rs @@ -229,7 +229,9 @@ impl SyncStatusStore { })?; if again { - this.update(cx, |this, cx| this.refresh(cx))?; + this.update(cx, |this, cx| { + this.refresh(cx); + })?; } Ok::<_, Error>(())