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.
This commit is contained in:
2026-04-14 15:12:00 +02:00
parent d489716c9b
commit c55f9a392c
+216 -12
View File
@@ -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 <password> # print hash for given password
//! hash-password --user <slug> [--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 '<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<String> = 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<String> },
PatchFile { slug: String, file: PathBuf },
}
fn parse_args(args: &[String]) -> Result<Mode, String> {
let mut slug: Option<String> = None;
let mut file: Option<PathBuf> = None;
let mut positional: Option<String> = 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 <password> # print hash for given password");
eprintln!(" hash-password --user <slug> [--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<String, String> {
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();
}
}