Skip to main content

mcp_execution_core/
command.rs

1//! Command validation and sanitization for secure subprocess execution.
2//!
3//! This module provides security-focused validation of server configurations before
4//! they are executed as subprocesses, preventing command injection attacks.
5//!
6//! # Security
7//!
8//! The validation enforces:
9//! - Command validation (absolute path or binary name)
10//! - Argument sanitization (no shell metacharacters)
11//! - Environment variable validation (block dangerous names)
12//! - Executable permission checks (for absolute paths)
13//!
14//! # Examples
15//!
16//! ```
17//! use mcp_execution_core::{ServerConfig, validate_server_config};
18//!
19//! // Valid binary name (resolved via PATH) — `build()` validates internally,
20//! // so `validate_server_config` is redundant here; shown for clarity.
21//! let config = ServerConfig::builder()
22//!     .command("docker".to_string())
23//!     .arg("run".to_string())
24//!     .build()
25//!     .unwrap();
26//! assert!(validate_server_config(&config).is_ok());
27//!
28//! // Invalid: shell metacharacters in arg — rejected by `build()` itself,
29//! // so no `ServerConfig` carrying this arg can ever exist.
30//! let err = ServerConfig::builder()
31//!     .command("docker".to_string())
32//!     .arg("run; rm -rf /".to_string())
33//!     .build()
34//!     .unwrap_err();
35//! assert!(err.is_security_error());
36//! ```
37
38use crate::{Error, Result, ServerConfig, Transport};
39use std::path::Path;
40use std::time::Duration;
41
42/// Shell metacharacters that indicate potential command injection.
43const FORBIDDEN_CHARS: &[char] = &[';', '|', '&', '>', '<', '`', '$', '(', ')', '\n', '\r'];
44
45/// Forbidden environment variable names that pose security risks.
46///
47/// Matched by [`validate_env_name`] using an ASCII-case-insensitive comparison — Windows
48/// treats environment variable names as case-insensitive at the OS/`CreateProcess` level, so
49/// a config carrying e.g. `Path` or `NODE_options` is rejected exactly like the canonical
50/// spelling shown below.
51///
52/// # Threat Model — What This List Does and Does Not Protect Against
53///
54/// This is an **accidental/indirect-misconfiguration guard, not a sandbox
55/// boundary**. It blocks the well-known names an interpreter or dynamic
56/// linker consults to load extra code or redirect its own search paths —
57/// covering the runtimes this bridge actually spawns (Node.js, Python, Ruby,
58/// Perl, the JVM, and POSIX shells, in addition to the native dynamic
59/// linker) — so that a config sourced from `mcp.json` or CLI flags cannot
60/// silently turn an intended `docker`/`node`/`python` invocation into
61/// arbitrary code execution via one of these documented hijack vectors:
62///
63/// - `LD_PRELOAD` / `LD_LIBRARY_PATH` / `LD_AUDIT`: Linux dynamic linker —
64///   force-load an arbitrary shared object into the child process
65/// - `DYLD_*`: macOS dynamic linker equivalents
66/// - `PATH`: binary substitution for any bare (non-absolute) command
67/// - `NODE_OPTIONS`: Node.js — inject interpreter flags such as `--require`
68///   or `--experimental-loader` into any `node`/`npx` invocation
69/// - `BASH_ENV`: sourced by non-interactive `bash` before running a
70///   script/command, letting a config inject arbitrary shell code
71/// - `PYTHONPATH` / `PYTHONSTARTUP`: Python — module search-path hijacking
72///   and arbitrary code executed at interpreter startup
73/// - `RUBYOPT`: Ruby — inject interpreter flags (`-r`, `-e`) to load
74///   arbitrary code
75/// - `PERL5OPT`: Perl — inject interpreter switches to run arbitrary code
76/// - `JAVA_TOOL_OPTIONS`: JVM — inject arbitrary JVM arguments, including a
77///   `-javaagent` for bytecode instrumentation
78///
79/// What it deliberately does **not** protect against: a command/binary that
80/// is itself malicious, a compromised dependency, arbitrary code the spawned
81/// server executes once running, or forbidden-adjacent variables not on this
82/// exact-match/prefix list (e.g. an interpreter-specific vector this project
83/// does not yet spawn). This list is reviewed and extended as new spawn
84/// targets are added, not treated as exhaustive by construction.
85const FORBIDDEN_ENV_NAMES: &[&str] = &[
86    "LD_PRELOAD",
87    "LD_LIBRARY_PATH",
88    "LD_AUDIT",
89    "DYLD_INSERT_LIBRARIES",
90    "DYLD_LIBRARY_PATH",
91    "DYLD_FRAMEWORK_PATH",
92    "PATH",         // Block PATH override to prevent binary substitution
93    "NODE_OPTIONS", // Lets a config inject e.g. `--require /tmp/evil.js` into any Node subprocess
94    "BASH_ENV",     // Sourced by non-interactive `bash` before running a script/command
95    "PYTHONPATH",
96    "PYTHONSTARTUP",
97    "RUBYOPT",
98    "PERL5OPT",
99    "JAVA_TOOL_OPTIONS",
100];
101
102/// Environment-variable-name prefix rejected regardless of exact match: macOS's
103/// dynamic-linker variable family (`DYLD_INSERT_LIBRARIES`, `DYLD_LIBRARY_PATH`, ...).
104///
105/// Matched by [`validate_env_name`] case-insensitively, same as [`FORBIDDEN_ENV_NAMES`].
106const FORBIDDEN_ENV_PREFIX: &str = "DYLD_";
107
108/// Upper bound for `connect_timeout`/`discover_timeout`, matching the
109/// 30-second defaults declared in `server_config.rs` with headroom for
110/// slow-starting servers configured via `mcp.json`.
111const MAX_TIMEOUT: Duration = Duration::from_mins(10);
112
113/// Maximum number of positional arguments accepted in a `ServerConfig` (denial-of-service
114/// protection, CWE-400).
115///
116/// An `mcp.json` entry or CLI invocation is expected to pass a short, fixed argv to the
117/// spawned subprocess, so this is generous headroom rather than a realistic expectation.
118///
119/// # Examples
120///
121/// ```
122/// use mcp_execution_core::MAX_ARG_COUNT;
123///
124/// assert!(MAX_ARG_COUNT > 0);
125/// ```
126pub const MAX_ARG_COUNT: usize = 256;
127
128/// Maximum byte length for a single command string, argument, or environment variable name.
129///
130/// A legitimate command/argument/env-name is always a short identifier or path, never
131/// free-form text, so this ceiling exists purely as a resource-exhaustion backstop.
132///
133/// # Examples
134///
135/// ```
136/// use mcp_execution_core::MAX_ARG_LEN;
137///
138/// assert!(MAX_ARG_LEN > 0);
139/// ```
140pub const MAX_ARG_LEN: usize = 4096;
141
142/// Maximum number of environment variables accepted in a `ServerConfig`.
143///
144/// # Examples
145///
146/// ```
147/// use mcp_execution_core::MAX_ENV_COUNT;
148///
149/// assert!(MAX_ENV_COUNT > 0);
150/// ```
151pub const MAX_ENV_COUNT: usize = 256;
152
153/// Maximum byte length for a single environment variable value.
154///
155/// Wider than [`MAX_ARG_LEN`] since env values legitimately carry things like JSON
156/// configuration blobs, not just short identifiers.
157///
158/// # Examples
159///
160/// ```
161/// use mcp_execution_core::MAX_ENV_VALUE_LEN;
162///
163/// assert!(MAX_ENV_VALUE_LEN > 0);
164/// ```
165pub const MAX_ENV_VALUE_LEN: usize = 32 * 1024;
166
167/// Maximum number of HTTP headers accepted for Http/Sse transport.
168///
169/// # Examples
170///
171/// ```
172/// use mcp_execution_core::MAX_HEADER_COUNT;
173///
174/// assert!(MAX_HEADER_COUNT > 0);
175/// ```
176pub const MAX_HEADER_COUNT: usize = 128;
177
178/// Maximum byte length for a single HTTP header value.
179///
180/// Wider than [`MAX_ARG_LEN`] since header values legitimately carry things like long
181/// bearer tokens.
182///
183/// # Examples
184///
185/// ```
186/// use mcp_execution_core::MAX_HEADER_VALUE_LEN;
187///
188/// assert!(MAX_HEADER_VALUE_LEN > 0);
189/// ```
190pub const MAX_HEADER_VALUE_LEN: usize = 8 * 1024;
191
192/// Maximum byte length for the HTTP/Sse transport `url`.
193///
194/// Generous headroom over any realistic endpoint URL (including a long query string), while
195/// still bounding a hostile or hand-edited `mcp.json` entry (denial-of-service protection,
196/// CWE-400).
197///
198/// # Examples
199///
200/// ```
201/// use mcp_execution_core::MAX_URL_LEN;
202///
203/// assert!(MAX_URL_LEN > 0);
204/// ```
205pub const MAX_URL_LEN: usize = 8 * 1024;
206
207/// Returns the shell metacharacters considered forbidden in a command or argument string.
208///
209/// Exposed so downstream consumers that must mirror this exact rule outside this function —
210/// currently, the generated TypeScript runtime bridge
211/// (`crates/mcp-codegen/templates/progressive/runtime-bridge.ts.hbs`) — can render their copy
212/// directly from this constant at code-generation time instead of hand-copying it, which
213/// would otherwise silently drift out of sync.
214///
215/// # Examples
216///
217/// ```
218/// use mcp_execution_core::forbidden_chars;
219///
220/// assert!(forbidden_chars().contains(&';'));
221/// ```
222#[must_use]
223pub const fn forbidden_chars() -> &'static [char] {
224    FORBIDDEN_CHARS
225}
226
227/// Returns the exact-match forbidden environment variable names.
228///
229/// Does not include the `DYLD_` prefix rule — see [`forbidden_env_prefix`] for that. Exposed
230/// for the same drift-elimination reason as [`forbidden_chars`]; see its documentation.
231///
232/// # Examples
233///
234/// ```
235/// use mcp_execution_core::forbidden_env_names;
236///
237/// assert!(forbidden_env_names().contains(&"LD_PRELOAD"));
238/// ```
239#[must_use]
240pub const fn forbidden_env_names() -> &'static [&'static str] {
241    FORBIDDEN_ENV_NAMES
242}
243
244/// Returns the environment-variable-name prefix rejected regardless of exact match
245/// (currently `DYLD_`, macOS's dynamic-linker variable family).
246///
247/// # Examples
248///
249/// ```
250/// use mcp_execution_core::forbidden_env_prefix;
251///
252/// assert_eq!(forbidden_env_prefix(), "DYLD_");
253/// ```
254#[must_use]
255pub const fn forbidden_env_prefix() -> &'static str {
256    FORBIDDEN_ENV_PREFIX
257}
258
259/// Human-readable description of the POSIX/Windows environment-variable-name identifier
260/// charset enforced by [`validate_env_name`]: first character `[A-Za-z_]`, subsequent
261/// characters `[A-Za-z0-9_]*`. Embedded in both this module's own rejection message and
262/// [`env_name_charset_pattern`]'s rendered pattern, so a `test_env_name_charset_pattern_matches_desc`
263/// regression test can catch the two ever describing a different charset.
264const ENV_NAME_CHARSET_DESC: &str = "[A-Za-z_][A-Za-z0-9_]*";
265
266/// Returns the environment-variable-name identifier charset as an anchored, JavaScript
267/// `RegExp`-compatible pattern source.
268///
269/// Matches `validate_env_name`'s charset rule: first character `[A-Za-z_]`, subsequent
270/// characters `[A-Za-z0-9_]*`. Exposed so downstream consumers that must mirror this exact
271/// rule outside this function —
272/// currently, the generated TypeScript runtime bridge
273/// (`crates/mcp-codegen/templates/progressive/runtime-bridge.ts.hbs`) — can render their copy
274/// directly from this constant at code-generation time instead of hand-copying it. Closes the
275/// #467 gap where the bridge's `validateEnvName` had no charset check at all and relied
276/// entirely on `String.prototype.toUpperCase()`'s incidental Unicode case folding to catch a
277/// forbidden-name confusable.
278///
279/// # Examples
280///
281/// ```
282/// use mcp_execution_core::env_name_charset_pattern;
283///
284/// assert_eq!(env_name_charset_pattern(), "^[A-Za-z_][A-Za-z0-9_]*$");
285/// ```
286#[must_use]
287pub const fn env_name_charset_pattern() -> &'static str {
288    "^[A-Za-z_][A-Za-z0-9_]*$"
289}
290
291/// Returns the human-readable description of the environment-variable-name identifier
292/// charset used in this module's own rejection message (`"[A-Za-z_][A-Za-z0-9_]*"`).
293///
294/// Exposed for the same drift-elimination reason as [`env_name_charset_pattern`] — this covers
295/// the *message text* around the rule rather than its enforcement, so a consumer that renders
296/// its own rejection message (currently the generated TypeScript runtime bridge's
297/// `validateEnvName`) can render it from this constant instead of hand-copying the literal a
298/// second time.
299///
300/// # Examples
301///
302/// ```
303/// use mcp_execution_core::env_name_charset_desc;
304///
305/// assert_eq!(env_name_charset_desc(), "[A-Za-z_][A-Za-z0-9_]*");
306/// ```
307#[must_use]
308pub const fn env_name_charset_desc() -> &'static str {
309    ENV_NAME_CHARSET_DESC
310}
311
312/// Validates a `ServerConfig` for safe execution, dispatching on transport type.
313///
314/// This function performs comprehensive security validation before a config is
315/// used to connect to a server. It validates:
316///
317/// 1. **Stdio transport**: command (absolute path or binary name), arguments, and
318///    environment variables.
319/// 2. **Http/Sse transport**: URL presence and scheme, and HTTP header names/values.
320/// 3. **Timeouts**: `connect_timeout`/`discover_timeout` checked against bounds,
321///    for all transports.
322///
323/// # Security Rules
324///
325/// - **Forbidden chars in command/args**: `;`, `|`, `&`, `>`, `<`, `` ` ``, `$`, `(`, `)`, `\n`, `\r`
326/// - **Env name charset**: must match `[A-Za-z_][A-Za-z0-9_]*` (POSIX/Windows environment
327///   variable identifier convention); rejects non-ASCII Unicode confusables (e.g. U+0131 `ı`,
328///   U+017F `ſ`) that could otherwise dodge the ASCII-only case-insensitive comparison below
329///   while still resolving as the forbidden name on Windows
330/// - **Forbidden env names**: dynamic-linker (`LD_PRELOAD`, `LD_LIBRARY_PATH`,
331///   `LD_AUDIT`, `DYLD_*`), `PATH`, and interpreter hijack vectors
332///   (`NODE_OPTIONS`, `BASH_ENV`, `PYTHONPATH`, `PYTHONSTARTUP`, `RUBYOPT`,
333///   `PERL5OPT`, `JAVA_TOOL_OPTIONS`), matched case-insensitively — see the
334///   `FORBIDDEN_ENV_NAMES` constant's doc comment in this module's source for
335///   the full threat-model note
336/// - **Absolute paths**: Must exist and be executable
337/// - **Binary names**: Allowed (resolved via PATH at runtime)
338/// - **URL scheme**: Must be `http://` or `https://`
339/// - **Header names/values**: Must not contain control characters
340/// - **Timeout bounds**: `connect_timeout`/`discover_timeout` must be greater than zero and at
341///   most `MAX_TIMEOUT` (600s)
342/// - **Element counts/lengths** (denial-of-service protection, CWE-400) — see this module's
343///   `validate_stdio_size_bounds`/`validate_network_size_bounds`: at most `MAX_ARG_COUNT`
344///   args, `MAX_ENV_COUNT` env vars, and `MAX_HEADER_COUNT` headers; at most `MAX_ARG_LEN`
345///   bytes per command/argument/env-name/header-name, `MAX_ENV_VALUE_LEN` bytes per env
346///   value, `MAX_HEADER_VALUE_LEN` bytes per header value, and `MAX_URL_LEN` bytes for the
347///   `url` field
348///
349/// # Errors
350///
351/// Returns `Error::SecurityViolation` if:
352/// - Command is empty or whitespace
353/// - Command/args contain shell metacharacters
354/// - Absolute path does not exist or is not executable
355/// - Environment variable name is forbidden, or outside the `[A-Za-z_][A-Za-z0-9_]*` charset
356/// - URL scheme is not `http://`/`https://`, or a header name/value contains control characters
357///
358/// Returns `Error::ValidationError` if:
359/// - URL is missing for Http/Sse transport
360/// - `connect_timeout` or `discover_timeout` is zero
361/// - `connect_timeout` or `discover_timeout` exceeds `MAX_TIMEOUT` (600s)
362///
363/// # Examples
364///
365/// ```
366/// use mcp_execution_core::{ServerConfig, validate_server_config};
367///
368/// // Valid: binary name
369/// let config = ServerConfig::builder()
370///     .command("docker".to_string())
371///     .build()
372///     .unwrap();
373/// assert!(validate_server_config(&config).is_ok());
374///
375/// // Invalid: forbidden env var — `ServerConfigBuilder::build()` already
376/// // rejects this, so no unvalidated `ServerConfig` reaches this function.
377/// let err = ServerConfig::builder()
378///     .command("docker".to_string())
379///     .env("LD_PRELOAD".to_string(), "/evil.so".to_string())
380///     .build()
381///     .unwrap_err();
382/// assert!(err.is_security_error());
383///
384/// // Valid: HTTP transport
385/// let config = ServerConfig::builder()
386///     .http_transport("https://api.example.com/mcp".to_string())
387///     .build()
388///     .unwrap();
389/// assert!(validate_server_config(&config).is_ok());
390/// ```
391///
392/// # Security Considerations
393///
394/// - Binary names are allowed and resolved via PATH at runtime
395/// - Absolute paths undergo strict validation (existence, permissions)
396/// - All arguments are validated separately to prevent injection
397/// - Environment variables are checked against forbidden names
398/// - Header values are never echoed into error messages, since they routinely
399///   carry secrets such as bearer tokens
400/// - Header *names* are never echoed either, once rejected: a `Name=Value` or
401///   `Name: Value` CLI argument can be mis-split on the wrong separator,
402///   leaving a full secret value in the "name" position — the token-charset
403///   error, the duplicate-header-name error, and the header-value
404///   control-character error all omit the name for this reason
405/// - There is no infinite-timeout option: `0` is always rejected, since an
406///   unbounded wait would let a hung server block this non-interactive tool
407///   forever (see the `validate_timeout` design note in this module)
408pub fn validate_server_config(config: &ServerConfig) -> Result<()> {
409    match config.transport() {
410        Transport::Stdio {
411            command, args, env, ..
412        } => {
413            // Element counts/lengths (denial-of-service protection, CWE-400) are bounded
414            // before the command-injection-specific checks below.
415            validate_stdio_size_bounds(command, args, env)?;
416            validate_stdio_config(command, args, env)?;
417        }
418        Transport::Http { url, headers } | Transport::Sse { url, headers } => {
419            validate_network_size_bounds(url, headers)?;
420            validate_network_config(url, headers)?;
421        }
422    }
423
424    // Validate timeout bounds. Zero fires immediately and breaks all
425    // discovery; an infinite timeout is deliberately unsupported (see
426    // `validate_timeout` doc comment) because it would let a hung or
427    // malicious server block this non-interactive CLI tool forever,
428    // re-opening the DoS window these timeouts were introduced to close.
429    validate_timeout(config.connect_timeout(), "connect_timeout")?;
430    validate_timeout(config.discover_timeout(), "discover_timeout")?;
431
432    Ok(())
433}
434
435/// Bounds `command`'s length and `args`'/`env`'s counts/lengths (denial-of-service
436/// protection, CWE-400) for a [`Transport::Stdio`] config.
437///
438/// Since #313, `Transport::Http`/`Transport::Sse` have no `command`/`args`/`env` fields at
439/// all — the cross-transport bypass this once guarded against (issue #198 S2: a hostile
440/// `mcp.json` populating `args`/`env` for a non-stdio transport) is unrepresentable rather
441/// than merely unchecked, so this only needs to run for the `Stdio` variant.
442///
443/// Deliberately does not check for shell metacharacters or forbidden environment variable
444/// names — that remains [`validate_stdio_config`]'s responsibility, since it is only
445/// meaningful for a config that is actually used to spawn a subprocess.
446fn validate_stdio_size_bounds(
447    command: &str,
448    args: &[String],
449    env: &std::collections::HashMap<String, String>,
450) -> Result<()> {
451    if command.len() > MAX_ARG_LEN {
452        return Err(Error::SecurityViolation {
453            reason: format!(
454                "command too long: {} bytes exceeds the {MAX_ARG_LEN} limit",
455                command.len()
456            ),
457        });
458    }
459
460    if args.len() > MAX_ARG_COUNT {
461        return Err(Error::SecurityViolation {
462            reason: format!(
463                "too many arguments: {} exceeds the {MAX_ARG_COUNT} limit",
464                args.len()
465            ),
466        });
467    }
468    for (idx, arg) in args.iter().enumerate() {
469        if arg.len() > MAX_ARG_LEN {
470            return Err(Error::SecurityViolation {
471                reason: format!(
472                    "argument {idx} too long: {} bytes exceeds the {MAX_ARG_LEN} limit",
473                    arg.len()
474                ),
475            });
476        }
477    }
478
479    if env.len() > MAX_ENV_COUNT {
480        return Err(Error::SecurityViolation {
481            reason: format!(
482                "too many environment variables: {} exceeds the {MAX_ENV_COUNT} limit",
483                env.len()
484            ),
485        });
486    }
487    for (env_name, env_value) in env {
488        if env_name.len() > MAX_ARG_LEN {
489            return Err(Error::SecurityViolation {
490                reason: format!(
491                    "environment variable name too long: {} bytes exceeds the {MAX_ARG_LEN} \
492                     limit",
493                    env_name.len()
494                ),
495            });
496        }
497        if env_value.len() > MAX_ENV_VALUE_LEN {
498            return Err(Error::SecurityViolation {
499                reason: format!(
500                    "environment variable '{env_name}' value too long: {} bytes exceeds the \
501                     {MAX_ENV_VALUE_LEN} limit",
502                    env_value.len()
503                ),
504            });
505        }
506    }
507
508    Ok(())
509}
510
511/// Bounds `url`'s length and `headers`' count/lengths (denial-of-service protection,
512/// CWE-400) for a [`Transport::Http`]/[`Transport::Sse`] config.
513///
514/// See [`validate_stdio_size_bounds`]'s doc comment for why this only needs to run for its
515/// own variant family since #313.
516fn validate_network_size_bounds(
517    url: &str,
518    headers: &std::collections::HashMap<String, String>,
519) -> Result<()> {
520    if url.len() > MAX_URL_LEN {
521        return Err(Error::SecurityViolation {
522            reason: format!(
523                "url too long: {} bytes exceeds the {MAX_URL_LEN} limit",
524                url.len()
525            ),
526        });
527    }
528
529    if headers.len() > MAX_HEADER_COUNT {
530        return Err(Error::SecurityViolation {
531            reason: format!(
532                "too many headers: {} exceeds the {MAX_HEADER_COUNT} limit",
533                headers.len()
534            ),
535        });
536    }
537    for (name, value) in headers {
538        if name.len() > MAX_ARG_LEN {
539            return Err(Error::SecurityViolation {
540                reason: format!(
541                    "header name too long: {} bytes exceeds the {MAX_ARG_LEN} limit",
542                    name.len()
543                ),
544            });
545        }
546        if value.len() > MAX_HEADER_VALUE_LEN {
547            return Err(Error::SecurityViolation {
548                reason: format!(
549                    "header value too long: {} bytes exceeds the {MAX_HEADER_VALUE_LEN} limit",
550                    value.len()
551                ),
552            });
553        }
554    }
555
556    Ok(())
557}
558
559/// Validates the stdio-transport-specific fields of a `ServerConfig`.
560///
561/// Checks the command (absolute path or binary name), arguments, and environment variables
562/// for command-injection risks. Element counts/lengths are already bounded unconditionally by
563/// [`validate_stdio_size_bounds`] before this runs; this function only adds the checks that
564/// are meaningful specifically because this config will be used to spawn a subprocess.
565fn validate_stdio_config(
566    command: &str,
567    args: &[String],
568    env: &std::collections::HashMap<String, String>,
569) -> Result<()> {
570    // Validate command
571    validate_command_string(command, "command")?;
572
573    // If command is absolute path, perform additional checks
574    let command_path = Path::new(command);
575    if command_path.is_absolute() {
576        validate_absolute_path(command)?;
577    }
578    // If not absolute, it's a binary name (to be resolved via PATH) - this is OK
579
580    // Validate each argument separately
581    for (idx, arg) in args.iter().enumerate() {
582        validate_command_string(arg, &format!("argument {idx}"))?;
583    }
584
585    // Validate environment variable names
586    for env_name in env.keys() {
587        validate_env_name(env_name)?;
588    }
589
590    Ok(())
591}
592
593/// Validates the Http/Sse-transport-specific fields of a `ServerConfig`.
594///
595/// `url` is a required field of [`Transport::Http`]/[`Transport::Sse`] (see #313), so unlike
596/// before, a config missing it cannot reach this function at all — that gap is now closed at
597/// deserialization/construction time rather than here.
598///
599/// `headers`'/`url`'s element counts/lengths are already bounded unconditionally by
600/// [`validate_network_size_bounds`] before this runs; this function only adds the checks that
601/// are meaningful specifically because this config will be used to send an HTTP request
602/// (header name charset, control characters, scheme, duplicate names).
603fn validate_network_config(
604    url: &str,
605    headers: &std::collections::HashMap<String, String>,
606) -> Result<()> {
607    validate_url_scheme(url)?;
608
609    // `http::HeaderName` lowercases on parse, so two headers that differ only
610    // in case (e.g. "Authorization" and "authorization") collapse into a
611    // single entry with a nondeterministic winner once converted — reject
612    // that here rather than letting it silently drop a header downstream.
613    let mut seen_header_names = std::collections::HashSet::new();
614    for (name, value) in headers {
615        validate_header_name_string(name)?;
616        validate_header_value_string(value)?;
617        if !seen_header_names.insert(name.to_ascii_lowercase()) {
618            return Err(Error::SecurityViolation {
619                reason: "duplicate header name (case-insensitive); name omitted as it may \
620                         be secret-shaped"
621                    .to_string(),
622            });
623        }
624    }
625
626    Ok(())
627}
628
629/// Validates that a URL uses the `http://` or `https://` scheme.
630///
631/// This is defense in depth: rejects `file://`, `unix://`, and similar
632/// schemes at the `mcp-core` validation boundary rather than relying on the
633/// HTTP client to reject them. The scheme comparison is case-insensitive per
634/// RFC 3986 (`HTTP://host` is a valid URL, not a different scheme).
635///
636/// This is a minimal, string-based scheme check — it does not validate the
637/// rest of the URL's structure (e.g. it does not require a host). It is
638/// exposed publicly so that other crates checking URL validity for the same
639/// http/sse transport (e.g. `mcp-execution-cli`'s server status/validation
640/// commands) can share this exact rule instead of drifting from it with a
641/// second, differently-behaved check.
642///
643/// # Errors
644///
645/// Returns [`Error::SecurityViolation`] if `url` does not start with an
646/// `http://` or `https://` scheme (case-insensitive).
647///
648/// # Examples
649///
650/// ```
651/// use mcp_execution_core::validate_url_scheme;
652///
653/// assert!(validate_url_scheme("https://example.com/mcp").is_ok());
654/// assert!(validate_url_scheme("HTTP://example.com").is_ok());
655/// assert!(validate_url_scheme("ftp://example.com").is_err());
656/// assert!(validate_url_scheme("  https://example.com").is_err());
657/// ```
658pub fn validate_url_scheme(url: &str) -> Result<()> {
659    let is_valid = url.split_once("://").is_some_and(|(scheme, _)| {
660        scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
661    });
662    if is_valid {
663        Ok(())
664    } else {
665        Err(Error::SecurityViolation {
666            reason: "url must use the http:// or https:// scheme".to_string(),
667        })
668    }
669}
670
671/// Returns `true` if `value` contains an ASCII or Unicode control character
672/// (including `\r`, `\n`, and NUL), which could otherwise be used to smuggle
673/// extra header lines into an HTTP request.
674fn contains_control_char(value: &str) -> bool {
675    value.chars().any(char::is_control)
676}
677
678/// Returns `true` if `c` is a valid RFC 7230 `tchar` (the charset allowed in
679/// an HTTP header field name).
680const fn is_header_name_tchar(c: char) -> bool {
681    c.is_ascii_alphanumeric()
682        || matches!(
683            c,
684            '!' | '#'
685                | '$'
686                | '%'
687                | '&'
688                | '\''
689                | '*'
690                | '+'
691                | '-'
692                | '.'
693                | '^'
694                | '_'
695                | '`'
696                | '|'
697                | '~'
698        )
699}
700
701/// Validates an HTTP header name against the RFC 7230 `token` charset.
702///
703/// A plain control-character check is not tight enough: a space, `:`, or `@`
704/// is not a control character but is still an invalid header-name character
705/// that would otherwise pass here and fail later inside `http::HeaderName`
706/// construction with an opaque error.
707///
708/// # Security
709///
710/// The rejected name is never echoed into the error message. A `Name=Value`
711/// or `Name: Value` CLI argument can be mis-split on the wrong separator,
712/// leaving a full secret value in the "name" position; that value only needs
713/// one non-`tchar` byte to reach this branch, so it must be treated the same
714/// as a secret — mirroring the duplicate-header-name check below, which
715/// redacts for the same reason.
716fn validate_header_name_string(name: &str) -> Result<()> {
717    if name.is_empty() {
718        return Err(Error::SecurityViolation {
719            reason: "header name cannot be empty".to_string(),
720        });
721    }
722    if !name.chars().all(is_header_name_tchar) {
723        return Err(Error::SecurityViolation {
724            reason: "header name contains characters outside the allowed HTTP token charset"
725                .to_string(),
726        });
727    }
728    Ok(())
729}
730
731/// Validates an HTTP header value for control characters.
732///
733/// # Security
734///
735/// The header *value* routinely carries secrets (e.g. bearer tokens), so it
736/// must never appear in the returned error's reason string. The header
737/// *name* is not echoed either: this runs after `validate_header_name_string`
738/// has already accepted it as RFC 7230 `token`-charset-only, the same
739/// "may still be secret-shaped input from a misparsed argument" condition
740/// that the tchar-violation and duplicate-header-name errors above already
741/// treat as untrusted.
742fn validate_header_value_string(value: &str) -> Result<()> {
743    if contains_control_char(value) {
744        return Err(Error::SecurityViolation {
745            reason: "header value contains control characters".to_string(),
746        });
747    }
748    Ok(())
749}
750
751/// Validates that a timeout is within `(0, MAX_TIMEOUT]`.
752///
753/// # Design Note: No Infinite Timeout
754///
755/// A timeout of zero is permanently rejected rather than treated as a
756/// sentinel for "no timeout". This tool spawns subprocesses and connects to
757/// servers non-interactively (CLI and MCP-server modes); an unbounded
758/// connect/discover wait would let a hung or malicious server block the
759/// caller indefinitely, which is exactly the denial-of-service exposure
760/// these timeouts were added to close. Callers that need a longer wait
761/// should raise the value up to `MAX_TIMEOUT` (10 minutes) instead.
762fn validate_timeout(timeout: Duration, field: &str) -> Result<()> {
763    if timeout.is_zero() {
764        return Err(Error::ValidationError {
765            field: field.to_string(),
766            reason: "timeout must be greater than zero".to_string(),
767        });
768    }
769    if timeout > MAX_TIMEOUT {
770        return Err(Error::ValidationError {
771            field: field.to_string(),
772            reason: format!("timeout {timeout:?} exceeds maximum allowed {MAX_TIMEOUT:?}"),
773        });
774    }
775    Ok(())
776}
777
778/// Validates a command string for forbidden shell metacharacters.
779///
780/// This is an internal helper that checks a string (command or argument)
781/// for dangerous shell metacharacters. Length is already bounded unconditionally by
782/// [`validate_stdio_size_bounds`] before this runs.
783///
784/// # Security
785///
786/// The offending value is never echoed into the error message. `context` is
787/// `"argument {idx}"` for CLI arguments, which routinely carry secrets in a
788/// `--api-key sk-...`-style value; the same "may be secret-shaped" treatment
789/// as `validate_header_value_string` and the duplicate-header-name check
790/// applies here.
791fn validate_command_string(value: &str, context: &str) -> Result<()> {
792    // Check for empty
793    let value = value.trim();
794    if value.is_empty() {
795        return Err(Error::SecurityViolation {
796            reason: format!("{context} cannot be empty"),
797        });
798    }
799
800    // Check for shell metacharacters
801    for forbidden in FORBIDDEN_CHARS {
802        if value.contains(*forbidden) {
803            return Err(Error::SecurityViolation {
804                reason: format!(
805                    "{context} contains forbidden shell metacharacter '{forbidden}'; \
806                     value omitted as it may be secret-shaped"
807                ),
808            });
809        }
810    }
811
812    Ok(())
813}
814
815/// Validates an absolute path command for existence and executability.
816///
817/// This is an internal helper that performs file system checks on
818/// absolute path commands.
819fn validate_absolute_path(command: &str) -> Result<()> {
820    let path = Path::new(command);
821
822    // Verify file exists
823    if !path.exists() {
824        return Err(Error::SecurityViolation {
825            reason: format!("Command file does not exist: {command}"),
826        });
827    }
828
829    // Verify it's a file (not a directory)
830    if !path.is_file() {
831        return Err(Error::SecurityViolation {
832            reason: format!("Command path is not a file: {command}"),
833        });
834    }
835
836    // Verify executable permissions (Unix only)
837    #[cfg(unix)]
838    {
839        use std::os::unix::fs::PermissionsExt;
840        let metadata = std::fs::metadata(path).map_err(|e| Error::SecurityViolation {
841            reason: format!("Cannot read command metadata: {e}"),
842        })?;
843        let permissions = metadata.permissions();
844        let mode = permissions.mode();
845
846        // Check if any execute bit is set (owner, group, or other)
847        if mode & 0o111 == 0 {
848            return Err(Error::SecurityViolation {
849                reason: format!("Command file is not executable: {command}"),
850            });
851        }
852    }
853
854    Ok(())
855}
856
857/// Validates an environment variable name.
858///
859/// This is an internal helper that checks if an environment variable name is in the
860/// forbidden list. Length is already bounded unconditionally by [`validate_stdio_size_bounds`]
861/// before this runs.
862///
863/// The comparison is ASCII-case-insensitive: Windows treats environment variable names as
864/// case-insensitive at the OS/`CreateProcess` level (and so does std's `Command` environment
865/// block on that platform), so a case-varied spelling such as `Path` or `path` would otherwise
866/// bypass this list while still functioning as a real override when the subprocess is spawned.
867///
868/// Before that comparison, the name is required to match the conventional POSIX/Windows
869/// environment-variable-name charset `[A-Za-z_][A-Za-z0-9_]*`. Windows' own name comparison
870/// folds case using the OS's Unicode uppercase table, which is broader than the ASCII-only
871/// folding `eq_ignore_ascii_case` performs here — e.g. `ı` (U+0131, Turkish dotless i)
872/// uppercases to `I` and `ſ` (U+017F, long s) uppercases to `S` on Windows, so a forbidden name
873/// spelled with one of these in place of the ASCII letter (e.g. `NODE_OPTıONS`) would pass the
874/// ASCII-only comparison yet still resolve as the forbidden name once handed to the OS
875/// environment block. Rather than chase every such Unicode confusable, any name outside the
876/// conventional identifier charset is rejected outright, since it is not a valid environment
877/// variable name to begin with.
878fn validate_env_name(name: &str) -> Result<()> {
879    let mut chars = name.chars();
880    let is_valid_charset = chars
881        .next()
882        .is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
883        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
884    if !is_valid_charset {
885        return Err(Error::SecurityViolation {
886            reason: format!(
887                "environment variable name is not a valid identifier (expected \
888                 {ENV_NAME_CHARSET_DESC}): {name}"
889            ),
890        });
891    }
892
893    // Check for forbidden env names (exact match, case-insensitive)
894    if FORBIDDEN_ENV_NAMES
895        .iter()
896        .any(|forbidden| name.eq_ignore_ascii_case(forbidden))
897    {
898        return Err(Error::SecurityViolation {
899            reason: format!("Forbidden environment variable name: {name}"),
900        });
901    }
902
903    // Check for DYLD_* prefix (macOS dynamic linker variables), case-insensitive.
904    // Compared as bytes (not `str` slicing) so a multi-byte UTF-8 name can never
905    // panic on a non-char-boundary split.
906    if name
907        .as_bytes()
908        .get(..FORBIDDEN_ENV_PREFIX.len())
909        .is_some_and(|prefix| prefix.eq_ignore_ascii_case(FORBIDDEN_ENV_PREFIX.as_bytes()))
910    {
911        return Err(Error::SecurityViolation {
912            reason: format!("Forbidden environment variable prefix DYLD_: {name}"),
913        });
914    }
915
916    Ok(())
917}
918
919#[cfg(test)]
920mod tests {
921    use super::*;
922    use std::collections::HashMap;
923    use std::fs;
924    use std::io::Write;
925
926    #[test]
927    fn test_validate_server_config_binary_name() {
928        // Binary names (not absolute paths) should be valid
929        assert!(
930            ServerConfig::builder()
931                .command("docker".to_string())
932                .build()
933                .is_ok()
934        );
935        assert!(
936            ServerConfig::builder()
937                .command("python".to_string())
938                .build()
939                .is_ok()
940        );
941        assert!(
942            ServerConfig::builder()
943                .command("node".to_string())
944                .build()
945                .is_ok()
946        );
947    }
948
949    #[test]
950    fn test_validate_server_config_binary_with_args() {
951        let result = ServerConfig::builder()
952            .command("docker".to_string())
953            .arg("run".to_string())
954            .arg("--rm".to_string())
955            .arg("mcp-server".to_string())
956            .build();
957        assert!(result.is_ok());
958    }
959
960    #[test]
961    fn test_validate_server_config_empty_command() {
962        // Empty command should fail during build
963        let result = ServerConfig::builder().command(String::new()).build();
964        assert!(result.is_err());
965        assert!(result.unwrap_err().to_string().contains("empty"));
966
967        // Whitespace-only command should fail during build
968        let result = ServerConfig::builder().command("   ".to_string()).build();
969        assert!(result.is_err());
970        assert!(result.unwrap_err().to_string().contains("empty"));
971    }
972
973    #[test]
974    fn test_validate_server_config_command_with_metacharacters() {
975        let dangerous_commands = vec![
976            "docker; rm -rf /",
977            "docker | cat",
978            "docker && echo pwned",
979            "docker > /tmp/out",
980            "docker < /tmp/in",
981            "docker `whoami`",
982            "docker $(whoami)",
983            "docker & background",
984            "docker\nrm -rf /",
985        ];
986
987        for cmd in dangerous_commands {
988            // `build()` now runs security validation internally, so a config
989            // carrying a shell metacharacter is rejected at construction.
990            let result = ServerConfig::builder().command(cmd.to_string()).build();
991            assert!(
992                result.is_err(),
993                "Should reject command with metacharacters: {cmd}"
994            );
995            if let Err(Error::SecurityViolation { reason }) = result {
996                assert!(
997                    reason.contains("forbidden") || reason.contains("metacharacter"),
998                    "Error should mention forbidden character: {reason}"
999                );
1000            }
1001        }
1002    }
1003
1004    #[test]
1005    fn test_validate_server_config_args_with_metacharacters() {
1006        let dangerous_args = vec![
1007            "run; rm -rf /",
1008            "run | cat",
1009            "run && echo pwned",
1010            "run > /tmp/out",
1011            "run < /tmp/in",
1012            "run `whoami`",
1013            "run $(whoami)",
1014            "run & background",
1015            "run\nrm -rf /",
1016        ];
1017
1018        for arg in dangerous_args {
1019            let result = ServerConfig::builder()
1020                .command("docker".to_string())
1021                .arg(arg.to_string())
1022                .build();
1023            assert!(
1024                result.is_err(),
1025                "Should reject arg with metacharacters: {arg}"
1026            );
1027            if let Err(Error::SecurityViolation { reason }) = result {
1028                assert!(
1029                    reason.contains("argument")
1030                        && (reason.contains("forbidden") || reason.contains("metacharacter")),
1031                    "Error should mention argument and forbidden character: {reason}"
1032                );
1033            }
1034        }
1035    }
1036
1037    #[test]
1038    fn test_validate_server_config_arg_with_metacharacter_does_not_leak_secret() {
1039        // Regression test for #229: a rejected arg is routinely a
1040        // misparsed `--api-key sk-...`-style secret; the metacharacter
1041        // error must never echo the raw value.
1042        let secret_shaped_arg = "--api-key sk-live-supersecretvalue1234567890;whoami";
1043        let result = ServerConfig::builder()
1044            .command("docker".to_string())
1045            .arg(secret_shaped_arg.to_string())
1046            .build();
1047
1048        assert!(result.is_err());
1049        if let Err(Error::SecurityViolation { reason }) = result {
1050            assert!(!reason.contains(secret_shaped_arg));
1051            assert!(!reason.contains("sk-live-supersecretvalue1234567890"));
1052        }
1053    }
1054
1055    #[test]
1056    fn test_validate_server_config_empty_arg() {
1057        let result = ServerConfig::builder()
1058            .command("docker".to_string())
1059            .arg(String::new())
1060            .build();
1061        assert!(result.is_err());
1062    }
1063
1064    #[test]
1065    fn test_validate_server_config_forbidden_env_ld_preload() {
1066        let result = ServerConfig::builder()
1067            .command("docker".to_string())
1068            .env("LD_PRELOAD".to_string(), "/evil.so".to_string())
1069            .build();
1070        assert!(result.is_err());
1071        if let Err(Error::SecurityViolation { reason }) = result {
1072            assert!(reason.contains("LD_PRELOAD"));
1073        }
1074    }
1075
1076    #[test]
1077    fn test_validate_server_config_forbidden_env_ld_library_path() {
1078        let result = ServerConfig::builder()
1079            .command("docker".to_string())
1080            .env("LD_LIBRARY_PATH".to_string(), "/evil".to_string())
1081            .build();
1082        assert!(result.is_err());
1083        if let Err(Error::SecurityViolation { reason }) = result {
1084            assert!(reason.contains("LD_LIBRARY_PATH"));
1085        }
1086    }
1087
1088    #[test]
1089    fn test_validate_server_config_forbidden_env_dyld() {
1090        let dyld_vars = vec![
1091            "DYLD_INSERT_LIBRARIES",
1092            "DYLD_LIBRARY_PATH",
1093            "DYLD_FRAMEWORK_PATH",
1094            "DYLD_PRINT_TO_FILE",
1095            "DYLD_CUSTOM_VAR",
1096        ];
1097
1098        for var in dyld_vars {
1099            let result = ServerConfig::builder()
1100                .command("docker".to_string())
1101                .env(var.to_string(), "/evil".to_string())
1102                .build();
1103            assert!(result.is_err(), "Should reject DYLD_* variable: {var}");
1104            if let Err(Error::SecurityViolation { reason }) = result {
1105                assert!(
1106                    reason.contains("DYLD_"),
1107                    "Error should mention DYLD_: {reason}"
1108                );
1109            }
1110        }
1111    }
1112
1113    #[test]
1114    fn test_validate_server_config_forbidden_env_path() {
1115        let result = ServerConfig::builder()
1116            .command("docker".to_string())
1117            .env("PATH".to_string(), "/evil:/usr/bin".to_string())
1118            .build();
1119        assert!(result.is_err());
1120        if let Err(Error::SecurityViolation { reason }) = result {
1121            assert!(reason.contains("PATH"));
1122        }
1123    }
1124
1125    /// #221.1 — the interpreter hijack vectors added alongside the original
1126    /// dynamic-linker/`PATH` entries must also be rejected.
1127    #[test]
1128    fn test_validate_server_config_forbidden_env_interpreter_hijack_vectors() {
1129        // NODE_OPTIONS and BASH_ENV have their own dedicated tests above.
1130        let interpreter_vars = vec![
1131            "PYTHONPATH",
1132            "PYTHONSTARTUP",
1133            "RUBYOPT",
1134            "PERL5OPT",
1135            "JAVA_TOOL_OPTIONS",
1136            "LD_AUDIT",
1137        ];
1138
1139        for var in interpreter_vars {
1140            let result = ServerConfig::builder()
1141                .command("docker".to_string())
1142                .env(var.to_string(), "evil".to_string())
1143                .build();
1144            assert!(result.is_err(), "Should reject variable: {var}");
1145            if let Err(Error::SecurityViolation { reason }) = result {
1146                assert!(reason.contains(var), "Error should mention {var}: {reason}");
1147            }
1148        }
1149    }
1150
1151    #[test]
1152    fn test_validate_server_config_forbidden_env_node_options() {
1153        // NODE_OPTIONS lets a config inject e.g. `--require /tmp/evil.js` into any Node
1154        // subprocess the server itself spawns.
1155        let result = ServerConfig::builder()
1156            .command("node".to_string())
1157            .env(
1158                "NODE_OPTIONS".to_string(),
1159                "--require /tmp/evil.js".to_string(),
1160            )
1161            .build();
1162        assert!(result.is_err());
1163        if let Err(Error::SecurityViolation { reason }) = result {
1164            assert!(reason.contains("NODE_OPTIONS"));
1165        }
1166    }
1167
1168    #[test]
1169    fn test_validate_server_config_forbidden_env_bash_env() {
1170        // BASH_ENV is sourced by non-interactive `bash` before running a script or command.
1171        let result = ServerConfig::builder()
1172            .command("bash".to_string())
1173            .env("BASH_ENV".to_string(), "/tmp/evil.sh".to_string())
1174            .build();
1175        assert!(result.is_err());
1176        if let Err(Error::SecurityViolation { reason }) = result {
1177            assert!(reason.contains("BASH_ENV"));
1178        }
1179    }
1180
1181    #[test]
1182    fn test_validate_server_config_safe_env() {
1183        let result = ServerConfig::builder()
1184            .command("docker".to_string())
1185            .env("LOG_LEVEL".to_string(), "debug".to_string())
1186            .env("DEBUG".to_string(), "1".to_string())
1187            .env("HOME".to_string(), "/home/user".to_string())
1188            .env("MY_CUSTOM_VAR".to_string(), "value".to_string())
1189            .build();
1190        assert!(result.is_ok());
1191    }
1192
1193    #[test]
1194    #[cfg(unix)]
1195    fn test_validate_server_config_absolute_path_valid() {
1196        use std::os::unix::fs::PermissionsExt;
1197
1198        // Create a temporary executable file
1199        let temp_file = "/tmp/test-mcp-server-config";
1200        let mut file = fs::File::create(temp_file).unwrap();
1201        writeln!(file, "#!/bin/sh").unwrap();
1202
1203        // Set execute permissions
1204        let mut perms = fs::metadata(temp_file).unwrap().permissions();
1205        perms.set_mode(0o755);
1206        fs::set_permissions(temp_file, perms).unwrap();
1207
1208        let result = ServerConfig::builder()
1209            .command(temp_file.to_string())
1210            .arg("--port".to_string())
1211            .arg("8080".to_string())
1212            .build();
1213
1214        fs::remove_file(temp_file).ok();
1215
1216        assert!(result.is_ok());
1217    }
1218
1219    #[test]
1220    #[cfg(unix)]
1221    fn test_validate_server_config_absolute_path_not_executable() {
1222        use std::os::unix::fs::PermissionsExt;
1223
1224        // Create a temporary non-executable file
1225        let temp_file = "/tmp/test-mcp-server-config-noexec";
1226        let mut file = fs::File::create(temp_file).unwrap();
1227        writeln!(file, "#!/bin/sh").unwrap();
1228
1229        // Remove execute permissions
1230        let mut perms = fs::metadata(temp_file).unwrap().permissions();
1231        perms.set_mode(0o644);
1232        fs::set_permissions(temp_file, perms).unwrap();
1233
1234        let result = ServerConfig::builder()
1235            .command(temp_file.to_string())
1236            .build();
1237
1238        fs::remove_file(temp_file).ok();
1239
1240        assert!(result.is_err());
1241        if let Err(Error::SecurityViolation { reason }) = result {
1242            assert!(reason.contains("not executable"));
1243        }
1244    }
1245
1246    #[test]
1247    fn test_validate_server_config_absolute_path_nonexistent() {
1248        #[cfg(unix)]
1249        let nonexistent = "/absolutely/nonexistent/path/to/server";
1250        #[cfg(windows)]
1251        let nonexistent = "C:\\absolutely\\nonexistent\\path\\to\\server.exe";
1252
1253        let result = ServerConfig::builder()
1254            .command(nonexistent.to_string())
1255            .build();
1256
1257        assert!(result.is_err());
1258        if let Err(Error::SecurityViolation { reason }) = result {
1259            assert!(reason.contains("does not exist"));
1260        }
1261    }
1262
1263    #[test]
1264    fn test_validate_server_config_with_cwd() {
1265        // cwd doesn't affect validation (it's not security-critical)
1266        let result = ServerConfig::builder()
1267            .command("docker".to_string())
1268            .cwd(std::path::PathBuf::from("/tmp"))
1269            .build();
1270        assert!(result.is_ok());
1271    }
1272
1273    #[test]
1274    fn test_validate_server_config_complex_valid() {
1275        let result = ServerConfig::builder()
1276            .command("docker".to_string())
1277            .arg("run".to_string())
1278            .arg("--rm".to_string())
1279            .arg("-e".to_string())
1280            .arg("DEBUG=1".to_string())
1281            .arg("mcp-server".to_string())
1282            .env("LOG_LEVEL".to_string(), "info".to_string())
1283            .env("CACHE_DIR".to_string(), "/var/cache".to_string())
1284            .cwd(std::path::PathBuf::from("/opt/app"))
1285            .build();
1286        assert!(result.is_ok());
1287    }
1288
1289    #[test]
1290    fn test_validate_server_config_default_timeouts_pass() {
1291        let result = ServerConfig::builder()
1292            .command("docker".to_string())
1293            .build();
1294        assert!(result.is_ok());
1295    }
1296
1297    #[test]
1298    fn test_validate_server_config_zero_connect_timeout_rejected() {
1299        let result = ServerConfig::builder()
1300            .command("docker".to_string())
1301            .connect_timeout(std::time::Duration::ZERO)
1302            .build();
1303        assert!(result.is_err());
1304        if let Err(Error::ValidationError { field, reason }) = result {
1305            assert_eq!(field, "connect_timeout");
1306            assert!(reason.contains("greater than zero"));
1307        } else {
1308            panic!("expected ValidationError");
1309        }
1310    }
1311
1312    #[test]
1313    fn test_validate_server_config_zero_discover_timeout_rejected() {
1314        let result = ServerConfig::builder()
1315            .command("docker".to_string())
1316            .discover_timeout(std::time::Duration::ZERO)
1317            .build();
1318        assert!(result.is_err());
1319        if let Err(Error::ValidationError { field, .. }) = result {
1320            assert_eq!(field, "discover_timeout");
1321        } else {
1322            panic!("expected ValidationError");
1323        }
1324    }
1325
1326    #[test]
1327    fn test_validate_server_config_above_max_timeout_rejected() {
1328        let result = ServerConfig::builder()
1329            .command("docker".to_string())
1330            .connect_timeout(std::time::Duration::from_secs(601))
1331            .build();
1332        assert!(result.is_err());
1333        if let Err(Error::ValidationError { field, reason }) = result {
1334            assert_eq!(field, "connect_timeout");
1335            assert!(reason.contains("exceeds maximum"));
1336        } else {
1337            panic!("expected ValidationError");
1338        }
1339    }
1340
1341    #[test]
1342    fn test_validate_server_config_in_bounds_timeout_accepted() {
1343        let result = ServerConfig::builder()
1344            .command("docker".to_string())
1345            .connect_timeout(std::time::Duration::from_mins(1))
1346            .discover_timeout(std::time::Duration::from_mins(10))
1347            .build();
1348        assert!(result.is_ok());
1349    }
1350
1351    #[test]
1352    fn test_validate_env_name_edge_cases() {
1353        // Test exact matches and prefix matches
1354        assert!(validate_env_name("LD_PRELOAD").is_err());
1355        assert!(validate_env_name("DYLD_TEST").is_err());
1356        assert!(validate_env_name("PATH").is_err());
1357
1358        // These should be OK (not in forbidden list)
1359        assert!(validate_env_name("LD_DEBUG").is_ok()); // Not in list
1360        assert!(validate_env_name("MY_PATH").is_ok()); // Not exact match
1361        assert!(validate_env_name("DYLD").is_ok()); // No underscore, not prefix match
1362    }
1363
1364    #[test]
1365    fn test_validate_env_name_case_insensitive() {
1366        // Windows treats env var names as case-insensitive, so any casing of an
1367        // exact-match forbidden name must be rejected, not just the canonical spelling.
1368        assert!(validate_env_name("Path").is_err());
1369        assert!(validate_env_name("path").is_err());
1370        assert!(validate_env_name("PATH").is_err());
1371        assert!(validate_env_name("PaTh").is_err());
1372
1373        assert!(validate_env_name("Ld_Preload").is_err());
1374        assert!(validate_env_name("ld_preload").is_err());
1375
1376        assert!(validate_env_name("Node_Options").is_err());
1377        assert!(validate_env_name("node_options").is_err());
1378
1379        // Prefix match must also be case-insensitive.
1380        assert!(validate_env_name("dyld_insert_libraries").is_err());
1381        assert!(validate_env_name("Dyld_Insert_Libraries").is_err());
1382        assert!(validate_env_name("dYlD_anything").is_err());
1383
1384        // Sanity: names that are not case variants of a forbidden entry stay allowed.
1385        assert!(validate_env_name("MyPath").is_ok());
1386        assert!(validate_env_name("dyl").is_ok()); // too short for DYLD_ prefix, not a match
1387    }
1388
1389    #[test]
1390    fn test_validate_env_name_prefix_check_does_not_panic_on_utf8_boundary() {
1391        // "DYL€_REST": the euro sign ('\u{20AC}') is a 3-byte UTF-8 sequence occupying byte
1392        // indices 3..6, so byte index `FORBIDDEN_ENV_PREFIX.len()` (5) falls strictly inside
1393        // it and is not a char boundary. A naive `name[..5]` slice would panic here. The
1394        // charset check now rejects this name (non-ASCII characters are never valid identifier
1395        // characters) before the byte-slice `eq_ignore_ascii_case` prefix comparison even runs,
1396        // but that comparison must still not panic if reached directly.
1397        let name = "DYL\u{20AC}_REST";
1398        assert_eq!(name.len(), 3 + 3 + 5);
1399        assert!(!name.is_char_boundary(5));
1400        assert!(validate_env_name(name).is_err());
1401    }
1402
1403    /// #438 — Windows' own environment-name comparison folds case using the OS's Unicode
1404    /// uppercase table, which is broader than `eq_ignore_ascii_case`. A forbidden name spelled
1405    /// with a Unicode confusable in place of an ASCII letter must be rejected outright by the
1406    /// charset check, since it is not a valid `[A-Za-z_][A-Za-z0-9_]*` identifier to begin with.
1407    #[test]
1408    fn test_validate_env_name_rejects_unicode_case_confusables() {
1409        // U+0131 LATIN SMALL LETTER DOTLESS I uppercases to 'I' on Windows.
1410        assert!(validate_env_name("NODE_OPT\u{0131}ONS").is_err());
1411        // U+017F LATIN SMALL LETTER LONG S uppercases to 'S' on Windows.
1412        assert!(validate_env_name("JAVA_TOOL_OPTION\u{017F}").is_err());
1413    }
1414
1415    #[test]
1416    fn test_validate_env_name_charset_accepts_valid_ascii_identifiers() {
1417        assert!(validate_env_name("MY_VAR_1").is_ok());
1418        assert!(validate_env_name("_LEADING_UNDERSCORE").is_ok());
1419        assert!(validate_env_name("A").is_ok());
1420        assert!(validate_env_name("a1").is_ok());
1421    }
1422
1423    #[test]
1424    fn test_validate_env_name_charset_rejects_empty_and_leading_digit() {
1425        assert!(validate_env_name("").is_err());
1426        assert!(validate_env_name("1FOO").is_err());
1427        assert!(validate_env_name("9").is_err());
1428    }
1429
1430    #[test]
1431    fn test_env_name_charset_pattern_matches_desc() {
1432        // Guards against `ENV_NAME_CHARSET_DESC` (used in the rejection message) and
1433        // `env_name_charset_pattern()` (rendered into the generated TS bridge) ever
1434        // describing a different charset.
1435        assert_eq!(
1436            env_name_charset_pattern(),
1437            format!("^{ENV_NAME_CHARSET_DESC}$")
1438        );
1439    }
1440
1441    #[test]
1442    fn test_env_name_charset_desc_matches_pattern() {
1443        assert_eq!(
1444            env_name_charset_desc(),
1445            format!("^{ENV_NAME_CHARSET_DESC}$")
1446                .trim_start_matches('^')
1447                .trim_end_matches('$')
1448        );
1449        assert_eq!(env_name_charset_desc(), ENV_NAME_CHARSET_DESC);
1450    }
1451
1452    #[test]
1453    fn test_env_name_charset_pattern_has_no_ts_string_literal_metacharacters() {
1454        // The generated TS bridge embeds this pattern inside a single-quoted string literal
1455        // (`new RegExp('...')`), passed through `sanitize_ts_string_literal` for defense in
1456        // depth (see `BridgeContext::default` in `mcp-codegen`). This test guards the
1457        // assumption that currently makes that escaping a no-op: if a future edit introduces a
1458        // `'` or `\` into this pattern, the escaping stops being a no-op and becomes the only
1459        // thing standing between this constant and a broken (or silently different) regex in
1460        // the rendered bridge — see #471/#467 critique S2.
1461        let pattern = env_name_charset_pattern();
1462        assert!(
1463            !pattern.contains('\''),
1464            "pattern must not contain a quote: {pattern}"
1465        );
1466        assert!(
1467            !pattern.contains('\\'),
1468            "pattern must not contain a backslash: {pattern}"
1469        );
1470    }
1471
1472    #[test]
1473    fn test_forbidden_env_constants_are_already_ascii_uppercase() {
1474        // The generated TS runtime bridge (`runtime-bridge.ts.hbs`) renders these constants
1475        // verbatim and upper-cases only the *input* name before comparing, relying on
1476        // `FORBIDDEN_ENV_NAMES`/`FORBIDDEN_ENV_PREFIX` already being upper-case. This invariant
1477        // protects that render-from-Rust drift guarantee.
1478        for forbidden in FORBIDDEN_ENV_NAMES {
1479            assert_eq!(*forbidden, forbidden.to_ascii_uppercase());
1480        }
1481        assert_eq!(
1482            FORBIDDEN_ENV_PREFIX,
1483            FORBIDDEN_ENV_PREFIX.to_ascii_uppercase()
1484        );
1485    }
1486
1487    // ── Http/Sse transport validation ────────────────────────────────────────
1488
1489    #[test]
1490    fn test_validate_server_config_http_valid() {
1491        let result = ServerConfig::builder()
1492            .http_transport("https://api.example.com/mcp".to_string())
1493            .build();
1494        assert!(result.is_ok());
1495    }
1496
1497    #[test]
1498    fn test_validate_server_config_sse_valid() {
1499        let result = ServerConfig::builder()
1500            .sse_transport("https://api.example.com/sse".to_string())
1501            .build();
1502        assert!(result.is_ok());
1503    }
1504
1505    #[test]
1506    fn test_validate_server_config_http_with_valid_headers() {
1507        let result = ServerConfig::builder()
1508            .http_transport("https://api.example.com/mcp".to_string())
1509            .header("Authorization".to_string(), "Bearer token123".to_string())
1510            .build();
1511        assert!(result.is_ok());
1512    }
1513
1514    /// #313 — `url` is a required (non-`#[serde(default)]`) field of `Transport::Http`, so a
1515    /// hand-edited `mcp.json` with `"transport": "http"` and no `url` key now fails to
1516    /// deserialize at all, rather than producing an incomplete `ServerConfig` that only
1517    /// `validate_server_config` would have caught downstream (see also
1518    /// `server_config::tests::test_deserialize_http_config_missing_url_is_rejected`).
1519    #[test]
1520    fn test_validate_server_config_http_missing_url_rejected() {
1521        let result: std::result::Result<ServerConfig, _> =
1522            serde_json::from_str(r#"{"transport": "http"}"#);
1523        assert!(result.is_err());
1524    }
1525
1526    #[test]
1527    fn test_validate_server_config_sse_missing_url_rejected() {
1528        let result: std::result::Result<ServerConfig, _> =
1529            serde_json::from_str(r#"{"transport": "sse"}"#);
1530        assert!(result.is_err());
1531    }
1532
1533    #[test]
1534    fn test_validate_server_config_http_rejects_non_http_scheme() {
1535        for url in [
1536            "file:///etc/passwd",
1537            "unix:///tmp/socket",
1538            "ftp://host/path",
1539        ] {
1540            let result = ServerConfig::builder()
1541                .http_transport(url.to_string())
1542                .build();
1543            assert!(result.is_err(), "should reject scheme: {url}");
1544            if let Err(Error::SecurityViolation { reason }) = result {
1545                assert!(reason.contains("http://") || reason.contains("https://"));
1546            } else {
1547                panic!("expected SecurityViolation for url: {url}");
1548            }
1549        }
1550    }
1551
1552    #[test]
1553    fn test_validate_server_config_http_accepts_case_insensitive_scheme() {
1554        for url in ["HTTP://api.example.com/mcp", "HTTPS://api.example.com/mcp"] {
1555            let result = ServerConfig::builder()
1556                .http_transport(url.to_string())
1557                .build();
1558            assert!(
1559                result.is_ok(),
1560                "should accept case-insensitive scheme: {url}"
1561            );
1562        }
1563    }
1564
1565    #[test]
1566    fn test_validate_server_config_http_rejects_scheme_lookalike() {
1567        // "httpsomething" must not be accepted as a loose prefix match of "http".
1568        let result = ServerConfig::builder()
1569            .http_transport("httpsomething://api.example.com/mcp".to_string())
1570            .build();
1571        assert!(result.is_err());
1572    }
1573
1574    #[test]
1575    fn test_validate_server_config_http_rejects_duplicate_header_case_insensitive() {
1576        let result = ServerConfig::builder()
1577            .http_transport("https://api.example.com/mcp".to_string())
1578            .header("Authorization".to_string(), "Bearer one".to_string())
1579            .header("authorization".to_string(), "Bearer two".to_string())
1580            .build();
1581
1582        assert!(result.is_err());
1583        if let Err(Error::SecurityViolation { reason }) = result {
1584            assert!(reason.contains("duplicate header"));
1585            assert!(!reason.contains("Authorization"));
1586            assert!(!reason.to_ascii_lowercase().contains("authorization"));
1587        } else {
1588            panic!("expected SecurityViolation for duplicate header name");
1589        }
1590    }
1591
1592    #[test]
1593    fn test_validate_server_config_http_rejects_duplicate_header_secret_shaped_name() {
1594        // A misparsed `Name: Value`-style CLI argument can leave a "key" that
1595        // is entirely RFC 7230 token-charset (alphanumerics plus
1596        // `!#$%&'*+-.^_`|~`), e.g. a hex-encoded key or JWT-like value using
1597        // only `A-Za-z0-9-_.`. Such a name passes `validate_header_name_string`
1598        // and must not be echoed if it collides case-insensitively.
1599        let secret_name = "eyJhbGciOiJIUzI1NiJ9.super-secret-token-material";
1600        let result = ServerConfig::builder()
1601            .http_transport("https://api.example.com/mcp".to_string())
1602            .header(secret_name.to_string(), "value one".to_string())
1603            .header(secret_name.to_ascii_uppercase(), "value two".to_string())
1604            .build();
1605
1606        assert!(result.is_err());
1607        if let Err(Error::SecurityViolation { reason }) = result {
1608            assert!(reason.contains("duplicate header"));
1609            assert!(!reason.contains(secret_name));
1610            assert!(
1611                !reason
1612                    .to_ascii_lowercase()
1613                    .contains(&secret_name.to_ascii_lowercase())
1614            );
1615        } else {
1616            panic!("expected SecurityViolation for duplicate header name");
1617        }
1618    }
1619
1620    #[test]
1621    fn test_validate_server_config_http_rejects_header_name_with_invalid_tchar() {
1622        // Space, ':', and '@' are not control characters but are still
1623        // invalid HTTP header-name characters (outside RFC 7230's `token`).
1624        for bad_name in ["X Bad Header", "X:Bad", "X@Bad"] {
1625            let result = ServerConfig::builder()
1626                .http_transport("https://api.example.com/mcp".to_string())
1627                .header(bad_name.to_string(), "value".to_string())
1628                .build();
1629
1630            assert!(result.is_err(), "should reject header name: {bad_name}");
1631            if let Err(Error::SecurityViolation { reason }) = result {
1632                assert!(reason.contains("header name"));
1633                assert!(!reason.contains(bad_name));
1634            } else {
1635                panic!("expected SecurityViolation for header name: {bad_name}");
1636            }
1637        }
1638    }
1639
1640    #[test]
1641    fn test_validate_server_config_http_rejects_secret_shaped_header_name_without_leaking_it() {
1642        // Reproduces the #215 leak vector: a `Name=Value` CLI argument
1643        // mis-split on the wrong `=` leaves a base64-encoded secret in the
1644        // "name" position. It only needs one non-tchar byte (here `/`) to
1645        // reach `validate_header_name_string`'s tchar-violation branch,
1646        // which must not echo it back.
1647        let secret_name = "aGVsbG8/d29ybGQK=supersecretpayload";
1648        let result = ServerConfig::builder()
1649            .http_transport("https://api.example.com/mcp".to_string())
1650            .header(secret_name.to_string(), "value".to_string())
1651            .build();
1652
1653        assert!(result.is_err());
1654        if let Err(Error::SecurityViolation { reason }) = result {
1655            assert!(reason.contains("header name"));
1656            assert!(!reason.contains(secret_name));
1657            assert!(!reason.contains("aGVsbG8"));
1658        } else {
1659            panic!("expected SecurityViolation for header name");
1660        }
1661    }
1662
1663    #[test]
1664    fn test_validate_server_config_http_rejects_control_char_in_header_name() {
1665        let result = ServerConfig::builder()
1666            .http_transport("https://api.example.com/mcp".to_string())
1667            .header("X-Bad\r\nHeader".to_string(), "value".to_string())
1668            .build();
1669
1670        assert!(result.is_err());
1671        if let Err(Error::SecurityViolation { reason }) = result {
1672            assert!(reason.contains("header name"));
1673            assert!(!reason.contains("X-Bad"));
1674        } else {
1675            panic!("expected SecurityViolation for header name");
1676        }
1677    }
1678
1679    #[test]
1680    fn test_validate_server_config_http_rejects_control_char_in_header_value() {
1681        let result = ServerConfig::builder()
1682            .http_transport("https://api.example.com/mcp".to_string())
1683            .header(
1684                "Authorization".to_string(),
1685                "Bearer sekrit\r\nX-Injected: evil".to_string(),
1686            )
1687            .build();
1688
1689        assert!(result.is_err());
1690        if let Err(Error::SecurityViolation { reason }) = result {
1691            assert!(reason.contains("header value"));
1692            // Neither the header value nor its (ordinary, non-secret-shaped
1693            // here) name need to appear — the name is withheld unconditionally
1694            // since this path cannot distinguish an ordinary name from a
1695            // secret-shaped one.
1696            assert!(!reason.contains("Authorization"));
1697            assert!(!reason.contains("sekrit"));
1698            assert!(!reason.contains("X-Injected"));
1699        } else {
1700            panic!("expected SecurityViolation for header value");
1701        }
1702    }
1703
1704    #[test]
1705    fn test_validate_server_config_http_rejects_control_char_in_value_with_secret_shaped_name() {
1706        // Reproduces the critic's S5 repro: a JWT-shaped header *name* (fully
1707        // RFC 7230 token-charset, so it clears `validate_header_name_string`)
1708        // paired with a control character in the *value*. Both the name and
1709        // the control-char-bearing value must be absent from the error.
1710        let secret_name = "eyJhbGciOiJIUzI1NiJ9.abc-secret_material";
1711        let result = ServerConfig::builder()
1712            .http_transport("https://api.example.com/mcp".to_string())
1713            .header(secret_name.to_string(), "x\ry".to_string())
1714            .build();
1715
1716        assert!(result.is_err());
1717        if let Err(Error::SecurityViolation { reason }) = result {
1718            assert!(reason.contains("header value"));
1719            assert!(!reason.contains(secret_name));
1720            assert!(!reason.contains("eyJhbGciOiJIUzI1NiJ9"));
1721        } else {
1722            panic!("expected SecurityViolation for header value");
1723        }
1724    }
1725
1726    // ── Resource-exhaustion bounds (issue #198) ──────────────────────────────
1727
1728    #[test]
1729    fn test_validate_server_config_rejects_too_many_args() {
1730        let args = (0..=MAX_ARG_COUNT).map(|i| format!("a{i}")).collect();
1731        let result = ServerConfig::builder()
1732            .command("docker".to_string())
1733            .args(args)
1734            .build();
1735        assert!(result.is_err());
1736        if let Err(Error::SecurityViolation { reason }) = result {
1737            assert!(reason.contains("too many arguments"));
1738        } else {
1739            panic!("expected SecurityViolation for too many arguments");
1740        }
1741    }
1742
1743    #[test]
1744    fn test_validate_server_config_accepts_max_arg_count() {
1745        let args = (0..MAX_ARG_COUNT).map(|i| format!("a{i}")).collect();
1746        let result = ServerConfig::builder()
1747            .command("docker".to_string())
1748            .args(args)
1749            .build();
1750        assert!(result.is_ok());
1751    }
1752
1753    #[test]
1754    fn test_validate_server_config_rejects_oversized_arg() {
1755        let long_arg = "a".repeat(MAX_ARG_LEN + 1);
1756        let result = ServerConfig::builder()
1757            .command("docker".to_string())
1758            .arg(long_arg)
1759            .build();
1760        assert!(result.is_err());
1761        if let Err(Error::SecurityViolation { reason }) = result {
1762            assert!(reason.contains("too long"));
1763        } else {
1764            panic!("expected SecurityViolation for oversized argument");
1765        }
1766    }
1767
1768    #[test]
1769    fn test_validate_server_config_accepts_arg_at_max_len() {
1770        let arg_at_cap = "a".repeat(MAX_ARG_LEN);
1771        let result = ServerConfig::builder()
1772            .command("docker".to_string())
1773            .arg(arg_at_cap)
1774            .build();
1775        assert!(result.is_ok());
1776    }
1777
1778    #[test]
1779    fn test_validate_server_config_rejects_oversized_command() {
1780        let long_command = "a".repeat(MAX_ARG_LEN + 1);
1781        let result = ServerConfig::builder().command(long_command).build();
1782        assert!(result.is_err());
1783        if let Err(Error::SecurityViolation { reason }) = result {
1784            assert!(reason.contains("too long"));
1785        } else {
1786            panic!("expected SecurityViolation for oversized command");
1787        }
1788    }
1789
1790    #[test]
1791    fn test_validate_server_config_rejects_too_many_env_vars() {
1792        let env: HashMap<String, String> = (0..=MAX_ENV_COUNT)
1793            .map(|i| (format!("VAR_{i}"), "value".to_string()))
1794            .collect();
1795        let result = ServerConfig::builder()
1796            .command("docker".to_string())
1797            .environment(env)
1798            .build();
1799        assert!(result.is_err());
1800        if let Err(Error::SecurityViolation { reason }) = result {
1801            assert!(reason.contains("too many environment variables"));
1802        } else {
1803            panic!("expected SecurityViolation for too many env vars");
1804        }
1805    }
1806
1807    #[test]
1808    fn test_validate_server_config_accepts_max_env_count() {
1809        let env: HashMap<String, String> = (0..MAX_ENV_COUNT)
1810            .map(|i| (format!("VAR_{i}"), "value".to_string()))
1811            .collect();
1812        let result = ServerConfig::builder()
1813            .command("docker".to_string())
1814            .environment(env)
1815            .build();
1816        assert!(result.is_ok());
1817    }
1818
1819    #[test]
1820    fn test_validate_server_config_rejects_oversized_env_value() {
1821        let long_value = "v".repeat(MAX_ENV_VALUE_LEN + 1);
1822        let result = ServerConfig::builder()
1823            .command("docker".to_string())
1824            .env("MY_VAR".to_string(), long_value)
1825            .build();
1826        assert!(result.is_err());
1827        if let Err(Error::SecurityViolation { reason }) = result {
1828            assert!(reason.contains("too long"));
1829        } else {
1830            panic!("expected SecurityViolation for oversized env value");
1831        }
1832    }
1833
1834    #[test]
1835    fn test_validate_server_config_accepts_env_value_at_max_len() {
1836        let value_at_cap = "v".repeat(MAX_ENV_VALUE_LEN);
1837        let result = ServerConfig::builder()
1838            .command("docker".to_string())
1839            .env("MY_VAR".to_string(), value_at_cap)
1840            .build();
1841        assert!(result.is_ok());
1842    }
1843
1844    #[test]
1845    fn test_validate_server_config_rejects_oversized_env_name() {
1846        let long_name = "V".repeat(MAX_ARG_LEN + 1);
1847        let result = ServerConfig::builder()
1848            .command("docker".to_string())
1849            .env(long_name, "value".to_string())
1850            .build();
1851        assert!(result.is_err());
1852        if let Err(Error::SecurityViolation { reason }) = result {
1853            assert!(reason.contains("too long"));
1854        } else {
1855            panic!("expected SecurityViolation for oversized env name");
1856        }
1857    }
1858
1859    #[test]
1860    fn test_validate_server_config_rejects_too_many_headers() {
1861        let headers: HashMap<String, String> = (0..=MAX_HEADER_COUNT)
1862            .map(|i| (format!("X-Header-{i}"), "value".to_string()))
1863            .collect();
1864        let result = ServerConfig::builder()
1865            .http_transport("https://api.example.com/mcp".to_string())
1866            .headers(headers)
1867            .build();
1868        assert!(result.is_err());
1869        if let Err(Error::SecurityViolation { reason }) = result {
1870            assert!(reason.contains("too many headers"));
1871        } else {
1872            panic!("expected SecurityViolation for too many headers");
1873        }
1874    }
1875
1876    #[test]
1877    fn test_validate_server_config_accepts_max_header_count() {
1878        let headers: HashMap<String, String> = (0..MAX_HEADER_COUNT)
1879            .map(|i| (format!("X-Header-{i}"), "value".to_string()))
1880            .collect();
1881        let result = ServerConfig::builder()
1882            .http_transport("https://api.example.com/mcp".to_string())
1883            .headers(headers)
1884            .build();
1885        assert!(result.is_ok());
1886    }
1887
1888    #[test]
1889    fn test_validate_server_config_rejects_oversized_header_value() {
1890        let long_value = "v".repeat(MAX_HEADER_VALUE_LEN + 1);
1891        let result = ServerConfig::builder()
1892            .http_transport("https://api.example.com/mcp".to_string())
1893            .header("Authorization".to_string(), long_value)
1894            .build();
1895        assert!(result.is_err());
1896        if let Err(Error::SecurityViolation { reason }) = result {
1897            assert!(reason.contains("too long"));
1898        } else {
1899            panic!("expected SecurityViolation for oversized header value");
1900        }
1901    }
1902
1903    #[test]
1904    fn test_validate_server_config_accepts_header_value_at_max_len() {
1905        let value_at_cap = "v".repeat(MAX_HEADER_VALUE_LEN);
1906        let result = ServerConfig::builder()
1907            .http_transport("https://api.example.com/mcp".to_string())
1908            .header("Authorization".to_string(), value_at_cap)
1909            .build();
1910        assert!(result.is_ok());
1911    }
1912
1913    // ── #313: cross-transport fields are now unrepresentable ────────────────────
1914    //
1915    // The S2/N1 bypass this section used to guard against (a hand-edited `mcp.json`
1916    // populating `args`/`env`/`headers`/`url`/`command` for the "wrong" transport, since every
1917    // field used to exist unconditionally at the type level) is closed by construction as of
1918    // #313: `Transport::Http`/`Transport::Sse` have no `command`/`args`/`env`/`cwd` fields, and
1919    // `Transport::Stdio` has no `url`/`headers` fields. A JSON key that doesn't belong to the
1920    // deserialized variant has no field to populate, so `serde` simply ignores it — the same as
1921    // any other unrecognized key — rather than it being a bypass.
1922
1923    #[test]
1924    fn test_deserialize_ignores_cross_transport_command_field() {
1925        let json = serde_json::json!({
1926            "transport": "http",
1927            "url": "https://api.example.com/mcp",
1928            "command": "a".repeat(MAX_ARG_LEN + 1),
1929        });
1930        let config: ServerConfig = serde_json::from_value(json).expect("valid ServerConfig JSON");
1931
1932        // An Http config has no `command` field to populate, so the oversized value was never
1933        // stored anywhere and is not a resource-exhaustion vector.
1934        assert!(config.command().is_none());
1935        assert!(validate_server_config(&config).is_ok());
1936    }
1937
1938    #[test]
1939    fn test_deserialize_ignores_cross_transport_headers_field() {
1940        let headers: HashMap<String, String> = (0..=MAX_HEADER_COUNT)
1941            .map(|i| (format!("X-Header-{i}"), "value".to_string()))
1942            .collect();
1943        let json = serde_json::json!({
1944            "transport": "stdio",
1945            "command": "docker",
1946            "headers": headers,
1947        });
1948        let config: ServerConfig = serde_json::from_value(json).expect("valid ServerConfig JSON");
1949
1950        assert!(config.headers().is_empty());
1951        assert!(validate_server_config(&config).is_ok());
1952    }
1953
1954    #[test]
1955    fn test_validate_server_config_http_rejects_url_too_long() {
1956        let long_url = format!("https://example.com/{}", "a".repeat(MAX_URL_LEN));
1957        let result = ServerConfig::builder().http_transport(long_url).build();
1958
1959        assert!(result.is_err());
1960        if let Err(Error::SecurityViolation { reason }) = result {
1961            assert!(reason.contains("url too long"));
1962        } else {
1963            panic!("expected SecurityViolation for oversized url");
1964        }
1965    }
1966
1967    #[test]
1968    fn test_validate_server_config_http_accepts_url_at_max_len() {
1969        let prefix = "https://example.com/";
1970        let padding_len = MAX_URL_LEN - prefix.len();
1971        let url_at_cap = format!("{prefix}{}", "a".repeat(padding_len));
1972        assert_eq!(url_at_cap.len(), MAX_URL_LEN);
1973
1974        let result = ServerConfig::builder().http_transport(url_at_cap).build();
1975        assert!(result.is_ok());
1976    }
1977
1978    #[test]
1979    fn test_validate_server_config_http_rejects_header_name_too_long() {
1980        let long_name = format!("X-{}", "a".repeat(MAX_ARG_LEN));
1981        let result = ServerConfig::builder()
1982            .http_transport("https://api.example.com/mcp".to_string())
1983            .header(long_name, "value".to_string())
1984            .build();
1985
1986        assert!(result.is_err());
1987        if let Err(Error::SecurityViolation { reason }) = result {
1988            assert!(reason.contains("header name too long"));
1989        } else {
1990            panic!("expected SecurityViolation for oversized header name");
1991        }
1992    }
1993
1994    #[test]
1995    fn test_validate_server_config_http_accepts_header_name_at_max_len() {
1996        let name_at_cap = "a".repeat(MAX_ARG_LEN);
1997        let result = ServerConfig::builder()
1998            .http_transport("https://api.example.com/mcp".to_string())
1999            .header(name_at_cap, "value".to_string())
2000            .build();
2001
2002        assert!(result.is_ok());
2003    }
2004
2005    #[test]
2006    fn test_validate_server_config_http_timeout_bounds_still_enforced() {
2007        let result = ServerConfig::builder()
2008            .http_transport("https://api.example.com/mcp".to_string())
2009            .connect_timeout(std::time::Duration::ZERO)
2010            .build();
2011
2012        assert!(result.is_err());
2013        if let Err(Error::ValidationError { field, .. }) = result {
2014            assert_eq!(field, "connect_timeout");
2015        } else {
2016            panic!("expected ValidationError for connect_timeout");
2017        }
2018    }
2019}