rusty-pwgen 0.1.0

Generate pronounceable or random passwords from the OS CSPRNG — a Rust port of Theodore Ts'o's `pwgen` with strict-compat mode, deterministic `-H` reproducible mode (SHA256 + ChaCha20), and a typed library API.
Documentation
//! Strict-mode entry point (FR-022, FR-023, FR-045).
//!
//! Hand-rolled argv parser bypassing clap; last-wins conflict resolution;
//! first-error-only unknown-flag formatter per the rusty-portfolio STF-003
//! option A pattern.

use crate::{Pwgen, PwgenBuilder};
use std::ffi::OsString;
use std::io::Write;
use std::process::ExitCode;

/// First unknown flag encountered by the Strict parser.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UnknownFlag {
    Short(char),
    Long(String),
}

pub fn format_unknown_flag(flag: &UnknownFlag) -> String {
    match flag {
        UnknownFlag::Short(c) => format!("rusty-pwgen: invalid option -- '{c}'"),
        UnknownFlag::Long(name) => format!("rusty-pwgen: unknown option -- '{name}'"),
    }
}

pub fn pre_scan_strict_flag(argv: &[OsString]) -> Option<bool> {
    let mut chosen: Option<bool> = None;
    for arg in argv.iter().skip(1) {
        let s = arg.to_string_lossy();
        if s == "--strict" {
            chosen = Some(true);
        } else if s == "--no-strict" {
            chosen = Some(false);
        } else if s == "--" {
            break;
        }
    }
    chosen
}

#[derive(Debug, Default)]
struct StrictArgs {
    length: Option<usize>,
    count: Option<usize>,
    secure: bool,
    capitalize: Option<bool>,
    numerals: Option<bool>,
    symbols: bool,
    ambiguous_filter: bool,
    no_vowels: bool,
    remove_chars: Option<String>,
    sha1: Option<String>,
    columnar: bool,
    one_column: bool,
}

fn parse(argv: &[OsString]) -> Result<StrictArgs, UnknownFlag> {
    let mut out = StrictArgs::default();
    let mut iter = argv.iter().skip(1).peekable();

    while let Some(arg) = iter.next() {
        let s = arg.to_string_lossy();

        if s == "--strict" || s == "--no-strict" {
            continue;
        }
        if s == "--" {
            break;
        }
        // Long flags rejected per FR-013 (Strict mode rejects all long forms
        // EXCEPT `--strict`/`--no-strict` consumed above).
        if let Some(rest) = s.strip_prefix("--") {
            let name = rest.split('=').next().unwrap_or(rest).to_string();
            return Err(UnknownFlag::Long(name));
        }
        // Short flags. Upstream pwgen accepts grouped short flags like `-cny`.
        if let Some(rest) = s.strip_prefix('-') {
            if rest.is_empty() {
                // Bare `-` is not a recognized arg in pwgen.
                return Err(UnknownFlag::Short('-'));
            }
            let mut chars = rest.chars().peekable();
            while let Some(c) = chars.next() {
                match c {
                    'c' => out.capitalize = Some(true),
                    'A' => out.capitalize = Some(false),
                    'n' => out.numerals = Some(true),
                    '0' => out.numerals = Some(false),
                    'y' => out.symbols = true,
                    's' => out.secure = true,
                    'B' => out.ambiguous_filter = true,
                    'v' => out.no_vowels = true,
                    '1' => out.one_column = true,
                    'C' => out.columnar = true,
                    'N' => {
                        // `-N` takes a value.
                        let value = if let Some(rest_chars) =
                            chars.clone().collect::<String>().chars().next()
                        {
                            // Inline value: -N5
                            let inline: String = chars.collect();
                            if inline.is_empty() {
                                iter.next()
                                    .map(|s| s.to_string_lossy().into_owned())
                                    .unwrap_or_default()
                            } else {
                                let _ = rest_chars;
                                inline
                            }
                        } else {
                            iter.next()
                                .map(|s| s.to_string_lossy().into_owned())
                                .unwrap_or_default()
                        };
                        out.count = value.parse().ok();
                        break;
                    }
                    'r' => {
                        // `-r <chars>` takes the next arg.
                        let value = iter
                            .next()
                            .map(|s| s.to_string_lossy().into_owned())
                            .unwrap_or_default();
                        out.remove_chars = Some(value);
                        break;
                    }
                    'H' => {
                        let value = iter
                            .next()
                            .map(|s| s.to_string_lossy().into_owned())
                            .unwrap_or_default();
                        out.sha1 = Some(value);
                        break;
                    }
                    other => return Err(UnknownFlag::Short(other)),
                }
            }
            continue;
        }
        // Positional: length then count.
        if out.length.is_none() {
            out.length = s.parse().ok();
        } else if out.count.is_none() {
            out.count = s.parse().ok();
        }
    }

    Ok(out)
}

