use anyhow::{Context, Result};
use bcrypt::verify;
use std::collections::HashMap;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
#[derive(Debug, Clone, Default)]
pub struct HtpasswdFile {
users: HashMap<String, String>,
}
impl HtpasswdFile {
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
let file = File::open(path.as_ref()).with_context(|| {
format!("Failed to open htpasswd file: {}", path.as_ref().display())
})?;
let reader = BufReader::new(file);
let mut users = HashMap::new();
for (line_num, line) in reader.lines().enumerate() {
let line = line.with_context(|| format!("Failed to read line {}", line_num + 1))?;
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let parts: Vec<&str> = line.splitn(2, ':').collect();
if parts.len() != 2 {
return Err(anyhow::anyhow!(
"Invalid htpasswd format at line {}: expected 'username:hash'",
line_num + 1
));
}
let username = parts[0].trim().to_string();
let hash = parts[1].trim().to_string();
if username.is_empty() {
return Err(anyhow::anyhow!("Empty username at line {}", line_num + 1));
}
if !hash.starts_with("$2") {
return Err(anyhow::anyhow!(
"Password hash for user '{}' doesn't appear to be a bcrypt hash (should start with $2a$, $2b$, or $2y$)",
username
));
}
users.insert(username, hash);
}
if users.is_empty() {
return Err(anyhow::anyhow!("No valid users found in htpasswd file"));
}
Ok(HtpasswdFile { users })
}
pub fn new() -> Self {
HtpasswdFile {
users: HashMap::new(),
}
}
pub fn add_user(&mut self, username: String, bcrypt_hash: String) {
self.users.insert(username, bcrypt_hash);
}
pub fn verify(&self, username: &str, password: &str) -> bool {
if let Some(hash) = self.users.get(username) {
match verify(password, hash) {
Ok(valid) => valid,
Err(e) => {
log::warn!("Bcrypt verification error for user '{}': {}", username, e);
false
}
}
} else {
false
}
}
pub fn user_count(&self) -> usize {
self.users.len()
}
pub fn has_user(&self, username: &str) -> bool {
self.users.contains_key(username)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_verify_valid_password() {
let mut htpasswd = HtpasswdFile::new();
let hash = "$2b$04$aZQZqW0z2Z6Z2Z6Z2Z6Z2O7YZJ3Z2Z6Z2Z6Z2Z6Z2Z6Z2Z6Z2Z6ZO";
htpasswd.add_user("testuser".to_string(), hash.to_string());
}
#[test]
fn test_load_htpasswd_file() -> Result<()> {
let mut file = NamedTempFile::new()?;
writeln!(file, "# This is a comment")?;
writeln!(
file,
"user1:$2b$10$abcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKLMNOP"
)?;
writeln!(
file,
"user2:$2a$10$abcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKLMNOP"
)?;
writeln!(file)?; writeln!(
file,
"user3:$2y$10$abcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKLMNOP"
)?;
file.flush()?;
let htpasswd = HtpasswdFile::load(file.path())?;
assert_eq!(htpasswd.user_count(), 3);
assert!(htpasswd.has_user("user1"));
assert!(htpasswd.has_user("user2"));
assert!(htpasswd.has_user("user3"));
assert!(!htpasswd.has_user("nonexistent"));
Ok(())
}
#[test]
fn test_invalid_format() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "invalid_line_without_colon").unwrap();
file.flush().unwrap();
let result = HtpasswdFile::load(file.path());
assert!(result.is_err());
}
#[test]
fn test_invalid_hash_format() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "user1:not_a_bcrypt_hash").unwrap();
file.flush().unwrap();
let result = HtpasswdFile::load(file.path());
assert!(result.is_err());
}
}