Skip to main content

syncweb_core/folder/
catalog.rs

1use std::{str::FromStr, time::Duration};
2
3use iroh::{PublicKey, address_lookup::memory::MemoryLookup};
4use iroh_blobs::{Hash, ticket::BlobTicket};
5use iroh_gossip::{
6    TopicId,
7    api::{Event, GossipSender, GossipTopic},
8};
9use n0_future::StreamExt;
10use semver::Version;
11use serde::{Deserialize, Serialize};
12use uuid::Uuid;
13
14use crate::{
15    error::{Result, SyncwebError},
16    node::gossip_service::GossipService,
17};
18
19const CATALOG_TOPIC_SEED: &[u8] = b"syncweb/public-package-catalog/v1";
20
21/// The public gossip topic used for package announcements.
22#[must_use]
23pub fn catalog_topic() -> TopicId {
24    TopicId::from_bytes(*blake3::hash(CATALOG_TOPIC_SEED).as_bytes())
25}
26
27/// A gossip announcement for a publicly fetchable collection manifest.
28#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
29#[non_exhaustive]
30pub struct PackageAnnouncement {
31    pub collection_id: Uuid,
32    pub name: String,
33    pub version: String,
34    pub sequence: u64,
35    pub manifest: Hash,
36    pub manifest_ticket: String,
37    pub publisher: String,
38}
39
40impl PackageAnnouncement {
41    /// # Errors
42    ///
43    /// Returns an error if the ticket is invalid or does not identify the
44    /// announced manifest.
45    pub fn new(
46        collection_id: Uuid,
47        name: impl Into<String>,
48        version: impl Into<String>,
49        sequence: u64,
50        manifest: Hash,
51        manifest_ticket: impl Into<String>,
52        publisher: PublicKey,
53    ) -> Result<Self> {
54        let announcement = Self {
55            collection_id,
56            name: name.into(),
57            version: version.into(),
58            sequence,
59            manifest,
60            manifest_ticket: manifest_ticket.into(),
61            publisher: publisher.to_string(),
62        };
63        announcement.validate()?;
64        Ok(announcement)
65    }
66
67    /// # Errors
68    ///
69    /// Returns an error if the announcement has invalid metadata or a ticket
70    /// that does not match the manifest hash.
71    pub fn validate(&self) -> Result<()> {
72        if self.name.trim().is_empty() {
73            return Err(SyncwebError::InvalidConfig(
74                "package announcement name cannot be empty".to_owned(),
75            ));
76        }
77        if self.sequence == 0 {
78            return Err(SyncwebError::InvalidConfig(
79                "package announcement sequence must be greater than zero".to_owned(),
80            ));
81        }
82        Version::parse(&self.version)
83            .map_err(|error| SyncwebError::InvalidConfig(format!("invalid package version: {error}")))?;
84        let ticket = BlobTicket::from_str(&self.manifest_ticket)
85            .map_err(|error| SyncwebError::InvalidTicket(error.to_string()))?;
86        if ticket.hash() != self.manifest {
87            return Err(SyncwebError::InvalidTicket(
88                "package manifest ticket does not match its manifest hash".to_owned(),
89            ));
90        }
91        self.publisher
92            .parse::<PublicKey>()
93            .map_err(|error| SyncwebError::InvalidIdentity(error.to_string()))?;
94        Ok(())
95    }
96
97    /// # Errors
98    ///
99    /// Returns an error if the announcement is invalid or cannot be encoded.
100    pub fn to_bytes(&self) -> Result<Vec<u8>> {
101        self.validate()?;
102        serde_json::to_vec(self)
103            .map_err(|error| SyncwebError::operation("failed to serialize package announcement", error))
104    }
105
106    /// # Errors
107    ///
108    /// Returns an error if the announcement cannot be decoded or validated.
109    pub fn from_bytes(bytes: impl AsRef<[u8]>) -> Result<Self> {
110        let announcement: Self = serde_json::from_slice(bytes.as_ref())
111            .map_err(|error| SyncwebError::operation("failed to deserialize package announcement", error))?;
112        announcement.validate()?;
113        Ok(announcement)
114    }
115
116    /// Parse the manifest blob ticket carried by this announcement.
117    ///
118    /// # Errors
119    ///
120    /// Returns an error if the ticket is invalid.
121    pub fn ticket(&self) -> Result<BlobTicket> {
122        BlobTicket::from_str(&self.manifest_ticket).map_err(|error| SyncwebError::InvalidTicket(error.to_string()))
123    }
124
125    fn matches(&self, query: Option<&str>) -> bool {
126        query.is_none_or(|needle| {
127            self.name.contains(needle)
128                || self.collection_id.to_string().contains(needle)
129                || self.version.contains(needle)
130        })
131    }
132}
133
134/// Gossip-backed discovery for publicly published package manifests.
135#[derive(Clone)]
136pub struct PackageCatalog {
137    gossip: GossipService,
138}
139
140impl PackageCatalog {
141    #[must_use]
142    pub fn new(gossip: &GossipService) -> Self {
143        Self { gossip: gossip.clone() }
144    }
145
146    /// Subscribe to the public package catalog.
147    ///
148    /// The caller must retain the returned topic while receiving
149    /// announcements.
150    ///
151    /// # Errors
152    ///
153    /// Returns an error if the gossip subscription cannot be created.
154    pub async fn subscribe(&self, bootstrap: Vec<PublicKey>) -> Result<GossipTopic> {
155        self.gossip.subscribe(catalog_topic(), bootstrap).await
156    }
157
158    /// Subscribe to the public package catalog and wait for a bootstrap
159    /// connection.
160    ///
161    /// # Errors
162    ///
163    /// Returns an error if the gossip subscription cannot join its bootstrap
164    /// peers.
165    pub async fn subscribe_and_join(&self, bootstrap: Vec<PublicKey>) -> Result<GossipTopic> {
166        self.gossip.subscribe_and_join(catalog_topic(), bootstrap).await
167    }
168
169    /// Broadcast an announcement to the public catalog.
170    ///
171    /// # Errors
172    ///
173    /// Returns an error if the announcement is invalid or cannot be sent.
174    pub async fn announce(&self, sender: &GossipSender, announcement: &PackageAnnouncement) -> Result<()> {
175        self.gossip.publish(sender, announcement.to_bytes()?).await
176    }
177
178    /// Collect matching announcements until the timeout expires.
179    ///
180    /// A timeout is a normal end condition because gossip has no finite
181    /// response boundary.
182    ///
183    /// # Errors
184    ///
185    /// Returns an error if a received announcement is malformed.
186    pub async fn search(
187        &self,
188        topic: &mut GossipTopic,
189        query: Option<&str>,
190        timeout: Duration,
191    ) -> Result<Vec<PackageAnnouncement>> {
192        let mut announcements = Vec::new();
193        let receive = async {
194            while let Some(event) = topic.next().await {
195                if let Event::Received(message) =
196                    event.map_err(|error| SyncwebError::operation("package catalog event failed", error))?
197                {
198                    let announcement = PackageAnnouncement::from_bytes(message.content)?;
199                    if announcement.matches(query)
200                        && !announcements.iter().any(|item: &PackageAnnouncement| {
201                            item.collection_id == announcement.collection_id
202                                && item.version == announcement.version
203                                && item.manifest == announcement.manifest
204                        })
205                    {
206                        announcements.push(announcement);
207                    }
208                }
209            }
210            Ok::<(), SyncwebError>(())
211        };
212        if let Ok(result) = tokio::time::timeout(timeout, receive).await {
213            result?;
214        }
215        Ok(announcements)
216    }
217
218    /// Add the endpoint address for a discovered ticket to a memory lookup.
219    ///
220    /// This helper is useful to callers that construct a node with a shared
221    /// [`MemoryLookup`] and want to fetch an announcement immediately.
222    ///
223    /// # Errors
224    ///
225    /// Returns an error if the announcement's manifest ticket is invalid.
226    pub fn register_ticket_endpoint(lookup: &MemoryLookup, announcement: &PackageAnnouncement) -> Result<()> {
227        let ticket = announcement.ticket()?;
228        lookup.add_endpoint_info(ticket.addr().clone());
229        Ok(())
230    }
231}