1use std::borrow::Cow;
10use std::fmt;
11
12use crate::telemetry::{REDACTED_VALUE, is_never_echo, is_never_log};
13
14pub const ADMISSION_CHALLENGE_TYPE: &str = "mkit.transport.v1.AdmissionChallenge";
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub enum Code {
28 Canceled,
30 Unknown,
32 InvalidArgument,
34 DeadlineExceeded,
36 NotFound,
38 AlreadyExists,
40 PermissionDenied,
43 ResourceExhausted,
47 FailedPrecondition,
49 Aborted,
51 OutOfRange,
53 Unimplemented,
55 Internal,
57 Unavailable,
61 DataLoss,
63 Unauthenticated,
65}
66
67impl Code {
68 #[must_use]
71 pub const fn as_str(self) -> &'static str {
72 match self {
73 Self::Canceled => "canceled",
74 Self::Unknown => "unknown",
75 Self::InvalidArgument => "invalid_argument",
76 Self::DeadlineExceeded => "deadline_exceeded",
77 Self::NotFound => "not_found",
78 Self::AlreadyExists => "already_exists",
79 Self::PermissionDenied => "permission_denied",
80 Self::ResourceExhausted => "resource_exhausted",
81 Self::FailedPrecondition => "failed_precondition",
82 Self::Aborted => "aborted",
83 Self::OutOfRange => "out_of_range",
84 Self::Unimplemented => "unimplemented",
85 Self::Internal => "internal",
86 Self::Unavailable => "unavailable",
87 Self::DataLoss => "data_loss",
88 Self::Unauthenticated => "unauthenticated",
89 }
90 }
91
92 #[must_use]
96 pub const fn is_retryable(self) -> bool {
97 matches!(
98 self,
99 Self::Unavailable | Self::ResourceExhausted | Self::Aborted
100 )
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct ErrorDetail {
108 pub type_name: String,
110 pub value: bytes::Bytes,
112}
113
114#[derive(Clone, PartialEq, Eq)]
118pub struct Redacted(String);
119
120impl Redacted {
121 #[must_use]
123 pub fn new(value: impl Into<String>) -> Self {
124 Self(value.into())
125 }
126
127 #[must_use]
129 pub fn expose(&self) -> &str {
130 &self.0
131 }
132}
133
134impl fmt::Debug for Redacted {
135 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136 write!(f, "Redacted({} bytes)", self.0.len())
137 }
138}
139
140impl fmt::Display for Redacted {
141 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142 f.write_str(REDACTED_VALUE)
143 }
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
148pub enum InvalidHeader {
149 #[error("header name is not an HTTP token")]
151 Name,
152 #[error("header name is reserved")]
155 Reserved,
156 #[error("header value contains a control character")]
158 Value,
159}
160
161const RESERVED_RESPONSE_HEADERS: &[&str] = &[
164 "connection",
165 "host",
166 "keep-alive",
167 "proxy-authenticate",
168 "proxy-connection",
169 "set-cookie",
170 "te",
171 "trailer",
172 "transfer-encoding",
173 "upgrade",
174];
175
176const RESERVED_RESPONSE_PREFIXES: &[&str] = &["content-", "connect-"];
179
180#[derive(Clone, thiserror::Error)]
186#[error("{public}")]
187pub struct ServerError {
188 code: Code,
189 public: Cow<'static, str>,
190 detail: Option<Redacted>,
191 http_status: Option<u16>,
192 headers: Vec<(String, String)>,
193 details: Vec<ErrorDetail>,
194 abort: Option<AbortCause>,
195 transport_admission: bool,
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub(crate) enum AbortCause {
203 EpochMismatch,
205 ReplayRace,
207 QuotaWindow,
209 Contention,
211}
212
213impl fmt::Debug for ServerError {
214 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215 struct Headers<'a>(&'a [(String, String)]);
216 impl fmt::Debug for Headers<'_> {
217 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218 f.debug_list()
219 .entries(self.0.iter().map(|(name, value)| {
220 let shown = if is_never_log(name) {
221 REDACTED_VALUE
222 } else {
223 value
224 };
225 (name, shown)
226 }))
227 .finish()
228 }
229 }
230 f.debug_struct("ServerError")
231 .field("code", &self.code)
232 .field("public", &self.public)
233 .field("detail", &self.detail)
234 .field("http_status", &self.http_status)
235 .field("headers", &Headers(&self.headers))
236 .field("details", &self.details)
237 .field("abort", &self.abort)
238 .field("transport_admission", &self.transport_admission)
239 .finish()
240 }
241}
242
243impl ServerError {
244 #[must_use]
246 pub fn new(code: Code, public: impl Into<Cow<'static, str>>) -> Self {
247 Self {
248 code,
249 public: public.into(),
250 detail: None,
251 http_status: None,
252 headers: Vec::new(),
253 details: Vec::new(),
254 abort: None,
255 transport_admission: false,
256 }
257 }
258
259 #[must_use]
261 pub(crate) fn with_abort_cause(mut self, cause: AbortCause) -> Self {
262 self.abort = Some(cause);
263 self
264 }
265
266 #[must_use]
269 pub(crate) fn with_transport_admission_required(mut self) -> Self {
270 self.transport_admission = true;
271 self
272 }
273
274 #[must_use]
279 pub fn is_transport_admission_required(&self) -> bool {
280 self.transport_admission
281 }
282
283 #[must_use]
285 pub(crate) fn abort_cause(&self) -> Option<AbortCause> {
286 self.abort
287 }
288
289 #[must_use]
291 pub fn invalid_argument(public: impl Into<Cow<'static, str>>) -> Self {
292 Self::new(Code::InvalidArgument, public)
293 }
294
295 #[must_use]
297 pub fn unauthenticated(public: impl Into<Cow<'static, str>>) -> Self {
298 Self::new(Code::Unauthenticated, public)
299 }
300
301 #[must_use]
303 pub fn permission_denied(public: impl Into<Cow<'static, str>>) -> Self {
304 Self::new(Code::PermissionDenied, public)
305 }
306
307 #[must_use]
313 pub fn admission_challenge(challenge: bytes::Bytes) -> Self {
314 Self::new(Code::PermissionDenied, "admission required")
315 .with_http_status(402)
316 .with_header("Cache-Control", "no-store")
317 .with_detail(ErrorDetail {
318 type_name: ADMISSION_CHALLENGE_TYPE.to_owned(),
319 value: challenge,
320 })
321 }
322
323 #[must_use]
325 pub fn not_found(public: impl Into<Cow<'static, str>>) -> Self {
326 Self::new(Code::NotFound, public)
327 }
328
329 #[must_use]
332 pub fn repository_not_found() -> Self {
333 Self::not_found("repository not found")
334 }
335
336 #[must_use]
338 pub fn failed_precondition(public: impl Into<Cow<'static, str>>) -> Self {
339 Self::new(Code::FailedPrecondition, public)
340 }
341
342 #[must_use]
345 pub fn resource_exhausted(public: impl Into<Cow<'static, str>>) -> Self {
346 Self::new(Code::ResourceExhausted, public)
347 }
348
349 #[must_use]
352 pub fn aborted_retryable(public: impl Into<Cow<'static, str>>) -> Self {
353 Self::new(Code::Aborted, public)
354 }
355
356 #[must_use]
358 pub fn unavailable(public: impl Into<Cow<'static, str>>) -> Self {
359 Self::new(Code::Unavailable, public)
360 }
361
362 #[must_use]
364 pub fn unimplemented(public: impl Into<Cow<'static, str>>) -> Self {
365 Self::new(Code::Unimplemented, public)
366 }
367
368 #[must_use]
371 pub fn internal(public: &'static str, detail: impl fmt::Display) -> Self {
372 Self {
373 detail: Some(Redacted(detail.to_string())),
374 ..Self::new(Code::Internal, public)
375 }
376 }
377
378 #[must_use]
383 pub fn with_http_status(mut self, status: u16) -> Self {
384 let allowed = status_allowed(self.code, status);
385 debug_assert!(
386 allowed,
387 "HTTP status {status} is not an error status, or is 402 on {:?}",
388 self.code
389 );
390 if allowed {
391 self.http_status = Some(status);
392 }
393 self
394 }
395
396 pub fn try_with_header(
404 mut self,
405 name: impl Into<String>,
406 value: impl Into<String>,
407 ) -> Result<Self, InvalidHeader> {
408 let (name, value) = (name.into(), value.into());
409 check_header(&name, &value)?;
410 self.headers.push((name, value));
411 Ok(self)
412 }
413
414 #[must_use]
421 pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
422 let (name, value) = (name.into(), value.into());
423 match check_header(&name, &value) {
424 Ok(()) => self.headers.push((name, value)),
425 Err(reason) => {
426 tracing::warn!(header = ?name, %reason, "dropped an error response header");
428 debug_assert!(
429 reason != InvalidHeader::Reserved,
430 "error response header {name:?} is reserved"
431 );
432 }
433 }
434 self
435 }
436
437 #[must_use]
440 pub fn with_detail(mut self, detail: ErrorDetail) -> Self {
441 debug_assert!(
442 detail.type_name != ADMISSION_CHALLENGE_TYPE
443 || !self
444 .details
445 .iter()
446 .any(|d| d.type_name == ADMISSION_CHALLENGE_TYPE),
447 "an error carries exactly one AdmissionChallenge detail"
448 );
449 self.details.push(detail);
450 self
451 }
452
453 #[must_use]
455 pub fn code(&self) -> Code {
456 self.code
457 }
458
459 #[must_use]
461 pub fn public_message(&self) -> &str {
462 &self.public
463 }
464
465 #[must_use]
467 pub fn log_detail(&self) -> Option<&str> {
468 self.detail.as_ref().map(Redacted::expose)
469 }
470
471 #[must_use]
473 pub fn http_status(&self) -> Option<u16> {
474 self.http_status
475 }
476
477 #[must_use]
480 pub fn headers(&self) -> &[(String, String)] {
481 &self.headers
482 }
483
484 #[must_use]
486 pub fn details(&self) -> &[ErrorDetail] {
487 &self.details
488 }
489
490 #[must_use]
492 pub(crate) fn strip_admission_shape(mut self) -> Self {
493 self.details
494 .retain(|detail| detail.type_name != ADMISSION_CHALLENGE_TYPE);
495 if self.http_status == Some(402) {
496 self.http_status = Some(403);
497 self.headers.clear();
498 }
499 self.headers.retain(|(name, _)| {
500 !crate::pipeline::ADMISSION_EXPOSE_HEADERS
501 .iter()
502 .any(|blocked| blocked.eq_ignore_ascii_case(name))
503 });
504 self
505 }
506}
507
508fn status_allowed(code: Code, status: u16) -> bool {
512 (400..=599).contains(&status) && (status != 402 || code == Code::PermissionDenied)
513}
514
515fn check_header(name: &str, value: &str) -> Result<(), InvalidHeader> {
519 let token_char = |b: u8| b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b);
520 if name.is_empty() || !name.bytes().all(token_char) {
521 return Err(InvalidHeader::Name);
522 }
523 let reserved_prefix = RESERVED_RESPONSE_PREFIXES.iter().any(|prefix| {
524 name.len() >= prefix.len()
525 && name.as_bytes()[..prefix.len()].eq_ignore_ascii_case(prefix.as_bytes())
526 });
527 if is_never_echo(name)
528 || reserved_prefix
529 || RESERVED_RESPONSE_HEADERS
530 .iter()
531 .any(|reserved| reserved.eq_ignore_ascii_case(name))
532 {
533 return Err(InvalidHeader::Reserved);
534 }
535 if !value.bytes().all(|b| b == b'\t' || !b.is_ascii_control()) {
536 return Err(InvalidHeader::Value);
537 }
538 Ok(())
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544
545 const SECRET: &str = "r2 put failed: bucket=prod-packs key=packs/abc token=s3cr3t";
546
547 #[test]
548 fn internal_error_never_displays_detail() {
549 let e = ServerError::internal("storage failure", SECRET);
550 assert_eq!(e.code(), Code::Internal);
551 assert_eq!(e.public_message(), "storage failure");
552 assert_eq!(e.log_detail(), Some(SECRET));
553 let display = format!("{e}");
554 let debug = format!("{e:?}");
555 let alternate = format!("{e:#?}");
556 for rendered in [&display, &debug, &alternate] {
557 assert!(!rendered.contains("s3cr3t"), "{rendered}");
558 assert!(!rendered.contains("prod-packs"), "{rendered}");
559 }
560 assert_eq!(display, "storage failure");
561 }
562
563 #[test]
564 fn redacted_prints_only_its_length() {
565 let r = Redacted::new("hunter2");
566 assert_eq!(format!("{r:?}"), "Redacted(7 bytes)");
567 assert!(!format!("{r}").contains("hunter2"));
568 assert_eq!(r.expose(), "hunter2");
569 }
570
571 #[test]
572 fn constructors_set_code_and_message() {
573 let cases = [
574 (ServerError::invalid_argument("a"), Code::InvalidArgument),
575 (ServerError::unauthenticated("a"), Code::Unauthenticated),
576 (ServerError::permission_denied("a"), Code::PermissionDenied),
577 (ServerError::not_found("a"), Code::NotFound),
578 (
579 ServerError::failed_precondition("a"),
580 Code::FailedPrecondition,
581 ),
582 (
583 ServerError::resource_exhausted("a"),
584 Code::ResourceExhausted,
585 ),
586 (ServerError::aborted_retryable("a"), Code::Aborted),
587 (ServerError::unavailable("a"), Code::Unavailable),
588 (ServerError::unimplemented("a"), Code::Unimplemented),
589 (
590 ServerError::new(Code::DeadlineExceeded, String::from("a")),
591 Code::DeadlineExceeded,
592 ),
593 ];
594 for (e, code) in cases {
595 assert_eq!(e.code(), code);
596 assert_eq!(e.public_message(), "a");
597 assert_eq!(e.log_detail(), None);
598 assert_eq!(e.http_status(), None);
599 assert!(e.headers().is_empty() && e.details().is_empty());
600 }
601 }
602
603 const ALL_CODES: [(Code, &str); 16] = [
604 (Code::Canceled, "canceled"),
605 (Code::Unknown, "unknown"),
606 (Code::InvalidArgument, "invalid_argument"),
607 (Code::DeadlineExceeded, "deadline_exceeded"),
608 (Code::NotFound, "not_found"),
609 (Code::AlreadyExists, "already_exists"),
610 (Code::PermissionDenied, "permission_denied"),
611 (Code::ResourceExhausted, "resource_exhausted"),
612 (Code::FailedPrecondition, "failed_precondition"),
613 (Code::Aborted, "aborted"),
614 (Code::OutOfRange, "out_of_range"),
615 (Code::Unimplemented, "unimplemented"),
616 (Code::Internal, "internal"),
617 (Code::Unavailable, "unavailable"),
618 (Code::DataLoss, "data_loss"),
619 (Code::Unauthenticated, "unauthenticated"),
620 ];
621
622 #[test]
623 fn code_names_match_connect() {
624 for (code, name) in ALL_CODES {
625 assert_eq!(code.as_str(), name);
626 }
627 }
628
629 #[test]
630 fn only_unavailable_exhausted_and_aborted_are_retryable() {
631 let retryable: Vec<_> = ALL_CODES
632 .iter()
633 .filter(|(c, _)| c.is_retryable())
634 .map(|(c, _)| *c)
635 .collect();
636 assert_eq!(
637 retryable,
638 [Code::ResourceExhausted, Code::Aborted, Code::Unavailable]
639 );
640 }
641
642 #[test]
643 fn admission_challenge_is_permission_denied_402_with_one_detail() {
644 let challenge = bytes::Bytes::from_static(b"\x0a\x03abc");
645 let e = ServerError::admission_challenge(challenge.clone())
646 .with_header("WWW-Authenticate", "Payment x");
647 assert_eq!(e.code(), Code::PermissionDenied);
648 assert!(!e.code().is_retryable());
649 assert_eq!(e.http_status(), Some(402));
650 assert_eq!(
651 e.details(),
652 &[ErrorDetail {
653 type_name: ADMISSION_CHALLENGE_TYPE.to_owned(),
654 value: challenge,
655 }]
656 );
657 assert_eq!(
658 e.headers(),
659 &[
660 ("Cache-Control".to_owned(), "no-store".to_owned()),
661 ("WWW-Authenticate".to_owned(), "Payment x".to_owned())
662 ]
663 );
664 assert_eq!(format!("{e}"), "admission required");
665 }
666
667 #[test]
668 fn response_shaping_roundtrips() {
669 let detail = ErrorDetail {
670 type_name: "mkit.transport.v1.PendingVerification".into(),
671 value: bytes::Bytes::from_static(b"\x08\x05"),
672 };
673 let e = ServerError::unavailable("pending verification")
674 .with_http_status(503)
675 .with_header("Retry-After", "5")
676 .with_detail(detail.clone());
677 assert_eq!(e.http_status(), Some(503));
678 assert_eq!(e.headers(), &[("Retry-After".to_owned(), "5".to_owned())]);
679 assert_eq!(e.details(), std::slice::from_ref(&detail));
680 }
681
682 #[test]
683 fn status_rule_accepts_only_error_statuses_and_402_on_permission_denied() {
684 for status in [0, 99, 100, 200, 302, 399, 600, u16::MAX] {
685 assert!(!status_allowed(Code::Unavailable, status), "{status}");
686 }
687 for status in [400, 403, 429, 499, 500, 503, 599] {
688 assert!(status_allowed(Code::Unavailable, status), "{status}");
689 }
690 assert!(status_allowed(Code::PermissionDenied, 402));
691 assert!(!status_allowed(Code::ResourceExhausted, 402));
692 assert!(!status_allowed(Code::Unavailable, 402));
693 }
694
695 #[test]
696 fn receipts_pass_through_but_debug_redacts_them() {
697 let e = ServerError::admission_challenge(bytes::Bytes::new())
698 .with_header("Payment-Receipt", "rcpt-s3cr3t")
699 .with_header("PAYMENT-RESPONSE", "resp-s3cr3t")
700 .with_header("WWW-Authenticate", "Payment realm=x");
701 assert_eq!(e.headers().len(), 4);
702 let debug = format!("{e:?}");
703 assert!(!debug.contains("s3cr3t"), "{debug}");
704 assert!(debug.contains("Payment-Receipt"), "{debug}");
705 assert!(debug.contains("Payment realm=x"), "{debug}");
706 }
707
708 #[test]
709 fn try_with_header_reports_why() {
710 let base = || ServerError::unavailable("down");
711 for (name, value, why) in [
712 ("", "v", InvalidHeader::Name),
713 ("Bad Name", "v", InvalidHeader::Name),
714 ("X-Colon:", "v", InvalidHeader::Name),
715 ("X-Split\r\nSet-Cookie", "v", InvalidHeader::Name),
716 ("Authorization", "Bearer x", InvalidHeader::Reserved),
717 ("PROXY-AUTHORIZATION", "x", InvalidHeader::Reserved),
718 ("cookie", "a=b", InvalidHeader::Reserved),
719 ("Payment-Authorization", "x", InvalidHeader::Reserved),
720 ("payment-signature", "x", InvalidHeader::Reserved),
721 ("Set-Cookie", "a=b", InvalidHeader::Reserved),
722 ("Content-Type", "text/html", InvalidHeader::Reserved),
723 ("content-length", "0", InvalidHeader::Reserved),
724 ("Connect-Protocol-Version", "1", InvalidHeader::Reserved),
725 ("Transfer-Encoding", "chunked", InvalidHeader::Reserved),
726 ("Connection", "close", InvalidHeader::Reserved),
727 ("Host", "evil", InvalidHeader::Reserved),
728 ("Keep-Alive", "x", InvalidHeader::Reserved),
729 ("TE", "trailers", InvalidHeader::Reserved),
730 ("Trailer", "x", InvalidHeader::Reserved),
731 ("Upgrade", "h2c", InvalidHeader::Reserved),
732 ("X-Ok", "line\r\nSet-Cookie: a=b", InvalidHeader::Value),
733 ("X-Ok", "nul\0byte", InvalidHeader::Value),
734 ("X-Ok", "del\u{7f}", InvalidHeader::Value),
735 ] {
736 assert_eq!(
737 base().try_with_header(name, value).unwrap_err(),
738 why,
739 "{name:?}: {value:?}"
740 );
741 }
742 let ok = base()
743 .try_with_header("x-mkit-trace!#$%&'*+.^_`|~", "tab\tand space ok")
744 .unwrap()
745 .try_with_header("Payment-Receipt", "r")
746 .unwrap()
747 .try_with_header("Contentious", "not a Content-* header")
748 .unwrap();
749 assert_eq!(ok.headers().len(), 3);
750 }
751
752 #[test]
753 fn with_header_drops_malformed_input_without_panicking() {
754 let e = ServerError::unavailable("down")
755 .with_header("Bad Name", "v")
756 .with_header("X-Ok", "line\r\nSet-Cookie: a=b")
757 .with_header("Retry-After", "5");
758 assert_eq!(e.headers(), &[("Retry-After".to_owned(), "5".to_owned())]);
759 }
760
761 #[cfg(debug_assertions)]
762 #[test]
763 #[should_panic(expected = "reserved")]
764 fn with_header_reserved_name_fails_debug_assertion() {
765 let _ = ServerError::unavailable("down").with_header("Authorization", "Bearer x");
766 }
767
768 #[test]
769 fn stripping_drops_payment_headers_on_any_error() {
770 let stripped = ServerError::permission_denied("no")
771 .with_header("WWW-Authenticate", "Payment x")
772 .with_header("payment-required", "x")
773 .with_header("Payment-Receipt", "r")
774 .with_header("PAYMENT-RESPONSE", "r")
775 .with_header("Retry-After", "30")
776 .strip_admission_shape();
777 assert_eq!(
778 stripped.headers(),
779 [("Retry-After".to_owned(), "30".to_owned())]
780 );
781 }
782}