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("{}", UnresolvedKeyDisplay { key, available_keys })]
UnresolvedKey {
key: String,
available_keys: Vec<String>,
},
}
struct UnresolvedKeyDisplay<'a> {
key: &'a str,
available_keys: &'a [String],
}
impl std::fmt::Display for UnresolvedKeyDisplay<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "\"{}\" not found in env file", self.key)?;
if self.available_keys.is_empty() {
write!(f, "; env file has no keys")?;
} else {
let mut sorted = self.available_keys.to_vec();
sorted.sort_unstable();
write!(f, "; available keys: [{}]", sorted.join(", "))?;
}
Ok(())
}
}
#[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(),
available_keys: vec![],
});
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(),
available_keys: vec![],
};
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(),
available_keys: vec!["API_KEY".into(), "DB_USER".into()],
};
let msg = err.to_string();
assert!(msg.contains("DB_PASS"), "should contain the missing key");
assert!(msg.contains("API_KEY"), "should list available key API_KEY");
assert!(msg.contains("DB_USER"), "should list available key DB_USER");
}
#[test]
fn display_unresolved_key_empty_env_file() {
let err = PlaceholderError::UnresolvedKey {
key: "FOO".into(),
available_keys: vec![],
};
let msg = err.to_string();
assert!(msg.contains("FOO"));
assert!(msg.contains("no keys"), "should say env file has no keys");
}
#[test]
fn display_unresolved_key_available_keys_are_sorted() {
let err = PlaceholderError::UnresolvedKey {
key: "MISSING".into(),
available_keys: vec!["Z_KEY".into(), "A_KEY".into(), "M_KEY".into()],
};
let msg = err.to_string();
let a_pos = msg.find("A_KEY").unwrap();
let m_pos = msg.find("M_KEY").unwrap();
let z_pos = msg.find("Z_KEY").unwrap();
assert!(
a_pos < m_pos && m_pos < z_pos,
"keys should appear sorted: {msg}"
);
}
#[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:?}"
);
}
}