Skip to main content

syncweb_core/net/
network.rs

1use std::{
2    collections::HashSet,
3    fmt::{Display, Formatter},
4    str::FromStr,
5};
6
7use iroh::PublicKey;
8use iroh_docs::NamespaceId;
9use iroh_gossip::TopicId;
10use serde::{Deserialize, Serialize};
11
12use crate::{Result, SyncwebError};
13
14/// Stable identifier derived from a network name.
15#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
16pub struct NetworkId([u8; 32]);
17
18impl NetworkId {
19    #[must_use]
20    pub fn from_name(name: &str) -> Self {
21        let mut hasher = blake3::Hasher::new();
22        hasher.update(b"network:");
23        hasher.update(name.as_bytes());
24        Self(*hasher.finalize().as_bytes())
25    }
26
27    #[must_use]
28    pub const fn as_bytes(&self) -> &[u8; 32] {
29        &self.0
30    }
31}
32
33impl Display for NetworkId {
34    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
35        formatter.write_str(&hex::encode(self.0))
36    }
37}
38
39impl FromStr for NetworkId {
40    type Err = SyncwebError;
41
42    fn from_str(value: &str) -> Result<Self> {
43        let bytes =
44            hex::decode(value).map_err(|error| SyncwebError::InvalidConfig(format!("invalid network ID: {error}")))?;
45        let array: [u8; 32] = bytes
46            .try_into()
47            .map_err(|_length_error| SyncwebError::InvalidConfig("network ID must contain 32 bytes".to_owned()))?;
48        Ok(Self(array))
49    }
50}
51
52/// Options used when creating a network.
53#[derive(Clone, Debug, Default, Eq, PartialEq)]
54#[non_exhaustive]
55pub struct NetworkOptions {
56    pub label: String,
57    pub invite_only: bool,
58}
59
60impl NetworkOptions {
61    #[must_use]
62    pub fn with_label(mut self, label: impl Into<String>) -> Self {
63        self.label = label.into();
64        self
65    }
66
67    #[must_use]
68    pub const fn invite_only(mut self, invite_only: bool) -> Self {
69        self.invite_only = invite_only;
70        self
71    }
72}
73
74/// A named group of devices and synchronized folders.
75#[derive(Clone, Debug)]
76#[non_exhaustive]
77pub struct Network {
78    pub id: NetworkId,
79    pub name: String,
80    pub label: String,
81    pub topic: TopicId,
82    pub owner: PublicKey,
83    pub members: HashSet<PublicKey>,
84    pub folders: HashSet<NamespaceId>,
85    pub(crate) shared_secret: Option<[u8; 32]>,
86    pub doc_ticket: Option<String>,
87}
88
89impl Network {
90    #[must_use]
91    pub fn new(name_arg: impl Into<String>, owner: PublicKey, options: NetworkOptions) -> Self {
92        let name = name_arg.into().trim().to_owned();
93        let id = NetworkId::from_name(&name);
94        let topic = network_topic(id);
95        let mut members = HashSet::new();
96        members.insert(owner);
97        Self {
98            id,
99            name,
100            label: options.label,
101            topic,
102            owner,
103            members,
104            folders: HashSet::new(),
105            shared_secret: options.invite_only.then(rand::random),
106            doc_ticket: None,
107        }
108    }
109
110    #[must_use]
111    pub fn is_member(&self, node_id: &PublicKey) -> bool {
112        self.members.contains(node_id)
113    }
114
115    #[must_use]
116    pub const fn topic(&self) -> TopicId {
117        self.topic
118    }
119
120    #[must_use]
121    pub const fn is_invite_only(&self) -> bool {
122        self.shared_secret.is_some()
123    }
124}
125
126/// Shareable capability for joining a network.
127#[derive(Clone, Debug, Eq, PartialEq)]
128pub struct NetworkTicket {
129    pub network_id: NetworkId,
130    pub name: String,
131    pub label: String,
132    pub owner: PublicKey,
133    pub invited_node: Option<PublicKey>,
134    pub members: HashSet<PublicKey>,
135    pub folders: HashSet<NamespaceId>,
136    pub(crate) shared_secret: Option<[u8; 32]>,
137    pub doc_ticket: Option<String>,
138}
139
140impl NetworkTicket {
141    #[must_use]
142    pub const fn is_invite_only(&self) -> bool {
143        self.shared_secret.is_some()
144    }
145}
146
147impl Display for NetworkTicket {
148    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
149        let wire = TicketWire::from(self);
150        let bytes = serde_json::to_vec(&wire).map_err(|_serialization_error| std::fmt::Error)?;
151        write!(formatter, "syncweb://network/{}", hex::encode(bytes))
152    }
153}
154
155impl FromStr for NetworkTicket {
156    type Err = SyncwebError;
157
158    fn from_str(value: &str) -> Result<Self> {
159        let encoded = value.strip_prefix("syncweb://network/").ok_or_else(|| {
160            SyncwebError::InvalidTicket("network ticket must start with syncweb://network/".to_owned())
161        })?;
162        let bytes = hex::decode(encoded)
163            .map_err(|error| SyncwebError::InvalidTicket(format!("invalid network ticket encoding: {error}")))?;
164        let wire: TicketWire = serde_json::from_slice(&bytes)
165            .map_err(|error| SyncwebError::InvalidTicket(format!("invalid network ticket: {error}")))?;
166        wire.try_into()
167    }
168}
169
170#[derive(Deserialize, Serialize)]
171struct TicketWire {
172    network_id: String,
173    name: String,
174    label: String,
175    owner: String,
176    invited_node: Option<String>,
177    members: Vec<String>,
178    folders: Vec<String>,
179    shared_secret: Option<String>,
180    #[serde(default)]
181    doc_ticket: Option<String>,
182}
183
184impl From<&NetworkTicket> for TicketWire {
185    fn from(ticket: &NetworkTicket) -> Self {
186        let mut members = ticket.members.iter().map(ToString::to_string).collect::<Vec<_>>();
187        members.sort_unstable();
188        let mut folders = ticket.folders.iter().map(ToString::to_string).collect::<Vec<_>>();
189        folders.sort_unstable();
190        Self {
191            network_id: ticket.network_id.to_string(),
192            name: ticket.name.clone(),
193            label: ticket.label.clone(),
194            owner: ticket.owner.to_string(),
195            invited_node: ticket.invited_node.map(|node| node.to_string()),
196            members,
197            folders,
198            shared_secret: ticket.shared_secret.map(hex::encode),
199            doc_ticket: ticket.doc_ticket.clone(),
200        }
201    }
202}
203
204impl TryFrom<TicketWire> for NetworkTicket {
205    type Error = SyncwebError;
206
207    fn try_from(wire: TicketWire) -> Result<Self> {
208        if wire.name.trim().is_empty() {
209            return Err(SyncwebError::InvalidTicket("network name cannot be empty".to_owned()));
210        }
211        let network_id: NetworkId = wire.network_id.parse()?;
212        if network_id != NetworkId::from_name(wire.name.trim()) {
213            return Err(SyncwebError::InvalidTicket(
214                "network ticket ID does not match its network name".to_owned(),
215            ));
216        }
217        let members = wire
218            .members
219            .into_iter()
220            .map(|member| parse_public_key(&member))
221            .collect::<Result<HashSet<_>>>()?;
222        let folders = wire
223            .folders
224            .into_iter()
225            .map(|folder| {
226                folder
227                    .parse()
228                    .map_err(|error| SyncwebError::InvalidTicket(format!("invalid folder namespace: {error}")))
229            })
230            .collect::<Result<HashSet<_>>>()?;
231        let shared_secret = wire
232            .shared_secret
233            .map(|secret| {
234                let bytes = hex::decode(secret)
235                    .map_err(|error| SyncwebError::InvalidTicket(format!("invalid network secret: {error}")))?;
236                bytes.try_into().map_err(|_length_error| {
237                    SyncwebError::InvalidTicket("network secret must contain 32 bytes".to_owned())
238                })
239            })
240            .transpose()?;
241        Ok(Self {
242            network_id,
243            name: wire.name,
244            label: wire.label,
245            owner: parse_public_key(&wire.owner)?,
246            invited_node: wire.invited_node.map(|node| parse_public_key(&node)).transpose()?,
247            members,
248            folders,
249            shared_secret,
250            doc_ticket: wire.doc_ticket,
251        })
252    }
253}
254
255pub(crate) fn network_topic(id: NetworkId) -> TopicId {
256    let digest = blake3::hash(format!("syncweb/net/{id}").as_bytes());
257    TopicId::from_bytes(*digest.as_bytes())
258}
259
260pub(crate) fn parse_public_key(value: &str) -> Result<PublicKey> {
261    value
262        .parse()
263        .map_err(|error| SyncwebError::InvalidTicket(format!("invalid node ID: {error}")))
264}