use crate::errors::{Error, Result};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use greentic_secrets_spec::GeneratedSecretRequirement;
use greentic_types::secrets::SecretFormat;
use rand::{Rng, RngExt};
const RAW_TEXT_ALPHABET: &[u8] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-";
const MAX_GENERATED_LENGTH: usize = 4096;
pub fn generate_secret_value(
generated: &GeneratedSecretRequirement,
) -> Result<(Vec<u8>, SecretFormat)> {
if !generated.policy.eq_ignore_ascii_case("random") {
return Err(Error::Invalid(
"generated secret policy".to_string(),
generated.policy.clone(),
));
}
if generated.length > MAX_GENERATED_LENGTH {
return Err(Error::Invalid(
"generated secret length".to_string(),
format!(
"{} exceeds the maximum supported length of {MAX_GENERATED_LENGTH}",
generated.length
),
));
}
let length = generated.length.max(1);
let text = match generated.encoding.as_str() {
"raw_text" => random_ascii(length),
"base64url" => URL_SAFE_NO_PAD.encode(random_bytes(length)),
"hex" => hex_encode(&random_bytes(length)),
other => {
return Err(Error::Invalid(
"generated secret encoding".to_string(),
other.to_string(),
));
}
};
Ok((text.into_bytes(), SecretFormat::Text))
}
fn random_ascii(length: usize) -> String {
let mut rng = rand::rng();
let mut out = String::with_capacity(length);
for _ in 0..length {
let idx = rng.random_range(0..RAW_TEXT_ALPHABET.len());
out.push(RAW_TEXT_ALPHABET[idx] as char);
}
out
}
fn random_bytes(len: usize) -> Vec<u8> {
let mut buffer = vec![0u8; len];
rand::rng().fill_bytes(&mut buffer);
buffer
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for &b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use greentic_secrets_spec::GeneratedSecretScope;
fn spec(encoding: &str, length: usize) -> GeneratedSecretRequirement {
GeneratedSecretRequirement {
policy: "random".to_string(),
length,
encoding: encoding.to_string(),
scope: GeneratedSecretScope {
level: "tenant".to_string(),
team: Some("_".to_string()),
},
regenerate_if_present: false,
}
}
#[test]
fn raw_text_has_requested_length_and_charset() {
let (bytes, fmt) = generate_secret_value(&spec("raw_text", 20)).unwrap();
assert_eq!(fmt, SecretFormat::Text);
assert_eq!(bytes.len(), 20);
assert!(bytes.iter().all(|b| RAW_TEXT_ALPHABET.contains(b)));
}
#[test]
fn hex_is_two_chars_per_byte() {
let (bytes, _) = generate_secret_value(&spec("hex", 16)).unwrap();
assert_eq!(bytes.len(), 32);
assert!(bytes.iter().all(|b| b.is_ascii_hexdigit()));
}
#[test]
fn base64url_decodes_to_requested_byte_count() {
let (b64, _) = generate_secret_value(&spec("base64url", 24)).unwrap();
assert_eq!(URL_SAFE_NO_PAD.decode(&b64).unwrap().len(), 24);
assert!(!b64.contains(&b'='));
assert!(!b64.contains(&b'+'));
assert!(!b64.contains(&b'/'));
}
#[test]
fn two_generations_differ() {
let (a, _) = generate_secret_value(&spec("raw_text", 20)).unwrap();
let (b, _) = generate_secret_value(&spec("raw_text", 20)).unwrap();
assert_ne!(a, b);
}
#[test]
fn zero_length_is_clamped_to_one() {
let (bytes, _) = generate_secret_value(&spec("raw_text", 0)).unwrap();
assert_eq!(bytes.len(), 1);
}
#[test]
fn unsupported_policy_and_encoding_error() {
let mut bad_policy = spec("raw_text", 20);
bad_policy.policy = "fixed".to_string();
assert!(matches!(
generate_secret_value(&bad_policy),
Err(Error::Invalid(_, _))
));
assert!(matches!(
generate_secret_value(&spec("uuid", 20)),
Err(Error::Invalid(_, _))
));
}
#[test]
fn over_max_length_is_rejected_and_boundary_is_accepted() {
let mut over = spec("raw_text", 20);
over.length = MAX_GENERATED_LENGTH + 1;
assert!(matches!(
generate_secret_value(&over),
Err(Error::Invalid(_, _))
));
let mut at_max = spec("raw_text", 20);
at_max.length = MAX_GENERATED_LENGTH;
let (bytes, _) = generate_secret_value(&at_max).unwrap();
assert_eq!(bytes.len(), MAX_GENERATED_LENGTH);
}
}