From c55f9a392cdbe0149efcea41a48c97a4f510d4cc Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 14 Apr 2026 15:12:00 +0200 Subject: [PATCH] feat: Enhance password hashing utility This commit introduces several improvements to the `hash-password` binary: - **New command-line interface:** Supports printing hashes directly, hashing via prompts, or patching `web_password` in `users.toml` in place. - **Atomic file patching:** Utilizes `toml_edit` to preserve comments, ordering, and formatting, and writes changes atomically via a temporary file and rename operation. - **Robust argument parsing:** Handles various command-line scenarios and provides informative usage messages. - **Error handling:** Improves error reporting for failed operations. - **Added unit tests:** Includes tests for patching behavior and error conditions. --- server/src/bin/hash-password.rs | 228 ++++++++++++++++++++++++++++++-- 1 file changed, 216 insertions(+), 12 deletions(-) diff --git a/server/src/bin/hash-password.rs b/server/src/bin/hash-password.rs index 87b855a..e0c57fe 100644 --- a/server/src/bin/hash-password.rs +++ b/server/src/bin/hash-password.rs @@ -1,16 +1,220 @@ -//! Generate a bcrypt hash for a plaintext password. -//! Use when adding or rotating a `web_password` entry in `users.toml`. +//! Generate a bcrypt hash for a plaintext password and optionally patch +//! the `web_password` field in `users.toml` in place. //! -//! Dev: `cargo run --quiet --bin hash-password -- 'my-password'` -//! Release: `./target/release/hash-password 'my-password'` +//! Modes: +//! hash-password # prompt silently, print hash +//! hash-password # print hash for given password +//! hash-password --user [--file PATH] +//! # prompt silently, patch file in place +//! +//! The file patch uses toml_edit to preserve comments, ordering, and +//! formatting, and writes atomically (tmp file + rename). -fn main() { - let password = std::env::args().nth(1).unwrap_or_else(|| { - eprintln!("Usage: hash-password ''"); - std::process::exit(2); - }); +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; - let hash = bcrypt::hash(&password, bcrypt::DEFAULT_COST) - .expect("bcrypt hashing failed"); - println!("{hash}"); +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + + match parse_args(&args) { + Ok(Mode::PrintHash { password }) => { + let pw = match password { + Some(p) => p, + None => match read_password_twice() { + Ok(p) => p, + Err(e) => { + eprintln!("{e}"); + return ExitCode::from(1); + } + }, + }; + println!("{}", hash(&pw)); + ExitCode::SUCCESS + } + Ok(Mode::PatchFile { slug, file }) => match read_password_twice() { + Ok(pw) => match patch_users_toml(&file, &slug, &hash(&pw)) { + Ok(()) => { + eprintln!("Updated web_password for slug \"{slug}\" in {}", file.display()); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("Failed to patch {}: {e}", file.display()); + ExitCode::from(1) + } + }, + Err(e) => { + eprintln!("{e}"); + ExitCode::from(1) + } + }, + Err(msg) => { + eprintln!("{msg}"); + eprintln!(); + print_usage(); + ExitCode::from(2) + } + } +} + +enum Mode { + PrintHash { password: Option }, + PatchFile { slug: String, file: PathBuf }, +} + +fn parse_args(args: &[String]) -> Result { + let mut slug: Option = None; + let mut file: Option = None; + let mut positional: Option = None; + + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--user" => { + i += 1; + slug = Some(args.get(i).ok_or("--user requires a value")?.clone()); + } + "--file" => { + i += 1; + file = Some(PathBuf::from(args.get(i).ok_or("--file requires a value")?)); + } + "-h" | "--help" => return Err("help".into()), + other if other.starts_with("--") => { + return Err(format!("unknown flag: {other}")); + } + _ => { + if positional.is_some() { + return Err("unexpected extra positional argument".into()); + } + positional = Some(args[i].clone()); + } + } + i += 1; + } + + match (slug, positional) { + (Some(_), Some(_)) => Err("--user cannot be combined with a positional password".into()), + (Some(slug), None) => Ok(Mode::PatchFile { + slug, + file: file.unwrap_or_else(|| PathBuf::from("users.toml")), + }), + (None, pw) => Ok(Mode::PrintHash { password: pw }), + } +} + +fn print_usage() { + eprintln!("Usage:"); + eprintln!(" hash-password # prompt silently, print hash"); + eprintln!(" hash-password # print hash for given password"); + eprintln!(" hash-password --user [--file PATH]"); + eprintln!(" # prompt silently, patch users.toml"); +} + +fn hash(password: &str) -> String { + bcrypt::hash(password, bcrypt::DEFAULT_COST).expect("bcrypt hashing failed") +} + +fn read_password_twice() -> Result { + let a = rpassword::prompt_password("Password: ") + .map_err(|e| format!("Failed to read password: {e}"))?; + let b = rpassword::prompt_password("Repeat: ") + .map_err(|e| format!("Failed to read password: {e}"))?; + if a != b { + return Err("Passwords do not match".into()); + } + if a.is_empty() { + return Err("Password must not be empty".into()); + } + Ok(a) +} + +/// Patch `web_password` for the `[[user]]` entry whose `slug` matches. +/// Preserves comments and formatting via `toml_edit`. Writes atomically. +fn patch_users_toml(path: &Path, slug: &str, new_hash: &str) -> Result<(), String> { + let content = std::fs::read_to_string(path) + .map_err(|e| format!("read: {e}"))?; + let mut doc: toml_edit::DocumentMut = content + .parse() + .map_err(|e| format!("parse: {e}"))?; + + let users = doc + .get_mut("user") + .and_then(|u| u.as_array_of_tables_mut()) + .ok_or_else(|| "No [[user]] array in file".to_string())?; + + let mut found = false; + for table in users.iter_mut() { + let matches = table.get("slug").and_then(|v| v.as_str()) == Some(slug); + if matches { + table["web_password"] = toml_edit::value(new_hash); + found = true; + break; + } + } + if !found { + return Err(format!("No [[user]] entry with slug \"{slug}\"")); + } + + // Atomic write: tmp file in same dir, then rename. + let tmp = path.with_extension("toml.tmp"); + { + let mut f = std::fs::File::create(&tmp) + .map_err(|e| format!("create tmp: {e}"))?; + f.write_all(doc.to_string().as_bytes()) + .map_err(|e| format!("write tmp: {e}"))?; + f.sync_all().map_err(|e| format!("sync tmp: {e}"))?; + } + std::fs::rename(&tmp, path).map_err(|e| format!("rename: {e}"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = r#"# header comment + +[[user]] +slug = "dr_a" +api_key = "key-a" +web_password = "$2b$12$OLD" +role = "doctor" + +[[user]] +slug = "dr_b" +api_key = "key-b" +web_password = "$2b$12$KEEP" +role = "doctor" +"#; + + fn tmpfile(content: &str) -> PathBuf { + let p = std::env::temp_dir().join(format!( + "hp-test-{}-{}.toml", + std::process::id(), + uuid::Uuid::new_v4() + )); + std::fs::write(&p, content).unwrap(); + p + } + + #[test] + fn patches_matching_user_and_keeps_others() { + let p = tmpfile(SAMPLE); + patch_users_toml(&p, "dr_a", "$2b$12$NEW").unwrap(); + let out = std::fs::read_to_string(&p).unwrap(); + assert!(out.contains(r#"web_password = "$2b$12$NEW""#), "got: {out}"); + assert!(out.contains(r#"web_password = "$2b$12$KEEP""#), "dr_b changed: {out}"); + assert!(out.contains("# header comment"), "comment lost: {out}"); + std::fs::remove_file(&p).ok(); + } + + #[test] + fn rejects_unknown_slug() { + let p = tmpfile(SAMPLE); + let err = patch_users_toml(&p, "ghost", "$2b$12$X").unwrap_err(); + assert!(err.contains("ghost"), "got: {err}"); + // File must be untouched on error. + assert_eq!(std::fs::read_to_string(&p).unwrap(), SAMPLE); + std::fs::remove_file(&p).ok(); + } }