Skip to main content

cloudillo_types/
address.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Address type detection and validation for IPv4, IPv6, and hostnames
5
6use crate::prelude::*;
7use serde::{Deserialize, Serialize};
8use std::net::{Ipv4Addr, Ipv6Addr};
9use std::str::FromStr;
10
11/// Type of address
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13pub enum AddressType {
14	/// IPv4 address (e.g., 192.168.1.1)
15	Ipv4,
16	/// IPv6 address (e.g., 2001:db8::1)
17	Ipv6,
18	/// Hostname/domain name (e.g., example.com)
19	Hostname,
20}
21
22impl std::fmt::Display for AddressType {
23	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24		match self {
25			AddressType::Ipv4 => write!(f, "ipv4"),
26			AddressType::Ipv6 => write!(f, "ipv6"),
27			AddressType::Hostname => write!(f, "hostname"),
28		}
29	}
30}
31
32/// Parse and determine the type of an address (IPv4, IPv6, or hostname)
33///
34/// Returns the AddressType if the address is valid, otherwise returns an error
35pub fn parse_address_type(address: &str) -> ClResult<AddressType> {
36	// Try to parse as IPv4
37	if Ipv4Addr::from_str(address).is_ok() {
38		return Ok(AddressType::Ipv4);
39	}
40
41	// Try to parse as IPv6
42	if Ipv6Addr::from_str(address).is_ok() {
43		return Ok(AddressType::Ipv6);
44	}
45
46	// Validate as hostname
47	// Basic hostname validation: must be non-empty, contain only alphanumeric, dots, hyphens, underscores
48	// and must not start or end with a hyphen or dot
49	if address.is_empty() {
50		return Err(Error::ValidationError("Address cannot be empty".to_string()));
51	}
52
53	if address.len() > 253 {
54		return Err(Error::ValidationError("Hostname too long (max 253 characters)".to_string()));
55	}
56
57	// Check valid hostname characters
58	let valid_chars = |c: char| c.is_alphanumeric() || c == '.' || c == '-' || c == '_';
59	if !address.chars().all(valid_chars) {
60		return Err(Error::ValidationError(
61			"Invalid hostname characters (allowed: alphanumeric, dot, hyphen, underscore)"
62				.to_string(),
63		));
64	}
65
66	// Check labels (parts between dots)
67	for label in address.split('.') {
68		if label.is_empty() {
69			return Err(Error::ValidationError("Hostname labels cannot be empty".to_string()));
70		}
71		if label.starts_with('-') || label.ends_with('-') {
72			return Err(Error::ValidationError(
73				"Hostname labels cannot start or end with hyphen".to_string(),
74			));
75		}
76		if label.len() > 63 {
77			return Err(Error::ValidationError(
78				"Hostname label too long (max 63 characters)".to_string(),
79			));
80		}
81	}
82
83	Ok(AddressType::Hostname)
84}
85
86/// Validate that all addresses are the same type
87/// Returns the common type if all match, or an error if they're mixed
88pub fn validate_address_type_consistency(addresses: &[Box<str>]) -> ClResult<Option<AddressType>> {
89	// Empty is fine
90	if addresses.is_empty() {
91		return Ok(None);
92	}
93
94	// Parse first address
95	let first_type = parse_address_type(addresses[0].as_ref())?;
96
97	// Check all subsequent addresses match the first type
98	for (i, addr) in addresses.iter().enumerate().skip(1) {
99		let addr_type = parse_address_type(addr.as_ref())?;
100		if addr_type != first_type {
101			return Err(Error::ValidationError(format!(
102				"Address type mismatch: address[0] is {}, but address[{}] is {}",
103				first_type, i, addr_type
104			)));
105		}
106	}
107
108	Ok(Some(first_type))
109}
110
111#[cfg(test)]
112mod tests {
113	use super::*;
114
115	#[test]
116	fn test_ipv4_detection() {
117		assert_eq!(parse_address_type("192.168.1.1").ok(), Some(AddressType::Ipv4));
118		assert_eq!(parse_address_type("203.0.113.42").ok(), Some(AddressType::Ipv4));
119		assert_eq!(parse_address_type("0.0.0.0").ok(), Some(AddressType::Ipv4));
120		assert_eq!(parse_address_type("255.255.255.255").ok(), Some(AddressType::Ipv4));
121	}
122
123	#[test]
124	fn test_ipv6_detection() {
125		assert_eq!(parse_address_type("2001:db8::1").ok(), Some(AddressType::Ipv6));
126		assert_eq!(parse_address_type("::1").ok(), Some(AddressType::Ipv6));
127		assert_eq!(parse_address_type("::").ok(), Some(AddressType::Ipv6));
128		assert_eq!(parse_address_type("fe80::1").ok(), Some(AddressType::Ipv6));
129	}
130
131	#[test]
132	fn test_hostname_detection() {
133		assert_eq!(parse_address_type("example.com").ok(), Some(AddressType::Hostname));
134		assert_eq!(parse_address_type("server.cloudillo.net").ok(), Some(AddressType::Hostname));
135		assert_eq!(parse_address_type("api-server").ok(), Some(AddressType::Hostname));
136		assert_eq!(parse_address_type("my_server").ok(), Some(AddressType::Hostname));
137	}
138
139	#[test]
140	fn test_hostname_validation_errors() {
141		// Empty
142		assert!(parse_address_type("").is_err());
143
144		// Too long
145		assert!(parse_address_type(&"a".repeat(254)).is_err());
146
147		// Invalid characters
148		assert!(parse_address_type("example.com/path").is_err());
149		assert!(parse_address_type("example@com").is_err());
150
151		// Empty labels
152		assert!(parse_address_type("example..com").is_err());
153		assert!(parse_address_type(".example.com").is_err());
154		assert!(parse_address_type("example.com.").is_err());
155
156		// Label with hyphen at start/end
157		assert!(parse_address_type("-example.com").is_err());
158		assert!(parse_address_type("example.com-").is_err());
159		assert!(parse_address_type("example.-com").is_err());
160
161		// Label too long (>63 chars)
162		assert!(parse_address_type(&format!("{}.com", "a".repeat(64))).is_err());
163	}
164
165	#[test]
166	fn test_address_type_consistency_empty() {
167		let addresses: Vec<Box<str>> = vec![];
168		assert!(validate_address_type_consistency(&addresses).is_ok());
169		assert_eq!(validate_address_type_consistency(&addresses).ok(), Some(None));
170	}
171
172	#[test]
173	fn test_address_type_consistency_single() {
174		let addresses = vec!["192.168.1.1".into()];
175		let result = validate_address_type_consistency(&addresses);
176		assert!(result.is_ok());
177		assert_eq!(result.ok(), Some(Some(AddressType::Ipv4)));
178	}
179
180	#[test]
181	fn test_address_type_consistency_mixed() {
182		let addresses = vec!["192.168.1.1".into(), "2001:db8::1".into()];
183		assert!(validate_address_type_consistency(&addresses).is_err());
184	}
185}
186
187// vim: ts=4