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.
|
//! Generate a bcrypt hash for a plaintext password and optionally patch
|
||||||
//! Use when adding or rotating a `web_password` entry in `users.toml`.
|
//! the `web_password` field in `users.toml` in place.
|
||||||
//!
|
//!
|
||||||
//! Dev: `cargo run --quiet --bin hash-password -- 'my-password'`
|
//! Modes:
|
||||||
//! Release: `./target/release/hash-password 'my-password'`
|
//! 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() {
|
use std::io::Write;
|
||||||
let password = std::env::args().nth(1).unwrap_or_else(|| {
|
use std::path::{Path, PathBuf};
|
||||||
eprintln!("Usage: hash-password '<password>'");
|
use std::process::ExitCode;
|
||||||
std::process::exit(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
let hash = bcrypt::hash(&password, bcrypt::DEFAULT_COST)
|
fn main() -> ExitCode {
|
||||||
.expect("bcrypt hashing failed");
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
println!("{hash}");
|
|
||||||
|
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