From 8a8b4e8d7bc1d2a0d08a79a8d085c5a02e5c00d8 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Tue, 6 Oct 2026 00:32:02 +0100 Subject: [PATCH 1/2] MCP: progress notifications for long scans, and cancellation Progress: discover_network, scan_ports and sweep_network send notifications/progress when the request carries _meta.progressToken. The core already reports per-port and per-host progress; server/progress.rs maps each operation's phases onto one 0-1000 scale so the count only rises, and sends at most one notification per 250 ms plus the final one. The reporter is a task-local set around the request, read only by those three operations. Cancellation: notifications/cancelled with a requestId aborts that request's task (server/inflight.rs), and no response is sent for it, as the protocol asks. Before, the call ran to the end holding a request slot. End to end on netscli serve: a /24 sweep sent 6 progress notifications in 2.05 s ending at 1000/1000; a 4,096-port scan cancelled after 1 s was never answered while a tools/list sent after the cancel was answered at once. Tests cover throttling, monotonic progress, phase mapping and cancel; the cancel test fails with the abort removed. --- CHANGELOG.md | 8 + crates/netscli-mcp/src/server.rs | 49 ++++-- crates/netscli-mcp/src/server/inflight.rs | 94 +++++++++++ crates/netscli-mcp/src/server/operations.rs | 53 +++++- crates/netscli-mcp/src/server/progress.rs | 171 ++++++++++++++++++++ site/src/content/docs/docs/mcp.md | 12 ++ 6 files changed, 368 insertions(+), 19 deletions(-) create mode 100644 crates/netscli-mcp/src/server/inflight.rs create mode 100644 crates/netscli-mcp/src/server/progress.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 383e78c4..fc412048 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,14 @@ its heading and collects entries. The date and the link go on with the tag. ## [Unreleased] +### Added + +- **The MCP server reports progress and can be cancelled.** Discover, port + scans and sweeps send progress notifications to clients that ask for them, + so a long sweep no longer looks stuck. A client that cancels a call now + stops the scan, instead of it running to the end and holding one of the + server's sixteen request slots. + ### Changed - **Windows lists the desktop app's publisher as Felix Stubner.** Installed diff --git a/crates/netscli-mcp/src/server.rs b/crates/netscli-mcp/src/server.rs index 6c53ad51..165d1256 100644 --- a/crates/netscli-mcp/src/server.rs +++ b/crates/netscli-mcp/src/server.rs @@ -1,9 +1,11 @@ mod dispatch; mod errors; +mod inflight; #[cfg(feature = "pcap")] mod jobs; mod limits; mod operations; +mod progress; mod protocol; mod schemas; mod targets; @@ -81,6 +83,7 @@ pub async fn run_server() -> anyhow::Result<()> { let mut reader = BufReader::new(stdin); let state = Arc::new(Mutex::new(ServerState::default())); let limiter = Arc::new(Semaphore::new(MAX_CONCURRENT_REQUESTS)); + let inflight = Arc::new(inflight::InFlight::default()); // Responses are funnelled through one channel to a single writer task. // Two concurrent handlers must never interleave bytes on stdout, and a @@ -164,6 +167,11 @@ pub async fn run_server() -> anyhow::Result<()> { guard.initialized = true; } } + if request.method == "notifications/cancelled" + && inflight.cancel(request.params.as_ref()) + { + tracing::info!("request cancelled by the client"); + } continue; } @@ -184,31 +192,42 @@ pub async fn run_server() -> anyhow::Result<()> { // `Option>` distinguishes absent from explicit null; a // notification never reaches here, so the outer layer is always Some. let id = request.id.clone().flatten(); - handlers.spawn(async move { + let key = id.as_ref().map(inflight::key); + let progress = progress::Progress::for_request(request.params.as_ref(), &tx); + let inflight_for_task = Arc::clone(&inflight); + let key_for_task = key.clone(); + let task = async move { let _permit = permit; // A hard ceiling on the whole call. Per-probe timeouts are // bounded but their product was not, and a request that never // returns holds its permit forever. - let response = - match tokio::time::timeout(MAX_REQUEST_DURATION, handle_request(state, request)) - .await - { - Ok(response) => response, - Err(_) => { - tracing::warn!( - timeout_s = MAX_REQUEST_DURATION.as_secs(), - "request exceeded the maximum duration" - ); - JsonRpcResponse::request_timeout(id, MAX_REQUEST_DURATION.as_secs()) - } - }; + let call = progress::scope(progress, handle_request(state, request)); + let response = match tokio::time::timeout(MAX_REQUEST_DURATION, call).await { + Ok(response) => response, + Err(_) => { + tracing::warn!( + timeout_s = MAX_REQUEST_DURATION.as_secs(), + "request exceeded the maximum duration" + ); + JsonRpcResponse::request_timeout(id, MAX_REQUEST_DURATION.as_secs()) + } + }; match serde_json::to_string(&response) { Ok(s) => { let _ = tx.send(s); } Err(e) => tracing::error!(error = %e, "failed to serialize response"), } - }); + if let Some(key) = key_for_task { + inflight_for_task.finish(&key); + } + }; + match key { + Some(key) => inflight.track(key, || handlers.spawn(task)), + None => { + handlers.spawn(task); + } + } } // EOF: the client is gone, so nothing is waiting for these answers. diff --git a/crates/netscli-mcp/src/server/inflight.rs b/crates/netscli-mcp/src/server/inflight.rs new file mode 100644 index 00000000..36654118 --- /dev/null +++ b/crates/netscli-mcp/src/server/inflight.rs @@ -0,0 +1,94 @@ +//! Requests still running, so a client can cancel one. +//! +//! MCP clients send `notifications/cancelled` with the `requestId` of a call +//! they no longer want, for example when the user stops a model mid-answer. +//! The server used to ignore it: a sweep carried on to the end, holding one of +//! the sixteen request slots and sending probes nobody would read. Now the +//! request's task is aborted, and, as the protocol asks, no response is sent +//! for it. + +use std::collections::HashMap; +use std::sync::{Mutex, MutexGuard}; + +use serde_json::Value; +use tokio::task::AbortHandle; + +#[derive(Default)] +pub(super) struct InFlight(Mutex>); + +/// One key per JSON-RPC id. The id's JSON text keeps `1` and `"1"` apart, +/// which JSON-RPC treats as different ids. +pub(super) fn key(id: &Value) -> String { + id.to_string() +} + +impl InFlight { + fn map(&self) -> MutexGuard<'_, HashMap> { + self.0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + /// Track a request while it is spawned. The lock is held across `spawn` + /// so a task that finishes at once cannot call `finish` before its handle + /// is recorded, which would leave the handle behind for good. + pub(super) fn track(&self, key: String, spawn: impl FnOnce() -> AbortHandle) { + let mut map = self.map(); + let handle = spawn(); + map.insert(key, handle); + } + + /// The request answered; stop tracking it. + pub(super) fn finish(&self, key: &str) { + self.map().remove(key); + } + + /// Handle `notifications/cancelled`. Returns whether a running request + /// was stopped; an unknown or finished id is ignored, as the protocol + /// expects, since the response may already be on its way. + pub(super) fn cancel(&self, params: Option<&Value>) -> bool { + let Some(id) = params.and_then(|p| p.get("requestId")) else { + return false; + }; + match self.map().remove(&key(id)) { + Some(handle) => { + handle.abort(); + true + } + None => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::time::Duration; + + #[tokio::test] + async fn cancelling_a_running_request_aborts_it() { + let inflight = InFlight::default(); + let mut set = tokio::task::JoinSet::new(); + inflight.track(key(&json!(5)), || { + set.spawn(async { tokio::time::sleep(Duration::from_secs(60)).await }) + }); + assert!(inflight.cancel(Some(&json!({"requestId": 5, "reason": "user stopped"})))); + let joined = tokio::time::timeout(Duration::from_secs(1), set.join_next()).await; + let outcome = joined.expect("cancel must end the task promptly").unwrap(); + assert!(outcome.unwrap_err().is_cancelled()); + } + + #[test] + fn unknown_or_finished_ids_are_ignored() { + let inflight = InFlight::default(); + assert!(!inflight.cancel(Some(&json!({"requestId": 9})))); + assert!(!inflight.cancel(Some(&json!({})))); + assert!(!inflight.cancel(None)); + } + + #[test] + fn numeric_and_string_ids_are_different_requests() { + assert_ne!(key(&json!(1)), key(&json!("1"))); + } +} diff --git a/crates/netscli-mcp/src/server/operations.rs b/crates/netscli-mcp/src/server/operations.rs index c0142605..38d7273c 100644 --- a/crates/netscli-mcp/src/server/operations.rs +++ b/crates/netscli-mcp/src/server/operations.rs @@ -1,4 +1,5 @@ use super::errors::RpcError; +use super::progress; use super::schemas::{ clamp_concurrency, clamp_timeout_ms, normalize_ports, validate_subnet, DiscoverParams, DnsParams, PingHostParams, ScanParams, SweepParams, @@ -75,7 +76,21 @@ pub(super) async fn op_discover(p: DiscoverParams) -> Result (0.0, 0.8, "pinging"), + DiscoverPhase::Resolve => (0.8, 0.2, "naming hosts"), + }; + ( + progress::phase_fraction(start, span, e.completed, e.total), + format!("{what}: {} of {}, {} found", e.completed, e.total, e.found), + ) + }), + ) .await .map_err(|e| RpcError::ToolError(e.to_string()))?; Ok(hosts) @@ -91,10 +106,21 @@ pub(super) async fn op_scan_ports( // see `ensure_host_allowed` for why the two can differ. let ip = ensure_host_allowed(&p.host, netscli_core::DEFAULT_DNS_TIMEOUT_MS).await?; let ops = netscli_core::Ops::new(cfg); + let progress = progress::callback(|e: &netscli_core::PortScanProgress| { + ( + progress::phase_fraction(0.0, 1.0, e.completed, e.total), + format!( + "{} of {} ports, {} open", + e.completed, e.total, e.open_found + ), + ) + }); let scan = if p.udp.unwrap_or(false) { - ops.scan_udp_ports(&ip.to_string(), ports).await + ops.scan_udp_ports_with_progress(&ip.to_string(), ports, progress) + .await } else { - ops.scan_ports(&ip.to_string(), ports).await + ops.scan_ports_with_progress(&ip.to_string(), ports, progress) + .await }; let (_ip, mut res) = scan.map_err(|e| RpcError::ToolError(e.to_string()))?; if !p.include_closed.unwrap_or(false) { @@ -144,8 +170,27 @@ pub(super) async fn op_sweep(p: SweepParams) -> Result (0.0, 0.4, "pinging"), + SweepPhase::DiscoverResolve => (0.4, 0.1, "naming hosts"), + SweepPhase::Scan => (0.5, 0.5, "scanning hosts"), + }; + ( + progress::phase_fraction(start, span, e.completed, e.total), + format!("{what}: {} of {}, {} found", e.completed, e.total, e.found), + ) + }); let (_subnet, res) = ops - .sweep_ipv4(Some(subnet), ports, p.resolve_hostnames.unwrap_or(false)) + .sweep_ipv4_with_progress( + Some(subnet), + ports, + p.resolve_hostnames.unwrap_or(false), + progress, + ) .await .map_err(|e| RpcError::ToolError(e.to_string()))?; Ok(res) diff --git a/crates/netscli-mcp/src/server/progress.rs b/crates/netscli-mcp/src/server/progress.rs new file mode 100644 index 00000000..b5a17782 --- /dev/null +++ b/crates/netscli-mcp/src/server/progress.rs @@ -0,0 +1,171 @@ +//! MCP progress notifications for long tool calls. +//! +//! A client that wants progress puts a `progressToken` in the request's +//! `_meta`. Without this, a sweep of a /24 ran for tens of seconds with no +//! sign of life, and the only feedback a model got was the final answer or a +//! timeout. With it, the server sends `notifications/progress` while the +//! scan runs. +//! +//! The reporter travels in a task-local rather than as a parameter: it is +//! set once around the request in `server.rs` and read only by the three +//! long operations in `operations.rs`, and threading it through the five +//! dispatch layers between them would change every signature on the way for +//! the benefit of the two ends. + +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; +use tokio::sync::mpsc::UnboundedSender; + +/// At most one notification per interval, plus the final one. A port scan +/// reports every port, and 4,096 notifications for one call would cost the +/// client more than the scan. +const MIN_INTERVAL: Duration = Duration::from_millis(250); + +/// Progress is sent as thousandths of the whole call, so operations with +/// several phases (a sweep pings, resolves, then scans) still count up +/// monotonically, as the protocol requires. +const SCALE: u64 = 1000; + +pub(super) struct Progress { + token: Value, + tx: UnboundedSender, + last: Mutex<(Option, u64)>, +} + +tokio::task_local! { + static CURRENT: Option>; +} + +impl Progress { + /// A reporter for a request whose params carry `_meta.progressToken`. + pub(super) fn for_request( + params: Option<&Value>, + tx: &UnboundedSender, + ) -> Option> { + let token = params?.get("_meta")?.get("progressToken")?; + if !(token.is_string() || token.is_number()) { + return None; + } + Some(Arc::new(Self { + token: token.clone(), + tx: tx.clone(), + last: Mutex::new((None, 0)), + })) + } + + /// Report `fraction` (0.0 to 1.0) of the whole call done. + fn report(&self, fraction: f64, message: String) { + let value = ((fraction.clamp(0.0, 1.0)) * SCALE as f64).round() as u64; + let Ok(mut last) = self.last.lock() else { + return; + }; + let (sent_at, sent_value) = *last; + let finished = value >= SCALE; + // Never repeat or go backwards, and do not flood. + if value <= sent_value && sent_at.is_some() { + return; + } + if !finished && sent_at.is_some_and(|at| at.elapsed() < MIN_INTERVAL) { + return; + } + *last = (Some(Instant::now()), value); + let note = json!({ + "jsonrpc": "2.0", + "method": "notifications/progress", + "params": { + "progressToken": self.token, + "progress": value, + "total": SCALE, + "message": message, + } + }); + let _ = self.tx.send(note.to_string()); + } +} + +/// Run `fut` with `progress` as the current request's reporter. +pub(super) async fn scope( + progress: Option>, + fut: F, +) -> F::Output { + CURRENT.scope(progress, fut).await +} + +/// A core progress callback that reports through the current request, or +/// `None` when the client asked for no progress. `describe` turns one core +/// event into the fraction of the call done and a short message. +pub(super) fn callback( + describe: impl Fn(&T) -> (f64, String) + Send + Sync + 'static, +) -> Option> { + let progress = CURRENT.try_with(|p| p.clone()).ok().flatten()?; + Some(Arc::new(move |event: T| { + let (fraction, message) = describe(&event); + progress.report(fraction, message); + })) +} + +/// Fraction `completed / total` of a phase that covers `[start, start + span)` +/// of the whole call. +pub(super) fn phase_fraction(start: f64, span: f64, completed: usize, total: usize) -> f64 { + if total == 0 { + return start + span; + } + start + span * (completed as f64 / total as f64) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::sync::mpsc; + + fn sent(rx: &mut mpsc::UnboundedReceiver) -> Vec { + let mut out = Vec::new(); + while let Ok(line) = rx.try_recv() { + out.push(serde_json::from_str(&line).unwrap()); + } + out + } + + #[test] + fn no_token_means_no_reporter() { + let (tx, _rx) = mpsc::unbounded_channel(); + assert!(Progress::for_request(Some(&json!({"name": "scan_ports"})), &tx).is_none()); + assert!(Progress::for_request(None, &tx).is_none()); + } + + #[test] + fn notifications_count_up_and_are_throttled_but_the_last_always_goes() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let p = + Progress::for_request(Some(&json!({"_meta": {"progressToken": "t1"}})), &tx).unwrap(); + for i in 0..=100 { + p.report(f64::from(i) / 100.0, format!("step {i}")); + } + let notes = sent(&mut rx); + // The first goes at once, the rest fall inside the interval, and the + // final one is never held back. + assert_eq!(notes.len(), 2, "{notes:?}"); + assert_eq!(notes[0]["params"]["progressToken"], "t1"); + assert_eq!(notes[1]["params"]["progress"], SCALE); + assert_eq!(notes[1]["method"], "notifications/progress"); + } + + #[test] + fn progress_never_goes_backwards() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let p = Progress::for_request(Some(&json!({"_meta": {"progressToken": 7}})), &tx).unwrap(); + p.report(0.5, "half".into()); + std::thread::sleep(MIN_INTERVAL); + p.report(0.4, "earlier phase".into()); + assert_eq!(sent(&mut rx).len(), 1); + } + + #[test] + fn phases_map_onto_one_scale() { + assert_eq!(phase_fraction(0.0, 0.3, 0, 254), 0.0); + assert!((phase_fraction(0.3, 0.6, 127, 254) - 0.6).abs() < 1e-9); + assert_eq!(phase_fraction(0.4, 0.6, 0, 0), 1.0); + } +} diff --git a/site/src/content/docs/docs/mcp.md b/site/src/content/docs/docs/mcp.md index 6fab867e..97923f35 100644 --- a/site/src/content/docs/docs/mcp.md +++ b/site/src/content/docs/docs/mcp.md @@ -147,6 +147,18 @@ filtered. Closing stdin cancels any operation still running and shuts the server down, which is why a client that exits mid-scan leaves nothing behind. +## Progress and cancelling + +`discover_network`, `scan_ports` and `sweep_network` report progress while +they run, for clients that ask for it by sending a `progressToken` in the +request's `_meta`. The server sends at most four `notifications/progress` a +second, each with how far through the call it is (out of 1000) and a short +message such as `scanning hosts: 16 of 23, 10 found`. + +A client can stop any call by sending `notifications/cancelled` with the +call's `requestId`. The scan stops, frees its slot for the next request, and, +as the protocol asks, sends no response for the cancelled call. + ## Packet capture jobs Packet-capture tools appear only in MCP builds that include packet-capture support. Captures also need Npcap on Windows or libpcap on Linux/macOS. Supported builds expose two packet-capture styles. From 44b1023c4b47772d4be4780c4269bfdab3374f7b Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Tue, 6 Oct 2026 02:00:52 +0100 Subject: [PATCH 2/2] Move the MCP operations tests into their own file to keep operations.rs under 300 lines --- crates/netscli-mcp/src/server/operations.rs | 67 +------------------ .../src/server/operations/tests.rs | 64 ++++++++++++++++++ 2 files changed, 65 insertions(+), 66 deletions(-) create mode 100644 crates/netscli-mcp/src/server/operations/tests.rs diff --git a/crates/netscli-mcp/src/server/operations.rs b/crates/netscli-mcp/src/server/operations.rs index 38d7273c..1b2c3155 100644 --- a/crates/netscli-mcp/src/server/operations.rs +++ b/crates/netscli-mcp/src/server/operations.rs @@ -270,69 +270,4 @@ pub(super) async fn op_discover_mdns( } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn closed_and_filtered_ports_are_dropped_and_the_rest_kept() { - use netscli_core::PortStatus; - assert!(worth_reporting(PortStatus::Open)); - assert!(worth_reporting(PortStatus::OpenFiltered)); - assert!(worth_reporting(PortStatus::Error)); - assert!(!worth_reporting(PortStatus::Closed)); - assert!(!worth_reporting(PortStatus::Filtered)); - } - - #[test] - fn without_a_timeout_each_phase_keeps_the_core_default() { - let cfg = ops_config(64, None); - assert_eq!(cfg.concurrency, 64); - assert_eq!(cfg.ping_timeout_ms, netscli_core::DEFAULT_PING_TIMEOUT_MS); - assert_eq!(cfg.scan_timeout_ms, netscli_core::DEFAULT_SCAN_TIMEOUT_MS); - assert_eq!(cfg.dns_timeout_ms, netscli_core::DEFAULT_DNS_TIMEOUT_MS); - } - - #[test] - fn a_timeout_from_the_client_applies_to_every_phase_within_bounds() { - let cfg = ops_config(64, Some(2_000)); - assert_eq!( - (cfg.ping_timeout_ms, cfg.scan_timeout_ms, cfg.dns_timeout_ms), - (2_000, 2_000, 2_000) - ); - assert_eq!(ops_config(64, Some(1)).scan_timeout_ms, 10); - } - - #[test] - fn a_publicly_addressed_interface_is_refused_rather_than_scanned() { - // The case this whole change exists for. Before, the default was - // substituted inside `Ops` after every check had run, so a host whose - // LAN is publicly addressed -- some universities and ISPs still do - // this -- had its own subnet scanned with no policy consulted. - let err = resolve_and_check(None, || "203.0.113.0/24".to_string()) - .expect_err("a public default must be refused, not silently scanned"); - let message = err.to_string(); - assert!(message.contains("203.0.113.0/24"), "got: {message}"); - assert!( - message.contains("NETSCLI_MCP_ALLOW_PUBLIC_TARGETS"), - "got: {message}" - ); - } - - #[test] - fn an_ordinary_private_default_is_returned_unchanged() { - let subnet = resolve_and_check(None, || "192.168.1.0/24".to_string()) - .expect("a private default must pass"); - assert_eq!(subnet, "192.168.1.0/24"); - } - - #[test] - fn an_explicit_subnet_wins_over_the_default() { - // The default must not be consulted at all when one was supplied, - // or a bad default could override a good request. - let subnet = resolve_and_check(Some("10.1.2.0/24".to_string()), || { - panic!("the default must not be evaluated when a subnet was given") - }) - .expect("an explicit private subnet must pass"); - assert_eq!(subnet, "10.1.2.0/24"); - } -} +mod tests; diff --git a/crates/netscli-mcp/src/server/operations/tests.rs b/crates/netscli-mcp/src/server/operations/tests.rs new file mode 100644 index 00000000..46a8b0cc --- /dev/null +++ b/crates/netscli-mcp/src/server/operations/tests.rs @@ -0,0 +1,64 @@ +use super::*; + +#[test] +fn closed_and_filtered_ports_are_dropped_and_the_rest_kept() { + use netscli_core::PortStatus; + assert!(worth_reporting(PortStatus::Open)); + assert!(worth_reporting(PortStatus::OpenFiltered)); + assert!(worth_reporting(PortStatus::Error)); + assert!(!worth_reporting(PortStatus::Closed)); + assert!(!worth_reporting(PortStatus::Filtered)); +} + +#[test] +fn without_a_timeout_each_phase_keeps_the_core_default() { + let cfg = ops_config(64, None); + assert_eq!(cfg.concurrency, 64); + assert_eq!(cfg.ping_timeout_ms, netscli_core::DEFAULT_PING_TIMEOUT_MS); + assert_eq!(cfg.scan_timeout_ms, netscli_core::DEFAULT_SCAN_TIMEOUT_MS); + assert_eq!(cfg.dns_timeout_ms, netscli_core::DEFAULT_DNS_TIMEOUT_MS); +} + +#[test] +fn a_timeout_from_the_client_applies_to_every_phase_within_bounds() { + let cfg = ops_config(64, Some(2_000)); + assert_eq!( + (cfg.ping_timeout_ms, cfg.scan_timeout_ms, cfg.dns_timeout_ms), + (2_000, 2_000, 2_000) + ); + assert_eq!(ops_config(64, Some(1)).scan_timeout_ms, 10); +} + +#[test] +fn a_publicly_addressed_interface_is_refused_rather_than_scanned() { + // The case this whole change exists for. Before, the default was + // substituted inside `Ops` after every check had run, so a host whose + // LAN is publicly addressed -- some universities and ISPs still do + // this -- had its own subnet scanned with no policy consulted. + let err = resolve_and_check(None, || "203.0.113.0/24".to_string()) + .expect_err("a public default must be refused, not silently scanned"); + let message = err.to_string(); + assert!(message.contains("203.0.113.0/24"), "got: {message}"); + assert!( + message.contains("NETSCLI_MCP_ALLOW_PUBLIC_TARGETS"), + "got: {message}" + ); +} + +#[test] +fn an_ordinary_private_default_is_returned_unchanged() { + let subnet = resolve_and_check(None, || "192.168.1.0/24".to_string()) + .expect("a private default must pass"); + assert_eq!(subnet, "192.168.1.0/24"); +} + +#[test] +fn an_explicit_subnet_wins_over_the_default() { + // The default must not be consulted at all when one was supplied, + // or a bad default could override a good request. + let subnet = resolve_and_check(Some("10.1.2.0/24".to_string()), || { + panic!("the default must not be evaluated when a subnet was given") + }) + .expect("an explicit private subnet must pass"); + assert_eq!(subnet, "10.1.2.0/24"); +}