This commit is contained in:
elwrcl committed 2026-09-26 17:09:38 +03:00
commit 6df73cc246
22 files changed
+11922

No files matched your search

+253
View File
@@ -0,0 +1,253 @@
use crate::generator::charset::CharsetOptions;
use crate::generator::passphrase::PassphraseOptions;
use clap::{Args, Parser, Subcommand, ValueEnum};
#[derive(Parser, Debug)]
#[command(name = "pasx", version, about = "Terminal password generator")]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Command>,
#[command(flatten)]
pub opts: GenArgs,
}
#[derive(Subcommand, Debug)]
pub enum Command {
/// Generate one password, print it, and exit
Gen(GenCmdArgs),
/// Print a shell completion script
Completions {
#[arg(value_enum)]
shell: ShellKind,
},
}
#[derive(Args, Debug)]
pub struct GenCmdArgs {
#[command(flatten)]
pub opts: GenArgs,
/// Also copy the password to the clipboard
#[arg(long)]
pub copy: bool,
}
#[derive(Args, Debug, Clone)]
pub struct GenArgs {
/// Generation mode
#[arg(short = 'm', long, value_enum, default_value_t = Mode::Charset)]
pub mode: Mode,
/// Password length in characters
#[arg(short = 'l', long, default_value_t = 18)]
pub length: usize,
/// Exclude uppercase letters
#[arg(long)]
pub no_upper: bool,
/// Exclude lowercase letters
#[arg(long)]
pub no_lower: bool,
/// Exclude digits
#[arg(long)]
pub no_digits: bool,
/// Exclude symbols
#[arg(long)]
pub no_symbols: bool,
/// Exclude look-alike characters (0 O o l 1 I)
#[arg(long)]
pub exclude_ambiguous: bool,
/// Number of words in passphrase mode
#[arg(short = 'w', long, default_value_t = 5)]
pub words: usize,
/// Separator between words in passphrase mode
#[arg(long, default_value_t = '-')]
pub separator: char,
/// Capitalize each word in passphrase mode
#[arg(long)]
pub capitalize: bool,
/// Append a random digit in passphrase mode
#[arg(long)]
pub add_number: bool,
}
#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Charset,
Passphrase,
}
#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShellKind {
Nu,
Bash,
Zsh,
Fish,
}
impl GenArgs {
pub fn charset_options(&self) -> CharsetOptions {
CharsetOptions {
length: self.length,
upper: !self.no_upper,
lower: !self.no_lower,
digits: !self.no_digits,
symbols: !self.no_symbols,
exclude_ambiguous: self.exclude_ambiguous,
}
}
pub fn passphrase_options(&self) -> PassphraseOptions {
PassphraseOptions {
words: self.words,
separator: self.separator,
capitalize: self.capitalize,
add_number: self.add_number,
}
}
}
use clap::CommandFactory;
pub fn write_completions(shell: ShellKind, out: &mut impl std::io::Write) -> std::io::Result<()> {
let mut cmd = Cli::command();
let name = cmd.get_name().to_string();
// clap_complete panics internally if the writer it is handed fails, so it
// renders into memory and the fallible write happens here.
let mut script = Vec::new();
match shell {
ShellKind::Nu => {
clap_complete::generate(clap_complete_nushell::Nushell, &mut cmd, name, &mut script)
}
ShellKind::Bash => {
clap_complete::generate(clap_complete::Shell::Bash, &mut cmd, name, &mut script)
}
ShellKind::Zsh => {
clap_complete::generate(clap_complete::Shell::Zsh, &mut cmd, name, &mut script)
}
ShellKind::Fish => {
clap_complete::generate(clap_complete::Shell::Fish, &mut cmd, name, &mut script)
}
}
out.write_all(&script)
}
#[cfg(test)]
mod tests {
use super::*;
use clap::{CommandFactory, Parser};
#[test]
fn argument_definition_is_valid() {
Cli::command().debug_assert();
}
#[test]
fn bare_invocation_has_no_subcommand_and_full_defaults() {
let cli = Cli::parse_from(["pasx"]);
assert!(cli.command.is_none());
assert_eq!(cli.opts.mode, Mode::Charset);
assert_eq!(cli.opts.charset_options(), CharsetOptions::default());
assert_eq!(cli.opts.passphrase_options(), PassphraseOptions::default());
}
#[test]
fn negative_flags_switch_classes_off() {
let cli = Cli::parse_from(["pasx", "--length", "24", "--no-symbols", "--no-upper"]);
let opts = cli.opts.charset_options();
assert_eq!(opts.length, 24);
assert!(!opts.symbols);
assert!(!opts.upper);
assert!(opts.lower);
assert!(opts.digits);
}
#[test]
fn passphrase_flags_reach_the_options() {
let cli = Cli::parse_from([
"pasx",
"--mode",
"passphrase",
"--words",
"7",
"--separator",
".",
"--capitalize",
]);
assert_eq!(cli.opts.mode, Mode::Passphrase);
let opts = cli.opts.passphrase_options();
assert_eq!(opts.words, 7);
assert_eq!(opts.separator, '.');
assert!(opts.capitalize);
assert!(!opts.add_number);
}
#[test]
fn gen_subcommand_takes_the_same_flags_plus_copy() {
let cli = Cli::parse_from(["pasx", "gen", "-l", "32", "--copy"]);
match cli.command {
Some(Command::Gen(args)) => {
assert_eq!(args.opts.charset_options().length, 32);
assert!(args.copy);
}
other => panic!("expected gen subcommand, got {other:?}"),
}
}
#[test]
fn completions_subcommand_parses_every_shell() {
for (arg, expected) in [
("nu", ShellKind::Nu),
("bash", ShellKind::Bash),
("zsh", ShellKind::Zsh),
("fish", ShellKind::Fish),
] {
let cli = Cli::parse_from(["pasx", "completions", arg]);
match cli.command {
Some(Command::Completions { shell }) => assert_eq!(shell, expected),
other => panic!("expected completions subcommand, got {other:?}"),
}
}
}
#[test]
fn exclude_ambiguous_reaches_the_options() {
let cli = Cli::parse_from(["pasx", "--exclude-ambiguous"]);
assert!(cli.opts.charset_options().exclude_ambiguous);
}
#[test]
fn nushell_completions_mention_the_binary_and_its_flags() {
let mut buf = Vec::new();
write_completions(ShellKind::Nu, &mut buf).unwrap();
let script = String::from_utf8(buf).unwrap();
assert!(script.contains("pasx"), "{script}");
assert!(script.contains("--exclude-ambiguous"), "{script}");
assert!(script.contains("pasx gen"), "{script}");
}
#[test]
fn every_shell_produces_a_non_empty_script() {
let shells = [
ShellKind::Nu,
ShellKind::Bash,
ShellKind::Zsh,
ShellKind::Fish,
];
for shell in shells {
let mut buf = Vec::new();
write_completions(shell, &mut buf).unwrap();
assert!(!buf.is_empty(), "{shell:?} produced nothing");
}
}
}
+114
View File
@@ -0,0 +1,114 @@
// A short-lived process cannot own a Wayland or X11 selection: the selection
// belongs to the process and dies with it. The platform clipboard tools
// daemonize themselves and are the only mechanism that actually persists, so
// this shells out to whichever one is present rather than using a clipboard
// crate.
use anyhow::{Result, anyhow, bail};
use std::io::Write;
use std::process::{Command, Stdio};
type Tool = (&'static str, &'static [&'static str]);
const TOOLS: &[Tool] = &[
("wl-copy", &[]),
("xclip", &["-selection", "clipboard"]),
("xsel", &["--clipboard", "--input"]),
("pbcopy", &[]),
];
pub fn copy(text: &str) -> Result<()> {
copy_with(TOOLS, text)
}
fn copy_with(tools: &[Tool], text: &str) -> Result<()> {
let mut missing = Vec::new();
for (program, args) in tools {
match feed(program, args, text) {
Ok(()) => return Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => missing.push(*program),
Err(err) => return Err(anyhow!(err).context(format!("{program} failed"))),
}
}
bail!("no clipboard tool found (tried {})", missing.join(", "))
}
fn feed(program: &str, args: &[&str], text: &str) -> std::io::Result<()> {
let mut child = Command::new(program)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()?;
let written = match child.stdin.take() {
Some(mut stdin) => stdin.write_all(text.as_bytes()),
None => Ok(()),
};
// wait_with_output reaps the child and collects stderr even when the
// write failed: dropping a Child does not wait, and the TUI calls this
// repeatedly over a long session.
let output = child.wait_with_output()?;
written?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = stderr.trim();
if stderr.is_empty() {
Err(std::io::Error::other(format!(
"{program} exited with {}",
output.status
)))
} else {
Err(std::io::Error::other(format!(
"{program} exited with {}: {stderr}",
output.status
)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reports_when_no_clipboard_tool_is_available() {
let err = copy_with(&[("pasx-no-such-tool", &[])], "secret").unwrap_err();
assert!(err.to_string().contains("no clipboard tool"), "{err}");
}
#[test]
fn feeds_the_text_to_the_first_tool_that_exists() {
// `cat` stands in for a clipboard tool: it exists everywhere, reads
// stdin, and exits cleanly.
copy_with(&[("pasx-no-such-tool", &[]), ("cat", &[])], "secret").unwrap();
}
#[test]
fn reports_a_tool_that_exists_but_fails() {
copy_with(&[("false", &[])], "secret").unwrap_err();
}
#[test]
fn a_failing_tool_s_stderr_reaches_the_error() {
let err = copy_with(
&[(
"sh",
&[
"-c",
"echo permission denied talking to daemon 1>&2; exit 1",
],
)],
"secret",
)
.unwrap_err();
let full = format!("{err:#}");
assert!(
full.contains("permission denied"),
"stderr was not surfaced: {full}"
);
}
}
+219
View File
@@ -0,0 +1,219 @@
use crate::generator::{GenError, MAX_LENGTH, MIN_LENGTH, RETRY_LIMIT};
use rand::RngExt;
pub const UPPER: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
pub const LOWER: &str = "abcdefghijklmnopqrstuvwxyz";
pub const DIGITS: &str = "0123456789";
pub const SYMBOLS: &str = "!@#$%^&*()-_=+[]{};:,.<>?/";
pub const AMBIGUOUS: &str = "0Ool1I";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CharsetOptions {
pub length: usize,
pub upper: bool,
pub lower: bool,
pub digits: bool,
pub symbols: bool,
pub exclude_ambiguous: bool,
}
impl Default for CharsetOptions {
fn default() -> Self {
Self {
length: 18,
upper: true,
lower: true,
digits: true,
symbols: true,
exclude_ambiguous: false,
}
}
}
impl CharsetOptions {
pub fn enabled_count(&self) -> usize {
[self.upper, self.lower, self.digits, self.symbols]
.iter()
.filter(|on| **on)
.count()
}
pub fn classes(&self) -> Vec<Vec<char>> {
[
(self.upper, UPPER),
(self.lower, LOWER),
(self.digits, DIGITS),
(self.symbols, SYMBOLS),
]
.into_iter()
.filter(|(on, _)| *on)
.map(|(_, set)| {
set.chars()
.filter(|c| !(self.exclude_ambiguous && AMBIGUOUS.contains(*c)))
.collect()
})
.collect()
}
pub fn pool(&self) -> Vec<char> {
self.classes().into_iter().flatten().collect()
}
pub fn validate(&self) -> Result<(), GenError> {
if !(MIN_LENGTH..=MAX_LENGTH).contains(&self.length) {
return Err(GenError::InvalidLength {
got: self.length,
min: MIN_LENGTH,
max: MAX_LENGTH,
});
}
if self.enabled_count() == 0 {
return Err(GenError::NoCharacterClass);
}
Ok(())
}
}
pub fn generate<R: RngExt>(opts: &CharsetOptions, rng: &mut R) -> Result<String, GenError> {
opts.validate()?;
let classes = opts.classes();
let pool: Vec<char> = classes.iter().flatten().copied().collect();
for _ in 0..RETRY_LIMIT {
let candidate: Vec<char> = (0..opts.length)
.map(|_| pool[rng.random_range(0..pool.len())])
.collect();
if classes
.iter()
.all(|class| candidate.iter().any(|c| class.contains(c)))
{
return Ok(candidate.into_iter().collect());
}
}
Err(GenError::RetryLimitExceeded(RETRY_LIMIT))
}
#[cfg(test)]
mod tests {
use super::*;
use rand::SeedableRng;
use rand::rngs::StdRng;
fn rng() -> StdRng {
StdRng::seed_from_u64(42)
}
#[test]
fn respects_requested_length() {
let opts = CharsetOptions {
length: 24,
..Default::default()
};
let out = generate(&opts, &mut rng()).unwrap();
assert_eq!(out.chars().count(), 24);
}
#[test]
fn uses_only_enabled_classes() {
let opts = CharsetOptions {
length: 32,
upper: false,
symbols: false,
..Default::default()
};
let out = generate(&opts, &mut rng()).unwrap();
assert!(
out.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
);
}
#[test]
fn includes_every_enabled_class() {
let opts = CharsetOptions {
length: 8,
..Default::default()
};
for seed in 0..50 {
let out = generate(&opts, &mut StdRng::seed_from_u64(seed)).unwrap();
assert!(out.chars().any(|c| c.is_ascii_uppercase()), "{out}");
assert!(out.chars().any(|c| c.is_ascii_lowercase()), "{out}");
assert!(out.chars().any(|c| c.is_ascii_digit()), "{out}");
assert!(out.chars().any(|c| SYMBOLS.contains(c)), "{out}");
}
}
#[test]
fn excludes_ambiguous_characters_when_asked() {
let opts = CharsetOptions {
length: 64,
exclude_ambiguous: true,
..Default::default()
};
let out = generate(&opts, &mut rng()).unwrap();
assert!(out.chars().all(|c| !AMBIGUOUS.contains(c)), "{out}");
}
#[test]
fn rejects_length_below_minimum() {
let opts = CharsetOptions {
length: 3,
..Default::default()
};
assert_eq!(
generate(&opts, &mut rng()),
Err(GenError::InvalidLength {
got: 3,
min: MIN_LENGTH,
max: MAX_LENGTH
})
);
}
#[test]
fn rejects_length_above_maximum() {
let opts = CharsetOptions {
length: 129,
..Default::default()
};
assert_eq!(
generate(&opts, &mut rng()),
Err(GenError::InvalidLength {
got: 129,
min: MIN_LENGTH,
max: MAX_LENGTH
})
);
}
#[test]
fn rejects_empty_class_selection() {
let opts = CharsetOptions {
upper: false,
lower: false,
digits: false,
symbols: false,
..Default::default()
};
assert_eq!(generate(&opts, &mut rng()), Err(GenError::NoCharacterClass));
}
#[test]
fn every_pool_character_is_reachable() {
let opts = CharsetOptions {
length: 16,
..Default::default()
};
let mut seen = std::collections::HashSet::new();
let mut r = rng();
for _ in 0..10_000 {
seen.extend(generate(&opts, &mut r).unwrap().chars());
}
for c in opts.pool() {
assert!(
seen.contains(&c),
"pool character {c:?} was never generated"
);
}
}
}
+117
View File
@@ -0,0 +1,117 @@
use crate::generator::charset::CharsetOptions;
use crate::generator::passphrase::{PassphraseOptions, WORDS};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Strength {
Weak,
Fair,
Strong,
Excellent,
}
impl Strength {
pub fn label(&self) -> &'static str {
match self {
Strength::Weak => "weak",
Strength::Fair => "fair",
Strength::Strong => "strong",
Strength::Excellent => "excellent",
}
}
}
// This is an upper bound, not an exact figure: generation is uniform over the
// set of passwords with every enabled class represented (see charset.rs's
// rejection sampling), which is smaller than the full pool^length space this
// formula assumes. The gap is small and shrinks with length — material only
// at the short end of the supported range, where the result is already
// labelled weak — so it is reported as-is rather than computed exactly.
pub fn charset_bits(opts: &CharsetOptions) -> f64 {
let pool = opts.pool().len();
if pool == 0 {
return 0.0;
}
opts.length as f64 * (pool as f64).log2()
}
// `capitalize` is deliberately absent from this figure: it is a deterministic
// transform (always titlecase, never a per-word coin flip), so it adds no
// uncertainty and must not be counted as if it did. Only `add_number`, which
// draws a random digit, adds bits.
pub fn passphrase_bits(opts: &PassphraseOptions) -> f64 {
let per_word = (WORDS.len() as f64).log2();
let digit = if opts.add_number { 10f64.log2() } else { 0.0 };
opts.words as f64 * per_word + digit
}
pub fn strength(bits: f64) -> Strength {
match bits {
b if b < 50.0 => Strength::Weak,
b if b < 80.0 => Strength::Fair,
b if b < 110.0 => Strength::Strong,
_ => Strength::Excellent,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn full_pool_is_eighty_eight_characters() {
assert_eq!(CharsetOptions::default().pool().len(), 88);
}
#[test]
fn charset_bits_match_the_pool_formula() {
let opts = CharsetOptions {
length: 18,
..Default::default()
};
let bits = charset_bits(&opts);
assert!((bits - 18.0 * 88f64.log2()).abs() < 1e-9);
assert!((bits - 116.3).abs() < 0.1, "{bits}");
}
#[test]
fn passphrase_bits_are_about_twelve_nine_per_word() {
let opts = PassphraseOptions {
words: 5,
..Default::default()
};
let bits = passphrase_bits(&opts);
assert!((bits - 5.0 * 7776f64.log2()).abs() < 1e-9);
assert!((bits - 64.6).abs() < 0.1, "{bits}");
}
#[test]
fn capitalizing_adds_no_entropy() {
let plain = PassphraseOptions::default();
let capitalized = PassphraseOptions {
capitalize: true,
..Default::default()
};
assert_eq!(passphrase_bits(&plain), passphrase_bits(&capitalized));
}
#[test]
fn adding_a_digit_adds_log2_of_ten() {
let plain = PassphraseOptions::default();
let numbered = PassphraseOptions {
add_number: true,
..Default::default()
};
let delta = passphrase_bits(&numbered) - passphrase_bits(&plain);
assert!((delta - 10f64.log2()).abs() < 1e-9, "{delta}");
}
#[test]
fn strength_bands_split_at_fifty_eighty_and_one_ten() {
assert_eq!(strength(49.9).label(), "weak");
assert_eq!(strength(50.0).label(), "fair");
assert_eq!(strength(79.9).label(), "fair");
assert_eq!(strength(80.0).label(), "strong");
assert_eq!(strength(109.9).label(), "strong");
assert_eq!(strength(110.0).label(), "excellent");
}
}
+42
View File
@@ -0,0 +1,42 @@
use thiserror::Error;
pub mod charset;
pub mod entropy;
pub mod passphrase;
pub const MIN_LENGTH: usize = 4;
pub const MAX_LENGTH: usize = 128;
pub const MIN_WORDS: usize = 3;
pub const MAX_WORDS: usize = 12;
pub const RETRY_LIMIT: usize = 1000;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum GenError {
#[error("length {got} is out of range {min}..={max}")]
InvalidLength { got: usize, min: usize, max: usize },
#[error("at least one character class must be enabled")]
NoCharacterClass,
#[error("word count {got} is out of range {min}..={max}")]
InvalidWordCount { got: usize, min: usize, max: usize },
#[error("could not satisfy character class constraints after {0} attempts")]
RetryLimitExceeded(usize),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn errors_render_readable_messages() {
let err = GenError::InvalidLength {
got: 2,
min: MIN_LENGTH,
max: MAX_LENGTH,
};
assert_eq!(err.to_string(), "length 2 is out of range 4..=128");
assert_eq!(
GenError::NoCharacterClass.to_string(),
"at least one character class must be enabled"
);
}
}
+183
View File
@@ -0,0 +1,183 @@
use crate::generator::{GenError, MAX_WORDS, MIN_WORDS};
use rand::RngExt;
use std::sync::LazyLock;
pub static WORDS: LazyLock<Vec<&'static str>> =
LazyLock::new(|| include_str!("wordlist.txt").lines().collect());
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PassphraseOptions {
pub words: usize,
pub separator: char,
pub capitalize: bool,
pub add_number: bool,
}
impl Default for PassphraseOptions {
fn default() -> Self {
Self {
words: 5,
separator: '-',
capitalize: false,
add_number: false,
}
}
}
impl PassphraseOptions {
pub fn validate(&self) -> Result<(), GenError> {
if !(MIN_WORDS..=MAX_WORDS).contains(&self.words) {
return Err(GenError::InvalidWordCount {
got: self.words,
min: MIN_WORDS,
max: MAX_WORDS,
});
}
Ok(())
}
}
pub fn generate<R: RngExt>(opts: &PassphraseOptions, rng: &mut R) -> Result<String, GenError> {
opts.validate()?;
let words: Vec<String> = (0..opts.words)
.map(|_| {
let word = WORDS[rng.random_range(0..WORDS.len())];
if opts.capitalize {
capitalize(word)
} else {
word.to_owned()
}
})
.collect();
let mut out = words.join(&opts.separator.to_string());
if opts.add_number {
out.push(char::from_digit(rng.random_range(0..10), 10).unwrap_or('0'));
}
Ok(out)
}
fn capitalize(word: &str) -> String {
let mut chars = word.chars();
match chars.next() {
Some(first) => first.to_uppercase().chain(chars).collect(),
None => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use rand::SeedableRng;
use rand::rngs::StdRng;
fn rng() -> StdRng {
StdRng::seed_from_u64(7)
}
#[test]
fn wordlist_has_the_expected_size() {
assert_eq!(WORDS.len(), 7776);
assert!(WORDS.iter().all(|w| !w.is_empty()));
}
// Four wordlist entries contain a hyphen, so tests that split the output
// back into words use a separator that cannot occur inside a word.
const SPLITTABLE: char = '.';
#[test]
fn produces_the_requested_word_count() {
let opts = PassphraseOptions {
words: 6,
separator: SPLITTABLE,
..Default::default()
};
let out = generate(&opts, &mut rng()).unwrap();
assert_eq!(out.split(SPLITTABLE).count(), 6);
}
#[test]
fn every_word_comes_from_the_list() {
let opts = PassphraseOptions {
separator: SPLITTABLE,
..Default::default()
};
let out = generate(&opts, &mut rng()).unwrap();
assert!(out.split(SPLITTABLE).all(|w| WORDS.contains(&w)), "{out}");
}
#[test]
fn joins_words_with_the_configured_separator() {
let opts = PassphraseOptions {
separator: SPLITTABLE,
..Default::default()
};
let out = generate(&opts, &mut rng()).unwrap();
assert_eq!(out.matches(SPLITTABLE).count(), 4);
}
#[test]
fn defaults_to_a_hyphen_separator() {
let opts = PassphraseOptions::default();
assert_eq!(opts.separator, '-');
let out = generate(&opts, &mut rng()).unwrap();
assert!(out.matches('-').count() >= 4, "{out}");
}
#[test]
fn capitalizes_each_word_when_asked() {
let opts = PassphraseOptions {
capitalize: true,
separator: SPLITTABLE,
..Default::default()
};
let out = generate(&opts, &mut rng()).unwrap();
assert!(
out.split(SPLITTABLE)
.all(|w| w.chars().next().is_some_and(|c| c.is_ascii_uppercase())),
"{out}"
);
}
#[test]
fn appends_a_digit_when_asked() {
let opts = PassphraseOptions {
add_number: true,
..Default::default()
};
let out = generate(&opts, &mut rng()).unwrap();
assert!(
out.chars().last().is_some_and(|c| c.is_ascii_digit()),
"{out}"
);
}
#[test]
fn rejects_word_counts_outside_the_bounds() {
let too_few = PassphraseOptions {
words: 2,
..Default::default()
};
assert_eq!(
generate(&too_few, &mut rng()),
Err(GenError::InvalidWordCount {
got: 2,
min: MIN_WORDS,
max: MAX_WORDS
})
);
let too_many = PassphraseOptions {
words: 13,
..Default::default()
};
assert_eq!(
generate(&too_many, &mut rng()),
Err(GenError::InvalidWordCount {
got: 13,
min: MIN_WORDS,
max: MAX_WORDS
})
);
}
}
File diff suppressed because it is too large. Load diff
+41
View File
@@ -0,0 +1,41 @@
mod cli;
mod clipboard;
mod generator;
mod run;
mod tui;
use anyhow::Context;
use clap::Parser;
use cli::{Cli, Command};
use std::io::Write;
use std::process::ExitCode;
fn main() -> ExitCode {
match dispatch() {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("error: {err:#}");
ExitCode::from(2)
}
}
}
fn dispatch() -> anyhow::Result<()> {
let cli = Cli::parse();
match cli.command {
Some(Command::Gen(args)) => {
let password = run::generate_once(&args.opts, &mut rand::rng())?;
writeln!(std::io::stdout(), "{password}").context("write password to stdout")?;
if args.copy
&& let Err(err) = clipboard::copy(&password)
{
eprintln!("warning: could not copy to clipboard: {err:#}");
}
Ok(())
}
Some(Command::Completions { shell }) => {
cli::write_completions(shell, &mut std::io::stdout()).context("write completion script")
}
None => tui::run(&cli.opts),
}
}
+58
View File
@@ -0,0 +1,58 @@
use crate::cli::{GenArgs, Mode};
use crate::generator::{GenError, charset, passphrase};
use rand::RngExt;
pub fn generate_once<R: RngExt>(args: &GenArgs, rng: &mut R) -> Result<String, GenError> {
match args.mode {
Mode::Charset => charset::generate(&args.charset_options(), rng),
Mode::Passphrase => passphrase::generate(&args.passphrase_options(), rng),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::{Cli, Mode};
use clap::Parser;
use rand::SeedableRng;
use rand::rngs::StdRng;
fn args(argv: &[&str]) -> crate::cli::GenArgs {
Cli::parse_from(argv).opts
}
#[test]
fn charset_mode_produces_a_password_of_the_requested_length() {
let out =
generate_once(&args(&["pasx", "-l", "20"]), &mut StdRng::seed_from_u64(1)).unwrap();
assert_eq!(out.chars().count(), 20);
}
#[test]
fn passphrase_mode_produces_the_requested_word_count() {
let a = args(&[
"pasx",
"--mode",
"passphrase",
"--words",
"4",
"--separator",
".",
]);
assert_eq!(a.mode, Mode::Passphrase);
let out = generate_once(&a, &mut StdRng::seed_from_u64(1)).unwrap();
assert_eq!(out.split('.').count(), 4);
}
#[test]
fn invalid_options_return_an_error() {
let out = generate_once(&args(&["pasx", "-l", "2"]), &mut StdRng::seed_from_u64(1));
assert!(out.is_err());
}
#[test]
fn output_never_contains_a_trailing_newline() {
let out = generate_once(&args(&["pasx"]), &mut StdRng::seed_from_u64(1)).unwrap();
assert_eq!(out.trim_end(), out);
}
}
+415
View File
@@ -0,0 +1,415 @@
use crate::cli::{GenArgs, Mode};
use crate::generator::charset::{self, CharsetOptions};
use crate::generator::entropy::{self, Strength};
use crate::generator::passphrase::{self, PassphraseOptions};
use crate::generator::{MAX_LENGTH, MAX_WORDS, MIN_LENGTH, MIN_WORDS};
use rand::RngExt;
use std::time::{Duration, Instant};
const STATUS_TTL: Duration = Duration::from_secs(3);
const SEPARATORS: [char; 4] = ['-', '.', '_', ' '];
const CHARSET_FIELDS: [Field; 6] = [
Field::Length,
Field::Upper,
Field::Lower,
Field::Digits,
Field::Symbols,
Field::ExcludeAmbiguous,
];
const PASSPHRASE_FIELDS: [Field; 4] = [
Field::Words,
Field::Separator,
Field::Capitalize,
Field::AddNumber,
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Field {
Length,
Upper,
Lower,
Digits,
Symbols,
ExcludeAmbiguous,
Words,
Separator,
Capitalize,
AddNumber,
}
impl Field {
pub fn label(&self) -> &'static str {
match self {
Field::Length => "Length",
Field::Upper => "Uppercase",
Field::Lower => "Lowercase",
Field::Digits => "Digits",
Field::Symbols => "Symbols",
Field::ExcludeAmbiguous => "Exclude look-alikes",
Field::Words => "Words",
Field::Separator => "Separator",
Field::Capitalize => "Capitalize",
Field::AddNumber => "Append digit",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
NextField,
PrevField,
Increase,
Decrease,
Toggle,
Regenerate,
SwitchMode,
Quit,
}
#[derive(Debug, Clone)]
pub struct Status {
pub message: String,
pub expires_at: Instant,
}
pub struct App {
pub mode: Mode,
pub charset: CharsetOptions,
pub passphrase: PassphraseOptions,
pub output: String,
pub bits: f64,
pub selected: usize,
pub status: Option<Status>,
pub should_quit: bool,
}
// Derived Debug would print `output`, which is the generated password. Redact
// it so a future `dbg!` or error chain does not leak it.
impl std::fmt::Debug for App {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("App")
.field("mode", &self.mode)
.field("charset", &self.charset)
.field("passphrase", &self.passphrase)
.field("output", &"<redacted>")
.field("bits", &self.bits)
.field("selected", &self.selected)
.field("status", &self.status)
.field("should_quit", &self.should_quit)
.finish()
}
}
impl App {
pub fn new(args: &GenArgs, rng: &mut impl RngExt) -> Self {
let mut app = Self {
mode: args.mode,
charset: args.charset_options(),
passphrase: args.passphrase_options(),
output: String::new(),
bits: 0.0,
selected: 0,
status: None,
should_quit: false,
};
app.regenerate(rng);
app
}
pub fn fields(&self) -> &'static [Field] {
match self.mode {
Mode::Charset => &CHARSET_FIELDS,
Mode::Passphrase => &PASSPHRASE_FIELDS,
}
}
pub fn selected_field(&self) -> Field {
self.fields()[self.selected.min(self.fields().len() - 1)]
}
pub fn strength(&self) -> Strength {
entropy::strength(self.bits)
}
pub fn set_status(&mut self, message: impl Into<String>) {
self.status = Some(Status {
message: message.into(),
expires_at: Instant::now() + STATUS_TTL,
});
}
pub fn expire_status(&mut self, now: Instant) {
if self.status.as_ref().is_some_and(|s| now >= s.expires_at) {
self.status = None;
}
}
pub fn apply(&mut self, action: Action, rng: &mut impl RngExt) {
match action {
Action::Quit => self.should_quit = true,
Action::NextField => {
self.selected = (self.selected + 1) % self.fields().len();
}
Action::PrevField => {
let len = self.fields().len();
self.selected = (self.selected + len - 1) % len;
}
Action::SwitchMode => {
self.mode = match self.mode {
Mode::Charset => Mode::Passphrase,
Mode::Passphrase => Mode::Charset,
};
self.selected = 0;
self.regenerate(rng);
}
Action::Regenerate => self.regenerate(rng),
Action::Increase => self.step(1, rng),
Action::Decrease => self.step(-1, rng),
Action::Toggle => self.toggle(rng),
}
}
fn step(&mut self, delta: i32, rng: &mut impl RngExt) {
match self.selected_field() {
Field::Length => {
self.charset.length =
clamp_step(self.charset.length, delta, MIN_LENGTH, MAX_LENGTH);
self.regenerate(rng);
}
Field::Words => {
self.passphrase.words =
clamp_step(self.passphrase.words, delta, MIN_WORDS, MAX_WORDS);
self.regenerate(rng);
}
Field::Separator => {
let current = SEPARATORS
.iter()
.position(|c| *c == self.passphrase.separator)
.unwrap_or(0);
let len = SEPARATORS.len() as i32;
let next = ((current as i32 + delta).rem_euclid(len)) as usize;
self.passphrase.separator = SEPARATORS[next];
self.regenerate(rng);
}
_ => self.toggle(rng),
}
}
fn toggle(&mut self, rng: &mut impl RngExt) {
match self.selected_field() {
field @ (Field::Upper | Field::Lower | Field::Digits | Field::Symbols) => {
if self.charset.enabled_count() == 1 && self.is_enabled(field) {
self.set_status("at least one character class must stay enabled");
return;
}
match field {
Field::Upper => self.charset.upper = !self.charset.upper,
Field::Lower => self.charset.lower = !self.charset.lower,
Field::Digits => self.charset.digits = !self.charset.digits,
Field::Symbols => self.charset.symbols = !self.charset.symbols,
_ => unreachable!("matched only the four class fields above"),
}
self.regenerate(rng);
}
Field::ExcludeAmbiguous => {
self.charset.exclude_ambiguous = !self.charset.exclude_ambiguous;
self.regenerate(rng);
}
Field::Capitalize => {
self.passphrase.capitalize = !self.passphrase.capitalize;
self.regenerate(rng);
}
Field::AddNumber => {
self.passphrase.add_number = !self.passphrase.add_number;
self.regenerate(rng);
}
Field::Length | Field::Words | Field::Separator => {}
}
}
fn is_enabled(&self, field: Field) -> bool {
match field {
Field::Upper => self.charset.upper,
Field::Lower => self.charset.lower,
Field::Digits => self.charset.digits,
Field::Symbols => self.charset.symbols,
_ => false,
}
}
fn regenerate(&mut self, rng: &mut impl RngExt) {
let generated = match self.mode {
Mode::Charset => charset::generate(&self.charset, rng),
Mode::Passphrase => passphrase::generate(&self.passphrase, rng),
};
match generated {
Ok(password) => {
self.output = password;
self.bits = match self.mode {
Mode::Charset => entropy::charset_bits(&self.charset),
Mode::Passphrase => entropy::passphrase_bits(&self.passphrase),
};
}
Err(err) => {
// Never leave a password on screen that the visible settings
// did not produce: the entropy figure beside it would lie.
self.output.clear();
self.bits = 0.0;
self.set_status(err.to_string());
}
}
}
}
fn clamp_step(value: usize, delta: i32, min: usize, max: usize) -> usize {
let next = value as i32 + delta;
next.clamp(min as i32, max as i32) as usize
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::Cli;
use clap::Parser;
use rand::SeedableRng;
use rand::rngs::StdRng;
fn app() -> (App, StdRng) {
let mut rng = StdRng::seed_from_u64(3);
let args = Cli::parse_from(["pasx"]).opts;
let app = App::new(&args, &mut rng);
(app, rng)
}
#[test]
fn starts_with_a_generated_password() {
let (app, _) = app();
assert_eq!(app.output.chars().count(), 18);
assert!(app.bits > 100.0);
}
#[test]
fn changing_length_regenerates_at_the_new_length() {
let (mut app, mut rng) = app();
app.apply(Action::Increase, &mut rng);
assert_eq!(app.charset.length, 19);
assert_eq!(app.output.chars().count(), 19);
}
#[test]
fn length_clamps_at_both_bounds() {
let (mut app, mut rng) = app();
app.charset.length = MAX_LENGTH;
app.apply(Action::Increase, &mut rng);
assert_eq!(app.charset.length, MAX_LENGTH);
app.charset.length = MIN_LENGTH;
app.apply(Action::Decrease, &mut rng);
assert_eq!(app.charset.length, MIN_LENGTH);
}
#[test]
fn the_last_enabled_class_cannot_be_turned_off() {
let (mut app, mut rng) = app();
app.charset.lower = false;
app.charset.digits = false;
app.charset.symbols = false;
app.selected = app
.fields()
.iter()
.position(|f| *f == Field::Upper)
.unwrap();
app.apply(Action::Toggle, &mut rng);
assert!(app.charset.upper, "the last class was turned off");
assert!(app.status.is_some(), "no explanation was shown");
}
#[test]
fn switching_mode_regenerates_a_passphrase() {
let (mut app, mut rng) = app();
app.apply(Action::SwitchMode, &mut rng);
assert_eq!(app.mode, Mode::Passphrase);
assert_eq!(app.passphrase.words, 5);
assert!(app.output.matches('-').count() >= 4, "{}", app.output);
assert_eq!(app.selected, 0);
}
#[test]
fn field_selection_wraps_around() {
let (mut app, mut rng) = app();
let last = app.fields().len() - 1;
app.apply(Action::PrevField, &mut rng);
assert_eq!(app.selected, last);
app.apply(Action::NextField, &mut rng);
assert_eq!(app.selected, 0);
}
#[test]
fn regenerate_produces_a_different_password() {
let (mut app, mut rng) = app();
let first = app.output.clone();
app.apply(Action::Regenerate, &mut rng);
assert_ne!(app.output, first);
}
#[test]
fn a_failed_generation_clears_the_stale_output() {
let mut rng = StdRng::seed_from_u64(5);
let args = Cli::parse_from([
"pasx",
"--no-upper",
"--no-lower",
"--no-digits",
"--no-symbols",
])
.opts;
let mut app = App::new(&args, &mut rng);
assert!(app.output.is_empty());
assert_eq!(app.bits, 0.0);
assert!(app.status.is_some());
app.selected = app
.fields()
.iter()
.position(|f| *f == Field::Lower)
.unwrap();
app.apply(Action::Toggle, &mut rng);
assert!(app.charset.lower);
assert_eq!(app.output.chars().count(), 18);
}
#[test]
fn quit_sets_the_flag() {
let (mut app, mut rng) = app();
app.apply(Action::Quit, &mut rng);
assert!(app.should_quit);
}
#[test]
fn status_messages_expire() {
let (mut app, _) = app();
app.set_status("copied");
assert!(app.status.is_some());
app.expire_status(Instant::now() + Duration::from_secs(5));
assert!(app.status.is_none());
}
#[test]
fn separator_cycles_through_the_allowed_set() {
let (mut app, mut rng) = app();
app.apply(Action::SwitchMode, &mut rng);
app.selected = app
.fields()
.iter()
.position(|f| *f == Field::Separator)
.unwrap();
app.apply(Action::Increase, &mut rng);
assert_eq!(app.passphrase.separator, '.');
assert!(app.output.contains('.'));
}
}
+85
View File
@@ -0,0 +1,85 @@
use crate::tui::app::Action;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Intent {
Act(Action),
Copy,
Ignore,
}
// Only Ctrl is inspected: an Alt or Shift chord over a mapped key is treated
// as the bare key rather than swallowed.
pub fn map_key(key: KeyEvent) -> Intent {
if key.modifiers.contains(KeyModifiers::CONTROL) {
return match key.code {
KeyCode::Char('c') => Intent::Act(Action::Quit),
_ => Intent::Ignore,
};
}
match key.code {
KeyCode::Down => Intent::Act(Action::NextField),
KeyCode::Up => Intent::Act(Action::PrevField),
KeyCode::Right => Intent::Act(Action::Increase),
KeyCode::Left => Intent::Act(Action::Decrease),
KeyCode::Char(' ') | KeyCode::Enter => Intent::Act(Action::Toggle),
KeyCode::Tab => Intent::Act(Action::SwitchMode),
KeyCode::Char('r') => Intent::Act(Action::Regenerate),
KeyCode::Char('c') => Intent::Copy,
KeyCode::Char('q') | KeyCode::Esc => Intent::Act(Action::Quit),
_ => Intent::Ignore,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn arrows_move_and_adjust() {
assert_eq!(map_key(key(KeyCode::Down)), Intent::Act(Action::NextField));
assert_eq!(map_key(key(KeyCode::Up)), Intent::Act(Action::PrevField));
assert_eq!(map_key(key(KeyCode::Right)), Intent::Act(Action::Increase));
assert_eq!(map_key(key(KeyCode::Left)), Intent::Act(Action::Decrease));
}
#[test]
fn space_and_enter_toggle_and_tab_switches_mode() {
assert_eq!(
map_key(key(KeyCode::Char(' '))),
Intent::Act(Action::Toggle)
);
assert_eq!(map_key(key(KeyCode::Enter)), Intent::Act(Action::Toggle));
assert_eq!(map_key(key(KeyCode::Tab)), Intent::Act(Action::SwitchMode));
}
#[test]
fn letters_trigger_regenerate_and_copy() {
assert_eq!(
map_key(key(KeyCode::Char('r'))),
Intent::Act(Action::Regenerate)
);
assert_eq!(map_key(key(KeyCode::Char('c'))), Intent::Copy);
}
#[test]
fn q_escape_and_ctrl_c_all_quit() {
assert_eq!(map_key(key(KeyCode::Char('q'))), Intent::Act(Action::Quit));
assert_eq!(map_key(key(KeyCode::Esc)), Intent::Act(Action::Quit));
assert_eq!(
map_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
Intent::Act(Action::Quit)
);
}
#[test]
fn unknown_keys_are_ignored() {
assert_eq!(map_key(key(KeyCode::Char('z'))), Intent::Ignore);
assert_eq!(map_key(key(KeyCode::F(5))), Intent::Ignore);
}
}
+64
View File
@@ -0,0 +1,64 @@
pub mod app;
pub mod input;
pub mod render;
use crate::cli::GenArgs;
use crate::clipboard;
use crate::tui::app::App;
use crate::tui::input::{Intent, map_key};
use anyhow::{Context, Result};
use ratatui::crossterm::event::{self, Event, KeyEventKind};
use std::time::{Duration, Instant};
const POLL_INTERVAL: Duration = Duration::from_millis(250);
pub fn run(args: &GenArgs) -> Result<()> {
let mut rng = rand::rng();
let mut app = App::new(args, &mut rng);
// ratatui::run's init() panics (via expect) when there is no usable
// terminal, e.g. run under cron/systemd/CI with no controlling tty. Use
// the fallible try_init/try_restore pair instead, and restore on every
// exit path — including an Err from the loop body — since that is what
// ratatui::run was doing for us.
let mut terminal = ratatui::try_init().context("failed to initialize terminal")?;
let result = event_loop(&mut terminal, &mut app, &mut rng);
ratatui::try_restore().context("failed to restore terminal")?;
result
}
fn event_loop(
terminal: &mut ratatui::DefaultTerminal,
app: &mut App,
rng: &mut impl rand::RngExt,
) -> Result<()> {
while !app.should_quit {
terminal.draw(|frame| render::draw(frame, app))?;
if event::poll(POLL_INTERVAL)?
&& let Event::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press
{
match map_key(key) {
Intent::Act(action) => app.apply(action, rng),
Intent::Copy => {
if app.output.is_empty() {
app.set_status("nothing to copy");
} else {
match clipboard::copy(&app.output) {
Ok(()) => app.set_status("copied to clipboard"),
// Alternate formatting prints anyhow's full context
// chain (e.g. the tool's stderr), not just the
// outermost "<tool> failed" context.
Err(err) => app.set_status(format!("clipboard unavailable: {err:#}")),
}
}
}
Intent::Ignore => {}
}
}
app.expire_status(Instant::now());
}
Ok(())
}
+287
View File
@@ -0,0 +1,287 @@
use crate::cli::Mode;
use crate::tui::app::{App, Field};
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style, Stylize};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Gauge, List, ListItem, ListState, Paragraph, Wrap};
pub const MIN_WIDTH: u16 = 40;
pub const MIN_HEIGHT: u16 = 14;
const GAUGE_CEILING: f64 = 128.0;
// Rows the gauge, settings, footer and the password block's own borders need,
// so the password can claim whatever is left.
const RESERVED_ROWS: u16 = 13;
pub fn draw(frame: &mut Frame, app: &App) {
let area = frame.area();
if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
frame.render_widget(
Paragraph::new("terminal too small").block(Block::default().borders(Borders::ALL)),
area,
);
return;
}
let (needed, available) = password_rows(app, area);
let truncated = needed > available;
let [password_area, gauge_area, settings_area, footer_area] = Layout::vertical([
Constraint::Length(needed.min(available) + 2),
Constraint::Length(3),
Constraint::Min(5),
Constraint::Length(3),
])
.areas(area);
draw_password(frame, app, password_area, truncated);
draw_gauge(frame, app, gauge_area);
draw_settings(frame, app, settings_area);
draw_footer(frame, app, footer_area);
}
// The rows the whole password would need to display, and the rows actually
// available in the full frame. A 128-character password does not fit on one
// line in a narrow terminal; when `needed` exceeds `available` the block is
// too small to show the whole password and the title must say so — nothing
// else on screen would tell the user their password was cut off.
fn password_rows(app: &App, area: Rect) -> (u16, u16) {
let inner_width = (area.width.saturating_sub(2) as usize).max(1);
let needed = (app.output.chars().count().div_ceil(inner_width)).max(1) as u16;
let available = area.height.saturating_sub(RESERVED_ROWS).max(1);
(needed, available)
}
fn draw_password(frame: &mut Frame, app: &App, area: Rect, truncated: bool) {
let title = if truncated {
" pasx \u{2014} truncated "
} else {
" pasx "
};
let block = Block::default().borders(Borders::ALL).title(title);
frame.render_widget(
Paragraph::new(app.output.as_str())
.bold()
.wrap(Wrap { trim: false })
.block(block),
area,
);
}
fn draw_gauge(frame: &mut Frame, app: &App, area: Rect) {
let ratio = (app.bits / GAUGE_CEILING).clamp(0.0, 1.0);
let label = format!("entropy: {:.0} bit - {}", app.bits, app.strength().label());
frame.render_widget(
Gauge::default()
.block(Block::default().borders(Borders::ALL))
.ratio(ratio)
.label(label),
area,
);
}
fn draw_settings(frame: &mut Frame, app: &App, area: Rect) {
let title = match app.mode {
Mode::Charset => " Mode: [characters] passphrase ",
Mode::Passphrase => " Mode: characters [passphrase] ",
};
let items: Vec<ListItem> = app
.fields()
.iter()
.map(|field| {
let line = Line::from(vec![
Span::raw(format!("{:<22}", field.label())),
Span::raw(value_of(app, *field)),
]);
ListItem::new(line)
})
.collect();
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(title))
.highlight_symbol("> ")
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
// A stateful render lets ratatui scroll the viewport to keep the
// selection visible; a plain widget always starts at offset 0 and a
// selection past the visible rows looks like the UI has frozen.
let mut state = ListState::default().with_selected(Some(app.selected));
frame.render_stateful_widget(list, area, &mut state);
}
fn draw_footer(frame: &mut Frame, app: &App, area: Rect) {
// Kept comfortably under 80 columns so it never clips off the "q quit"
// hint, which a full-screen app must never hide.
let hints = "\u{2191}\u{2193} select \u{2190}\u{2192} change space toggle r regen c copy tab mode q quit";
let status = app
.status
.as_ref()
.map(|s| s.message.as_str())
.unwrap_or("");
frame.render_widget(
Paragraph::new(vec![Line::from(status), Line::from(hints)]).wrap(Wrap { trim: false }),
area,
);
}
fn value_of(app: &App, field: Field) -> String {
let on_off = |on: bool| {
if on {
"[x]".to_string()
} else {
"[ ]".to_string()
}
};
match field {
Field::Length => format!("< {} >", app.charset.length),
Field::Upper => on_off(app.charset.upper),
Field::Lower => on_off(app.charset.lower),
Field::Digits => on_off(app.charset.digits),
Field::Symbols => on_off(app.charset.symbols),
Field::ExcludeAmbiguous => on_off(app.charset.exclude_ambiguous),
Field::Words => format!("< {} >", app.passphrase.words),
Field::Separator => format!("< {:?} >", app.passphrase.separator),
Field::Capitalize => on_off(app.passphrase.capitalize),
Field::AddNumber => on_off(app.passphrase.add_number),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::Cli;
use crate::tui::app::App;
use clap::Parser;
use rand::SeedableRng;
use rand::rngs::StdRng;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn render_at(width: u16, height: u16) -> String {
let mut rng = StdRng::seed_from_u64(11);
let app = App::new(&Cli::parse_from(["pasx"]).opts, &mut rng);
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal.draw(|frame| draw(frame, &app)).unwrap();
terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect()
}
#[test]
fn renders_the_password_and_the_settings() {
let screen = render_at(80, 24);
assert!(screen.contains("pasx"), "{screen}");
assert!(screen.contains("Length"), "{screen}");
assert!(screen.contains("Symbols"), "{screen}");
assert!(screen.contains("entropy"), "{screen}");
}
#[test]
fn shows_a_message_instead_of_a_broken_layout_when_too_small() {
let screen = render_at(20, 6);
assert!(screen.contains("too small"), "{screen}");
assert!(!screen.contains("Length"), "{screen}");
}
#[test]
fn wraps_a_long_password_instead_of_clipping_it() {
let mut rng = StdRng::seed_from_u64(11);
let args = Cli::parse_from(["pasx", "--length", "120"]).opts;
let app = App::new(&args, &mut rng);
let mut terminal = Terminal::new(TestBackend::new(60, 24)).unwrap();
terminal.draw(|frame| draw(frame, &app)).unwrap();
let screen: String = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect();
// 120 chars at 58 usable columns wraps to three lines (58+58+4), each
// framed by its own left/right border in the flattened buffer, so a
// single contiguous "everything after the first line" slice can never
// match; assert every wrapped chunk lands on screen instead.
let inner_width = 58;
let chars: Vec<char> = app.output.chars().collect();
for chunk in chars.chunks(inner_width) {
let chunk: String = chunk.iter().collect();
assert!(screen.contains(&chunk), "{screen}");
}
}
#[test]
fn renders_passphrase_mode_without_panicking() {
let mut rng = StdRng::seed_from_u64(11);
let mut app = App::new(&Cli::parse_from(["pasx"]).opts, &mut rng);
app.apply(crate::tui::app::Action::SwitchMode, &mut rng);
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
terminal.draw(|frame| draw(frame, &app)).unwrap();
let screen: String = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(screen.contains("Words"), "{screen}");
}
fn render_app(app: &App, width: u16, height: u16) -> String {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal.draw(|frame| draw(frame, app)).unwrap();
terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect()
}
#[test]
fn selecting_the_last_field_scrolls_it_into_view() {
let mut rng = StdRng::seed_from_u64(11);
let mut app = App::new(&Cli::parse_from(["pasx"]).opts, &mut rng);
// At 40x14 the settings list has room for only a few rows, so a plain
// (stateless) List would leave the last field selected but scrolled
// out of view. Walk selection to the last charset field and confirm
// it is still rendered.
let last = app.fields().len() - 1;
for _ in 0..last {
app.apply(crate::tui::app::Action::NextField, &mut rng);
}
assert_eq!(app.selected, last);
let screen = render_app(&app, MIN_WIDTH, MIN_HEIGHT);
assert!(screen.contains(app.selected_field().label()), "{screen}");
}
#[test]
fn footer_shows_the_quit_hint_at_eighty_columns() {
let mut rng = StdRng::seed_from_u64(11);
let app = App::new(&Cli::parse_from(["pasx"]).opts, &mut rng);
let screen = render_app(&app, 80, 24);
assert!(screen.contains("q quit"), "{screen}");
}
#[test]
fn password_block_is_marked_truncated_when_it_does_not_fit() {
let mut rng = StdRng::seed_from_u64(11);
let cramped = App::new(&Cli::parse_from(["pasx", "--length", "128"]).opts, &mut rng);
let screen = render_app(&cramped, MIN_WIDTH, MIN_HEIGHT);
assert!(screen.contains("truncated"), "{screen}");
let mut rng = StdRng::seed_from_u64(11);
let roomy = App::new(&Cli::parse_from(["pasx", "--length", "128"]).opts, &mut rng);
let screen = render_app(&roomy, 80, 40);
assert!(!screen.contains("truncated"), "{screen}");
}
}