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, IpRange, Ipv4Range};
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 zond_engine::core::parse::ip::{to_set, Keyword};
99/// use zond_engine::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(
139    s: &str,
140    set: &mut IpSet,
141    resolver: Option<ResolverFn>,
142) -> Result<(), IpParseError> {
143    if s.contains('/') {
144        let range = parse_cidr(s)?;
145        set.insert_range(range);
146        return Ok(());
147    }
148
149    if s.contains('-') {
150        let range = parse_range(s)?;
151        set.insert_range(range);
152        return Ok(());
153    }
154
155    if s.eq_ignore_ascii_case(Keyword::Lan.as_str()) {
156        if let Some(r) = resolver {
157            return r(Keyword::Lan, set);
158        } else {
159            return Err(IpParseError::LanError(
160                "LAN keyword used but no resolver provided".into(),
161            ));
162        }
163    }
164
165    let ip = s
166        .parse::<IpAddr>()
167        .map_err(|_| IpParseError::Malformed(s.to_string()))?;
168    set.insert(ip);
169
170    Ok(())
171}
172
173/// Parses hyphenated range strings into an [`IpRange`].
174fn parse_range(s: &str) -> Result<IpRange, IpParseError> {
175    let (start_str, end_str) = s
176        .split_once('-')
177        .ok_or_else(|| IpParseError::Malformed(s.into()))?;
178
179    let start_addr = start_str
180        .parse::<IpAddr>()
181        .map_err(|_| IpParseError::Malformed(s.into()))?;
182
183    match start_addr {
184        IpAddr::V4(start_v4) => {
185            let end_v4 = if let Ok(addr) = end_str.parse::<Ipv4Addr>() {
186                addr
187            } else {
188                let mut octets = start_v4.octets();
189                let parts: Vec<u8> = end_str
190                    .split('.')
191                    .map(|p| p.parse::<u8>())
192                    .collect::<Result<Vec<u8>, _>>()
193                    .map_err(|_| IpParseError::Malformed(s.into()))?;
194
195                if parts.is_empty() || parts.len() > 4 {
196                    return Err(IpParseError::Malformed(s.into()));
197                }
198
199                let offset = 4 - parts.len();
200                octets[offset..].copy_from_slice(&parts);
201                Ipv4Addr::from(octets)
202            };
203            Ipv4Range::new(start_v4, end_v4)
204                .map(IpRange::V4)
205                .map_err(map_range_error)
206        }
207        IpAddr::V6(start_v6) => {
208            let end_v6 = end_str
209                .parse::<Ipv6Addr>()
210                .map_err(|_| IpParseError::Malformed(s.into()))?;
211            crate::core::models::ip::range::Ipv6Range::new(start_v6, end_v6)
212                .map(IpRange::V6)
213                .map_err(map_range_error)
214        }
215    }
216}
217
218/// Parses CIDR notation strings into an [`IpRange`].
219fn parse_cidr(s: &str) -> Result<IpRange, IpParseError> {
220    let (ip_str, prefix_str) = s
221        .split_once('/')
222        .ok_or_else(|| IpParseError::Malformed(s.into()))?;
223
224    let ip = ip_str
225        .parse::<IpAddr>()
226        .map_err(|_| IpParseError::Malformed(s.into()))?;
227
228    let prefix = prefix_str
229        .parse::<u8>()
230        .map_err(|_| IpParseError::Malformed(s.into()))?;
231
232    crate::core::models::ip::range::cidr_range(ip, prefix).map_err(map_range_error)
233}
234
235fn map_range_error(e: IpError) -> IpParseError {
236    match e {
237        IpError::InvalidRange(s, e) => IpParseError::InvalidRange(s, e),
238        IpError::InvalidPrefix(p) => IpParseError::InvalidPrefix(p),
239        IpError::NetworkError(msg) => IpParseError::NetworkError(msg),
240        _ => IpParseError::Malformed("Invalid IP range".into()),
241    }
242}
243
244// ╔════════════════════════════════════════════╗
245// ║ ████████╗███████╗███████╗████████╗███████╗ ║
246// ║ ╚══██╔══╝██╔════╝██╔════╝╚══██╔══╝██╔════╝ ║
247// ║    ██║   █████╗  ███████╗   ██║   ███████╗ ║
248// ║    ██║   ██╔══╝  ╚════██║   ██║   ╚════██║ ║
249// ║    ██║   ███████╗███████║   ██║   ███████║ ║
250// ║    ╚═╝   ╚══════╝╚══════╝   ╚═╝   ╚══════╝ ║
251// ╚════════════════════════════════════════════╝
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use std::net::Ipv4Addr;
257
258    #[test]
259    fn to_set_basic_single() {
260        let input = vec!["192.168.1.1"];
261        let set = to_set(&input, None).expect("Should parse single IP");
262        assert_eq!(set.len(), 1);
263        assert!(set.contains(&IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))));
264    }
265
266    #[test]
267    fn to_set_comma_separated() {
268        let input = vec!["10.0.0.1, 10.0.0.2, 10.0.0.5"];
269        let set = to_set(&input, None).expect("Should parse comma list");
270        assert_eq!(set.len(), 3);
271        assert!(set.contains(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
272    }
273
274    #[test]
275    fn parse_cidr_blocks() {
276        let input = vec!["172.16.0.0/24"];
277        let set = to_set(&input, None).expect("Should parse CIDR");
278        assert_eq!(set.len(), 256);
279    }
280
281    #[test]
282    fn parse_short_range_suffix() {
283        let input = vec!["192.168.1.250-2.10"];
284        let set = to_set(&input, None).unwrap();
285        assert_eq!(set.len(), 17);
286    }
287
288    #[test]
289    fn error_invalid_cidr() {
290        let input = vec!["192.168.1.1/33"];
291        let result = to_set(&input, None);
292        assert_eq!(result.unwrap_err(), IpParseError::InvalidPrefix(33));
293    }
294
295    #[test]
296    fn error_invalid_range_order() {
297        let input = vec!["10.0.0.10-1"];
298        let result = to_set(&input, None);
299        assert!(matches!(result, Err(IpParseError::InvalidRange(_, _))));
300    }
301
302    #[test]
303    fn empty_input_error() {
304        let input: Vec<&str> = vec!["", " "];
305        let result = to_set(&input, None);
306        assert_eq!(result.unwrap_err(), IpParseError::EmptySet);
307    }
308}