#[macro_use]
extern crate lazy_static;
use clap::Parser;
use md5::Md5;
use regex::Regex;
use sha2::{Digest, Sha512};
use std::fmt;
lazy_static! {
static ref RE_STARTS_WITH_LOWERCASE_LETTER: Regex = Regex::new(r"^[a-z]").unwrap();
static ref RE_CONTAINS_UPPERCASE_LETTER: Regex = Regex::new(r"[A-Z]").unwrap();
static ref RE_CONATINS_NUMERAL: Regex = Regex::new(r"[0-9]").unwrap();
static ref RE_DOMAIN: Regex = Regex::new(r"^(?:[a-zA-Z]+://)?(?:[^/@]+@)?([^/:]+)").unwrap();
static ref RE_IP_ADDRESS: Regex = Regex::new(r"^\d{1,3}\.\d{1,3}.\d{1,3}\.\d{1,3}$").unwrap();
static ref TLD_LIST: Vec<String> = include_str!("tldlist.txt")
.lines()
.map(String::from)
.collect();
}
pub fn generate_with_url<S: Into<String>>(password: S, url: S) -> String {
let domain = get_hostname(url).expect("Couldn't parse URL");
generate_with_config(password.into(), domain, GenerateConfig::default())
}
pub fn generate<S: Into<String>>(password: S, domain: S) -> String {
generate_with_config(password, domain, GenerateConfig::default())
}
pub fn generate_with_config<S: Into<String>>(
password: S,
domain: S,
config: GenerateConfig,
) -> String {
let mut hash: String = format!(
"{}{}:{}",
password.into(),
config.secret.unwrap_or_else(|| "".to_string()),
domain.into()
);
let mut i = 0;
while i < config.hash_rounds || !validate_password(&hash[..config.length]) {
hash = match config.hash_algorithm {
HashAlgorithm::MD5 => base64_md5(hash),
HashAlgorithm::SHA512 => base64_sha512(hash),
};
i += 1;
}
hash[..config.length].to_string()
}
pub fn get_hostname<S: Into<String>>(domain: S) -> Result<String, RustgenpassError> {
get_hostname_with_config(domain, HostnameConfig::default())
}
pub fn get_hostname_with_config<S: Into<String>>(
domain: S,
config: HostnameConfig,
) -> Result<String, RustgenpassError> {
let domain = domain.into();
if config.passthrough {
return Ok(domain);
}
let hostname = match RE_DOMAIN.captures(domain.as_ref()) {
Some(hostname) => hostname.get(1).unwrap().as_str(),
None => return Err(RustgenpassError::InvalidUrl(domain)),
};
if RE_IP_ADDRESS.is_match(hostname) || config.keep_subdomains {
return Ok(hostname.to_string());
}
Ok(remove_subdomain(hostname.to_string()))
}
fn remove_subdomain<S: Into<String>>(hostname: S) -> String {
let hostname = hostname.into().to_lowercase();
let parts = hostname.split('.').collect::<Vec<&str>>();
if parts.len() < 2 {
return hostname;
}
if let Some(cc_tld) = TLD_LIST
.iter()
.find(|&subdomain| hostname.ends_with(subdomain))
{
let part_count = cc_tld.matches('.').count() + 1;
return parts[(parts.len() - part_count)..].join(".");
}
parts.as_slice()[parts.len() - 2..].join(".")
}
fn validate_password<S: Into<String>>(password: S) -> bool {
let password = password.into();
RE_STARTS_WITH_LOWERCASE_LETTER.is_match(&password)
&& RE_CONATINS_NUMERAL.is_match(&password)
&& RE_CONTAINS_UPPERCASE_LETTER.is_match(&password)
}
fn base64_md5<S: Into<String>>(hash: S) -> String {
let mut hasher = Md5::new();
hasher.update(hash.into());
let digest = hasher.finalize();
base64_encode(&digest)
}
fn base64_sha512<S: Into<String>>(hash: S) -> String {
let mut hasher = Sha512::new();
hasher.update(hash.into());
let digest = hasher.finalize();
base64_encode(&digest)
}
fn base64_encode(digest: &[u8]) -> String {
let b64_md5 = base64::encode(digest);
b64_md5
.chars()
.map(|x| match x {
'=' => 'A',
'+' => '9',
'/' => '8',
_ => x,
})
.collect()
}
#[derive(Debug)]
pub enum RustgenpassError {
InvalidUrl(String),
}
impl fmt::Display for RustgenpassError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
RustgenpassError::InvalidUrl(domain) => write!(f, "Invalid URL: {}", domain),
}
}
}
impl std::error::Error for RustgenpassError {}
#[derive(Debug)]
pub struct GenerateConfig {
pub secret: Option<String>,
pub length: usize,
pub hash_rounds: u8,
pub hash_algorithm: HashAlgorithm,
}
impl Default for GenerateConfig {
fn default() -> Self {
Self {
secret: None,
length: 10,
hash_rounds: 10,
hash_algorithm: HashAlgorithm::default(),
}
}
}
impl From<Cli> for GenerateConfig {
fn from(cli: Cli) -> Self {
Self {
secret: cli.secret,
length: cli.length as usize,
hash_rounds: cli.rounds,
hash_algorithm: cli.hash,
}
}
}
#[derive(Default, Debug)]
pub struct HostnameConfig {
pub passthrough: bool,
pub keep_subdomains: bool,
}
impl From<Cli> for HostnameConfig {
fn from(cli: Cli) -> Self {
Self {
passthrough: cli.passthrough,
keep_subdomains: cli.keep_subdomains,
}
}
}
#[derive(Clone, Debug, clap::ValueEnum)]
pub enum HashAlgorithm {
MD5,
SHA512,
}
impl Default for HashAlgorithm {
fn default() -> Self {
HashAlgorithm::MD5
}
}
#[derive(Parser, Clone, Debug)]
#[clap(author, version, about, long_about = None)]
pub struct Cli {
#[clap(short, long, value_parser)]
pub password: Option<String>,
#[clap(short, long, value_parser)]
pub secret: Option<String>,
#[clap(short, long, value_parser)]
pub domain: Option<String>,
#[clap(short, long, default_value_t = 10, value_parser = clap::value_parser!(u8).range(4..=24))]
pub length: u8,
#[clap(short, long, default_value_t = 10)]
pub rounds: u8,
#[clap(short, long, action)]
pub keep_subdomains: bool,
#[clap(short = 'P', long, action)]
pub passthrough: bool,
#[clap(short = 'H', long, value_enum, default_value_t = HashAlgorithm::MD5)]
pub hash: HashAlgorithm,
}
#[cfg(test)]
mod test_validate_password {
use super::*;
#[test]
fn validates_minimal_example() {
assert!(validate_password("aB9"));
}
#[test]
fn requires_an_uppercase_letter() {
assert_eq!(false, validate_password("a"));
}
#[test]
fn requires_password_to_start_with_lowercase_letter() {
assert_eq!(false, validate_password("A"));
}
#[test]
fn requires_a_number() {
assert_eq!(false, validate_password("aA"));
}
}