Skip to main content

gloves_core/
error.rs

1use std::io;
2
3use thiserror::Error;
4
5/// Validation failures for identifiers.
6#[derive(Debug, Error, Clone, PartialEq, Eq)]
7pub enum ValidationError {
8    /// Secret name is empty or too long.
9    #[error("invalid name: must be 1..=128 characters")]
10    InvalidName,
11    /// Secret name attempts path traversal.
12    #[error("invalid path: traversal is not allowed")]
13    PathTraversal,
14    /// Secret name contains unsupported characters.
15    #[error("invalid character in name: use only A-Za-z0-9._/-")]
16    InvalidCharacter,
17}
18
19/// Top-level application errors.
20#[derive(Debug, Error)]
21pub enum GlovesError {
22    /// Resource was not found.
23    #[error("not found")]
24    NotFound,
25    /// Secret already exists and overwrite is disallowed.
26    #[error("already exists")]
27    AlreadyExists,
28    /// User or agent is unauthorized.
29    #[error("unauthorized")]
30    Unauthorized,
31    /// Operation is forbidden by policy.
32    #[error("forbidden")]
33    Forbidden,
34    /// Secret has expired.
35    #[error("expired")]
36    Expired,
37    /// Secret ciphertext integrity check failed.
38    #[error("integrity check failed")]
39    IntegrityViolation,
40    /// Input was syntactically valid but semantically unsupported.
41    #[error("invalid input: {0}")]
42    InvalidInput(String),
43    /// GPG access denied by pass.
44    #[error("gpg denied")]
45    GpgDenied,
46    /// Validation failure.
47    #[error(transparent)]
48    Validation(#[from] ValidationError),
49    /// I/O error.
50    #[error(transparent)]
51    Io(#[from] io::Error),
52    /// JSON serialization error.
53    #[error(transparent)]
54    Serde(#[from] serde_json::Error),
55    /// UTF-8 conversion error.
56    #[error(transparent)]
57    Utf8(#[from] std::string::FromUtf8Error),
58    /// Cryptography failure.
59    #[error("crypto error: {0}")]
60    Crypto(String),
61}
62
63/// A typed result used across the crate.
64pub type Result<T> = std::result::Result<T, GlovesError>;
65
66/// Generic invalid-input error.
67pub const ERROR_CODE_INVALID_INPUT: &str = "E100";
68/// Secret identifier validation error.
69pub const ERROR_CODE_SECRET_ID: &str = "E101";
70/// Request identifier validation error.
71pub const ERROR_CODE_REQUEST_ID: &str = "E102";
72/// Missing runtime dependency or binary.
73pub const ERROR_CODE_MISSING_RUNTIME: &str = "E103";
74/// Operation is blocked by policy.
75pub const ERROR_CODE_FORBIDDEN: &str = "E200";
76/// Caller identity is not authorized.
77pub const ERROR_CODE_UNAUTHORIZED: &str = "E201";
78/// Resource lookup failed.
79pub const ERROR_CODE_NOT_FOUND: &str = "E300";
80/// Resource already exists.
81pub const ERROR_CODE_ALREADY_EXISTS: &str = "E301";
82/// Resource expired.
83pub const ERROR_CODE_EXPIRED: &str = "E302";
84/// Human backend GPG/pass denied access.
85pub const ERROR_CODE_GPG_DENIED: &str = "E400";
86/// Integrity check failed.
87pub const ERROR_CODE_INTEGRITY: &str = "E500";
88/// Filesystem or stream I/O failed.
89pub const ERROR_CODE_IO: &str = "E900";
90/// Internal serialization/crypto/encoding failure.
91pub 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
211/// Returns the stable error code for a runtime error.
212pub 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
247/// Normalizes a user-provided error code for lookups.
248pub fn normalize_error_code(raw: &str) -> String {
249    raw.trim().to_ascii_uppercase()
250}
251
252/// Returns an explanation block for a known error code.
253pub 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
273/// Stable list of explainable error codes.
274pub 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}