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#[derive(Debug, Error)]
14pub enum ParseDsnError {
15 #[error("no valid url provided")]
17 InvalidUrl,
18 #[error("no valid scheme")]
20 InvalidScheme,
21 #[error("username is empty")]
23 NoUsername,
24 #[error("empty path")]
26 NoProjectId,
27 #[error("invalid project id")]
29 InvalidProjectId(#[from] ParseProjectIdError),
30}
31
32#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
36pub enum Scheme {
37 Http,
39 Https,
41}
42
43impl Scheme {
44 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#[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 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 pub fn store_api_url(&self) -> Url {
106 self.api_url("store")
107 }
108
109 pub fn envelope_api_url(&self) -> Url {
111 self.api_url("envelope")
112 }
113
114 pub fn scheme(&self) -> Scheme {
116 self.scheme
117 }
118
119 pub fn public_key(&self) -> &str {
121 &self.public_key
122 }
123
124 pub fn secret_key(&self) -> Option<&str> {
126 self.secret_key.as_deref()
127 }
128
129 pub fn host(&self) -> &str {
131 &self.host
132 }
133
134 pub fn port(&self) -> u16 {
136 self.port.unwrap_or_else(|| self.scheme.default_port())
137 }
138
139 pub fn path(&self) -> &str {
141 &self.path
142 }
143
144 pub fn project_id(&self) -> &ProjectId {
146 &self.project_id
147 }
148
149 #[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
231fn extract_org_id(host: &str) -> Option<&str> {
239 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 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 #[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}