Skip to main content

rusty_pwgen/
lib.rs

1//! # rusty-pwgen
2//!
3//! A Rust port of Theodore Ts'o's `pwgen` utility: generate pronounceable or
4//! random passwords from the OS CSPRNG.
5//!
6//! ## Quick start
7//!
8//! ```no_run
9//! use rusty_pwgen::{PwgenBuilder, CompatibilityMode};
10//!
11//! let mut pwgen = PwgenBuilder::new()
12//!     .length(12)
13//!     .count(5)
14//!     .compat(CompatibilityMode::Default)
15//!     .build()?;
16//!
17//! let passwords: Vec<String> = pwgen.generate_n(5);
18//! # Ok::<(), rusty_pwgen::Error>(())
19//! ```
20//!
21//! ## Stability (lockstep SemVer)
22//!
23//! Library and binary share a single crate version. The `-H` reproducible-mode
24//! contract (SHA256 + ChaCha20Rng chain) is **locked at v0.1.0** — any change
25//! is a MAJOR bump.
26
27pub mod charset;
28pub mod error;
29pub mod phoneme;
30pub mod rng;
31pub mod secure;
32
33pub use error::Error;
34
35/// Whether to apply Default-mode ergonomic extensions or Strict upstream parity.
36///
37/// # Examples
38///
39/// ```
40/// use rusty_pwgen::CompatibilityMode;
41///
42/// assert_eq!(CompatibilityMode::default(), CompatibilityMode::Default);
43/// ```
44#[non_exhaustive]
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46pub enum CompatibilityMode {
47    /// Default mode: ergonomic surface; rejects conflicting flag pairs at parse time.
48    #[default]
49    Default,
50    /// Strict mode: byte-equal upstream pwgen behavior; last-wins conflict resolution.
51    Strict,
52}
53
54/// Default password length per upstream pwgen.
55pub const DEFAULT_LENGTH: usize = 8;
56
57/// Default count when stdout is not a TTY (matches upstream's 1-password behavior).
58pub const DEFAULT_COUNT_PIPED: usize = 1;
59
60/// Default count multiplier per row when stdout IS a TTY (matches upstream's `20 * cols_per_row`).
61pub const DEFAULT_TTY_ROWS: usize = 20;
62
63/// Runtime engine for one pwgen invocation. Constructed via [`PwgenBuilder`].
64#[non_exhaustive]
65pub struct Pwgen {
66    length: usize,
67    count: usize,
68    secure: bool,
69    capitalize: bool,
70    numerals: bool,
71    symbols: bool,
72    ambiguous_filter: bool,
73    no_vowels: bool,
74    remove_chars: Vec<u8>,
75    rng: Box<dyn rng::RngSource + Send>,
76    /// Cached active charset for secure mode.
77    charset: Vec<u8>,
78    #[allow(dead_code)]
79    compat: CompatibilityMode,
80}
81
82impl std::fmt::Debug for Pwgen {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        f.debug_struct("Pwgen")
85            .field("length", &self.length)
86            .field("count", &self.count)
87            .field("secure", &self.secure)
88            .field("capitalize", &self.capitalize)
89            .field("numerals", &self.numerals)
90            .field("symbols", &self.symbols)
91            .field("ambiguous_filter", &self.ambiguous_filter)
92            .field("no_vowels", &self.no_vowels)
93            .field("compat", &self.compat)
94            .finish()
95    }
96}
97
98/// Builder for [`Pwgen`]. All chain methods are `#[must_use]`.
99///
100/// # Examples
101///
102/// ```
103/// use rusty_pwgen::PwgenBuilder;
104///
105/// let pwgen = PwgenBuilder::new()
106///     .length(16)
107///     .secure(true)
108///     .build()
109///     .expect("valid configuration");
110/// # let _ = pwgen;
111/// ```
112#[non_exhaustive]
113#[derive(Debug, Clone)]
114pub struct PwgenBuilder {
115    length: usize,
116    count: usize,
117    secure: bool,
118    capitalize: bool,
119    numerals: bool,
120    symbols: bool,
121    ambiguous_filter: bool,
122    no_vowels: bool,
123    remove_chars: Vec<u8>,
124    reproducible_seed: Option<Vec<u8>>,
125    compat: CompatibilityMode,
126}
127
128impl Default for PwgenBuilder {
129    fn default() -> Self {
130        Self::new()
131    }
132}
133
134impl PwgenBuilder {
135    /// Construct a new builder with upstream-pwgen defaults: length 8, count 1,
136    /// pronounceable, capitals + numerals on, no symbols.
137    #[must_use]
138    pub fn new() -> Self {
139        Self {
140            length: DEFAULT_LENGTH,
141            count: DEFAULT_COUNT_PIPED,
142            secure: false,
143            capitalize: true,
144            numerals: true,
145            symbols: false,
146            ambiguous_filter: false,
147            no_vowels: false,
148            remove_chars: Vec::new(),
149            reproducible_seed: None,
150            compat: CompatibilityMode::Default,
151        }
152    }
153
154    #[must_use]
155    pub fn length(mut self, length: usize) -> Self {
156        self.length = length;
157        self
158    }
159
160    #[must_use]
161    pub fn count(mut self, count: usize) -> Self {
162        self.count = count;
163        self
164    }
165
166    #[must_use]
167    pub fn secure(mut self, secure: bool) -> Self {
168        self.secure = secure;
169        self
170    }
171
172    #[must_use]
173    pub fn capitalize(mut self, capitalize: bool) -> Self {
174        self.capitalize = capitalize;
175        self
176    }
177
178    #[must_use]
179    pub fn numerals(mut self, numerals: bool) -> Self {
180        self.numerals = numerals;
181        self
182    }
183
184    #[must_use]
185    pub fn symbols(mut self, symbols: bool) -> Self {
186        self.symbols = symbols;
187        self
188    }
189
190    #[must_use]
191    pub fn ambiguous_filter(mut self, ambiguous_filter: bool) -> Self {
192        self.ambiguous_filter = ambiguous_filter;
193        self
194    }
195
196    #[must_use]
197    pub fn no_vowels(mut self, no_vowels: bool) -> Self {
198        self.no_vowels = no_vowels;
199        self
200    }
201
202    #[must_use]
203    pub fn remove_chars(mut self, chars: impl Into<String>) -> Self {
204        self.remove_chars = chars.into().into_bytes();
205        self
206    }
207
208    #[must_use]
209    pub fn reproducible_seed(mut self, seed: impl Into<Vec<u8>>) -> Self {
210        self.reproducible_seed = Some(seed.into());
211        self
212    }
213
214    #[must_use]
215    pub fn compat(mut self, compat: CompatibilityMode) -> Self {
216        self.compat = compat;
217        self
218    }
219
220    /// Validate and build a [`Pwgen`].
221    ///
222    /// Per FR-008/FR-009: `no_vowels(true)` and a non-empty `remove_chars`
223    /// each silently engage secure mode (matches upstream pwgen).
224    /// Per FR-032: `length < 5` in pronounceable mode silently switches to
225    /// secure mode (matches upstream).
226    pub fn build(mut self) -> Result<Pwgen, Error> {
227        // Silent implications per FR-008, FR-009, FR-032.
228        if self.no_vowels || !self.remove_chars.is_empty() || self.length < 5 {
229            self.secure = true;
230        }
231
232        let charset = charset::build(
233            charset::CharSetFlags {
234                capitalize: self.capitalize,
235                numerals: self.numerals,
236                symbols: self.symbols,
237                ambiguous_filter: self.ambiguous_filter,
238                no_vowels: self.no_vowels,
239            },
240            &self.remove_chars,
241        );
242
243        if charset.is_empty() && self.length > 0 {
244            return Err(Error::InvalidBuilderConfiguration(
245                "empty character set after applying filters",
246            ));
247        }
248
249        let rng: Box<dyn rng::RngSource + Send> = if let Some(seed_bytes) = self.reproducible_seed {
250            // Per FR-028: SHA256 of the seed bytes → 32-byte ChaCha20 seed.
251            use sha2::Digest;
252            let digest = sha2::Sha256::digest(&seed_bytes);
253            let mut seed = [0u8; 32];
254            seed.copy_from_slice(&digest);
255            Box::new(rng::SeededSource::from_seed(seed))
256        } else {
257            Box::new(rng::OsRngSource::new())
258        };
259
260        Ok(Pwgen {
261            length: self.length,
262            count: self.count,
263            secure: self.secure,
264            capitalize: self.capitalize,
265            numerals: self.numerals,
266            symbols: self.symbols,
267            ambiguous_filter: self.ambiguous_filter,
268            no_vowels: self.no_vowels,
269            remove_chars: self.remove_chars,
270            rng,
271            charset,
272            compat: self.compat,
273        })
274    }
275}
276
277impl Pwgen {
278    /// Generate one password.
279    pub fn generate_one(&mut self) -> String {
280        if self.secure {
281            secure::generate(&mut *self.rng, &self.charset, self.length)
282        } else {
283            phoneme::generate(&mut *self.rng, self.length, self.capitalize, self.numerals)
284        }
285    }
286
287    /// Generate `n` passwords. Pre-allocates the `Vec`.
288    pub fn generate_n(&mut self, n: usize) -> Vec<String> {
289        let mut out = Vec::with_capacity(n);
290        for _ in 0..n {
291            out.push(self.generate_one());
292        }
293        out
294    }
295
296    /// Iterator over generated passwords (streaming, no per-iteration allocation
297    /// beyond the password itself).
298    pub fn iter(&mut self) -> impl Iterator<Item = String> + '_ {
299        std::iter::from_fn(move || Some(self.generate_one()))
300    }
301
302    /// Per-builder `count` (the count the binary would emit at default settings).
303    pub fn count(&self) -> usize {
304        self.count
305    }
306
307    /// Filters that affect output character set (for diagnostics + tests).
308    #[allow(dead_code)]
309    pub(crate) fn debug_charset(&self) -> &[u8] {
310        &self.charset
311    }
312
313    /// Debug helper — not part of stable API.
314    #[allow(dead_code)]
315    pub(crate) fn debug_filters(&self) -> (bool, bool, bool, bool, bool, &[u8]) {
316        (
317            self.capitalize,
318            self.numerals,
319            self.symbols,
320            self.ambiguous_filter,
321            self.no_vowels,
322            &self.remove_chars,
323        )
324    }
325}
326
327// CLI-only modules.
328#[cfg(feature = "cli")]
329pub mod cli;
330#[cfg(feature = "cli")]
331pub mod mode;
332#[cfg(feature = "cli")]
333pub mod output;
334#[cfg(feature = "cli")]
335pub mod seed;
336#[cfg(feature = "cli")]
337pub mod strict;
338
339/// Binary entry-point helper used by `src/main.rs` and `src/bin/pwgen.rs`.
340#[cfg(feature = "cli")]
341pub fn run() -> std::process::ExitCode {
342    use clap::Parser;
343    use std::ffi::OsString;
344    use std::process::ExitCode;
345
346    let raw_argv: Vec<OsString> = std::env::args_os().collect();
347    let pre_strict = strict::pre_scan_strict_flag(&raw_argv);
348    let env_strict = std::env::var_os("RUSTY_PWGEN_STRICT");
349    let argv0 = raw_argv.first().cloned();
350    let resolved_mode = mode::resolve(pre_strict, env_strict.as_deref(), argv0.as_deref());
351
352    if resolved_mode == CompatibilityMode::Strict {
353        return strict::run(&raw_argv);
354    }
355
356    let cli_args = match cli::Cli::try_parse() {
357        Ok(args) => args,
358        Err(e) => {
359            e.print().ok();
360            return match e.kind() {
361                clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion => {
362                    ExitCode::SUCCESS
363                }
364                _ => ExitCode::from(2),
365            };
366        }
367    };
368
369    // Completions subcommand.
370    if let Some(cli::Subcommand::Completions { shell }) = cli_args.command {
371        use clap::CommandFactory;
372        let mut cmd = cli::Cli::command();
373        let name = cmd.get_name().to_string();
374        clap_complete::generate(shell, &mut cmd, name, &mut std::io::stdout());
375        return ExitCode::SUCCESS;
376    }
377
378    // Resolve seed bytes if `-H` was set.
379    let seed_bytes = match cli_args.sha1.as_deref() {
380        Some(spec) => match seed::resolve_seed_input(spec) {
381            Ok(b) => Some(b),
382            Err(Error::SeedSourceUnavailable(path)) => {
383                eprintln!("rusty-pwgen: seed file '{path}' not found");
384                return ExitCode::from(1);
385            }
386            Err(e) => {
387                eprintln!("rusty-pwgen: {e}");
388                return ExitCode::from(1);
389            }
390        },
391        None => None,
392    };
393
394    // FR-032 warning: pronounceable mode with length < 5 falls back to secure.
395    if !cli_args.secure
396        && cli_args.length < 5
397        && !cli_args.no_vowels
398        && cli_args.remove_chars.is_none()
399    {
400        eprintln!(
401            "rusty-pwgen: pronounceable mode requires length >= 5; using secure mode for length {}",
402            cli_args.length
403        );
404    }
405
406    // FR-011 / FR-019: count resolution = explicit `-N` > positional count > TTY default.
407    let resolved_count = cli_args.resolved_count(output::is_tty());
408
409    let mut builder = PwgenBuilder::new()
410        .length(cli_args.length)
411        .count(resolved_count)
412        .secure(cli_args.secure)
413        .capitalize(cli_args.capitalize_effective())
414        .numerals(cli_args.numerals_effective())
415        .symbols(cli_args.symbols)
416        .ambiguous_filter(cli_args.ambiguous_filter)
417        .no_vowels(cli_args.no_vowels);
418    if let Some(rc) = &cli_args.remove_chars {
419        builder = builder.remove_chars(rc.clone());
420    }
421    if let Some(bytes) = seed_bytes {
422        builder = builder.reproducible_seed(bytes);
423    }
424
425    let mut pwgen = match builder.build() {
426        Ok(p) => p,
427        Err(e) => {
428            eprintln!("rusty-pwgen: {e}");
429            return ExitCode::from(1);
430        }
431    };
432
433    output::emit_passwords(
434        &mut pwgen,
435        resolved_count,
436        cli_args.one_column,
437        cli_args.columnar,
438    );
439    ExitCode::SUCCESS
440}