1use serde::{Deserialize, Serialize};
53use std::collections::BTreeMap;
54use std::fmt;
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
88pub enum AshErrorCode {
89 CtxNotFound,
91 CtxExpired,
93 CtxAlreadyUsed,
95 BindingMismatch,
97 ProofMissing,
99 ProofInvalid,
101 CanonicalizationError,
103 ValidationError,
106 ModeViolation,
108 UnsupportedContentType,
110 ScopeMismatch,
112 ChainBroken,
114 InternalError,
116 TimestampInvalid,
118 ScopedFieldMissing,
120}
121
122impl Serialize for AshErrorCode {
126 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
127 serializer.serialize_str(self.as_str())
128 }
129}
130
131impl<'de> Deserialize<'de> for AshErrorCode {
132 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
133 let s = String::deserialize(deserializer)?;
134 match s.as_str() {
135 "ASH_CTX_NOT_FOUND" => Ok(AshErrorCode::CtxNotFound),
136 "ASH_CTX_EXPIRED" => Ok(AshErrorCode::CtxExpired),
137 "ASH_CTX_ALREADY_USED" => Ok(AshErrorCode::CtxAlreadyUsed),
138 "ASH_BINDING_MISMATCH" => Ok(AshErrorCode::BindingMismatch),
139 "ASH_PROOF_MISSING" => Ok(AshErrorCode::ProofMissing),
140 "ASH_PROOF_INVALID" => Ok(AshErrorCode::ProofInvalid),
141 "ASH_CANONICALIZATION_ERROR" => Ok(AshErrorCode::CanonicalizationError),
142 "ASH_VALIDATION_ERROR" => Ok(AshErrorCode::ValidationError),
143 "ASH_MODE_VIOLATION" => Ok(AshErrorCode::ModeViolation),
144 "ASH_UNSUPPORTED_CONTENT_TYPE" => Ok(AshErrorCode::UnsupportedContentType),
145 "ASH_SCOPE_MISMATCH" => Ok(AshErrorCode::ScopeMismatch),
146 "ASH_CHAIN_BROKEN" => Ok(AshErrorCode::ChainBroken),
147 "ASH_INTERNAL_ERROR" => Ok(AshErrorCode::InternalError),
148 "ASH_TIMESTAMP_INVALID" => Ok(AshErrorCode::TimestampInvalid),
149 "ASH_SCOPED_FIELD_MISSING" => Ok(AshErrorCode::ScopedFieldMissing),
150 _ => Err(serde::de::Error::unknown_variant(
151 &s,
152 &[
153 "ASH_CTX_NOT_FOUND", "ASH_CTX_EXPIRED", "ASH_CTX_ALREADY_USED",
154 "ASH_BINDING_MISMATCH", "ASH_PROOF_MISSING", "ASH_PROOF_INVALID",
155 "ASH_CANONICALIZATION_ERROR", "ASH_VALIDATION_ERROR", "ASH_MODE_VIOLATION",
156 "ASH_UNSUPPORTED_CONTENT_TYPE", "ASH_SCOPE_MISMATCH", "ASH_CHAIN_BROKEN",
157 "ASH_INTERNAL_ERROR", "ASH_TIMESTAMP_INVALID", "ASH_SCOPED_FIELD_MISSING",
158 ],
159 )),
160 }
161 }
162}
163
164impl AshErrorCode {
165 pub fn http_status(&self) -> u16 {
171 match self {
172 AshErrorCode::CtxNotFound => 450,
174 AshErrorCode::CtxExpired => 451,
175 AshErrorCode::CtxAlreadyUsed => 452,
176 AshErrorCode::ProofInvalid => 460,
178 AshErrorCode::BindingMismatch => 461,
180 AshErrorCode::ScopeMismatch => 473,
182 AshErrorCode::ChainBroken => 474,
183 AshErrorCode::ScopedFieldMissing => 475,
184 AshErrorCode::TimestampInvalid => 482,
186 AshErrorCode::ProofMissing => 483,
187 AshErrorCode::CanonicalizationError => 484,
188 AshErrorCode::ValidationError => 485,
189 AshErrorCode::ModeViolation => 486,
190 AshErrorCode::UnsupportedContentType => 415,
192 AshErrorCode::InternalError => 500,
193 }
194 }
195
196 pub fn retryable(&self) -> bool {
205 matches!(
206 self,
207 AshErrorCode::TimestampInvalid | AshErrorCode::InternalError
208 )
209 }
210
211 pub fn as_str(&self) -> &'static str {
215 match self {
216 AshErrorCode::CtxNotFound => "ASH_CTX_NOT_FOUND",
217 AshErrorCode::CtxExpired => "ASH_CTX_EXPIRED",
218 AshErrorCode::CtxAlreadyUsed => "ASH_CTX_ALREADY_USED",
219 AshErrorCode::BindingMismatch => "ASH_BINDING_MISMATCH",
220 AshErrorCode::ProofMissing => "ASH_PROOF_MISSING",
221 AshErrorCode::ProofInvalid => "ASH_PROOF_INVALID",
222 AshErrorCode::CanonicalizationError => "ASH_CANONICALIZATION_ERROR",
223 AshErrorCode::ValidationError => "ASH_VALIDATION_ERROR",
224 AshErrorCode::ModeViolation => "ASH_MODE_VIOLATION",
225 AshErrorCode::UnsupportedContentType => "ASH_UNSUPPORTED_CONTENT_TYPE",
226 AshErrorCode::ScopeMismatch => "ASH_SCOPE_MISMATCH",
227 AshErrorCode::ChainBroken => "ASH_CHAIN_BROKEN",
228 AshErrorCode::InternalError => "ASH_INTERNAL_ERROR",
229 AshErrorCode::TimestampInvalid => "ASH_TIMESTAMP_INVALID",
230 AshErrorCode::ScopedFieldMissing => "ASH_SCOPED_FIELD_MISSING",
231 }
232 }
233}
234
235impl fmt::Display for AshErrorCode {
236 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
237 write!(f, "{}", self.as_str())
238 }
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
264pub enum InternalReason {
265 HdrMissing,
268 HdrMultiValue,
270 HdrInvalidChars,
272
273 TsParse,
276 TsSkew,
278 TsLeadingZeros,
280 TsOverflow,
282
283 NonceTooShort,
286 NonceTooLong,
288 NonceInvalidChars,
290
291 General,
293}
294
295impl fmt::Display for InternalReason {
296 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
297 match self {
298 InternalReason::HdrMissing => write!(f, "HDR_MISSING"),
299 InternalReason::HdrMultiValue => write!(f, "HDR_MULTI_VALUE"),
300 InternalReason::HdrInvalidChars => write!(f, "HDR_INVALID_CHARS"),
301 InternalReason::TsParse => write!(f, "TS_PARSE"),
302 InternalReason::TsSkew => write!(f, "TS_SKEW"),
303 InternalReason::TsLeadingZeros => write!(f, "TS_LEADING_ZEROS"),
304 InternalReason::TsOverflow => write!(f, "TS_OVERFLOW"),
305 InternalReason::NonceTooShort => write!(f, "NONCE_TOO_SHORT"),
306 InternalReason::NonceTooLong => write!(f, "NONCE_TOO_LONG"),
307 InternalReason::NonceInvalidChars => write!(f, "NONCE_INVALID_CHARS"),
308 InternalReason::General => write!(f, "GENERAL"),
309 }
310 }
311}
312
313#[derive(Debug, Clone)]
326pub struct AshError {
327 code: AshErrorCode,
329 message: String,
331 reason: InternalReason,
333 details: Option<BTreeMap<&'static str, String>>,
335}
336
337impl AshError {
338 pub fn new(code: AshErrorCode, message: impl Into<String>) -> Self {
340 Self {
341 code,
342 message: message.into(),
343 reason: InternalReason::General,
344 details: None,
345 }
346 }
347
348 pub fn with_reason(code: AshErrorCode, reason: InternalReason, message: impl Into<String>) -> Self {
350 Self {
351 code,
352 message: message.into(),
353 reason,
354 details: None,
355 }
356 }
357
358 pub fn with_detail(mut self, key: &'static str, value: impl Into<String>) -> Self {
360 let map = self.details.get_or_insert_with(BTreeMap::new);
361 map.insert(key, value.into());
362 self
363 }
364
365 pub fn code(&self) -> AshErrorCode {
367 self.code
368 }
369
370 pub fn message(&self) -> &str {
372 &self.message
373 }
374
375 pub fn http_status(&self) -> u16 {
377 self.code.http_status()
378 }
379
380 pub fn reason(&self) -> InternalReason {
382 self.reason
383 }
384
385 pub fn details(&self) -> Option<&BTreeMap<&'static str, String>> {
387 self.details.as_ref()
388 }
389
390 pub fn retryable(&self) -> bool {
395 self.code.retryable()
396 }
397}
398
399impl fmt::Display for AshError {
400 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
401 write!(f, "{}: {}", self.code, self.message)
402 }
403}
404
405impl std::error::Error for AshError {}
406
407impl AshError {
409 pub fn ctx_not_found() -> Self {
411 Self::new(AshErrorCode::CtxNotFound, "Context not found")
412 }
413
414 pub fn ctx_expired() -> Self {
416 Self::new(AshErrorCode::CtxExpired, "Context has expired")
417 }
418
419 pub fn ctx_already_used() -> Self {
421 Self::new(AshErrorCode::CtxAlreadyUsed, "Context already consumed")
422 }
423
424 pub fn binding_mismatch() -> Self {
426 Self::new(
427 AshErrorCode::BindingMismatch,
428 "Binding does not match endpoint",
429 )
430 }
431
432 pub fn proof_missing() -> Self {
434 Self::new(AshErrorCode::ProofMissing, "Required proof not provided")
435 }
436
437 pub fn proof_invalid() -> Self {
439 Self::new(AshErrorCode::ProofInvalid, "Proof verification failed")
440 }
441
442 pub fn canonicalization_error() -> Self {
448 Self::new(
449 AshErrorCode::CanonicalizationError,
450 "Failed to canonicalize payload",
451 )
452 }
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458
459 #[test]
460 fn test_error_code_http_status() {
461 assert_eq!(AshErrorCode::CtxNotFound.http_status(), 450);
463 assert_eq!(AshErrorCode::CtxExpired.http_status(), 451);
464 assert_eq!(AshErrorCode::CtxAlreadyUsed.http_status(), 452);
465 assert_eq!(AshErrorCode::ProofInvalid.http_status(), 460);
467 assert_eq!(AshErrorCode::BindingMismatch.http_status(), 461);
469 assert_eq!(AshErrorCode::ScopeMismatch.http_status(), 473);
471 assert_eq!(AshErrorCode::ChainBroken.http_status(), 474);
472 assert_eq!(AshErrorCode::ScopedFieldMissing.http_status(), 475);
473 assert_eq!(AshErrorCode::TimestampInvalid.http_status(), 482);
475 assert_eq!(AshErrorCode::ProofMissing.http_status(), 483);
476 assert_eq!(AshErrorCode::CanonicalizationError.http_status(), 484);
477 assert_eq!(AshErrorCode::ValidationError.http_status(), 485);
478 assert_eq!(AshErrorCode::ModeViolation.http_status(), 486);
479 assert_eq!(AshErrorCode::UnsupportedContentType.http_status(), 415);
481 assert_eq!(AshErrorCode::InternalError.http_status(), 500);
482 }
483
484 #[test]
485 fn test_error_code_as_str() {
486 assert_eq!(AshErrorCode::CtxNotFound.as_str(), "ASH_CTX_NOT_FOUND");
487 assert_eq!(AshErrorCode::CtxAlreadyUsed.as_str(), "ASH_CTX_ALREADY_USED");
488 }
489
490 #[test]
491 fn test_error_display() {
492 let err = AshError::ctx_not_found();
493 assert_eq!(err.to_string(), "ASH_CTX_NOT_FOUND: Context not found");
494 }
495
496 #[test]
497 fn test_error_convenience_functions() {
498 assert_eq!(
499 AshError::ctx_not_found().code(),
500 AshErrorCode::CtxNotFound
501 );
502 assert_eq!(
503 AshError::ctx_expired().code(),
504 AshErrorCode::CtxExpired
505 );
506 assert_eq!(
507 AshError::ctx_already_used().code(),
508 AshErrorCode::CtxAlreadyUsed
509 );
510 }
511
512 #[test]
514 fn test_error_code_serde_serialization() {
515 let serialized = serde_json::to_string(&AshErrorCode::CtxNotFound).unwrap();
517 assert_eq!(serialized, r#""ASH_CTX_NOT_FOUND""#);
518
519 let serialized = serde_json::to_string(&AshErrorCode::ValidationError).unwrap();
520 assert_eq!(serialized, r#""ASH_VALIDATION_ERROR""#);
521
522 let serialized = serde_json::to_string(&AshErrorCode::ScopedFieldMissing).unwrap();
523 assert_eq!(serialized, r#""ASH_SCOPED_FIELD_MISSING""#);
524 }
525
526 #[test]
527 fn test_error_code_serde_deserialization() {
528 let code: AshErrorCode = serde_json::from_str(r#""ASH_CTX_NOT_FOUND""#).unwrap();
530 assert_eq!(code, AshErrorCode::CtxNotFound);
531
532 let code: AshErrorCode = serde_json::from_str(r#""ASH_PROOF_INVALID""#).unwrap();
533 assert_eq!(code, AshErrorCode::ProofInvalid);
534
535 let code: AshErrorCode = serde_json::from_str(r#""ASH_INTERNAL_ERROR""#).unwrap();
536 assert_eq!(code, AshErrorCode::InternalError);
537 }
538
539 #[test]
540 fn test_error_code_serde_roundtrip_all_variants() {
541 let all_codes = [
543 AshErrorCode::CtxNotFound,
544 AshErrorCode::CtxExpired,
545 AshErrorCode::CtxAlreadyUsed,
546 AshErrorCode::BindingMismatch,
547 AshErrorCode::ProofMissing,
548 AshErrorCode::ProofInvalid,
549 AshErrorCode::CanonicalizationError,
550 AshErrorCode::ValidationError,
551 AshErrorCode::ModeViolation,
552 AshErrorCode::UnsupportedContentType,
553 AshErrorCode::ScopeMismatch,
554 AshErrorCode::ChainBroken,
555 AshErrorCode::InternalError,
556 AshErrorCode::TimestampInvalid,
557 AshErrorCode::ScopedFieldMissing,
558 ];
559
560 for code in &all_codes {
561 let serialized = serde_json::to_string(code).unwrap();
562 assert!(serialized.contains("ASH_"), "Missing ASH_ prefix for {:?}: {}", code, serialized);
564 let deserialized: AshErrorCode = serde_json::from_str(&serialized).unwrap();
566 assert_eq!(*code, deserialized, "Roundtrip failed for {:?}", code);
567 let expected = format!("\"{}\"", code.as_str());
569 assert_eq!(serialized, expected, "Serde output doesn't match as_str() for {:?}", code);
570 }
571 }
572
573 #[test]
574 fn test_retryable_timestamp_invalid() {
575 assert!(AshErrorCode::TimestampInvalid.retryable());
576 }
577
578 #[test]
579 fn test_retryable_internal_error() {
580 assert!(AshErrorCode::InternalError.retryable());
581 }
582
583 #[test]
584 fn test_not_retryable_proof_invalid() {
585 assert!(!AshErrorCode::ProofInvalid.retryable());
586 }
587
588 #[test]
589 fn test_not_retryable_validation_error() {
590 assert!(!AshErrorCode::ValidationError.retryable());
591 }
592
593 #[test]
594 fn test_not_retryable_all_permanent_codes() {
595 let permanent = [
596 AshErrorCode::CtxNotFound,
597 AshErrorCode::CtxExpired,
598 AshErrorCode::CtxAlreadyUsed,
599 AshErrorCode::ProofInvalid,
600 AshErrorCode::BindingMismatch,
601 AshErrorCode::ScopeMismatch,
602 AshErrorCode::ChainBroken,
603 AshErrorCode::ScopedFieldMissing,
604 AshErrorCode::ProofMissing,
605 AshErrorCode::CanonicalizationError,
606 AshErrorCode::ValidationError,
607 AshErrorCode::ModeViolation,
608 AshErrorCode::UnsupportedContentType,
609 ];
610 for code in &permanent {
611 assert!(!code.retryable(), "{:?} should not be retryable", code);
612 }
613 }
614
615 #[test]
616 fn test_ash_error_retryable_delegates() {
617 let retryable = AshError::new(AshErrorCode::TimestampInvalid, "skew");
618 assert!(retryable.retryable());
619
620 let permanent = AshError::new(AshErrorCode::ProofInvalid, "bad proof");
621 assert!(!permanent.retryable());
622 }
623
624 #[test]
625 fn test_error_code_serde_rejects_invalid() {
626 let result: Result<AshErrorCode, _> = serde_json::from_str(r#""INVALID_CODE""#);
628 assert!(result.is_err());
629
630 let result: Result<AshErrorCode, _> = serde_json::from_str(r#""CTX_NOT_FOUND""#);
632 assert!(result.is_err());
633 }
634}