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}
87
88impl Network {
89    #[must_use]
90    pub fn new(name_arg: impl Into<String>, owner: PublicKey, options: NetworkOptions) -> Self {
91        let name = name_arg.into().trim().to_owned();
92        let id = NetworkId::from_name(&name);
93        let topic = network_topic(id);
94        let mut members = HashSet::new();
95        members.insert(owner);
96        Self {
97            id,
98            name,
99            label: options.label,
100            topic,
101            owner,
102            members,
103            folders: HashSet::new(),
104            shared_secret: options.invite_only.then(rand::random),
105        }
106    }
107
108    #[must_use]
109    pub fn is_member(&self, node_id: &PublicKey) -> bool {
110        self.members.contains(node_id)
111    }
112
113    #[must_use]
114    pub const fn topic(&self) -> TopicId {
115        self.topic
116    }
117
118    #[must_use]
119    pub const fn is_invite_only(&self) -> bool {
120        self.shared_secret.is_some()
121    }
122}
123
124/// Shareable capability for joining a network.
125#[derive(Clone, Debug, Eq, PartialEq)]
126pub struct NetworkTicket {
127    pub network_id: NetworkId,
128    pub name: String,
129    pub label: String,
130    pub owner: PublicKey,
131    pub invited_node: Option<PublicKey>,
132    pub members: HashSet<PublicKey>,
133    pub folders: HashSet<NamespaceId>,
134    pub(crate) shared_secret: Option<[u8; 32]>,
135}
136
137impl NetworkTicket {
138    #[must_use]
139    pub const fn is_invite_only(&self) -> bool {
140        self.shared_secret.is_some()
141    }
142}
143
144impl Display for NetworkTicket {
145    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
146        let wire = TicketWire::from(self);
147        let bytes = serde_json::to_vec(&wire).map_err(|_serialization_error| std::fmt::Error)?;
148        write!(formatter, "syncweb://network/{}", hex::encode(bytes))
149    }
150}
151
152impl FromStr for NetworkTicket {
153    type Err = SyncwebError;
154
155    fn from_str(value: &str) -> Result<Self> {
156        let encoded = value.strip_prefix("syncweb://network/").ok_or_else(|| {
157            SyncwebError::InvalidTicket("network ticket must start with syncweb://network/".to_owned())
158        })?;
159        let bytes = hex::decode(encoded)
160            .map_err(|error| SyncwebError::InvalidTicket(format!("invalid network ticket encoding: {error}")))?;
161        let wire: TicketWire = serde_json::from_slice(&bytes)
162            .map_err(|error| SyncwebError::InvalidTicket(format!("invalid network ticket: {error}")))?;
163        wire.try_into()
164    }
165}
166
167#[derive(Deserialize, Serialize)]
168struct TicketWire {
169    network_id: String,
170    name: String,
171    label: String,
172    owner: String,
173    invited_node: Option<String>,
174    members: Vec<String>,
175    folders: Vec<String>,
176    shared_secret: Option<String>,
177}
178
179impl From<&NetworkTicket> for TicketWire {
180    fn from(ticket: &NetworkTicket) -> Self {
181        let mut members = ticket.members.iter().map(ToString::to_string).collect::<Vec<_>>();
182        members.sort_unstable();
183        let mut folders = ticket.folders.iter().map(ToString::to_string).collect::<Vec<_>>();
184        folders.sort_unstable();
185        Self {
186            network_id: ticket.network_id.to_string(),
187            name: ticket.name.clone(),
188            label: ticket.label.clone(),
189            owner: ticket.owner.to_string(),
190            invited_node: ticket.invited_node.map(|node| node.to_string()),
191            members,
192            folders,
193            shared_secret: ticket.shared_secret.map(hex::encode),
194        }
195    }
196}
197
198impl TryFrom<TicketWire> for NetworkTicket {
199    type Error = SyncwebError;
200
201    fn try_from(wire: TicketWire) -> Result<Self> {
202        if wire.name.trim().is_empty() {
203            return Err(SyncwebError::InvalidTicket("network name cannot be empty".to_owned()));
204        }
205        let network_id: NetworkId = wire.network_id.parse()?;
206        if network_id != NetworkId::from_name(wire.name.trim()) {
207            return Err(SyncwebError::InvalidTicket(
208                "network ticket ID does not match its network name".to_owned(),
209            ));
210        }
211        let members = wire
212            .members
213            .into_iter()
214            .map(|member| parse_public_key(&member))
215            .collect::<Result<HashSet<_>>>()?;
216        let folders = wire
217            .folders
218            .into_iter()
219            .map(|folder| {
220                folder
221                    .parse()
222                    .map_err(|error| SyncwebError::InvalidTicket(format!("invalid folder namespace: {error}")))
223            })
224            .collect::<Result<HashSet<_>>>()?;
225        let shared_secret = wire
226            .shared_secret
227            .map(|secret| {
228                let bytes = hex::decode(secret)
229                    .map_err(|error| SyncwebError::InvalidTicket(format!("invalid network secret: {error}")))?;
230                bytes.try_into().map_err(|_length_error| {
231                    SyncwebError::InvalidTicket("network secret must contain 32 bytes".to_owned())
232                })
233            })
234            .transpose()?;
235        Ok(Self {
236            network_id,
237            name: wire.name,
238            label: wire.label,
239            owner: parse_public_key(&wire.owner)?,
240            invited_node: wire.invited_node.map(|node| parse_public_key(&node)).transpose()?,
241            members,
242            folders,
243            shared_secret,
244        })
245    }
246}
247
248pub(crate) fn network_topic(id: NetworkId) -> TopicId {
249    let digest = blake3::hash(format!("syncweb/net/{id}").as_bytes());
250    TopicId::from_bytes(*digest.as_bytes())
251}
252
253pub(crate) fn parse_public_key(value: &str) -> Result<PublicKey> {
254    value
255        .parse()
256        .map_err(|error| SyncwebError::InvalidTicket(format!("invalid node ID: {error}")))
257}