1use std::io;
2
3use thiserror::Error;
4
5#[derive(Debug, Error, Clone, PartialEq, Eq)]
7pub enum ValidationError {
8 #[error("invalid name: must be 1..=128 characters")]
10 InvalidName,
11 #[error("invalid path: traversal is not allowed")]
13 PathTraversal,
14 #[error("invalid character in name: use only A-Za-z0-9._/-")]
16 InvalidCharacter,
17}
18
19#[derive(Debug, Error)]
21pub enum GlovesError {
22 #[error("not found")]
24 NotFound,
25 #[error("already exists")]
27 AlreadyExists,
28 #[error("unauthorized")]
30 Unauthorized,
31 #[error("forbidden")]
33 Forbidden,
34 #[error("expired")]
36 Expired,
37 #[error("integrity check failed")]
39 IntegrityViolation,
40 #[error("invalid input: {0}")]
42 InvalidInput(String),
43 #[error("gpg denied")]
45 GpgDenied,
46 #[error(transparent)]
48 Validation(#[from] ValidationError),
49 #[error(transparent)]
51 Io(#[from] io::Error),
52 #[error(transparent)]
54 Serde(#[from] serde_json::Error),
55 #[error(transparent)]
57 Utf8(#[from] std::string::FromUtf8Error),
58 #[error("crypto error: {0}")]
60 Crypto(String),
61}
62
63pub type Result<T> = std::result::Result<T, GlovesError>;
65
66pub const ERROR_CODE_INVALID_INPUT: &str = "E100";
68pub const ERROR_CODE_SECRET_ID: &str = "E101";
70pub const ERROR_CODE_REQUEST_ID: &str = "E102";
72pub const ERROR_CODE_MISSING_RUNTIME: &str = "E103";
74pub const ERROR_CODE_FORBIDDEN: &str = "E200";
76pub const ERROR_CODE_UNAUTHORIZED: &str = "E201";
78pub const ERROR_CODE_NOT_FOUND: &str = "E300";
80pub const ERROR_CODE_ALREADY_EXISTS: &str = "E301";
82pub const ERROR_CODE_EXPIRED: &str = "E302";
84pub const ERROR_CODE_GPG_DENIED: &str = "E400";
86pub const ERROR_CODE_INTEGRITY: &str = "E500";
88pub const ERROR_CODE_IO: &str = "E900";
90pub const ERROR_CODE_INTERNAL: &str = "E999";
92
93const ERROR_EXPLANATION_E100: &str = r#"E100 invalid input
94
95The command arguments are syntactically valid but semantically unsupported.
96
97Common fixes:
98 - Run `gloves help [topic...]` for exact argument usage.
99 - For TTL fields, use a positive day value, `never`, or omit `--ttl` to use the configured default.
100 - For piping policy errors, configure `GLOVES_GET_PIPE_ALLOWLIST` or `.gloves.toml` policy."#;
101const ERROR_EXPLANATION_E101: &str = r#"E101 invalid secret identifier
102
103Secret names must be 1..=128 chars, avoid traversal, and use only:
104 A-Z a-z 0-9 . _ / -
105
106Examples:
107 - Valid: `service/token`
108 - Invalid: `/root/token`, `../token`, `db pass`"#;
109const ERROR_EXPLANATION_E102: &str = r#"E102 invalid request identifier
110
111Request ids must be UUID values from pending requests.
112
113Recovery:
114 gloves requests list
115 gloves requests approve <request-id>
116 gloves requests deny <request-id>
117
118Tip:
119 `requests` is a label, not a request id."#;
120const ERROR_EXPLANATION_E103: &str = r#"E103 missing runtime dependency
121
122The command requires one or more binaries that were not found in PATH.
123
124Recovery:
125 - Install the missing binary (for example `gpg`, `gocryptfs`, `mountpoint`, `fusermount`).
126 - Verify PATH in the current shell/session.
127 - Retry the command."#;
128const ERROR_EXPLANATION_E200: &str = r#"E200 forbidden by policy
129
130The operation is blocked by ACL or configured policy rules.
131
132Recovery:
133 gloves access paths --agent <id> --json
134 gloves help request
135 gloves help requests approve"#;
136const ERROR_EXPLANATION_E201: &str = r#"E201 unauthorized caller
137
138The current caller identity is not allowed for the requested operation.
139
140Recovery:
141 - Check `--agent` identity.
142 - Confirm request/approval state if using human-gated access."#;
143const ERROR_EXPLANATION_E300: &str = r#"E300 resource not found
144
145The referenced secret/request/key could not be located.
146
147Recovery:
148 gloves list (check existing secrets)
149 gloves requests list (check pending requests)"#;
150const ERROR_EXPLANATION_E301: &str = r#"E301 resource already exists
151
152The target name is already present and overwrite is not allowed.
153
154Recovery:
155 - Choose a different name.
156 - Or remove existing value with `gloves secrets revoke <name>` and retry."#;
157const ERROR_EXPLANATION_E302: &str = r#"E302 expired resource
158
159The secret or request has exceeded its TTL.
160
161Recovery:
162 - Create a new value or request.
163 - Run `gloves verify` to reap and normalize expired state."#;
164const ERROR_EXPLANATION_E400: &str = r#"E400 GPG/pass denied
165
166Human backend access was denied by pass/GPG.
167
168Recovery:
169 - Verify the active GPG session can read the value directly:
170 `pass show <secret-name>`
171 - Check agent-specific key setup:
172 `gloves --agent <id> gpg create`"#;
173const ERROR_EXPLANATION_E500: &str = r#"E500 integrity verification failed
174
175Stored ciphertext and metadata checksum validation did not match.
176
177Recovery:
178 - Run `gloves verify`.
179 - Rotate the affected secret and investigate storage integrity."#;
180const ERROR_EXPLANATION_E900: &str = r#"E900 I/O failure
181
182Filesystem or stream operations failed.
183
184Recovery:
185 - Verify `--root` exists and is writable.
186 - Check file permissions and available disk space."#;
187const ERROR_EXPLANATION_E999: &str = r#"E999 internal runtime failure
188
189An internal serialization, decoding, or crypto error occurred.
190
191Recovery:
192 - Retry once with the same inputs.
193 - If it persists, collect command, inputs, and stderr for diagnosis."#;
194
195const KNOWN_ERROR_CODES: [&str; 13] = [
196 ERROR_CODE_INVALID_INPUT,
197 ERROR_CODE_SECRET_ID,
198 ERROR_CODE_REQUEST_ID,
199 ERROR_CODE_MISSING_RUNTIME,
200 ERROR_CODE_FORBIDDEN,
201 ERROR_CODE_UNAUTHORIZED,
202 ERROR_CODE_NOT_FOUND,
203 ERROR_CODE_ALREADY_EXISTS,
204 ERROR_CODE_EXPIRED,
205 ERROR_CODE_GPG_DENIED,
206 ERROR_CODE_INTEGRITY,
207 ERROR_CODE_IO,
208 ERROR_CODE_INTERNAL,
209];
210
211pub fn classify_error_code(error: &GlovesError) -> &'static str {
213 match error {
214 GlovesError::Validation(_) => ERROR_CODE_SECRET_ID,
215 GlovesError::InvalidInput(message) => classify_invalid_input_code(message),
216 GlovesError::Forbidden => ERROR_CODE_FORBIDDEN,
217 GlovesError::Unauthorized => ERROR_CODE_UNAUTHORIZED,
218 GlovesError::NotFound => ERROR_CODE_NOT_FOUND,
219 GlovesError::AlreadyExists => ERROR_CODE_ALREADY_EXISTS,
220 GlovesError::Expired => ERROR_CODE_EXPIRED,
221 GlovesError::GpgDenied => ERROR_CODE_GPG_DENIED,
222 GlovesError::IntegrityViolation => ERROR_CODE_INTEGRITY,
223 GlovesError::Io(_) => ERROR_CODE_IO,
224 GlovesError::Serde(_) | GlovesError::Utf8(_) | GlovesError::Crypto(_) => {
225 ERROR_CODE_INTERNAL
226 }
227 }
228}
229
230fn classify_invalid_input_code(message: &str) -> &'static str {
231 let lowered = message.to_ascii_lowercase();
232 if lowered.contains("request id") {
233 return ERROR_CODE_REQUEST_ID;
234 }
235 if lowered.contains("required binary not found")
236 || lowered.contains("missing required binaries")
237 || lowered.contains("vault mode 'required' is set but missing required binaries")
238 {
239 return ERROR_CODE_MISSING_RUNTIME;
240 }
241 if lowered.contains("not found") {
242 return ERROR_CODE_NOT_FOUND;
243 }
244 ERROR_CODE_INVALID_INPUT
245}
246
247pub fn normalize_error_code(raw: &str) -> String {
249 raw.trim().to_ascii_uppercase()
250}
251
252pub fn explain_error_code(raw: &str) -> Option<&'static str> {
254 let normalized = normalize_error_code(raw);
255 match normalized.as_str() {
256 ERROR_CODE_INVALID_INPUT => Some(ERROR_EXPLANATION_E100),
257 ERROR_CODE_SECRET_ID => Some(ERROR_EXPLANATION_E101),
258 ERROR_CODE_REQUEST_ID => Some(ERROR_EXPLANATION_E102),
259 ERROR_CODE_MISSING_RUNTIME => Some(ERROR_EXPLANATION_E103),
260 ERROR_CODE_FORBIDDEN => Some(ERROR_EXPLANATION_E200),
261 ERROR_CODE_UNAUTHORIZED => Some(ERROR_EXPLANATION_E201),
262 ERROR_CODE_NOT_FOUND => Some(ERROR_EXPLANATION_E300),
263 ERROR_CODE_ALREADY_EXISTS => Some(ERROR_EXPLANATION_E301),
264 ERROR_CODE_EXPIRED => Some(ERROR_EXPLANATION_E302),
265 ERROR_CODE_GPG_DENIED => Some(ERROR_EXPLANATION_E400),
266 ERROR_CODE_INTEGRITY => Some(ERROR_EXPLANATION_E500),
267 ERROR_CODE_IO => Some(ERROR_EXPLANATION_E900),
268 ERROR_CODE_INTERNAL => Some(ERROR_EXPLANATION_E999),
269 _ => None,
270 }
271}
272
273pub fn known_error_codes() -> &'static [&'static str] {
275 &KNOWN_ERROR_CODES
276}
277
278#[cfg(test)]
279mod unit_tests {
280 use super::{
281 classify_error_code, explain_error_code, known_error_codes, GlovesError,
282 ERROR_CODE_MISSING_RUNTIME, ERROR_CODE_NOT_FOUND, ERROR_CODE_REQUEST_ID,
283 };
284
285 #[test]
286 fn classify_invalid_request_id_input() {
287 let error = GlovesError::InvalidInput("invalid request id `requests`".to_owned());
288 assert_eq!(classify_error_code(&error), ERROR_CODE_REQUEST_ID);
289 }
290
291 #[test]
292 fn classify_missing_runtime_binary() {
293 let error = GlovesError::InvalidInput("required binary not found: gpg".to_owned());
294 assert_eq!(classify_error_code(&error), ERROR_CODE_MISSING_RUNTIME);
295 }
296
297 #[test]
298 fn classify_not_found_message_from_invalid_input() {
299 let error = GlovesError::InvalidInput("secret `missing` was not found".to_owned());
300 assert_eq!(classify_error_code(&error), ERROR_CODE_NOT_FOUND);
301 }
302
303 #[test]
304 fn explain_request_id_code_contains_recovery_steps() {
305 let explanation = explain_error_code("e102").unwrap();
306 assert!(explanation.contains("gloves requests list"));
307 assert!(explanation.contains("gloves requests approve <request-id>"));
308 }
309
310 #[test]
311 fn explain_unknown_code_returns_none() {
312 assert!(explain_error_code("e000").is_none());
313 }
314
315 #[test]
316 fn known_codes_include_request_id_code() {
317 assert!(known_error_codes().contains(&ERROR_CODE_REQUEST_ID));
318 }
319}