From ed2d299a92e18740d096447f0999ebb5c2652794 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 1 May 2026 19:53:29 -0400 Subject: [PATCH] cli: add 'rate ' subcommand (zxcvbn) --- crates/relicario-cli/src/main.rs | 36 ++++++++++++++++++++++ crates/relicario-cli/tests/smart_inputs.rs | 33 ++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 1846557..98d4022 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -179,6 +179,16 @@ enum Commands { #[arg(value_enum)] shell: Shell, }, + + /// Rate a passphrase with zxcvbn — prints score (0-4) and estimated + /// guesses. Informational only; does not gate vault operations. + /// + /// Pass `-` as the argument to read one line from stdin instead, which + /// keeps the passphrase out of shell history. + Rate { + /// Passphrase to score, or `-` to read from stdin. + passphrase: String, + }, } #[derive(Subcommand)] @@ -375,6 +385,7 @@ fn main() -> Result<()> { generate(shell, &mut cmd, "relicario", &mut std::io::stdout()); Ok(()) } + Commands::Rate { passphrase } => cmd_rate(passphrase), } } @@ -2180,3 +2191,28 @@ struct ParamsKdf { argon2_t: u32, argon2_p: u32, } + +fn cmd_rate(passphrase: String) -> Result<()> { + let pw: String = if passphrase == "-" { + use std::io::BufRead; + let stdin = std::io::stdin(); + let mut line = String::new(); + stdin.lock().read_line(&mut line)?; + line.trim_end_matches(&['\r', '\n'][..]).to_string() + } else { + passphrase + }; + let est = relicario_core::generators::rate_passphrase(&pw); + let label = match est.score { + 0 => "very weak", + 1 => "weak", + 2 => "fair", + 3 => "good", + 4 => "strong", + _ => "?", + }; + println!("score: {}/4 ({})", est.score, label); + println!("guesses: ~10^{:.1}", est.guesses_log10); + println!("note: init requires score ≥ 3 (see `relicario init`)"); + Ok(()) +} diff --git a/crates/relicario-cli/tests/smart_inputs.rs b/crates/relicario-cli/tests/smart_inputs.rs index 897f2f4..5795980 100644 --- a/crates/relicario-cli/tests/smart_inputs.rs +++ b/crates/relicario-cli/tests/smart_inputs.rs @@ -1,6 +1,7 @@ mod common; use assert_cmd::Command; +use predicates::prelude::PredicateBooleanExt; use predicates::str::contains; #[test] @@ -103,3 +104,35 @@ fn no_groups_cache_env_var_suppresses_write() { "groups.cache should not exist when RELICARIO_NO_GROUPS_CACHE=1" ); } + +#[test] +fn rate_strong_passphrase_prints_score_and_guesses() { + Command::cargo_bin("relicario").unwrap() + .args(["rate", "correct horse battery staple table cocoa rocket spirit ferment"]) + .assert() + .success() + .stdout(contains("score:")) + .stdout(contains("guesses:")) + .stdout(contains("strong")); +} + +#[test] +fn rate_weak_passphrase_exits_zero_with_weak_label() { + // `rate` is informational — does NOT exit nonzero on weak input. + // The hard gate lives at `init` (Plan 2B Task 10). + Command::cargo_bin("relicario").unwrap() + .args(["rate", "password"]) + .assert() + .success() + .stdout(contains("very weak").or(contains("weak"))); +} + +#[test] +fn rate_reads_from_stdin_when_arg_is_dash() { + Command::cargo_bin("relicario").unwrap() + .args(["rate", "-"]) + .write_stdin("correcthorsebatterystaple\n") + .assert() + .success() + .stdout(contains("score:")); +}