strat9_abi/ip.rs
1//! IPv4/IPv6 literal parsing helpers.
2//!
3//! These functions parse IP address literals from strings into byte arrays
4//! suitable for network operations. They handle both dotted-decimal (IPv4)
5//! and colon-hex (IPv6) formats.
6//!
7//! # Examples
8//!
9//! ```ignore
10//! use strat9_abi::ip::{parse_ipv4_literal, parse_ipv6_literal};
11//!
12//! // IPv4
13//! assert_eq!(parse_ipv4_literal("192.168.1.10"), Some([192, 168, 1, 10]));
14//! assert_eq!(parse_ipv4_literal("10.0.0.1"), Some([10, 0, 0, 1]));
15//! assert_eq!(parse_ipv4_literal("256.1.1.1"), None); // octet > 255
16//!
17//! // IPv6
18//! assert_eq!(
19//! parse_ipv6_literal("::1"),
20//! Some([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1])
21//! );
22//! assert_eq!(
23//! parse_ipv6_literal("fe80::1"),
24//! Some([0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1])
25//! );
26//! ```
27
28/// Returns true when the input looks like a dotted IPv4 literal candidate.
29///
30/// This is a fast pre-filter: it checks that the string contains only
31/// digits and dots, and has at least one dot. It does NOT validate the
32/// actual address format — use [`parse_ipv4_literal`] for that.
33///
34/// # Example
35///
36/// ```ignore
37/// assert!(is_ipv4_literal_candidate("192.168.1.1"));
38/// assert!(!is_ipv4_literal_candidate("hello"));
39/// assert!(is_ipv4_literal_candidate("999.999.999.999")); // passes filter, fails parse
40/// ```
41pub fn is_ipv4_literal_candidate(s: &str) -> bool {
42 let bytes = s.as_bytes();
43 !bytes.is_empty()
44 && bytes.iter().all(|b| b.is_ascii_digit() || *b == b'.')
45 && bytes.contains(&b'.')
46}
47
48/// Returns true when the input looks like an IPv6 literal candidate.
49///
50/// This is a fast pre-filter: it checks that the string contains only
51/// hex digits, colons, and dots, and has at least one colon.
52///
53/// # Limitations
54///
55/// - IPv4-mapped/embedded forms (e.g. `::ffff:192.168.1.1`) are **not** recognised
56/// as candidates: the embedded decimal octets are not valid hex digits.
57/// - Zone IDs (e.g. `fe80::1%eth0`) are **not** recognised: `%` is not in the
58/// allowed character set.
59pub fn is_ipv6_literal_candidate(s: &str) -> bool {
60 let bytes = s.as_bytes();
61 !bytes.is_empty()
62 && bytes
63 .iter()
64 .all(|b| b.is_ascii_hexdigit() || *b == b':' || *b == b'.')
65 && bytes.contains(&b':')
66}
67
68/// Parses an IPv4 literal into network-order octets.
69///
70/// Input must be in standard dotted-decimal format: `A.B.C.D` where
71/// each octet is 0-255.
72///
73/// Returns `None` if the format is invalid.
74///
75/// # Example
76///
77/// ```ignore
78/// assert_eq!(parse_ipv4_literal("192.168.1.10"), Some([192, 168, 1, 10]));
79/// assert_eq!(parse_ipv4_literal("10.0.0.1"), Some([10, 0, 0, 1]));
80/// assert_eq!(parse_ipv4_literal("0.0.0.0"), Some([0, 0, 0, 0]));
81/// assert_eq!(parse_ipv4_literal("255.255.255.255"), Some([255, 255, 255, 255]));
82///
83/// // Invalid formats
84/// assert_eq!(parse_ipv4_literal(""), None);
85/// assert_eq!(parse_ipv4_literal("192.168.1"), None); // too few octets
86/// assert_eq!(parse_ipv4_literal("192.168.1.1.1"), None); // too many octets
87/// assert_eq!(parse_ipv4_literal("256.1.1.1"), None); // octet > 255
88/// assert_eq!(parse_ipv4_literal("abc.def.ghi.jkl"), None); // non-numeric
89/// ```
90pub fn parse_ipv4_literal(s: &str) -> Option<[u8; 4]> {
91 let mut octets = [0u8; 4];
92 let mut idx = 0usize;
93 let mut val: u16 = 0;
94 let mut has_digit = false;
95
96 for &b in s.as_bytes() {
97 if b == b'.' {
98 if !has_digit || idx >= 3 || val > 255 {
99 return None;
100 }
101 octets[idx] = val as u8;
102 idx += 1;
103 val = 0;
104 has_digit = false;
105 continue;
106 }
107
108 if !b.is_ascii_digit() {
109 return None;
110 }
111
112 val = val.checked_mul(10)?.checked_add((b - b'0') as u16)?;
113 has_digit = true;
114 }
115
116 if !has_digit || idx != 3 || val > 255 {
117 return None;
118 }
119
120 octets[3] = val as u8;
121 Some(octets)
122}
123
124/// Parses a hex string of 1-4 digits into a `u16`.
125fn parse_hex_u16(s: &str) -> Option<u16> {
126 if s.is_empty() || s.len() > 4 {
127 return None;
128 }
129
130 let mut value = 0u16;
131 for &b in s.as_bytes() {
132 let digit = match b {
133 b'0'..=b'9' => (b - b'0') as u16,
134 b'a'..=b'f' => 10 + (b - b'a') as u16,
135 b'A'..=b'F' => 10 + (b - b'A') as u16,
136 _ => return None,
137 };
138 value = value.checked_mul(16)?.checked_add(digit)?;
139 }
140
141 Some(value)
142}
143
144/// Parses an IPv6 literal into network-order octets.
145///
146/// Supports standard colon-hex notation (RFC 4291) with `::` compression.
147///
148/// # Limitations
149///
150/// - IPv4-mapped/embedded forms (e.g. `::ffff:192.168.1.1`) are **not** supported;
151/// only pure colon-hex notation is accepted.
152/// - Zone IDs (e.g. `fe80::1%eth0`) are **not** supported; the zone identifier
153/// must be stripped before calling this function.
154///
155/// # Example
156///
157/// ```ignore
158/// assert_eq!(
159/// parse_ipv6_literal("2001:0db8:0000:0000:0000:ff00:0042:8329"),
160/// Some([
161/// 0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, 0x00, 0x00,
162/// 0x00, 0x00, 0xff, 0x00, 0x00, 0x42, 0x83, 0x29,
163/// ])
164/// );
165///
166/// // Compressed forms
167/// assert_eq!(
168/// parse_ipv6_literal("::1"),
169/// Some([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1])
170/// );
171/// assert_eq!(
172/// parse_ipv6_literal("fe80::5054:ff:fe12:3456"),
173/// Some([
174/// 0xfe, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
175/// 0x50, 0x54, 0x00, 0xff, 0xfe, 0x12, 0x34, 0x56,
176/// ])
177/// );
178///
179/// // Loopback
180/// assert_eq!(
181/// parse_ipv6_literal("::1"),
182/// Some([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1])
183/// );
184/// ```
185pub fn parse_ipv6_literal(s: &str) -> Option<[u8; 16]> {
186 let mut groups = [0u16; 8];
187
188 if let Some(dc) = s.find("::") {
189 let left = &s[..dc];
190 let right = &s[dc + 2..];
191 let mut left_count = 0usize;
192 if !left.is_empty() {
193 for part in left.split(':') {
194 if left_count >= 8 {
195 return None;
196 }
197 groups[left_count] = parse_hex_u16(part)?;
198 left_count += 1;
199 }
200 }
201
202 let mut right_groups = [0u16; 8];
203 let mut right_count = 0usize;
204 if !right.is_empty() {
205 for part in right.split(':') {
206 if right_count >= 8 {
207 return None;
208 }
209 right_groups[right_count] = parse_hex_u16(part)?;
210 right_count += 1;
211 }
212 }
213
214 if left_count + right_count > 8 {
215 return None;
216 }
217
218 let start = 8 - right_count;
219 for i in 0..right_count {
220 groups[start + i] = right_groups[i];
221 }
222 } else {
223 let mut count = 0usize;
224 for part in s.split(':') {
225 if count >= 8 {
226 return None;
227 }
228 groups[count] = parse_hex_u16(part)?;
229 count += 1;
230 }
231 if count != 8 {
232 return None;
233 }
234 }
235
236 let mut octets = [0u8; 16];
237 for (idx, &group) in groups.iter().enumerate() {
238 octets[2 * idx] = (group >> 8) as u8;
239 octets[2 * idx + 1] = (group & 0xFF) as u8;
240 }
241
242 Some(octets)
243}
244
245#[cfg(test)]
246mod tests {
247 use super::{parse_ipv4_literal, parse_ipv6_literal};
248
249 #[test]
250 fn parses_ipv4_literal() {
251 assert_eq!(parse_ipv4_literal("192.168.1.10"), Some([192, 168, 1, 10]));
252 }
253
254 #[test]
255 fn parses_full_ipv6_literal() {
256 assert_eq!(
257 parse_ipv6_literal("2001:0db8:0000:0000:0000:ff00:0042:8329"),
258 Some([
259 0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x00, 0x00,
260 0x42, 0x83, 0x29,
261 ])
262 );
263 }
264
265 #[test]
266 fn parses_compressed_ipv6_literal() {
267 assert_eq!(
268 parse_ipv6_literal("fe80::5054:ff:fe12:3456"),
269 Some([
270 0xfe, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x50, 0x54, 0x00, 0xff, 0xfe,
271 0x12, 0x34, 0x56,
272 ])
273 );
274 }
275
276 #[test]
277 fn rejects_multiple_double_colons() {
278 assert_eq!(parse_ipv6_literal("fe80::5054::ff::fe12::3456"), None);
279 }
280}