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