"""Hostname and zone validation with punycode support.""" import re # Valid hostname label pattern (after punycode encoding) LABEL_PATTERN = re.compile(r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$', re.IGNORECASE) class ValidationError(Exception): """Raised when validation fails.""" pass def encode_hostname(hostname): """ Encode hostname to ASCII using punycode (IDNA). Args: hostname: Hostname string, possibly with unicode characters. Returns: ASCII-encoded hostname. Raises: ValidationError: If hostname is invalid. """ hostname = hostname.lower().strip() if not hostname: raise ValidationError("Hostname cannot be empty") # Remove trailing dot if present if hostname.endswith('.'): hostname = hostname[:-1] if len(hostname) > 253: raise ValidationError("Hostname too long (max 253 characters)") try: # Encode each label using IDNA labels = hostname.split('.') encoded_labels = [] for label in labels: if not label: raise ValidationError("Empty label in hostname") # Encode to punycode if needed try: encoded = label.encode('idna').decode('ascii') except UnicodeError as e: raise ValidationError(f"Invalid label '{label}': {e}") if len(encoded) > 63: raise ValidationError( f"Label '{label}' too long (max 63 characters)" ) if not LABEL_PATTERN.match(encoded): raise ValidationError(f"Invalid label format: '{label}'") encoded_labels.append(encoded) return '.'.join(encoded_labels) except ValidationError: raise except Exception as e: raise ValidationError(f"Invalid hostname '{hostname}': {e}") def encode_zone(zone): """ Encode zone name to ASCII using punycode (IDNA). Args: zone: Zone name string, possibly with unicode characters. Returns: ASCII-encoded zone name. Raises: ValidationError: If zone is invalid. """ if not zone: raise ValidationError("Zone cannot be empty") # Zone validation is same as hostname return encode_hostname(zone) def validate_hostname_in_zone(hostname, zone): """ Validate and encode hostname and zone, ensuring hostname is in zone. Args: hostname: Hostname string. zone: Zone string. Returns: Tuple of (encoded_hostname, encoded_zone). Raises: ValidationError: If validation fails. """ encoded_hostname = encode_hostname(hostname) encoded_zone = encode_zone(zone) # Check hostname ends with zone if not (encoded_hostname == encoded_zone or encoded_hostname.endswith('.' + encoded_zone)): raise ValidationError( f"Hostname '{hostname}' is not in zone '{zone}'" ) return encoded_hostname, encoded_zone def get_relative_name(hostname, zone): """ Get the relative name (hostname without zone suffix). Args: hostname: Encoded hostname. zone: Encoded zone. Returns: Relative name (e.g., 'mypc' from 'mypc.dyn.example.com'). """ if hostname == zone: return '@' suffix = '.' + zone if hostname.endswith(suffix): return hostname[:-len(suffix)] return hostname