Skip to main content

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#[cfg(feature = "protocol")]
10use crate::protocol::v7::OrganizationId;
11
12/// Represents a dsn url parsing error.
13#[derive(Debug, Error)]
14pub enum ParseDsnError {
15    /// raised on completely invalid urls
16    #[error("no valid url provided")]
17    InvalidUrl,
18    /// raised the scheme is invalid / unsupported.
19    #[error("no valid scheme")]
20    InvalidScheme,
21    /// raised if the username (public key) portion is missing.
22    #[error("username is empty")]
23    NoUsername,
24    /// raised the project is is missing (first path component)
25    #[error("empty path")]
26    NoProjectId,
27    /// raised the project id is invalid.
28    #[error("invalid project id")]
29    InvalidProjectId(#[from] ParseProjectIdError),
30}
31
32/// Represents the scheme of an url http/https.
33///
34/// This holds schemes that are supported by sentry and relays.
35#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
36pub enum Scheme {
37    /// unencrypted HTTP scheme (should not be used)
38    Http,
39    /// encrypted HTTPS scheme
40    Https,
41}
42
43impl Scheme {
44    /// Returns the default port for this scheme.
45    pub fn default_port(self) -> u16 {
46        match self {
47            Scheme::Http => 80,
48            Scheme::Https => 443,
49        }
50    }
51}
52
53impl fmt::Display for Scheme {
54    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
55        write!(
56            f,
57            "{}",
58            match *self {
59                Scheme::Https => "https",
60                Scheme::Http => "http",
61            }
62        )
63    }
64}
65
66/// Represents a Sentry dsn.
67#[derive(Clone, Eq, PartialEq, Hash, Debug)]
68pub struct Dsn {
69    scheme: Scheme,
70    public_key: String,
71    secret_key: Option<String>,
72    host: String,
73    port: Option<u16>,
74    path: String,
75    project_id: ProjectId,
76}
77
78impl Dsn {
79    /// Converts the dsn into an auth object.
80    ///
81    /// This always attaches the latest and greatest protocol
82    /// version to the auth header.
83    pub fn to_auth(&self, client_agent: Option<&str>) -> Auth {
84        auth_from_dsn_and_client(self, client_agent)
85    }
86
87    fn api_url(&self, endpoint: &str) -> Url {
88        use std::fmt::Write;
89        let mut buf = format!("{}://{}", self.scheme(), self.host());
90        if self.port() != self.scheme.default_port() {
91            write!(&mut buf, ":{}", self.port()).unwrap();
92        }
93        write!(
94            &mut buf,
95            "{}api/{}/{}/",
96            self.path,
97            self.project_id(),
98            endpoint
99        )
100        .unwrap();
101        Url::parse(&buf).unwrap()
102    }
103
104    /// Returns the submission API URL.
105    pub fn store_api_url(&self) -> Url {
106        self.api_url("store")
107    }
108
109    /// Returns the API URL for Envelope submission.
110    pub fn envelope_api_url(&self) -> Url {
111        self.api_url("envelope")
112    }
113
114    /// Returns the scheme
115    pub fn scheme(&self) -> Scheme {
116        self.scheme
117    }
118
119    /// Returns the public_key
120    pub fn public_key(&self) -> &str {
121        &self.public_key
122    }
123
124    /// Returns secret_key
125    pub fn secret_key(&self) -> Option<&str> {
126        self.secret_key.as_deref()
127    }
128
129    /// Returns the host
130    pub fn host(&self) -> &str {
131        &self.host
132    }
133
134    /// Returns the port
135    pub fn port(&self) -> u16 {
136        self.port.unwrap_or_else(|| self.scheme.default_port())
137    }
138
139    /// Returns the path
140    pub fn path(&self) -> &str {
141        &self.path
142    }
143
144    /// Returns the project_id
145    pub fn project_id(&self) -> &ProjectId {
146        &self.project_id
147    }
148
149    /// Returns the organization ID for SaaS DSNs.
150    ///
151    /// The organization ID is parsed from Sentry SaaS ingest hosts whose first
152    /// DNS label is `o<digits>`, such as `o123.ingest.sentry.io`.
153    #[cfg(feature = "protocol")]
154    pub fn org_id(&self) -> Option<OrganizationId> {
155        let org_id = extract_org_id(&self.host)?;
156        let is_numeric = org_id.chars().all(|c| c.is_ascii_digit());
157
158        (is_numeric).then(|| org_id.parse().ok()).flatten()
159    }
160}
161
162impl fmt::Display for Dsn {
163    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
164        write!(f, "{}://{}:", self.scheme, self.public_key)?;
165        if let Some(ref secret_key) = self.secret_key {
166            write!(f, "{secret_key}")?;
167        }
168        write!(f, "@{}", self.host)?;
169        if let Some(ref port) = self.port {
170            write!(f, ":{port}")?;
171        }
172        write!(f, "{}{}", self.path, self.project_id)?;
173        Ok(())
174    }
175}
176
177impl FromStr for Dsn {
178    type Err = ParseDsnError;
179
180    fn from_str(s: &str) -> Result<Dsn, ParseDsnError> {
181        let url = Url::parse(s).map_err(|_| ParseDsnError::InvalidUrl)?;
182
183        if url.path() == "/" {
184            return Err(ParseDsnError::NoProjectId);
185        }
186
187        let mut path_segments = url.path().trim_matches('/').rsplitn(2, '/');
188
189        let project_id = path_segments
190            .next()
191            .ok_or(ParseDsnError::NoProjectId)?
192            .parse()
193            .map_err(ParseDsnError::InvalidProjectId)?;
194        let path = match path_segments.next().unwrap_or("") {
195            "" | "/" => "/".into(),
196            other => format!("/{other}/"),
197        };
198
199        let public_key = match url.username() {
200            "" => return Err(ParseDsnError::NoUsername),
201            username => username.to_string(),
202        };
203
204        let scheme = match url.scheme() {
205            "http" => Scheme::Http,
206            "https" => Scheme::Https,
207            _ => return Err(ParseDsnError::InvalidScheme),
208        };
209
210        let secret_key = url.password().map(|s| s.into());
211        let port = url.port();
212        let host = match url.host_str() {
213            Some(host) => host.into(),
214            None => return Err(ParseDsnError::InvalidUrl),
215        };
216
217        Ok(Dsn {
218            scheme,
219            public_key,
220            secret_key,
221            host,
222            port,
223            path,
224            project_id,
225        })
226    }
227}
228
229impl_str_serde!(Dsn);
230
231/// Given a Sentry host, extracts the org ID as a `&str`.
232///
233/// Returns [`None`] if this is not a Sentry host.
234///
235/// Sentry hosts must look like one of the following:
236///     - `o{orgid}.ingest.{region}.sentry.io`
237///     - `o{orgid}.ingest.sentry.io`
238fn extract_org_id(host: &str) -> Option<&str> {
239    // First split on the dots, up to 6 segments.
240    let mut host_iter = host.splitn(6, '.');
241    let segments = [
242        host_iter.next(),
243        host_iter.next(),
244        host_iter.next(),
245        host_iter.next(),
246        host_iter.next(),
247        host_iter.next(),
248    ];
249
250    // Then, match either the region or regionless variant, stripping the "o" prefix.
251    match segments {
252        [Some(org_segment), Some("ingest"), Some(_), Some("sentry"), Some("io"), None]
253        | [Some(org_segment), Some("ingest"), Some("sentry"), Some("io"), None, None] => {
254            org_segment.strip_prefix('o')
255        }
256        _ => None,
257    }
258}
259
260#[cfg(test)]
261mod test {
262    use super::*;
263
264    #[test]
265    fn test_dsn_serialize_deserialize() {
266        let dsn = Dsn::from_str("https://username:@domain/42").unwrap();
267        let serialized = serde_json::to_string(&dsn).unwrap();
268        assert_eq!(serialized, "\"https://username:@domain/42\"");
269        let deserialized: Dsn = serde_json::from_str(&serialized).unwrap();
270        assert_eq!(deserialized.to_string(), "https://username:@domain/42");
271    }
272
273    #[test]
274    fn test_dsn_parsing() {
275        let url = "https://username:password@domain:8888/23%21";
276        let dsn = url.parse::<Dsn>().unwrap();
277        assert_eq!(dsn.scheme(), Scheme::Https);
278        assert_eq!(dsn.public_key(), "username");
279        assert_eq!(dsn.secret_key(), Some("password"));
280        assert_eq!(dsn.host(), "domain");
281        assert_eq!(dsn.port(), 8888);
282        assert_eq!(dsn.path(), "/");
283        assert_eq!(dsn.project_id(), &ProjectId::new("23%21"));
284        assert_eq!(url, dsn.to_string());
285    }
286
287    #[test]
288    fn test_dsn_no_port() {
289        let url = "https://username:@domain/42";
290        let dsn = Dsn::from_str(url).unwrap();
291        assert_eq!(dsn.port(), 443);
292        assert_eq!(url, dsn.to_string());
293        assert_eq!(
294            dsn.store_api_url().to_string(),
295            "https://domain/api/42/store/"
296        );
297        assert_eq!(
298            dsn.envelope_api_url().to_string(),
299            "https://domain/api/42/envelope/"
300        );
301    }
302
303    #[test]
304    fn test_insecure_dsn_no_port() {
305        let url = "http://username:@domain/42";
306        let dsn = Dsn::from_str(url).unwrap();
307        assert_eq!(dsn.port(), 80);
308        assert_eq!(url, dsn.to_string());
309        assert_eq!(
310            dsn.store_api_url().to_string(),
311            "http://domain/api/42/store/"
312        );
313        assert_eq!(
314            dsn.envelope_api_url().to_string(),
315            "http://domain/api/42/envelope/"
316        );
317    }
318
319    #[test]
320    fn test_dsn_no_password() {
321        let url = "https://username:@domain:8888/42";
322        let dsn = Dsn::from_str(url).unwrap();
323        assert_eq!(url, dsn.to_string());
324        assert_eq!(
325            dsn.store_api_url().to_string(),
326            "https://domain:8888/api/42/store/"
327        );
328        assert_eq!(
329            dsn.envelope_api_url().to_string(),
330            "https://domain:8888/api/42/envelope/"
331        );
332    }
333
334    #[test]
335    fn test_dsn_no_password_colon() {
336        let url = "https://username@domain:8888/42";
337        let dsn = Dsn::from_str(url).unwrap();
338        assert_eq!("https://username:@domain:8888/42", dsn.to_string());
339    }
340
341    #[test]
342    fn test_dsn_http_url() {
343        let url = "http://username:@domain:8888/42";
344        let dsn = Dsn::from_str(url).unwrap();
345        assert_eq!(url, dsn.to_string());
346    }
347
348    #[test]
349    fn test_dsn_non_integer_project_id() {
350        let url = "https://username:password@domain:8888/abc123youandme%21%21";
351        let dsn = url.parse::<Dsn>().unwrap();
352        assert_eq!(dsn.project_id(), &ProjectId::new("abc123youandme%21%21"));
353    }
354
355    #[test]
356    fn test_dsn_more_than_one_non_integer_path() {
357        let url = "http://username:@domain:8888/pathone/pathtwo/pid";
358        let dsn = url.parse::<Dsn>().unwrap();
359        assert_eq!(dsn.project_id(), &ProjectId::new("pid"));
360        assert_eq!(dsn.path(), "/pathone/pathtwo/");
361    }
362
363    /// Asserts the organization ID parsed from the given DSN.
364    #[cfg(feature = "protocol")]
365    fn assert_org_id(url: &str, expected: Option<OrganizationId>) {
366        let dsn = Dsn::from_str(url).unwrap();
367        assert_eq!(dsn.org_id(), expected);
368    }
369
370    #[cfg(feature = "protocol")]
371    #[test]
372    fn test_dsn_org_id_from_ingest_host() {
373        assert_org_id(
374            "https://username@o123.ingest.sentry.io/42",
375            Some(123.into()),
376        );
377    }
378
379    #[cfg(feature = "protocol")]
380    #[test]
381    fn test_dsn_org_id_from_regional_ingest_host() {
382        assert_org_id(
383            "https://username@o123.ingest.de.sentry.io/42",
384            Some(123.into()),
385        );
386    }
387
388    #[cfg(feature = "protocol")]
389    #[test]
390    fn test_dsn_org_id_ignores_host_without_org_label() {
391        assert_org_id("https://username@blah.ingest.sentry.io/42", None);
392    }
393
394    #[cfg(feature = "protocol")]
395    #[test]
396    fn test_dsn_org_id_ignores_non_ingest_saas_host() {
397        assert_org_id("https://username@o123.sentry.io/42", None);
398    }
399
400    #[cfg(feature = "protocol")]
401    #[test]
402    fn test_dsn_org_id_ignores_relay_host() {
403        assert_org_id("https://username@relay.example.com/42", None);
404    }
405
406    #[cfg(feature = "protocol")]
407    #[test]
408    fn test_dsn_org_id_ignores_localhost() {
409        assert_org_id("https://username@localhost/42", None);
410    }
411
412    #[cfg(feature = "protocol")]
413    #[test]
414    fn test_dsn_org_id_rejects_non_numeric_org_label() {
415        assert_org_id("https://username@oabc.ingest.sentry.io/42", None);
416    }
417
418    #[cfg(feature = "protocol")]
419    #[test]
420    fn test_dsn_org_id_rejects_missing_org_prefix() {
421        assert_org_id("https://username@123.ingest.sentry.io/42", None);
422    }
423
424    #[cfg(feature = "protocol")]
425    #[test]
426    fn test_dsn_org_id_rejects_empty_first_label() {
427        assert_org_id("https://username@.ingest.sentry.io/42", None);
428    }
429
430    #[cfg(feature = "protocol")]
431    #[test]
432    fn test_dsn_org_id_rejects_empty_org_id() {
433        assert_org_id("https://username@o.ingest.sentry.io/42", None);
434    }
435
436    #[cfg(feature = "protocol")]
437    #[test]
438    fn test_dsn_org_id_rejects_overflow() {
439        assert_org_id(
440            "https://username@o18446744073709551616.ingest.sentry.io/42",
441            None,
442        );
443    }
444
445    #[cfg(feature = "protocol")]
446    #[test]
447    fn test_dsn_org_id_ignores_host_with_extra_segment() {
448        assert_org_id("https://username@o123.ingest.de.sentry.io.com/42", None);
449    }
450
451    #[test]
452    #[should_panic(expected = "NoUsername")]
453    fn test_dsn_no_username() {
454        Dsn::from_str("https://:password@domain:8888/23").unwrap();
455    }
456
457    #[test]
458    #[should_panic(expected = "InvalidUrl")]
459    fn test_dsn_invalid_url() {
460        Dsn::from_str("random string").unwrap();
461    }
462
463    #[test]
464    #[should_panic(expected = "InvalidUrl")]
465    fn test_dsn_no_host() {
466        Dsn::from_str("https://username:password@:8888/42").unwrap();
467    }
468
469    #[test]
470    #[should_panic(expected = "NoProjectId")]
471    fn test_dsn_no_project_id() {
472        Dsn::from_str("https://username:password@domain:8888/").unwrap();
473    }
474
475    #[test]
476    #[should_panic(expected = "InvalidScheme")]
477    fn test_dsn_invalid_scheme() {
478        Dsn::from_str("ftp://username:password@domain:8888/1").unwrap();
479    }
480}