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}