1use crate::response;
34use hmac::{Hmac, Mac};
35use secrecy::SecretString;
36use sha2::Sha256;
37use std::fmt;
38use subtle::ConstantTimeEq;
39
40pub type VerificationResult<T> = core::result::Result<T, VerificationError>;
42pub type ParseResult<T> = core::result::Result<T, ParseError>;
44
45type HmacSha256 = Hmac<Sha256>;
46
47#[derive(Clone)]
48pub struct SignatureKey(SecretString);
50
51impl SignatureKey {
52 pub fn from_secret(raw: impl Into<String>) -> Self {
54 Self(SecretString::new(raw.into()))
55 }
56
57 fn expose_for_verification(&self) -> &str {
58 use secrecy::ExposeSecret;
59 self.0.expose_secret()
60 }
61}
62
63impl fmt::Debug for SignatureKey {
64 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
65 formatter.write_str("SignatureKey(<redacted>)")
66 }
67}
68
69impl fmt::Display for SignatureKey {
70 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
71 formatter.write_str("<redacted>")
72 }
73}
74
75#[derive(Clone, Debug, PartialEq, Eq)]
76pub enum EventType {
78 CheckIn,
80 CheckOut,
82 CheckingIn,
84 CheckingOut,
86 EmailSent,
88 OwnerCreated,
90 OwnerEdited,
92 AnimalCreated,
94 AnimalEdited,
96 IncidentCreated,
98 IncidentEdited,
100 LeadCreated,
102 Unknown(String),
104}
105
106impl EventType {
107 pub fn parse(raw: impl AsRef<str>) -> Self {
109 match raw.as_ref() {
110 "check_in" => Self::CheckIn,
111 "check_out" => Self::CheckOut,
112 "checking_in" => Self::CheckingIn,
113 "checking_out" => Self::CheckingOut,
114 "email_sent" => Self::EmailSent,
115 "owner_created" => Self::OwnerCreated,
116 "owner_edited" => Self::OwnerEdited,
117 "animal_created" => Self::AnimalCreated,
118 "animal_edited" => Self::AnimalEdited,
119 "incident_created" => Self::IncidentCreated,
120 "incident_edited" => Self::IncidentEdited,
121 "lead_created" => Self::LeadCreated,
122 other => Self::Unknown(other.to_owned()),
123 }
124 }
125
126 pub fn as_provider_str(&self) -> &str {
128 match self {
129 Self::CheckIn => "check_in",
130 Self::CheckOut => "check_out",
131 Self::CheckingIn => "checking_in",
132 Self::CheckingOut => "checking_out",
133 Self::EmailSent => "email_sent",
134 Self::OwnerCreated => "owner_created",
135 Self::OwnerEdited => "owner_edited",
136 Self::AnimalCreated => "animal_created",
137 Self::AnimalEdited => "animal_edited",
138 Self::IncidentCreated => "incident_created",
139 Self::IncidentEdited => "incident_edited",
140 Self::LeadCreated => "lead_created",
141 Self::Unknown(raw) => raw,
142 }
143 }
144}
145
146#[derive(Clone, Debug, PartialEq, Eq)]
147pub enum EntityType {
149 Reservation,
151 Owner,
153 Animal,
155 Incident,
157 Lead,
159 Unknown(String),
161}
162
163impl EntityType {
164 pub fn parse(raw: impl AsRef<str>) -> Self {
166 match raw.as_ref() {
167 "reservation" => Self::Reservation,
168 "owner" => Self::Owner,
169 "animal" => Self::Animal,
170 "incident" => Self::Incident,
171 "lead" => Self::Lead,
172 other => Self::Unknown(other.to_owned()),
173 }
174 }
175
176 pub fn as_provider_str(&self) -> &str {
178 match self {
179 Self::Reservation => "reservation",
180 Self::Owner => "owner",
181 Self::Animal => "animal",
182 Self::Incident => "incident",
183 Self::Lead => "lead",
184 Self::Unknown(raw) => raw,
185 }
186 }
187}
188
189#[derive(Clone, Debug, PartialEq, Eq)]
190pub struct EntityId(String);
192
193impl EntityId {
194 fn normalize(value: &serde_json::Value) -> VerificationResult<Self> {
195 match value {
196 serde_json::Value::String(raw) if !raw.is_empty() => Ok(Self(raw.clone())),
197 serde_json::Value::Number(number) if number.is_i64() || number.is_u64() => {
198 Ok(Self(number.to_string()))
199 }
200 other => Err(VerificationError::UnsupportedEntityId {
201 observed_type: json_type_name(other).to_owned(),
202 }),
203 }
204 }
205
206 pub fn as_str(&self) -> &str {
208 &self.0
209 }
210}
211
212impl fmt::Display for EntityId {
213 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
214 formatter.write_str(&self.0)
215 }
216}
217
218#[derive(Clone, PartialEq)]
219pub struct Envelope {
221 wire: WireEnvelope,
222}
223
224impl fmt::Debug for Envelope {
225 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
226 formatter
227 .debug_struct("Envelope")
228 .field("webhook_type", &self.wire.webhook_type)
229 .field(
230 "entity_id_type",
231 &self.wire.entity_id.as_ref().map(json_type_name),
232 )
233 .field("entity_type", &self.wire.entity_type)
234 .field("signature_present", &self.wire.signature.is_some())
235 .finish_non_exhaustive()
236 }
237}
238
239impl Envelope {
240 pub fn from_json(raw: impl AsRef<str>) -> ParseResult<Self> {
242 let wire = serde_json::from_str(raw.as_ref())?;
243 Ok(Self { wire })
244 }
245
246 pub fn event_type_input(&self) -> Option<&str> {
248 self.wire.webhook_type.as_deref()
249 }
250
251 pub fn entity_type_input(&self) -> Option<&str> {
253 self.wire.entity_type.as_deref()
254 }
255
256 pub fn signature_input(&self) -> Option<&str> {
258 self.wire.signature.as_deref()
259 }
260
261 pub fn verify(self, key: &SignatureKey) -> VerificationResult<Verified> {
263 let webhook_type =
264 self.wire
265 .webhook_type
266 .as_deref()
267 .ok_or(VerificationError::MissingField {
268 field: "webhook_type",
269 })?;
270 let entity_id_value = self
271 .wire
272 .entity_id
273 .as_ref()
274 .ok_or(VerificationError::MissingField { field: "entity_id" })?;
275 let entity_id = EntityId::normalize(entity_id_value)?;
276 let entity_type =
277 self.wire
278 .entity_type
279 .as_deref()
280 .ok_or(VerificationError::MissingField {
281 field: "entity_type",
282 })?;
283 let supplied_signature = self
284 .wire
285 .signature
286 .as_deref()
287 .ok_or(VerificationError::MissingField { field: "signature" })?;
288
289 verify_signature(
290 key,
291 webhook_type,
292 entity_id.as_str(),
293 entity_type,
294 supplied_signature,
295 )?;
296
297 Ok(Verified {
298 event_type: EventType::parse(webhook_type),
299 entity_id,
300 entity_type: EntityType::parse(entity_type),
301 payload: Payload { wire: self.wire },
302 })
303 }
304}
305
306#[derive(Clone, Debug, PartialEq)]
307pub struct Verified {
309 event_type: EventType,
310 entity_id: EntityId,
311 entity_type: EntityType,
312 payload: Payload,
313}
314
315impl Verified {
316 pub fn event_type(&self) -> EventType {
318 self.event_type.clone()
319 }
320
321 pub fn entity_id(&self) -> &EntityId {
323 &self.entity_id
324 }
325
326 pub fn entity_type(&self) -> EntityType {
328 self.entity_type.clone()
329 }
330
331 pub fn payload(&self) -> &Payload {
333 &self.payload
334 }
335}
336
337#[derive(Clone, PartialEq)]
338pub struct Payload {
340 wire: WireEnvelope,
341}
342
343impl fmt::Debug for Payload {
344 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
345 formatter
346 .debug_struct("Payload")
347 .field("provider_url", &self.wire.webhook_url)
348 .field("has_entity_data", &!self.wire.entity_data.is_null())
349 .field("has_email_data", &self.wire.email_data.is_some())
350 .field(
351 "recipient_count",
352 &self.wire.recipients.as_ref().map(Vec::len),
353 )
354 .finish_non_exhaustive()
355 }
356}
357
358impl Payload {
359 pub fn entity_data(&self) -> &serde_json::Value {
361 &self.wire.entity_data
362 }
363
364 pub fn email_data(&self) -> Option<&serde_json::Value> {
366 self.wire.email_data.as_ref()
367 }
368
369 pub fn recipients(&self) -> Option<&Vec<serde_json::Value>> {
371 self.wire.recipients.as_ref()
372 }
373
374 pub fn provider_url(&self) -> Option<&str> {
376 self.wire.webhook_url.as_deref()
377 }
378}
379
380#[derive(Clone, PartialEq, serde::Deserialize)]
381struct WireEnvelope {
382 webhook_url: Option<String>,
383 webhook_type: Option<String>,
384 entity_id: Option<serde_json::Value>,
385 entity_type: Option<String>,
386 signature: Option<String>,
387 #[serde(default)]
388 entity_data: serde_json::Value,
389 email_data: Option<serde_json::Value>,
390 recipients: Option<Vec<serde_json::Value>>,
391 #[serde(flatten)]
392 unknown: serde_json::Map<String, serde_json::Value>,
393}
394
395#[derive(Debug, thiserror::Error)]
396pub enum ParseError {
398 #[error("invalid Gingr webhook JSON: {0}")]
399 Json(#[from] serde_json::Error),
401}
402
403#[derive(Debug, thiserror::Error, PartialEq, Eq)]
404pub enum VerificationError {
406 #[error("Gingr webhook is missing required field {field}")]
407 MissingField {
409 field: &'static str,
411 },
412 #[error("unsupported Gingr webhook entity_id representation: {observed_type}")]
413 UnsupportedEntityId {
415 observed_type: String,
417 },
418 #[error("malformed Gingr webhook signature: {reason}")]
419 MalformedSignature {
421 reason: String,
423 },
424 #[error("Gingr webhook signature mismatch")]
425 SignatureMismatch,
427}
428
429#[derive(Clone, Debug, PartialEq, Eq)]
430pub enum Ack {
432 Processed,
434 RejectedPermanently,
436 RetryableFailure,
438 RetryableStatus(response::HttpStatus),
440}
441
442impl Ack {
443 pub fn retryable_status(status: response::HttpStatus) -> Self {
445 if status.is_gingr_retry_override_allowed() {
446 Self::RetryableStatus(status)
447 } else {
448 Self::RetryableFailure
449 }
450 }
451
452 pub fn http_status(&self) -> response::HttpStatus {
454 match self {
455 Self::Processed => response::HttpStatus::OK,
456 Self::RejectedPermanently => response::HttpStatus::FORBIDDEN,
457 Self::RetryableFailure => response::HttpStatus::INTERNAL_SERVER_ERROR,
458 Self::RetryableStatus(status) => *status,
459 }
460 }
461}
462
463fn verify_signature(
464 key: &SignatureKey,
465 webhook_type: &str,
466 entity_id: &str,
467 entity_type: &str,
468 supplied_signature: &str,
469) -> VerificationResult<()> {
470 let supplied = decode_lower_hex_sha256(supplied_signature)?;
471 let mut mac = HmacSha256::new_from_slice(key.expose_for_verification().as_bytes())
472 .expect("HMAC accepts keys of any size");
473 mac.update(webhook_type.as_bytes());
474 mac.update(entity_id.as_bytes());
475 mac.update(entity_type.as_bytes());
476 let expected = mac.finalize().into_bytes();
477
478 if expected.as_slice().ct_eq(&supplied).into() {
479 Ok(())
480 } else {
481 Err(VerificationError::SignatureMismatch)
482 }
483}
484
485fn decode_lower_hex_sha256(raw: &str) -> VerificationResult<[u8; 32]> {
486 if raw.len() != 64 {
487 return Err(VerificationError::MalformedSignature {
488 reason: "expected 64 lowercase hex characters".to_owned(),
489 });
490 }
491
492 let mut decoded = [0_u8; 32];
493 for (index, pair) in raw.as_bytes().chunks_exact(2).enumerate() {
494 let high = decode_lower_hex_nibble(pair[0])?;
495 let low = decode_lower_hex_nibble(pair[1])?;
496 decoded[index] = (high << 4) | low;
497 }
498 Ok(decoded)
499}
500
501fn decode_lower_hex_nibble(byte: u8) -> VerificationResult<u8> {
502 match byte {
503 b'0'..=b'9' => Ok(byte - b'0'),
504 b'a'..=b'f' => Ok(byte - b'a' + 10),
505 _ => Err(VerificationError::MalformedSignature {
506 reason: "signature must be lowercase hex".to_owned(),
507 }),
508 }
509}
510
511fn json_type_name(value: &serde_json::Value) -> &'static str {
512 match value {
513 serde_json::Value::Null => "null",
514 serde_json::Value::Bool(_) => "boolean",
515 serde_json::Value::Number(number) if number.is_f64() => "decimal number",
516 serde_json::Value::Number(_) => "number",
517 serde_json::Value::String(_) => "string",
518 serde_json::Value::Array(_) => "array",
519 serde_json::Value::Object(_) => "object",
520 }
521}