1use axum::{
14 http::{HeaderValue, StatusCode},
15 response::{IntoResponse, Response},
16};
17
18#[derive(Debug, thiserror::Error)]
24pub enum KipukaError {
25 #[error("configuration error: {0}")]
28 Config(String),
29
30 #[error("TLS error: {0}")]
32 Tls(String),
33
34 #[error("database error: {0}")]
36 Db(String),
37
38 #[error("HSM error: {0}")]
40 Hsm(String),
41
42 #[error("authentication error: {0}")]
48 Auth(String),
49
50 #[error("EST error: {0}")]
55 Est(String),
56
57 #[error("CA error: {0}")]
59 Ca(String),
60
61 #[error("audit error: {0}")]
66 Audit(String),
67
68 #[error("I/O error: {0}")]
70 Io(String),
71
72 #[error("not found")]
75 NotFound,
76
77 #[error("method not allowed")]
79 MethodNotAllowed,
80
81 #[error("payload too large")]
83 PayloadTooLarge,
84
85 #[error("unsupported media type")]
89 UnsupportedMediaType,
90
91 #[error("forbidden: {0}")]
97 Forbidden(String),
98
99 #[error("bad request: {0}")]
101 BadRequest(String),
102
103 #[error("service unavailable: {0}")]
105 ServiceUnavailable(String),
106
107 #[error("internal server error: {0}")]
109 Internal(String),
110}
111
112impl From<sqlx::Error> for KipukaError {
113 fn from(e: sqlx::Error) -> Self {
114 KipukaError::Db(e.to_string())
115 }
116}
117
118impl From<std::io::Error> for KipukaError {
119 fn from(e: std::io::Error) -> Self {
120 KipukaError::Io(e.to_string())
121 }
122}
123
124impl From<crate::ca::issue::IssuanceError> for KipukaError {
125 fn from(e: crate::ca::issue::IssuanceError) -> Self {
139 if e.is_client_error() {
140 KipukaError::BadRequest(e.to_string())
141 } else {
142 KipukaError::Ca(e.to_string())
143 }
144 }
145}
146
147impl KipukaError {
148 fn http_status(&self) -> StatusCode {
153 match self {
154 KipukaError::Auth(_) => StatusCode::UNAUTHORIZED,
156 KipukaError::Forbidden(_) => StatusCode::FORBIDDEN,
157 KipukaError::Est(_) => StatusCode::BAD_REQUEST,
158 KipukaError::BadRequest(_) => StatusCode::BAD_REQUEST,
159 KipukaError::NotFound => StatusCode::NOT_FOUND,
160 KipukaError::MethodNotAllowed => StatusCode::METHOD_NOT_ALLOWED,
161 KipukaError::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
162 KipukaError::UnsupportedMediaType => StatusCode::UNSUPPORTED_MEDIA_TYPE,
163 KipukaError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
164
165 KipukaError::Config(_)
167 | KipukaError::Tls(_)
168 | KipukaError::Db(_)
169 | KipukaError::Hsm(_)
170 | KipukaError::Ca(_)
171 | KipukaError::Audit(_)
172 | KipukaError::Io(_)
173 | KipukaError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
174 }
175 }
176
177 fn client_detail(&self) -> String {
182 let status = self.http_status();
183 if status.is_server_error() {
184 "internal server error".to_string()
185 } else {
186 self.to_string()
187 }
188 }
189}
190
191impl IntoResponse for KipukaError {
192 fn into_response(self) -> Response {
193 let status = self.http_status();
194
195 if status.is_server_error() {
197 tracing::error!(error = %self, status = status.as_u16(), "server error");
198 } else {
199 tracing::debug!(error = %self, status = status.as_u16(), "client error");
200 }
201
202 let detail = self.client_detail();
203
204 let mut resp = (status, detail).into_response();
209 resp.headers_mut().insert(
210 axum::http::header::CONTENT_TYPE,
211 HeaderValue::from_static("text/plain; charset=utf-8"),
212 );
213
214 if status == StatusCode::UNAUTHORIZED {
218 resp.headers_mut().insert(
219 axum::http::header::WWW_AUTHENTICATE,
220 HeaderValue::from_static(kipuka_util::WWW_AUTHENTICATE_BASIC),
221 );
222 }
223
224 if status == StatusCode::SERVICE_UNAVAILABLE
226 && let Ok(hv) = HeaderValue::from_str("120")
227 {
228 resp.headers_mut()
229 .insert(axum::http::header::RETRY_AFTER, hv);
230 }
231
232 resp
233 }
234}
235
236pub type Result<T> = std::result::Result<T, KipukaError>;
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242
243 #[test]
244 fn auth_error_returns_401() {
245 let err = KipukaError::Auth("bad certificate".into());
246 assert_eq!(err.http_status(), StatusCode::UNAUTHORIZED);
247 }
248
249 #[test]
250 fn est_error_returns_400() {
251 let err = KipukaError::Est("malformed CSR".into());
252 assert_eq!(err.http_status(), StatusCode::BAD_REQUEST);
253 }
254
255 #[test]
256 fn db_error_returns_500() {
257 let err = KipukaError::Db("connection refused".into());
258 assert_eq!(err.http_status(), StatusCode::INTERNAL_SERVER_ERROR);
259 }
260
261 #[test]
262 fn server_error_hides_detail() {
263 let err = KipukaError::Internal("secret details".into());
264 assert_eq!(err.client_detail(), "internal server error");
265 }
266
267 #[test]
268 fn client_error_exposes_detail() {
269 let err = KipukaError::BadRequest("missing CN".into());
270 assert_eq!(err.client_detail(), "bad request: missing CN");
271 }
272
273 #[test]
274 fn from_sqlx_error() {
275 let sqlx_err = sqlx::Error::RowNotFound;
276 let err = KipukaError::from(sqlx_err);
277 assert!(matches!(err, KipukaError::Db(_)));
278 }
279
280 #[test]
281 fn from_io_error() {
282 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
283 let err = KipukaError::from(io_err);
284 assert!(matches!(err, KipukaError::Io(_)));
285 }
286
287 #[test]
288 fn into_response_unauthorized_has_www_authenticate() {
289 let resp = KipukaError::Auth("bad cert".into()).into_response();
290 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
291 assert!(resp.headers().get("www-authenticate").is_some());
292 }
293
294 #[test]
295 fn into_response_service_unavailable_has_retry_after() {
296 let resp = KipukaError::ServiceUnavailable("HSM offline".into()).into_response();
297 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
298 assert!(resp.headers().get("retry-after").is_some());
299 }
300
301 #[test]
302 fn into_response_content_type_is_text_plain() {
303 let resp = KipukaError::NotFound.into_response();
304 assert_eq!(
305 resp.headers().get("content-type").unwrap(),
306 "text/plain; charset=utf-8"
307 );
308 }
309}