Skip to main content

secretsh/
error.rs

1/// All error types for the secretsh project.
2///
3/// The top-level [`SecretshError`] enum encompasses every failure category the
4/// binary can encounter.  Each variant wraps a domain-specific sub-error type
5/// so that call-sites can match on fine-grained conditions without losing the
6/// ability to propagate a single unified error type up the call stack.
7///
8/// Exit-code semantics follow GNU coreutils conventions (`timeout`, `env`):
9///
10/// | Code | Meaning                                                  |
11/// |------|----------------------------------------------------------|
12/// | 0    | Success                                                  |
13/// | 1–123| Child process exit code (passed through)                 |
14/// | 124  | Timeout or output-size limit exceeded (child was killed) |
15/// | 125  | secretsh internal error (placeholder / tokenization / spawn) |
16/// | 126  | Command found but not executable                         |
17/// | 127  | Command not found                                        |
18/// | 128+N| Child killed by signal N                                 |
19use thiserror::Error;
20
21// ─────────────────────────────────────────────────────────────────────────────
22// Tokenization
23// ─────────────────────────────────────────────────────────────────────────────
24
25/// Errors produced by the command-string tokenizer.
26///
27/// The tokenizer implements a strict subset of POSIX shell quoting rules and
28/// rejects any shell metacharacter that could allow shell-injection or
29/// unintended expansion.
30#[derive(Debug, Error)]
31pub enum TokenizationError {
32    /// An unquoted shell metacharacter was found in the command string.
33    ///
34    /// The `character` field holds the offending character (e.g. `|`, `>`,
35    /// `&`, `;`, `*`, `?`, `[`, `$`, `` ` ``).
36    #[error(
37        "rejected shell metacharacter {character:?} at byte offset {offset} \
38         — wrap it in quotes if it is intended to be literal"
39    )]
40    RejectedMetacharacter { character: char, offset: usize },
41
42    /// A placeholder was opened with `{{` but never closed with `}}`.
43    ///
44    /// The `fragment` field contains the partial placeholder text seen so far.
45    #[error("malformed placeholder: {fragment:?} — missing closing '}}'")]
46    MalformedPlaceholder { fragment: String },
47
48    /// A placeholder's key name does not match `[A-Za-z_][A-Za-z0-9_]*`.
49    ///
50    /// Key names must start with an ASCII letter or underscore and contain
51    /// only ASCII alphanumerics and underscores.  The `fragment` field
52    /// contains the full `{{…}}` text as it appeared in the command string.
53    #[error(
54        "invalid placeholder key name in {fragment:?} — key names must match \
55         [A-Za-z_][A-Za-z0-9_]* (start with a letter or underscore, \
56         contain only letters, digits, and underscores)"
57    )]
58    InvalidKeyName { fragment: String },
59
60    /// A single-quoted string was opened but the closing `'` was never found.
61    #[error("unclosed single-quoted string starting at byte offset {offset}")]
62    UnclosedSingleQuote { offset: usize },
63
64    /// A double-quoted string was opened but the closing `"` was never found.
65    #[error("unclosed double-quoted string starting at byte offset {offset}")]
66    UnclosedDoubleQuote { offset: usize },
67
68    /// A backslash appeared at the very end of the input with no following
69    /// character to escape.
70    #[error("trailing backslash at end of command string — nothing to escape")]
71    TrailingBackslash,
72
73    /// The command string was empty or contained only whitespace.
74    #[error("command string is empty — nothing to execute")]
75    EmptyCommand,
76}
77
78// ─────────────────────────────────────────────────────────────────────────────
79// Placeholder
80// ─────────────────────────────────────────────────────────────────────────────
81
82/// Errors produced during placeholder resolution.
83///
84/// A placeholder is a `{{KEY_NAME}}` token embedded in the command string.
85/// Resolution fails when the env file does not contain an entry for the requested
86/// key.
87#[derive(Debug, Error)]
88pub enum PlaceholderError {
89    /// The env file contains no entry for the requested key.
90    ///
91    /// `available_keys` lists every key that *was* loaded from the env file so
92    /// the caller (or an AI agent) can see what is actually available.
93    ///
94    /// The command is **not** executed when this error occurs.
95    #[error("{}", UnresolvedKeyDisplay { key, available_keys })]
96    UnresolvedKey {
97        key: String,
98        available_keys: Vec<String>,
99    },
100}
101
102/// Helper that formats the `UnresolvedKey` error message, including the sorted
103/// list of available keys.
104struct UnresolvedKeyDisplay<'a> {
105    key: &'a str,
106    available_keys: &'a [String],
107}
108
109impl std::fmt::Display for UnresolvedKeyDisplay<'_> {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        write!(f, "\"{}\" not found in env file", self.key)?;
112        if self.available_keys.is_empty() {
113            write!(f, "; env file has no keys")?;
114        } else {
115            let mut sorted = self.available_keys.to_vec();
116            sorted.sort_unstable();
117            write!(f, "; available keys: [{}]", sorted.join(", "))?;
118        }
119        Ok(())
120    }
121}
122
123// ─────────────────────────────────────────────────────────────────────────────
124// Spawn
125// ─────────────────────────────────────────────────────────────────────────────
126
127/// Errors produced when spawning the child process.
128#[derive(Debug, Error)]
129pub enum SpawnError {
130    /// The command binary could not be found on `PATH` or at the given path.
131    ///
132    /// Maps to exit code 127 (GNU convention).
133    #[error("command not found: {command:?} — verify the binary exists and is on PATH")]
134    NotFound { command: String },
135
136    /// The command binary exists but the current user does not have execute
137    /// permission, or the file is not a valid executable (e.g. a directory or
138    /// a plain text file without a shebang).
139    ///
140    /// Maps to exit code 126 (GNU convention).
141    #[error(
142        "command not executable: {command:?} — check file permissions and \
143         ensure the file is a valid executable"
144    )]
145    NotExecutable { command: String },
146
147    /// The resolved argv[0] is a known shell interpreter and `--no-shell` was
148    /// set.  Shell delegation is blocked to prevent oracle attacks where an AI
149    /// agent constructs `sh -c '[ "{{KEY}}" = guess ]'` probes to infer secret
150    /// values through conditional output.
151    ///
152    /// Maps to exit code 125 (internal secretsh error).
153    #[error(
154        "shell delegation blocked: {shell:?} is a shell interpreter — \
155         remove --no-shell if you genuinely need shell features"
156    )]
157    ShellDelegationBlocked { shell: String },
158
159    /// The underlying `fork(2)` / `posix_spawnp(3)` / `execvp(2)` syscall
160    /// failed for a reason other than "not found" or "not executable".
161    #[error("failed to spawn {command:?}: {reason}")]
162    ForkExecFailed { command: String, reason: String },
163
164    /// The child process was killed because it exceeded the execution timeout.
165    ///
166    /// Maps to exit code 124 (GNU `timeout` convention).
167    #[error(
168        "child process {pid} exceeded the {timeout_secs}s execution timeout \
169         and was killed"
170    )]
171    Timeout { pid: u32, timeout_secs: u64 },
172
173    /// The child process was killed because its stdout or stderr output
174    /// exceeded the configured size limit.
175    ///
176    /// Maps to exit code 124.
177    #[error(
178        "child process {pid} exceeded the output size limit \
179         ({limit_bytes} bytes) and was killed"
180    )]
181    OutputLimitExceeded { pid: u32, limit_bytes: u64 },
182}
183
184// ─────────────────────────────────────────────────────────────────────────────
185// Redaction
186// ─────────────────────────────────────────────────────────────────────────────
187
188/// Errors produced while building the Aho-Corasick redaction automaton.
189#[derive(Debug, Error)]
190pub enum RedactionError {
191    /// The Aho-Corasick automaton could not be constructed from the provided
192    /// patterns.
193    ///
194    /// This is an internal error — it should not occur under normal operation
195    /// because the patterns are raw byte sequences derived from .env values.
196    #[error("failed to build redaction pattern automaton: {reason}")]
197    PatternBuildFailed { reason: String },
198}
199
200// ─────────────────────────────────────────────────────────────────────────────
201// I/O
202// ─────────────────────────────────────────────────────────────────────────────
203
204/// A thin wrapper around [`std::io::Error`] for I/O failures that do not fit
205/// into a more specific category (e.g. reading the .env file).
206#[derive(Debug, Error)]
207#[error("I/O error: {0}")]
208pub struct IoError(#[from] pub std::io::Error);
209
210// ─────────────────────────────────────────────────────────────────────────────
211// Top-level error
212// ─────────────────────────────────────────────────────────────────────────────
213
214/// The unified error type for the entire secretsh binary.
215///
216/// Every public API surface returns `Result<T, SecretshError>`.  The
217/// [`SecretshError::exit_code`] method maps each variant to the appropriate
218/// process exit code following GNU coreutils conventions.
219#[derive(Debug, Error)]
220pub enum SecretshError {
221    /// A tokenization failure — the command string was rejected before any
222    /// env file access or process spawning occurred.
223    #[error("tokenization error: {0}")]
224    Tokenization(#[from] TokenizationError),
225
226    /// A placeholder could not be resolved against the env file.
227    #[error("placeholder error: {0}")]
228    Placeholder(#[from] PlaceholderError),
229
230    /// The child process could not be spawned, or was killed by a resource
231    /// limit.
232    #[error("spawn error: {0}")]
233    Spawn(#[from] SpawnError),
234
235    /// The Aho-Corasick redaction automaton could not be built.
236    #[error("redaction error: {0}")]
237    Redaction(#[from] RedactionError),
238
239    /// A CLI usage or configuration error.
240    #[error("{0}")]
241    Config(String),
242
243    /// An I/O error that does not fit a more specific category.
244    #[error(transparent)]
245    Io(#[from] IoError),
246}
247
248impl SecretshError {
249    /// Returns the process exit code that secretsh should use when this error
250    /// causes the binary to terminate.
251    ///
252    /// The mapping follows GNU coreutils conventions (`timeout`, `env`):
253    ///
254    /// | Code | Condition                                                |
255    /// |------|----------------------------------------------------------|
256    /// | 124  | Timeout or output-size limit exceeded                    |
257    /// | 125  | Internal secretsh error (placeholder, tokenization, spawn failure) |
258    /// | 126  | Command found but not executable                         |
259    /// | 127  | Command not found                                        |
260    pub fn exit_code(&self) -> i32 {
261        match self {
262            // ── Timeout / output-limit ────────────────────────────────────
263            SecretshError::Spawn(SpawnError::Timeout { .. }) => 124,
264            SecretshError::Spawn(SpawnError::OutputLimitExceeded { .. }) => 124,
265
266            // ── Command not found ─────────────────────────────────────────
267            SecretshError::Spawn(SpawnError::NotFound { .. }) => 127,
268
269            // ── Command not executable ────────────────────────────────────
270            SecretshError::Spawn(SpawnError::NotExecutable { .. }) => 126,
271
272            // ── All other spawn failures ──────────────────────────────────
273            SecretshError::Spawn(_) => 125,
274
275            // ── Internal errors ───────────────────────────────────────────
276            SecretshError::Tokenization(_) => 125,
277            SecretshError::Placeholder(_) => 125,
278            SecretshError::Redaction(_) => 125,
279            SecretshError::Config(_) => 125,
280            SecretshError::Io(_) => 125,
281        }
282    }
283}
284
285// ─────────────────────────────────────────────────────────────────────────────
286// Convenience: std::io::Error → SecretshError without going through IoError
287// ─────────────────────────────────────────────────────────────────────────────
288
289impl From<std::io::Error> for SecretshError {
290    fn from(e: std::io::Error) -> Self {
291        SecretshError::Io(IoError(e))
292    }
293}
294
295// ─────────────────────────────────────────────────────────────────────────────
296// Tests
297// ─────────────────────────────────────────────────────────────────────────────
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    // ── exit_code mapping ────────────────────────────────────────────────────
304
305    #[test]
306    fn timeout_maps_to_124() {
307        let err = SecretshError::Spawn(SpawnError::Timeout {
308            pid: 1234,
309            timeout_secs: 300,
310        });
311        assert_eq!(err.exit_code(), 124);
312    }
313
314    #[test]
315    fn output_limit_maps_to_124() {
316        let err = SecretshError::Spawn(SpawnError::OutputLimitExceeded {
317            pid: 5678,
318            limit_bytes: 52_428_800,
319        });
320        assert_eq!(err.exit_code(), 124);
321    }
322
323    #[test]
324    fn not_found_maps_to_127() {
325        let err = SecretshError::Spawn(SpawnError::NotFound {
326            command: "nonexistent-binary".into(),
327        });
328        assert_eq!(err.exit_code(), 127);
329    }
330
331    #[test]
332    fn not_executable_maps_to_126() {
333        let err = SecretshError::Spawn(SpawnError::NotExecutable {
334            command: "/etc/hosts".into(),
335        });
336        assert_eq!(err.exit_code(), 126);
337    }
338
339    #[test]
340    fn fork_exec_failed_maps_to_125() {
341        let err = SecretshError::Spawn(SpawnError::ForkExecFailed {
342            command: "ls".into(),
343            reason: "ENOMEM".into(),
344        });
345        assert_eq!(err.exit_code(), 125);
346    }
347
348    #[test]
349    fn tokenization_maps_to_125() {
350        let err = SecretshError::Tokenization(TokenizationError::EmptyCommand);
351        assert_eq!(err.exit_code(), 125);
352    }
353
354    #[test]
355    fn placeholder_maps_to_125() {
356        let err = SecretshError::Placeholder(PlaceholderError::UnresolvedKey {
357            key: "MY_SECRET".into(),
358            available_keys: vec![],
359        });
360        assert_eq!(err.exit_code(), 125);
361    }
362
363    #[test]
364    fn redaction_maps_to_125() {
365        let err = SecretshError::Redaction(RedactionError::PatternBuildFailed {
366            reason: "too many patterns".into(),
367        });
368        assert_eq!(err.exit_code(), 125);
369    }
370
371    #[test]
372    fn io_error_maps_to_125() {
373        let io = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "EACCES");
374        let err = SecretshError::from(io);
375        assert_eq!(err.exit_code(), 125);
376    }
377
378    // ── From conversions ─────────────────────────────────────────────────────
379
380    #[test]
381    fn from_tokenization_error() {
382        let inner = TokenizationError::TrailingBackslash;
383        let err: SecretshError = inner.into();
384        assert!(matches!(err, SecretshError::Tokenization(_)));
385    }
386
387    #[test]
388    fn from_placeholder_error() {
389        let inner = PlaceholderError::UnresolvedKey {
390            key: "K".into(),
391            available_keys: vec![],
392        };
393        let err: SecretshError = inner.into();
394        assert!(matches!(err, SecretshError::Placeholder(_)));
395    }
396
397    #[test]
398    fn from_spawn_error() {
399        let inner = SpawnError::NotFound {
400            command: "foo".into(),
401        };
402        let err: SecretshError = inner.into();
403        assert!(matches!(err, SecretshError::Spawn(_)));
404    }
405
406    #[test]
407    fn from_redaction_error() {
408        let inner = RedactionError::PatternBuildFailed { reason: "x".into() };
409        let err: SecretshError = inner.into();
410        assert!(matches!(err, SecretshError::Redaction(_)));
411    }
412
413    #[test]
414    fn from_io_error_via_wrapper() {
415        let io = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
416        let wrapper = IoError(io);
417        let err: SecretshError = wrapper.into();
418        assert!(matches!(err, SecretshError::Io(_)));
419    }
420
421    // ── Display messages ─────────────────────────────────────────────────────
422
423    #[test]
424    fn display_rejected_metacharacter() {
425        let err = TokenizationError::RejectedMetacharacter {
426            character: '|',
427            offset: 7,
428        };
429        let msg = err.to_string();
430        assert!(msg.contains('|'), "message should mention the character");
431        assert!(msg.contains("7"), "message should mention the offset");
432    }
433
434    #[test]
435    fn display_malformed_placeholder() {
436        let err = TokenizationError::MalformedPlaceholder {
437            fragment: "{{FOO".into(),
438        };
439        let msg = err.to_string();
440        assert!(msg.contains("{{FOO"));
441        // The #[error] format string uses `}}` to produce a literal `}` in the
442        // rendered message — assert on the single-brace form.
443        assert!(msg.contains('}'));
444    }
445
446    #[test]
447    fn display_invalid_key_name() {
448        let err = TokenizationError::InvalidKeyName {
449            fragment: "{{1FOO}}".into(),
450        };
451        let msg = err.to_string();
452        assert!(
453            msg.contains("{{1FOO}}"),
454            "message should contain the fragment"
455        );
456        assert!(
457            msg.contains("[A-Za-z_]"),
458            "message should describe valid key-name pattern"
459        );
460    }
461
462    #[test]
463    fn display_unresolved_key_contains_key_name() {
464        let err = PlaceholderError::UnresolvedKey {
465            key: "DB_PASS".into(),
466            available_keys: vec!["API_KEY".into(), "DB_USER".into()],
467        };
468        let msg = err.to_string();
469        assert!(msg.contains("DB_PASS"), "should contain the missing key");
470        assert!(msg.contains("API_KEY"), "should list available key API_KEY");
471        assert!(msg.contains("DB_USER"), "should list available key DB_USER");
472    }
473
474    #[test]
475    fn display_unresolved_key_empty_env_file() {
476        let err = PlaceholderError::UnresolvedKey {
477            key: "FOO".into(),
478            available_keys: vec![],
479        };
480        let msg = err.to_string();
481        assert!(msg.contains("FOO"));
482        assert!(msg.contains("no keys"), "should say env file has no keys");
483    }
484
485    #[test]
486    fn display_unresolved_key_available_keys_are_sorted() {
487        let err = PlaceholderError::UnresolvedKey {
488            key: "MISSING".into(),
489            available_keys: vec!["Z_KEY".into(), "A_KEY".into(), "M_KEY".into()],
490        };
491        let msg = err.to_string();
492        let a_pos = msg.find("A_KEY").unwrap();
493        let m_pos = msg.find("M_KEY").unwrap();
494        let z_pos = msg.find("Z_KEY").unwrap();
495        assert!(
496            a_pos < m_pos && m_pos < z_pos,
497            "keys should appear sorted: {msg}"
498        );
499    }
500
501    // ── ShellDelegationBlocked ────────────────────────────────────────────────
502
503    #[test]
504    fn shell_delegation_blocked_display_contains_shell_name() {
505        let err = SpawnError::ShellDelegationBlocked {
506            shell: "bash".into(),
507        };
508        let msg = err.to_string();
509        assert!(
510            msg.contains("bash"),
511            "error message should contain the shell name, got: {msg:?}"
512        );
513        assert!(
514            msg.contains("shell delegation blocked"),
515            "error message should contain the phrase 'shell delegation blocked', got: {msg:?}"
516        );
517    }
518
519    #[test]
520    fn shell_delegation_blocked_exit_code_is_125() {
521        let err: SecretshError =
522            SecretshError::Spawn(SpawnError::ShellDelegationBlocked { shell: "sh".into() });
523        assert_eq!(
524            err.exit_code(),
525            125,
526            "ShellDelegationBlocked should map to exit code 125"
527        );
528    }
529
530    #[test]
531    fn shell_delegation_blocked_display_does_not_contain_secret() {
532        // The shell name in the error comes from the resolved argv[0] basename.
533        // If a secret happened to resolve to a shell name, the error message
534        // must not inadvertently expose it — the basename-only extraction means
535        // only the last path component appears, and the redactor in cli.rs has
536        // already been applied before this error is constructed.
537        // This test verifies the display format is bounded to the basename.
538        let err = SpawnError::ShellDelegationBlocked { shell: "sh".into() };
539        let msg = err.to_string();
540        // The full path "/usr/local/bin/sh" must not appear — only "sh".
541        assert!(
542            !msg.contains("/usr/local/bin"),
543            "error should only contain basename, not full path, got: {msg:?}"
544        );
545    }
546}