1use rustlavel_core::{Error, Json};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum VaultError {
14 BadRequest(String),
16 PermissionDenied(String),
21 NotFound { path: String },
26 Stale(String),
29 Standby(String),
31 Sealed(String),
34 Unexpected { status: u16, message: String },
36 Transport(String),
38 Malformed { path: String, message: String },
46}
47
48impl VaultError {
49 pub fn from_response(status: u16, path: &str, body: &str) -> VaultError {
51 let message = messages(body);
52
53 match status {
54 400 => VaultError::BadRequest(message),
55 401 | 403 => VaultError::PermissionDenied(message),
59 404 => VaultError::NotFound { path: path.to_string() },
60 412 => VaultError::Stale(message),
61 429 => VaultError::Standby(message),
62 501 | 503 => VaultError::Sealed(message),
63 other => VaultError::Unexpected { status: other, message },
64 }
65 }
66
67 pub fn is_retryable(&self) -> bool {
73 matches!(self, VaultError::Stale(_) | VaultError::Standby(_) | VaultError::Transport(_))
74 }
75
76 pub fn needs_new_token(&self) -> bool {
78 matches!(self, VaultError::PermissionDenied(_))
79 }
80
81 pub fn is_not_found(&self) -> bool {
82 matches!(self, VaultError::NotFound { .. })
83 }
84}
85
86fn messages(body: &str) -> String {
92 let parsed = Json::parse(body).ok();
93 let errors = parsed
94 .as_ref()
95 .and_then(|json| json.get("errors"))
96 .and_then(Json::as_array)
97 .map(|items| {
98 items.iter().filter_map(Json::as_str).collect::<Vec<_>>().join("; ")
99 })
100 .filter(|joined| !joined.is_empty());
101
102 match errors {
103 Some(errors) => errors,
104 None => {
105 let trimmed = body.trim();
106 if trimmed.is_empty() {
107 "the server sent no explanation".to_string()
108 } else if trimmed.len() > 300 {
109 format!("{}…", &trimmed[..300])
110 } else {
111 trimmed.to_string()
112 }
113 }
114 }
115}
116
117impl std::fmt::Display for VaultError {
118 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119 match self {
120 VaultError::BadRequest(message) => write!(f, "the secret store rejected the request: {message}"),
121 VaultError::PermissionDenied(message) => write!(
122 f,
123 "the secret store refused: {message}. The token is missing, expired, or has no \
124 policy granting this path — Vault answers the same way for all three."
125 ),
126 VaultError::NotFound { path } => {
127 write!(f, "nothing is stored at `{path}`")
128 }
129 VaultError::Stale(message) => write!(
130 f,
131 "a standby node is behind and cannot serve this yet: {message}"
132 ),
133 VaultError::Standby(message) => {
134 write!(f, "this node is a standby and will not serve the request: {message}")
135 }
136 VaultError::Sealed(message) => write!(
137 f,
138 "the secret store is sealed or unavailable: {message}. It has to be unsealed \
139 before anything here will work; retrying will not help."
140 ),
141 VaultError::Unexpected { status, message } => {
142 write!(f, "the secret store answered {status}: {message}")
143 }
144 VaultError::Transport(message) => {
145 write!(f, "could not reach the secret store: {message}")
146 }
147 VaultError::Malformed { path, message } => {
148 write!(f, "the reply from `{path}` was not what this driver expected: {message}")
149 }
150 }
151 }
152}
153
154impl std::error::Error for VaultError {}
155
156impl From<VaultError> for Error {
157 fn from(error: VaultError) -> Error {
158 Error::msg(error.to_string())
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 #[test]
167 fn maps_each_status_to_what_the_caller_should_do_about_it() {
168 let body = r#"{"errors":["permission denied"]}"#;
169
170 assert!(matches!(
171 VaultError::from_response(403, "secret/data/x", body),
172 VaultError::PermissionDenied(_)
173 ));
174 assert!(matches!(
175 VaultError::from_response(404, "secret/data/x", "{}"),
176 VaultError::NotFound { .. }
177 ));
178 assert!(matches!(
179 VaultError::from_response(503, "sys/health", "{}"),
180 VaultError::Sealed(_)
181 ));
182 assert!(matches!(
183 VaultError::from_response(418, "secret/data/x", "{}"),
184 VaultError::Unexpected { status: 418, .. }
185 ));
186 }
187
188 #[test]
189 fn a_missing_token_and_a_forbidden_one_look_the_same_because_they_are() {
190 assert_eq!(
193 VaultError::from_response(401, "p", r#"{"errors":["x"]}"#),
194 VaultError::from_response(403, "p", r#"{"errors":["x"]}"#),
195 );
196 }
197
198 #[test]
199 fn a_sealed_store_is_not_retried() {
200 assert!(!VaultError::Sealed("sealed".into()).is_retryable());
203 assert!(VaultError::Standby("standby".into()).is_retryable());
204 assert!(VaultError::Stale("behind".into()).is_retryable());
205 assert!(VaultError::Transport("refused".into()).is_retryable());
206 assert!(!VaultError::BadRequest("bad".into()).is_retryable());
207 }
208
209 #[test]
210 fn only_a_refusal_suggests_getting_a_new_token() {
211 assert!(VaultError::PermissionDenied("x".into()).needs_new_token());
212 assert!(!VaultError::NotFound { path: "p".into() }.needs_new_token());
213 }
214
215 #[test]
216 fn reads_the_error_list_vault_actually_sends() {
217 let error = VaultError::from_response(400, "p", r#"{"errors":["one","two"]}"#);
218 assert!(error.to_string().contains("one; two"), "got {error}");
219 }
220
221 #[test]
222 fn a_body_that_is_not_json_is_shown_rather_than_swallowed() {
223 let error = VaultError::from_response(502, "p", "<html>Bad Gateway</html>");
226 assert!(error.to_string().contains("Bad Gateway"), "got {error}");
227 }
228
229 #[test]
230 fn an_empty_body_says_so_instead_of_pretending() {
231 let error = VaultError::from_response(500, "p", "");
232 assert!(error.to_string().contains("no explanation"), "got {error}");
233 }
234
235 #[test]
236 fn a_huge_body_is_truncated_so_it_cannot_flood_a_log() {
237 let error = VaultError::from_response(500, "p", &"x".repeat(5000));
238 assert!(error.to_string().len() < 500, "an error must not be a log flood");
239 }
240
241 #[test]
242 fn a_not_found_names_the_path_because_that_is_the_useful_part() {
243 let error = VaultError::from_response(404, "secret/data/missing", "{}");
244 assert!(error.to_string().contains("secret/data/missing"));
245 }
246
247 #[test]
248 fn an_errors_field_that_is_empty_falls_back_to_the_body() {
249 let error = VaultError::from_response(404, "p", r#"{"errors":[]}"#);
252 assert!(!error.to_string().is_empty());
253 }
254}