use thiserror::Error;
#[derive(Debug, Error)]
pub enum TokenizationError {
#[error(
"rejected shell metacharacter {character:?} at byte offset {offset} \
— wrap it in quotes if it is intended to be literal"
)]
RejectedMetacharacter { character: char, offset: usize },
#[error("malformed placeholder: {fragment:?} — missing closing '}}'")]
MalformedPlaceholder { fragment: String },
#[error(
"invalid placeholder key name in {fragment:?} — key names must match \
[A-Za-z_][A-Za-z0-9_]* (start with a letter or underscore, \
contain only letters, digits, and underscores)"
)]
InvalidKeyName { fragment: String },
#[error("unclosed single-quoted string starting at byte offset {offset}")]
UnclosedSingleQuote { offset: usize },
#[error("unclosed double-quoted string starting at byte offset {offset}")]
UnclosedDoubleQuote { offset: usize },
#[error("trailing backslash at end of command string — nothing to escape")]
TrailingBackslash,
#[error("command string is empty — nothing to execute")]
EmptyCommand,
}
#[derive(Debug, Error)]
pub enum PlaceholderError {
#[error(
"unresolved placeholder {{{key}}} — no entry with that name \
exists in the env file. Use `secretsh --env .env run --` to reference keys from your .env file"
)]
UnresolvedKey { key: String },
}
#[derive(Debug, Error)]
pub enum SpawnError {
#[error("command not found: {command:?} — verify the binary exists and is on PATH")]
NotFound { command: String },
#[error(
"command not executable: {command:?} — check file permissions and \
ensure the file is a valid executable"
)]
NotExecutable { command: String },
#[error(
"shell delegation blocked: {shell:?} is a shell interpreter — \
remove --no-shell if you genuinely need shell features"
)]
ShellDelegationBlocked { shell: String },
#[error("failed to spawn {command:?}: {reason}")]
ForkExecFailed { command: String, reason: String },
#[error(
"child process {pid} exceeded the {timeout_secs}s execution timeout \
and was killed"
)]
Timeout { pid: u32, timeout_secs: u64 },
#[error(
"child process {pid} exceeded the output size limit \
({limit_bytes} bytes) and was killed"
)]
OutputLimitExceeded { pid: u32, limit_bytes: u64 },
}
#[derive(Debug, Error)]
pub enum RedactionError {
#[error("failed to build redaction pattern automaton: {reason}")]
PatternBuildFailed { reason: String },
}
#[derive(Debug, Error)]
#[error("I/O error: {0}")]
pub struct IoError(#[from] pub std::io::Error);
#[derive(Debug, Error)]
pub enum SecretshError {
#[error("tokenization error: {0}")]
Tokenization(#[from] TokenizationError),
#[error("placeholder error: {0}")]
Placeholder(#[from] PlaceholderError),
#[error("spawn error: {0}")]
Spawn(#[from] SpawnError),
#[error("redaction error: {0}")]
Redaction(#[from] RedactionError),
#[error("{0}")]
Config(String),
#[error(transparent)]
Io(#[from] IoError),
}
impl SecretshError {
pub fn exit_code(&self) -> i32 {
match self {
SecretshError::Spawn(SpawnError::Timeout { .. }) => 124,
SecretshError::Spawn(SpawnError::OutputLimitExceeded { .. }) => 124,
SecretshError::Spawn(SpawnError::NotFound { .. }) => 127,
SecretshError::Spawn(SpawnError::NotExecutable { .. }) => 126,
SecretshError::Spawn(_) => 125,
SecretshError::Tokenization(_) => 125,
SecretshError::Placeholder(_) => 125,
SecretshError::Redaction(_) => 125,
SecretshError::Config(_) => 125,
SecretshError::Io(_) => 125,
}
}
}
impl From<std::io::Error> for SecretshError {
fn from(e: std::io::Error) -> Self {
SecretshError::Io(IoError(e))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn timeout_maps_to_124() {
let err = SecretshError::Spawn(SpawnError::Timeout {
pid: 1234,
timeout_secs: 300,
});
assert_eq!(err.exit_code(), 124);
}
#[test]
fn output_limit_maps_to_124() {
let err = SecretshError::Spawn(SpawnError::OutputLimitExceeded {
pid: 5678,
limit_bytes: 52_428_800,
});
assert_eq!(err.exit_code(), 124);
}
#[test]
fn not_found_maps_to_127() {
let err = SecretshError::Spawn(SpawnError::NotFound {
command: "nonexistent-binary".into(),
});
assert_eq!(err.exit_code(), 127);
}
#[test]
fn not_executable_maps_to_126() {
let err = SecretshError::Spawn(SpawnError::NotExecutable {
command: "/etc/hosts".into(),
});
assert_eq!(err.exit_code(), 126);
}
#[test]
fn fork_exec_failed_maps_to_125() {
let err = SecretshError::Spawn(SpawnError::ForkExecFailed {
command: "ls".into(),
reason: "ENOMEM".into(),
});
assert_eq!(err.exit_code(), 125);
}
#[test]
fn tokenization_maps_to_125() {
let err = SecretshError::Tokenization(TokenizationError::EmptyCommand);
assert_eq!(err.exit_code(), 125);
}
#[test]
fn placeholder_maps_to_125() {
let err = SecretshError::Placeholder(PlaceholderError::UnresolvedKey {
key: "MY_SECRET".into(),
});
assert_eq!(err.exit_code(), 125);
}
#[test]
fn redaction_maps_to_125() {
let err = SecretshError::Redaction(RedactionError::PatternBuildFailed {
reason: "too many patterns".into(),
});
assert_eq!(err.exit_code(), 125);
}
#[test]
fn io_error_maps_to_125() {
let io = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "EACCES");
let err = SecretshError::from(io);
assert_eq!(err.exit_code(), 125);
}
#[test]
fn from_tokenization_error() {
let inner = TokenizationError::TrailingBackslash;
let err: SecretshError = inner.into();
assert!(matches!(err, SecretshError::Tokenization(_)));
}
#[test]
fn from_placeholder_error() {
let inner = PlaceholderError::UnresolvedKey { key: "K".into() };
let err: SecretshError = inner.into();
assert!(matches!(err, SecretshError::Placeholder(_)));
}
#[test]
fn from_spawn_error() {
let inner = SpawnError::NotFound {
command: "foo".into(),
};
let err: SecretshError = inner.into();
assert!(matches!(err, SecretshError::Spawn(_)));
}
#[test]
fn from_redaction_error() {
let inner = RedactionError::PatternBuildFailed { reason: "x".into() };
let err: SecretshError = inner.into();
assert!(matches!(err, SecretshError::Redaction(_)));
}
#[test]
fn from_io_error_via_wrapper() {
let io = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
let wrapper = IoError(io);
let err: SecretshError = wrapper.into();
assert!(matches!(err, SecretshError::Io(_)));
}
#[test]
fn display_rejected_metacharacter() {
let err = TokenizationError::RejectedMetacharacter {
character: '|',
offset: 7,
};
let msg = err.to_string();
assert!(msg.contains('|'), "message should mention the character");
assert!(msg.contains("7"), "message should mention the offset");
}
#[test]
fn display_malformed_placeholder() {
let err = TokenizationError::MalformedPlaceholder {
fragment: "{{FOO".into(),
};
let msg = err.to_string();
assert!(msg.contains("{{FOO"));
assert!(msg.contains('}'));
}
#[test]
fn display_invalid_key_name() {
let err = TokenizationError::InvalidKeyName {
fragment: "{{1FOO}}".into(),
};
let msg = err.to_string();
assert!(
msg.contains("{{1FOO}}"),
"message should contain the fragment"
);
assert!(
msg.contains("[A-Za-z_]"),
"message should describe valid key-name pattern"
);
}
#[test]
fn display_unresolved_key_contains_key_name() {
let err = PlaceholderError::UnresolvedKey {
key: "DB_PASS".into(),
};
let msg = err.to_string();
assert!(msg.contains("DB_PASS"));
}
#[test]
fn shell_delegation_blocked_display_contains_shell_name() {
let err = SpawnError::ShellDelegationBlocked {
shell: "bash".into(),
};
let msg = err.to_string();
assert!(
msg.contains("bash"),
"error message should contain the shell name, got: {msg:?}"
);
assert!(
msg.contains("shell delegation blocked"),
"error message should contain the phrase 'shell delegation blocked', got: {msg:?}"
);
}
#[test]
fn shell_delegation_blocked_exit_code_is_125() {
let err: SecretshError =
SecretshError::Spawn(SpawnError::ShellDelegationBlocked { shell: "sh".into() });
assert_eq!(
err.exit_code(),
125,
"ShellDelegationBlocked should map to exit code 125"
);
}
#[test]
fn shell_delegation_blocked_display_does_not_contain_secret() {
let err = SpawnError::ShellDelegationBlocked { shell: "sh".into() };
let msg = err.to_string();
assert!(
!msg.contains("/usr/local/bin"),
"error should only contain basename, not full path, got: {msg:?}"
);
}
}