Skip to main content

camel_component_http/
lib.rs

1pub mod bundle;
2pub(crate) mod client_cache;
3pub mod config;
4mod header_policy;
5pub mod health;
6pub mod registry;
7pub(crate) mod rest_match;
8pub(crate) mod ssrf;
9pub mod static_config;
10pub mod static_dispatch;
11pub mod static_endpoint;
12pub(crate) mod tls;
13#[cfg(test)]
14mod tls_harness;
15pub(crate) mod tls_reload;
16use crate::config::parse_ok_status_code_range;
17pub use bundle::HttpBundle;
18pub use bundle::HttpStaticBundle;
19pub(crate) use client_cache::{
20    HttpComponentKind, PINNED_CLIENT_MAX_ENTRIES, PINNED_CLIENT_TTL, PinnedClientCache,
21};
22pub use config::HttpConfig;
23pub use health::HttpHealthCheck;
24pub use registry::HttpRouteRegistry;
25pub use static_config::HttpStaticConfig;
26pub use static_endpoint::{HttpStaticComponent, HttpStaticConsumer, HttpStaticEndpoint};
27#[cfg(test)]
28pub(crate) use tls::{
29    FORCE_FALLBACK_REBUILD_FAIL, FORCE_WEBPKI_FALLBACK, build_client_call_count,
30    build_client_fallback_count,
31};
32#[cfg(test)]
33// Doc-link shim: tls_harness.rs links `crate::FallbackTrigger::Forced` and
34// `crate::webpki_fallback_client` from doc comments only — no code use, so
35// the re-export must be allowed unused to keep those links resolving.
36#[allow(unused_imports)]
37pub(crate) use tls::{FallbackTrigger, webpki_fallback_client};
38pub(crate) use tls::{build_client, client_or_emergency, strict_tls_error};
39
40use std::collections::HashMap;
41use std::future::Future;
42use std::pin::Pin;
43
44use std::sync::{Arc, Mutex, OnceLock};
45use std::task::{Context, Poll};
46use std::time::Duration;
47
48use tokio::sync::OnceCell;
49use tower::Layer;
50use tower::Service;
51use tracing::debug;
52
53use axum::body::BodyDataStream;
54use camel_api::component_metadata::ComponentMetadata;
55use camel_auth::bearer_token_layer::BearerTokenLayer;
56use camel_auth::oauth2::TokenProvider;
57use camel_component_api::tls_source::ServerTlsSource;
58use camel_component_api::{Body, BoxProcessor, CamelError, Exchange, StreamBody, StreamMetadata};
59use camel_component_api::{Component, Consumer, Endpoint, ProducerContext, RuntimeObservability};
60use camel_component_api::{UriComponents, UriConfig, parse_uri, raw_query_pairs};
61use futures::StreamExt;
62use futures::TryStreamExt;
63use futures::stream::BoxStream;
64
65// ---------------------------------------------------------------------------
66// HttpEndpointConfig
67// ---------------------------------------------------------------------------
68
69/// Configuration for an HTTP client (producer) endpoint.
70///
71/// # Memory Limits
72///
73/// HTTP operations enforce conservative memory limits to prevent denial-of-service
74/// attacks from untrusted network sources. These limits are significantly lower than
75/// file component limits (100MB) because HTTP typically handles API responses rather
76/// than large file transfers, and clients may be untrusted.
77///
78/// ## Default Limits
79///
80/// - **HTTP client body**: 10MB (typical API responses)
81/// - **HTTP server request**: 2MB (untrusted network input - see `HttpServerConfig`)
82/// - **HTTP server response**: 10MB (same as client - see `HttpServerConfig`)
83///
84/// ## Rationale
85///
86/// The 10MB limit for HTTP client responses is appropriate for most API interactions
87/// while providing protection against:
88/// - Malicious servers sending oversized responses
89/// - Runaway processes generating unexpectedly large payloads
90/// - Memory exhaustion attacks
91///
92/// The 2MB server request limit is even more conservative because it handles input
93/// from potentially untrusted clients on the public internet.
94///
95/// ## Overriding Limits
96///
97/// Override the default client body limit using the `maxBodySize` URI parameter:
98///
99/// ```text
100/// http://api.example.com/large-data?maxBodySize=52428800
101/// ```
102///
103/// For server endpoints, use `maxRequestBody` and `maxResponseBody` parameters:
104///
105/// ```text
106/// http://0.0.0.0:8080/upload?maxRequestBody=52428800
107/// ```
108///
109/// ## Behavior When Exceeded
110///
111/// When a body exceeds the configured limit:
112/// - An error is returned immediately
113/// - No memory is exhausted - the limit is checked before allocation
114/// - The HTTP connection is terminated cleanly
115///
116/// ## Security Considerations
117///
118/// HTTP endpoints should be treated with more caution than file endpoints because:
119/// - Clients may be unknown and untrusted
120/// - Network traffic can be spoofed or malicious
121/// - DoS attacks often exploit unbounded resource consumption
122///
123/// Only increase limits when you control both ends of the connection or when
124/// business requirements demand larger payloads.
125#[derive(Clone)]
126pub struct HttpEndpointConfig {
127    pub base_url: String,
128    pub http_method: Option<String>,
129    pub throw_exception_on_failure: bool,
130    pub ok_status_code_range: (u16, u16),
131    pub response_timeout: Option<Duration>,
132    /// Programmatic query parameters, serialized in declaration order with
133    /// minimal RFC-3986 encoding (`%20`, never `+`). Never populated from
134    /// the endpoint URI — set by callers via config construction.
135    pub query_params: Vec<(String, String)>,
136    /// Authored query bytes from the endpoint URI, verbatim (no decode, no
137    /// re-encode, no `RAW(...)` unwrapping). `Some("")` preserves a bare
138    /// `?` marker. Sole carrier of URI-authored pairs; consumed option
139    /// keys are filtered out at serialization time.
140    pub raw_query: Option<String>,
141    pub allow_internal: bool,
142    /// Public-cleartext transport consent (ADR-0081): when `false` (the
143    /// default), cleartext `http://` requests to PUBLIC targets are
144    /// rejected. Independent of `allow_internal` — internal-network
145    /// consent and cleartext-transport consent are separate decisions, and
146    /// there is deliberately no global cleartext lever.
147    pub allow_cleartext: bool,
148    pub blocked_hosts: Vec<String>,
149    pub max_body_size: usize,
150    pub read_timeout_ms: u64,
151    pub max_response_bytes: usize,
152    pub auth: HttpAuth,
153    pub token_provider: Option<Arc<dyn TokenProvider>>,
154    pub user_agent: Option<String>,
155    pub bridge_endpoint: bool,
156    pub connection_close: bool,
157    pub skip_request_headers: Vec<String>,
158    pub skip_response_headers: Vec<String>,
159    pub follow_redirects: bool,
160    pub max_redirects: usize,
161    /// CamelHttpUri host fence (`allowedUriHosts`): `None` when the option
162    /// is absent (override behavior unchanged); `Some` arms the fail-closed
163    /// fence. Parsed entries only — never re-serialized into the outbound
164    /// query.
165    pub allowed_uri_hosts: Option<Vec<AllowedUriHost>>,
166}
167
168/// ADR-0051 redact-by-construction, ADR-0076 strictest-wins: query bytes
169/// (authored `raw_query` and programmatic `query_params`) may carry
170/// credentials. The display-surface Debug renders the raw view
171/// blanket-masked (mirroring `redact_url_for_diagnostics`) and programmatic
172/// values masked, mirroring `UriComponents`' sensitive-value masking.
173/// `base_url` routes through the canonical
174/// [`camel_api::redact::redact_url`] (string surgery, no `url::Url`
175/// roundtrip, so authored bytes are never WHATWG-normalized): userinfo is
176/// masked in every authority window, query and fragment bytes are dropped
177/// behind their sentinels, and the result is capped at 256 bytes (rc-yvjp3
178/// converged the former byte-preserving local variant). Wire fidelity is
179/// unaffected.
180impl std::fmt::Debug for HttpEndpointConfig {
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        f.debug_struct("HttpEndpointConfig")
183            .field("base_url", &camel_api::redact::redact_url(&self.base_url))
184            .field("http_method", &self.http_method)
185            .field(
186                "throw_exception_on_failure",
187                &self.throw_exception_on_failure,
188            )
189            .field("ok_status_code_range", &self.ok_status_code_range)
190            .field("response_timeout", &self.response_timeout)
191            .field(
192                "query_params",
193                &self
194                    .query_params
195                    .iter()
196                    .map(|(key, _)| (key, "***"))
197                    .collect::<Vec<_>>(),
198            )
199            .field("raw_query", &self.raw_query.as_ref().map(|_| "?[redacted]"))
200            .field("allow_internal", &self.allow_internal)
201            .field("allow_cleartext", &self.allow_cleartext)
202            .field("blocked_hosts", &self.blocked_hosts)
203            .field("max_body_size", &self.max_body_size)
204            .field("read_timeout_ms", &self.read_timeout_ms)
205            .field("max_response_bytes", &self.max_response_bytes)
206            .field("auth", &self.auth)
207            .field("token_provider", &self.token_provider)
208            .field("user_agent", &self.user_agent)
209            .field("bridge_endpoint", &self.bridge_endpoint)
210            .field("connection_close", &self.connection_close)
211            .field("skip_request_headers", &self.skip_request_headers)
212            .field("skip_response_headers", &self.skip_response_headers)
213            .field("follow_redirects", &self.follow_redirects)
214            .field("max_redirects", &self.max_redirects)
215            .field("allowed_uri_hosts", &self.allowed_uri_hosts)
216            .finish()
217    }
218}
219
220#[derive(Clone, PartialEq)]
221pub enum HttpAuth {
222    None,
223    Basic { username: String, password: String },
224    Bearer { token: String },
225}
226
227impl std::fmt::Debug for HttpAuth {
228    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229        match self {
230            HttpAuth::None => f.write_str("None"),
231            HttpAuth::Basic { username, .. } => f
232                .debug_struct("Basic")
233                .field("username", username)
234                .field("password", &"***")
235                .finish(),
236            HttpAuth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
237        }
238    }
239}
240
241/// Whether `key` names a camel-http endpoint option consumed at parse time.
242///
243/// Single metadata-driven owner of OUTBOUND option filtering (ADR-0041):
244/// derived from the `#[uri_param]` metadata behind
245/// [`HttpEndpointConfig::uri_options`], so the raw query filter consumes
246/// exactly the keys the component documents — no duplicated handwritten
247/// key lists. `from_components`'s manual typed parsing stays direct and
248/// unchanged; this predicate never re-wires it.
249fn is_consumed_option(key: &str) -> bool {
250    HttpEndpointConfig::uri_options()
251        .iter()
252        .any(|option| option.name == key || option.aliases.iter().any(|alias| alias == key))
253}
254
255impl UriConfig for HttpEndpointConfig {
256    /// Returns "http" as the primary scheme (also accepts "https")
257    fn scheme() -> &'static str {
258        "http"
259    }
260
261    fn from_uri(uri: &str) -> Result<Self, CamelError> {
262        let parts = parse_uri(uri)?;
263        Self::from_components(parts)
264    }
265
266    fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
267        // Validate scheme - accept both http and https
268        if parts.scheme != "http" && parts.scheme != "https" {
269            return Err(CamelError::InvalidUri(format!(
270                "expected scheme 'http' or 'https', got '{}'",
271                parts.scheme
272            )));
273        }
274
275        // Construct base_url from scheme + path
276        // e.g., "http://localhost:8080/api" from scheme "http" and path "//localhost:8080/api"
277        let base_url = format!("{}:{}", parts.scheme, parts.path);
278
279        let http_method = parts.params.get("httpMethod").cloned();
280
281        let throw_exception_on_failure = match parts.params.get("throwExceptionOnFailure") {
282            Some(v) => parse_bool_param_http(v).map_err(|e| {
283                CamelError::InvalidUri(format!("invalid value for throwExceptionOnFailure: {e}"))
284            })?,
285            None => true,
286        };
287
288        // Parse status code range from "start-end" format (e.g., "200-299")
289        let ok_status_code_range = match parts.params.get("okStatusCodeRange") {
290            Some(v) => parse_ok_status_code_range(v)?,
291            None => (200, 299),
292        };
293
294        let response_timeout = match parts.params.get("responseTimeout") {
295            Some(v) => Some(v.parse::<u64>().map(Duration::from_millis).map_err(|e| {
296                CamelError::InvalidUri(format!("invalid value for responseTimeout: {e}"))
297            })?),
298            None => None,
299        };
300
301        // SSRF protection settings
302        let allow_internal = match parts.params.get("allowInternal") {
303            Some(v) => parse_bool_param_http(v).map_err(|e| {
304                CamelError::InvalidUri(format!("invalid value for allowInternal: {e}"))
305            })?,
306            None => false, // Default: block private IPs
307        };
308
309        // Public-cleartext transport consent (ADR-0081) — per-endpoint
310        // only, deliberately never inherited from the global HttpConfig.
311        let allow_cleartext = match parts.params.get("allowCleartext") {
312            Some(v) => parse_bool_param_http(v).map_err(|e| {
313                CamelError::InvalidUri(format!("invalid value for allowCleartext: {e}"))
314            })?,
315            None => false, // Default: reject cleartext http:// to public targets
316        };
317
318        // Parse comma-separated blocked hosts
319        let blocked_hosts = parts
320            .params
321            .get("blockedHosts")
322            .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
323            .unwrap_or_default();
324
325        let max_body_size = match parts.params.get("maxBodySize") {
326            Some(v) => v.parse::<usize>().map_err(|e| {
327                CamelError::InvalidUri(format!("invalid value for maxBodySize: {e}"))
328            })?,
329            None => 10 * 1024 * 1024, // Default: 10MB
330        };
331
332        let read_timeout_ms = match parts.params.get("readTimeout") {
333            Some(v) => v.parse::<u64>().map_err(|e| {
334                CamelError::InvalidUri(format!("invalid value for readTimeout: {e}"))
335            })?,
336            None => 30_000, // Default: 30s
337        };
338
339        let max_response_bytes = match parts.params.get("maxResponseBytes") {
340            Some(v) => v.parse::<usize>().map_err(|e| {
341                CamelError::InvalidUri(format!("invalid value for maxResponseBytes: {e}"))
342            })?,
343            None => 10 * 1024 * 1024, // Default: 10MB
344        };
345
346        let auth = parse_auth_from_params(&parts.params)?;
347
348        let user_agent = parts.params.get("userAgent").cloned();
349
350        if parts.params.contains_key("cookieHandling") {
351            return Err(CamelError::InvalidUri(
352                "cookieHandling is not supported".into(),
353            ));
354        }
355
356        let bridge_endpoint = match parts.params.get("bridgeEndpoint") {
357            Some(v) => parse_bool_param_http(v).map_err(|e| {
358                CamelError::InvalidUri(format!("invalid value for bridgeEndpoint: {e}"))
359            })?,
360            None => false,
361        };
362
363        let connection_close = match parts.params.get("connectionClose") {
364            Some(v) => parse_bool_param_http(v).map_err(|e| {
365                CamelError::InvalidUri(format!("invalid value for connectionClose: {e}"))
366            })?,
367            None => false,
368        };
369
370        let skip_request_headers = parts
371            .params
372            .get("skipRequestHeaders")
373            .map(|v| {
374                v.split(',')
375                    .map(str::trim)
376                    .filter(|s| !s.is_empty())
377                    .map(|s| s.to_ascii_lowercase())
378                    .collect::<Vec<_>>()
379            })
380            .unwrap_or_default();
381
382        let skip_response_headers = parts
383            .params
384            .get("skipResponseHeaders")
385            .map(|v| {
386                v.split(',')
387                    .map(str::trim)
388                    .filter(|s| !s.is_empty())
389                    .map(|s| s.to_ascii_lowercase())
390                    .collect::<Vec<_>>()
391            })
392            .unwrap_or_default();
393
394        let follow_redirects = match parts.params.get("followRedirects") {
395            Some(v) => parse_bool_param_http(v).map_err(|e| {
396                CamelError::InvalidUri(format!("invalid value for followRedirects: {e}"))
397            })?,
398            None => false,
399        };
400
401        let max_redirects = match parts.params.get("maxRedirects") {
402            Some(v) => v.parse::<usize>().map_err(|e| {
403                CamelError::InvalidUri(format!("invalid value for maxRedirects: {e}"))
404            })?,
405            None => 10,
406        };
407
408        // CamelHttpUri host fence: parsed eagerly so a malformed or empty
409        // allowlist fails endpoint creation (fail-closed), not resolution.
410        let allowed_uri_hosts = match parts.params.get("allowedUriHosts") {
411            Some(v) => Some(parse_allowed_uri_hosts(v)?),
412            None => None,
413        };
414
415        // Authored pairs ride raw_query verbatim (the sole carrier);
416        // query_params is programmatic-only — never auto-populated from
417        // URI leftovers. Consumed option keys are filtered at
418        // serialization time by `is_consumed_option`.
419        let raw_query = parts.raw_query.clone();
420
421        Ok(Self {
422            base_url,
423            http_method,
424            throw_exception_on_failure,
425            ok_status_code_range,
426            response_timeout,
427            query_params: Vec::new(),
428            raw_query,
429            allow_internal,
430            allow_cleartext,
431            blocked_hosts,
432            max_body_size,
433            read_timeout_ms,
434            max_response_bytes,
435            auth,
436            token_provider: None,
437            user_agent,
438            bridge_endpoint,
439            connection_close,
440            skip_request_headers,
441            skip_response_headers,
442            follow_redirects,
443            max_redirects,
444            allowed_uri_hosts,
445        })
446    }
447}
448
449/// Private container for macro-derived `uri_options()` and `metadata()`.
450///
451/// Mirrors the URI query parameters parsed by `HttpEndpointConfig::from_components`.
452/// `HttpEndpointConfig` holds typed fields (tuples, `Duration`, `HttpAuth`,
453/// `Arc<dyn TokenProvider>`) that the derive cannot represent, so metadata
454/// derivation targets this inner type whose fields are all URI-param-compatible.
455#[derive(Debug, Clone, UriConfig)]
456#[allow(dead_code)]
457#[uri_scheme = "http"]
458#[uri_config(
459    skip_impl,
460    metadata(
461        scheme = "http",
462        description = "HTTP client and server component",
463        producer,
464        consumer,
465        streaming
466    ),
467    crate = "camel_component_api"
468)]
469struct HttpEndpointUriConfig {
470    #[allow(dead_code)]
471    _base_url: String,
472
473    #[uri_param(
474        name = "httpMethod",
475        desc = "HTTP method. Defaults to CamelHttpMethod header or POST/GET"
476    )]
477    http_method: Option<String>,
478
479    #[uri_param(
480        name = "throwExceptionOnFailure",
481        default = "true",
482        desc = "Throw on non-2xx status"
483    )]
484    throw_exception_on_failure: bool,
485
486    #[uri_param(
487        name = "okStatusCodeRange",
488        default = "200-299",
489        desc = "Success status code range"
490    )]
491    ok_status_code_range: String,
492
493    #[uri_param(name = "responseTimeout", desc = "Response timeout in milliseconds")]
494    response_timeout: Option<u64>,
495
496    #[uri_param(
497        name = "connectTimeout",
498        desc = "Connection timeout in milliseconds (consumed option; effective timeout comes from the global http config)"
499    )]
500    connect_timeout: Option<u64>,
501
502    #[uri_param(
503        name = "allowInternal",
504        default = "false",
505        desc = "Allow private/internal network destinations (SSRF)"
506    )]
507    allow_internal: bool,
508
509    #[uri_param(
510        name = "allowCleartext",
511        default = "false",
512        desc = "Allow cleartext http:// to public targets (transport consent; ADR-0081)"
513    )]
514    allow_cleartext: bool,
515
516    #[uri_param(name = "blockedHosts", desc = "Comma-separated blocked host list")]
517    blocked_hosts: Option<String>,
518
519    #[uri_param(
520        name = "maxBodySize",
521        default = "10485760",
522        desc = "Max request/response body bytes"
523    )]
524    max_body_size: u64,
525
526    #[uri_param(name = "readTimeout", desc = "Socket read timeout in milliseconds")]
527    read_timeout: Option<u64>,
528
529    #[uri_param(name = "maxResponseBytes", desc = "Max response body bytes")]
530    max_response_bytes: Option<u64>,
531
532    #[uri_param(
533        name = "authMethod",
534        kind = "enum:None,Basic,Bearer",
535        desc = "Authentication method"
536    )]
537    auth_method: Option<String>,
538
539    #[uri_param(name = "authUsername", secret, desc = "Basic auth username")]
540    auth_username: Option<String>,
541
542    #[uri_param(name = "authPassword", secret, desc = "Basic auth password")]
543    auth_password: Option<String>,
544
545    #[uri_param(name = "authBearerToken", secret, desc = "Bearer auth token")]
546    auth_bearer_token: Option<String>,
547
548    #[uri_param(name = "userAgent", desc = "User-Agent header")]
549    user_agent: Option<String>,
550
551    #[uri_param(
552        name = "bridgeEndpoint",
553        default = "false",
554        desc = "Bridge endpoint mode"
555    )]
556    bridge_endpoint: bool,
557
558    #[uri_param(
559        name = "connectionClose",
560        default = "false",
561        desc = "Send Connection: close"
562    )]
563    connection_close: bool,
564
565    #[uri_param(
566        name = "skipRequestHeaders",
567        desc = "Comma-separated request headers to skip"
568    )]
569    skip_request_headers: Option<String>,
570
571    #[uri_param(
572        name = "skipResponseHeaders",
573        desc = "Comma-separated response headers to skip"
574    )]
575    skip_response_headers: Option<String>,
576
577    #[uri_param(
578        name = "followRedirects",
579        default = "false",
580        desc = "Follow HTTP redirects"
581    )]
582    follow_redirects: bool,
583
584    #[uri_param(name = "maxRedirects", default = "10", desc = "Max redirect hops")]
585    max_redirects: u64,
586
587    #[uri_param(
588        name = "allowedUriHosts",
589        desc = "Comma-separated allowlist of CamelHttpUri override hosts (host or host:port)"
590    )]
591    allowed_uri_hosts: Option<String>,
592}
593
594impl HttpEndpointConfig {
595    /// Component metadata for the http/https scheme, derived from the
596    /// `#[uri_param]` fields on `HttpEndpointUriConfig`.
597    pub fn metadata() -> ComponentMetadata {
598        HttpEndpointUriConfig::metadata()
599    }
600
601    /// URI option definitions, derived from `#[uri_param]` fields.
602    pub fn uri_options() -> Vec<camel_api::component_metadata::UriOption> {
603        HttpEndpointUriConfig::uri_options()
604    }
605}
606
607fn parse_auth_from_params(params: &HashMap<String, String>) -> Result<HttpAuth, CamelError> {
608    let Some(method) = params.get("authMethod") else {
609        return Ok(HttpAuth::None);
610    };
611
612    if method.eq_ignore_ascii_case("none") {
613        return Ok(HttpAuth::None);
614    }
615
616    if method.eq_ignore_ascii_case("basic") {
617        let username = params.get("authUsername").cloned().ok_or_else(|| {
618            CamelError::InvalidUri("authUsername is required for authMethod=Basic".to_string())
619        })?;
620        let password = params.get("authPassword").cloned().ok_or_else(|| {
621            CamelError::InvalidUri("authPassword is required for authMethod=Basic".to_string())
622        })?;
623        return Ok(HttpAuth::Basic { username, password });
624    }
625
626    if method.eq_ignore_ascii_case("bearer") {
627        let token = params.get("authBearerToken").cloned().ok_or_else(|| {
628            CamelError::InvalidUri("authBearerToken is required for authMethod=Bearer".to_string())
629        })?;
630        return Ok(HttpAuth::Bearer { token });
631    }
632
633    Err(CamelError::InvalidUri(format!(
634        "invalid value for authMethod: {method} (expected None, Basic, or Bearer)"
635    )))
636}
637
638fn parse_bool_param_http(value: &str) -> Result<bool, CamelError> {
639    match value.to_ascii_lowercase().as_str() {
640        "true" | "1" | "yes" => Ok(true),
641        "false" | "0" | "no" => Ok(false),
642        _ => Err(CamelError::InvalidUri(format!(
643            "invalid boolean value: '{value}'"
644        ))),
645    }
646}
647
648impl HttpEndpointConfig {
649    pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
650        let parts = parse_uri(uri)?;
651        let mut endpoint = Self::from_components(parts.clone())?;
652        if endpoint.response_timeout.is_none() {
653            endpoint.response_timeout = Some(Duration::from_millis(config.response_timeout_ms));
654        }
655        if !parts.params.contains_key("allowInternal") {
656            endpoint.allow_internal = config.allow_internal;
657        }
658        if !parts.params.contains_key("blockedHosts") {
659            endpoint.blocked_hosts = config.blocked_hosts.clone();
660        }
661        if !parts.params.contains_key("maxBodySize") {
662            endpoint.max_body_size = config.max_body_size;
663        }
664        if !parts.params.contains_key("readTimeout") {
665            endpoint.read_timeout_ms = config.read_timeout_ms;
666        }
667        if !parts.params.contains_key("maxResponseBytes") {
668            endpoint.max_response_bytes = config.max_response_bytes;
669        }
670        if !parts.params.contains_key("okStatusCodeRange")
671            && let Some(range) = &config.ok_status_code_range
672        {
673            endpoint.ok_status_code_range = parse_ok_status_code_range(range)?;
674        }
675        if !parts.params.contains_key("followRedirects") {
676            endpoint.follow_redirects = config.follow_redirects;
677        }
678        if !parts.params.contains_key("maxRedirects") {
679            endpoint.max_redirects = config.max_redirects.unwrap_or(10);
680        }
681
682        Ok(endpoint)
683    }
684}
685
686// ---------------------------------------------------------------------------
687// HttpServerConfig
688// ---------------------------------------------------------------------------
689
690/// Configuration for an HTTP server (consumer) endpoint.
691#[derive(Debug, Clone)]
692pub struct HttpServerConfig {
693    /// URI scheme ("http" or "https") parsed from the endpoint URI.
694    pub scheme: String,
695    /// Bind address, e.g. "0.0.0.0" or "127.0.0.1".
696    pub host: String,
697    /// TCP port to listen on.
698    pub port: u16,
699    /// URL path this consumer handles, e.g. "/orders".
700    pub path: String,
701    /// Maximum request body size in bytes.
702    pub max_request_body: usize,
703    /// Maximum response body size for materializing streams in bytes.
704    pub max_response_body: usize,
705    /// Maximum number of in-flight requests handled concurrently by this server.
706    /// bd rc-ns3yc: values above `tokio::sync::Semaphore::MAX_PERMITS` are
707    /// rejected at parse, `create_consumer`, start, and `spawn_entry` (the
708    /// inflight semaphore panics above that bound); `0` remains a
709    /// representable reject-everything value.
710    pub max_inflight_requests: usize,
711    /// HTTP method this consumer handles (e.g. `"GET"`). When `Some`,
712    /// the consumer registers as a method-aware REST endpoint and the
713    /// path is treated as a template (e.g. `/users/{id}` is matched
714    /// against any `/users/<value>`). When `None`, the consumer
715    /// registers in the legacy path-only `api_routes` registry.
716    /// Extracted from the `httpMethod=` URI param at config build time.
717    pub method: Option<String>,
718    /// Server-side TLS config. Populated from `tlsCert`/`tlsKey` URI params.
719    /// `None` for plain HTTP servers.
720    pub tls_config: Option<crate::config::ServerTlsConfig>,
721}
722
723impl UriConfig for HttpServerConfig {
724    /// Returns "http" as the primary scheme (also accepts "https")
725    fn scheme() -> &'static str {
726        "http"
727    }
728
729    fn from_uri(uri: &str) -> Result<Self, CamelError> {
730        let parts = parse_uri(uri)?;
731        Self::from_components(parts)
732    }
733
734    fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
735        // Validate scheme - accept both http and https
736        if parts.scheme != "http" && parts.scheme != "https" {
737            return Err(CamelError::InvalidUri(format!(
738                "expected scheme 'http' or 'https', got '{}'",
739                parts.scheme
740            )));
741        }
742
743        // parts.path is everything after the scheme colon, e.g. "//0.0.0.0:8080/orders"
744        // Strip leading "//"
745        let authority_and_path = parts.path.trim_start_matches('/');
746
747        // Split on the first "/" to separate "host:port" from "/path"
748        let (authority, path_suffix) = if let Some(idx) = authority_and_path.find('/') {
749            (&authority_and_path[..idx], &authority_and_path[idx..])
750        } else {
751            (authority_and_path, "/")
752        };
753
754        let path = if path_suffix.is_empty() {
755            "/"
756        } else {
757            path_suffix
758        }
759        .to_string();
760
761        // Parse host:port from authority
762        let (host, port) = if let Some(colon) = authority.rfind(':') {
763            let port_str = &authority[colon + 1..];
764            match port_str.parse::<u16>() {
765                Ok(p) => (authority[..colon].to_string(), p),
766                Err(_) => {
767                    return Err(CamelError::InvalidUri(format!(
768                        "invalid port '{}' in authority",
769                        port_str
770                    )));
771                }
772            }
773        } else {
774            // Default port based on scheme: 443 for https, 80 for http
775            let default_port = if parts.scheme == "https" { 443 } else { 80 };
776            (authority.to_string(), default_port)
777        };
778
779        let max_request_body = parts
780            .params
781            .get("maxRequestBody")
782            .and_then(|v| v.parse::<usize>().ok())
783            .unwrap_or(2 * 1024 * 1024); // Default: 2MB
784
785        let max_response_body = parts
786            .params
787            .get("maxResponseBody")
788            .and_then(|v| v.parse::<usize>().ok())
789            .unwrap_or(10 * 1024 * 1024); // Default: 10MB
790
791        let max_inflight_requests = parts
792            .params
793            .get("maxInflightRequests")
794            .and_then(|v| v.parse::<usize>().ok())
795            .unwrap_or(1024);
796        // bd rc-ns3yc: reject values above the semaphore primitive's bound
797        // at parse time — `Semaphore::new` panics above MAX_PERMITS
798        // (mirror of grpc consumer_concurrency_limit, commit 101327e5).
799        let max_inflight_requests = max_inflight_requests_limit(max_inflight_requests)?;
800
801        // Uppercase-normalize so a hand-written `httpMethod=get` matches the
802        // uppercase method the dispatcher compares against (axum's
803        // `req.method().to_string()` yields "GET"). Without this, a
804        // lower-case `httpMethod` would never match and silently 404.
805        // Review I5.
806        let method = parts.params.get("httpMethod").map(|m| m.to_uppercase());
807
808        Ok(Self {
809            scheme: parts.scheme,
810            host,
811            port,
812            path,
813            max_request_body,
814            max_response_body,
815            max_inflight_requests,
816            method,
817            tls_config: {
818                let cert = parts.params.get("tlsCert").cloned();
819                let key = parts.params.get("tlsKey").cloned();
820                match (cert, key) {
821                    (Some(c), Some(k)) => Some(crate::config::ServerTlsConfig {
822                        cert_path: c,
823                        key_path: k,
824                    }),
825                    (None, None) => None,
826                    _ => None, // partial — enforced in create_consumer, not here
827                }
828            },
829        })
830    }
831}
832
833impl HttpServerConfig {
834    pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
835        let parts = parse_uri(uri)?;
836        let mut server = Self::from_components(parts.clone())?;
837        if !parts.params.contains_key("maxRequestBody") {
838            server.max_request_body = config.max_request_body;
839        }
840        if !parts.params.contains_key("maxResponseBody") {
841            // Default max_response_body is 10MB via HttpConfig::default().max_body_size.
842            server.max_response_body = config.max_body_size;
843        }
844        Ok(server)
845    }
846}
847
848// ---------------------------------------------------------------------------
849// RequestEnvelope / HttpReply
850// ---------------------------------------------------------------------------
851
852/// Body of the HTTP response: already-materialized bytes or a lazy stream.
853///
854/// **Internal plumbing** — subject to change without notice.
855pub enum HttpReplyBody {
856    Bytes(bytes::Bytes),
857    Stream(BoxStream<'static, Result<bytes::Bytes, CamelError>>),
858}
859
860/// An inbound HTTP request sent from the Axum dispatch handler to an
861/// `HttpConsumer` receive loop.
862///
863/// **Internal plumbing** — subject to change without notice.
864pub struct RequestEnvelope {
865    pub method: String,
866    pub path: String,
867    pub query: String,
868    pub headers: http::HeaderMap,
869    pub body: StreamBody,
870    /// Path parameters extracted from a REST template match, e.g.
871    /// `id=42` for a request to `/users/42` matched against
872    /// `/users/{id}`. Empty for non-REST requests or for literal
873    /// template matches. The consumer turns these into
874    /// `CamelHttpPath_<param>` headers on the Exchange (expert guidance E2).
875    pub path_params: std::collections::HashMap<String, String>,
876    pub reply_tx: tokio::sync::oneshot::Sender<HttpReply>,
877}
878
879/// The HTTP response that `HttpConsumer` sends back to the Axum handler.
880///
881/// **Internal plumbing** — subject to change without notice.
882pub struct HttpReply {
883    pub status: u16,
884    pub headers: Vec<(String, String)>,
885    pub body: HttpReplyBody,
886}
887
888// ---------------------------------------------------------------------------
889// HttpRouteRegistry / ServerRegistry
890// ---------------------------------------------------------------------------
891
892type ServerKey = (String, u16);
893
894/// Handle to a running Axum server on one interface/port.
895struct ServerHandle {
896    registry: HttpRouteRegistry,
897    /// Actual local address of the served listening socket (differs from the
898    /// configured `host:port` when spawning from a staged/pre-bound listener).
899    bound_addr: std::net::SocketAddr,
900    max_request_body: usize,
901    max_response_body: usize,
902    max_inflight_requests: usize,
903    is_tls: bool,
904    tls_cert_path: Option<String>,
905    tls_key_path: Option<String>,
906    /// JoinHandle for the monitor_axum_task wrapper. `is_finished()` is the
907    /// dead-server eviction signal in `get_or_spawn`.
908    monitor_task: tokio::task::JoinHandle<()>,
909    /// Abort handle for the Axum server task itself. The JoinHandle is
910    /// consumed by `monitor_axum_task`; this survives on the handle so
911    /// crashed-server tests (and future ops tooling) can deterministically
912    /// kill the shared transport to exercise the death path.
913    /// Test-only today — no production reader yet (rc-szmob).
914    #[allow(dead_code)]
915    server_abort: tokio::task::AbortHandle,
916    // Retained so the reload handler (Task 7) can call reload_from_config()
917    // to hot-swap certs without restarting the server.
918    tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
919    tls_source: Option<ServerTlsSource>,
920}
921
922/// Internal registry state: live server entries plus pre-bound listeners
923/// staged for consumption by the next spawn on the same key.
924#[derive(Default)]
925struct RegistryState {
926    entries: HashMap<ServerKey, Arc<OnceCell<ServerHandle>>>,
927    staged: HashMap<ServerKey, tokio::net::TcpListener>,
928}
929
930/// Process-global registry mapping (host, port) → running Axum server handle.
931pub struct ServerRegistry {
932    inner: Mutex<RegistryState>,
933}
934
935impl ServerRegistry {
936    /// Returns the global singleton.
937    pub fn global() -> &'static Self {
938        static INSTANCE: OnceLock<ServerRegistry> = OnceLock::new();
939        INSTANCE.get_or_init(|| ServerRegistry {
940            inner: Mutex::new(RegistryState::default()),
941        })
942    }
943
944    /// Returns route registry for `port`, spawning new Axum server if
945    /// none is running on that port yet.
946    #[allow(clippy::too_many_arguments)]
947    pub async fn get_or_spawn(
948        &'static self,
949        host: &str,
950        port: u16,
951        max_request_body: usize,
952        max_response_body: usize,
953        max_inflight_requests: usize,
954        runtime: Arc<dyn RuntimeObservability>,
955        route_id: String,
956        tls_config: Option<crate::config::ServerTlsConfig>,
957    ) -> Result<HttpRouteRegistry, CamelError> {
958        self.get_or_spawn_internal(
959            host,
960            port,
961            max_request_body,
962            max_response_body,
963            max_inflight_requests,
964            runtime,
965            route_id,
966            tls_config,
967            None,
968        )
969        .await
970    }
971
972    /// Like [`ServerRegistry::get_or_spawn`], but serves `listener` instead
973    /// of binding `host:port`. The registry key is derived from the listener's
974    /// actual local address, so callers must query that port afterwards. If an
975    /// entry for the key already holds a live server, the same compatibility
976    /// checks as `get_or_spawn` apply and the entry is reused; the passed
977    /// listener is simply dropped.
978    #[allow(clippy::too_many_arguments)]
979    pub async fn get_or_spawn_with_listener(
980        &'static self,
981        listener: tokio::net::TcpListener,
982        max_request_body: usize,
983        max_response_body: usize,
984        max_inflight_requests: usize,
985        runtime: Arc<dyn RuntimeObservability>,
986        route_id: String,
987        tls_config: Option<crate::config::ServerTlsConfig>,
988    ) -> Result<HttpRouteRegistry, CamelError> {
989        let addr = listener
990            .local_addr()
991            .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
992        self.get_or_spawn_internal(
993            &addr.ip().to_string(),
994            addr.port(),
995            max_request_body,
996            max_response_body,
997            max_inflight_requests,
998            runtime,
999            route_id,
1000            tls_config,
1001            Some(listener),
1002        )
1003        .await
1004    }
1005
1006    /// Stage a pre-bound listener so the next `get_or_spawn` for its
1007    /// `(ip, port)` key serves this socket instead of binding a new one.
1008    ///
1009    /// The staged listener is consumed by exactly one spawn: the exact-key
1010    /// `get_or_spawn` takes it under the registry lock, eliminating the bind
1011    /// window between a port probe and server startup (itest-bound-ports).
1012    pub async fn stage_listener(
1013        &'static self,
1014        listener: tokio::net::TcpListener,
1015    ) -> Result<(), CamelError> {
1016        let addr = listener
1017            .local_addr()
1018            .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
1019        let host = addr.ip().to_string();
1020        use std::collections::hash_map::Entry;
1021        let mut guard = self.inner.lock().map_err(|_| {
1022            CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
1023        })?;
1024        match guard.staged.entry((host.clone(), addr.port())) {
1025            Entry::Occupied(_) => Err(CamelError::EndpointCreationFailed(format!(
1026                "listener already staged for {host}:{}",
1027                addr.port()
1028            ))),
1029            Entry::Vacant(slot) => {
1030                slot.insert(listener);
1031                Ok(())
1032            }
1033        }
1034    }
1035
1036    /// Returns the bound address of the live server entry for `(host, port)`,
1037    /// if one is initialized.
1038    pub fn bound_addr(&'static self, host: &str, port: u16) -> Option<std::net::SocketAddr> {
1039        let guard = self.inner.lock().ok()?;
1040        guard
1041            .entries
1042            .get(&(host.to_string(), port))
1043            .and_then(|cell| cell.get())
1044            .map(|handle| handle.bound_addr)
1045    }
1046
1047    #[allow(clippy::too_many_arguments)]
1048    async fn get_or_spawn_internal(
1049        &'static self,
1050        host: &str,
1051        port: u16,
1052        max_request_body: usize,
1053        max_response_body: usize,
1054        max_inflight_requests: usize,
1055        runtime: Arc<dyn RuntimeObservability>,
1056        route_id: String,
1057        tls_config: Option<crate::config::ServerTlsConfig>,
1058        provided: Option<tokio::net::TcpListener>,
1059    ) -> Result<HttpRouteRegistry, CamelError> {
1060        let host_owned = host.to_string();
1061        let key = (host.to_string(), port);
1062
1063        let cell = {
1064            let mut guard = self.inner.lock().map_err(|_| {
1065                CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
1066            })?;
1067            // Evict dead server so a fresh one can spawn (matches gRPC D-L2 pattern).
1068            // The monitor task awaits the server task, so monitor_task.is_finished()
1069            // is a reliable proxy for the server being gone (either crashed or aborted).
1070            if let Some(existing) = guard.entries.get(&key)
1071                && let Some(handle) = existing.get()
1072                && handle.monitor_task.is_finished()
1073            {
1074                // Deregister TLS reload handler so a respawned HTTPS server
1075                // doesn't reload stale cert config from the crashed handler.
1076                if handle.is_tls {
1077                    let scheme = if handle.is_tls { "https" } else { "http" };
1078                    camel_component_api::tls_source::TlsReloadRegistry::global()
1079                        .unregister(scheme, host, port);
1080                }
1081                guard.entries.remove(&key);
1082            }
1083            guard
1084                .entries
1085                .entry(key)
1086                .or_insert_with(|| Arc::new(OnceCell::new()))
1087                .clone()
1088        };
1089
1090        if let Some(existing) = cell.get()
1091            && existing.max_request_body != max_request_body
1092        {
1093            return Err(CamelError::EndpointCreationFailed(format!(
1094                "incompatible maxRequestBody for shared server (host={host}, port={port}): {} vs {}",
1095                existing.max_request_body, max_request_body
1096            )));
1097        }
1098
1099        if let Some(existing) = cell.get()
1100            && existing.max_response_body != max_response_body
1101        {
1102            return Err(CamelError::EndpointCreationFailed(format!(
1103                "incompatible maxResponseBody for shared server (host={host}, port={port}): {} vs {}",
1104                existing.max_response_body, max_response_body
1105            )));
1106        }
1107
1108        if let Some(existing) = cell.get()
1109            && existing.max_inflight_requests != max_inflight_requests
1110        {
1111            return Err(CamelError::EndpointCreationFailed(format!(
1112                "incompatible maxInflightRequests for shared server (host={host}, port={port}): {} vs {}",
1113                existing.max_inflight_requests, max_inflight_requests
1114            )));
1115        }
1116
1117        // TLS mode mismatch: plain vs TLS
1118        if let Some(existing) = cell.get()
1119            && existing.is_tls != tls_config.is_some()
1120        {
1121            return Err(CamelError::EndpointCreationFailed(format!(
1122                "incompatible TLS mode for shared server (host={host}, port={port}): existing is_tls={}, new has_tls={}",
1123                existing.is_tls,
1124                tls_config.is_some()
1125            )));
1126        }
1127
1128        // TLS cert/key mismatch: different cert on same TLS port
1129        if let (Some(existing), Some(new_tls)) = (cell.get(), &tls_config)
1130            && (existing.tls_cert_path.as_deref() != Some(&new_tls.cert_path)
1131                || existing.tls_key_path.as_deref() != Some(&new_tls.key_path))
1132        {
1133            return Err(CamelError::EndpointCreationFailed(format!(
1134                "incompatible TLS cert/key for shared server (host={host}, port={port}): routes on the same TLS port must use the same cert and key"
1135            )));
1136        }
1137
1138        let handle = cell
1139            .get_or_try_init(|| {
1140                let rt = Arc::clone(&runtime);
1141                let rid = route_id.clone();
1142                let key = (host_owned.clone(), port);
1143                async move {
1144                    // Resolve the listener source inside the init body so
1145                    // exactly one caller — the init winner — consumes a
1146                    // staged listener. Resolving it before the cell init let
1147                    // a racing caller strand the staged socket in the
1148                    // loser's hands: the winner then bound the same port and
1149                    // failed with EADDRINUSE. The sync registry lock here is
1150                    // never held across an await. Occupied cells never run
1151                    // this body, so they never touch the staged map.
1152                    let source = match provided {
1153                        Some(listener) => ListenerSource::Staged(listener),
1154                        None => {
1155                            let mut guard = self.inner.lock().map_err(|_| {
1156                                CamelError::EndpointCreationFailed(
1157                                    "ServerRegistry lock poisoned".into(),
1158                                )
1159                            })?;
1160                            match guard.staged.remove(&key) {
1161                                Some(listener) => ListenerSource::Staged(listener),
1162                                // Conflict check before any entry is
1163                                // initialized so the error leaves the staged
1164                                // slot untouched.
1165                                None => {
1166                                    if let Some((staged_host, _)) = guard
1167                                        .staged
1168                                        .keys()
1169                                        .find(|(_, staged_port)| *staged_port == port)
1170                                    {
1171                                        let staged_host = staged_host.clone();
1172                                        return Err(CamelError::EndpointCreationFailed(
1173                                            format!(
1174                                                "staged listener conflict on port {port}: staged under host {staged_host}, requested {host_owned}"
1175                                            ),
1176                                        ));
1177                                    }
1178                                    ListenerSource::Bind
1179                                }
1180                            }
1181                        }
1182                    };
1183                    spawn_entry(
1184                        key,
1185                        source,
1186                        max_request_body,
1187                        max_response_body,
1188                        max_inflight_requests,
1189                        rt,
1190                        rid,
1191                        tls_config,
1192                    )
1193                    .await
1194                    .and_then(|handle| {
1195                        // spawn_entry returns a freshly created Arc (refcount
1196                        // 1), so unwrapping it back into the owned handle for
1197                        // the cell always succeeds here.
1198                        Arc::try_unwrap(handle).map_err(|_| {
1199                            CamelError::EndpointCreationFailed(
1200                                "spawned server handle has dangling clones".into(),
1201                            )
1202                        })
1203                    })
1204                }
1205            })
1206            .await?;
1207
1208        Ok(handle.registry.clone())
1209    }
1210
1211    /// Unregister one consumer from a server. HTTP servers are process-lifetime:
1212    /// the server stays in the registry for potential restart. Path
1213    /// deregistration happens separately in the consumer's cleanup.
1214    pub async fn unregister(&self, host: &str, port: u16) {
1215        debug!(
1216            host = camel_api::redact::redact_host(host),
1217            port = port,
1218            "consumer unregistered from HTTP server"
1219        );
1220    }
1221
1222    /// Reset the global registry — **test-only**.
1223    ///
1224    /// Clears all registered server handles so that tests can start from a clean
1225    /// state. This is intentionally `#[cfg(test)]` because the registry is a
1226    /// process-global singleton in production and resetting it would break
1227    /// running servers.
1228    #[cfg(test)]
1229    pub fn reset() {
1230        let instance = Self::global();
1231        let mut guard = instance
1232            .inner
1233            .lock()
1234            .expect("ServerRegistry lock poisoned during test reset");
1235        guard.entries.clear();
1236        guard.staged.clear();
1237    }
1238}
1239
1240/// Where a spawned server's listening socket comes from: a fresh bind on
1241/// `key`, or a listener pre-bound (staged or passed) by the caller.
1242enum ListenerSource {
1243    Bind,
1244    Staged(tokio::net::TcpListener),
1245}
1246
1247/// Create the server handle for a vacant registry entry: serve `key` via a
1248/// freshly bound or caller-provided listener. This is the OnceCell init body
1249/// of `get_or_spawn`, extracted so the legacy and staged entry points share
1250/// one spawn path.
1251#[allow(clippy::too_many_arguments)]
1252async fn spawn_entry(
1253    key: ServerKey,
1254    source: ListenerSource,
1255    max_request_body: usize,
1256    max_response_body: usize,
1257    max_inflight_requests: usize,
1258    runtime: Arc<dyn RuntimeObservability>,
1259    route_id: String,
1260    tls_config: Option<crate::config::ServerTlsConfig>,
1261) -> Result<Arc<ServerHandle>, CamelError> {
1262    // bd rc-ns3yc: defense-in-depth — validate before any listener side
1263    // effect (fresh bind or staged-listener consumption), before the
1264    // registry/CancellationToken work, and immediately guarding the
1265    // `tokio::sync::Semaphore::new` construction below (panics above
1266    // MAX_PERMITS).
1267    max_inflight_requests_limit(max_inflight_requests)?;
1268    let rt = Arc::clone(&runtime);
1269    let rid = route_id.clone();
1270    let (host_owned, port) = key;
1271    let listener = match source {
1272        ListenerSource::Bind => {
1273            let addr = format!("{host_owned}:{port}");
1274            tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
1275                CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
1276            })?
1277        }
1278        ListenerSource::Staged(listener) => listener,
1279    };
1280    let bound_addr = listener
1281        .local_addr()
1282        .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
1283    let server_exited = tokio_util::sync::CancellationToken::new();
1284    let registry = HttpRouteRegistry::new_with_server_exited(server_exited.clone());
1285    let inflight = Arc::new(tokio::sync::Semaphore::new(max_inflight_requests));
1286    // Constructed once in the TLS branch so they can be retained
1287    // on ServerHandle for the reload handler (Task 7).
1288    let tls_rustls_cfg: Option<axum_server::tls_rustls::RustlsConfig>;
1289    let tls_source: Option<ServerTlsSource>;
1290    let server_task = if let Some(ref tls) = tls_config {
1291        let rustls_config = load_tls_config(&tls.cert_path, &tls.key_path)?;
1292        let source = ServerTlsSource {
1293            cert_path: std::path::PathBuf::from(&tls.cert_path),
1294            key_path: std::path::PathBuf::from(&tls.key_path),
1295            client_ca_path: None,
1296        };
1297        // Build the RustlsConfig once — clone() is cheap (Arc
1298        // internally) and shares the ArcSwap the reload handler
1299        // will mutate via reload_from_config().
1300        let rustls_cfg =
1301            axum_server::tls_rustls::RustlsConfig::from_config(std::sync::Arc::new(rustls_config));
1302        tls_rustls_cfg = Some(rustls_cfg.clone());
1303        tls_source = Some(source);
1304        // Convert tokio listener to std for axum-server
1305        let std_listener = listener.into_std().map_err(|e| {
1306            CamelError::EndpointCreationFailed(format!("TLS listener conversion: {e}"))
1307        })?;
1308        tokio::spawn(run_axum_server_tls(
1309            std_listener,
1310            rustls_cfg,
1311            registry.clone(),
1312            max_request_body,
1313            max_response_body,
1314            Arc::clone(&inflight),
1315            Arc::clone(&rt),
1316            rid.clone(),
1317        ))
1318    } else {
1319        tls_rustls_cfg = None;
1320        tls_source = None;
1321        tokio::spawn(run_axum_server(
1322            listener,
1323            registry.clone(),
1324            max_request_body,
1325            max_response_body,
1326            Arc::clone(&inflight),
1327            Arc::clone(&rt),
1328            rid.clone(),
1329        ))
1330    };
1331    let addr_for_monitor = format!("{host_owned}:{port}");
1332    let server_abort = server_task.abort_handle();
1333    let monitor_task = tokio::spawn(monitor_axum_task(
1334        server_task,
1335        addr_for_monitor,
1336        Arc::clone(&rt),
1337        rid,
1338        server_exited,
1339    ));
1340    let handle = ServerHandle {
1341        registry,
1342        bound_addr,
1343        max_request_body,
1344        max_response_body,
1345        max_inflight_requests,
1346        is_tls: tls_config.is_some(),
1347        tls_cert_path: tls_config.as_ref().map(|t| t.cert_path.clone()),
1348        tls_key_path: tls_config.as_ref().map(|t| t.key_path.clone()),
1349        monitor_task,
1350        server_abort,
1351        tls_config: tls_rustls_cfg,
1352        tls_source,
1353    };
1354    // Register reload handler (exactly-once: inside OnceCell init closure).
1355    // Note: HTTP servers are process-lifetime (no release/eviction path),
1356    // so handlers are never unregistered. If eviction is added later,
1357    // add TlsReloadRegistry::global().unregister() there.
1358    if let (Some(tls_cfg), Some(source)) = (handle.tls_config.as_ref(), handle.tls_source.as_ref())
1359    {
1360        let handler = Arc::new(crate::tls_reload::HttpReloadHandler::new(
1361            tls_cfg.clone(),
1362            source.clone(),
1363            host_owned.clone(),
1364            port,
1365        ));
1366        camel_component_api::tls_source::TlsReloadRegistry::global().register(handler);
1367    }
1368    Ok(Arc::new(handle))
1369}
1370
1371// ---------------------------------------------------------------------------
1372// Axum server
1373// ---------------------------------------------------------------------------
1374
1375use axum::{
1376    Router,
1377    body::Body as AxumBody,
1378    extract::{Request, State},
1379    http::{Response, StatusCode},
1380    response::IntoResponse,
1381};
1382
1383#[derive(Clone)]
1384pub(crate) struct AppState {
1385    registry: HttpRouteRegistry,
1386    max_request_body: usize,
1387    max_response_body: usize,
1388    inflight: Arc<tokio::sync::Semaphore>,
1389}
1390
1391/// Hard wall-clock limit for one inbound request on the consumer side
1392/// (audit 2026-08-31, F2-1). A slow-drip client otherwise holds an
1393/// `inflight` semaphore permit (and its connection) indefinitely, starving
1394/// the consumer into 503s. 30s matches the documented component default
1395/// timeouts. Applies to the whole dispatch; streaming bodies are additionally
1396/// protected by the byte cap in `dispatch_handler`.
1397const CONSUMER_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
1398
1399async fn run_axum_server(
1400    listener: tokio::net::TcpListener,
1401    registry: HttpRouteRegistry,
1402    max_request_body: usize,
1403    max_response_body: usize,
1404    inflight: Arc<tokio::sync::Semaphore>,
1405    runtime: Arc<dyn RuntimeObservability>,
1406    route_id: String,
1407) {
1408    let state = AppState {
1409        registry,
1410        max_request_body,
1411        max_response_body,
1412        inflight,
1413    };
1414    let app = Router::new()
1415        .fallback(dispatch_handler)
1416        .with_state(state)
1417        .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1418            StatusCode::REQUEST_TIMEOUT,
1419            CONSUMER_REQUEST_TIMEOUT,
1420        ));
1421
1422    axum::serve(listener, app).await.unwrap_or_else(|e| {
1423        runtime
1424            .metrics()
1425            .increment_errors(&route_id, "e:http:accept");
1426        // log-policy: outside-contract
1427        tracing::error!(error = %e, "Axum server error");
1428    });
1429}
1430
1431#[allow(clippy::too_many_arguments)]
1432async fn run_axum_server_tls(
1433    listener: std::net::TcpListener,
1434    tls_cfg: axum_server::tls_rustls::RustlsConfig,
1435    registry: HttpRouteRegistry,
1436    max_request_body: usize,
1437    max_response_body: usize,
1438    inflight: Arc<tokio::sync::Semaphore>,
1439    runtime: Arc<dyn RuntimeObservability>,
1440    route_id: String,
1441) {
1442    let state = AppState {
1443        registry,
1444        max_request_body,
1445        max_response_body,
1446        inflight,
1447    };
1448    let app = Router::new()
1449        .fallback(dispatch_handler)
1450        .with_state(state)
1451        .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1452            StatusCode::REQUEST_TIMEOUT,
1453            CONSUMER_REQUEST_TIMEOUT,
1454        ));
1455
1456    // RustlsConfig is now constructed once in get_or_spawn and retained on
1457    // ServerHandle so the reload handler can call reload_from_config() on it.
1458
1459    // axum-server 0.8: from_tcp_rustls is fallible (TLS acceptor setup).
1460    let server = match axum_server::from_tcp_rustls(listener, tls_cfg) {
1461        Ok(server) => server,
1462        Err(e) => {
1463            runtime
1464                .metrics()
1465                .increment_errors(&route_id, "e:http:accept-tls");
1466            // log-policy: outside-contract
1467            tracing::error!(error = %e, "Axum TLS server setup error");
1468            return;
1469        }
1470    };
1471
1472    server
1473        .serve(app.into_make_service())
1474        .await
1475        .unwrap_or_else(|e| {
1476            runtime
1477                .metrics()
1478                .increment_errors(&route_id, "e:http:accept-tls");
1479            // log-policy: outside-contract
1480            tracing::error!(error = %e, "Axum TLS server error");
1481        });
1482}
1483
1484/// Monitors the shared Axum server task of one (host, port).
1485///
1486/// On unexpected exit (panic or abort) it records the structured error
1487/// event and cancels the server's `server_exited` token. Every
1488/// `HttpConsumer` hosted on that server observes the cancellation in its
1489/// `start()` loop and returns `Err`, which camel-core's consumer watcher
1490/// turns into a per-route `CrashNotification` → `FailRoute` → supervision
1491/// backoff restart (ADR-0007). A clean exit (`Ok(())` — process shutdown)
1492/// cancels nothing: route stops own their termination.
1493async fn monitor_axum_task(
1494    handle: tokio::task::JoinHandle<()>,
1495    addr: String,
1496    runtime: Arc<dyn RuntimeObservability>,
1497    route_id: String,
1498    server_exited: tokio_util::sync::CancellationToken,
1499) {
1500    match handle.await {
1501        Ok(()) => {
1502            // Clean exit (process shutdown or normal stop)
1503        }
1504        Err(join_err) => {
1505            runtime
1506                .metrics()
1507                .increment_errors(&route_id, "e:http:server-task-exited");
1508            // log-policy: outside-contract
1509            tracing::error!(
1510                addr = %addr,
1511                error = %join_err,
1512                "Axum server task exited unexpectedly — all routes on this port are now dead"
1513            );
1514            // Fail every hosted route's consumer: each `start()` returns Err
1515            // and camel-core emits one CrashNotification per route (ADR-0007
1516            // parity with per-route transport death).
1517            server_exited.cancel();
1518        }
1519    }
1520}
1521
1522/// Load a rustls ServerConfig from PEM cert/key files.
1523/// Adapted from camel-ws lib.rs load_tls_config.
1524fn load_tls_config(
1525    cert_path: &str,
1526    key_path: &str,
1527) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
1528    use std::fs::File;
1529    use std::io::BufReader;
1530
1531    let cert_file = File::open(cert_path)
1532        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
1533    let key_file = File::open(key_path)
1534        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
1535
1536    let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
1537        .collect::<Result<Vec<_>, _>>()
1538        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
1539
1540    let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
1541        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
1542        .ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
1543
1544    tokio_rustls::rustls::ServerConfig::builder()
1545        .with_no_client_auth()
1546        .with_single_cert(certs, key)
1547        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
1548}
1549
1550async fn dispatch_handler(State(state): State<AppState>, req: Request) -> impl IntoResponse {
1551    let path = req.uri().path().to_owned();
1552    let method = req.method().to_string();
1553
1554    // Dispatch precedence (spec §7.2 / ADR-0009):
1555    //   1. Exact API path match (legacy `http:` routes without httpMethod)
1556    //   2. Templated API path match (REST, method-aware, by specificity)
1557    //   3. Static mount longest-prefix
1558    //   4. SPA fallback
1559    //
1560    // Legacy exact runs first: it is a cheap HashMap get, and the two
1561    // registries are mutually exclusive per route — a legacy route carries
1562    // no `httpMethod` and lives only in `api_routes`, while a REST-lowered
1563    // route carries `httpMethod` and lives only in `rest_endpoints`. So an
1564    // exact hit can never shadow a REST route that should have matched,
1565    // and running exact-first honours the documented precedence (the prior
1566    // REST-first order let a templated `GET /api/{resource}` steal a
1567    // request meant for an exact `GET /api/users`). Intra-REST method
1568    // disambiguation is handled inside `match_endpoint`, not by this
1569    // ordering. Review C2.
1570    let api_sender = {
1571        let inner = state.registry.inner.read().await;
1572        inner.api_routes.get(&path).cloned()
1573    }; // lock released BEFORE any IO
1574
1575    let (rest_sender, path_params) = if api_sender.is_some() {
1576        // Exact legacy match won — skip the templated scan entirely.
1577        (None, Default::default())
1578    } else {
1579        let inner = state.registry.inner.read().await;
1580        match rest_match::match_endpoint(&method, &path, &inner.rest_endpoints) {
1581            rest_match::MatchOutcome::Found(m) => (Some(m.payload), m.path_params),
1582            rest_match::MatchOutcome::Ambiguous => {
1583                // Ambiguous registration should have been rejected at
1584                // lowering time (rest.rs). Reaching here means two
1585                // equal-specificity templates matched one request —
1586                // surface a loud error rather than a silent 404. Review C3.
1587                // log-policy: handler-owned
1588                tracing::warn!(
1589                    method = %method,
1590                    path = %path,
1591                    "ambiguous REST template match — returning 500"
1592                );
1593                return Response::builder()
1594                    .status(StatusCode::INTERNAL_SERVER_ERROR)
1595                    .body(AxumBody::from("Internal Server Error"))
1596                    .expect("infallible"); // allow-unwrap
1597            }
1598            rest_match::MatchOutcome::NotFound => (None, Default::default()),
1599        }
1600    }; // lock released BEFORE any IO
1601
1602    let sender = api_sender.or(rest_sender);
1603
1604    if let Some(sender) = sender {
1605        let query = req.uri().query().unwrap_or("").to_string();
1606        let headers = req.headers().clone();
1607
1608        // Check Content-Length against limit BEFORE opening the stream
1609        let content_length: Option<u64> = headers
1610            .get(http::header::CONTENT_LENGTH)
1611            .and_then(|v| v.to_str().ok())
1612            .and_then(|s| s.parse().ok());
1613
1614        if let Some(len) = content_length
1615            && len > state.max_request_body as u64
1616        {
1617            return Response::builder()
1618                .status(StatusCode::PAYLOAD_TOO_LARGE)
1619                .body(AxumBody::from("Request body exceeds configured limit"))
1620                .expect("infallible"); // allow-unwrap
1621        }
1622
1623        let _permit = match Arc::clone(&state.inflight).try_acquire_owned() {
1624            Ok(permit) => permit,
1625            Err(_) => {
1626                return Response::builder()
1627                    .status(StatusCode::SERVICE_UNAVAILABLE)
1628                    .body(AxumBody::from("Service Unavailable"))
1629                    .expect("infallible"); // allow-unwrap
1630            }
1631        };
1632
1633        // Build StreamBody from Axum body WITHOUT materializing.
1634        // SECURITY (audit 2026-08-31, F2-1): the Content-Length pre-check above
1635        // cannot see chunked/no-length requests. Wrap the stream with a hard
1636        // byte cap so ANY downstream consumption fails closed once
1637        // max_request_body is exceeded — the cap travels with the body.
1638        let content_type = headers
1639            .get(http::header::CONTENT_TYPE)
1640            .and_then(|v| v.to_str().ok())
1641            .map(|s| s.to_string());
1642
1643        let data_stream: BodyDataStream = req.into_body().into_data_stream();
1644        let max_body = state.max_request_body;
1645        let mut seen: u64 = 0;
1646        let capped_stream =
1647            data_stream
1648                .map_err(|e| CamelError::Io(e.to_string()))
1649                .map(move |chunk| match chunk {
1650                    Ok(bytes) => {
1651                        seen = seen.saturating_add(bytes.len() as u64);
1652                        if seen > max_body as u64 {
1653                            Err(CamelError::ProcessorError(format!(
1654                                "Request body exceeds configured limit of {max_body} bytes"
1655                            )))
1656                        } else {
1657                            Ok(bytes)
1658                        }
1659                    }
1660                    Err(e) => Err(e),
1661                });
1662        let boxed: BoxStream<'static, Result<bytes::Bytes, CamelError>> = Box::pin(capped_stream);
1663
1664        let stream_body = StreamBody {
1665            stream: Arc::new(tokio::sync::Mutex::new(Some(boxed))),
1666            metadata: StreamMetadata {
1667                size_hint: content_length,
1668                content_type,
1669                origin: None,
1670            },
1671        };
1672
1673        let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<HttpReply>();
1674        let envelope = RequestEnvelope {
1675            method,
1676            path,
1677            query,
1678            headers,
1679            body: stream_body,
1680            path_params,
1681            reply_tx,
1682        };
1683
1684        if sender.send(envelope).await.is_err() {
1685            return Response::builder()
1686                .status(StatusCode::SERVICE_UNAVAILABLE)
1687                .body(AxumBody::from("Consumer unavailable"))
1688                .expect("infallible"); // allow-unwrap
1689        }
1690
1691        match reply_rx.await {
1692            Ok(reply) => {
1693                let reply = match reply.body {
1694                    HttpReplyBody::Bytes(b)
1695                        if exceeds_max_response_body(b.len(), state.max_response_body) =>
1696                    {
1697                        HttpReply {
1698                            status: 500,
1699                            headers: vec![],
1700                            body: HttpReplyBody::Bytes(bytes::Bytes::from(
1701                                "Response body exceeds configured limit",
1702                            )),
1703                        }
1704                    }
1705                    _ => reply,
1706                };
1707
1708                let status =
1709                    StatusCode::from_u16(reply.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1710                let mut builder = Response::builder().status(status);
1711                for (k, v) in &reply.headers {
1712                    builder = builder.header(k.as_str(), v.as_str());
1713                }
1714                match reply.body {
1715                    HttpReplyBody::Bytes(b) => {
1716                        builder.body(AxumBody::from(b)).unwrap_or_else(|_| {
1717                            Response::builder()
1718                                .status(StatusCode::INTERNAL_SERVER_ERROR)
1719                                .body(AxumBody::from("Invalid response headers from consumer"))
1720                                .expect("infallible") // allow-unwrap
1721                        })
1722                    }
1723                    HttpReplyBody::Stream(stream) => builder
1724                        .body(AxumBody::from_stream(stream))
1725                        .unwrap_or_else(|_| {
1726                            Response::builder()
1727                                .status(StatusCode::INTERNAL_SERVER_ERROR)
1728                                .body(AxumBody::from("Invalid response headers from consumer"))
1729                                .expect("infallible") // allow-unwrap
1730                        }),
1731                }
1732            }
1733            Err(_) => Response::builder()
1734                .status(StatusCode::INTERNAL_SERVER_ERROR)
1735                .body(AxumBody::from("Pipeline error"))
1736                .expect("infallible"), // allow-unwrap
1737        }
1738    } else {
1739        // No API route matched — try static mounts
1740        static_dispatch::dispatch_static(&state, req, &path).await
1741    }
1742}
1743
1744fn exceeds_max_response_body(len: usize, max: usize) -> bool {
1745    len > max
1746}
1747
1748fn title_case_header(name: &str) -> String {
1749    name.split('-')
1750        .map(|part| {
1751            let mut chars = part.chars();
1752            match chars.next() {
1753                None => String::new(),
1754                Some(first) => first.to_uppercase().chain(chars.as_str().chars()).collect(),
1755            }
1756        })
1757        .collect::<Vec<_>>()
1758        .join("-")
1759}
1760
1761// ---------------------------------------------------------------------------
1762// HttpConsumer
1763// ---------------------------------------------------------------------------
1764
1765/// Kernel authentication state captured from a route's [`SecurityContext`]
1766/// (`unify-transport-auth`, Task 2.9).
1767///
1768/// Same construction-order lifecycle as gRPC's `GrpcKernelAuth` (Task 2.1):
1769/// the compiled plan and the provider registry arrive via
1770/// `Consumer::set_security_context` before `start()` accepts requests. A
1771/// context lacking either piece keeps `kernel = None` — a plan without
1772/// providers can never mint a principal (fail-closed, never a silently
1773/// unauthenticated route: the controller's strict-mode dispatch check then
1774/// denies carrier-less Exchanges on non-Public plans).
1775pub(crate) struct HttpKernelAuth {
1776    pub(crate) plan: camel_api::security_policy::RouteSecurityPlan,
1777    pub(crate) providers: Arc<camel_auth::ProviderRegistry>,
1778}
1779
1780impl HttpKernelAuth {
1781    /// Capture the kernel state from a route's security context.
1782    ///
1783    /// `None` unless both the compiled plan and the provider registry are
1784    /// present.
1785    pub(crate) fn from_security_context(
1786        ctx: &camel_component_api::SecurityContext,
1787    ) -> Option<Self> {
1788        Some(Self {
1789            plan: ctx.plan.clone()?,
1790            providers: ctx.providers.clone()?,
1791        })
1792    }
1793}
1794
1795/// Capacity for the per-route RequestEnvelope channel.
1796///
1797/// Each in-flight request holds exactly one `maxInflightRequests` semaphore
1798/// permit from before `send()` until its reply, so at most N envelopes can be
1799/// outstanding at any time. A buffer of N therefore can never fill before the
1800/// semaphore exhausts: dispatcher `send()` calls never park on a full buffer
1801/// and the semaphore stays the single, URI-configurable backpressure point.
1802/// A hardcoded smaller buffer would act as a second, hidden inflight cap
1803/// (rc-3y6j: 64 vs default 1024 permits).
1804///
1805/// `max(1)`: `maxInflightRequests=0` is a representable "reject everything"
1806/// configuration, but `tokio::sync::mpsc::channel(0)` panics — keep consumer
1807/// start panic-free (the empty semaphore still 503s every request).
1808fn envelope_channel_capacity(max_inflight_requests: usize) -> usize {
1809    max_inflight_requests.max(1)
1810}
1811
1812/// Upper bound check for `maxInflightRequests`.
1813///
1814/// bd rc-ns3yc: `tokio::sync::Semaphore::new` panics for permit counts above
1815/// [`tokio::sync::Semaphore::MAX_PERMITS`], so any representable `usize`
1816/// above that bound must be rejected fail-closed with a typed configuration
1817/// error naming the parameter, the configured value, and the limit — before
1818/// the semaphore primitive is ever constructed. Enforced at URI parse,
1819/// `create_consumer`, consumer start, and `spawn_entry` (defense-in-depth);
1820/// mirror of the grpc `consumer_concurrency_limit` fix (commit 101327e5).
1821///
1822/// `0` is deliberately NOT normalized here (rc-3y6j): it remains a
1823/// representable reject-everything value (`envelope_channel_capacity`
1824/// separately guards the one primitive — the envelope channel — that
1825/// cannot take 0).
1826pub(crate) fn max_inflight_requests_limit(configured: usize) -> Result<usize, CamelError> {
1827    if configured > tokio::sync::Semaphore::MAX_PERMITS {
1828        return Err(CamelError::Config(format!(
1829            "maxInflightRequests {configured} exceeds the supported upper bound {} (tokio::sync::Semaphore::MAX_PERMITS)",
1830            tokio::sync::Semaphore::MAX_PERMITS
1831        )));
1832    }
1833    Ok(configured)
1834}
1835
1836pub struct HttpConsumer {
1837    config: HttpServerConfig,
1838    /// Runtime observability handle for ADR-0012 metrics and health calls.
1839    runtime: Arc<dyn RuntimeObservability>,
1840    /// Kernel authentication state (plan + providers), set via
1841    /// `set_security_context` before `start()` (Task 2.9). `None` for routes
1842    /// without route-level security (Public under the per-bind gate).
1843    kernel: Option<Arc<HttpKernelAuth>>,
1844}
1845
1846impl HttpConsumer {
1847    pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
1848        Self {
1849            config,
1850            runtime,
1851            kernel: None,
1852        }
1853    }
1854}
1855
1856#[async_trait::async_trait]
1857impl Consumer for HttpConsumer {
1858    async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
1859        use camel_component_api::{Body, Exchange, Message};
1860
1861        // bd rc-ns3yc: fail-closed BEFORE shared-server registry
1862        // interaction, listener binding, envelope-channel construction, or
1863        // inflight-semaphore construction — an oversized value must never
1864        // reach Semaphore::new (panic).
1865        max_inflight_requests_limit(self.config.max_inflight_requests)?;
1866
1867        let registry = ServerRegistry::global()
1868            .get_or_spawn(
1869                &self.config.host,
1870                self.config.port,
1871                self.config.max_request_body,
1872                self.config.max_response_body,
1873                self.config.max_inflight_requests,
1874                self.runtime.clone(),
1875                ctx.route_id().to_string(),
1876                self.config.tls_config.clone(),
1877            )
1878            .await?;
1879
1880        // Create channel for this path and register it. Capacity matches the
1881        // dispatcher's inflight semaphore (see envelope_channel_capacity) so
1882        // the channel can never become a second backpressure point.
1883        let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(
1884            envelope_channel_capacity(self.config.max_inflight_requests),
1885        );
1886        // When the from-URI carries `httpMethod=...` (REST-lowered
1887        // route), register the consumer as a method-aware REST endpoint
1888        // so the dispatcher can route by (method, path template).
1889        // Otherwise fall back to the legacy path-only api_routes
1890        // registry. The two registries never overlap for the same
1891        // route: each consumer registers in exactly one of them.
1892        if let Some(method) = self.config.method.clone() {
1893            let segments = rest_match::parse_path_template(&self.config.path);
1894            registry
1895                .register_rest_endpoint(method, segments, env_tx)
1896                .await;
1897        } else {
1898            registry
1899                .register_api_route(self.config.path.clone(), env_tx)
1900                .await;
1901        }
1902
1903        // rc-w1u9: Signal readiness AFTER (1) TcpListener::bind succeeded
1904        // (inside get_or_spawn above), (2) the axum server task was spawned,
1905        // and (3) this route's path/REST endpoint was registered. At this
1906        // point the listener is genuinely accepting connections and any
1907        // request to this route will be dispatched (not 404'd). The runtime
1908        // uses this signal to publish RouteStarted and to release
1909        // ctx.start() so external benchmarks can emit a reliable
1910        // listener-bound marker.
1911        ctx.mark_ready();
1912
1913        // rc-nftni (drainclaim): capture the context-global counter once;
1914        // every envelope this raw-sender consumer constructs carries a
1915        // claim minted at the acceptance dequeue below.
1916        let in_flight = ctx.in_flight_counter();
1917
1918        let path = self.config.path.clone();
1919        let registry_for_cleanup = registry.clone();
1920        let server_exited = registry.server_exited.clone();
1921        let cancel_token = ctx.cancel_token();
1922        let kernel = self.kernel.clone();
1923        // Set when the loop exits because the shared server died. The
1924        // post-loop cleanup still runs, then `start()` returns Err so
1925        // camel-core's consumer watcher emits a CrashNotification for THIS
1926        // route and supervision backoff engages (ADR-0007).
1927        let mut server_died = false;
1928        loop {
1929            tokio::select! {
1930                _ = ctx.cancelled() => {
1931                    break;
1932                }
1933                _ = server_exited.cancelled() => {
1934                    // Shared transport death: this route's consumer cannot
1935                    // continue. Fail (do NOT hang in Running) — parity with
1936                    // per-route transport death, which also surfaces as a
1937                    // consumer-task error.
1938                    server_died = true;
1939                    break;
1940                }
1941                 envelope = env_rx.recv() => {
1942                    let Some(envelope) = envelope else { break; };
1943
1944                    // rc-nftni: mint at acceptance — the dequeue of the
1945                    // dispatcher's RequestEnvelope is where this consumer
1946                    // takes ownership of the wire request. The claim is held
1947                    // across the authn await, the route channel, and the
1948                    // pipeline; every early exit in the per-request task
1949                    // (cancel-503, auth denial) drops it, and a failed push
1950                    // rolls it back with the dropped envelope (RAII).
1951                    let claim =
1952                        in_flight.as_ref().map(camel_component_api::InFlightClaim::attach);
1953
1954                    // Build Exchange from HTTP request
1955                    let mut msg = Message::default();
1956
1957                    // Set standard Camel HTTP headers
1958                    msg.set_header("CamelHttpMethod",
1959                        serde_json::Value::String(envelope.method.clone()));
1960                    msg.set_header("CamelHttpPath",
1961                        serde_json::Value::String(envelope.path.clone()));
1962                    msg.set_header("CamelHttpQuery",
1963                        serde_json::Value::String(envelope.query.clone()));
1964
1965                    // Set path-parameter headers from REST template
1966                    // match. Expert guidance E2: the consumer is
1967                    // responsible for translating the dispatcher's
1968                    // matched params into `CamelHttpPath_<param>`
1969                    // headers on the Exchange, matching the convention
1970                    // used by Camel HTTP for templated routes.
1971                    for (param_name, param_value) in &envelope.path_params {
1972                        msg.set_header(
1973                            format!("CamelHttpPath_{param_name}"),
1974                            serde_json::Value::String(param_value.clone()),
1975                        );
1976                    }
1977
1978                    // Forward HTTP headers with Title-Case names (hyper lowercases them)
1979                    for (k, v) in &envelope.headers {
1980                        if let Ok(val_str) = v.to_str() {
1981                            msg.set_header(
1982                                title_case_header(k.as_str()),
1983                                serde_json::Value::String(val_str.to_string()),
1984                            );
1985                        }
1986                    }
1987
1988                    // Body: always arrives as Body::Stream (native streaming)
1989                    // Routes can call into_bytes() if they need to materialize
1990                    msg.body = Body::Stream(envelope.body);
1991
1992                    #[allow(unused_mut)]
1993                    let mut exchange = Exchange::new(msg);
1994
1995                    // Extract W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
1996                    #[cfg(feature = "otel")]
1997                    {
1998                        let headers: HashMap<String, String> = envelope
1999                            .headers
2000                            .iter()
2001                            .filter_map(|(k, v)| {
2002                                Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
2003                            })
2004                            .collect();
2005                        camel_otel::extract_into_exchange(&mut exchange, &headers);
2006                    }
2007
2008                    let reply_tx = envelope.reply_tx;
2009                    let sender = ctx.sender().clone();
2010                    let path_clone = path.clone();
2011                    let cancel = cancel_token.clone();
2012                    // Task 2.9 boundary-auth inputs: the raw header map and
2013                    // the request URI (path + query) feed kernel credential
2014                    // extraction inside the per-request task.
2015                    let auth_headers = envelope.headers.clone();
2016                    let auth_uri: http::Uri = {
2017                        let full = if envelope.query.is_empty() {
2018                            envelope.path.clone()
2019                        } else {
2020                            format!("{}?{}", envelope.path, envelope.query)
2021                        };
2022                        // A malformed path cannot become a valid `Uri`; the
2023                        // empty default then carries no credentials, so
2024                        // extraction finds nothing and authn fails closed.
2025                        full.parse().unwrap_or_default()
2026                    };
2027                    let kernel = kernel.clone();
2028
2029                    // Spawn a task to handle this request concurrently
2030                    //
2031                    // NOTE: This spawns a separate tokio task for each incoming HTTP request to enable
2032                    // true concurrent request processing. This change was introduced as part of the
2033                    // pipeline concurrency feature and was NOT part of the original HttpConsumer design.
2034                    //
2035                    // Rationale:
2036                    // 1. Without spawning per-request tasks, the send_and_wait() operation would block
2037                    //    the consumer's main loop until the pipeline processing completes
2038                    // 2. This blocking would prevent multiple HTTP requests from being processed
2039                    //    concurrently, even when ConcurrencyModel::Concurrent is enabled on the pipeline
2040                    // 3. The channel would never have multiple exchanges buffered simultaneously,
2041                    //    defeating the purpose of pipeline-side concurrency
2042                    // 4. By spawning a task per request, we allow the consumer loop to continue
2043                    //    accepting new requests while existing ones are processed in the pipeline
2044                    //
2045                    // This approach effectively decouples request acceptance from pipeline processing,
2046                    // allowing the channel to buffer multiple exchanges that can be processed concurrently
2047                    // by the pipeline when ConcurrencyModel::Concurrent is active.
2048                    tokio::spawn(async move {
2049                        // Check for cancellation before sending to pipeline.
2050                        // Returns 503 (Service Unavailable) instead of letting the request
2051                        // enter a shutting-down pipeline. This is a behavioral change from
2052                        // the pre-concurrency implementation where cancellation during
2053                        // processing would result in a 500 (Internal Server Error).
2054                        // 503 is more semantically correct: the server is temporarily
2055                        // unable to handle the request due to shutdown.
2056                        if cancel.is_cancelled() {
2057                            let _ = reply_tx.send(HttpReply {
2058                                status: 503,
2059                                headers: vec![],
2060                                body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
2061                            });
2062                            return;
2063                        }
2064
2065                        // ADR-0061 Task 2.9: kernel authentication at the
2066                        // request boundary. A `Public` plan passes through
2067                        // with no extraction; any other mode extracts per
2068                        // the plan's sources, authenticates through the
2069                        // kernel, and installs the typed carrier BEFORE the
2070                        // pipeline runs. A denial renders in the HTTP idiom
2071                        // (401 via `pipeline_error_to_reply`) and the route
2072                        // body never sees the request.
2073                        if let Some(kernel) = kernel.as_ref()
2074                            && !matches!(
2075                                kernel.plan.access_mode,
2076                                camel_api::security_policy::AccessMode::Public
2077                            )
2078                        {
2079                            let principal = match camel_auth::extract_token_multi(
2080                                &auth_headers,
2081                                &auth_uri,
2082                                &kernel.plan.credential_sources,
2083                            ) {
2084                                Some(extracted) => {
2085                                    match camel_auth::kernel_authenticate(
2086                                        &kernel.plan,
2087                                        &kernel.providers,
2088                                        &extracted,
2089                                    )
2090                                    .await
2091                                    {
2092                                        Ok(principal) => principal,
2093                                        Err(e) => {
2094                                            // log-policy: handler-owned
2095                                            tracing::warn!(
2096                                                path = %path_clone,
2097                                                error = %e,
2098                                                "HTTP request authentication failed"
2099                                            );
2100                                            let _ = reply_tx.send(pipeline_error_to_reply(
2101                                                e,
2102                                                &path_clone,
2103                                            ));
2104                                            return;
2105                                        }
2106                                    }
2107                                }
2108                                None => {
2109                                    // log-policy: handler-owned
2110                                    tracing::warn!(
2111                                        path = %path_clone,
2112                                        "HTTP request rejected: no credential found in any source"
2113                                    );
2114                                    let _ = reply_tx.send(pipeline_error_to_reply(
2115                                        CamelError::Unauthenticated(
2116                                            "no credential found in any source".to_string(),
2117                                        ),
2118                                        &path_clone,
2119                                    ));
2120                                    return;
2121                                }
2122                            };
2123                            camel_auth::install_carrier(&mut exchange, &principal);
2124                        }
2125
2126                        // Send through pipeline and await result
2127                        let (tx, rx) = tokio::sync::oneshot::channel();
2128                        let envelope = camel_component_api::consumer::ExchangeEnvelope {
2129                            exchange,
2130                            reply_tx: Some(tx),
2131                            // rc-nftni: the acceptance-minted claim rides the
2132                            // envelope; the pipeline drain sites take it and
2133                            // hold it across the pipeline (release at
2134                            // completion; rejection paths above already
2135                            // dropped it).
2136                            in_flight_claim: claim,
2137                        };
2138
2139                        let result = match sender.send(envelope).await {
2140                            Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
2141                            Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
2142                        }
2143                        .and_then(|r| r);
2144
2145                        let reply = match result {
2146                            Ok(out) => {
2147                                let status = out
2148                                    .input
2149                                    .header("CamelHttpResponseCode")
2150                                    .and_then(|v| {
2151                                        let raw = v.as_u64()
2152                                            .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
2153                                        let code = raw as u16;
2154                                        (100..1000).contains(&code).then_some(code)
2155                                    })
2156                                    .unwrap_or(200);
2157
2158                                let user_content_type = out
2159                                    .input
2160                                    .header("Content-Type")
2161                                    .and_then(|v| v.as_str().map(|s| s.to_string()));
2162
2163                                let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
2164                                    Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
2165                                    Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
2166                                    Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
2167                                    Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
2168                                        v.to_string().into_bytes(),
2169                                    )), Some("application/json".to_string())),
2170                                    Body::Stream(s) => {
2171                                        let ct = s.metadata.content_type.clone();
2172                                        match s.stream.lock().await.take() {
2173                                            Some(stream) => (
2174                                                HttpReplyBody::Stream(stream),
2175                                                ct,
2176                                            ),
2177                                            None => {
2178                                                // log-policy: system-broken
2179                                                tracing::error!(
2180                                                    "Body::Stream already consumed before HTTP reply — returning 500"
2181                                                );
2182                                                let error_reply = HttpReply {
2183                                                    status: 500,
2184                                                    headers: vec![],
2185                                                    body: HttpReplyBody::Bytes(bytes::Bytes::new()),
2186                                                };
2187                                                if reply_tx.send(error_reply).is_err() {
2188                                                    debug!("reply_tx dropped before error reply could be sent");
2189                                                }
2190                                                return;
2191                                            }
2192                                        }
2193                                    }
2194                                    // Empty and future variants produce an empty reply body.
2195                                    _ => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
2196                                };
2197
2198                                let resp_headers = select_response_headers(
2199                                    &out.input.headers,
2200                                    user_content_type,
2201                                    inferred_content_type,
2202                                );
2203
2204                                HttpReply {
2205                                    status,
2206                                    headers: resp_headers,
2207                                    body: reply_body,
2208                                }
2209                            }
2210                            Err(e) => {
2211                                pipeline_error_to_reply(e, &path_clone)
2212                            }
2213                        };
2214
2215                        // Reply to Axum handler (ignore error if client disconnected)
2216                        let _ = reply_tx.send(reply);
2217                    });
2218                }
2219            }
2220        }
2221
2222        // Deregister this consumer. Mirror the registration choice:
2223        // REST-registered consumers remove their (method, path) endpoint
2224        // WITHOUT touching sibling verbs on the same template (review C1);
2225        // legacy consumers clean up api_routes.
2226        if let Some(method) = &self.config.method {
2227            registry_for_cleanup
2228                .unregister_rest_endpoint(method, &path)
2229                .await;
2230        } else {
2231            registry_for_cleanup.unregister_api_route(&path).await;
2232        }
2233
2234        // Leave the shared-server entry: `unregister` is a no-op today (no
2235        // refcount exists — stale D-L10 wording removed, rc-szmob review).
2236        // Dead servers are evicted lazily by `get_or_spawn_internal`, which
2237        // checks `monitor_task.is_finished()` and rebinds on the next spawn
2238        // (e.g. a supervision restart after this consumer's Err).
2239        ServerRegistry::global()
2240            .unregister(&self.config.host, self.config.port)
2241            .await;
2242
2243        if server_died {
2244            // log-policy: system-broken
2245            tracing::error!(
2246                host = %camel_api::redact::redact_host(&self.config.host),
2247                port = self.config.port,
2248                path = %path,
2249                "Shared HTTP server exited — failing consumer to engage route supervision (ADR-0007)"
2250            );
2251            // The error value is logged upstream by supervision (ADR-0076):
2252            // the host must ride the canonical masker, message structure
2253            // unchanged (bd rc-8bxeo item 3).
2254            return Err(CamelError::RouteError(format!(
2255                "shared HTTP server for {}:{} exited unexpectedly; route transport is dead",
2256                camel_api::redact::redact_host(&self.config.host),
2257                self.config.port
2258            )));
2259        }
2260
2261        Ok(())
2262    }
2263
2264    async fn stop(&mut self) -> Result<(), CamelError> {
2265        Ok(())
2266    }
2267
2268    fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
2269        camel_component_api::ConcurrencyModel::Concurrent { max: None }
2270    }
2271
2272    // rc-w1u9: HTTP consumer binds a TcpListener inside start() (via
2273    // ServerRegistry::get_or_spawn) and only THEN can it accept connections.
2274    // Opting into Explicit startup makes ctx.start() await the bind+register
2275    // completion so listeners fail fast on bind errors (previously a silent
2276    // background log) and external markers can reliably detect listener-bound
2277    // state.
2278    fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
2279        camel_component_api::ConsumerStartupMode::Explicit
2280    }
2281
2282    // Task 2.9: capture the kernel state (compiled plan + provider registry)
2283    // wired by the route controller before start(). See `HttpKernelAuth`.
2284    fn set_security_context(&mut self, ctx: camel_component_api::SecurityContext) {
2285        self.kernel = HttpKernelAuth::from_security_context(&ctx).map(Arc::new);
2286    }
2287}
2288
2289// ---------------------------------------------------------------------------
2290// HttpComponent / HttpsComponent
2291// ---------------------------------------------------------------------------
2292
2293pub struct HttpComponent {
2294    config: HttpConfig,
2295    pinned_cache: std::sync::Arc<PinnedClientCache>,
2296    client: reqwest::Client,
2297    /// Set at construction when `tls.strict` is on and the configured
2298    /// material fails to load; surfaced as an endpoint-creation failure
2299    /// (rc-ayrwk).
2300    strict_tls_error: Option<CamelError>,
2301}
2302
2303impl HttpComponent {
2304    pub fn new() -> Self {
2305        let config = HttpConfig::default();
2306        let strict_err = strict_tls_error(&config);
2307        let (client, build_err) = client_or_emergency(&config);
2308        Self {
2309            client,
2310            config,
2311            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2312                PINNED_CLIENT_TTL,
2313                PINNED_CLIENT_MAX_ENTRIES,
2314            )),
2315            strict_tls_error: strict_err.or(build_err),
2316        }
2317    }
2318
2319    pub fn with_config(config: HttpConfig) -> Self {
2320        let strict_err = strict_tls_error(&config);
2321        let (client, build_err) = client_or_emergency(&config);
2322        Self {
2323            client,
2324            config,
2325            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2326                PINNED_CLIENT_TTL,
2327                PINNED_CLIENT_MAX_ENTRIES,
2328            )),
2329            strict_tls_error: strict_err.or(build_err),
2330        }
2331    }
2332
2333    pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2334        match config {
2335            Some(cfg) => Self::with_config(cfg),
2336            None => Self::new(),
2337        }
2338    }
2339}
2340
2341impl Default for HttpComponent {
2342    fn default() -> Self {
2343        Self::new()
2344    }
2345}
2346
2347impl Component for HttpComponent {
2348    fn scheme(&self) -> &str {
2349        "http"
2350    }
2351
2352    fn metadata(&self) -> ComponentMetadata {
2353        HttpEndpointConfig::metadata()
2354    }
2355
2356    fn create_endpoint(
2357        &self,
2358        uri: &str,
2359        ctx: &dyn camel_component_api::ComponentContext,
2360    ) -> Result<Box<dyn Endpoint>, CamelError> {
2361        if let Some(err) = &self.strict_tls_error {
2362            return Err(err.clone());
2363        }
2364        self.config.validate()?;
2365        let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2366        let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2367        ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2368            server_config.host.clone(),
2369            server_config.port,
2370        )));
2371        self.pinned_cache
2372            .wire(HttpComponentKind::Http, ctx.metrics());
2373        Ok(Box::new(HttpEndpoint {
2374            uri: uri.to_string(),
2375            config,
2376            server_config,
2377            client: self.client.clone(),
2378            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2379            http_config: self.config.clone(),
2380        }))
2381    }
2382}
2383
2384pub struct HttpsComponent {
2385    config: HttpConfig,
2386    pinned_cache: std::sync::Arc<PinnedClientCache>,
2387    client: reqwest::Client,
2388    /// Set at construction when `tls.strict` is on and the configured
2389    /// material fails to load; surfaced as an endpoint-creation failure
2390    /// (rc-ayrwk).
2391    strict_tls_error: Option<CamelError>,
2392}
2393
2394impl HttpsComponent {
2395    pub fn new() -> Self {
2396        let config = HttpConfig::default();
2397        let strict_err = strict_tls_error(&config);
2398        let (client, build_err) = client_or_emergency(&config);
2399        Self {
2400            client,
2401            config,
2402            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2403                PINNED_CLIENT_TTL,
2404                PINNED_CLIENT_MAX_ENTRIES,
2405            )),
2406            strict_tls_error: strict_err.or(build_err),
2407        }
2408    }
2409
2410    pub fn with_config(config: HttpConfig) -> Self {
2411        let strict_err = strict_tls_error(&config);
2412        let (client, build_err) = client_or_emergency(&config);
2413        Self {
2414            client,
2415            config,
2416            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2417                PINNED_CLIENT_TTL,
2418                PINNED_CLIENT_MAX_ENTRIES,
2419            )),
2420            strict_tls_error: strict_err.or(build_err),
2421        }
2422    }
2423
2424    pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2425        match config {
2426            Some(cfg) => Self::with_config(cfg),
2427            None => Self::new(),
2428        }
2429    }
2430}
2431
2432impl Default for HttpsComponent {
2433    fn default() -> Self {
2434        Self::new()
2435    }
2436}
2437
2438impl Component for HttpsComponent {
2439    fn scheme(&self) -> &str {
2440        "https"
2441    }
2442
2443    fn metadata(&self) -> ComponentMetadata {
2444        // HTTPS shares the same URI option surface and capabilities as HTTP.
2445        // Only the scheme and description differ.
2446        let mut meta = HttpEndpointConfig::metadata();
2447        meta.scheme = "https".to_string();
2448        meta.description = "HTTPS client and server component (TLS over HTTP)".to_string();
2449        meta
2450    }
2451
2452    fn create_endpoint(
2453        &self,
2454        uri: &str,
2455        ctx: &dyn camel_component_api::ComponentContext,
2456    ) -> Result<Box<dyn Endpoint>, CamelError> {
2457        if let Some(err) = &self.strict_tls_error {
2458            return Err(err.clone());
2459        }
2460        self.config.validate()?;
2461        let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2462        let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2463        ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2464            server_config.host.clone(),
2465            server_config.port,
2466        )));
2467        self.pinned_cache
2468            .wire(HttpComponentKind::Https, ctx.metrics());
2469        Ok(Box::new(HttpEndpoint {
2470            uri: uri.to_string(),
2471            config,
2472            server_config,
2473            client: self.client.clone(),
2474            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2475            http_config: self.config.clone(),
2476        }))
2477    }
2478}
2479
2480// ---------------------------------------------------------------------------
2481// HttpEndpoint
2482// ---------------------------------------------------------------------------
2483
2484struct HttpEndpoint {
2485    uri: String,
2486    config: HttpEndpointConfig,
2487    server_config: HttpServerConfig,
2488    client: reqwest::Client,
2489    pinned_cache: std::sync::Arc<PinnedClientCache>,
2490    http_config: HttpConfig,
2491}
2492
2493impl Endpoint for HttpEndpoint {
2494    fn uri(&self) -> &str {
2495        &self.uri
2496    }
2497
2498    fn create_consumer(
2499        &self,
2500        rt: Arc<dyn camel_component_api::RuntimeObservability>,
2501    ) -> Result<Box<dyn Consumer>, CamelError> {
2502        // Scheme/config consistency check (spec §5) — uses parsed scheme
2503        // from HttpServerConfig, not a fragile port-443 heuristic.
2504        let scheme_is_https = self.server_config.scheme == "https";
2505        let has_tls = self.server_config.tls_config.is_some();
2506
2507        if scheme_is_https && !has_tls {
2508            return Err(CamelError::EndpointCreationFailed(
2509                "https:// consumer requires tlsCert and tlsKey parameters".to_string(),
2510            ));
2511        }
2512        if !scheme_is_https && has_tls {
2513            return Err(CamelError::EndpointCreationFailed(
2514                "http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
2515            ));
2516        }
2517        // bd rc-ns3yc: fail-closed BEFORE any HttpConsumer is constructed —
2518        // a directly-built HttpServerConfig never passed the parse seam, and
2519        // the inflight semaphore panics above the primitive bound
2520        // (mirror of grpc consumer_concurrency_limit, commit 101327e5).
2521        max_inflight_requests_limit(self.server_config.max_inflight_requests)?;
2522        Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
2523    }
2524
2525    fn create_producer(
2526        &self,
2527        rt: Arc<dyn camel_component_api::RuntimeObservability>,
2528        _ctx: &ProducerContext,
2529    ) -> Result<BoxProcessor, CamelError> {
2530        let producer = HttpProducer {
2531            config: Arc::new(self.config.clone()),
2532            client: self.client.clone(),
2533            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2534            http_config: Arc::new(self.http_config.clone()),
2535            runtime: rt,
2536        };
2537        if let Some(ref provider) = self.config.token_provider {
2538            let layer = BearerTokenLayer::new(Arc::clone(provider));
2539            Ok(BoxProcessor::new(layer.layer(producer)))
2540        } else {
2541            Ok(BoxProcessor::new(producer))
2542        }
2543    }
2544}
2545
2546// ---------------------------------------------------------------------------
2547// HttpProducer
2548// ---------------------------------------------------------------------------
2549
2550#[derive(Clone)]
2551struct HttpProducer {
2552    config: Arc<HttpEndpointConfig>,
2553    client: reqwest::Client,
2554    pinned_cache: std::sync::Arc<PinnedClientCache>,
2555    http_config: Arc<HttpConfig>,
2556    /// Runtime observability handle powering the component-ops facade at
2557    /// the request boundary (`("http","request")`, dashboard-observability
2558    /// Task 4.3). The retained `e:http:accept*` labels are consumer-side
2559    /// (server accept loop) — different boundary, no collision with
2560    /// `e:http:request`.
2561    runtime: Arc<dyn RuntimeObservability>,
2562}
2563
2564impl HttpProducer {
2565    fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
2566        if let Some(ref method) = config.http_method {
2567            return method.to_uppercase();
2568        }
2569        if let Some(method) = exchange
2570            .input
2571            .header("CamelHttpMethod")
2572            .and_then(|v| v.as_str())
2573        {
2574            return method.to_uppercase();
2575        }
2576        if !exchange.input.body.is_empty() {
2577            return "POST".to_string();
2578        }
2579        "GET".to_string()
2580    }
2581
2582    fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> Result<String, CamelError> {
2583        // bridgeEndpoint=true: exchange URL headers (CamelHttpUri,
2584        // CamelHttpPath, CamelHttpQuery) are ignored per Apache Camel
2585        // bridging semantics. The endpoint's own query still rides: the
2586        // same raw-preserving, consumed-option-filtered query as the
2587        // non-bridge path (bridgeEndpoint itself is a consumed option),
2588        // with programmatic query_params appending absent keys after the
2589        // raw base. This check MUST come before the CamelHttpUri override
2590        // so bridging wins over that header.
2591        if config.bridge_endpoint {
2592            let Some(query) = resolve_endpoint_query(config)? else {
2593                return Ok(config.base_url.clone());
2594            };
2595            // Validation only (rc-ph7z2): a malformed base still errors
2596            // through the redacted-diagnostic path below. The parsed value
2597            // is NEVER re-emitted — assembly is verbatim string
2598            // composition, authored bytes end-to-end: no WHATWG
2599            // normalization (dot-segment collapse, default-port strip,
2600            // scheme/host lowercasing), matching every other arm (Papal
2601            // Direction A).
2602            let _: url::Url = url::Url::parse(&config.base_url).map_err(|e| {
2603                CamelError::ProcessorError(format!(
2604                    "invalid base URL '{}': {e}",
2605                    redact_url_for_diagnostics(&config.base_url)
2606                ))
2607            })?;
2608            let mut url = config.base_url.clone();
2609            url.push('?');
2610            url.push_str(&query);
2611            return Ok(url);
2612        }
2613
2614        if let Some(uri) = exchange
2615            .input
2616            .header("CamelHttpUri")
2617            .and_then(|v| v.as_str())
2618        {
2619            // Host fence (allowedUriHosts): opt-in, fail-closed. Evaluated
2620            // on the raw override before any path/query assembly; a
2621            // rejection renders the URL only through the diagnostics
2622            // redaction path (ADR-0051).
2623            if let Some(fence) = &config.allowed_uri_hosts
2624                && !uri_host_allowed(uri, fence)?
2625            {
2626                return Err(CamelError::ProcessorError(format!(
2627                    "CamelHttpUri host not allowed by allowedUriHosts fence: {}",
2628                    redact_url_for_diagnostics(uri)
2629                )));
2630            }
2631            // The override replaces the base URL; its own query is the
2632            // higher-precedence source for composition (ADR-0071) — the
2633            // endpoint base query does not ride an override. Split at the
2634            // first `?` so CamelHttpPath applies to the path component
2635            // and the queries merge at pair level, never a second `?`
2636            // marker.
2637            let (base, override_query) = match uri.split_once('?') {
2638                Some((base, query)) => (base, Some(query)),
2639                None => (uri, None),
2640            };
2641            // Resolve-time span validation for the override URI's own query
2642            // (rc-m4xk1): a forbidden byte is a resolve error naming the
2643            // byte, never a verbatim ride that later surfaces as a reqwest
2644            // send error. Covers both downstream arms — the verbatim push
2645            // and merge_header_query, which validates only the header side.
2646            if let Some(query) = override_query {
2647                for (_key, span) in raw_query_pairs(query)? {
2648                    validate_raw_query_span(span)?;
2649                }
2650            }
2651            let mut url = base.to_string();
2652            if let Some(path) = exchange
2653                .input
2654                .header("CamelHttpPath")
2655                .and_then(|v| v.as_str())
2656            {
2657                if !url.ends_with('/') && !path.starts_with('/') {
2658                    url.push('/');
2659                }
2660                url.push_str(path);
2661            }
2662            if let Some(query) = exchange
2663                .input
2664                .header("CamelHttpQuery")
2665                .and_then(|v| v.as_str())
2666            {
2667                if let Some(merged) = merge_header_query(override_query, query)? {
2668                    url.push('?');
2669                    url.push_str(&merged);
2670                }
2671                return Ok(url);
2672            }
2673            if let Some(query) = override_query {
2674                url.push('?');
2675                url.push_str(query);
2676            }
2677            return Ok(url);
2678        }
2679
2680        let mut url = config.base_url.clone();
2681
2682        if let Some(path) = exchange
2683            .input
2684            .header("CamelHttpPath")
2685            .and_then(|v| v.as_str())
2686        {
2687            if !url.ends_with('/') && !path.starts_with('/') {
2688                url.push('/');
2689            }
2690            url.push_str(path);
2691        }
2692
2693        if let Some(query) = exchange
2694            .input
2695            .header("CamelHttpQuery")
2696            .and_then(|v| v.as_str())
2697        {
2698            // Compose: the endpoint query (raw-preserving,
2699            // consumed-option-filtered) comes first and wins collisions;
2700            // header pairs append verbatim for absent keys (ADR-0071).
2701            // An empty header leaves the endpoint query unchanged.
2702            if let Some(merged) =
2703                merge_header_query(resolve_endpoint_query(config)?.as_deref(), query)?
2704            {
2705                url.push('?');
2706                url.push_str(&merged);
2707            }
2708            return Ok(url);
2709        }
2710
2711        if let Some(query) = resolve_endpoint_query(config)? {
2712            url.push('?');
2713            url.push_str(&query);
2714        }
2715
2716        Ok(url)
2717    }
2718
2719    fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
2720        status >= range.0 && status <= range.1
2721    }
2722}
2723
2724/// One allowlist entry of the `CamelHttpUri` host fence (`allowedUriHosts`
2725/// endpoint option). DNS hosts are stored ASCII-lowercased; IPv6 literals
2726/// in bracketed canonical form (the `url` crate's host serialization). A
2727/// `port` of `None` is a host-only entry and permits any port.
2728#[derive(Clone, Debug, PartialEq, Eq)]
2729pub struct AllowedUriHost {
2730    /// Canonical host: lowercased DNS name or bracketed IPv6 literal.
2731    pub host: String,
2732    /// `Some` pins the entry to one effective port; `None` permits any.
2733    pub port: Option<u16>,
2734}
2735
2736/// Parse the `allowedUriHosts` option value: comma-separated `host` or
2737/// `host:port` entries. Bracketed IPv6 is supported (`[::1]:8443`, bare
2738/// `[::1]` host-only). Empty segments are dropped. Segments are parsed
2739/// through the `url` crate (with an `http://` scheme injected) so DNS
2740/// names are lowercased and ports range-checked; anything it rejects is a
2741/// malformed entry. A value yielding zero valid entries is also an error.
2742/// Both failure modes fail endpoint creation (fail-closed).
2743fn parse_allowed_uri_hosts(raw: &str) -> Result<Vec<AllowedUriHost>, CamelError> {
2744    let mut entries = Vec::new();
2745    for segment in raw.split(',') {
2746        let segment = segment.trim();
2747        if segment.is_empty() {
2748            continue;
2749        }
2750        let parsed = url::Url::parse(&format!("http://{segment}"))
2751            .map_err(|_| invalid_allowed_uri_host_entry(segment))?;
2752        // A segment carrying a path or userinfo is a typo'd entry — the
2753        // spec's "any other malformed entry" clause. Silently narrowing it
2754        // to its hostname would widen or skew the fence.
2755        if parsed.path() != "/" || !parsed.username().is_empty() || parsed.password().is_some() {
2756            return Err(invalid_allowed_uri_host_entry(segment));
2757        }
2758        let Some(host) = parsed.host_str() else {
2759            return Err(invalid_allowed_uri_host_entry(segment));
2760        };
2761        entries.push(AllowedUriHost {
2762            host: host.to_string(),
2763            port: parsed.port(),
2764        });
2765    }
2766    if entries.is_empty() {
2767        return Err(CamelError::InvalidUri(
2768            "allowedUriHosts declares no valid host entries".to_string(),
2769        ));
2770    }
2771    Ok(entries)
2772}
2773
2774fn invalid_allowed_uri_host_entry(segment: &str) -> CamelError {
2775    CamelError::InvalidUri(format!("invalid allowedUriHosts entry '{segment}'"))
2776}
2777
2778/// Whether `url_str` matches the fence. Parse failure or a host-less URL
2779/// is fail-closed (`Ok(false)`). DNS hosts compare case-insensitively
2780/// (both sides are lowercased by the `url` crate); IPv6 compares in
2781/// bracketed canonical form. A host-only entry permits any port; a
2782/// `host:port` entry matches only the effective port — the explicit port
2783/// or the scheme default (443 for https, 80 for http).
2784pub(crate) fn uri_host_allowed(
2785    url_str: &str,
2786    fence: &[AllowedUriHost],
2787) -> Result<bool, CamelError> {
2788    let Ok(parsed) = url::Url::parse(url_str) else {
2789        return Ok(false);
2790    };
2791    let Some(host) = parsed.host_str() else {
2792        return Ok(false);
2793    };
2794    let effective_port = parsed.port().or(match parsed.scheme() {
2795        "https" => Some(443_u16),
2796        "http" => Some(80),
2797        _ => None,
2798    });
2799    Ok(fence.iter().any(|entry| {
2800        entry.host == host
2801            && match entry.port {
2802                None => true,
2803                Some(port) => effective_port == Some(port),
2804            }
2805    }))
2806}
2807
2808/// Serialize the outbound query for the endpoint base.
2809///
2810/// Authored raw pairs come first, byte-for-byte minus consumed option keys
2811/// (order, separators and authored escapes — including `RAW(...)` text —
2812/// preserved); then programmatic `query_params` entries whose key is absent
2813/// from the authored pairs, in declaration order with minimal RFC-3986
2814/// encoding (`%20`, never `+`). Authored keys always win — no duplication,
2815/// no override.
2816///
2817/// Returns `Ok(None)` when no query component is emitted: no pairs at all,
2818/// or a non-empty raw query whose every pair was consumed. A bare `?`
2819/// marker (`raw_query == Some("")`) always emits the query component.
2820fn resolve_endpoint_query(config: &HttpEndpointConfig) -> Result<Option<String>, CamelError> {
2821    let mut parts: Vec<String> = Vec::new();
2822    let mut authored_keys = std::collections::HashSet::new();
2823
2824    if let Some(raw) = config.raw_query.as_deref() {
2825        for (key, span) in raw_query_pairs(raw)? {
2826            authored_keys.insert(key.clone());
2827            if is_consumed_option(&key) {
2828                continue;
2829            }
2830            validate_raw_query_span(span)?;
2831            parts.push(span.to_string());
2832        }
2833    }
2834
2835    for (key, value) in &config.query_params {
2836        if !authored_keys.contains(key.as_str()) {
2837            parts.push(format!(
2838                "{}={}",
2839                encode_query_component(key),
2840                encode_query_component(value)
2841            ));
2842        }
2843    }
2844
2845    if parts.is_empty() && config.raw_query.as_deref() != Some("") {
2846        return Ok(None);
2847    }
2848    Ok(Some(parts.join("&")))
2849}
2850
2851/// Compose the outbound query when a `CamelHttpQuery` exchange header is
2852/// present (ADR-0071). `higher_precedence` — the endpoint query in the
2853/// base arm, the override URI's own query in the override arm — comes
2854/// first and wins any key collision; header pairs append verbatim for
2855/// absent keys only. An empty header leaves the higher-precedence query
2856/// unchanged (no additional `?` marker). Header spans are validated, not
2857/// re-encoded: a byte forbidden in a query component is a resolve error
2858/// naming the byte (Wave-A law).
2859fn merge_header_query(
2860    higher_precedence: Option<&str>,
2861    header_query: &str,
2862) -> Result<Option<String>, CamelError> {
2863    if header_query.is_empty() {
2864        return Ok(higher_precedence.map(str::to_string));
2865    }
2866    let mut parts: Vec<String> = Vec::new();
2867    let mut higher_keys = std::collections::HashSet::new();
2868    for (key, span) in raw_query_pairs(higher_precedence.unwrap_or(""))? {
2869        higher_keys.insert(key);
2870        parts.push(span.to_string());
2871    }
2872    for (key, span) in raw_query_pairs(header_query)? {
2873        validate_raw_query_span(span)?;
2874        if !higher_keys.contains(key.as_str()) {
2875            parts.push(span.to_string());
2876        }
2877    }
2878    if parts.is_empty() {
2879        return Ok(None);
2880    }
2881    Ok(Some(parts.join("&")))
2882}
2883
2884/// Bytes that may appear unescaped in a URI query component. RFC 3986
2885/// (`query = *( pchar / "/" / "?" )`) admits unreserved, sub-delims, `:`,
2886/// `@`, `/`, `?`, and `%` — with ONE deliberate exclusion from the RFC set:
2887/// the apostrophe (`'`, 0x27). reqwest's WHATWG URL parser re-encodes 0x27
2888/// to `%27` in the special-query percent-encode set (http/https), so an
2889/// authored apostrophe can never ride the wire verbatim; admitting it would
2890/// silently normalize authored bytes (rc-nmupb). Authors write `%27`
2891/// explicitly when they mean the byte on the wire. The WHATWG set's other
2892/// extras (`"`, `` ` ``, `<`, `>`) are already rejected here — they are not
2893/// RFC 3986 query-legal bytes, so no special exclusion is needed for them.
2894fn is_legal_query_byte(byte: u8) -> bool {
2895    matches!(byte,
2896        b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z'
2897        | b'-' | b'.' | b'_' | b'~'
2898        | b'!' | b'$' | b'&' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'='
2899        | b':' | b'@' | b'/' | b'?'
2900        | b'%')
2901}
2902
2903/// Reject an authored raw pair carrying a byte that is not legal in a query
2904/// component (e.g. literal space, `#`, non-ASCII). The serializer never
2905/// silently re-encodes operator-authored bytes: "byte-for-byte" is bounded
2906/// to wire-legal bytes, and the check fires before the resolved string
2907/// reaches any consumer (SSRF pre-check, diagnostics redaction).
2908fn validate_raw_query_span(span: &str) -> Result<(), CamelError> {
2909    for &byte in span.as_bytes() {
2910        if !is_legal_query_byte(byte) {
2911            return Err(CamelError::ProcessorError(format!(
2912                "raw query pair '{span}' contains byte 0x{byte:02X}, which is not legal in a URL query component"
2913            )));
2914        }
2915    }
2916    Ok(())
2917}
2918
2919/// Minimal RFC-3986 percent-encoding for one programmatic query component:
2920/// unreserved bytes pass through, every other byte encodes as uppercase
2921/// hex. A space encodes as `%20`, never `+`.
2922fn encode_query_component(component: &str) -> String {
2923    const HEX: &[u8; 16] = b"0123456789ABCDEF";
2924    let mut out = String::with_capacity(component.len());
2925    for &byte in component.as_bytes() {
2926        match byte {
2927            b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z' | b'-' | b'.' | b'_' | b'~' => {
2928                out.push(byte as char);
2929            }
2930            _ => {
2931                out.push('%');
2932                out.push(HEX[(byte >> 4) as usize] as char);
2933                out.push(HEX[(byte & 0x0f) as usize] as char);
2934            }
2935        }
2936    }
2937    out
2938}
2939
2940/// Redact credentials from a URL before it reaches logs or error values
2941/// (ADR-0051 redact-by-construction). Masks userinfo (`user:pass@`) and
2942/// the query string (which commonly carries API keys/tokens). Host and
2943/// path stay visible for diagnosability. Fragments are never echoed: a
2944/// fragment (OAuth2 callback tokens such as `#access_token=...`) is
2945/// dropped and replaced with the `#[redacted]` sentinel in both the
2946/// parsed arm and the unparseable arm. Fail-closed: when the parse fails
2947/// and any authority window contains `@`, only the `[redacted]`
2948/// sentinel is returned. Every authority window is scanned: windows are
2949/// enumerated over maximal runs of `/` and `\` — pure-slash runs of two
2950/// or more characters, backslash-bearing runs only behind an RFC 3986
2951/// scheme prefix (see [`camel_api::redact`] for the canonical window
2952/// rule) — each window starts immediately after the run (so evaders like
2953/// `scheme:////user:pass@evil/` cannot hide a `@` behind a slash run)
2954/// and ends at the next `/`, `?`, or `#`; scanning all windows keeps
2955/// later `//user:pass@` substrings from hiding behind a benign first
2956/// window.
2957///
2958/// The parsed arm keeps `url::Url::parse` (the authority can only be
2959/// judged by the parser) and masks the real authority accessors, then
2960/// delegates wholesale to the canonical string surgery in
2961/// [`camel_api::redact::redact_url`]: rust-url can park later-window
2962/// userinfo bytes in the path (`https://h//user:pass@evil/`), and the
2963/// canonical helper owns window masking, `?`/`#` sentinel composition
2964/// (one per distinct introducer, first-occurrence order), and the
2965/// 256-byte UTF-8 cap. The unparseable arm delegates to
2966/// [`camel_api::redact::redact_url_fail_closed`].
2967pub(crate) fn redact_url_for_diagnostics(raw: &str) -> String {
2968    match url::Url::parse(raw) {
2969        Ok(mut u) => {
2970            // Fail closed when an authority marker was accepted but no
2971            // host was stored: userinfo-shaped bytes can hide in the path
2972            // behind the marker, and empty-host schemes (`file:///us@r/x`,
2973            // `unix:///@socket`) can put a `@` in that window too. Such
2974            // inputs are sentineled wholesale — deliberate fail-closed
2975            // over-redaction per ADR-0051.
2976            if !u.cannot_be_a_base()
2977                && u.host_str().is_none()
2978                && camel_api::redact::window_has_at_sign(raw)
2979            {
2980                return "[redacted]".to_string();
2981            }
2982            if !u.username().is_empty() || u.password().is_some() {
2983                let _ = u.set_username("***");
2984                let _ = u.set_password(None);
2985            }
2986            // Query and fragment stay on the rendered URL; the canonical
2987            // redactor drops them and composes the sentinels.
2988            let s = u.to_string();
2989            camel_api::redact::redact_url(&s)
2990        }
2991        Err(_) => camel_api::redact::redact_url_fail_closed(raw),
2992    }
2993}
2994
2995/// Maximum bytes of an upstream error response body embedded into
2996/// `CamelError::HttpOperationFailed`. The body is attacker-controllable (a
2997/// malicious or compromised upstream), so it is truncated and lossy-decoded to
2998/// bound log injection / DLQ payload size.
2999const MAX_ERROR_RESPONSE_BODY_BYTES: usize = 4096;
3000
3001fn truncate_error_body(body: &[u8]) -> String {
3002    if body.len() <= MAX_ERROR_RESPONSE_BODY_BYTES {
3003        String::from_utf8_lossy(body).into_owned()
3004    } else {
3005        let mut s = String::from_utf8_lossy(&body[..MAX_ERROR_RESPONSE_BODY_BYTES]).into_owned();
3006        s.push_str("...[truncated]");
3007        s
3008    }
3009}
3010
3011impl HttpProducer {
3012    /// Whether the HTTP method is entity-enclosing (may carry a request
3013    /// body). Follows Apache Camel's `HttpMethods` set: POST, PUT, PATCH are
3014    /// entity-enclosing; GET, HEAD, DELETE, OPTIONS, TRACE are not (RFC 9110
3015    /// §9.3.1/§9.3.2).
3016    fn is_entity_enclosing(method: &str) -> bool {
3017        matches!(method, "POST" | "PUT" | "PATCH")
3018    }
3019}
3020
3021impl Service<Exchange> for HttpProducer {
3022    type Response = Exchange;
3023    type Error = CamelError;
3024    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
3025
3026    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
3027        Poll::Ready(Ok(()))
3028    }
3029
3030    fn call(&mut self, exchange: Exchange) -> Self::Future {
3031        let config = self.config.clone();
3032        let shared_client = self.client.clone();
3033        let pinned_cache = std::sync::Arc::clone(&self.pinned_cache);
3034        let http_config = self.http_config.clone();
3035        let component_metrics = self.runtime.component_metrics();
3036
3037        Box::pin(async move {
3038            let mut exchange = exchange;
3039            let outcome = async {
3040                let method_str = HttpProducer::resolve_method(&exchange, &config);
3041                // Entity-enclosing gate (RFC 9110 §9.3.1/§9.3.2): only POST, PUT
3042                // and PATCH may carry a request body. Any other resolved method
3043                // drops the exchange body before the request is built (Apache
3044                // Camel `HttpMethods` parity).
3045                let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
3046                let url = HttpProducer::resolve_url(&exchange, &config)?;
3047
3048                // SECURITY: Validate URL for SSRF
3049                ssrf::validate_url_for_ssrf(&url, &config)?;
3050
3051                // Resolve hostname and pin validated IPs to prevent DNS-rebinding TOCTOU
3052                // (L-H2). When the URL uses a domain name and SSRF protection is active,
3053                // reuse the endpoint's cached DNS-pinned client for that validated
3054                // (host, addrs) pair — built once with resolve_to_addrs, then shared so
3055                // repeated requests keep one connection pool without re-resolving DNS.
3056                // Per-request SSRF validation and DNS pinning are unchanged. IP-literal
3057                // URLs use the endpoint's unpinned shared client.
3058                let resolved = ssrf::resolve_initial_url_for_ssrf(
3059                    &url,
3060                    config.allow_internal,
3061                    config.allow_cleartext,
3062                )
3063                .await?;
3064                let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
3065                    // A failed pinned build (strict fail-closed material
3066                    // conflict) propagates into the request error path —
3067                    // no degraded client is built or served.
3068                    pinned_cache
3069                        .get_or_build(host.as_str(), addrs, || {
3070                            build_client(&http_config, Some((host.as_str(), addrs)))
3071                        })
3072                        .await?
3073                } else {
3074                    shared_client.clone()
3075                };
3076
3077                debug!(
3078                    correlation_id = %exchange.correlation_id(),
3079                    method = %method_str,
3080                    url = %redact_url_for_diagnostics(&url),
3081                    "HTTP request"
3082                );
3083
3084                let method = method_str.parse::<reqwest::Method>().map_err(|e| {
3085                    CamelError::ProcessorError(format!(
3086                        "Invalid HTTP method '{}': {}",
3087                        method_str, e
3088                    ))
3089                })?;
3090
3091                // Collect headers for potential redirect replay
3092                let mut collected_headers: Vec<(
3093                    reqwest::header::HeaderName,
3094                    reqwest::header::HeaderValue,
3095                )> = Vec::new();
3096
3097                if let Some(user_agent) = &config.user_agent
3098                    && !config.bridge_endpoint
3099                {
3100                    match constructed_header("user-agent", user_agent) {
3101                        Ok((_, val)) => {
3102                            collected_headers.push((reqwest::header::USER_AGENT, val));
3103                        }
3104                        Err(drop) => debug!(
3105                            correlation_id = %exchange.correlation_id(),
3106                            header = %drop.name,
3107                            "outbound header dropped: {}",
3108                            drop.reason
3109                        ),
3110                    }
3111                }
3112
3113                // Inject W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
3114                #[cfg(feature = "otel")]
3115                let should_inject_otel = !config.bridge_endpoint;
3116                #[cfg(feature = "otel")]
3117                if should_inject_otel {
3118                    let mut otel_headers = HashMap::new();
3119                    camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
3120                    for (k, v) in otel_headers {
3121                        match constructed_header(&k, &v) {
3122                            Ok((name, val)) => collected_headers.push((name, val)),
3123                            Err(drop) => debug!(
3124                                correlation_id = %exchange.correlation_id(),
3125                                header = %drop.name,
3126                                "outbound header dropped: {}",
3127                                drop.reason
3128                            ),
3129                        }
3130                    }
3131                }
3132
3133                let conn_tokens = header_policy::connection_tokens(
3134                    exchange
3135                        .input
3136                        .headers
3137                        .iter()
3138                        .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3139                        .filter_map(|(_, v)| v.as_str()),
3140                );
3141
3142                let outbound = select_outbound_headers(
3143                    &exchange.input.headers,
3144                    &config.skip_request_headers,
3145                    &conn_tokens,
3146                );
3147                for drop in &outbound.drops {
3148                    if let Some(value_kind) = drop.value_kind {
3149                        debug!(
3150                            correlation_id = %exchange.correlation_id(),
3151                            header = %drop.name,
3152                            value_kind = value_kind,
3153                            "outbound header dropped: {}",
3154                            drop.reason
3155                        );
3156                    } else {
3157                        debug!(
3158                            correlation_id = %exchange.correlation_id(),
3159                            header = %drop.name,
3160                            "outbound header dropped: {}",
3161                            drop.reason
3162                        );
3163                    }
3164                }
3165                collected_headers.extend(outbound.accepted);
3166
3167                // Auth headers
3168                if !config.bridge_endpoint {
3169                    match &config.auth {
3170                        HttpAuth::None => {}
3171                        HttpAuth::Basic { username, password } => {
3172                            use base64::Engine;
3173                            // allow-secret: credentials combined for base64 Basic auth header
3174                            let credentials = format!("{username}:{password}");
3175                            let encoded =
3176                                base64::engine::general_purpose::STANDARD.encode(credentials);
3177                            // Base64 output is always header-safe; the guard is kept
3178                            // for uniformity with Bearer.
3179                            match constructed_header("authorization", &format!("Basic {encoded}")) {
3180                                Ok((_, val)) => {
3181                                    collected_headers.push((reqwest::header::AUTHORIZATION, val));
3182                                }
3183                                Err(drop) => debug!(
3184                                    correlation_id = %exchange.correlation_id(),
3185                                    header = %drop.name,
3186                                    "outbound header dropped: {}",
3187                                    drop.reason
3188                                ),
3189                            }
3190                        }
3191                        HttpAuth::Bearer { token } => {
3192                            // allow-secret: Bearer token in Authorization header
3193                            let bearer = format!("Bearer {token}");
3194                            match constructed_header("authorization", &bearer) {
3195                                Ok((_, val)) => {
3196                                    collected_headers.push((reqwest::header::AUTHORIZATION, val));
3197                                }
3198                                Err(drop) => debug!(
3199                                    correlation_id = %exchange.correlation_id(),
3200                                    header = %drop.name,
3201                                    "outbound header dropped: {}",
3202                                    drop.reason
3203                                ),
3204                            }
3205                        }
3206                    }
3207
3208                    if config.connection_close {
3209                        collected_headers.push((
3210                            reqwest::header::CONNECTION,
3211                            reqwest::header::HeaderValue::from_static("close"),
3212                        ));
3213                    }
3214                }
3215
3216                // Materialize body
3217                let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
3218                let materialized_body: Option<Vec<u8>> = if is_stream_body {
3219                    if suppress_body {
3220                        // A stream body dropped under a non-entity-enclosing
3221                        // method always warns (its emptiness is unknowable) and
3222                        // stays consumed (mem::take). The stream attach arm below
3223                        // still runs its outer flag check, but the inner `if let
3224                        // Body::Stream` re-match fails on the now-Empty body, so
3225                        // no stream is attached and no AlreadyConsumed error can
3226                        // fire.
3227                        std::mem::take(&mut exchange.input.body);
3228                        // log-policy: handler-owned
3229                        tracing::warn!(
3230                            correlation_id = %exchange.correlation_id(),
3231                            method = %method_str,
3232                            "dropping request body for non-entity-enclosing HTTP method"
3233                        );
3234                    }
3235                    None // Streams can't be replayed on redirect
3236                } else {
3237                    let body = std::mem::take(&mut exchange.input.body);
3238                    let bytes = body.into_bytes(config.max_body_size).await?;
3239                    if bytes.is_empty() {
3240                        // Empty body: nothing to send and nothing to warn about.
3241                        None
3242                    } else if suppress_body {
3243                        // log-policy: handler-owned
3244                        tracing::warn!(
3245                            correlation_id = %exchange.correlation_id(),
3246                            method = %method_str,
3247                            "dropping request body for non-entity-enclosing HTTP method"
3248                        );
3249                        None
3250                    } else {
3251                        Some(bytes.to_vec())
3252                    }
3253                };
3254
3255                let response = if config.follow_redirects && !is_stream_body {
3256                    // Use manual redirect loop with per-hop SSRF validation.
3257                    // `client` is the pinned-or-shared binding for the initial
3258                    // request (a hostname initial request keeps its DNS-pinned
3259                    // client); `shared_client` is the unpinned endpoint client
3260                    // reused by IP-literal redirect hops.
3261                    ssrf::send_with_ssrf_safe_redirects(
3262                        &client,
3263                        &shared_client,
3264                        &pinned_cache,
3265                        &http_config,
3266                        &config,
3267                        method,
3268                        &url,
3269                        collected_headers,
3270                        materialized_body,
3271                        config.max_redirects,
3272                        config.response_timeout,
3273                    )
3274                    .await?
3275                } else {
3276                    // Direct send (no redirect following, or streaming body)
3277                    let mut request = client.request(method, &url);
3278
3279                    if let Some(timeout) = config.response_timeout {
3280                        request = request.timeout(timeout);
3281                    }
3282
3283                    for (name, value) in &collected_headers {
3284                        request = request.header(name, value);
3285                    }
3286
3287                    if is_stream_body {
3288                        if let Body::Stream(ref s) = exchange.input.body {
3289                            let mut stream_lock = s.stream.lock().await;
3290                            if let Some(stream) = stream_lock.take() {
3291                                request = request.body(reqwest::Body::wrap_stream(stream));
3292                            } else {
3293                                return Err(CamelError::AlreadyConsumed);
3294                            }
3295                        }
3296                    } else if let Some(ref body_bytes) = materialized_body {
3297                        request = request.body(body_bytes.clone());
3298                    }
3299
3300                    request.send().await.map_err(|e| {
3301                        CamelError::ProcessorError(format!("HTTP request failed: {e}"))
3302                    })?
3303                };
3304
3305                let status_code = response.status().as_u16();
3306                let status_text = response
3307                    .status()
3308                    .canonical_reason()
3309                    .unwrap_or("Unknown")
3310                    .to_string();
3311
3312                for (key, value) in response.headers() {
3313                    if config
3314                        .skip_response_headers
3315                        .iter()
3316                        .any(|h| h.eq_ignore_ascii_case(key.as_str()))
3317                    {
3318                        continue;
3319                    }
3320                    if let Ok(val_str) = value.to_str() {
3321                        exchange.input.set_header(
3322                            title_case_header(key.as_str()),
3323                            serde_json::Value::String(val_str.to_string()),
3324                        );
3325                    }
3326                }
3327
3328                exchange.input.set_header(
3329                    "CamelHttpResponseCode",
3330                    serde_json::Value::Number(status_code.into()),
3331                );
3332                exchange.input.set_header(
3333                    "CamelHttpResponseText",
3334                    serde_json::Value::String(status_text.clone()),
3335                );
3336
3337                // Read response body with timeout and size guard (HTTP-004, HTTP-005)
3338                let read_timeout = Duration::from_millis(config.read_timeout_ms);
3339                let response_body = tokio::time::timeout(read_timeout, async {
3340                    // Check Content-Length header before allocating
3341                    if let Some(content_len) = response.content_length()
3342                        && content_len > config.max_response_bytes as u64
3343                    {
3344                        return Err(CamelError::ProcessorError(format!(
3345                            "Response body too large: {} bytes exceeds limit of {} bytes",
3346                            content_len, config.max_response_bytes
3347                        )));
3348                    }
3349                    // Use bytes_stream() for lazy streaming with size guard
3350                    use futures::TryStreamExt;
3351                    let mut stream = response.bytes_stream();
3352                    let mut total: usize = 0;
3353                    let mut collected = Vec::new();
3354                    while let Some(chunk) = stream.try_next().await.map_err(|e| {
3355                        CamelError::ProcessorError(format!("Failed to read response body: {e}"))
3356                    })? {
3357                        total += chunk.len();
3358                        if total > config.max_response_bytes {
3359                            return Err(CamelError::ProcessorError(format!(
3360                                "Response body too large: {} bytes exceeds limit of {} bytes",
3361                                total, config.max_response_bytes
3362                            )));
3363                        }
3364                        collected.push(chunk);
3365                    }
3366                    let mut result = bytes::BytesMut::with_capacity(total);
3367                    for chunk in collected {
3368                        result.extend_from_slice(&chunk);
3369                    }
3370                    Ok::<bytes::Bytes, CamelError>(result.freeze())
3371                })
3372                .await
3373                .map_err(|_| {
3374                    CamelError::ProcessorError(format!(
3375                        "Read timeout after {}ms",
3376                        config.read_timeout_ms
3377                    ))
3378                })??;
3379
3380                if config.throw_exception_on_failure
3381                    && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
3382                {
3383                    return Err(CamelError::HttpOperationFailed {
3384                        method: method_str,
3385                        // ADR-0051 redact-by-construction: never embed
3386                        // userinfo/query credentials in the error value.
3387                        url: redact_url_for_diagnostics(&url),
3388                        status_code,
3389                        status_text,
3390                        response_body: Some(truncate_error_body(&response_body)),
3391                    });
3392                }
3393
3394                if !response_body.is_empty() {
3395                    exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
3396                }
3397
3398                debug!(
3399                    correlation_id = %exchange.correlation_id(),
3400                    status = status_code,
3401                    url = %redact_url_for_diagnostics(&url),
3402                    "HTTP response"
3403                );
3404                Ok(exchange)
3405            }
3406            .await;
3407            // ("http","request") facade (dashboard-observability 4.3): the
3408            // request boundary is the full client round-trip — SSRF checks,
3409            // send, response read, and (with throwExceptionOnFailure) the
3410            // status gate. http runs no retry_async and the producer
3411            // previously emitted nothing, so no label collides with
3412            // e:http:request.
3413            component_metrics.observe("http", "request", outcome.is_err());
3414            outcome
3415        })
3416    }
3417}
3418
3419/// Serializes tests that mutate or depend on the global `ServerRegistry`.
3420///
3421/// `ServerRegistry::global()` is a process-wide singleton that persists
3422/// across tests. `ServerRegistry::reset()` clears ALL entries; if it races
3423/// with another test that has a live server on a fixed port (e.g. 9991),
3424/// the registry entry is removed while the OS socket is still bound, so
3425/// the next `get_or_spawn` call on that port fails with "Address already
3426/// in use". This mutex does not give blanket protection by itself. It
3427/// helps only where every participant follows the mutex law: the
3428/// consumer-test readiness helper holds it from `stage_listener` until
3429/// readiness-complete (http-test-harness spec, requirement
3430/// "Registry-mutation serialization during setup"), and each `reset()`
3431/// caller takes it before the reset.
3432#[cfg(test)]
3433pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
3434
3435/// Poison-recovering acquire of REGISTRY_TEST_MUTEX (httpflake).
3436///
3437/// The mutex guards test SERIALIZATION only - the registry own data is
3438/// protected by its inner lock - so a sibling test that panics while
3439/// holding the guard must not poison the mutex and cascade failures
3440/// into every other holder. Recovery via into_inner is therefore safe
3441/// and keeps one failing test failing as ONE test.
3442#[cfg(test)]
3443pub(crate) fn lock_registry_test_mutex() -> std::sync::MutexGuard<'static, ()> {
3444    REGISTRY_TEST_MUTEX
3445        .lock()
3446        .unwrap_or_else(|poisoned| poisoned.into_inner())
3447}
3448
3449/// Serializes tests that mutate (or assert on) the process-global
3450/// SSL_CERT_FILE/SSL_CERT_DIR CA-probe env vars (rc-3j4mq). Poison-
3451/// recovering for the same reason as REGISTRY_TEST_MUTEX.
3452#[cfg(test)]
3453static CA_STORE_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
3454
3455#[cfg(test)]
3456pub(crate) fn lock_ca_store_test_mutex() -> std::sync::MutexGuard<'static, ()> {
3457    CA_STORE_TEST_MUTEX
3458        .lock()
3459        .unwrap_or_else(|poisoned| poisoned.into_inner())
3460}
3461
3462/// Map a pipeline error to an HTTP reply.
3463///
3464/// Extracted from the inline `match` in `dispatch_handler` for unit
3465/// testability (rc-1dk4). Client-fault errors map to their 4xx codes
3466/// with a structured JSON error body: `TypeConversionFailed`/
3467/// `ValidationError` → 400, `UnsupportedMediaType` → 415 (media
3468/// negotiation gate, REST lowering), `NotAcceptable` → 406 (same
3469/// gate); `Unauthenticated`/`Unauthorized` keep their `401`/`403`
3470/// mappings; all other errors map to `500 Internal Server Error`.
3471fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
3472    match e {
3473        CamelError::Unauthenticated(msg) => {
3474            tracing::warn!(error = %msg, path = %path, "Authentication failed");
3475            HttpReply {
3476                status: 401,
3477                headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
3478                body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
3479            }
3480        }
3481        CamelError::Unauthorized(msg) => {
3482            tracing::warn!(error = %msg, path = %path, "Authorization failed");
3483            HttpReply {
3484                status: 403,
3485                headers: vec![],
3486                body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
3487            }
3488        }
3489        CamelError::TypeConversionFailed(msg) => {
3490            tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
3491            json_error_reply(400, "bad_request", msg)
3492        }
3493        CamelError::ValidationError(msg) => {
3494            tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
3495            json_error_reply(400, "validation_error", msg)
3496        }
3497        CamelError::ConsumerStopping => {
3498            tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
3499            HttpReply {
3500                status: 503,
3501                headers: vec![],
3502                body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
3503            }
3504        }
3505        CamelError::UnsupportedMediaType { consumed, declared } => {
3506            tracing::warn!(error = %consumed, declared = %declared, path = %path, "Unsupported media type (bad request)");
3507            json_error_reply(
3508                415,
3509                "unsupported_media_type",
3510                format!("consumed {consumed}, declared {declared}"),
3511            )
3512        }
3513        CamelError::NotAcceptable { accept, produced } => {
3514            tracing::warn!(error = %accept, produced = %produced, path = %path, "Not acceptable (bad request)");
3515            json_error_reply(
3516                406,
3517                "not_acceptable",
3518                format!("accept {accept}, produced {produced}"),
3519            )
3520        }
3521        e => {
3522            // log-policy: handler-owned
3523            tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
3524            HttpReply {
3525                status: 500,
3526                headers: vec![],
3527                body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
3528            }
3529        }
3530    }
3531}
3532
3533/// Build a JSON error reply with the given status, error code, and message.
3534///
3535/// Shared by the `TypeConversionFailed`/`ValidationError` (400),
3536/// `UnsupportedMediaType` (415), and `NotAcceptable` (406) arms of
3537/// `pipeline_error_to_reply` so the four replies cannot drift apart. The
3538/// `unwrap_or_else(|_| "{}".to_string())` fallback keeps the reply valid
3539/// JSON even if serialization fails.
3540fn json_error_reply(status: u16, code: &str, message: String) -> HttpReply {
3541    let body = serde_json::to_string(&serde_json::json!({
3542        "error": code,
3543        "message": message,
3544    }))
3545    .unwrap_or_else(|_| "{}".to_string()); // allow-unwrap
3546    HttpReply {
3547        status,
3548        headers: vec![("Content-Type".to_string(), "application/json".to_string())],
3549        body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
3550    }
3551}
3552
3553/// Lowercase kind name for a JSON value, used in drop diagnostics so log
3554/// readers see *why* a header had no scalar string form without the value
3555/// itself ever entering diagnostics.
3556const fn json_value_kind(v: &serde_json::Value) -> &'static str {
3557    match v {
3558        serde_json::Value::Null => "null",
3559        serde_json::Value::Bool(_) => "bool",
3560        serde_json::Value::Number(_) => "number",
3561        serde_json::Value::String(_) => "string",
3562        serde_json::Value::Array(_) => "array",
3563        serde_json::Value::Object(_) => "object",
3564    }
3565}
3566
3567/// Scalar string form of a JSON value: strings pass through, `Number` and
3568/// `Bool` are stringified, everything else has no single-value form.
3569/// Shared by the consumer reply finaliser and the producer outbound filter
3570/// so the two directions cannot drift apart (rc-lidtk / rc-8l23a).
3571fn scalar_string_form(v: &serde_json::Value) -> Option<String> {
3572    match v {
3573        serde_json::Value::String(s) => Some(s.clone()),
3574        serde_json::Value::Number(n) => Some(n.to_string()),
3575        serde_json::Value::Bool(b) => Some(b.to_string()),
3576        _ => None,
3577    }
3578}
3579
3580/// Select the HTTP response headers emitted by the consumer reply finaliser
3581/// (ADR-0057 / rc-2jj2). Extracted from the inline filter in
3582/// `dispatch_handler` for unit testability.
3583///
3584/// Drops Camel-namespace headers, hop-by-hop/framing, request-only, and
3585/// server-owned headers, plus `content-length`/`content-type` (re-derived),
3586/// and any header named by a `Connection` token. Scalar non-string values
3587/// (`Number`/`Bool`) are stringified so `set_header("X-Retries", 3)` reaches
3588/// the wire instead of being silently discarded (rc-lidtk); `null`, objects,
3589/// and arrays have no single-value form and are dropped. Every drop is
3590/// logged at DEBUG with the header name and reason — names only, never
3591/// values, so credentials cannot leak into diagnostics (ADR-0051).
3592/// Appends a single `Content-Type` from `user_content_type` falling back to
3593/// `inferred_content_type` when either is present.
3594fn select_response_headers(
3595    headers: &HashMap<String, serde_json::Value>,
3596    user_content_type: Option<String>,
3597    inferred_content_type: Option<String>,
3598) -> Vec<(String, String)> {
3599    let conn_tokens = header_policy::connection_tokens(
3600        headers
3601            .iter()
3602            .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3603            .filter_map(|(_, v)| v.as_str()),
3604    );
3605    let mut selected: Vec<(String, String)> = Vec::new();
3606    for (k, v) in headers {
3607        if k.starts_with("Camel") {
3608            debug!(header = %k, "reply header dropped: Camel namespace");
3609            continue;
3610        }
3611        if header_policy::excluded_response(k, &conn_tokens) {
3612            debug!(header = %k, "reply header dropped: emission policy");
3613            continue;
3614        }
3615        match scalar_string_form(v) {
3616            Some(s) => selected.push((k.clone(), s)),
3617            None => debug!(
3618                header = %k,
3619                value_kind = json_value_kind(v),
3620                "reply header dropped: no scalar string form"
3621            ),
3622        }
3623    }
3624    if let Some(ct) = user_content_type.or(inferred_content_type) {
3625        selected.push(("Content-Type".to_string(), ct));
3626    }
3627    selected
3628}
3629
3630/// One outbound header drop: the exchange header name, a stable reason
3631/// string, and — when the drop was caused by the value having no scalar
3632/// string form — the JSON value kind. Names and kinds only, never values
3633/// (ADR-0051).
3634#[derive(Debug)]
3635struct OutboundHeaderDrop<'a> {
3636    name: &'a str,
3637    reason: &'static str,
3638    value_kind: Option<&'static str>,
3639}
3640
3641/// Outbound exchange-header selection result: headers accepted for the
3642/// wire plus drop records for call-site DEBUG logging.
3643struct OutboundHeaderSelection<'a> {
3644    accepted: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>,
3645    drops: Vec<OutboundHeaderDrop<'a>>,
3646}
3647
3648/// Select the exchange headers the HTTP producer forwards on the outbound
3649/// request (ADR-0057 / rc-8l23a). Extracted from the inline filter in
3650/// `HttpProducer::call` for unit testability.
3651///
3652/// Drops `Camel`-namespace headers, names listed in `skip_request_headers`,
3653/// hop-by-hop/framing and connection-token-named headers excluded by the
3654/// outbound emission policy, and headers whose name or stringified value
3655/// fails `HeaderName`/`HeaderValue` construction. Scalar non-string values
3656/// (`Number`/`Bool`) are stringified so `set_header("X-Retries", 3)` reaches
3657/// the wire instead of being silently discarded (rc-8l23a); `null`, objects,
3658/// and arrays have no single-value form and are dropped. Drops are returned
3659/// rather than logged so the call site can attach the correlation id; log
3660/// consumers see names and kinds only, never values (ADR-0051).
3661fn select_outbound_headers<'a>(
3662    headers: &'a HashMap<String, serde_json::Value>,
3663    skip_request_headers: &[String],
3664    conn_tokens: &[String],
3665) -> OutboundHeaderSelection<'a> {
3666    let mut accepted = Vec::new();
3667    let mut drops = Vec::new();
3668    for (key, value) in headers {
3669        if key.starts_with("Camel") {
3670            drops.push(OutboundHeaderDrop {
3671                name: key,
3672                reason: "Camel namespace",
3673                value_kind: None,
3674            });
3675            continue;
3676        }
3677        if skip_request_headers
3678            .iter()
3679            .any(|h| h.eq_ignore_ascii_case(key))
3680        {
3681            drops.push(OutboundHeaderDrop {
3682                name: key,
3683                reason: "skip_request_headers",
3684                value_kind: None,
3685            });
3686            continue;
3687        }
3688        if header_policy::excluded_outbound(key, conn_tokens) {
3689            drops.push(OutboundHeaderDrop {
3690                name: key,
3691                reason: "outbound emission policy",
3692                value_kind: None,
3693            });
3694            continue;
3695        }
3696        let Some(val_str) = scalar_string_form(value) else {
3697            drops.push(OutboundHeaderDrop {
3698                name: key,
3699                reason: "no scalar string form",
3700                value_kind: Some(json_value_kind(value)),
3701            });
3702            continue;
3703        };
3704        match constructed_header(key, &val_str) {
3705            Ok((name, val)) => accepted.push((name, val)),
3706            Err(drop) => drops.push(drop),
3707        }
3708    }
3709    OutboundHeaderSelection { accepted, drops }
3710}
3711
3712/// Construct a wire-ready `(HeaderName, HeaderValue)` pair for one outbound
3713/// header, or a drop record when the name or value fails construction
3714/// (rc-jbs1v). Drop records carry name and reason only, never values
3715/// (ADR-0051).
3716fn constructed_header<'a>(
3717    name: &'a str,
3718    value: &str,
3719) -> Result<(reqwest::header::HeaderName, reqwest::header::HeaderValue), OutboundHeaderDrop<'a>> {
3720    let header_name = match reqwest::header::HeaderName::from_bytes(name.as_bytes()) {
3721        Ok(header_name) => header_name,
3722        Err(_) => {
3723            return Err(OutboundHeaderDrop {
3724                name,
3725                reason: "invalid header name",
3726                value_kind: None,
3727            });
3728        }
3729    };
3730    let header_value = match reqwest::header::HeaderValue::from_str(value) {
3731        Ok(header_value) => header_value,
3732        Err(_) => {
3733            return Err(OutboundHeaderDrop {
3734                name,
3735                reason: "invalid header value",
3736                value_kind: None,
3737            });
3738        }
3739    };
3740    Ok((header_name, header_value))
3741}
3742
3743#[cfg(test)]
3744#[path = "lib_tests.rs"]
3745mod tests;