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:
+216
-12
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user