Skip to main content

koi_certmesh/
error.rs

1//! Certmesh domain error types.
2
3use 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    /// Exhaustive test: every CertmeshError variant maps to the expected
125    /// ErrorCode and HTTP status. Adding a new variant forces a compile
126    /// error until explicitly mapped.
127    #[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}