sentry_types/
dsn.rs

1use std::fmt;
2use std::str::FromStr;
3
4use thiserror::Error;
5use url::Url;
6
7use crate::auth::{auth_from_dsn_and_client, Auth};
8use crate::project_id::{ParseProjectIdError, ProjectId};
9
10/// Represents a dsn url parsing error.
11#[derive(Debug, Error)]
12pub enum ParseDsnError {
13    /// raised on completely invalid urls
14    #[error("no valid url provided")]
15    InvalidUrl,
16    /// raised the scheme is invalid / unsupported.
17    #[error("no valid scheme")]
18    InvalidScheme,
19    /// raised if the username (public key) portion is missing.
20    #[error("username is empty")]
21    NoUsername,
22    /// raised the project is is missing (first path component)
23    #[error("empty path")]
24    NoProjectId,
25    /// raised the project id is invalid.
26    #[error("invalid project id")]
27    InvalidProjectId(#[from] ParseProjectIdError),
28}
29
30/// Represents the scheme of an url http/https.
31///
32/// This holds schemes that are supported by sentry and relays.
33#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
34pub enum Scheme {
35    /// unencrypted HTTP scheme (should not be used)
36    Http,
37    /// encrypted HTTPS scheme
38    Https,
39}
40
41impl Scheme {
42    /// Returns the default port for this scheme.
43    pub fn default_port(self) -> u16 {
44        match self {
45            Scheme::Http => 80,
46            Scheme::Https => 443,
47        }
48    }
49}
50
51impl fmt::Display for Scheme {
52    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
53        write!(
54            f,
55            "{}",
56            match *self {
57                Scheme::Https => "https",
58                Scheme::Http => "http",
59            }
60        )
61    }
62}
63
64/// Represents a Sentry dsn.
65#[derive(Clone, Eq, PartialEq, Hash, Debug)]
66pub struct Dsn {
67    scheme: Scheme,
68    public_key: String,
69    secret_key: Option<String>,
70    host: String,
71    port: Option<u16>,
72    path: String,
73    project_id: ProjectId,
74}
75
76impl Dsn {
77    /// Converts the dsn into an auth object.
78    ///
79    /// This always attaches the latest and greatest protocol
80    /// version to the auth header.
81    pub fn to_auth(&self, client_agent: Option<&str>) -> Auth {
82        auth_from_dsn_and_client(self, client_agent)
83    }
84
85    fn api_url(&self, endpoint: &str) -> Url {
86        use std::fmt::Write;
87        let mut buf = format!("{}://{}", self.scheme(), self.host());
88        if self.port() != self.scheme.default_port() {
89            write!(&mut buf, ":{}", self.port()).unwrap();
90        }
91        write!(
92            &mut buf,
93            "{}api/{}/{}/",
94            self.path,
95            self.project_id(),
96            endpoint
97        )
98        .unwrap();
99        Url::parse(&buf).unwrap()
100    }
101
102    /// Returns the submission API URL.
103    pub fn store_api_url(&self) -> Url {
104        self.api_url("store")
105    }
106
107    /// Returns the API URL for Envelope submission.
108    pub fn envelope_api_url(&self) -> Url {
109        self.api_url("envelope")
110    }
111
112    /// Returns the scheme
113    pub fn scheme(&self) -> Scheme {
114        self.scheme
115    }
116
117    /// Returns the public_key
118    pub fn public_key(&self) -> &str {
119        &self.public_key
120    }
121
122    /// Returns secret_key
123    pub fn secret_key(&self) -> Option<&str> {
124        self.secret_key.as_deref()
125    }
126
127    /// Returns the host
128    pub fn host(&self) -> &str {
129        &self.host
130    }
131
132    /// Returns the port
133    pub fn port(&self) -> u16 {
134        self.port.unwrap_or_else(|| self.scheme.default_port())
135    }
136
137    /// Returns the path
138    pub fn path(&self) -> &str {
139        &self.path
140    }
141
142    /// Returns the project_id
143    pub fn project_id(&self) -> &ProjectId {
144        &self.project_id
145    }
146}
147
148impl fmt::Display for Dsn {
149    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
150        write!(f, "{}://{}:", self.scheme, self.public_key)?;
151        if let Some(ref secret_key) = self.secret_key {
152            write!(f, "{secret_key}")?;
153        }
154        write!(f, "@{}", self.host)?;
155        if let Some(ref port) = self.port {
156            write!(f, ":{port}")?;
157        }
158        write!(f, "{}{}", self.path, self.project_id)?;
159        Ok(())
160    }
161}
162
163impl FromStr for Dsn {
164    type Err = ParseDsnError;
165
166    fn from_str(s: &str) -> Result<Dsn, ParseDsnError> {
167        let url = Url::parse(s).map_err(|_| ParseDsnError::InvalidUrl)?;
168
169        if url.path() == "/" {
170            return Err(ParseDsnError::NoProjectId);
171        }
172
173        let mut path_segments = url.path().trim_matches('/').rsplitn(2, '/');
174
175        let project_id = path_segments
176            .next()
177            .ok_or(ParseDsnError::NoProjectId)?
178            .parse()
179            .map_err(ParseDsnError::InvalidProjectId)?;
180        let path = match path_segments.next().unwrap_or("") {
181            "" | "/" => "/".into(),
182            other => format!("/{other}/"),
183        };
184
185        let public_key = match url.username() {
186            "" => return Err(ParseDsnError::NoUsername),
187            username => username.to_string(),
188        };
189
190        let scheme = match url.scheme() {
191            "http" => Scheme::Http,
192            "https" => Scheme::Https,
193            _ => return Err(ParseDsnError::InvalidScheme),
194        };
195
196        let secret_key = url.password().map(|s| s.into());
197        let port = url.port();
198        let host = match url.host_str() {
199            Some(host) => host.into(),
200            None => return Err(ParseDsnError::InvalidUrl),
201        };
202
203        Ok(Dsn {
204            scheme,
205            public_key,
206            secret_key,
207            host,
208            port,
209            path,
210            project_id,
211        })
212    }
213}
214
215impl_str_serde!(Dsn);
216
217#[cfg(test)]
218mod test {
219    use super::*;
220
221    #[test]
222    fn test_dsn_serialize_deserialize() {
223        let dsn = Dsn::from_str("https://username:@domain/42").unwrap();
224        let serialized = serde_json::to_string(&dsn).unwrap();
225        assert_eq!(serialized, "\"https://username:@domain/42\"");
226        let deserialized: Dsn = serde_json::from_str(&serialized).unwrap();
227        assert_eq!(deserialized.to_string(), "https://username:@domain/42");
228    }
229
230    #[test]
231    fn test_dsn_parsing() {
232        let url = "https://username:password@domain:8888/23%21";
233        let dsn = url.parse::<Dsn>().unwrap();
234        assert_eq!(dsn.scheme(), Scheme::Https);
235        assert_eq!(dsn.public_key(), "username");
236        assert_eq!(dsn.secret_key(), Some("password"));
237        assert_eq!(dsn.host(), "domain");
238        assert_eq!(dsn.port(), 8888);
239        assert_eq!(dsn.path(), "/");
240        assert_eq!(dsn.project_id(), &ProjectId::new("23%21"));
241        assert_eq!(url, dsn.to_string());
242    }
243
244    #[test]
245    fn test_dsn_no_port() {
246        let url = "https://username:@domain/42";
247        let dsn = Dsn::from_str(url).unwrap();
248        assert_eq!(dsn.port(), 443);
249        assert_eq!(url, dsn.to_string());
250        assert_eq!(
251            dsn.store_api_url().to_string(),
252            "https://domain/api/42/store/"
253        );
254        assert_eq!(
255            dsn.envelope_api_url().to_string(),
256            "https://domain/api/42/envelope/"
257        );
258    }
259
260    #[test]
261    fn test_insecure_dsn_no_port() {
262        let url = "http://username:@domain/42";
263        let dsn = Dsn::from_str(url).unwrap();
264        assert_eq!(dsn.port(), 80);
265        assert_eq!(url, dsn.to_string());
266        assert_eq!(
267            dsn.store_api_url().to_string(),
268            "http://domain/api/42/store/"
269        );
270        assert_eq!(
271            dsn.envelope_api_url().to_string(),
272            "http://domain/api/42/envelope/"
273        );
274    }
275
276    #[test]
277    fn test_dsn_no_password() {
278        let url = "https://username:@domain:8888/42";
279        let dsn = Dsn::from_str(url).unwrap();
280        assert_eq!(url, dsn.to_string());
281        assert_eq!(
282            dsn.store_api_url().to_string(),
283            "https://domain:8888/api/42/store/"
284        );
285        assert_eq!(
286            dsn.envelope_api_url().to_string(),
287            "https://domain:8888/api/42/envelope/"
288        );
289    }
290
291    #[test]
292    fn test_dsn_no_password_colon() {
293        let url = "https://username@domain:8888/42";
294        let dsn = Dsn::from_str(url).unwrap();
295        assert_eq!("https://username:@domain:8888/42", dsn.to_string());
296    }
297
298    #[test]
299    fn test_dsn_http_url() {
300        let url = "http://username:@domain:8888/42";
301        let dsn = Dsn::from_str(url).unwrap();
302        assert_eq!(url, dsn.to_string());
303    }
304
305    #[test]
306    fn test_dsn_non_integer_project_id() {
307        let url = "https://username:password@domain:8888/abc123youandme%21%21";
308        let dsn = url.parse::<Dsn>().unwrap();
309        assert_eq!(dsn.project_id(), &ProjectId::new("abc123youandme%21%21"));
310    }
311
312    #[test]
313    fn test_dsn_more_than_one_non_integer_path() {
314        let url = "http://username:@domain:8888/pathone/pathtwo/pid";
315        let dsn = url.parse::<Dsn>().unwrap();
316        assert_eq!(dsn.project_id(), &ProjectId::new("pid"));
317        assert_eq!(dsn.path(), "/pathone/pathtwo/");
318    }
319
320    #[test]
321    #[should_panic(expected = "NoUsername")]
322    fn test_dsn_no_username() {
323        Dsn::from_str("https://:password@domain:8888/23").unwrap();
324    }
325
326    #[test]
327    #[should_panic(expected = "InvalidUrl")]
328    fn test_dsn_invalid_url() {
329        Dsn::from_str("random string").unwrap();
330    }
331
332    #[test]
333    #[should_panic(expected = "InvalidUrl")]
334    fn test_dsn_no_host() {
335        Dsn::from_str("https://username:password@:8888/42").unwrap();
336    }
337
338    #[test]
339    #[should_panic(expected = "NoProjectId")]
340    fn test_dsn_no_project_id() {
341        Dsn::from_str("https://username:password@domain:8888/").unwrap();
342    }
343
344    #[test]
345    #[should_panic(expected = "InvalidScheme")]
346    fn test_dsn_invalid_scheme() {
347        Dsn::from_str("ftp://username:password@domain:8888/1").unwrap();
348    }
349}