Skip to main content

netscli_core/common/ports/
mod.rs

1use std::collections::BTreeSet;
2
3use super::constants::DEFAULT_PORTS;
4use crate::error::{Error, Result};
5
6pub const MAX_PORTS_PER_SCAN: usize = 4096;
7
8/// Validate an already-parsed port list.
9///
10/// Lives here, next to `MAX_PORTS_PER_SCAN`, so every interface answers the
11/// same way. The CLI and GUI reach it through `parse_ports_checked`; the MCP
12/// server and any library consumer reach it through `Ops`, which validates
13/// whatever it is handed rather than trusting the caller to have parsed it
14/// from a string.
15///
16/// Port 0 is rejected. It is not a connectable TCP port — in a `bind` it
17/// means "pick any free port", and in a `connect` it is meaningless. The MCP
18/// server has always rejected it while the CLI happily reported
19/// "Scanned 1 port (0 open)", which is the divergence this function exists
20/// to remove.
21pub fn validate_ports(ports: &[u16]) -> Result<()> {
22    if ports.contains(&0) {
23        return Err(Error::invalid_input(
24            "port 0 is invalid (not a connectable TCP port)",
25        ));
26    }
27    if ports.len() > MAX_PORTS_PER_SCAN {
28        return Err(Error::invalid_input(format!(
29            "too many ports requested ({} > {})",
30            ports.len(),
31            MAX_PORTS_PER_SCAN
32        )));
33    }
34    Ok(())
35}
36
37/// Lenient parser used by internal defaults — silently drops invalid tokens.
38/// Prefer `parse_ports_checked` for user input so typos are surfaced.
39pub fn parse_ports(input: Option<&str>) -> Option<Vec<u16>> {
40    input.map(|s| {
41        s.split(',')
42            .filter_map(|part| parse_port_token(part).ok())
43            .flatten()
44            .collect()
45    })
46}
47
48fn parse_port_token(part: &str) -> Result<Vec<u16>> {
49    let part = part.trim();
50    if part.is_empty() {
51        return Ok(Vec::new());
52    }
53
54    if let Some((start, end)) = part.split_once('-') {
55        let start_trim = start.trim();
56        let end_trim = end.trim();
57        let s: u16 = start_trim
58            .parse()
59            .map_err(|_| Error::invalid_input(format!("invalid port in range: '{start_trim}'")))?;
60        let e: u16 = end_trim
61            .parse()
62            .map_err(|_| Error::invalid_input(format!("invalid port in range: '{end_trim}'")))?;
63        if s > e {
64            return Err(Error::invalid_input(format!(
65                "inverted port range: {s}-{e}"
66            )));
67        }
68        Ok((s..=e).collect())
69    } else {
70        let port: u16 = part
71            .parse()
72            .map_err(|_| Error::invalid_input(format!("invalid port: '{part}'")))?;
73        Ok(vec![port])
74    }
75}
76
77/// Strict parser for user input — rejects any malformed token instead of
78/// silently discarding it. `Some(None)` means the input was absent/empty;
79/// `Err(Error::InvalidInput(..))` means the user typed something that
80/// couldn't be interpreted.
81pub fn parse_ports_checked(input: Option<&str>) -> Result<Option<Vec<u16>>> {
82    let Some(raw) = input else {
83        return Ok(None);
84    };
85
86    let raw = raw.trim();
87    if raw.is_empty() {
88        return Ok(None);
89    }
90
91    // Accumulate into a set, and check the cap *inside* the loop.
92    //
93    // This used to expand every token into one flat Vec, then sort, dedup
94    // and validate at the end — so the 4,096-port cap could not prevent the
95    // work it exists to prevent. `-p` is just a string, and each `1-65535`
96    // token expands to 65,535 entries before anything looks at the total:
97    // a 24 KB argument took **2m11s** to be rejected (measured, debug
98    // build), and the cost is linear, so a 1 MB argument allocates tens of
99    // gigabytes before erroring.
100    //
101    // A `BTreeSet` fixes both halves at once. Memory is bounded by the
102    // 65,536 distinct ports that exist rather than by the input length, and
103    // because the set is deduplicated as it grows, the length check after
104    // each token is exact — a leading `1-65535` is rejected immediately.
105    // Iterating a BTreeSet yields sorted unique values, so the explicit
106    // sort+dedup this replaces is now free, and the returned list is
107    // byte-for-byte what it was before.
108    //
109    // Residual: an input made entirely of *repeated identical* ranges
110    // (`1-4096,1-4096,…`) never grows the set past the cap, so it is still
111    // walked token by token. That is bounded work per token with no
112    // allocation growth, which is a different order of problem from the one
113    // above.
114    let mut ports: BTreeSet<u16> = BTreeSet::new();
115    for part in raw.split(',') {
116        let expanded = parse_port_token(part).map_err(|e| {
117            // Add the caller's context so consumers see both "why" and
118            // "where" in one Error. Match on the variant rather than
119            // formatting `e`, whose Display already carries an
120            // "invalid input: " prefix — interpolating it produced
121            // "invalid input: Invalid port list 'x': invalid input: …".
122            let detail = match e {
123                Error::InvalidInput(detail) => detail,
124                other => other.to_string(),
125            };
126            Error::invalid_input(format!("invalid port list '{raw}': {detail}"))
127        })?;
128        ports.extend(expanded);
129        if ports.len() > MAX_PORTS_PER_SCAN {
130            // Delegate to validate_ports rather than restating the limit or
131            // its wording here — one rule, one message, one place. It is
132            // guaranteed to return Err given the length we just checked.
133            let so_far: Vec<u16> = ports.iter().copied().collect();
134            validate_ports(&so_far)?;
135        }
136    }
137    if ports.is_empty() {
138        return Err(Error::invalid_input(format!("invalid port list: {raw}")));
139    }
140    let ports: Vec<u16> = ports.into_iter().collect();
141    // Propagate as-is rather than re-wrapping. `validate_ports` already
142    // returns an `Error::InvalidInput` naming the offending rule, and
143    // wrapping it in another one rendered as
144    // "invalid input: Invalid port list '0': invalid input: port 0 is …".
145    validate_ports(&ports)?;
146    Ok(Some(ports))
147}
148
149pub fn default_ports() -> Vec<u16> {
150    DEFAULT_PORTS.to_vec()
151}
152
153#[cfg(test)]
154mod tests;