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;