1use std::{collections::HashSet, str::FromStr, time::Duration};
2
3use ed25519_dalek::SigningKey;
4use iroh::{Endpoint, PublicKey, address_lookup::memory::MemoryLookup};
5use iroh_blobs::{Hash, ticket::BlobTicket};
6use iroh_gossip::{
7 TopicId,
8 api::{Event, GossipSender, GossipTopic},
9};
10use n0_future::StreamExt;
11use semver::Version;
12use serde::{Deserialize, Serialize};
13use uuid::Uuid;
14
15use crate::{
16 editorial::Channel,
17 error::{Result, SyncwebError},
18 node::gossip_service::GossipService,
19 node::signed_message::SignedMessage,
20};
21
22const CATALOG_TOPIC_SEED: &[u8] = b"syncweb/public-package-catalog/v1";
23
24#[must_use]
26pub fn catalog_topic() -> TopicId {
27 TopicId::from_bytes(*blake3::hash(CATALOG_TOPIC_SEED).as_bytes())
28}
29
30#[must_use]
35pub fn channel_topic_id(name: impl AsRef<str>) -> TopicId {
36 let seed = format!("syncweb/editorial/{}/v1", name.as_ref());
37 TopicId::from_bytes(*blake3::hash(seed.as_bytes()).as_bytes())
38}
39
40#[must_use]
42pub fn channel_topic(channel: &Channel) -> TopicId {
43 channel_topic_id(&channel.name)
44}
45
46#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
48#[non_exhaustive]
49pub struct PackageAnnouncement {
50 pub collection_id: Uuid,
51 pub name: String,
52 pub version: String,
53 pub sequence: u64,
54 pub manifest: Hash,
55 pub manifest_ticket: String,
56 pub publisher: String,
57}
58
59impl PackageAnnouncement {
60 pub fn new(
65 collection_id: Uuid,
66 name: impl Into<String>,
67 version: impl Into<String>,
68 sequence: u64,
69 manifest: Hash,
70 manifest_ticket: impl Into<String>,
71 publisher: PublicKey,
72 ) -> Result<Self> {
73 let announcement = Self {
74 collection_id,
75 name: name.into(),
76 version: version.into(),
77 sequence,
78 manifest,
79 manifest_ticket: manifest_ticket.into(),
80 publisher: publisher.to_string(),
81 };
82 announcement.validate()?;
83 Ok(announcement)
84 }
85
86 pub fn validate(&self) -> Result<()> {
91 if self.name.trim().is_empty() {
92 return Err(SyncwebError::InvalidConfig(
93 "package announcement name cannot be empty".to_owned(),
94 ));
95 }
96 if self.sequence == 0 {
97 return Err(SyncwebError::InvalidConfig(
98 "package announcement sequence must be greater than zero".to_owned(),
99 ));
100 }
101 Version::parse(&self.version)
102 .map_err(|error| SyncwebError::InvalidConfig(format!("invalid package version: {error}")))?;
103 let ticket = BlobTicket::from_str(&self.manifest_ticket)
104 .map_err(|error| SyncwebError::InvalidTicket(error.to_string()))?;
105 if ticket.hash() != self.manifest {
106 return Err(SyncwebError::InvalidTicket(
107 "package manifest ticket does not match its manifest hash".to_owned(),
108 ));
109 }
110 self.publisher
111 .parse::<PublicKey>()
112 .map_err(|error| SyncwebError::InvalidIdentity(error.to_string()))?;
113 Ok(())
114 }
115
116 pub fn to_bytes(&self) -> Result<Vec<u8>> {
120 self.validate()?;
121 serde_json::to_vec(self)
122 .map_err(|error| SyncwebError::operation("failed to serialize package announcement", error))
123 }
124
125 pub fn from_bytes(bytes: impl AsRef<[u8]>) -> Result<Self> {
129 let announcement: Self = serde_json::from_slice(bytes.as_ref())
130 .map_err(|error| SyncwebError::operation("failed to deserialize package announcement", error))?;
131 announcement.validate()?;
132 Ok(announcement)
133 }
134
135 pub fn ticket(&self) -> Result<BlobTicket> {
141 BlobTicket::from_str(&self.manifest_ticket).map_err(|error| SyncwebError::InvalidTicket(error.to_string()))
142 }
143
144 fn matches(&self, query: Option<&str>) -> bool {
145 query.is_none_or(|needle| {
146 self.name.contains(needle)
147 || self.collection_id.to_string().contains(needle)
148 || self.version.contains(needle)
149 })
150 }
151}
152
153#[derive(Clone)]
155pub struct PackageCatalog {
156 gossip: GossipService,
157 signing_key: SigningKey,
158 allowed_publishers: HashSet<PublicKey>,
161}
162
163impl PackageCatalog {
164 #[must_use]
165 pub fn new(gossip: &GossipService, endpoint: &Endpoint) -> Self {
166 Self {
167 gossip: gossip.clone(),
168 signing_key: SigningKey::from_bytes(&endpoint.secret_key().to_bytes()),
169 allowed_publishers: HashSet::new(),
170 }
171 }
172
173 #[must_use]
175 pub fn with_publishers(gossip: &GossipService, endpoint: &Endpoint, allowed_publishers: Vec<PublicKey>) -> Self {
176 Self {
177 gossip: gossip.clone(),
178 signing_key: SigningKey::from_bytes(&endpoint.secret_key().to_bytes()),
179 allowed_publishers: allowed_publishers.into_iter().collect(),
180 }
181 }
182
183 pub async fn subscribe(&self, bootstrap: Vec<PublicKey>) -> Result<GossipTopic> {
192 self.gossip.subscribe(catalog_topic(), bootstrap).await
193 }
194
195 pub async fn subscribe_and_join(&self, bootstrap: Vec<PublicKey>) -> Result<GossipTopic> {
203 self.gossip.subscribe_and_join(catalog_topic(), bootstrap).await
204 }
205
206 pub async fn announce(&self, sender: &GossipSender, announcement: &PackageAnnouncement) -> Result<()> {
215 let content = announcement.to_bytes()?;
216 let wire = SignedMessage::sign(&self.signing_key, &content);
217 self.gossip.publish(sender, wire).await
218 }
219
220 pub async fn subscribe_channel(&self, channel: &Channel, bootstrap: Vec<PublicKey>) -> Result<GossipTopic> {
226 self.gossip.subscribe(channel_topic(channel), bootstrap).await
227 }
228
229 pub async fn subscribe_channel_and_join(
237 &self,
238 channel: &Channel,
239 bootstrap: Vec<PublicKey>,
240 ) -> Result<GossipTopic> {
241 self.gossip.subscribe_and_join(channel_topic(channel), bootstrap).await
242 }
243
244 pub async fn search(
258 &self,
259 topic: &mut GossipTopic,
260 query: Option<&str>,
261 timeout: Duration,
262 ) -> Result<Vec<PackageAnnouncement>> {
263 self.search_inner(topic, query, timeout, &self.allowed_publishers).await
264 }
265
266 pub async fn search_channel(
277 &self,
278 topic: &mut GossipTopic,
279 channel: &Channel,
280 query: Option<&str>,
281 timeout: Duration,
282 ) -> Result<Vec<PackageAnnouncement>> {
283 let effective = self.effective_allowlist(channel);
284 self.search_inner(topic, query, timeout, &effective).await
285 }
286
287 fn effective_allowlist(&self, channel: &Channel) -> HashSet<PublicKey> {
288 if self.allowed_publishers.is_empty() {
289 return parse_publishers(&channel.allowed_publishers);
290 }
291 if channel.allowed_publishers.is_empty() {
292 return self.allowed_publishers.clone();
293 }
294 let channel_set = parse_publishers(&channel.allowed_publishers);
295 self.allowed_publishers.intersection(&channel_set).copied().collect()
296 }
297
298 async fn search_inner(
299 &self,
300 topic: &mut GossipTopic,
301 query: Option<&str>,
302 timeout: Duration,
303 allowed: &HashSet<PublicKey>,
304 ) -> Result<Vec<PackageAnnouncement>> {
305 let mut announcements = Vec::new();
306 let receive = async {
307 while let Some(event) = topic.next().await {
308 if let Event::Received(message) =
309 event.map_err(|error| SyncwebError::operation("package catalog event failed", error))?
310 {
311 let (verifying_key, content) = SignedMessage::verify(&message.content).map_err(|error| {
313 SyncwebError::operation("package catalog signature verification failed", error)
314 })?;
315
316 let announcement = PackageAnnouncement::from_bytes(content)?;
317
318 let claimed_publisher: PublicKey = announcement
320 .publisher
321 .parse()
322 .map_err(|error| SyncwebError::InvalidIdentity(format!("invalid publisher key: {error}")))?;
323 let actual_publisher = PublicKey::from_bytes(&verifying_key.to_bytes()).map_err(|error| {
324 SyncwebError::InvalidIdentity(format!("derived NodeId from verifying key is invalid: {error}"))
325 })?;
326 if claimed_publisher != actual_publisher {
327 return Err(SyncwebError::operation(
328 "package catalog publisher mismatch",
329 format!("claimed {claimed_publisher}, signed by {actual_publisher}"),
330 ));
331 }
332
333 if !allowed.is_empty() && !allowed.contains(&actual_publisher) {
335 tracing::debug!(
336 "skipping announcement from {} (not in publisher allowlist)",
337 actual_publisher
338 );
339 continue;
340 }
341
342 if announcement.matches(query)
343 && !announcements.iter().any(|item: &PackageAnnouncement| {
344 item.collection_id == announcement.collection_id
345 && item.version == announcement.version
346 && item.manifest == announcement.manifest
347 })
348 {
349 announcements.push(announcement);
350 }
351 }
352 }
353 Ok::<(), SyncwebError>(())
354 };
355 if let Ok(result) = tokio::time::timeout(timeout, receive).await {
356 result?;
357 }
358 Ok(announcements)
359 }
360
361 pub fn register_ticket_endpoint(lookup: &MemoryLookup, announcement: &PackageAnnouncement) -> Result<()> {
370 let ticket = announcement.ticket()?;
371 lookup.add_endpoint_info(ticket.addr().clone());
372 Ok(())
373 }
374}
375
376fn parse_publishers(keys: &[String]) -> HashSet<PublicKey> {
377 keys.iter()
378 .filter_map(|key| {
379 let stripped = key.strip_prefix("ed25519:").unwrap_or(key);
380 stripped.parse::<PublicKey>().ok()
381 })
382 .collect()
383}