/// Strict-mode binary entry point.
pub fn run(argv: &[OsString]) -> ExitCode {
    let parsed = match parse(argv) {
        Ok(p) => p,
        Err(unk) => {
            let _ = writeln!(std::io::stderr().lock(), "{}", format_unknown_flag(&unk));
            return ExitCode::from(2);
        }
    };

    let length = parsed.length.unwrap_or(crate::DEFAULT_LENGTH);
    let count_explicit = parsed.count;

    // Resolve seed if -H was set.
    let seed_bytes = match parsed.sha1.as_deref() {
        Some(spec) => match crate::seed::resolve_seed_input(spec) {
            Ok(b) => Some(b),
            Err(crate::Error::SeedSourceUnavailable(path)) => {
                let _ = writeln!(
                    std::io::stderr().lock(),
                    "rusty-pwgen: seed file '{path}' not found"
                );
                return ExitCode::from(1);
            }
            Err(e) => {
                let _ = writeln!(std::io::stderr().lock(), "rusty-pwgen: {e}");
                return ExitCode::from(1);
            }
        },
        None => None,
    };

    let is_tty = crate::output::is_tty();
    let resolved_count = count_explicit.unwrap_or_else(|| {
        if is_tty {
            let cols_per_row = crate::output::columns() / (length + 1).max(1);
            cols_per_row.max(1) * crate::DEFAULT_TTY_ROWS
        } else {
            crate::DEFAULT_COUNT_PIPED
        }
    });

    let mut builder = PwgenBuilder::new()
        .length(length)
        .count(resolved_count)
        .secure(parsed.secure)
        .capitalize(parsed.capitalize.unwrap_or(true))
        .numerals(parsed.numerals.unwrap_or(true))
        .symbols(parsed.symbols)
        .ambiguous_filter(parsed.ambiguous_filter)
        .no_vowels(parsed.no_vowels)
        .compat(crate::CompatibilityMode::Strict);
    if let Some(rc) = parsed.remove_chars {
        builder = builder.remove_chars(rc);
    }
    if let Some(bytes) = seed_bytes {
        builder = builder.reproducible_seed(bytes);
    }

    let mut pwgen: Pwgen = match builder.build() {
        Ok(p) => p,
        Err(e) => {
            let _ = writeln!(std::io::stderr().lock(), "rusty-pwgen: {e}");
            return ExitCode::from(1);
        }
    };

    crate::output::emit_passwords(
        &mut pwgen,
        resolved_count,
        parsed.one_column,
        parsed.columnar,
    );
    ExitCode::SUCCESS
}

#[cfg(test)]
mod tests {
    use super::*;

    fn argv(parts: &[&str]) -> Vec<OsString> {
        parts.iter().map(|s| OsString::from(*s)).collect()
    }

    #[test]
    fn format_unknown_short() {
        assert_eq!(
            format_unknown_flag(&UnknownFlag::Short('x')),
            "rusty-pwgen: invalid option -- 'x'"
        );
    }

    #[test]
    fn format_unknown_long() {
        assert_eq!(
            format_unknown_flag(&UnknownFlag::Long(String::from("help"))),
            "rusty-pwgen: unknown option -- 'help'"
        );
    }

    #[test]
    fn parse_default_flags() {
        let r = parse(&argv(&["pwgen"])).unwrap();
        assert_eq!(r.length, None);
        assert_eq!(r.count, None);
        assert!(!r.secure);
    }

    #[test]
    fn parse_secure_with_length() {
        let r = parse(&argv(&["pwgen", "-s", "16"])).unwrap();
        assert!(r.secure);
        assert_eq!(r.length, Some(16));
    }

    #[test]
    fn parse_rejects_help_long_flag() {
        let err = parse(&argv(&["pwgen", "--help"])).unwrap_err();
        assert_eq!(err, UnknownFlag::Long(String::from("help")));
    }

    #[test]
    fn parse_rejects_version_long_flag() {
        let err = parse(&argv(&["pwgen", "--version"])).unwrap_err();
        assert_eq!(err, UnknownFlag::Long(String::from("version")));
    }

    #[test]
    fn parse_rejects_unknown_short() {
        let err = parse(&argv(&["pwgen", "-x"])).unwrap_err();
        assert_eq!(err, UnknownFlag::Short('x'));
    }

    #[test]
    fn parse_last_wins_caps_conflict() {
        // FR-015: Strict mode = last-wins. `-c -A` → capitalize=false.
        let r = parse(&argv(&["pwgen", "-c", "-A"])).unwrap();
        assert_eq!(r.capitalize, Some(false));
        let r = parse(&argv(&["pwgen", "-A", "-c"])).unwrap();
        assert_eq!(r.capitalize, Some(true));
    }

    #[test]
    fn parse_grouped_short_flags() {
        // Upstream accepts -cny as a group.
        let r = parse(&argv(&["pwgen", "-cny"])).unwrap();
        assert_eq!(r.capitalize, Some(true));
        assert_eq!(r.numerals, Some(true));
        assert!(r.symbols);
    }

    #[test]
    fn parse_positional_length_count() {
        let r = parse(&argv(&["pwgen", "12", "5"])).unwrap();
        assert_eq!(r.length, Some(12));
        assert_eq!(r.count, Some(5));
    }

    #[test]
    fn parse_n_flag_with_value() {
        let r = parse(&argv(&["pwgen", "-N", "5"])).unwrap();
        assert_eq!(r.count, Some(5));
    }

    #[test]
    fn parse_first_unknown_short_wins() {
        let err = parse(&argv(&["pwgen", "-cAz"])).unwrap_err();
        // -c and -A are valid; -z is the first unknown.
        assert_eq!(err, UnknownFlag::Short('z'));
    }
}