use bindizr_core::dns::name::{email_to_soa_mailbox, split_presentation_labels};
use crate::{error::ServiceError, types::CreateZoneRequest};
const MAX_DOMAIN_LEN: usize = 253;
const MAX_EMAIL_LEN: usize = 254;
const MAX_EMAIL_LOCAL_LEN: usize = 64;
const MAX_DNS_LABEL_LEN: usize = 63;
const MIN_TTL: i32 = 60;
const MAX_TTL: i32 = 604_800;
pub(super) struct ValidatedCreateZoneRequest {
pub name: String,
pub primary_ns: String,
pub admin_email: String,
pub ttl: i32,
}
pub(super) fn validate_create_zone_request(
request: &CreateZoneRequest,
) -> Result<ValidatedCreateZoneRequest, ServiceError> {
let zone_name = normalize_zone_name(&request.name)?;
let primary_ns = normalize_primary_ns(&request.primary_ns)?;
let admin_email = normalize_email(&request.admin_email)?;
let ttl = validate_ttl(request.ttl)?;
validate_soa_wire_safety(&zone_name, &primary_ns, &admin_email)?;
Ok(ValidatedCreateZoneRequest {
name: zone_name,
primary_ns,
admin_email,
ttl,
})
}
pub(super) fn is_same_zone_name(existing_name: &str, normalized_name: &str) -> bool {
normalize_zone_name(existing_name)
.map(|existing| existing == normalized_name)
.unwrap_or_else(|_| {
existing_name
.trim()
.trim_end_matches('.')
.eq_ignore_ascii_case(normalized_name)
})
}
pub(crate) fn normalize_zone_lookup_name(value: &str) -> Result<String, ServiceError> {
normalize_zone_name(value)
}
pub(super) fn normalize_email(value: &str) -> Result<String, ServiceError> {
let value = value.trim();
if value.is_empty() {
return Err(ServiceError::BadRequest(
"admin email must not be empty".to_string(),
));
}
if has_whitespace_or_control(value) {
return Err(ServiceError::BadRequest(
"admin email must not contain whitespace or control characters".to_string(),
));
}
if value.matches('@').count() != 1 {
return Err(ServiceError::BadRequest(
"admin email must contain exactly one @".to_string(),
));
}
let (local, domain) = value
.split_once('@')
.expect("admin email contains exactly one @");
validate_email_local_part(local)?;
let domain = normalize_domain_name(domain, "admin email domain", false)?;
let normalized = format!("{}@{}", local, domain);
if normalized.len() > MAX_EMAIL_LEN {
return Err(ServiceError::BadRequest(
"admin email must be 254 bytes or fewer".to_string(),
));
}
Ok(normalized)
}
fn normalize_zone_name(value: &str) -> Result<String, ServiceError> {
let trimmed = value.trim();
if trimmed == "." {
return Err(ServiceError::BadRequest(
"zone name must not be the root zone".to_string(),
));
}
if trimmed.starts_with("*.") || trimmed == "*" {
return Err(ServiceError::BadRequest(
"wildcard zone names are not allowed".to_string(),
));
}
normalize_domain_name(trimmed, "zone name", false)
}
fn normalize_primary_ns(value: &str) -> Result<String, ServiceError> {
normalize_domain_name(value, "primary NS", false)
}
fn normalize_domain_name(
value: &str,
field: &str,
allow_wildcard: bool,
) -> Result<String, ServiceError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(ServiceError::BadRequest(format!(
"{} must not be empty",
field
)));
}
if has_whitespace_or_control(trimmed) {
return Err(ServiceError::BadRequest(format!(
"{} must not contain whitespace or control characters",
field
)));
}
let without_trailing_dot = trimmed.strip_suffix('.').unwrap_or(trimmed);
if without_trailing_dot.is_empty() {
return Err(ServiceError::BadRequest(format!(
"{} must not be empty",
field
)));
}
if without_trailing_dot.len() > MAX_DOMAIN_LEN {
return Err(ServiceError::BadRequest(format!(
"{} must be 253 bytes or fewer",
field
)));
}
for label in without_trailing_dot.split('.') {
validate_domain_label(label, field, allow_wildcard)?;
}
Ok(without_trailing_dot.to_ascii_lowercase())
}
fn validate_domain_label(
label: &str,
field: &str,
allow_wildcard: bool,
) -> Result<(), ServiceError> {
if label.is_empty() {
return Err(ServiceError::BadRequest(format!(
"{} must not contain empty labels",
field
)));
}
if label.len() > MAX_DNS_LABEL_LEN {
return Err(ServiceError::BadRequest(format!(
"{} labels must be 63 bytes or fewer",
field
)));
}
if label == "*" {
if allow_wildcard {
return Ok(());
}
return Err(ServiceError::BadRequest(format!(
"{} must not contain wildcard labels",
field
)));
}
if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
return Err(ServiceError::BadRequest(format!(
"{} labels must contain only ASCII letters, digits, or hyphens",
field
)));
}
if label.starts_with('-') || label.ends_with('-') {
return Err(ServiceError::BadRequest(format!(
"{} labels must not start or end with hyphens",
field
)));
}
Ok(())
}
fn validate_email_local_part(local: &str) -> Result<(), ServiceError> {
if local.is_empty() {
return Err(ServiceError::BadRequest(
"admin email local part must not be empty".to_string(),
));
}
if local.len() > MAX_EMAIL_LOCAL_LEN {
return Err(ServiceError::BadRequest(
"admin email local part must be 64 bytes or fewer".to_string(),
));
}
if local.starts_with('.') || local.ends_with('.') || local.contains("..") {
return Err(ServiceError::BadRequest(
"admin email local part must not start, end, or contain consecutive dots".to_string(),
));
}
if !local.chars().all(is_valid_email_local_char) {
return Err(ServiceError::BadRequest(
"admin email local part contains invalid characters".to_string(),
));
}
Ok(())
}
fn validate_ttl(ttl: i32) -> Result<i32, ServiceError> {
if ttl < MIN_TTL {
return Err(ServiceError::BadRequest(format!(
"ttl must be at least {} seconds",
MIN_TTL
)));
}
if ttl > MAX_TTL {
return Err(ServiceError::BadRequest(format!(
"ttl must be at most {} seconds",
MAX_TTL
)));
}
Ok(ttl)
}
fn validate_soa_wire_safety(
zone_name: &str,
primary_ns: &str,
admin_email: &str,
) -> Result<(), ServiceError> {
validate_wire_domain_name(zone_name, "zone name")?;
validate_wire_domain_name(primary_ns, "primary NS")?;
let soa_mailbox =
email_to_soa_mailbox(admin_email).map_err(|e| ServiceError::BadRequest(e.to_string()))?;
validate_wire_domain_name(&soa_mailbox, "admin email SOA RNAME")?;
Ok(())
}
fn validate_wire_domain_name(name: &str, field: &str) -> Result<(), ServiceError> {
let name = name.trim_end_matches('.');
if name.is_empty() {
return Err(ServiceError::BadRequest(format!(
"{} must be wire-encodable",
field
)));
}
for label in
split_presentation_labels(name).map_err(|e| ServiceError::BadRequest(e.to_string()))?
{
if label.is_empty() {
return Err(ServiceError::BadRequest(format!(
"{} must not contain empty labels",
field
)));
}
if label.len() > MAX_DNS_LABEL_LEN {
return Err(ServiceError::BadRequest(format!(
"{} labels must be 63 bytes or fewer",
field
)));
}
}
Ok(())
}
fn has_whitespace_or_control(value: &str) -> bool {
value
.chars()
.any(|c| c.is_ascii_control() || c.is_whitespace())
}
fn is_valid_email_local_char(c: char) -> bool {
c.is_ascii_alphanumeric()
|| matches!(
c,
'!' | '#'
| '$'
| '%'
| '&'
| '\''
| '*'
| '+'
| '-'
| '/'
| '='
| '?'
| '^'
| '_'
| '`'
| '{'
| '|'
| '}'
| '~'
| '.'
)
}