1use koi_common::error::ErrorCode;
4
5#[derive(Debug, thiserror::Error)]
6pub enum CertmeshError {
7 #[error("CA not initialized - run `koi certmesh create` first")]
8 CaNotInitialized,
9
10 #[error("CA is locked - run `koi certmesh unlock`")]
11 CaLocked,
12
13 #[error("invalid auth credential")]
14 InvalidAuth,
15
16 #[error("forbidden: {0}")]
17 Forbidden(String),
18
19 #[error("invalid payload: {0}")]
20 InvalidPayload(String),
21
22 #[error("conflict: {0}")]
23 Conflict(String),
24
25 #[error("rate limited - try again in {remaining_secs} seconds")]
26 RateLimited { remaining_secs: u64 },
27
28 #[error("enrollment is closed")]
29 EnrollmentClosed,
30
31 #[error("already enrolled: {0}")]
32 AlreadyEnrolled(String),
33
34 #[error("not found: {0}")]
35 NotFound(String),
36
37 #[error("revoked: {0}")]
38 Revoked(String),
39
40 #[error("crypto error: {0}")]
41 Crypto(String),
42
43 #[error("certificate error: {0}")]
44 Certificate(String),
45
46 #[error("io error: {0}")]
47 Io(#[from] std::io::Error),
48
49 #[error("{0}")]
50 Internal(String),
51
52 #[error("invalid backup: {0}")]
53 BackupInvalid(String),
54
55 #[error("promotion failed: {0}")]
56 PromotionFailed(String),
57
58 #[error("renewal failed for {hostname}: {reason}")]
59 RenewalFailed { hostname: String, reason: String },
60
61 #[error("unlock slot not configured: {0}")]
62 NoSlotFound(String),
63
64 #[error("enrollment denied by operator")]
65 ApprovalDenied,
66
67 #[error("enrollment approval timed out")]
68 ApprovalTimeout,
69
70 #[error("enrollment approval unavailable")]
71 ApprovalUnavailable,
72}
73
74impl From<koi_crypto::keys::CryptoError> for CertmeshError {
75 fn from(e: koi_crypto::keys::CryptoError) -> Self {
76 Self::Crypto(e.to_string())
77 }
78}
79
80impl From<koi_crypto::auth::AuthError> for CertmeshError {
81 fn from(e: koi_crypto::auth::AuthError) -> Self {
82 Self::Internal(e.to_string())
83 }
84}
85
86impl From<koi_crypto::vault::VaultError> for CertmeshError {
87 fn from(e: koi_crypto::vault::VaultError) -> Self {
88 Self::Internal(format!("vault error: {e}"))
89 }
90}
91
92impl From<&CertmeshError> for ErrorCode {
93 fn from(e: &CertmeshError) -> Self {
94 match e {
95 CertmeshError::CaNotInitialized => ErrorCode::CaNotInitialized,
96 CertmeshError::CaLocked => ErrorCode::CaLocked,
97 CertmeshError::InvalidAuth => ErrorCode::InvalidAuth,
98 CertmeshError::Forbidden(_) => ErrorCode::ScopeViolation,
99 CertmeshError::InvalidPayload(_) => ErrorCode::InvalidPayload,
100 CertmeshError::Conflict(_) => ErrorCode::Conflict,
101 CertmeshError::RateLimited { .. } => ErrorCode::RateLimited,
102 CertmeshError::EnrollmentClosed => ErrorCode::EnrollmentClosed,
103 CertmeshError::AlreadyEnrolled(_) => ErrorCode::Conflict,
104 CertmeshError::NotFound(_) => ErrorCode::NotFound,
105 CertmeshError::Revoked(_) => ErrorCode::Revoked,
106 CertmeshError::Crypto(_) | CertmeshError::Certificate(_) => ErrorCode::Internal,
107 CertmeshError::NoSlotFound(_) => ErrorCode::InvalidPayload,
108 CertmeshError::Io(_) => ErrorCode::IoError,
109 CertmeshError::Internal(_) => ErrorCode::Internal,
110 CertmeshError::BackupInvalid(_) => ErrorCode::InvalidPayload,
111 CertmeshError::PromotionFailed(_) => ErrorCode::PromotionFailed,
112 CertmeshError::RenewalFailed { .. } => ErrorCode::RenewalFailed,
113 CertmeshError::ApprovalDenied => ErrorCode::ApprovalDenied,
114 CertmeshError::ApprovalTimeout => ErrorCode::ApprovalTimeout,
115 CertmeshError::ApprovalUnavailable => ErrorCode::ApprovalUnavailable,
116 }
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 #[test]
128 fn all_certmesh_error_variants_map_to_expected_error_code_and_http_status() {
129 let cases: Vec<(CertmeshError, ErrorCode, u16)> = vec![
130 (
131 CertmeshError::CaNotInitialized,
132 ErrorCode::CaNotInitialized,
133 503,
134 ),
135 (CertmeshError::CaLocked, ErrorCode::CaLocked, 503),
136 (CertmeshError::InvalidAuth, ErrorCode::InvalidAuth, 401),
137 (
138 CertmeshError::Forbidden("cn not allowed".into()),
139 ErrorCode::ScopeViolation,
140 403,
141 ),
142 (
143 CertmeshError::InvalidPayload("bad entropy".into()),
144 ErrorCode::InvalidPayload,
145 400,
146 ),
147 (
148 CertmeshError::Conflict("already initialized".into()),
149 ErrorCode::Conflict,
150 409,
151 ),
152 (
153 CertmeshError::RateLimited { remaining_secs: 60 },
154 ErrorCode::RateLimited,
155 429,
156 ),
157 (
158 CertmeshError::EnrollmentClosed,
159 ErrorCode::EnrollmentClosed,
160 403,
161 ),
162 (
163 CertmeshError::AlreadyEnrolled("host-01".into()),
164 ErrorCode::Conflict,
165 409,
166 ),
167 (
168 CertmeshError::NotFound("missing".into()),
169 ErrorCode::NotFound,
170 404,
171 ),
172 (
173 CertmeshError::Revoked("node-01".into()),
174 ErrorCode::Revoked,
175 403,
176 ),
177 (
178 CertmeshError::Crypto("bad key".into()),
179 ErrorCode::Internal,
180 500,
181 ),
182 (
183 CertmeshError::Certificate("bad cert".into()),
184 ErrorCode::Internal,
185 500,
186 ),
187 (
188 CertmeshError::Io(std::io::Error::other("test")),
189 ErrorCode::IoError,
190 500,
191 ),
192 (
193 CertmeshError::Internal("unexpected".into()),
194 ErrorCode::Internal,
195 500,
196 ),
197 (
198 CertmeshError::BackupInvalid("bad magic".into()),
199 ErrorCode::InvalidPayload,
200 400,
201 ),
202 (
203 CertmeshError::PromotionFailed("transfer error".into()),
204 ErrorCode::PromotionFailed,
205 500,
206 ),
207 (
208 CertmeshError::RenewalFailed {
209 hostname: "node-05".into(),
210 reason: "cert expired".into(),
211 },
212 ErrorCode::RenewalFailed,
213 500,
214 ),
215 (
216 CertmeshError::NoSlotFound("TOTP".into()),
217 ErrorCode::InvalidPayload,
218 400,
219 ),
220 (
221 CertmeshError::ApprovalDenied,
222 ErrorCode::ApprovalDenied,
223 403,
224 ),
225 (
226 CertmeshError::ApprovalTimeout,
227 ErrorCode::ApprovalTimeout,
228 504,
229 ),
230 (
231 CertmeshError::ApprovalUnavailable,
232 ErrorCode::ApprovalUnavailable,
233 503,
234 ),
235 ];
236 for (error, expected_code, expected_status) in &cases {
237 let code = ErrorCode::from(error);
238 assert_eq!(
239 &code, expected_code,
240 "{error:?} should map to {expected_code:?}"
241 );
242 assert_eq!(
243 code.http_status(),
244 *expected_status,
245 "{error:?} → {expected_code:?} should have HTTP {expected_status}"
246 );
247 }
248 }
249
250 #[test]
251 fn crypto_error_converts_to_certmesh_error() {
252 let crypto_err = koi_crypto::keys::CryptoError::Encryption("test failure".into());
253 let certmesh_err: CertmeshError = crypto_err.into();
254 assert!(matches!(certmesh_err, CertmeshError::Crypto(_)));
255 assert!(certmesh_err.to_string().contains("test failure"));
256 }
257
258 #[test]
259 fn rate_limited_error_includes_remaining_secs_in_message() {
260 let e = CertmeshError::RateLimited { remaining_secs: 42 };
261 assert!(e.to_string().contains("42"));
262 }
263}