secret-agent 0.2.0

A CLI vault that keeps secrets out of AI agent traces
use rand::Rng;

#[derive(Debug, Clone, Copy, Default)]
pub enum Charset {
    #[default]
    Alphanumeric,
    Ascii,
    Hex,
    Base64,
}

impl std::str::FromStr for Charset {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.to_lowercase().as_str() {
            "alphanumeric" => Ok(Charset::Alphanumeric),
            "ascii" => Ok(Charset::Ascii),
            "hex" => Ok(Charset::Hex),
            "base64" => Ok(Charset::Base64),
            _ => Err(format!("unknown charset: {}", s)),
        }
    }
}

const ALPHANUMERIC: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const ASCII_PRINTABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+[]{}|;:,.<>?";
const HEX: &[u8] = b"0123456789abcdef";
const BASE64: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

pub fn generate(length: usize, charset: Charset) -> String {
    let chars = match charset {
        Charset::Alphanumeric => ALPHANUMERIC,
        Charset::Ascii => ASCII_PRINTABLE,
        Charset::Hex => HEX,
        Charset::Base64 => BASE64,
    };

    let mut rng = rand::thread_rng();
    (0..length)
        .map(|_| {
            let idx = rng.gen_range(0..chars.len());
            chars[idx] as char
        })
        .collect()
}

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

    #[test]
    fn test_generate_alphanumeric() {
        let secret = generate(32, Charset::Alphanumeric);
        assert_eq!(secret.len(), 32);
        assert!(secret.chars().all(|c| c.is_alphanumeric()));
    }

    #[test]
    fn test_generate_hex() {
        let secret = generate(64, Charset::Hex);
        assert_eq!(secret.len(), 64);
        assert!(secret.chars().all(|c| c.is_ascii_hexdigit()));
    }

    #[test]
    fn test_generate_length() {
        for len in [8, 16, 32, 64, 128] {
            let secret = generate(len, Charset::Alphanumeric);
            assert_eq!(secret.len(), len);
        }
    }

    #[test]
    fn test_charset_from_str() {
        assert!(matches!("alphanumeric".parse(), Ok(Charset::Alphanumeric)));
        assert!(matches!("hex".parse(), Ok(Charset::Hex)));
        assert!(matches!("base64".parse(), Ok(Charset::Base64)));
        assert!(matches!("ascii".parse(), Ok(Charset::Ascii)));
        assert!("invalid".parse::<Charset>().is_err());
    }
}