use std::borrow::Cow;
use std::fmt;
use crate::telemetry::{REDACTED_VALUE, is_never_echo, is_never_log};
pub const ADMISSION_CHALLENGE_TYPE: &str = "mkit.transport.v1.AdmissionChallenge";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Code {
Canceled,
Unknown,
InvalidArgument,
DeadlineExceeded,
NotFound,
AlreadyExists,
PermissionDenied,
ResourceExhausted,
FailedPrecondition,
Aborted,
OutOfRange,
Unimplemented,
Internal,
Unavailable,
DataLoss,
Unauthenticated,
}
impl Code {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Canceled => "canceled",
Self::Unknown => "unknown",
Self::InvalidArgument => "invalid_argument",
Self::DeadlineExceeded => "deadline_exceeded",
Self::NotFound => "not_found",
Self::AlreadyExists => "already_exists",
Self::PermissionDenied => "permission_denied",
Self::ResourceExhausted => "resource_exhausted",
Self::FailedPrecondition => "failed_precondition",
Self::Aborted => "aborted",
Self::OutOfRange => "out_of_range",
Self::Unimplemented => "unimplemented",
Self::Internal => "internal",
Self::Unavailable => "unavailable",
Self::DataLoss => "data_loss",
Self::Unauthenticated => "unauthenticated",
}
}
#[must_use]
pub const fn is_retryable(self) -> bool {
matches!(
self,
Self::Unavailable | Self::ResourceExhausted | Self::Aborted
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ErrorDetail {
pub type_name: String,
pub value: bytes::Bytes,
}
#[derive(Clone, PartialEq, Eq)]
pub struct Redacted(String);
impl Redacted {
#[must_use]
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
#[must_use]
pub fn expose(&self) -> &str {
&self.0
}
}
impl fmt::Debug for Redacted {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Redacted({} bytes)", self.0.len())
}
}
impl fmt::Display for Redacted {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(REDACTED_VALUE)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum InvalidHeader {
#[error("header name is not an HTTP token")]
Name,
#[error("header name is reserved")]
Reserved,
#[error("header value contains a control character")]
Value,
}
const RESERVED_RESPONSE_HEADERS: &[&str] = &[
"connection",
"host",
"keep-alive",
"proxy-authenticate",
"proxy-connection",
"set-cookie",
"te",
"trailer",
"transfer-encoding",
"upgrade",
];
const RESERVED_RESPONSE_PREFIXES: &[&str] = &["content-", "connect-"];
#[derive(Clone, thiserror::Error)]
#[error("{public}")]
pub struct ServerError {
code: Code,
public: Cow<'static, str>,
detail: Option<Redacted>,
http_status: Option<u16>,
headers: Vec<(String, String)>,
details: Vec<ErrorDetail>,
abort: Option<AbortCause>,
transport_admission: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AbortCause {
EpochMismatch,
ReplayRace,
QuotaWindow,
Contention,
}
impl fmt::Debug for ServerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
struct Headers<'a>(&'a [(String, String)]);
impl fmt::Debug for Headers<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_list()
.entries(self.0.iter().map(|(name, value)| {
let shown = if is_never_log(name) {
REDACTED_VALUE
} else {
value
};
(name, shown)
}))
.finish()
}
}
f.debug_struct("ServerError")
.field("code", &self.code)
.field("public", &self.public)
.field("detail", &self.detail)
.field("http_status", &self.http_status)
.field("headers", &Headers(&self.headers))
.field("details", &self.details)
.field("abort", &self.abort)
.field("transport_admission", &self.transport_admission)
.finish()
}
}
impl ServerError {
#[must_use]
pub fn new(code: Code, public: impl Into<Cow<'static, str>>) -> Self {
Self {
code,
public: public.into(),
detail: None,
http_status: None,
headers: Vec::new(),
details: Vec::new(),
abort: None,
transport_admission: false,
}
}
#[must_use]
pub(crate) fn with_abort_cause(mut self, cause: AbortCause) -> Self {
self.abort = Some(cause);
self
}
#[must_use]
pub(crate) fn with_transport_admission_required(mut self) -> Self {
self.transport_admission = true;
self
}
#[must_use]
pub fn is_transport_admission_required(&self) -> bool {
self.transport_admission
}
#[must_use]
pub(crate) fn abort_cause(&self) -> Option<AbortCause> {
self.abort
}
#[must_use]
pub fn invalid_argument(public: impl Into<Cow<'static, str>>) -> Self {
Self::new(Code::InvalidArgument, public)
}
#[must_use]
pub fn unauthenticated(public: impl Into<Cow<'static, str>>) -> Self {
Self::new(Code::Unauthenticated, public)
}
#[must_use]
pub fn permission_denied(public: impl Into<Cow<'static, str>>) -> Self {
Self::new(Code::PermissionDenied, public)
}
#[must_use]
pub fn admission_challenge(challenge: bytes::Bytes) -> Self {
Self::new(Code::PermissionDenied, "admission required")
.with_http_status(402)
.with_header("Cache-Control", "no-store")
.with_detail(ErrorDetail {
type_name: ADMISSION_CHALLENGE_TYPE.to_owned(),
value: challenge,
})
}
#[must_use]
pub fn not_found(public: impl Into<Cow<'static, str>>) -> Self {
Self::new(Code::NotFound, public)
}
#[must_use]
pub fn repository_not_found() -> Self {
Self::not_found("repository not found")
}
#[must_use]
pub fn failed_precondition(public: impl Into<Cow<'static, str>>) -> Self {
Self::new(Code::FailedPrecondition, public)
}
#[must_use]
pub fn resource_exhausted(public: impl Into<Cow<'static, str>>) -> Self {
Self::new(Code::ResourceExhausted, public)
}
#[must_use]
pub fn aborted_retryable(public: impl Into<Cow<'static, str>>) -> Self {
Self::new(Code::Aborted, public)
}
#[must_use]
pub fn unavailable(public: impl Into<Cow<'static, str>>) -> Self {
Self::new(Code::Unavailable, public)
}
#[must_use]
pub fn unimplemented(public: impl Into<Cow<'static, str>>) -> Self {
Self::new(Code::Unimplemented, public)
}
#[must_use]
pub fn internal(public: &'static str, detail: impl fmt::Display) -> Self {
Self {
detail: Some(Redacted(detail.to_string())),
..Self::new(Code::Internal, public)
}
}
#[must_use]
pub fn with_http_status(mut self, status: u16) -> Self {
let allowed = status_allowed(self.code, status);
debug_assert!(
allowed,
"HTTP status {status} is not an error status, or is 402 on {:?}",
self.code
);
if allowed {
self.http_status = Some(status);
}
self
}
pub fn try_with_header(
mut self,
name: impl Into<String>,
value: impl Into<String>,
) -> Result<Self, InvalidHeader> {
let (name, value) = (name.into(), value.into());
check_header(&name, &value)?;
self.headers.push((name, value));
Ok(self)
}
#[must_use]
pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
let (name, value) = (name.into(), value.into());
match check_header(&name, &value) {
Ok(()) => self.headers.push((name, value)),
Err(reason) => {
tracing::warn!(header = ?name, %reason, "dropped an error response header");
debug_assert!(
reason != InvalidHeader::Reserved,
"error response header {name:?} is reserved"
);
}
}
self
}
#[must_use]
pub fn with_detail(mut self, detail: ErrorDetail) -> Self {
debug_assert!(
detail.type_name != ADMISSION_CHALLENGE_TYPE
|| !self
.details
.iter()
.any(|d| d.type_name == ADMISSION_CHALLENGE_TYPE),
"an error carries exactly one AdmissionChallenge detail"
);
self.details.push(detail);
self
}
#[must_use]
pub fn code(&self) -> Code {
self.code
}
#[must_use]
pub fn public_message(&self) -> &str {
&self.public
}
#[must_use]
pub fn log_detail(&self) -> Option<&str> {
self.detail.as_ref().map(Redacted::expose)
}
#[must_use]
pub fn http_status(&self) -> Option<u16> {
self.http_status
}
#[must_use]
pub fn headers(&self) -> &[(String, String)] {
&self.headers
}
#[must_use]
pub fn details(&self) -> &[ErrorDetail] {
&self.details
}
#[must_use]
pub(crate) fn strip_admission_shape(mut self) -> Self {
self.details
.retain(|detail| detail.type_name != ADMISSION_CHALLENGE_TYPE);
if self.http_status == Some(402) {
self.http_status = Some(403);
self.headers.clear();
}
self.headers.retain(|(name, _)| {
!crate::pipeline::ADMISSION_EXPOSE_HEADERS
.iter()
.any(|blocked| blocked.eq_ignore_ascii_case(name))
});
self
}
}
fn status_allowed(code: Code, status: u16) -> bool {
(400..=599).contains(&status) && (status != 402 || code == Code::PermissionDenied)
}
fn check_header(name: &str, value: &str) -> Result<(), InvalidHeader> {
let token_char = |b: u8| b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b);
if name.is_empty() || !name.bytes().all(token_char) {
return Err(InvalidHeader::Name);
}
let reserved_prefix = RESERVED_RESPONSE_PREFIXES.iter().any(|prefix| {
name.len() >= prefix.len()
&& name.as_bytes()[..prefix.len()].eq_ignore_ascii_case(prefix.as_bytes())
});
if is_never_echo(name)
|| reserved_prefix
|| RESERVED_RESPONSE_HEADERS
.iter()
.any(|reserved| reserved.eq_ignore_ascii_case(name))
{
return Err(InvalidHeader::Reserved);
}
if !value.bytes().all(|b| b == b'\t' || !b.is_ascii_control()) {
return Err(InvalidHeader::Value);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const SECRET: &str = "r2 put failed: bucket=prod-packs key=packs/abc token=s3cr3t";
#[test]
fn internal_error_never_displays_detail() {
let e = ServerError::internal("storage failure", SECRET);
assert_eq!(e.code(), Code::Internal);
assert_eq!(e.public_message(), "storage failure");
assert_eq!(e.log_detail(), Some(SECRET));
let display = format!("{e}");
let debug = format!("{e:?}");
let alternate = format!("{e:#?}");
for rendered in [&display, &debug, &alternate] {
assert!(!rendered.contains("s3cr3t"), "{rendered}");
assert!(!rendered.contains("prod-packs"), "{rendered}");
}
assert_eq!(display, "storage failure");
}
#[test]
fn redacted_prints_only_its_length() {
let r = Redacted::new("hunter2");
assert_eq!(format!("{r:?}"), "Redacted(7 bytes)");
assert!(!format!("{r}").contains("hunter2"));
assert_eq!(r.expose(), "hunter2");
}
#[test]
fn constructors_set_code_and_message() {
let cases = [
(ServerError::invalid_argument("a"), Code::InvalidArgument),
(ServerError::unauthenticated("a"), Code::Unauthenticated),
(ServerError::permission_denied("a"), Code::PermissionDenied),
(ServerError::not_found("a"), Code::NotFound),
(
ServerError::failed_precondition("a"),
Code::FailedPrecondition,
),
(
ServerError::resource_exhausted("a"),
Code::ResourceExhausted,
),
(ServerError::aborted_retryable("a"), Code::Aborted),
(ServerError::unavailable("a"), Code::Unavailable),
(ServerError::unimplemented("a"), Code::Unimplemented),
(
ServerError::new(Code::DeadlineExceeded, String::from("a")),
Code::DeadlineExceeded,
),
];
for (e, code) in cases {
assert_eq!(e.code(), code);
assert_eq!(e.public_message(), "a");
assert_eq!(e.log_detail(), None);
assert_eq!(e.http_status(), None);
assert!(e.headers().is_empty() && e.details().is_empty());
}
}
const ALL_CODES: [(Code, &str); 16] = [
(Code::Canceled, "canceled"),
(Code::Unknown, "unknown"),
(Code::InvalidArgument, "invalid_argument"),
(Code::DeadlineExceeded, "deadline_exceeded"),
(Code::NotFound, "not_found"),
(Code::AlreadyExists, "already_exists"),
(Code::PermissionDenied, "permission_denied"),
(Code::ResourceExhausted, "resource_exhausted"),
(Code::FailedPrecondition, "failed_precondition"),
(Code::Aborted, "aborted"),
(Code::OutOfRange, "out_of_range"),
(Code::Unimplemented, "unimplemented"),
(Code::Internal, "internal"),
(Code::Unavailable, "unavailable"),
(Code::DataLoss, "data_loss"),
(Code::Unauthenticated, "unauthenticated"),
];
#[test]
fn code_names_match_connect() {
for (code, name) in ALL_CODES {
assert_eq!(code.as_str(), name);
}
}
#[test]
fn only_unavailable_exhausted_and_aborted_are_retryable() {
let retryable: Vec<_> = ALL_CODES
.iter()
.filter(|(c, _)| c.is_retryable())
.map(|(c, _)| *c)
.collect();
assert_eq!(
retryable,
[Code::ResourceExhausted, Code::Aborted, Code::Unavailable]
);
}
#[test]
fn admission_challenge_is_permission_denied_402_with_one_detail() {
let challenge = bytes::Bytes::from_static(b"\x0a\x03abc");
let e = ServerError::admission_challenge(challenge.clone())
.with_header("WWW-Authenticate", "Payment x");
assert_eq!(e.code(), Code::PermissionDenied);
assert!(!e.code().is_retryable());
assert_eq!(e.http_status(), Some(402));
assert_eq!(
e.details(),
&[ErrorDetail {
type_name: ADMISSION_CHALLENGE_TYPE.to_owned(),
value: challenge,
}]
);
assert_eq!(
e.headers(),
&[
("Cache-Control".to_owned(), "no-store".to_owned()),
("WWW-Authenticate".to_owned(), "Payment x".to_owned())
]
);
assert_eq!(format!("{e}"), "admission required");
}
#[test]
fn response_shaping_roundtrips() {
let detail = ErrorDetail {
type_name: "mkit.transport.v1.PendingVerification".into(),
value: bytes::Bytes::from_static(b"\x08\x05"),
};
let e = ServerError::unavailable("pending verification")
.with_http_status(503)
.with_header("Retry-After", "5")
.with_detail(detail.clone());
assert_eq!(e.http_status(), Some(503));
assert_eq!(e.headers(), &[("Retry-After".to_owned(), "5".to_owned())]);
assert_eq!(e.details(), std::slice::from_ref(&detail));
}
#[test]
fn status_rule_accepts_only_error_statuses_and_402_on_permission_denied() {
for status in [0, 99, 100, 200, 302, 399, 600, u16::MAX] {
assert!(!status_allowed(Code::Unavailable, status), "{status}");
}
for status in [400, 403, 429, 499, 500, 503, 599] {
assert!(status_allowed(Code::Unavailable, status), "{status}");
}
assert!(status_allowed(Code::PermissionDenied, 402));
assert!(!status_allowed(Code::ResourceExhausted, 402));
assert!(!status_allowed(Code::Unavailable, 402));
}
#[test]
fn receipts_pass_through_but_debug_redacts_them() {
let e = ServerError::admission_challenge(bytes::Bytes::new())
.with_header("Payment-Receipt", "rcpt-s3cr3t")
.with_header("PAYMENT-RESPONSE", "resp-s3cr3t")
.with_header("WWW-Authenticate", "Payment realm=x");
assert_eq!(e.headers().len(), 4);
let debug = format!("{e:?}");
assert!(!debug.contains("s3cr3t"), "{debug}");
assert!(debug.contains("Payment-Receipt"), "{debug}");
assert!(debug.contains("Payment realm=x"), "{debug}");
}
#[test]
fn try_with_header_reports_why() {
let base = || ServerError::unavailable("down");
for (name, value, why) in [
("", "v", InvalidHeader::Name),
("Bad Name", "v", InvalidHeader::Name),
("X-Colon:", "v", InvalidHeader::Name),
("X-Split\r\nSet-Cookie", "v", InvalidHeader::Name),
("Authorization", "Bearer x", InvalidHeader::Reserved),
("PROXY-AUTHORIZATION", "x", InvalidHeader::Reserved),
("cookie", "a=b", InvalidHeader::Reserved),
("Payment-Authorization", "x", InvalidHeader::Reserved),
("payment-signature", "x", InvalidHeader::Reserved),
("Set-Cookie", "a=b", InvalidHeader::Reserved),
("Content-Type", "text/html", InvalidHeader::Reserved),
("content-length", "0", InvalidHeader::Reserved),
("Connect-Protocol-Version", "1", InvalidHeader::Reserved),
("Transfer-Encoding", "chunked", InvalidHeader::Reserved),
("Connection", "close", InvalidHeader::Reserved),
("Host", "evil", InvalidHeader::Reserved),
("Keep-Alive", "x", InvalidHeader::Reserved),
("TE", "trailers", InvalidHeader::Reserved),
("Trailer", "x", InvalidHeader::Reserved),
("Upgrade", "h2c", InvalidHeader::Reserved),
("X-Ok", "line\r\nSet-Cookie: a=b", InvalidHeader::Value),
("X-Ok", "nul\0byte", InvalidHeader::Value),
("X-Ok", "del\u{7f}", InvalidHeader::Value),
] {
assert_eq!(
base().try_with_header(name, value).unwrap_err(),
why,
"{name:?}: {value:?}"
);
}
let ok = base()
.try_with_header("x-mkit-trace!#$%&'*+.^_`|~", "tab\tand space ok")
.unwrap()
.try_with_header("Payment-Receipt", "r")
.unwrap()
.try_with_header("Contentious", "not a Content-* header")
.unwrap();
assert_eq!(ok.headers().len(), 3);
}
#[test]
fn with_header_drops_malformed_input_without_panicking() {
let e = ServerError::unavailable("down")
.with_header("Bad Name", "v")
.with_header("X-Ok", "line\r\nSet-Cookie: a=b")
.with_header("Retry-After", "5");
assert_eq!(e.headers(), &[("Retry-After".to_owned(), "5".to_owned())]);
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "reserved")]
fn with_header_reserved_name_fails_debug_assertion() {
let _ = ServerError::unavailable("down").with_header("Authorization", "Bearer x");
}
#[test]
fn stripping_drops_payment_headers_on_any_error() {
let stripped = ServerError::permission_denied("no")
.with_header("WWW-Authenticate", "Payment x")
.with_header("payment-required", "x")
.with_header("Payment-Receipt", "r")
.with_header("PAYMENT-RESPONSE", "r")
.with_header("Retry-After", "30")
.strip_admission_shape();
assert_eq!(
stripped.headers(),
[("Retry-After".to_owned(), "30".to_owned())]
);
}
}