Skip to main content

mkit_server/
error.rs

1//! Transport-neutral server errors.
2//!
3//! A [`ServerError`] carries a [`Code`], a public message that is safe to
4//! send to any client, and optionally a [`Redacted`] detail for server-side
5//! logs only. Bindings map [`Code`] onto Connect or ssh error codes. Backend
6//! failures go through [`ServerError::internal`], which fixes the public
7//! message so SDK or storage text never reaches a client (issue #794).
8
9use std::borrow::Cow;
10use std::fmt;
11
12use crate::telemetry::{REDACTED_VALUE, is_never_echo, is_never_log};
13
14/// Fully qualified proto name of the admission challenge detail
15/// (SPEC-TRANSPORT-CONNECT §5.1).
16pub const ADMISSION_CHALLENGE_TYPE: &str = "mkit.transport.v1.AdmissionChallenge";
17
18/// Error category: the 16 Connect codes, one-to-one.
19///
20/// A missed `NotAfter` commit deadline, a full shard, outbox backpressure
21/// and pending verification are [`Code::Unavailable`], never
22/// [`Code::ResourceExhausted`] (SPEC-TRANSPORT-CONNECT §5): nothing
23/// commits and a retry with the same nonce is safe. Admission challenges
24/// and denials are [`Code::PermissionDenied`], never `ResourceExhausted`,
25/// because clients retry that code on a backoff ladder.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub enum Code {
28    /// The caller canceled the request.
29    Canceled,
30    /// No more specific category applies.
31    Unknown,
32    /// Malformed request: bad ref name, noncanonical field, wrong framing.
33    InvalidArgument,
34    /// The request ran past its deadline.
35    DeadlineExceeded,
36    /// The named ref, pack or repository does not exist (or is private).
37    NotFound,
38    /// The entity the caller tried to create already exists.
39    AlreadyExists,
40    /// Authenticated, but not allowed; also every admission challenge
41    /// (HTTP 402) and denial.
42    PermissionDenied,
43    /// A client-attributable size or rate cap, such as an oversized pack
44    /// or a spent per-signer quota. Never a server-side capacity limit:
45    /// those are [`Code::Unavailable`].
46    ResourceExhausted,
47    /// A compare-and-swap precondition or upload ticket did not hold.
48    FailedPrecondition,
49    /// The same operation is in flight; the client may retry unchanged.
50    Aborted,
51    /// A value outside its valid range.
52    OutOfRange,
53    /// The deployment does not support this procedure.
54    Unimplemented,
55    /// A server-side failure.
56    Internal,
57    /// Temporarily unable to commit: backend outage, missed `NotAfter`
58    /// deadline, full shard, outbox backpressure or pending verification.
59    /// Retrying with the same nonce is safe.
60    Unavailable,
61    /// Unrecoverable data loss or corruption.
62    DataLoss,
63    /// Missing or invalid credentials.
64    Unauthenticated,
65}
66
67impl Code {
68    /// The Connect protocol's canonical name, e.g. `invalid_argument`. Also
69    /// the `code` label value for [`crate::METRIC_REQUESTS`].
70    #[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    /// Whether a client retries this code on its backoff ladder:
93    /// `unavailable`, `resource_exhausted` and `aborted` (SPEC-TRANSPORT §7,
94    /// SPEC-TRANSPORT-CONNECT §5).
95    #[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/// A typed error detail (Connect `ErrorDetail`): the fully qualified proto
105/// message name and its encoded bytes.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct ErrorDetail {
108    /// Fully qualified proto type name, e.g. [`ADMISSION_CHALLENGE_TYPE`].
109    pub type_name: String,
110    /// The encoded message.
111    pub value: bytes::Bytes,
112}
113
114/// A string that never prints its contents through [`fmt::Debug`] or
115/// [`fmt::Display`]. Only [`Redacted::expose`] reads it, and only the
116/// server-side logging sink should call that.
117#[derive(Clone, PartialEq, Eq)]
118pub struct Redacted(String);
119
120impl Redacted {
121    /// Wrap `value`.
122    #[must_use]
123    pub fn new(value: impl Into<String>) -> Self {
124        Self(value.into())
125    }
126
127    /// The wrapped text, for the server-side log sink only.
128    #[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/// Why a response header was refused.
147#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
148pub enum InvalidHeader {
149    /// The name is not an RFC 9110 token.
150    #[error("header name is not an HTTP token")]
151    Name,
152    /// The name is a request credential ([`crate::NEVER_ECHO`]) or a
153    /// framing, hop-by-hop or cookie header the binding owns.
154    #[error("header name is reserved")]
155    Reserved,
156    /// The value holds a control character other than horizontal tab.
157    #[error("header value contains a control character")]
158    Value,
159}
160
161/// Response headers the Connect binding or the HTTP stack owns, compared
162/// ignoring ASCII case: framing, hop-by-hop (RFC 9110 §7.6.1) and cookies.
163const 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
176/// Reserved response-header name prefixes: `Content-*` framing and the
177/// Connect protocol's own `Connect-*` headers.
178const RESERVED_RESPONSE_PREFIXES: &[&str] = &["content-", "connect-"];
179
180/// A transport-neutral server error. `Display` shows the public message
181/// only. `Debug` shows the log detail as [`Redacted`] and replaces the
182/// value of every [`crate::NEVER_LOG`] header with a placeholder; a sink
183/// that knows deployment-configured names logs headers through
184/// [`crate::Redactor`] instead.
185#[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/// Why a reserved write ended without committing, as decided where the error
199/// is produced. Reservation abort reasons come from this marker, never from
200/// message text.
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub(crate) enum AbortCause {
203    /// The grant epoch moved.
204    EpochMismatch,
205    /// Another request with this nonce won the replay guard.
206    ReplayRace,
207    /// The namespace quota window advanced.
208    QuotaWindow,
209    /// Re-planning was exhausted by guard contention (`os`/`oc`, refs, quota).
210    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    /// An error with `code` and a client-safe `public` message.
245    #[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    /// Tag the error with the reservation abort cause.
260    #[must_use]
261    pub(crate) fn with_abort_cause(mut self, cause: AbortCause) -> Self {
262        self.abort = Some(cause);
263        self
264    }
265
266    /// Mark this refusal as an admission requirement that a transport-identity
267    /// (ssh or enc) session cannot satisfy.
268    #[must_use]
269    pub(crate) fn with_transport_admission_required(mut self) -> Self {
270        self.transport_admission = true;
271        self
272    }
273
274    /// Whether a transport-identity (ssh or enc) pipeline refused because
275    /// admission wants a payment or a reservation those transports cannot
276    /// carry: the ssh binding answers "use mkit+https" for it, rather than a
277    /// message that reads as a denied write.
278    #[must_use]
279    pub fn is_transport_admission_required(&self) -> bool {
280        self.transport_admission
281    }
282
283    /// The reservation abort cause, if the producer set one.
284    #[must_use]
285    pub(crate) fn abort_cause(&self) -> Option<AbortCause> {
286        self.abort
287    }
288
289    /// [`Code::InvalidArgument`].
290    #[must_use]
291    pub fn invalid_argument(public: impl Into<Cow<'static, str>>) -> Self {
292        Self::new(Code::InvalidArgument, public)
293    }
294
295    /// [`Code::Unauthenticated`].
296    #[must_use]
297    pub fn unauthenticated(public: impl Into<Cow<'static, str>>) -> Self {
298        Self::new(Code::Unauthenticated, public)
299    }
300
301    /// [`Code::PermissionDenied`], with its default HTTP status (403).
302    #[must_use]
303    pub fn permission_denied(public: impl Into<Cow<'static, str>>) -> Self {
304        Self::new(Code::PermissionDenied, public)
305    }
306
307    /// An admission challenge (SPEC-TRANSPORT-CONNECT §5.1):
308    /// [`Code::PermissionDenied`] with HTTP status 402 and exactly one
309    /// [`ADMISSION_CHALLENGE_TYPE`] detail. `challenge` is the encoded
310    /// `AdmissionChallenge` message. Challenge headers such as
311    /// `WWW-Authenticate` are added with [`Self::with_header`].
312    #[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    /// [`Code::NotFound`].
324    #[must_use]
325    pub fn not_found(public: impl Into<Cow<'static, str>>) -> Self {
326        Self::new(Code::NotFound, public)
327    }
328
329    /// [`Code::NotFound`] for a repository that is missing or unreadable
330    /// without revealing which (SPEC-WRITE-GRANTS §9.3).
331    #[must_use]
332    pub fn repository_not_found() -> Self {
333        Self::not_found("repository not found")
334    }
335
336    /// [`Code::FailedPrecondition`].
337    #[must_use]
338    pub fn failed_precondition(public: impl Into<Cow<'static, str>>) -> Self {
339        Self::new(Code::FailedPrecondition, public)
340    }
341
342    /// [`Code::ResourceExhausted`]: a client-attributable cap only (see
343    /// [`Code`]).
344    #[must_use]
345    pub fn resource_exhausted(public: impl Into<Cow<'static, str>>) -> Self {
346        Self::new(Code::ResourceExhausted, public)
347    }
348
349    /// [`Code::Aborted`]: the same operation is in flight and the client may
350    /// retry it unchanged (PRD §5.4 stage 0, `in_flight` replay records).
351    #[must_use]
352    pub fn aborted_retryable(public: impl Into<Cow<'static, str>>) -> Self {
353        Self::new(Code::Aborted, public)
354    }
355
356    /// [`Code::Unavailable`].
357    #[must_use]
358    pub fn unavailable(public: impl Into<Cow<'static, str>>) -> Self {
359        Self::new(Code::Unavailable, public)
360    }
361
362    /// [`Code::Unimplemented`].
363    #[must_use]
364    pub fn unimplemented(public: impl Into<Cow<'static, str>>) -> Self {
365        Self::new(Code::Unimplemented, public)
366    }
367
368    /// A backend failure: a fixed public message, with `detail` kept only
369    /// for server-side logs (issue #794).
370    #[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    /// Set the HTTP status the Connect binding answers with. Only error
379    /// statuses (`400..=599`) are accepted, and 402 only on
380    /// [`Code::PermissionDenied`] (use [`Self::admission_challenge`]). Any
381    /// other value fails a debug assertion and is ignored.
382    #[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    /// Add a response header from dynamic input, e.g. a value an admission
397    /// helper produced.
398    ///
399    /// # Errors
400    /// [`InvalidHeader`] if the name is not an HTTP token, is reserved
401    /// (a request credential, framing, hop-by-hop or cookie header), or the
402    /// value holds a control character. `self` is dropped on error.
403    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    /// Add a response header, e.g. `WWW-Authenticate`. An invalid or
415    /// reserved header is dropped with a warning, so a credential or a
416    /// framing header can never be echoed. A reserved *name* is a
417    /// programming error and also fails a debug assertion; a malformed
418    /// value never panics. Use [`Self::try_with_header`] to handle the
419    /// failure instead.
420    #[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                // Never log the value: it may be the credential being dropped.
427                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    /// Attach a typed error detail. An error carries at most one
438    /// [`ADMISSION_CHALLENGE_TYPE`] detail (checked in debug builds).
439    #[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    /// The error category.
454    #[must_use]
455    pub fn code(&self) -> Code {
456        self.code
457    }
458
459    /// The client-safe message.
460    #[must_use]
461    pub fn public_message(&self) -> &str {
462        &self.public
463    }
464
465    /// The server-side detail, for the log sink only.
466    #[must_use]
467    pub fn log_detail(&self) -> Option<&str> {
468        self.detail.as_ref().map(Redacted::expose)
469    }
470
471    /// The HTTP status override, if any.
472    #[must_use]
473    pub fn http_status(&self) -> Option<u16> {
474        self.http_status
475    }
476
477    /// Response headers, in insertion order. Values may include receipts
478    /// ([`crate::NEVER_LOG`]); log them through [`crate::Redactor`].
479    #[must_use]
480    pub fn headers(&self) -> &[(String, String)] {
481        &self.headers
482    }
483
484    /// Typed error details, in insertion order.
485    #[must_use]
486    pub fn details(&self) -> &[ErrorDetail] {
487        &self.details
488    }
489
490    /// Remove admission-only response shape from errors produced outside stage 3.
491    #[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
508/// Whether `status` may be set on an error with `code`: an error status
509/// (`400..=599`), and 402 only for [`Code::PermissionDenied`]
510/// (SPEC-TRANSPORT-CONNECT §5).
511fn status_allowed(code: Code, status: u16) -> bool {
512    (400..=599).contains(&status) && (status != 402 || code == Code::PermissionDenied)
513}
514
515/// Whether a response header may be attached to an error: the name is an
516/// RFC 9110 token that is neither a request credential nor reserved, and
517/// the value holds no control character other than horizontal tab.
518fn 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}