Skip to main content

mkit_server/connect/
error.rs

1//! The one `ServerError` → `ConnectError` table (A14). Handlers never build
2//! a `ConnectError` themselves: they return a [`ServerError`] and `?` maps
3//! it here, so only the public message, never a log detail, reaches the
4//! wire (issue #794), and the M3 response shaping (HTTP status, headers such
5//! as `WWW-Authenticate`, typed details such as `AdmissionChallenge`)
6//! reaches it too.
7
8use base64::Engine as _;
9use base64::engine::general_purpose::STANDARD_NO_PAD;
10use connectrpc::{ConnectError, ErrorCode};
11use http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
12
13use crate::error::{Code, ServerError};
14use crate::upload::UploadError;
15
16/// The Connect code for `code`: the same 16 codes, one to one.
17fn error_code(code: Code) -> ErrorCode {
18    match code {
19        Code::Canceled => ErrorCode::Canceled,
20        Code::Unknown => ErrorCode::Unknown,
21        Code::InvalidArgument => ErrorCode::InvalidArgument,
22        Code::DeadlineExceeded => ErrorCode::DeadlineExceeded,
23        Code::NotFound => ErrorCode::NotFound,
24        Code::AlreadyExists => ErrorCode::AlreadyExists,
25        Code::PermissionDenied => ErrorCode::PermissionDenied,
26        Code::ResourceExhausted => ErrorCode::ResourceExhausted,
27        Code::FailedPrecondition => ErrorCode::FailedPrecondition,
28        Code::Aborted => ErrorCode::Aborted,
29        Code::OutOfRange => ErrorCode::OutOfRange,
30        Code::Unimplemented => ErrorCode::Unimplemented,
31        Code::Internal => ErrorCode::Internal,
32        Code::Unavailable => ErrorCode::Unavailable,
33        Code::DataLoss => ErrorCode::DataLoss,
34        Code::Unauthenticated => ErrorCode::Unauthenticated,
35    }
36}
37
38/// A connectrpc error (e.g. a broken request stream) as the [`ServerError`]
39/// a request is recorded with: its code, and its message for the log.
40pub(super) fn recorded(err: &ConnectError) -> ServerError {
41    let code = match err.code {
42        ErrorCode::Canceled => Code::Canceled,
43        ErrorCode::InvalidArgument => Code::InvalidArgument,
44        ErrorCode::DeadlineExceeded => Code::DeadlineExceeded,
45        ErrorCode::NotFound => Code::NotFound,
46        ErrorCode::AlreadyExists => Code::AlreadyExists,
47        ErrorCode::PermissionDenied => Code::PermissionDenied,
48        ErrorCode::ResourceExhausted => Code::ResourceExhausted,
49        ErrorCode::FailedPrecondition => Code::FailedPrecondition,
50        ErrorCode::Aborted => Code::Aborted,
51        ErrorCode::OutOfRange => Code::OutOfRange,
52        ErrorCode::Unimplemented => Code::Unimplemented,
53        ErrorCode::Internal => Code::Internal,
54        ErrorCode::Unavailable => Code::Unavailable,
55        ErrorCode::DataLoss => Code::DataLoss,
56        ErrorCode::Unauthenticated => Code::Unauthenticated,
57        // `ErrorCode` is non-exhaustive.
58        _ => Code::Unknown,
59    };
60    ServerError::new(code, err.message.clone().unwrap_or_default())
61}
62
63/// `err`'s response headers. `ServerError` already refused credentials,
64/// framing headers and control characters; a name or value `http` still
65/// rejects is dropped with a warning, its value never logged.
66fn header_map(err: &ServerError) -> HeaderMap {
67    let mut map = HeaderMap::new();
68    for (name, value) in err.headers() {
69        let encoded = HeaderName::from_bytes(name.as_bytes())
70            .ok()
71            .zip(HeaderValue::from_str(value).ok());
72        if let Some((name, value)) = encoded {
73            map.append(name, value);
74        } else {
75            tracing::warn!(header = ?name, "dropped an unencodable error response header");
76        }
77    }
78    map
79}
80
81impl From<ServerError> for ConnectError {
82    fn from(err: ServerError) -> Self {
83        let mut out = ConnectError::new(error_code(err.code()), err.public_message());
84        if let Some(status) = err.http_status().and_then(|s| StatusCode::from_u16(s).ok()) {
85            out = out.with_http_status(status);
86        }
87        if !err.headers().is_empty() {
88            out = out.with_headers(header_map(&err));
89        }
90        for detail in err.details() {
91            out = out.with_detail(connectrpc::ErrorDetail {
92                type_url: detail.type_name.clone(),
93                value: Some(STANDARD_NO_PAD.encode(&detail.value)),
94                debug: None,
95            });
96        }
97        out
98    }
99}
100
101/// An `UploadPack` framing error on the Connect wire: its code and
102/// `mkit-transport-connect`'s message.
103#[must_use]
104pub fn from_upload_error(err: UploadError) -> ConnectError {
105    ServerError::from(err).into()
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::error::ErrorDetail;
112
113    #[test]
114    fn every_code_keeps_its_name() {
115        let codes = [
116            Code::Canceled,
117            Code::Unknown,
118            Code::InvalidArgument,
119            Code::DeadlineExceeded,
120            Code::NotFound,
121            Code::AlreadyExists,
122            Code::PermissionDenied,
123            Code::ResourceExhausted,
124            Code::FailedPrecondition,
125            Code::Aborted,
126            Code::OutOfRange,
127            Code::Unimplemented,
128            Code::Internal,
129            Code::Unavailable,
130            Code::DataLoss,
131            Code::Unauthenticated,
132        ];
133        for code in codes {
134            assert_eq!(error_code(code).as_str(), code.as_str());
135            let back = recorded(&ConnectError::new(error_code(code), "m"));
136            assert_eq!(back.code(), code);
137        }
138    }
139
140    #[test]
141    fn internal_detail_never_crosses() {
142        let err: ConnectError = ServerError::internal("storage failure", "bucket=secret").into();
143        assert_eq!(err.code, ErrorCode::Internal);
144        assert_eq!(err.message.as_deref(), Some("storage failure"));
145        let json = String::from_utf8(err.to_json().to_vec()).unwrap();
146        assert!(!json.contains("secret"), "{json}");
147        assert!(err.details.is_empty());
148    }
149
150    #[test]
151    fn shaping_crosses() {
152        let err: ConnectError = ServerError::permission_denied("pay")
153            .with_http_status(402)
154            .with_header("WWW-Authenticate", "Payment id=a")
155            .with_header("WWW-Authenticate", "Payment id=b")
156            .with_detail(ErrorDetail {
157                type_name: "mkit.test.Detail".to_owned(),
158                value: bytes::Bytes::from_static(&[1, 2, 3]),
159            })
160            .into();
161        assert_eq!(err.http_status(), StatusCode::PAYMENT_REQUIRED);
162        let values: Vec<_> = err
163            .response_headers()
164            .get_all("www-authenticate")
165            .iter()
166            .collect();
167        assert_eq!(values, ["Payment id=a", "Payment id=b"]);
168        assert_eq!(err.details.len(), 1);
169        assert_eq!(err.details[0].type_url, "mkit.test.Detail");
170        assert_eq!(err.details[0].value.as_deref(), Some("AQID"));
171    }
172
173    #[test]
174    fn upload_errors_keep_the_connect_message() {
175        let err = from_upload_error(UploadError::HeaderMissing {
176            stream_empty: false,
177        });
178        assert_eq!(err.code, ErrorCode::InvalidArgument);
179        assert_eq!(
180            err.message.as_deref(),
181            Some("UploadPack: first message MUST be `header`")
182        );
183        let big = from_upload_error(UploadError::TotalTooLarge { total: 9, cap: 8 });
184        assert_eq!(big.code, ErrorCode::ResourceExhausted);
185    }
186}