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
//! `-H` seed input loader (FR-028, FR-029, FR-030).
//!
//! Parses the `-H <path>[#suffix]` spec, reads the file (or stdin if `path` is `-`),
//! and returns the raw bytes to feed into the seed-derivation chain.

use crate::Error;
use std::io::Read;

/// Parse the `-H` spec into (path, optional_suffix).
fn split_spec(spec: &str) -> (&str, Option<&str>) {
    if let Some(idx) = spec.find('#') {
        (&spec[..idx], Some(&spec[idx + 1..]))
    } else {
        (spec, None)
    }
}

/// Resolve `-H <spec>` into the bytes that feed FR-028's SHA256 chain:
/// `file_bytes ++ "#" ++ suffix_bytes` if `#suffix` present, else `file_bytes`.
///
/// The caller passes the result to `PwgenBuilder::reproducible_seed`, which
/// applies SHA256 to produce the 32-byte ChaCha20Rng seed.
pub fn resolve_seed_input(spec: &str) -> Result<Vec<u8>, Error> {
    let (path, suffix) = split_spec(spec);

    let file_bytes = if path == "-" {
        // Read from stdin (FR-029).
        let stdin = std::io::stdin();
        let mut handle = stdin.lock();
        let mut buf = Vec::new();
        handle.read_to_end(&mut buf)?;
        buf
    } else {
        // FR-030: clear error on missing file.
        std::fs::read(path).map_err(|_| Error::SeedSourceUnavailable(path.to_string()))?
    };

    let mut out = file_bytes;
    if let Some(suffix) = suffix {
        out.push(b'#');
        out.extend_from_slice(suffix.as_bytes());
    }
    Ok(out)
}

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

    #[test]
    fn split_spec_no_suffix() {
        assert_eq!(split_spec("/tmp/foo"), ("/tmp/foo", None));
    }

    #[test]
    fn split_spec_with_suffix() {
        assert_eq!(split_spec("/tmp/foo#bar"), ("/tmp/foo", Some("bar")));
    }

    #[test]
    fn split_spec_empty_suffix() {
        assert_eq!(split_spec("/tmp/foo#"), ("/tmp/foo", Some("")));
    }

    #[test]
    fn resolve_missing_file_returns_seed_unavailable() {
        let result = resolve_seed_input("/tmp/definitely-not-a-real-file-xyz-789");
        match result {
            Err(Error::SeedSourceUnavailable(p)) => {
                assert!(p.contains("definitely-not-a-real-file"));
            }
            other => panic!("expected SeedSourceUnavailable, got {other:?}"),
        }
    }

    #[test]
    fn resolve_with_suffix_appends_hash_and_suffix() {
        let tmpdir = tempfile::tempdir().unwrap();
        let path = tmpdir.path().join("seed.txt");
        std::fs::write(&path, b"hello").unwrap();
        let spec = format!("{}#world", path.display());
        let bytes = resolve_seed_input(&spec).unwrap();
        assert_eq!(bytes, b"hello#world");
    }

    #[test]
    fn resolve_without_suffix_returns_raw_file_bytes() {
        let tmpdir = tempfile::tempdir().unwrap();
        let path = tmpdir.path().join("seed.txt");
        std::fs::write(&path, b"raw-bytes").unwrap();
        let bytes = resolve_seed_input(&path.to_string_lossy()).unwrap();
        assert_eq!(bytes, b"raw-bytes");
    }
}