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 VaultError {
#[error("vault not found at {path:?} — run `secretsh init` to create one")]
NotFound { path: std::path::PathBuf },
#[error(
"vault header HMAC mismatch — the vault header may have been tampered \
with or corrupted"
)]
HmacMismatch,
#[error(
"vault commit-tag HMAC mismatch — the vault file may have been \
tampered with, truncated, or corrupted"
)]
CommitTagMismatch,
#[error(
"GCM authentication tag mismatch for entry at index {index} — \
possible wrong passphrase or ciphertext corruption"
)]
GcmMismatch { index: u32 },
#[error(
"GCM AAD mismatch for entry at index {index} — entries may have been \
reordered or spliced from a different vault"
)]
AadMismatch { index: u32 },
#[error(
"vault file {path:?} has insecure permissions (mode {mode:#o}) — \
expected 0600 (owner read/write only). \
Use --allow-insecure-permissions to override"
)]
InsecurePermissions { path: std::path::PathBuf, mode: u32 },
#[error(
"decryption failed — the master passphrase is incorrect or the vault \
is corrupt"
)]
WrongPassphrase,
#[error(
"vault format version {found} is not supported by this binary \
(maximum supported: {supported}) — upgrade secretsh to open this vault"
)]
VersionTooNew { found: u8, supported: u8 },
#[error("vault format version {found} is invalid — the vault file may be corrupt")]
VersionInvalid { found: u8 },
#[error(
"vault entry limit exceeded — the vault already contains {limit} \
entries (the maximum). Delete unused entries before adding new ones"
)]
EntryLimitExceeded { limit: usize },
#[error(
"vault is locked by another process (lockfile: {lockfile_path:?}). \
Waited {elapsed_secs}s without acquiring the lock. \
If the lock is stale, delete the lockfile and retry"
)]
LockTimeout {
lockfile_path: std::path::PathBuf,
elapsed_secs: u64,
},
#[error(
"stale vault lockfile detected at {lockfile_path:?} \
(recorded PID {pid} is not running, lock age {age_secs}s). \
The lockfile has been removed and the operation will be retried"
)]
StaleLock {
lockfile_path: std::path::PathBuf,
pid: u32,
age_secs: u64,
},
#[error(
"vault file does not start with the expected magic bytes \
(found {found:?}) — this is not a secretsh vault file"
)]
BadMagic { found: [u8; 8] },
#[error(
"vault file is truncated — expected at least {expected} bytes but \
found {found} bytes"
)]
Truncated { expected: usize, found: usize },
}
#[derive(Debug, Error)]
pub enum PlaceholderError {
#[error(
"unresolved placeholder {{{{{{key}}}}}} — no entry with that name \
exists in the vault. Use `secretsh set {key}` to add it"
)]
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("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)]
pub enum MasterKeyError {
#[error(
"environment variable {env_var:?} is not set — \
set it to the master passphrase before running secretsh"
)]
EnvVarNotSet { env_var: String },
#[error(
"passphrase is too short ({length} characters) — \
a minimum of {minimum} characters is required. \
Use --no-passphrase-check to disable this check for machine-generated passphrases"
)]
PassphraseTooShort { length: usize, minimum: usize },
}
#[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("vault error: {0}")]
Vault(#[from] VaultError),
#[error("placeholder error: {0}")]
Placeholder(#[from] PlaceholderError),
#[error("spawn error: {0}")]
Spawn(#[from] SpawnError),
#[error("redaction error: {0}")]
Redaction(#[from] RedactionError),
#[error("master key error: {0}")]
MasterKey(#[from] MasterKeyError),
#[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::Vault(_) => 125,
SecretshError::Placeholder(_) => 125,
SecretshError::Redaction(_) => 125,
SecretshError::MasterKey(_) => 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 vault_not_found_maps_to_125() {
let err = SecretshError::Vault(VaultError::NotFound {
path: "/tmp/missing.bin".into(),
});
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 master_key_maps_to_125() {
let err = SecretshError::MasterKey(MasterKeyError::EnvVarNotSet {
env_var: "SECRETSH_KEY".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_vault_error() {
let inner = VaultError::HmacMismatch;
let err: SecretshError = inner.into();
assert!(matches!(err, SecretshError::Vault(_)));
}
#[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_master_key_error() {
let inner = MasterKeyError::PassphraseTooShort {
length: 4,
minimum: 12,
};
let err: SecretshError = inner.into();
assert!(matches!(err, SecretshError::MasterKey(_)));
}
#[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 display_insecure_permissions_contains_mode() {
let err = VaultError::InsecurePermissions {
path: "/home/user/.local/share/secretsh/vault.bin".into(),
mode: 0o644,
};
let msg = err.to_string();
assert!(
msg.contains("0644") || msg.contains("644"),
"mode should appear in message"
);
}
#[test]
fn display_version_too_new() {
let err = VaultError::VersionTooNew {
found: 5,
supported: 1,
};
let msg = err.to_string();
assert!(msg.contains('5'));
assert!(msg.contains('1'));
}
#[test]
fn display_passphrase_too_short() {
let err = MasterKeyError::PassphraseTooShort {
length: 6,
minimum: 12,
};
let msg = err.to_string();
assert!(msg.contains('6'));
assert!(msg.contains("12"));
}
#[test]
fn display_lock_timeout_contains_path() {
let err = VaultError::LockTimeout {
lockfile_path: "/tmp/vault.lock".into(),
elapsed_secs: 30,
};
let msg = err.to_string();
assert!(msg.contains("/tmp/vault.lock"));
assert!(msg.contains("30"));
}
}