diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..143b1ca0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ + +/target/ +**/*.rs.bk +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..74ea7e09 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "sentry-types" +version = "0.1.0" +authors = ["Sentry "] + +[dependencies] +failure = "0.1.1" +failure_derive = "0.1.1" +url = "1.6.0" +serde = "1.0.27" +serde_derive = "1.0.27" +serde_json = "1.0.9" diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 00000000..9866461a --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,156 @@ +use std::fmt; +use std::str::FromStr; + +/// Represents an auth header parsing error. +#[derive(Debug, Fail)] +pub enum AuthParseError { + /// Raised if the auth header is not indicating sentry auth + #[fail(display = "non sentry auth")] + NonSentryAuth, + /// Raised if the timestamp value is invalid. + #[fail(display = "invalid value for timestamp")] + InvalidTimestamp, + /// Raised if the version value is invalid + #[fail(display = "invalid value for version")] + InvalidVersion, + /// Raised if the version is missing entirely + #[fail(display = "no valid version defined")] + MissingVersion, + /// Raised if the public key is missing entirely + #[fail(display = "missing public key in auth header")] + MissingPublicKey, +} + +/// Represents an auth header. +#[derive(Default, Debug)] +pub struct Auth { + timestamp: Option, + client: Option, + version: u16, + key: String, + secret: Option, +} + +impl Auth { + /// Returns the unix timestamp the client defined + pub fn timestamp(&self) -> Option { + self.timestamp + } + + /// Returns the protocol version the client speaks + pub fn version(&self) -> u16 { + self.version + } + + /// Returns the public key + pub fn public_key(&self) -> &str { + &self.key + } + + /// Returns the client's secret if it authenticated with a secret. + pub fn secret_key(&self) -> Option<&str> { + self.secret.as_ref().map(|x| x.as_str()) + } + + /// Returns true if the authentication implies public auth (no secret) + pub fn is_public(&self) -> bool { + self.secret.is_none() + } + + /// Returns the client's relay + pub fn client_relay(&self) -> Option<&str> { + self.client.as_ref().map(|x| x.as_str()) + } +} + +impl fmt::Display for Auth { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "Sentry sentry_key={}, sentry_version={}", + self.key, self.version + )?; + if let Some(ts) = self.timestamp { + write!(f, ", sentry_timestamp={}", ts)?; + } + if let Some(ref client) = self.client { + write!(f, ", sentry_client={}", client)?; + } + if let Some(ref secret) = self.secret { + write!(f, ", sentry_secret={}", secret)?; + } + Ok(()) + } +} + +impl FromStr for Auth { + type Err = AuthParseError; + + fn from_str(s: &str) -> Result { + let mut rv = Auth::default(); + let mut base_iter = s.splitn(2, ' '); + if !base_iter + .next() + .unwrap_or("") + .eq_ignore_ascii_case("sentry") + { + return Err(AuthParseError::NonSentryAuth); + } + let items = base_iter.next().unwrap_or(""); + for item in items.split(',') { + let mut kviter = item.trim().split('='); + match (kviter.next(), kviter.next()) { + (Some("sentry_timestamp"), Some(ts)) => { + rv.timestamp = Some(ts.parse().map_err(|_| AuthParseError::InvalidTimestamp)?); + } + (Some("sentry_client"), Some(client)) => { + rv.client = Some(client.into()); + } + (Some("sentry_version"), Some(version)) => { + rv.version = version.parse().map_err(|_| AuthParseError::InvalidVersion)?; + } + (Some("sentry_key"), Some(key)) => { + rv.key = key.into(); + } + (Some("sentry_secret"), Some(secret)) => { + rv.secret = Some(secret.into()); + } + _ => {} + } + } + + if rv.key.is_empty() { + return Err(AuthParseError::MissingPublicKey); + } + if rv.version == 0 { + return Err(AuthParseError::MissingVersion); + } + + Ok(rv) + } +} + +#[test] +fn test_auth_parsing() { + let auth: Auth = "Sentry sentry_timestamp=1328055286.51, \ + sentry_client=raven-python/42, \ + sentry_version=6, \ + sentry_key=public, \ + sentry_secret=secret" + .parse() + .unwrap(); + assert_eq!(auth.timestamp(), Some(1328055286.51)); + assert_eq!(auth.client_relay(), Some("raven-python/42")); + assert_eq!(auth.version(), 6); + assert_eq!(auth.public_key(), "public"); + assert_eq!(auth.secret_key(), Some("secret")); + + assert_eq!( + auth.to_string(), + "Sentry sentry_key=public, \ + sentry_version=6, \ + sentry_timestamp=1328055286.51, \ + sentry_client=raven-python/42, \ + sentry_secret=secret" + ); +} diff --git a/src/dsn.rs b/src/dsn.rs new file mode 100644 index 00000000..d81c8e2d --- /dev/null +++ b/src/dsn.rs @@ -0,0 +1,255 @@ +use std::fmt; +use std::str::FromStr; + +use url::Url; + +use project_id::{ProjectId, ProjectIdParseError}; + +/// Represents a dsn url parsing error. +#[derive(Debug, Fail)] +pub enum DsnParseError { + /// raised on completely invalid urls + #[fail(display = "no valid url provided")] + InvalidUrl, + /// raised the scheme is invalid / unsupported. + #[fail(display = "no valid scheme")] + InvalidScheme, + /// raised if the username (public key) portion is missing. + #[fail(display = "username is empty")] + NoUsername, + /// raised the project is is missing (first path component) + #[fail(display = "empty path")] + NoProjectId, + /// raised the project id is invalid. + #[fail(display = "invalid project id")] + InvalidProjectId(#[cause] ProjectIdParseError), +} + +/// Represents the scheme of an url http/https. +/// +/// This holds schemes that are supported by sentry and relays. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] +pub enum Scheme { + /// unencrypted HTTP scheme (should not be used) + Http, + /// encrypted HTTPS scheme + Https, +} + +impl Scheme { + /// Returns the default port for this scheme. + pub fn default_port(&self) -> u16 { + match *self { + Scheme::Http => 80, + Scheme::Https => 443, + } + } +} + +impl fmt::Display for Scheme { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "{}", + match *self { + Scheme::Https => "https", + Scheme::Http => "http", + } + ) + } +} + +/// Represents a Sentry dsn. +#[derive(Clone, Eq, PartialEq, Hash, Debug)] +pub struct Dsn { + scheme: Scheme, + public_key: String, + secret_key: Option, + host: String, + port: Option, + project_id: ProjectId, +} + +impl Dsn { + /// Returns the scheme + pub fn scheme(&self) -> Scheme { + self.scheme + } + + /// Returns the public_key + pub fn public_key(&self) -> &str { + &self.public_key + } + + /// Returns secret_key + pub fn secret_key(&self) -> Option<&str> { + self.secret_key.as_ref().map(|x| x.as_str()) + } + + /// Returns the host + pub fn host(&self) -> &str { + &self.host + } + + /// Returns the port + pub fn port(&self) -> Option { + self.port + } + + /// Returns the project_id + pub fn project_id(&self) -> ProjectId { + self.project_id + } +} + +impl fmt::Display for Dsn { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}://{}", self.scheme, self.public_key)?; + if let Some(ref secret_key) = self.secret_key { + write!(f, ":{}", secret_key)?; + } + write!(f, "@{}", self.host)?; + if let Some(ref port) = self.port { + write!(f, ":{}", port)?; + } + write!(f, "/{}", self.project_id)?; + Ok(()) + } +} + +impl FromStr for Dsn { + type Err = DsnParseError; + + fn from_str(s: &str) -> Result { + let url = Url::parse(s).map_err(|_| DsnParseError::InvalidUrl)?; + + if url.path() == "/" { + return Err(DsnParseError::NoProjectId); + } + + let path_segments = url.path_segments() + .ok_or_else(|| DsnParseError::NoProjectId)?; + if path_segments.count() > 1 { + return Err(DsnParseError::InvalidUrl); + } + + let public_key = match url.username() { + "" => return Err(DsnParseError::NoUsername), + username => username.to_string(), + }; + + let scheme = match url.scheme() { + "http" => Scheme::Http, + "https" => Scheme::Https, + _ => return Err(DsnParseError::InvalidScheme), + }; + + let secret_key = url.password().map(|s| s.into()); + let port = url.port().map(|s| s.into()); + let host = match url.host_str() { + Some(host) => host.into(), + None => return Err(DsnParseError::InvalidUrl), + }; + let project_id = url.path() + .trim_matches('/') + .parse() + .map_err(DsnParseError::InvalidProjectId)?; + + Ok(Dsn { + scheme, + public_key, + secret_key, + port, + host, + project_id, + }) + } +} + +impl_str_serialization!(Dsn, "a sentry dsn"); + +#[cfg(test)] +mod test { + + use super::*; + use serde_json; + + #[test] + fn test_dsn_serialize_deserialize() { + let dsn = Dsn::from_str("https://username@domain/42").unwrap(); + let serialized = serde_json::to_string(&dsn).unwrap(); + assert_eq!(serialized, "\"https://username@domain/42\""); + let deserialized: Dsn = serde_json::from_str(&serialized).unwrap(); + assert_eq!(deserialized.to_string(), "https://username@domain/42"); + } + + #[test] + fn test_dsn_parsing() { + let url = "https://username:password@domain:8888/23"; + let dsn = url.parse::().unwrap(); + assert_eq!(dsn.scheme(), Scheme::Https); + assert_eq!(dsn.public_key(), "username"); + assert_eq!(dsn.secret_key(), Some("password")); + assert_eq!(dsn.host(), "domain"); + assert_eq!(dsn.port(), Some(8888)); + assert_eq!(dsn.project_id(), ProjectId::from(23)); + assert_eq!(url, dsn.to_string()); + } + + #[test] + fn test_dsn_no_port() { + let url = "https://username@domain/42"; + let dsn = Dsn::from_str(url).unwrap(); + assert_eq!(url, dsn.to_string()); + } + + #[test] + fn test_dsn_no_password() { + let url = "https://username@domain:8888/42"; + let dsn = Dsn::from_str(url).unwrap(); + assert_eq!(url, dsn.to_string()); + } + + #[test] + fn test_dsn_http_url() { + let url = "http://username@domain:8888/42"; + let dsn = Dsn::from_str(url).unwrap(); + assert_eq!(url, dsn.to_string()); + } + + #[test] + #[should_panic(expected = "InvalidUrl")] + fn test_dsn_more_than_one_non_integer_path() { + Dsn::from_str("http://username@domain:8888/path/path2").unwrap(); + } + + #[test] + #[should_panic(expected = "NoUsername")] + fn test_dsn_no_username() { + Dsn::from_str("https://:password@domain:8888/23").unwrap(); + } + + #[test] + #[should_panic(expected = "InvalidUrl")] + fn test_dsn_invalid_url() { + Dsn::from_str("random string").unwrap(); + } + + #[test] + #[should_panic(expected = "InvalidUrl")] + fn test_dsn_no_host() { + Dsn::from_str("https://username:password@:8888/42").unwrap(); + } + + #[test] + #[should_panic(expected = "NoProjectId")] + fn test_dsn_no_project_id() { + Dsn::from_str("https://username:password@domain:8888/").unwrap(); + } + + #[test] + #[should_panic(expected = "InvalidScheme")] + fn test_dsn_invalid_scheme() { + Dsn::from_str("ftp://username:password@domain:8888/1").unwrap(); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 00000000..25ff5bae --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,23 @@ +//! This crate provides common types for working with the Sentry protocol or the +//! Sentry server. It's used by the sentry relay infrastructure as well as the +//! rust Sentry client/. +#![warn(missing_docs)] +extern crate failure; +#[macro_use] +extern crate failure_derive; +extern crate serde; +#[macro_use] +extern crate serde_derive; +extern crate serde_json; +extern crate url; + +#[macro_use] +mod macros; + +mod auth; +mod dsn; +mod project_id; + +pub use auth::*; +pub use dsn::*; +pub use project_id::*; diff --git a/src/macros.rs b/src/macros.rs new file mode 100644 index 00000000..66fe5238 --- /dev/null +++ b/src/macros.rs @@ -0,0 +1,47 @@ +/// Helper macro to implement string based serialization. +/// +/// If a type implements `FromStr` and `Display` then this automatically +/// implements a serializer/deserializer for that type that dispatches +/// appropriately. First argument is the name of the type, the second +/// is a message for the expectation error (human readable type effectively). +macro_rules! impl_str_serialization { + ($type:ty, $expectation:expr) => { + impl ::serde::ser::Serialize for $type { + fn serialize(&self, serializer: S) -> Result + where + S: ::serde::ser::Serializer, + { + serializer.serialize_str(&self.to_string()) + } + } + + impl<'de> ::serde::de::Deserialize<'de> for $type { + fn deserialize(deserializer: D) -> Result + where + D: ::serde::de::Deserializer<'de>, + { + struct V; + + impl<'de> ::serde::de::Visitor<'de> for V { + type Value = $type; + + fn expecting(&self, formatter: &mut ::std::fmt::Formatter) -> fmt::Result { + formatter.write_str($expectation) + } + + fn visit_str(self, value: &str) -> Result<$type, E> + where + E: ::serde::de::Error, + { + value + .parse() + .map_err(|_| ::serde::de::Error::invalid_value( + ::serde::de::Unexpected::Str(value), &self)) + } + } + + deserializer.deserialize_str(V) + } + } + } +} diff --git a/src/project_id.rs b/src/project_id.rs new file mode 100644 index 00000000..1a6ce5c9 --- /dev/null +++ b/src/project_id.rs @@ -0,0 +1,107 @@ +use std::fmt; +use std::str::FromStr; + +/// Represents a project ID. +/// +/// This is a thin wrapper around IDs supported by the Sentry +/// server. The idea is that the sentry server generally can +/// switch the ID format in the future (eg: we implement the IDs +/// as strings and not as integers) but the actual ID format that +/// is encountered are currently indeed integers. +/// +/// To be future proof we support either integers or "short" +/// strings. +#[derive(Copy, Clone, PartialEq, Eq, Ord, PartialOrd, Hash)] +pub struct ProjectId { + // for now the only supported format is indeed an u64 + val: u64, +} + +/// Raised if a project ID cannot be parsed from a string. +#[derive(Debug, Fail, PartialEq, Eq, PartialOrd, Ord)] +pub enum ProjectIdParseError { + /// Raised if the value is not an integer in the supported range. + #[fail(display = "invalid value for project id")] + InvalidValue, + /// Raised if an empty value is parsed. + #[fail(display = "empty or missing project id")] + EmptyValue, +} + +impl fmt::Display for ProjectId { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.val) + } +} + +impl fmt::Debug for ProjectId { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self) + } +} + +macro_rules! impl_from { + ($ty:ty) => { + impl From<$ty> for ProjectId { + fn from(val: $ty) -> ProjectId { + ProjectId { val: val as u64 } + } + } + } +} + +impl_from!(usize); +impl_from!(u8); +impl_from!(u16); +impl_from!(u32); +impl_from!(u64); +impl_from!(i8); +impl_from!(i16); +impl_from!(i32); +impl_from!(i64); + +impl FromStr for ProjectId { + type Err = ProjectIdParseError; + + fn from_str(s: &str) -> Result { + if s.is_empty() { + return Err(ProjectIdParseError::EmptyValue); + } + match s.parse::() { + Ok(val) => Ok(ProjectId { val: val }), + Err(_) => Err(ProjectIdParseError::InvalidValue), + } + } +} + +impl_str_serialization!(ProjectId, "a project id"); + +#[cfg(test)] +mod test { + use super::*; + use serde_json; + + #[test] + fn test_basic_api() { + let id: ProjectId = "42".parse().unwrap(); + assert_eq!(id, ProjectId::from(42)); + assert_eq!( + "42xxx".parse::(), + Err(ProjectIdParseError::InvalidValue) + ); + assert_eq!( + "".parse::(), + Err(ProjectIdParseError::EmptyValue) + ); + assert_eq!(ProjectId::from(42).to_string(), "42"); + + assert_eq!( + serde_json::to_string(&ProjectId::from(42)).unwrap(), + "\"42\"" + ); + assert_eq!( + serde_json::from_str::("\"42\"").unwrap(), + ProjectId::from(42) + ); + } +}