use anyhow::Result;
use secrecy::{ExposeSecret, SecretString};
use std::fmt;
use crate::diagnosticln as eprintln;
#[derive(Clone)]
pub struct Password {
inner: SecretString,
}
impl Password {
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_str(&self) -> &str {
self.inner.expose_secret()
}
pub fn is_empty(&self) -> bool {
self.inner.expose_secret().is_empty()
}
}
impl fmt::Debug for Password {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Password")
.field("password", &"[REDACTED]")
.finish()
}
}
pub fn prompt_password() -> Result<Password> {
let password = rpassword::prompt_password("Enter SSH password (used for all hosts): ")
.map_err(|e| anyhow::anyhow!("Failed to read password: {}", e))?;
if password.is_empty() {
anyhow::bail!("Empty password not allowed. Please enter a valid SSH password.");
}
Password::new(password)
}
pub fn get_password_from_env() -> Result<Option<Password>> {
match std::env::var("BSSH_PASSWORD") {
Ok(password) if !password.is_empty() => Ok(Some(Password::new(password)?)),
Ok(_) => {
anyhow::bail!("BSSH_PASSWORD is set but empty. Empty passwords are not allowed.");
}
Err(_) => Ok(None),
}
}
pub fn get_password(warn_env: bool) -> Result<Password> {
match get_password_from_env()? {
Some(password) => {
if warn_env {
eprintln!(
"Warning: Using SSH password from BSSH_PASSWORD environment variable. \
This is not recommended for security reasons."
);
}
Ok(password)
}
None => prompt_password(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::EnvGuard;
use serial_test::serial;
#[test]
fn test_password_creation() {
let password = Password::new("test123".to_string()).unwrap();
assert_eq!(password.as_str(), "test123");
assert!(!password.is_empty());
}
#[test]
fn test_password_empty_rejection() {
let result = Password::new(String::new());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("cannot be empty"));
}
#[test]
fn test_password_debug_redaction() {
let password = Password::new("secret".to_string()).unwrap();
let debug_output = format!("{password:?}");
assert!(!debug_output.contains("secret"));
assert!(debug_output.contains("[REDACTED]"));
}
#[test]
fn test_clone_independence() {
let p1 = Password::new("original".to_string()).unwrap();
let p2 = p1.clone();
assert_eq!(p1.as_str(), "original");
assert_eq!(p2.as_str(), "original");
}
#[test]
fn test_arc_sharing() {
use std::sync::Arc;
let p = Arc::new(Password::new("shared".to_string()).unwrap());
let c1 = Arc::clone(&p);
let c2 = Arc::clone(&p);
assert_eq!(p.as_str(), "shared");
assert_eq!(c1.as_str(), "shared");
assert_eq!(c2.as_str(), "shared");
assert_eq!(Arc::strong_count(&p), 3);
}
#[test]
#[serial]
fn test_get_password_from_env_empty() {
let _guard = EnvGuard::set("BSSH_PASSWORD", "");
let result = get_password_from_env();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("empty"));
}
#[test]
#[serial]
fn test_get_password_from_env_valid() {
let _guard = EnvGuard::set("BSSH_PASSWORD", "test_password");
let result = get_password_from_env();
assert!(result.is_ok());
let password = result.unwrap();
assert!(password.is_some());
assert_eq!(password.unwrap().as_str(), "test_password");
}
#[test]
#[serial]
fn test_get_password_from_env_not_set() {
let _guard = EnvGuard::remove("BSSH_PASSWORD");
let result = get_password_from_env();
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
#[test]
#[serial]
fn test_get_password_dispatcher_collection_pattern() {
use std::sync::Arc;
let _guard = EnvGuard::set("BSSH_PASSWORD", "shared_password");
let password = get_password(false).expect("env password should succeed");
let shared = Arc::new(password);
let n1 = Arc::clone(&shared);
let n2 = Arc::clone(&shared);
let n3 = Arc::clone(&shared);
assert_eq!(n1.as_str(), "shared_password");
assert_eq!(n2.as_str(), "shared_password");
assert_eq!(n3.as_str(), "shared_password");
assert_eq!(Arc::strong_count(&shared), 4);
}
}