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("bad request: {0}")]
93 BadRequest(String),
94
95 #[error("service unavailable: {0}")]
97 ServiceUnavailable(String),
98
99 #[error("internal server error: {0}")]
101 Internal(String),
102}
103
104impl From<sqlx::Error> for KipukaError {
105 fn from(e: sqlx::Error) -> Self {
106 KipukaError::Db(e.to_string())
107 }
108}
109
110impl From<std::io::Error> for KipukaError {
111 fn from(e: std::io::Error) -> Self {
112 KipukaError::Io(e.to_string())
113 }
114}
115
116impl KipukaError {
117 fn http_status(&self) -> StatusCode {
122 match self {
123 KipukaError::Auth(_) => StatusCode::UNAUTHORIZED,
125 KipukaError::Est(_) => StatusCode::BAD_REQUEST,
126 KipukaError::BadRequest(_) => StatusCode::BAD_REQUEST,
127 KipukaError::NotFound => StatusCode::NOT_FOUND,
128 KipukaError::MethodNotAllowed => StatusCode::METHOD_NOT_ALLOWED,
129 KipukaError::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
130 KipukaError::UnsupportedMediaType => StatusCode::UNSUPPORTED_MEDIA_TYPE,
131 KipukaError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
132
133 KipukaError::Config(_)
135 | KipukaError::Tls(_)
136 | KipukaError::Db(_)
137 | KipukaError::Hsm(_)
138 | KipukaError::Ca(_)
139 | KipukaError::Audit(_)
140 | KipukaError::Io(_)
141 | KipukaError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
142 }
143 }
144
145 fn client_detail(&self) -> String {
150 let status = self.http_status();
151 if status.is_server_error() {
152 "internal server error".to_string()
153 } else {
154 self.to_string()
155 }
156 }
157}
158
159impl IntoResponse for KipukaError {
160 fn into_response(self) -> Response {
161 let status = self.http_status();
162
163 if status.is_server_error() {
165 tracing::error!(error = %self, status = status.as_u16(), "server error");
166 } else {
167 tracing::debug!(error = %self, status = status.as_u16(), "client error");
168 }
169
170 let detail = self.client_detail();
171
172 let mut resp = (status, detail).into_response();
177 resp.headers_mut().insert(
178 axum::http::header::CONTENT_TYPE,
179 HeaderValue::from_static("text/plain; charset=utf-8"),
180 );
181
182 if status == StatusCode::UNAUTHORIZED {
186 resp.headers_mut().insert(
187 axum::http::header::WWW_AUTHENTICATE,
188 HeaderValue::from_static(kipuka_util::WWW_AUTHENTICATE_BASIC),
189 );
190 }
191
192 if status == StatusCode::SERVICE_UNAVAILABLE
194 && let Ok(hv) = HeaderValue::from_str("120")
195 {
196 resp.headers_mut()
197 .insert(axum::http::header::RETRY_AFTER, hv);
198 }
199
200 resp
201 }
202}
203
204pub type Result<T> = std::result::Result<T, KipukaError>;
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn auth_error_returns_401() {
213 let err = KipukaError::Auth("bad certificate".into());
214 assert_eq!(err.http_status(), StatusCode::UNAUTHORIZED);
215 }
216
217 #[test]
218 fn est_error_returns_400() {
219 let err = KipukaError::Est("malformed CSR".into());
220 assert_eq!(err.http_status(), StatusCode::BAD_REQUEST);
221 }
222
223 #[test]
224 fn db_error_returns_500() {
225 let err = KipukaError::Db("connection refused".into());
226 assert_eq!(err.http_status(), StatusCode::INTERNAL_SERVER_ERROR);
227 }
228
229 #[test]
230 fn server_error_hides_detail() {
231 let err = KipukaError::Internal("secret details".into());
232 assert_eq!(err.client_detail(), "internal server error");
233 }
234
235 #[test]
236 fn client_error_exposes_detail() {
237 let err = KipukaError::BadRequest("missing CN".into());
238 assert_eq!(err.client_detail(), "bad request: missing CN");
239 }
240
241 #[test]
242 fn from_sqlx_error() {
243 let sqlx_err = sqlx::Error::RowNotFound;
244 let err = KipukaError::from(sqlx_err);
245 assert!(matches!(err, KipukaError::Db(_)));
246 }
247
248 #[test]
249 fn from_io_error() {
250 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
251 let err = KipukaError::from(io_err);
252 assert!(matches!(err, KipukaError::Io(_)));
253 }
254
255 #[test]
256 fn into_response_unauthorized_has_www_authenticate() {
257 let resp = KipukaError::Auth("bad cert".into()).into_response();
258 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
259 assert!(resp.headers().get("www-authenticate").is_some());
260 }
261
262 #[test]
263 fn into_response_service_unavailable_has_retry_after() {
264 let resp = KipukaError::ServiceUnavailable("HSM offline".into()).into_response();
265 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
266 assert!(resp.headers().get("retry-after").is_some());
267 }
268
269 #[test]
270 fn into_response_content_type_is_text_plain() {
271 let resp = KipukaError::NotFound.into_response();
272 assert_eq!(
273 resp.headers().get("content-type").unwrap(),
274 "text/plain; charset=utf-8"
275 );
276 }
277}