magic_wormhole/
uri.rs

1//! Custom magic wormhole URI scheme
2//!
3//! At the moment, only `wormhole-transfer:` is specified as scheme
4//! and therefore URLs can only be used for file transfer applications.
5//! This, however, might change in the future.
6
7use super::*;
8
9/// An error occurred during parsing an URI
10#[derive(Debug, thiserror::Error, Clone, Eq, PartialEq)]
11#[non_exhaustive]
12pub enum ParseError {
13    /// Wrong URI scheme, must be `wormhole-transfer``
14    #[error("Wrong URI scheme, must be 'wormhole-transfer' but was '{_0}'")]
15    SchemeError(String),
16    /// Wormhole URIs start with `wormhole-transfer:${{code}}`, they do not have a host
17    #[error("Wormhole URIs start with 'wormhole-transfer:${{code}}', they do not have a host")]
18    HasHost,
19    /// Code is missing or empty
20    #[error("Code is missing or empty")]
21    MissingCode,
22    /// Unsupported scheme version
23    #[error("Unsupported scheme version {_0}")]
24    UnsupportedVersion(String),
25    /// Invalid 'role' parameter
26    #[error("Invalid 'role' parameter: '{_0}'")]
27    InvalidRole(String),
28    /// Some deserialization went wrong, we probably got some garbage
29    #[error("String does not parse as URL")]
30    UrlParseError(
31        #[from]
32        #[source]
33        url::ParseError,
34    ),
35    /// Invalid UTF-8 encoding
36    #[error("Invalid UTF-8 encoding: {_0}")]
37    Utf8Error(
38        #[from]
39        #[source]
40        std::str::Utf8Error,
41    ),
42    /// Error parsing the mailbox code
43    #[error("Error parsing the mailbox code")]
44    ParseCodeError(#[from] ParseCodeError),
45}
46
47/// The wormhole-transfer URI Scheme is used to encode a wormhole code for file transfer as a URI.
48/// This can then be used to generate QR codes, or be opened by the platform URI handler to open a supporting client.
49#[derive(Debug, Clone, Eq, PartialEq)]
50pub struct WormholeTransferUri {
51    /// The wormhole code
52    pub code: Code,
53    /// If `Some`, a custom non-default rendezvous-server is being requested
54    pub rendezvous_server: Option<url::Url>,
55    /// By default, the "leader" (e.g. the file sender) generates the code (and thus the link),
56    /// while the "follower" (receiver) parses the code. However, since not all devices can
57    /// parse QR images equally well, this dynamic can be inversed.
58    ///
59    /// For example, when sending a file from a smart phone to a computer, one would initiate the
60    /// transfer from the computer side (and thus set `is_leader` to `true`), because only the phone
61    /// has a camera.
62    pub is_leader: bool,
63}
64
65impl WormholeTransferUri {
66    /// Create a new URI from the given code with the default settings
67    pub fn new(code: Code) -> Self {
68        Self {
69            code,
70            rendezvous_server: None,
71            is_leader: false,
72        }
73    }
74}
75
76impl TryFrom<&url::Url> for WormholeTransferUri {
77    type Error = ParseError;
78
79    fn try_from(url: &url::Url) -> Result<Self, ParseError> {
80        use std::ops::Deref;
81
82        match url.scheme() {
83            "wormhole-transfer" => {},
84            other => return Err(ParseError::SchemeError(other.into())),
85        }
86        if url.has_host() {
87            return Err(ParseError::HasHost);
88        }
89        let queries = url
90            .query_pairs()
91            .collect::<std::collections::HashMap<_, _>>();
92        match queries.get("version").map(Deref::deref).unwrap_or("0") {
93            "0" => {},
94            unsupported => return Err(ParseError::UnsupportedVersion(unsupported.into())),
95        }
96        let rendezvous_server = queries
97            .get("rendezvous")
98            .map(Deref::deref)
99            .map(url::Url::parse)
100            .transpose()?;
101        let is_leader = match queries.get("role").map(Deref::deref).unwrap_or("follower") {
102            "leader" => true,
103            "follower" => false,
104            invalid => return Err(ParseError::InvalidRole(invalid.into())),
105        };
106        let code: Code = percent_encoding::percent_decode_str(url.path())
107            .decode_utf8()?
108            .parse()
109            .map_err(|e| {
110                // TODO: Remove for 0.8
111                if matches!(e, ParseCodeError::Empty) {
112                    ParseError::MissingCode
113                } else {
114                    e.into()
115                }
116            })?;
117
118        Ok(WormholeTransferUri {
119            code,
120            rendezvous_server,
121            is_leader,
122        })
123    }
124}
125
126impl TryFrom<url::Url> for WormholeTransferUri {
127    type Error = ParseError;
128
129    fn try_from(url: url::Url) -> Result<Self, ParseError> {
130        (&url).try_into()
131    }
132}
133
134impl std::str::FromStr for WormholeTransferUri {
135    type Err = ParseError;
136
137    fn from_str(s: &str) -> Result<Self, Self::Err> {
138        url::Url::parse(s)?.try_into()
139    }
140}
141
142impl From<&WormholeTransferUri> for url::Url {
143    fn from(val: &WormholeTransferUri) -> Self {
144        let mut url = url::Url::parse("wormhole-transfer:").unwrap();
145        url.set_path(val.code.as_ref());
146        /* Only do this if there are any query parameteres at all, otherwise the URL will have an ugly trailing '?'. */
147        if val.rendezvous_server.is_some() || val.is_leader {
148            let mut query = url.query_pairs_mut();
149            query.clear();
150            if let Some(rendezvous_server) = val.rendezvous_server.as_ref() {
151                query.append_pair("rendezvous", rendezvous_server.as_ref());
152            }
153            if val.is_leader {
154                query.append_pair("role", "leader");
155            }
156        }
157        url
158    }
159}
160
161impl std::fmt::Display for WormholeTransferUri {
162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        url::Url::from(self).fmt(f)
164    }
165}
166
167#[cfg(test)]
168mod test {
169    use super::*;
170
171    fn test_eq(parsed: WormholeTransferUri, string: &str) {
172        assert_eq!(parsed.to_string(), string);
173        assert_eq!(string.parse::<WormholeTransferUri>().unwrap(), parsed);
174    }
175
176    #[test]
177    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
178    fn test_uri() {
179        test_eq(
180            WormholeTransferUri::new("4-hurricane-equipment".parse().unwrap()),
181            "wormhole-transfer:4-hurricane-equipment",
182        );
183
184        test_eq(
185            WormholeTransferUri::new("8-🙈-🙉-🙊".parse().unwrap()),
186            "wormhole-transfer:8-%F0%9F%99%88-%F0%9F%99%89-%F0%9F%99%8A",
187        );
188
189        test_eq(
190            WormholeTransferUri {
191                code: "8-🙈-🙉-🙊".parse().unwrap(),
192                rendezvous_server: Some(url::Url::parse("ws://localhost:4000").unwrap()),
193                is_leader: true,
194            },
195            "wormhole-transfer:8-%F0%9F%99%88-%F0%9F%99%89-%F0%9F%99%8A?rendezvous=ws%3A%2F%2Flocalhost%3A4000%2F&role=leader"
196        );
197    }
198
199    #[test]
200    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
201    fn test_uri_err() {
202        assert_eq!(
203            "wormhole-transfer:8-%F0%9F%99%88-%F0%9F%99%89-%F0%9F%99%8A?version=42&rendezvous=ws%3A%2F%2Flocalhost%3A4000%2F&role=leader".parse::<WormholeTransferUri>(),
204            Err(ParseError::UnsupportedVersion("42".into()))
205        );
206        assert_eq!(
207            "wormhole-transfer:?rendezvous=ws%3A%2F%2Flocalhost%3A4000%2F&role=leader"
208                .parse::<WormholeTransferUri>(),
209            Err(ParseError::MissingCode)
210        );
211    }
212}