Skip to main content

zond_engine/core/parse/
ip.rs

1// Copyright (c) 2026 Erik Lening (hollowpointer) and Contributors
2//
3// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
4// If a copy of the MPL was not distributed with this file, You can obtain one at
5// https://mozilla.org/MPL/2.0/.
6
7//! # Network Target Parser
8//!
9//! This module provides the logic to resolve abstract input strings into a concrete,
10//! deduplicated [`IpSet`]. It acts as the translation layer between user intent
11//! (CLI arguments, configuration strings) and the underlying network models.
12//!
13//! ## Supported Formats
14//!
15//! The parser recognizes several distinct IPv4 formats:
16//!
17//! * **Single IP**: Standard dotted-decimal notation (e.g., `127.0.0.1`).
18//! * **CIDR Block**: Network address with a prefix length (e.g., `192.168.1.0/24`).
19//! * **Explicit Range**: Two full IPs separated by a hyphen (e.g., `10.0.0.1-10.0.0.50`).
20//! * **Shortened Range**: An IP followed by a hyphen and a partial suffix (e.g., `10.0.0.1-50` or `192.168.1.1-2.254`).
21//! * **Keywords**: Special identifiers like `lan`, which resolve dynamically based on the host's active interface.
22//!
23//! ## Merging Behavior
24//!
25//! All inputs are resolved into an [`IpSet`]. The parser ensures that overlapping
26//! or adjacent inputs are merged into contiguous ranges to optimize scanning performance.
27
28use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
29use std::sync::atomic::AtomicBool;
30use thiserror::Error;
31
32use crate::core::models::ip::range::{IpError, Ipv4Range, IpRange};
33use crate::core::models::ip::set::IpSet;
34use crate::success;
35
36/// Global indicator set to `true` if a "lan" resolution was successfully performed.
37pub static IS_LAN_SCAN: AtomicBool = AtomicBool::new(false);
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Keyword {
41    Lan,
42    Vpn,
43}
44
45impl Keyword {
46    pub fn as_str(&self) -> &'static str {
47        match self {
48            Keyword::Lan => "lan",
49            Keyword::Vpn => "vpn",
50        }
51    }
52}
53
54/// Errors encountered during the parsing or resolution of IP-related strings.
55#[derive(Debug, Error, PartialEq, Eq)]
56pub enum IpParseError {
57    /// The provided CIDR prefix is outside the valid IPv4 range of 0-32.
58    #[error("Invalid CIDR prefix: {0} (must be 0-32)")]
59    InvalidPrefix(u8),
60
61    /// The start address of a range is numerically higher than the end address.
62    #[error("Invalid range: start address {0} is greater than end address {1}")]
63    InvalidRange(IpAddr, IpAddr),
64
65    /// The input string does not match any known IP, Range, or CIDR format.
66    #[error("Malformed IP or range string: '{0}'")]
67    Malformed(String),
68
69    /// Failed to retrieve local interface information for "lan" resolution.
70    #[error("Could not resolve LAN interface: {0}")]
71    LanError(String),
72
73    /// Wrapper for underlying network library or calculation failures.
74    #[error("Network error: {0}")]
75    NetworkError(String),
76
77    /// The provided input resulted in zero valid IP addresses.
78    #[error("Target input resulted in an empty set")]
79    EmptySet,
80}
81
82/// Resolves a collection of input strings into a consolidated [`IpSet`].
83///
84/// Handles whitespace trimming, comma-separated lists, and individual item parsing.
85///
86/// # Arguments
87///
88/// * `ips` - A slice of string-like objects representing scan targets.
89///
90/// # Errors
91///
92/// Returns an [`IpParseError`] if any component fails to parse or if the final set
93/// is empty.
94///
95/// # Examples
96///
97/// ```
98/// use crate::core::parse::ip::{to_set, Keyword};
99/// use crate::core::models::ip::set::IpSet;
100///
101/// let ips = vec!["192.168.1.0/24", "10.0.0.1", "10.0.0.5-10"];
102/// let set = to_set(&ips, None).unwrap();
103///
104/// // /24 (256) + single (1) + range 5-10 (6) = 263
105/// assert_eq!(set.len(), 263);
106/// ```
107pub type ResolverFn = fn(Keyword, &mut IpSet) -> Result<(), IpParseError>;
108
109pub fn to_set<S>(ips: &[S], resolver: Option<ResolverFn>) -> Result<IpSet, IpParseError>
110where
111    S: AsRef<str>,
112{
113    let mut set = IpSet::new();
114
115    for ip in ips {
116        let s = ip.as_ref().trim();
117        if s.is_empty() {
118            continue;
119        }
120
121        for part in s.split(',').map(|p| p.trim()).filter(|p| !p.is_empty()) {
122            parse_and_insert(part, &mut set, resolver)?;
123        }
124    }
125
126    if set.is_empty() {
127        return Err(IpParseError::EmptySet);
128    }
129
130    let len = set.len();
131    let suffix = if len == 1 { "" } else { "es" };
132    success!("{len} IP address{suffix} resolved successfully");
133
134    Ok(set)
135}
136
137/// Identifies the format of a single target string and inserts it into the set.
138fn parse_and_insert(s: &str, set: &mut IpSet, resolver: Option<ResolverFn>) -> Result<(), IpParseError> {
139    if s.contains('/') {
140        let range = parse_cidr(s)?;
141        set.insert_range(range);
142        return Ok(());
143    }
144
145    if s.contains('-') {
146        let range = parse_range(s)?;
147        set.insert_range(range);
148        return Ok(());
149    }
150
151    if s.eq_ignore_ascii_case(Keyword::Lan.as_str()) {
152        if let Some(r) = resolver {
153            return r(Keyword::Lan, set);
154        } else {
155            return Err(IpParseError::LanError("LAN keyword used but no resolver provided".into()));
156        }
157    }
158
159    let ip = s
160        .parse::<IpAddr>()
161        .map_err(|_| IpParseError::Malformed(s.to_string()))?;
162    set.insert(ip);
163
164    Ok(())
165}
166
167/// Parses hyphenated range strings into an [`IpRange`].
168fn parse_range(s: &str) -> Result<IpRange, IpParseError> {
169    let (start_str, end_str) = s
170        .split_once('-')
171        .ok_or_else(|| IpParseError::Malformed(s.into()))?;
172
173    let start_addr = start_str
174        .parse::<IpAddr>()
175        .map_err(|_| IpParseError::Malformed(s.into()))?;
176
177    match start_addr {
178        IpAddr::V4(start_v4) => {
179            let end_v4 = if let Ok(addr) = end_str.parse::<Ipv4Addr>() {
180                addr
181            } else {
182                let mut octets = start_v4.octets();
183                let parts: Vec<u8> = end_str
184                    .split('.')
185                    .map(|p| p.parse::<u8>())
186                    .collect::<Result<Vec<u8>, _>>()
187                    .map_err(|_| IpParseError::Malformed(s.into()))?;
188
189                if parts.is_empty() || parts.len() > 4 {
190                    return Err(IpParseError::Malformed(s.into()));
191                }
192
193                let offset = 4 - parts.len();
194                octets[offset..].copy_from_slice(&parts);
195                Ipv4Addr::from(octets)
196            };
197            Ipv4Range::new(start_v4, end_v4)
198                .map(IpRange::V4)
199                .map_err(map_range_error)
200        }
201        IpAddr::V6(start_v6) => {
202            let end_v6 = end_str
203                .parse::<Ipv6Addr>()
204                .map_err(|_| IpParseError::Malformed(s.into()))?;
205            crate::core::models::ip::range::Ipv6Range::new(start_v6, end_v6)
206                .map(IpRange::V6)
207                .map_err(map_range_error)
208        }
209    }
210}
211
212/// Parses CIDR notation strings into an [`IpRange`].
213fn parse_cidr(s: &str) -> Result<IpRange, IpParseError> {
214    let (ip_str, prefix_str) = s
215        .split_once('/')
216        .ok_or_else(|| IpParseError::Malformed(s.into()))?;
217
218    let ip = ip_str
219        .parse::<IpAddr>()
220        .map_err(|_| IpParseError::Malformed(s.into()))?;
221
222    let prefix = prefix_str
223        .parse::<u8>()
224        .map_err(|_| IpParseError::Malformed(s.into()))?;
225
226    crate::core::models::ip::range::cidr_range(ip, prefix).map_err(map_range_error)
227}
228
229fn map_range_error(e: IpError) -> IpParseError {
230    match e {
231        IpError::InvalidRange(s, e) => IpParseError::InvalidRange(s, e),
232        IpError::InvalidPrefix(p) => IpParseError::InvalidPrefix(p),
233        IpError::NetworkError(msg) => IpParseError::NetworkError(msg),
234        _ => IpParseError::Malformed("Invalid IP range".into()),
235    }
236}
237
238// ╔════════════════════════════════════════════╗
239// ║ ████████╗███████╗███████╗████████╗███████╗ ║
240// ║ ╚══██╔══╝██╔════╝██╔════╝╚══██╔══╝██╔════╝ ║
241// ║    ██║   █████╗  ███████╗   ██║   ███████╗ ║
242// ║    ██║   ██╔══╝  ╚════██║   ██║   ╚════██║ ║
243// ║    ██║   ███████╗███████║   ██║   ███████║ ║
244// ║    ╚═╝   ╚══════╝╚══════╝   ╚═╝   ╚══════╝ ║
245// ╚════════════════════════════════════════════╝
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use std::net::Ipv4Addr;
251
252    #[test]
253    fn to_set_basic_single() {
254        let input = vec!["192.168.1.1"];
255        let set = to_set(&input, None).expect("Should parse single IP");
256        assert_eq!(set.len(), 1);
257        assert!(set.contains(&IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))));
258    }
259
260    #[test]
261    fn to_set_comma_separated() {
262        let input = vec!["10.0.0.1, 10.0.0.2, 10.0.0.5"];
263        let set = to_set(&input, None).expect("Should parse comma list");
264        assert_eq!(set.len(), 3);
265        assert!(set.contains(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
266    }
267
268    #[test]
269    fn parse_cidr_blocks() {
270        let input = vec!["172.16.0.0/24"];
271        let set = to_set(&input, None).expect("Should parse CIDR");
272        assert_eq!(set.len(), 256);
273    }
274
275    #[test]
276    fn parse_short_range_suffix() {
277        let input = vec!["192.168.1.250-2.10"];
278        let set = to_set(&input, None).unwrap();
279        assert_eq!(set.len(), 17);
280    }
281
282    #[test]
283    fn error_invalid_cidr() {
284        let input = vec!["192.168.1.1/33"];
285        let result = to_set(&input, None);
286        assert_eq!(result.unwrap_err(), IpParseError::InvalidPrefix(33));
287    }
288
289    #[test]
290    fn error_invalid_range_order() {
291        let input = vec!["10.0.0.10-1"];
292        let result = to_set(&input, None);
293        assert!(matches!(result, Err(IpParseError::InvalidRange(_, _))));
294    }
295
296    #[test]
297    fn empty_input_error() {
298        let input: Vec<&str> = vec!["", " "];
299        let result = to_set(&input, None);
300        assert_eq!(result.unwrap_err(), IpParseError::EmptySet);
301    }
302}