Skip to main content

netscli_core/
common.rs

1use ipnet::Ipv4Net;
2#[cfg(not(windows))]
3use ipnetwork::IpNetwork;
4
5use crate::error::{Error, Result};
6
7pub const DEFAULT_SUBNET: &str = "192.168.1.0/24";
8pub const DEFAULT_PORTS: &[u16] = &[22, 80, 443];
9pub const DEFAULT_CONCURRENCY: usize = 256;
10pub const DEFAULT_SCAN_TIMEOUT_MS: u64 = 500;
11pub const DEFAULT_PING_TIMEOUT_MS: u64 = 1000;
12pub const DEFAULT_DNS_TIMEOUT_MS: u64 = 1500;
13
14/// Lenient parser used by internal defaults — silently drops invalid tokens.
15/// Prefer `parse_ports_checked` for user input so typos are surfaced.
16pub fn parse_ports(input: Option<&str>) -> Option<Vec<u16>> {
17    input.map(|s| {
18        s.split(',')
19            .filter_map(|part| parse_port_token(part).ok())
20            .flatten()
21            .collect()
22    })
23}
24
25fn parse_port_token(part: &str) -> Result<Vec<u16>> {
26    let part = part.trim();
27    if part.is_empty() {
28        return Ok(Vec::new());
29    }
30
31    if let Some((start, end)) = part.split_once('-') {
32        let start_trim = start.trim();
33        let end_trim = end.trim();
34        let s: u16 = start_trim
35            .parse()
36            .map_err(|_| Error::invalid_input(format!("invalid port in range: '{start_trim}'")))?;
37        let e: u16 = end_trim
38            .parse()
39            .map_err(|_| Error::invalid_input(format!("invalid port in range: '{end_trim}'")))?;
40        if s > e {
41            return Err(Error::invalid_input(format!(
42                "inverted port range: {s}-{e}"
43            )));
44        }
45        Ok((s..=e).collect())
46    } else {
47        let port: u16 = part
48            .parse()
49            .map_err(|_| Error::invalid_input(format!("invalid port: '{part}'")))?;
50        Ok(vec![port])
51    }
52}
53
54/// Strict parser for user input — rejects any malformed token instead of
55/// silently discarding it. `Some(None)` means the input was absent/empty;
56/// `Err(Error::InvalidInput(..))` means the user typed something that
57/// couldn't be interpreted.
58pub fn parse_ports_checked(input: Option<&str>) -> Result<Option<Vec<u16>>> {
59    let Some(raw) = input else {
60        return Ok(None);
61    };
62
63    let raw = raw.trim();
64    if raw.is_empty() {
65        return Ok(None);
66    }
67
68    let mut ports = Vec::new();
69    for part in raw.split(',') {
70        let mut expanded = parse_port_token(part).map_err(|e| {
71            // Wrap the inner parser's per-token message with the caller's
72            // context so consumers see both "why" and "where" in one Error.
73            Error::invalid_input(format!("Invalid port list '{raw}': {e}"))
74        })?;
75        ports.append(&mut expanded);
76    }
77    if ports.is_empty() {
78        return Err(Error::invalid_input(format!("Invalid port list: {raw}")));
79    }
80    // Sort + dedup so downstream scanners don't probe the same port twice.
81    ports.sort_unstable();
82    ports.dedup();
83    Ok(Some(ports))
84}
85
86#[cfg(not(windows))]
87pub fn detect_default_ipv4_subnet() -> Option<Ipv4Net> {
88    // Prefer non-tunnel interfaces (vpn/wireguard adapters often sort first
89    // on laptops, and scanning the VPN subnet is almost never what the user
90    // wants). We iterate ALL qualifying interfaces and return the first one
91    // that actually has an IPv4 address — skipping IPv6-only interfaces that
92    // would otherwise fail silently.
93    let mut interfaces: Vec<_> = pnet_datalink::interfaces()
94        .into_iter()
95        .filter(|i| i.is_up() && !i.is_loopback())
96        .collect();
97    interfaces.sort_by_key(|i| interface_preference_rank(&i.name));
98
99    for iface in interfaces {
100        for ip in iface.ips {
101            if let IpNetwork::V4(v4) = ip {
102                if let Ok(net) = Ipv4Net::new(v4.ip(), v4.prefix()) {
103                    // `.trunc()` drops any host bits so we return the network
104                    // address regardless of whether the interface IP is a host.
105                    return Some(net.trunc());
106                }
107            }
108        }
109    }
110
111    None
112}
113
114/// Lower rank = more preferred. Tunnel/VPN interfaces are pushed to the back
115/// so ordinary ethernet/wifi wins on multi-adapter hosts.
116#[cfg(not(windows))]
117fn interface_preference_rank(name: &str) -> u8 {
118    const TUNNEL_PREFIXES: &[&str] = &["tun", "tap", "wg", "utun", "zt", "ppp", "gif", "ipsec"];
119    // Lowercase once per call rather than per-comparison.
120    let lowered = name.to_ascii_lowercase();
121    if TUNNEL_PREFIXES.iter().any(|p| lowered.starts_with(p)) {
122        2
123    } else {
124        0
125    }
126}
127
128#[cfg(windows)]
129pub fn detect_default_ipv4_subnet() -> Option<Ipv4Net> {
130    use ipconfig::{IfType, OperStatus};
131    use std::net::IpAddr;
132
133    let adapters = ipconfig::get_adapters().ok()?;
134    for adapter in adapters {
135        if adapter.oper_status() != OperStatus::IfOperStatusUp {
136            continue;
137        }
138        if adapter.if_type() == IfType::SoftwareLoopback {
139            continue;
140        }
141        for (prefix, len) in adapter.prefixes() {
142            if let IpAddr::V4(v4) = *prefix {
143                let prefix_len = (*len).min(32) as u8;
144                if let Ok(net) = Ipv4Net::new(v4, prefix_len) {
145                    return Some(net);
146                }
147            }
148        }
149    }
150
151    None
152}
153
154#[cfg(not(windows))]
155pub fn detect_default_ipv4_addr() -> Option<std::net::Ipv4Addr> {
156    let iface = pnet_datalink::interfaces()
157        .into_iter()
158        .find(|i| i.is_up() && !i.is_loopback())?;
159
160    for ip in iface.ips {
161        if let IpNetwork::V4(v4) = ip {
162            return Some(v4.ip());
163        }
164    }
165
166    None
167}
168
169#[cfg(windows)]
170pub fn detect_default_ipv4_addr() -> Option<std::net::Ipv4Addr> {
171    use ipconfig::{IfType, OperStatus};
172    use std::net::IpAddr;
173
174    let adapters = ipconfig::get_adapters().ok()?;
175    for adapter in adapters {
176        if adapter.oper_status() != OperStatus::IfOperStatusUp {
177            continue;
178        }
179        if adapter.if_type() == IfType::SoftwareLoopback {
180            continue;
181        }
182        for ip in adapter.ip_addresses() {
183            if let IpAddr::V4(v4) = *ip {
184                return Some(v4);
185            }
186        }
187    }
188
189    None
190}
191
192pub fn default_ipv4_subnet_string() -> String {
193    detect_default_ipv4_subnet()
194        .map(|n| format!("{}/{}", n.network(), n.prefix_len()))
195        .unwrap_or_else(|| DEFAULT_SUBNET.to_string())
196}
197
198pub fn default_ports() -> Vec<u16> {
199    DEFAULT_PORTS.to_vec()
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn test_parse_ports_single() {
208        assert_eq!(parse_ports(Some("80")), Some(vec![80]));
209        assert_eq!(parse_ports(Some("443")), Some(vec![443]));
210    }
211
212    #[test]
213    fn test_parse_ports_comma_separated() {
214        assert_eq!(parse_ports(Some("80,443,8080")), Some(vec![80, 443, 8080]));
215        assert_eq!(parse_ports(Some("22,80,443")), Some(vec![22, 80, 443]));
216    }
217
218    #[test]
219    fn test_parse_ports_range() {
220        assert_eq!(parse_ports(Some("80-82")), Some(vec![80, 81, 82]));
221        assert_eq!(parse_ports(Some("22-24")), Some(vec![22, 23, 24]));
222    }
223
224    #[test]
225    fn test_parse_ports_mixed() {
226        assert_eq!(
227            parse_ports(Some("80,443,8080-8082")),
228            Some(vec![80, 443, 8080, 8081, 8082])
229        );
230    }
231
232    #[test]
233    fn test_parse_ports_with_spaces() {
234        assert_eq!(
235            parse_ports(Some("80, 443, 8080")),
236            Some(vec![80, 443, 8080])
237        );
238        assert_eq!(parse_ports(Some("80 - 82")), Some(vec![80, 81, 82]));
239    }
240
241    #[test]
242    fn test_parse_ports_none() {
243        assert_eq!(parse_ports(None), None);
244    }
245
246    #[test]
247    fn test_parse_ports_invalid_lenient() {
248        // Lenient `parse_ports` silently drops invalid tokens — that's
249        // intentional for internal defaults. User input goes through
250        // `parse_ports_checked` which is strict.
251        assert_eq!(parse_ports(Some("invalid")), Some(vec![]));
252        assert_eq!(parse_ports(Some("80,invalid,443")), Some(vec![80, 443]));
253    }
254
255    #[test]
256    fn test_parse_ports_checked_invalid() {
257        let err = parse_ports_checked(Some("invalid")).unwrap_err();
258        assert!(err.to_string().contains("Invalid port list"));
259    }
260
261    #[test]
262    fn test_parse_ports_checked_rejects_typo() {
263        // Previously this silently returned [80,443]. Typos are now surfaced.
264        let err = parse_ports_checked(Some("80,invalid,443")).unwrap_err();
265        assert!(
266            err.to_string().contains("Invalid port list"),
267            "expected strict rejection, got: {err}"
268        );
269    }
270
271    #[test]
272    fn test_parse_ports_checked_rejects_out_of_range() {
273        let err = parse_ports_checked(Some("22,99999")).unwrap_err();
274        assert!(err.to_string().contains("Invalid port list"));
275    }
276
277    #[test]
278    fn test_parse_ports_checked_rejects_inverted_range() {
279        let err = parse_ports_checked(Some("90-80")).unwrap_err();
280        assert!(err.to_string().contains("Invalid port list"));
281    }
282
283    #[test]
284    fn test_parse_ports_checked_empty() {
285        assert_eq!(parse_ports_checked(Some("  ")).unwrap(), None);
286    }
287
288    #[test]
289    fn test_parse_ports_checked_sorts_and_dedupes() {
290        assert_eq!(
291            parse_ports_checked(Some("443,80,22,80,22")).unwrap(),
292            Some(vec![22, 80, 443])
293        );
294    }
295
296    #[test]
297    fn test_parse_ports_checked_valid() {
298        assert_eq!(
299            parse_ports_checked(Some("80,443")).unwrap(),
300            Some(vec![80, 443])
301        );
302    }
303}