use anyhow::Result;
use secrecy::{ExposeSecret, SecretString};
use std::fmt;
use zeroize::Zeroizing;
pub const SUDO_PROMPT_PATTERNS: &[&str] = &[
"[sudo] password for ",
"password for ",
"password:",
"'s password:",
"sudo password",
"enter password",
"[sudo]",
];
pub const SUDO_FAILURE_PATTERNS: &[&str] = &[
"sorry, try again",
"incorrect password",
"authentication failure",
"permission denied",
"sudo: 3 incorrect password attempts",
"sudo: no password was provided",
];
#[derive(Clone)]
pub struct SudoPassword {
inner: SecretString,
}
impl SudoPassword {
pub fn new(password: String) -> Result<Self> {
if password.is_empty() {
anyhow::bail!("Password cannot be empty");
}
Ok(Self {
inner: SecretString::new(password.into_boxed_str()),
})
}
pub fn as_bytes(&self) -> &[u8] {
self.inner.expose_secret().as_bytes()
}
pub fn with_newline(&self) -> Zeroizing<Vec<u8>> {
let mut bytes = self.inner.expose_secret().as_bytes().to_vec();
bytes.push(b'\n');
Zeroizing::new(bytes)
}
pub fn is_empty(&self) -> bool {
self.inner.expose_secret().is_empty()
}
}
impl fmt::Debug for SudoPassword {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SudoPassword")
.field("password", &"[REDACTED]")
.finish()
}
}
pub fn contains_sudo_prompt(output: &str) -> bool {
let lower = output.to_lowercase();
SUDO_PROMPT_PATTERNS
.iter()
.any(|pattern| lower.contains(*pattern))
}
pub fn contains_sudo_failure(output: &str) -> bool {
let lower = output.to_lowercase();
SUDO_FAILURE_PATTERNS
.iter()
.any(|pattern| lower.contains(*pattern))
}
pub fn prompt_sudo_password() -> Result<SudoPassword> {
eprintln!("Enter sudo password: ");
let password = rpassword::read_password()
.map_err(|e| anyhow::anyhow!("Failed to read sudo password: {}", e))?;
if password.is_empty() {
anyhow::bail!("Empty password not allowed. Please enter a valid sudo password.");
}
SudoPassword::new(password)
}
pub fn get_sudo_password_from_env() -> Result<Option<SudoPassword>> {
match std::env::var("BSSH_SUDO_PASSWORD") {
Ok(password) if !password.is_empty() => Ok(Some(SudoPassword::new(password)?)),
Ok(_) => {
anyhow::bail!("BSSH_SUDO_PASSWORD is set but empty. Empty passwords are not allowed.");
}
Err(_) => Ok(None),
}
}
pub fn get_sudo_password(warn_env: bool) -> Result<SudoPassword> {
match get_sudo_password_from_env()? {
Some(password) => {
if warn_env {
eprintln!(
"Warning: Using sudo password from BSSH_SUDO_PASSWORD environment variable. \
This is not recommended for security reasons."
);
}
Ok(password)
}
None => prompt_sudo_password(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
#[test]
fn test_sudo_password_creation() {
let password = SudoPassword::new("test123".to_string()).unwrap();
assert_eq!(password.as_bytes(), b"test123");
assert!(!password.is_empty());
}
#[test]
fn test_sudo_password_empty_rejection() {
let result = SudoPassword::new(String::new());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("cannot be empty"));
}
#[test]
fn test_sudo_password_with_newline() {
let password = SudoPassword::new("test123".to_string()).unwrap();
let with_newline = password.with_newline();
assert_eq!(&*with_newline, b"test123\n");
}
#[test]
fn test_sudo_password_with_newline_is_zeroizing() {
let password = SudoPassword::new("test123".to_string()).unwrap();
let with_newline = password.with_newline();
assert_eq!(&*with_newline, b"test123\n");
drop(with_newline);
}
#[test]
fn test_sudo_password_debug_redaction() {
let password = SudoPassword::new("secret".to_string()).unwrap();
let debug_output = format!("{:?}", password);
assert!(!debug_output.contains("secret"));
assert!(debug_output.contains("[REDACTED]"));
}
#[test]
fn test_contains_sudo_prompt() {
assert!(contains_sudo_prompt("[sudo] password for user:"));
assert!(contains_sudo_prompt("Password:"));
assert!(contains_sudo_prompt("user's password:"));
assert!(contains_sudo_prompt("[sudo] password for admin:"));
assert!(contains_sudo_prompt("[SUDO] PASSWORD FOR USER:"));
assert!(contains_sudo_prompt("PASSWORD:"));
assert!(!contains_sudo_prompt("Command executed successfully"));
assert!(!contains_sudo_prompt("root@server:~#"));
}
#[test]
fn test_contains_sudo_failure() {
assert!(contains_sudo_failure("Sorry, try again."));
assert!(contains_sudo_failure("sudo: 3 incorrect password attempts"));
assert!(contains_sudo_failure("Authentication failure"));
assert!(contains_sudo_failure("Permission denied"));
assert!(!contains_sudo_failure("Command executed successfully"));
assert!(!contains_sudo_failure("password accepted"));
}
#[test]
fn test_clone_independence() {
let password1 = SudoPassword::new("original".to_string()).unwrap();
let password2 = password1.clone();
assert_eq!(password1.as_bytes(), b"original");
assert_eq!(password2.as_bytes(), b"original");
}
#[test]
#[serial]
fn test_get_sudo_password_from_env_empty() {
std::env::remove_var("BSSH_SUDO_PASSWORD");
std::env::set_var("BSSH_SUDO_PASSWORD", "");
let result = get_sudo_password_from_env();
std::env::remove_var("BSSH_SUDO_PASSWORD");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("empty"));
}
#[test]
#[serial]
fn test_get_sudo_password_from_env_valid() {
std::env::remove_var("BSSH_SUDO_PASSWORD");
std::env::set_var("BSSH_SUDO_PASSWORD", "test_password");
let result = get_sudo_password_from_env();
std::env::remove_var("BSSH_SUDO_PASSWORD");
assert!(result.is_ok());
let password = result.unwrap();
assert!(password.is_some());
assert_eq!(password.unwrap().as_bytes(), b"test_password");
}
#[test]
#[serial]
fn test_get_sudo_password_from_env_not_set() {
std::env::remove_var("BSSH_SUDO_PASSWORD");
let result = get_sudo_password_from_env();
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
}