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;
12#[cfg(test)]
13mod tls_harness;
14pub(crate) mod tls_reload;
15use crate::config::parse_ok_status_code_range;
16pub use bundle::HttpBundle;
17pub use bundle::HttpStaticBundle;
18pub(crate) use client_cache::{
19 HttpComponentKind, PINNED_CLIENT_MAX_ENTRIES, PINNED_CLIENT_TTL, PinnedClientCache,
20};
21pub use config::HttpConfig;
22pub use health::HttpHealthCheck;
23pub use registry::HttpRouteRegistry;
24pub use static_config::HttpStaticConfig;
25pub use static_endpoint::{HttpStaticComponent, HttpStaticConsumer, HttpStaticEndpoint};
26
27use std::collections::HashMap;
28use std::future::Future;
29use std::pin::Pin;
30
31use std::sync::{Arc, Mutex, OnceLock};
32use std::task::{Context, Poll};
33use std::time::Duration;
34
35use tokio::sync::OnceCell;
36use tower::Layer;
37use tower::Service;
38use tracing::debug;
39
40use axum::body::BodyDataStream;
41use camel_api::component_metadata::ComponentMetadata;
42use camel_auth::bearer_token_layer::BearerTokenLayer;
43use camel_auth::oauth2::TokenProvider;
44use camel_component_api::tls_source::ServerTlsSource;
45use camel_component_api::{Body, BoxProcessor, CamelError, Exchange, StreamBody, StreamMetadata};
46use camel_component_api::{Component, Consumer, Endpoint, ProducerContext, RuntimeObservability};
47use camel_component_api::{UriComponents, UriConfig, parse_uri, raw_query_pairs};
48use futures::StreamExt;
49use futures::TryStreamExt;
50use futures::stream::BoxStream;
51
52#[derive(Clone)]
113pub struct HttpEndpointConfig {
114 pub base_url: String,
115 pub http_method: Option<String>,
116 pub throw_exception_on_failure: bool,
117 pub ok_status_code_range: (u16, u16),
118 pub response_timeout: Option<Duration>,
119 pub query_params: Vec<(String, String)>,
123 pub raw_query: Option<String>,
128 pub allow_internal: bool,
129 pub allow_cleartext: bool,
135 pub blocked_hosts: Vec<String>,
136 pub max_body_size: usize,
137 pub read_timeout_ms: u64,
138 pub max_response_bytes: usize,
139 pub auth: HttpAuth,
140 pub token_provider: Option<Arc<dyn TokenProvider>>,
141 pub user_agent: Option<String>,
142 pub bridge_endpoint: bool,
143 pub connection_close: bool,
144 pub skip_request_headers: Vec<String>,
145 pub skip_response_headers: Vec<String>,
146 pub follow_redirects: bool,
147 pub max_redirects: usize,
148 pub allowed_uri_hosts: Option<Vec<AllowedUriHost>>,
153}
154
155impl std::fmt::Debug for HttpEndpointConfig {
168 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169 f.debug_struct("HttpEndpointConfig")
170 .field("base_url", &camel_api::redact::redact_url(&self.base_url))
171 .field("http_method", &self.http_method)
172 .field(
173 "throw_exception_on_failure",
174 &self.throw_exception_on_failure,
175 )
176 .field("ok_status_code_range", &self.ok_status_code_range)
177 .field("response_timeout", &self.response_timeout)
178 .field(
179 "query_params",
180 &self
181 .query_params
182 .iter()
183 .map(|(key, _)| (key, "***"))
184 .collect::<Vec<_>>(),
185 )
186 .field("raw_query", &self.raw_query.as_ref().map(|_| "?[redacted]"))
187 .field("allow_internal", &self.allow_internal)
188 .field("allow_cleartext", &self.allow_cleartext)
189 .field("blocked_hosts", &self.blocked_hosts)
190 .field("max_body_size", &self.max_body_size)
191 .field("read_timeout_ms", &self.read_timeout_ms)
192 .field("max_response_bytes", &self.max_response_bytes)
193 .field("auth", &self.auth)
194 .field("token_provider", &self.token_provider)
195 .field("user_agent", &self.user_agent)
196 .field("bridge_endpoint", &self.bridge_endpoint)
197 .field("connection_close", &self.connection_close)
198 .field("skip_request_headers", &self.skip_request_headers)
199 .field("skip_response_headers", &self.skip_response_headers)
200 .field("follow_redirects", &self.follow_redirects)
201 .field("max_redirects", &self.max_redirects)
202 .field("allowed_uri_hosts", &self.allowed_uri_hosts)
203 .finish()
204 }
205}
206
207#[derive(Clone, PartialEq)]
208pub enum HttpAuth {
209 None,
210 Basic { username: String, password: String },
211 Bearer { token: String },
212}
213
214impl std::fmt::Debug for HttpAuth {
215 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216 match self {
217 HttpAuth::None => f.write_str("None"),
218 HttpAuth::Basic { username, .. } => f
219 .debug_struct("Basic")
220 .field("username", username)
221 .field("password", &"***")
222 .finish(),
223 HttpAuth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
224 }
225 }
226}
227
228fn is_consumed_option(key: &str) -> bool {
237 HttpEndpointConfig::uri_options()
238 .iter()
239 .any(|option| option.name == key || option.aliases.iter().any(|alias| alias == key))
240}
241
242impl UriConfig for HttpEndpointConfig {
243 fn scheme() -> &'static str {
245 "http"
246 }
247
248 fn from_uri(uri: &str) -> Result<Self, CamelError> {
249 let parts = parse_uri(uri)?;
250 Self::from_components(parts)
251 }
252
253 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
254 if parts.scheme != "http" && parts.scheme != "https" {
256 return Err(CamelError::InvalidUri(format!(
257 "expected scheme 'http' or 'https', got '{}'",
258 parts.scheme
259 )));
260 }
261
262 let base_url = format!("{}:{}", parts.scheme, parts.path);
265
266 let http_method = parts.params.get("httpMethod").cloned();
267
268 let throw_exception_on_failure = match parts.params.get("throwExceptionOnFailure") {
269 Some(v) => parse_bool_param_http(v).map_err(|e| {
270 CamelError::InvalidUri(format!("invalid value for throwExceptionOnFailure: {e}"))
271 })?,
272 None => true,
273 };
274
275 let ok_status_code_range = match parts.params.get("okStatusCodeRange") {
277 Some(v) => parse_ok_status_code_range(v)?,
278 None => (200, 299),
279 };
280
281 let response_timeout = match parts.params.get("responseTimeout") {
282 Some(v) => Some(v.parse::<u64>().map(Duration::from_millis).map_err(|e| {
283 CamelError::InvalidUri(format!("invalid value for responseTimeout: {e}"))
284 })?),
285 None => None,
286 };
287
288 let allow_internal = match parts.params.get("allowInternal") {
290 Some(v) => parse_bool_param_http(v).map_err(|e| {
291 CamelError::InvalidUri(format!("invalid value for allowInternal: {e}"))
292 })?,
293 None => false, };
295
296 let allow_cleartext = match parts.params.get("allowCleartext") {
299 Some(v) => parse_bool_param_http(v).map_err(|e| {
300 CamelError::InvalidUri(format!("invalid value for allowCleartext: {e}"))
301 })?,
302 None => false, };
304
305 let blocked_hosts = parts
307 .params
308 .get("blockedHosts")
309 .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
310 .unwrap_or_default();
311
312 let max_body_size = match parts.params.get("maxBodySize") {
313 Some(v) => v.parse::<usize>().map_err(|e| {
314 CamelError::InvalidUri(format!("invalid value for maxBodySize: {e}"))
315 })?,
316 None => 10 * 1024 * 1024, };
318
319 let read_timeout_ms = match parts.params.get("readTimeout") {
320 Some(v) => v.parse::<u64>().map_err(|e| {
321 CamelError::InvalidUri(format!("invalid value for readTimeout: {e}"))
322 })?,
323 None => 30_000, };
325
326 let max_response_bytes = match parts.params.get("maxResponseBytes") {
327 Some(v) => v.parse::<usize>().map_err(|e| {
328 CamelError::InvalidUri(format!("invalid value for maxResponseBytes: {e}"))
329 })?,
330 None => 10 * 1024 * 1024, };
332
333 let auth = parse_auth_from_params(&parts.params)?;
334
335 let user_agent = parts.params.get("userAgent").cloned();
336
337 if parts.params.contains_key("cookieHandling") {
338 return Err(CamelError::InvalidUri(
339 "cookieHandling is not supported".into(),
340 ));
341 }
342
343 let bridge_endpoint = match parts.params.get("bridgeEndpoint") {
344 Some(v) => parse_bool_param_http(v).map_err(|e| {
345 CamelError::InvalidUri(format!("invalid value for bridgeEndpoint: {e}"))
346 })?,
347 None => false,
348 };
349
350 let connection_close = match parts.params.get("connectionClose") {
351 Some(v) => parse_bool_param_http(v).map_err(|e| {
352 CamelError::InvalidUri(format!("invalid value for connectionClose: {e}"))
353 })?,
354 None => false,
355 };
356
357 let skip_request_headers = parts
358 .params
359 .get("skipRequestHeaders")
360 .map(|v| {
361 v.split(',')
362 .map(str::trim)
363 .filter(|s| !s.is_empty())
364 .map(|s| s.to_ascii_lowercase())
365 .collect::<Vec<_>>()
366 })
367 .unwrap_or_default();
368
369 let skip_response_headers = parts
370 .params
371 .get("skipResponseHeaders")
372 .map(|v| {
373 v.split(',')
374 .map(str::trim)
375 .filter(|s| !s.is_empty())
376 .map(|s| s.to_ascii_lowercase())
377 .collect::<Vec<_>>()
378 })
379 .unwrap_or_default();
380
381 let follow_redirects = match parts.params.get("followRedirects") {
382 Some(v) => parse_bool_param_http(v).map_err(|e| {
383 CamelError::InvalidUri(format!("invalid value for followRedirects: {e}"))
384 })?,
385 None => false,
386 };
387
388 let max_redirects = match parts.params.get("maxRedirects") {
389 Some(v) => v.parse::<usize>().map_err(|e| {
390 CamelError::InvalidUri(format!("invalid value for maxRedirects: {e}"))
391 })?,
392 None => 10,
393 };
394
395 let allowed_uri_hosts = match parts.params.get("allowedUriHosts") {
398 Some(v) => Some(parse_allowed_uri_hosts(v)?),
399 None => None,
400 };
401
402 let raw_query = parts.raw_query.clone();
407
408 Ok(Self {
409 base_url,
410 http_method,
411 throw_exception_on_failure,
412 ok_status_code_range,
413 response_timeout,
414 query_params: Vec::new(),
415 raw_query,
416 allow_internal,
417 allow_cleartext,
418 blocked_hosts,
419 max_body_size,
420 read_timeout_ms,
421 max_response_bytes,
422 auth,
423 token_provider: None,
424 user_agent,
425 bridge_endpoint,
426 connection_close,
427 skip_request_headers,
428 skip_response_headers,
429 follow_redirects,
430 max_redirects,
431 allowed_uri_hosts,
432 })
433 }
434}
435
436#[derive(Debug, Clone, UriConfig)]
443#[allow(dead_code)]
444#[uri_scheme = "http"]
445#[uri_config(
446 skip_impl,
447 metadata(
448 scheme = "http",
449 description = "HTTP client and server component",
450 producer,
451 consumer,
452 streaming
453 ),
454 crate = "camel_component_api"
455)]
456struct HttpEndpointUriConfig {
457 #[allow(dead_code)]
458 _base_url: String,
459
460 #[uri_param(
461 name = "httpMethod",
462 desc = "HTTP method. Defaults to CamelHttpMethod header or POST/GET"
463 )]
464 http_method: Option<String>,
465
466 #[uri_param(
467 name = "throwExceptionOnFailure",
468 default = "true",
469 desc = "Throw on non-2xx status"
470 )]
471 throw_exception_on_failure: bool,
472
473 #[uri_param(
474 name = "okStatusCodeRange",
475 default = "200-299",
476 desc = "Success status code range"
477 )]
478 ok_status_code_range: String,
479
480 #[uri_param(name = "responseTimeout", desc = "Response timeout in milliseconds")]
481 response_timeout: Option<u64>,
482
483 #[uri_param(
484 name = "connectTimeout",
485 desc = "Connection timeout in milliseconds (consumed option; effective timeout comes from the global http config)"
486 )]
487 connect_timeout: Option<u64>,
488
489 #[uri_param(
490 name = "allowInternal",
491 default = "false",
492 desc = "Allow private/internal network destinations (SSRF)"
493 )]
494 allow_internal: bool,
495
496 #[uri_param(
497 name = "allowCleartext",
498 default = "false",
499 desc = "Allow cleartext http:// to public targets (transport consent; ADR-0081)"
500 )]
501 allow_cleartext: bool,
502
503 #[uri_param(name = "blockedHosts", desc = "Comma-separated blocked host list")]
504 blocked_hosts: Option<String>,
505
506 #[uri_param(
507 name = "maxBodySize",
508 default = "10485760",
509 desc = "Max request/response body bytes"
510 )]
511 max_body_size: u64,
512
513 #[uri_param(name = "readTimeout", desc = "Socket read timeout in milliseconds")]
514 read_timeout: Option<u64>,
515
516 #[uri_param(name = "maxResponseBytes", desc = "Max response body bytes")]
517 max_response_bytes: Option<u64>,
518
519 #[uri_param(
520 name = "authMethod",
521 kind = "enum:None,Basic,Bearer",
522 desc = "Authentication method"
523 )]
524 auth_method: Option<String>,
525
526 #[uri_param(name = "authUsername", secret, desc = "Basic auth username")]
527 auth_username: Option<String>,
528
529 #[uri_param(name = "authPassword", secret, desc = "Basic auth password")]
530 auth_password: Option<String>,
531
532 #[uri_param(name = "authBearerToken", secret, desc = "Bearer auth token")]
533 auth_bearer_token: Option<String>,
534
535 #[uri_param(name = "userAgent", desc = "User-Agent header")]
536 user_agent: Option<String>,
537
538 #[uri_param(
539 name = "bridgeEndpoint",
540 default = "false",
541 desc = "Bridge endpoint mode"
542 )]
543 bridge_endpoint: bool,
544
545 #[uri_param(
546 name = "connectionClose",
547 default = "false",
548 desc = "Send Connection: close"
549 )]
550 connection_close: bool,
551
552 #[uri_param(
553 name = "skipRequestHeaders",
554 desc = "Comma-separated request headers to skip"
555 )]
556 skip_request_headers: Option<String>,
557
558 #[uri_param(
559 name = "skipResponseHeaders",
560 desc = "Comma-separated response headers to skip"
561 )]
562 skip_response_headers: Option<String>,
563
564 #[uri_param(
565 name = "followRedirects",
566 default = "false",
567 desc = "Follow HTTP redirects"
568 )]
569 follow_redirects: bool,
570
571 #[uri_param(name = "maxRedirects", default = "10", desc = "Max redirect hops")]
572 max_redirects: u64,
573
574 #[uri_param(
575 name = "allowedUriHosts",
576 desc = "Comma-separated allowlist of CamelHttpUri override hosts (host or host:port)"
577 )]
578 allowed_uri_hosts: Option<String>,
579}
580
581impl HttpEndpointConfig {
582 pub fn metadata() -> ComponentMetadata {
585 HttpEndpointUriConfig::metadata()
586 }
587
588 pub fn uri_options() -> Vec<camel_api::component_metadata::UriOption> {
590 HttpEndpointUriConfig::uri_options()
591 }
592}
593
594fn parse_auth_from_params(params: &HashMap<String, String>) -> Result<HttpAuth, CamelError> {
595 let Some(method) = params.get("authMethod") else {
596 return Ok(HttpAuth::None);
597 };
598
599 if method.eq_ignore_ascii_case("none") {
600 return Ok(HttpAuth::None);
601 }
602
603 if method.eq_ignore_ascii_case("basic") {
604 let username = params.get("authUsername").cloned().ok_or_else(|| {
605 CamelError::InvalidUri("authUsername is required for authMethod=Basic".to_string())
606 })?;
607 let password = params.get("authPassword").cloned().ok_or_else(|| {
608 CamelError::InvalidUri("authPassword is required for authMethod=Basic".to_string())
609 })?;
610 return Ok(HttpAuth::Basic { username, password });
611 }
612
613 if method.eq_ignore_ascii_case("bearer") {
614 let token = params.get("authBearerToken").cloned().ok_or_else(|| {
615 CamelError::InvalidUri("authBearerToken is required for authMethod=Bearer".to_string())
616 })?;
617 return Ok(HttpAuth::Bearer { token });
618 }
619
620 Err(CamelError::InvalidUri(format!(
621 "invalid value for authMethod: {method} (expected None, Basic, or Bearer)"
622 )))
623}
624
625fn parse_bool_param_http(value: &str) -> Result<bool, CamelError> {
626 match value.to_ascii_lowercase().as_str() {
627 "true" | "1" | "yes" => Ok(true),
628 "false" | "0" | "no" => Ok(false),
629 _ => Err(CamelError::InvalidUri(format!(
630 "invalid boolean value: '{value}'"
631 ))),
632 }
633}
634
635impl HttpEndpointConfig {
636 pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
637 let parts = parse_uri(uri)?;
638 let mut endpoint = Self::from_components(parts.clone())?;
639 if endpoint.response_timeout.is_none() {
640 endpoint.response_timeout = Some(Duration::from_millis(config.response_timeout_ms));
641 }
642 if !parts.params.contains_key("allowInternal") {
643 endpoint.allow_internal = config.allow_internal;
644 }
645 if !parts.params.contains_key("blockedHosts") {
646 endpoint.blocked_hosts = config.blocked_hosts.clone();
647 }
648 if !parts.params.contains_key("maxBodySize") {
649 endpoint.max_body_size = config.max_body_size;
650 }
651 if !parts.params.contains_key("readTimeout") {
652 endpoint.read_timeout_ms = config.read_timeout_ms;
653 }
654 if !parts.params.contains_key("maxResponseBytes") {
655 endpoint.max_response_bytes = config.max_response_bytes;
656 }
657 if !parts.params.contains_key("okStatusCodeRange")
658 && let Some(range) = &config.ok_status_code_range
659 {
660 endpoint.ok_status_code_range = parse_ok_status_code_range(range)?;
661 }
662 if !parts.params.contains_key("followRedirects") {
663 endpoint.follow_redirects = config.follow_redirects;
664 }
665 if !parts.params.contains_key("maxRedirects") {
666 endpoint.max_redirects = config.max_redirects.unwrap_or(10);
667 }
668
669 Ok(endpoint)
670 }
671}
672
673#[derive(Debug, Clone)]
679pub struct HttpServerConfig {
680 pub scheme: String,
682 pub host: String,
684 pub port: u16,
686 pub path: String,
688 pub max_request_body: usize,
690 pub max_response_body: usize,
692 pub max_inflight_requests: usize,
698 pub method: Option<String>,
705 pub tls_config: Option<crate::config::ServerTlsConfig>,
708}
709
710impl UriConfig for HttpServerConfig {
711 fn scheme() -> &'static str {
713 "http"
714 }
715
716 fn from_uri(uri: &str) -> Result<Self, CamelError> {
717 let parts = parse_uri(uri)?;
718 Self::from_components(parts)
719 }
720
721 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
722 if parts.scheme != "http" && parts.scheme != "https" {
724 return Err(CamelError::InvalidUri(format!(
725 "expected scheme 'http' or 'https', got '{}'",
726 parts.scheme
727 )));
728 }
729
730 let authority_and_path = parts.path.trim_start_matches('/');
733
734 let (authority, path_suffix) = if let Some(idx) = authority_and_path.find('/') {
736 (&authority_and_path[..idx], &authority_and_path[idx..])
737 } else {
738 (authority_and_path, "/")
739 };
740
741 let path = if path_suffix.is_empty() {
742 "/"
743 } else {
744 path_suffix
745 }
746 .to_string();
747
748 let (host, port) = if let Some(colon) = authority.rfind(':') {
750 let port_str = &authority[colon + 1..];
751 match port_str.parse::<u16>() {
752 Ok(p) => (authority[..colon].to_string(), p),
753 Err(_) => {
754 return Err(CamelError::InvalidUri(format!(
755 "invalid port '{}' in authority",
756 port_str
757 )));
758 }
759 }
760 } else {
761 let default_port = if parts.scheme == "https" { 443 } else { 80 };
763 (authority.to_string(), default_port)
764 };
765
766 let max_request_body = parts
767 .params
768 .get("maxRequestBody")
769 .and_then(|v| v.parse::<usize>().ok())
770 .unwrap_or(2 * 1024 * 1024); let max_response_body = parts
773 .params
774 .get("maxResponseBody")
775 .and_then(|v| v.parse::<usize>().ok())
776 .unwrap_or(10 * 1024 * 1024); let max_inflight_requests = parts
779 .params
780 .get("maxInflightRequests")
781 .and_then(|v| v.parse::<usize>().ok())
782 .unwrap_or(1024);
783 let max_inflight_requests = max_inflight_requests_limit(max_inflight_requests)?;
787
788 let method = parts.params.get("httpMethod").map(|m| m.to_uppercase());
794
795 Ok(Self {
796 scheme: parts.scheme,
797 host,
798 port,
799 path,
800 max_request_body,
801 max_response_body,
802 max_inflight_requests,
803 method,
804 tls_config: {
805 let cert = parts.params.get("tlsCert").cloned();
806 let key = parts.params.get("tlsKey").cloned();
807 match (cert, key) {
808 (Some(c), Some(k)) => Some(crate::config::ServerTlsConfig {
809 cert_path: c,
810 key_path: k,
811 }),
812 (None, None) => None,
813 _ => None, }
815 },
816 })
817 }
818}
819
820impl HttpServerConfig {
821 pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
822 let parts = parse_uri(uri)?;
823 let mut server = Self::from_components(parts.clone())?;
824 if !parts.params.contains_key("maxRequestBody") {
825 server.max_request_body = config.max_request_body;
826 }
827 if !parts.params.contains_key("maxResponseBody") {
828 server.max_response_body = config.max_body_size;
830 }
831 Ok(server)
832 }
833}
834
835pub enum HttpReplyBody {
843 Bytes(bytes::Bytes),
844 Stream(BoxStream<'static, Result<bytes::Bytes, CamelError>>),
845}
846
847pub struct RequestEnvelope {
852 pub method: String,
853 pub path: String,
854 pub query: String,
855 pub headers: http::HeaderMap,
856 pub body: StreamBody,
857 pub path_params: std::collections::HashMap<String, String>,
863 pub reply_tx: tokio::sync::oneshot::Sender<HttpReply>,
864}
865
866pub struct HttpReply {
870 pub status: u16,
871 pub headers: Vec<(String, String)>,
872 pub body: HttpReplyBody,
873}
874
875type ServerKey = (String, u16);
880
881struct ServerHandle {
883 registry: HttpRouteRegistry,
884 bound_addr: std::net::SocketAddr,
887 max_request_body: usize,
888 max_response_body: usize,
889 max_inflight_requests: usize,
890 is_tls: bool,
891 tls_cert_path: Option<String>,
892 tls_key_path: Option<String>,
893 monitor_task: tokio::task::JoinHandle<()>,
896 #[allow(dead_code)]
902 server_abort: tokio::task::AbortHandle,
903 tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
906 tls_source: Option<ServerTlsSource>,
907}
908
909#[derive(Default)]
912struct RegistryState {
913 entries: HashMap<ServerKey, Arc<OnceCell<ServerHandle>>>,
914 staged: HashMap<ServerKey, tokio::net::TcpListener>,
915}
916
917pub struct ServerRegistry {
919 inner: Mutex<RegistryState>,
920}
921
922impl ServerRegistry {
923 pub fn global() -> &'static Self {
925 static INSTANCE: OnceLock<ServerRegistry> = OnceLock::new();
926 INSTANCE.get_or_init(|| ServerRegistry {
927 inner: Mutex::new(RegistryState::default()),
928 })
929 }
930
931 #[allow(clippy::too_many_arguments)]
934 pub async fn get_or_spawn(
935 &'static self,
936 host: &str,
937 port: u16,
938 max_request_body: usize,
939 max_response_body: usize,
940 max_inflight_requests: usize,
941 runtime: Arc<dyn RuntimeObservability>,
942 route_id: String,
943 tls_config: Option<crate::config::ServerTlsConfig>,
944 ) -> Result<HttpRouteRegistry, CamelError> {
945 self.get_or_spawn_internal(
946 host,
947 port,
948 max_request_body,
949 max_response_body,
950 max_inflight_requests,
951 runtime,
952 route_id,
953 tls_config,
954 None,
955 )
956 .await
957 }
958
959 #[allow(clippy::too_many_arguments)]
966 pub async fn get_or_spawn_with_listener(
967 &'static self,
968 listener: tokio::net::TcpListener,
969 max_request_body: usize,
970 max_response_body: usize,
971 max_inflight_requests: usize,
972 runtime: Arc<dyn RuntimeObservability>,
973 route_id: String,
974 tls_config: Option<crate::config::ServerTlsConfig>,
975 ) -> Result<HttpRouteRegistry, CamelError> {
976 let addr = listener
977 .local_addr()
978 .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
979 self.get_or_spawn_internal(
980 &addr.ip().to_string(),
981 addr.port(),
982 max_request_body,
983 max_response_body,
984 max_inflight_requests,
985 runtime,
986 route_id,
987 tls_config,
988 Some(listener),
989 )
990 .await
991 }
992
993 pub async fn stage_listener(
1000 &'static self,
1001 listener: tokio::net::TcpListener,
1002 ) -> Result<(), CamelError> {
1003 let addr = listener
1004 .local_addr()
1005 .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
1006 let host = addr.ip().to_string();
1007 use std::collections::hash_map::Entry;
1008 let mut guard = self.inner.lock().map_err(|_| {
1009 CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
1010 })?;
1011 match guard.staged.entry((host.clone(), addr.port())) {
1012 Entry::Occupied(_) => Err(CamelError::EndpointCreationFailed(format!(
1013 "listener already staged for {host}:{}",
1014 addr.port()
1015 ))),
1016 Entry::Vacant(slot) => {
1017 slot.insert(listener);
1018 Ok(())
1019 }
1020 }
1021 }
1022
1023 pub fn bound_addr(&'static self, host: &str, port: u16) -> Option<std::net::SocketAddr> {
1026 let guard = self.inner.lock().ok()?;
1027 guard
1028 .entries
1029 .get(&(host.to_string(), port))
1030 .and_then(|cell| cell.get())
1031 .map(|handle| handle.bound_addr)
1032 }
1033
1034 #[allow(clippy::too_many_arguments)]
1035 async fn get_or_spawn_internal(
1036 &'static self,
1037 host: &str,
1038 port: u16,
1039 max_request_body: usize,
1040 max_response_body: usize,
1041 max_inflight_requests: usize,
1042 runtime: Arc<dyn RuntimeObservability>,
1043 route_id: String,
1044 tls_config: Option<crate::config::ServerTlsConfig>,
1045 provided: Option<tokio::net::TcpListener>,
1046 ) -> Result<HttpRouteRegistry, CamelError> {
1047 let host_owned = host.to_string();
1048 let key = (host.to_string(), port);
1049
1050 let cell = {
1051 let mut guard = self.inner.lock().map_err(|_| {
1052 CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
1053 })?;
1054 if let Some(existing) = guard.entries.get(&key)
1058 && let Some(handle) = existing.get()
1059 && handle.monitor_task.is_finished()
1060 {
1061 if handle.is_tls {
1064 let scheme = if handle.is_tls { "https" } else { "http" };
1065 camel_component_api::tls_source::TlsReloadRegistry::global()
1066 .unregister(scheme, host, port);
1067 }
1068 guard.entries.remove(&key);
1069 }
1070 guard
1071 .entries
1072 .entry(key)
1073 .or_insert_with(|| Arc::new(OnceCell::new()))
1074 .clone()
1075 };
1076
1077 if let Some(existing) = cell.get()
1078 && existing.max_request_body != max_request_body
1079 {
1080 return Err(CamelError::EndpointCreationFailed(format!(
1081 "incompatible maxRequestBody for shared server (host={host}, port={port}): {} vs {}",
1082 existing.max_request_body, max_request_body
1083 )));
1084 }
1085
1086 if let Some(existing) = cell.get()
1087 && existing.max_response_body != max_response_body
1088 {
1089 return Err(CamelError::EndpointCreationFailed(format!(
1090 "incompatible maxResponseBody for shared server (host={host}, port={port}): {} vs {}",
1091 existing.max_response_body, max_response_body
1092 )));
1093 }
1094
1095 if let Some(existing) = cell.get()
1096 && existing.max_inflight_requests != max_inflight_requests
1097 {
1098 return Err(CamelError::EndpointCreationFailed(format!(
1099 "incompatible maxInflightRequests for shared server (host={host}, port={port}): {} vs {}",
1100 existing.max_inflight_requests, max_inflight_requests
1101 )));
1102 }
1103
1104 if let Some(existing) = cell.get()
1106 && existing.is_tls != tls_config.is_some()
1107 {
1108 return Err(CamelError::EndpointCreationFailed(format!(
1109 "incompatible TLS mode for shared server (host={host}, port={port}): existing is_tls={}, new has_tls={}",
1110 existing.is_tls,
1111 tls_config.is_some()
1112 )));
1113 }
1114
1115 if let (Some(existing), Some(new_tls)) = (cell.get(), &tls_config)
1117 && (existing.tls_cert_path.as_deref() != Some(&new_tls.cert_path)
1118 || existing.tls_key_path.as_deref() != Some(&new_tls.key_path))
1119 {
1120 return Err(CamelError::EndpointCreationFailed(format!(
1121 "incompatible TLS cert/key for shared server (host={host}, port={port}): routes on the same TLS port must use the same cert and key"
1122 )));
1123 }
1124
1125 let handle = cell
1126 .get_or_try_init(|| {
1127 let rt = Arc::clone(&runtime);
1128 let rid = route_id.clone();
1129 let key = (host_owned.clone(), port);
1130 async move {
1131 let source = match provided {
1140 Some(listener) => ListenerSource::Staged(listener),
1141 None => {
1142 let mut guard = self.inner.lock().map_err(|_| {
1143 CamelError::EndpointCreationFailed(
1144 "ServerRegistry lock poisoned".into(),
1145 )
1146 })?;
1147 match guard.staged.remove(&key) {
1148 Some(listener) => ListenerSource::Staged(listener),
1149 None => {
1153 if let Some((staged_host, _)) = guard
1154 .staged
1155 .keys()
1156 .find(|(_, staged_port)| *staged_port == port)
1157 {
1158 let staged_host = staged_host.clone();
1159 return Err(CamelError::EndpointCreationFailed(
1160 format!(
1161 "staged listener conflict on port {port}: staged under host {staged_host}, requested {host_owned}"
1162 ),
1163 ));
1164 }
1165 ListenerSource::Bind
1166 }
1167 }
1168 }
1169 };
1170 spawn_entry(
1171 key,
1172 source,
1173 max_request_body,
1174 max_response_body,
1175 max_inflight_requests,
1176 rt,
1177 rid,
1178 tls_config,
1179 )
1180 .await
1181 .and_then(|handle| {
1182 Arc::try_unwrap(handle).map_err(|_| {
1186 CamelError::EndpointCreationFailed(
1187 "spawned server handle has dangling clones".into(),
1188 )
1189 })
1190 })
1191 }
1192 })
1193 .await?;
1194
1195 Ok(handle.registry.clone())
1196 }
1197
1198 pub async fn unregister(&self, host: &str, port: u16) {
1202 debug!(
1203 host = camel_api::redact::redact_host(host),
1204 port = port,
1205 "consumer unregistered from HTTP server"
1206 );
1207 }
1208
1209 #[cfg(test)]
1216 pub fn reset() {
1217 let instance = Self::global();
1218 let mut guard = instance
1219 .inner
1220 .lock()
1221 .expect("ServerRegistry lock poisoned during test reset");
1222 guard.entries.clear();
1223 guard.staged.clear();
1224 }
1225}
1226
1227enum ListenerSource {
1230 Bind,
1231 Staged(tokio::net::TcpListener),
1232}
1233
1234#[allow(clippy::too_many_arguments)]
1239async fn spawn_entry(
1240 key: ServerKey,
1241 source: ListenerSource,
1242 max_request_body: usize,
1243 max_response_body: usize,
1244 max_inflight_requests: usize,
1245 runtime: Arc<dyn RuntimeObservability>,
1246 route_id: String,
1247 tls_config: Option<crate::config::ServerTlsConfig>,
1248) -> Result<Arc<ServerHandle>, CamelError> {
1249 max_inflight_requests_limit(max_inflight_requests)?;
1255 let rt = Arc::clone(&runtime);
1256 let rid = route_id.clone();
1257 let (host_owned, port) = key;
1258 let listener = match source {
1259 ListenerSource::Bind => {
1260 let addr = format!("{host_owned}:{port}");
1261 tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
1262 CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
1263 })?
1264 }
1265 ListenerSource::Staged(listener) => listener,
1266 };
1267 let bound_addr = listener
1268 .local_addr()
1269 .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
1270 let server_exited = tokio_util::sync::CancellationToken::new();
1271 let registry = HttpRouteRegistry::new_with_server_exited(server_exited.clone());
1272 let inflight = Arc::new(tokio::sync::Semaphore::new(max_inflight_requests));
1273 let tls_rustls_cfg: Option<axum_server::tls_rustls::RustlsConfig>;
1276 let tls_source: Option<ServerTlsSource>;
1277 let server_task = if let Some(ref tls) = tls_config {
1278 let rustls_config = load_tls_config(&tls.cert_path, &tls.key_path)?;
1279 let source = ServerTlsSource {
1280 cert_path: std::path::PathBuf::from(&tls.cert_path),
1281 key_path: std::path::PathBuf::from(&tls.key_path),
1282 client_ca_path: None,
1283 };
1284 let rustls_cfg =
1288 axum_server::tls_rustls::RustlsConfig::from_config(std::sync::Arc::new(rustls_config));
1289 tls_rustls_cfg = Some(rustls_cfg.clone());
1290 tls_source = Some(source);
1291 let std_listener = listener.into_std().map_err(|e| {
1293 CamelError::EndpointCreationFailed(format!("TLS listener conversion: {e}"))
1294 })?;
1295 tokio::spawn(run_axum_server_tls(
1296 std_listener,
1297 rustls_cfg,
1298 registry.clone(),
1299 max_request_body,
1300 max_response_body,
1301 Arc::clone(&inflight),
1302 Arc::clone(&rt),
1303 rid.clone(),
1304 ))
1305 } else {
1306 tls_rustls_cfg = None;
1307 tls_source = None;
1308 tokio::spawn(run_axum_server(
1309 listener,
1310 registry.clone(),
1311 max_request_body,
1312 max_response_body,
1313 Arc::clone(&inflight),
1314 Arc::clone(&rt),
1315 rid.clone(),
1316 ))
1317 };
1318 let addr_for_monitor = format!("{host_owned}:{port}");
1319 let server_abort = server_task.abort_handle();
1320 let monitor_task = tokio::spawn(monitor_axum_task(
1321 server_task,
1322 addr_for_monitor,
1323 Arc::clone(&rt),
1324 rid,
1325 server_exited,
1326 ));
1327 let handle = ServerHandle {
1328 registry,
1329 bound_addr,
1330 max_request_body,
1331 max_response_body,
1332 max_inflight_requests,
1333 is_tls: tls_config.is_some(),
1334 tls_cert_path: tls_config.as_ref().map(|t| t.cert_path.clone()),
1335 tls_key_path: tls_config.as_ref().map(|t| t.key_path.clone()),
1336 monitor_task,
1337 server_abort,
1338 tls_config: tls_rustls_cfg,
1339 tls_source,
1340 };
1341 if let (Some(tls_cfg), Some(source)) = (handle.tls_config.as_ref(), handle.tls_source.as_ref())
1346 {
1347 let handler = Arc::new(crate::tls_reload::HttpReloadHandler::new(
1348 tls_cfg.clone(),
1349 source.clone(),
1350 host_owned.clone(),
1351 port,
1352 ));
1353 camel_component_api::tls_source::TlsReloadRegistry::global().register(handler);
1354 }
1355 Ok(Arc::new(handle))
1356}
1357
1358use axum::{
1363 Router,
1364 body::Body as AxumBody,
1365 extract::{Request, State},
1366 http::{Response, StatusCode},
1367 response::IntoResponse,
1368};
1369
1370#[derive(Clone)]
1371pub(crate) struct AppState {
1372 registry: HttpRouteRegistry,
1373 max_request_body: usize,
1374 max_response_body: usize,
1375 inflight: Arc<tokio::sync::Semaphore>,
1376}
1377
1378const CONSUMER_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
1385
1386async fn run_axum_server(
1387 listener: tokio::net::TcpListener,
1388 registry: HttpRouteRegistry,
1389 max_request_body: usize,
1390 max_response_body: usize,
1391 inflight: Arc<tokio::sync::Semaphore>,
1392 runtime: Arc<dyn RuntimeObservability>,
1393 route_id: String,
1394) {
1395 let state = AppState {
1396 registry,
1397 max_request_body,
1398 max_response_body,
1399 inflight,
1400 };
1401 let app = Router::new()
1402 .fallback(dispatch_handler)
1403 .with_state(state)
1404 .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1405 StatusCode::REQUEST_TIMEOUT,
1406 CONSUMER_REQUEST_TIMEOUT,
1407 ));
1408
1409 axum::serve(listener, app).await.unwrap_or_else(|e| {
1410 runtime
1411 .metrics()
1412 .increment_errors(&route_id, "e:http:accept");
1413 tracing::error!(error = %e, "Axum server error");
1415 });
1416}
1417
1418#[allow(clippy::too_many_arguments)]
1419async fn run_axum_server_tls(
1420 listener: std::net::TcpListener,
1421 tls_cfg: axum_server::tls_rustls::RustlsConfig,
1422 registry: HttpRouteRegistry,
1423 max_request_body: usize,
1424 max_response_body: usize,
1425 inflight: Arc<tokio::sync::Semaphore>,
1426 runtime: Arc<dyn RuntimeObservability>,
1427 route_id: String,
1428) {
1429 let state = AppState {
1430 registry,
1431 max_request_body,
1432 max_response_body,
1433 inflight,
1434 };
1435 let app = Router::new()
1436 .fallback(dispatch_handler)
1437 .with_state(state)
1438 .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1439 StatusCode::REQUEST_TIMEOUT,
1440 CONSUMER_REQUEST_TIMEOUT,
1441 ));
1442
1443 let server = match axum_server::from_tcp_rustls(listener, tls_cfg) {
1448 Ok(server) => server,
1449 Err(e) => {
1450 runtime
1451 .metrics()
1452 .increment_errors(&route_id, "e:http:accept-tls");
1453 tracing::error!(error = %e, "Axum TLS server setup error");
1455 return;
1456 }
1457 };
1458
1459 server
1460 .serve(app.into_make_service())
1461 .await
1462 .unwrap_or_else(|e| {
1463 runtime
1464 .metrics()
1465 .increment_errors(&route_id, "e:http:accept-tls");
1466 tracing::error!(error = %e, "Axum TLS server error");
1468 });
1469}
1470
1471async fn monitor_axum_task(
1481 handle: tokio::task::JoinHandle<()>,
1482 addr: String,
1483 runtime: Arc<dyn RuntimeObservability>,
1484 route_id: String,
1485 server_exited: tokio_util::sync::CancellationToken,
1486) {
1487 match handle.await {
1488 Ok(()) => {
1489 }
1491 Err(join_err) => {
1492 runtime
1493 .metrics()
1494 .increment_errors(&route_id, "e:http:server-task-exited");
1495 tracing::error!(
1497 addr = %addr,
1498 error = %join_err,
1499 "Axum server task exited unexpectedly — all routes on this port are now dead"
1500 );
1501 server_exited.cancel();
1505 }
1506 }
1507}
1508
1509fn load_tls_config(
1512 cert_path: &str,
1513 key_path: &str,
1514) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
1515 use std::fs::File;
1516 use std::io::BufReader;
1517
1518 let cert_file = File::open(cert_path)
1519 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
1520 let key_file = File::open(key_path)
1521 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
1522
1523 let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
1524 .collect::<Result<Vec<_>, _>>()
1525 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
1526
1527 let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
1528 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
1529 .ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
1530
1531 tokio_rustls::rustls::ServerConfig::builder()
1532 .with_no_client_auth()
1533 .with_single_cert(certs, key)
1534 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
1535}
1536
1537async fn dispatch_handler(State(state): State<AppState>, req: Request) -> impl IntoResponse {
1538 let path = req.uri().path().to_owned();
1539 let method = req.method().to_string();
1540
1541 let api_sender = {
1558 let inner = state.registry.inner.read().await;
1559 inner.api_routes.get(&path).cloned()
1560 }; let (rest_sender, path_params) = if api_sender.is_some() {
1563 (None, Default::default())
1565 } else {
1566 let inner = state.registry.inner.read().await;
1567 match rest_match::match_endpoint(&method, &path, &inner.rest_endpoints) {
1568 rest_match::MatchOutcome::Found(m) => (Some(m.payload), m.path_params),
1569 rest_match::MatchOutcome::Ambiguous => {
1570 tracing::warn!(
1576 method = %method,
1577 path = %path,
1578 "ambiguous REST template match — returning 500"
1579 );
1580 return Response::builder()
1581 .status(StatusCode::INTERNAL_SERVER_ERROR)
1582 .body(AxumBody::from("Internal Server Error"))
1583 .expect("infallible"); }
1585 rest_match::MatchOutcome::NotFound => (None, Default::default()),
1586 }
1587 }; let sender = api_sender.or(rest_sender);
1590
1591 if let Some(sender) = sender {
1592 let query = req.uri().query().unwrap_or("").to_string();
1593 let headers = req.headers().clone();
1594
1595 let content_length: Option<u64> = headers
1597 .get(http::header::CONTENT_LENGTH)
1598 .and_then(|v| v.to_str().ok())
1599 .and_then(|s| s.parse().ok());
1600
1601 if let Some(len) = content_length
1602 && len > state.max_request_body as u64
1603 {
1604 return Response::builder()
1605 .status(StatusCode::PAYLOAD_TOO_LARGE)
1606 .body(AxumBody::from("Request body exceeds configured limit"))
1607 .expect("infallible"); }
1609
1610 let _permit = match Arc::clone(&state.inflight).try_acquire_owned() {
1611 Ok(permit) => permit,
1612 Err(_) => {
1613 return Response::builder()
1614 .status(StatusCode::SERVICE_UNAVAILABLE)
1615 .body(AxumBody::from("Service Unavailable"))
1616 .expect("infallible"); }
1618 };
1619
1620 let content_type = headers
1626 .get(http::header::CONTENT_TYPE)
1627 .and_then(|v| v.to_str().ok())
1628 .map(|s| s.to_string());
1629
1630 let data_stream: BodyDataStream = req.into_body().into_data_stream();
1631 let max_body = state.max_request_body;
1632 let mut seen: u64 = 0;
1633 let capped_stream =
1634 data_stream
1635 .map_err(|e| CamelError::Io(e.to_string()))
1636 .map(move |chunk| match chunk {
1637 Ok(bytes) => {
1638 seen = seen.saturating_add(bytes.len() as u64);
1639 if seen > max_body as u64 {
1640 Err(CamelError::ProcessorError(format!(
1641 "Request body exceeds configured limit of {max_body} bytes"
1642 )))
1643 } else {
1644 Ok(bytes)
1645 }
1646 }
1647 Err(e) => Err(e),
1648 });
1649 let boxed: BoxStream<'static, Result<bytes::Bytes, CamelError>> = Box::pin(capped_stream);
1650
1651 let stream_body = StreamBody {
1652 stream: Arc::new(tokio::sync::Mutex::new(Some(boxed))),
1653 metadata: StreamMetadata {
1654 size_hint: content_length,
1655 content_type,
1656 origin: None,
1657 },
1658 };
1659
1660 let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<HttpReply>();
1661 let envelope = RequestEnvelope {
1662 method,
1663 path,
1664 query,
1665 headers,
1666 body: stream_body,
1667 path_params,
1668 reply_tx,
1669 };
1670
1671 if sender.send(envelope).await.is_err() {
1672 return Response::builder()
1673 .status(StatusCode::SERVICE_UNAVAILABLE)
1674 .body(AxumBody::from("Consumer unavailable"))
1675 .expect("infallible"); }
1677
1678 match reply_rx.await {
1679 Ok(reply) => {
1680 let reply = match reply.body {
1681 HttpReplyBody::Bytes(b)
1682 if exceeds_max_response_body(b.len(), state.max_response_body) =>
1683 {
1684 HttpReply {
1685 status: 500,
1686 headers: vec![],
1687 body: HttpReplyBody::Bytes(bytes::Bytes::from(
1688 "Response body exceeds configured limit",
1689 )),
1690 }
1691 }
1692 _ => reply,
1693 };
1694
1695 let status =
1696 StatusCode::from_u16(reply.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1697 let mut builder = Response::builder().status(status);
1698 for (k, v) in &reply.headers {
1699 builder = builder.header(k.as_str(), v.as_str());
1700 }
1701 match reply.body {
1702 HttpReplyBody::Bytes(b) => {
1703 builder.body(AxumBody::from(b)).unwrap_or_else(|_| {
1704 Response::builder()
1705 .status(StatusCode::INTERNAL_SERVER_ERROR)
1706 .body(AxumBody::from("Invalid response headers from consumer"))
1707 .expect("infallible") })
1709 }
1710 HttpReplyBody::Stream(stream) => builder
1711 .body(AxumBody::from_stream(stream))
1712 .unwrap_or_else(|_| {
1713 Response::builder()
1714 .status(StatusCode::INTERNAL_SERVER_ERROR)
1715 .body(AxumBody::from("Invalid response headers from consumer"))
1716 .expect("infallible") }),
1718 }
1719 }
1720 Err(_) => Response::builder()
1721 .status(StatusCode::INTERNAL_SERVER_ERROR)
1722 .body(AxumBody::from("Pipeline error"))
1723 .expect("infallible"), }
1725 } else {
1726 static_dispatch::dispatch_static(&state, req, &path).await
1728 }
1729}
1730
1731fn exceeds_max_response_body(len: usize, max: usize) -> bool {
1732 len > max
1733}
1734
1735fn title_case_header(name: &str) -> String {
1736 name.split('-')
1737 .map(|part| {
1738 let mut chars = part.chars();
1739 match chars.next() {
1740 None => String::new(),
1741 Some(first) => first.to_uppercase().chain(chars.as_str().chars()).collect(),
1742 }
1743 })
1744 .collect::<Vec<_>>()
1745 .join("-")
1746}
1747
1748pub(crate) struct HttpKernelAuth {
1763 pub(crate) plan: camel_api::security_policy::RouteSecurityPlan,
1764 pub(crate) providers: Arc<camel_auth::ProviderRegistry>,
1765}
1766
1767impl HttpKernelAuth {
1768 pub(crate) fn from_security_context(
1773 ctx: &camel_component_api::SecurityContext,
1774 ) -> Option<Self> {
1775 Some(Self {
1776 plan: ctx.plan.clone()?,
1777 providers: ctx.providers.clone()?,
1778 })
1779 }
1780}
1781
1782fn envelope_channel_capacity(max_inflight_requests: usize) -> usize {
1796 max_inflight_requests.max(1)
1797}
1798
1799pub(crate) fn max_inflight_requests_limit(configured: usize) -> Result<usize, CamelError> {
1814 if configured > tokio::sync::Semaphore::MAX_PERMITS {
1815 return Err(CamelError::Config(format!(
1816 "maxInflightRequests {configured} exceeds the supported upper bound {} (tokio::sync::Semaphore::MAX_PERMITS)",
1817 tokio::sync::Semaphore::MAX_PERMITS
1818 )));
1819 }
1820 Ok(configured)
1821}
1822
1823pub struct HttpConsumer {
1824 config: HttpServerConfig,
1825 runtime: Arc<dyn RuntimeObservability>,
1827 kernel: Option<Arc<HttpKernelAuth>>,
1831}
1832
1833impl HttpConsumer {
1834 pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
1835 Self {
1836 config,
1837 runtime,
1838 kernel: None,
1839 }
1840 }
1841}
1842
1843#[async_trait::async_trait]
1844impl Consumer for HttpConsumer {
1845 async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
1846 use camel_component_api::{Body, Exchange, Message};
1847
1848 max_inflight_requests_limit(self.config.max_inflight_requests)?;
1853
1854 let registry = ServerRegistry::global()
1855 .get_or_spawn(
1856 &self.config.host,
1857 self.config.port,
1858 self.config.max_request_body,
1859 self.config.max_response_body,
1860 self.config.max_inflight_requests,
1861 self.runtime.clone(),
1862 ctx.route_id().to_string(),
1863 self.config.tls_config.clone(),
1864 )
1865 .await?;
1866
1867 let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(
1871 envelope_channel_capacity(self.config.max_inflight_requests),
1872 );
1873 if let Some(method) = self.config.method.clone() {
1880 let segments = rest_match::parse_path_template(&self.config.path);
1881 registry
1882 .register_rest_endpoint(method, segments, env_tx)
1883 .await;
1884 } else {
1885 registry
1886 .register_api_route(self.config.path.clone(), env_tx)
1887 .await;
1888 }
1889
1890 ctx.mark_ready();
1899
1900 let in_flight = ctx.in_flight_counter();
1904
1905 let path = self.config.path.clone();
1906 let registry_for_cleanup = registry.clone();
1907 let server_exited = registry.server_exited.clone();
1908 let cancel_token = ctx.cancel_token();
1909 let kernel = self.kernel.clone();
1910 let mut server_died = false;
1915 loop {
1916 tokio::select! {
1917 _ = ctx.cancelled() => {
1918 break;
1919 }
1920 _ = server_exited.cancelled() => {
1921 server_died = true;
1926 break;
1927 }
1928 envelope = env_rx.recv() => {
1929 let Some(envelope) = envelope else { break; };
1930
1931 let claim =
1939 in_flight.as_ref().map(camel_component_api::InFlightClaim::attach);
1940
1941 let mut msg = Message::default();
1943
1944 msg.set_header("CamelHttpMethod",
1946 serde_json::Value::String(envelope.method.clone()));
1947 msg.set_header("CamelHttpPath",
1948 serde_json::Value::String(envelope.path.clone()));
1949 msg.set_header("CamelHttpQuery",
1950 serde_json::Value::String(envelope.query.clone()));
1951
1952 for (param_name, param_value) in &envelope.path_params {
1959 msg.set_header(
1960 format!("CamelHttpPath_{param_name}"),
1961 serde_json::Value::String(param_value.clone()),
1962 );
1963 }
1964
1965 for (k, v) in &envelope.headers {
1967 if let Ok(val_str) = v.to_str() {
1968 msg.set_header(
1969 title_case_header(k.as_str()),
1970 serde_json::Value::String(val_str.to_string()),
1971 );
1972 }
1973 }
1974
1975 msg.body = Body::Stream(envelope.body);
1978
1979 #[allow(unused_mut)]
1980 let mut exchange = Exchange::new(msg);
1981
1982 #[cfg(feature = "otel")]
1984 {
1985 let headers: HashMap<String, String> = envelope
1986 .headers
1987 .iter()
1988 .filter_map(|(k, v)| {
1989 Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
1990 })
1991 .collect();
1992 camel_otel::extract_into_exchange(&mut exchange, &headers);
1993 }
1994
1995 let reply_tx = envelope.reply_tx;
1996 let sender = ctx.sender().clone();
1997 let path_clone = path.clone();
1998 let cancel = cancel_token.clone();
1999 let auth_headers = envelope.headers.clone();
2003 let auth_uri: http::Uri = {
2004 let full = if envelope.query.is_empty() {
2005 envelope.path.clone()
2006 } else {
2007 format!("{}?{}", envelope.path, envelope.query)
2008 };
2009 full.parse().unwrap_or_default()
2013 };
2014 let kernel = kernel.clone();
2015
2016 tokio::spawn(async move {
2036 if cancel.is_cancelled() {
2044 let _ = reply_tx.send(HttpReply {
2045 status: 503,
2046 headers: vec![],
2047 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
2048 });
2049 return;
2050 }
2051
2052 if let Some(kernel) = kernel.as_ref()
2061 && !matches!(
2062 kernel.plan.access_mode,
2063 camel_api::security_policy::AccessMode::Public
2064 )
2065 {
2066 let principal = match camel_auth::extract_token_multi(
2067 &auth_headers,
2068 &auth_uri,
2069 &kernel.plan.credential_sources,
2070 ) {
2071 Some(extracted) => {
2072 match camel_auth::kernel_authenticate(
2073 &kernel.plan,
2074 &kernel.providers,
2075 &extracted,
2076 )
2077 .await
2078 {
2079 Ok(principal) => principal,
2080 Err(e) => {
2081 tracing::warn!(
2083 path = %path_clone,
2084 error = %e,
2085 "HTTP request authentication failed"
2086 );
2087 let _ = reply_tx.send(pipeline_error_to_reply(
2088 e,
2089 &path_clone,
2090 ));
2091 return;
2092 }
2093 }
2094 }
2095 None => {
2096 tracing::warn!(
2098 path = %path_clone,
2099 "HTTP request rejected: no credential found in any source"
2100 );
2101 let _ = reply_tx.send(pipeline_error_to_reply(
2102 CamelError::Unauthenticated(
2103 "no credential found in any source".to_string(),
2104 ),
2105 &path_clone,
2106 ));
2107 return;
2108 }
2109 };
2110 camel_auth::install_carrier(&mut exchange, &principal);
2111 }
2112
2113 let (tx, rx) = tokio::sync::oneshot::channel();
2115 let envelope = camel_component_api::consumer::ExchangeEnvelope {
2116 exchange,
2117 reply_tx: Some(tx),
2118 in_flight_claim: claim,
2124 };
2125
2126 let result = match sender.send(envelope).await {
2127 Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
2128 Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
2129 }
2130 .and_then(|r| r);
2131
2132 let reply = match result {
2133 Ok(out) => {
2134 let status = out
2135 .input
2136 .header("CamelHttpResponseCode")
2137 .and_then(|v| {
2138 let raw = v.as_u64()
2139 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
2140 let code = raw as u16;
2141 (100..1000).contains(&code).then_some(code)
2142 })
2143 .unwrap_or(200);
2144
2145 let user_content_type = out
2146 .input
2147 .header("Content-Type")
2148 .and_then(|v| v.as_str().map(|s| s.to_string()));
2149
2150 let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
2151 Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
2152 Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
2153 Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
2154 Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
2155 v.to_string().into_bytes(),
2156 )), Some("application/json".to_string())),
2157 Body::Stream(s) => {
2158 let ct = s.metadata.content_type.clone();
2159 match s.stream.lock().await.take() {
2160 Some(stream) => (
2161 HttpReplyBody::Stream(stream),
2162 ct,
2163 ),
2164 None => {
2165 tracing::error!(
2167 "Body::Stream already consumed before HTTP reply — returning 500"
2168 );
2169 let error_reply = HttpReply {
2170 status: 500,
2171 headers: vec![],
2172 body: HttpReplyBody::Bytes(bytes::Bytes::new()),
2173 };
2174 if reply_tx.send(error_reply).is_err() {
2175 debug!("reply_tx dropped before error reply could be sent");
2176 }
2177 return;
2178 }
2179 }
2180 }
2181 _ => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
2183 };
2184
2185 let resp_headers = select_response_headers(
2186 &out.input.headers,
2187 user_content_type,
2188 inferred_content_type,
2189 );
2190
2191 HttpReply {
2192 status,
2193 headers: resp_headers,
2194 body: reply_body,
2195 }
2196 }
2197 Err(e) => {
2198 pipeline_error_to_reply(e, &path_clone)
2199 }
2200 };
2201
2202 let _ = reply_tx.send(reply);
2204 });
2205 }
2206 }
2207 }
2208
2209 if let Some(method) = &self.config.method {
2214 registry_for_cleanup
2215 .unregister_rest_endpoint(method, &path)
2216 .await;
2217 } else {
2218 registry_for_cleanup.unregister_api_route(&path).await;
2219 }
2220
2221 ServerRegistry::global()
2227 .unregister(&self.config.host, self.config.port)
2228 .await;
2229
2230 if server_died {
2231 tracing::error!(
2233 host = %camel_api::redact::redact_host(&self.config.host),
2234 port = self.config.port,
2235 path = %path,
2236 "Shared HTTP server exited — failing consumer to engage route supervision (ADR-0007)"
2237 );
2238 return Err(CamelError::RouteError(format!(
2242 "shared HTTP server for {}:{} exited unexpectedly; route transport is dead",
2243 camel_api::redact::redact_host(&self.config.host),
2244 self.config.port
2245 )));
2246 }
2247
2248 Ok(())
2249 }
2250
2251 async fn stop(&mut self) -> Result<(), CamelError> {
2252 Ok(())
2253 }
2254
2255 fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
2256 camel_component_api::ConcurrencyModel::Concurrent { max: None }
2257 }
2258
2259 fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
2266 camel_component_api::ConsumerStartupMode::Explicit
2267 }
2268
2269 fn set_security_context(&mut self, ctx: camel_component_api::SecurityContext) {
2272 self.kernel = HttpKernelAuth::from_security_context(&ctx).map(Arc::new);
2273 }
2274}
2275
2276pub struct HttpComponent {
2281 config: HttpConfig,
2282 pinned_cache: std::sync::Arc<PinnedClientCache>,
2283 client: reqwest::Client,
2284 strict_tls_error: Option<CamelError>,
2288}
2289
2290#[cfg(test)]
2291thread_local! {
2292 static BUILD_CLIENT_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
2293 static BUILD_CLIENT_FALLBACKS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
2294 static FORCE_WEBPKI_FALLBACK: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
2299 static FORCE_FALLBACK_REBUILD_FAIL: std::cell::Cell<bool> =
2307 const { std::cell::Cell::new(false) };
2308}
2309
2310fn client_builder(
2315 config: &HttpConfig,
2316 resolve_override: Option<(&str, &[std::net::SocketAddr])>,
2317) -> reqwest::ClientBuilder {
2318 let mut builder = reqwest::Client::builder()
2319 .no_proxy() .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
2321 .pool_max_idle_per_host(config.pool_max_idle_per_host)
2322 .pool_idle_timeout(Duration::from_millis(config.pool_idle_timeout_ms));
2323
2324 builder = builder.redirect(reqwest::redirect::Policy::none());
2328
2329 if let Some((host, addrs)) = resolve_override {
2330 builder = builder.resolve_to_addrs(host, addrs);
2331 }
2332
2333 if let Some(tls) = &config.tls
2334 && tls.enabled
2335 {
2336 if tls.insecure || !tls.verify_peer {
2337 tracing::warn!("HTTP TLS verification disabled — connections are vulnerable to MitM");
2339 builder = builder.danger_accept_invalid_certs(true);
2340 }
2341
2342 if let Some(ca_path) = &tls.ca_cert_path {
2343 match std::fs::read(ca_path) {
2348 Ok(ca_bytes) => {
2349 let pem_sections = rustls_pemfile::certs(&mut std::io::Cursor::new(&ca_bytes))
2356 .filter(|r| r.is_ok())
2357 .count();
2358 if pem_sections == 0 {
2359 tracing::warn!(
2361 "configured CA certificate contains no parseable PEM CERTIFICATE section — falling back to system roots"
2362 );
2363 }
2364 match reqwest::Certificate::from_pem(&ca_bytes)
2365 .or_else(|_| reqwest::Certificate::from_der(&ca_bytes))
2366 {
2367 Ok(ca_cert) => {
2368 builder = builder.add_root_certificate(ca_cert);
2369 }
2370 Err(e) => {
2371 tracing::warn!(
2373 error = %e,
2374 "configured CA certificate failed to parse — falling back to system roots"
2375 );
2376 }
2377 }
2378 }
2379 Err(e) => {
2380 tracing::warn!(
2382 error = %e,
2383 "configured CA certificate file unreadable — falling back to system roots"
2384 );
2385 }
2386 }
2387 }
2388
2389 if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path) {
2393 match (std::fs::read(cert_path), std::fs::read(key_path)) {
2394 (Ok(cert_bytes), Ok(key_bytes)) => {
2395 let mut identity_pem = cert_bytes;
2396 identity_pem.extend_from_slice(&key_bytes);
2397 match reqwest::Identity::from_pem(&identity_pem) {
2398 Ok(identity) => {
2399 builder = builder.identity(identity);
2400 }
2401 Err(e) => {
2402 tracing::warn!(
2404 error = %e,
2405 "configured mTLS identity failed to parse — client certificate NOT used"
2406 );
2407 }
2408 }
2409 }
2410 (cert_r, key_r) => {
2411 tracing::warn!(
2413 cert_ok = cert_r.is_ok(),
2414 key_ok = key_r.is_ok(),
2415 "configured mTLS cert/key file unreadable — client certificate NOT used"
2416 );
2417 }
2418 }
2419 }
2420 }
2421
2422 builder
2423}
2424
2425fn webpki_root_client_config() -> rustls::ClientConfig {
2440 let provider = rustls::crypto::CryptoProvider::get_default()
2445 .cloned()
2446 .unwrap_or_else(|| std::sync::Arc::new(rustls::crypto::aws_lc_rs::default_provider()));
2447 rustls::ClientConfig::builder_with_provider(provider)
2448 .with_safe_default_protocol_versions()
2449 .expect("stock rustls provider supports the safe default protocol versions") .with_root_certificates(mozilla_only())
2454 .with_no_client_auth()
2455 }
2460
2461fn fallback_root_store(
2475 custom_ca: Option<&str>,
2476 strict: bool,
2477) -> Result<rustls::RootCertStore, CamelError> {
2478 let mut store = rustls::RootCertStore::empty();
2485 let Some(ca_path) = custom_ca else {
2486 return Ok(mozilla_only());
2487 };
2488 let ca_bytes = match std::fs::read(ca_path) {
2489 Ok(bytes) => bytes,
2490 Err(e) => {
2491 if strict {
2492 return Err(CamelError::EndpointCreationFailed(format!(
2493 "tls.strict/webpki-fallback: configured CA certificate '{ca_path}' is unreadable: {e}"
2494 )));
2495 }
2496 tracing::warn!(
2498 error = %e,
2499 "configured CA certificate file unreadable — falling back to bundled Mozilla roots"
2500 );
2501 return Ok(mozilla_only());
2502 }
2503 };
2504 let certs: Vec<_> = rustls_pemfile::certs(&mut std::io::Cursor::new(&ca_bytes))
2505 .filter_map(|r| r.ok())
2506 .collect();
2507 if certs.is_empty() {
2508 if strict {
2509 return Err(CamelError::EndpointCreationFailed(format!(
2510 "tls.strict/webpki-fallback: configured CA certificate '{ca_path}' has no \
2511 parseable PEM CERTIFICATE section"
2512 )));
2513 }
2514 tracing::warn!(
2516 "configured CA certificate contains no parseable PEM CERTIFICATE section — \
2517 falling back to bundled Mozilla roots"
2518 );
2519 return Ok(mozilla_only());
2520 }
2521 let certs_len = certs.len();
2522 let (added, _ignored) = store.add_parsable_certificates(certs);
2523 if added == 0 {
2524 if strict {
2525 return Err(CamelError::EndpointCreationFailed(format!(
2526 "tls.strict/webpki-fallback: configured CA certificate '{ca_path}' was \
2527 rejected by the TLS root store (0 of {certs_len} accepted)"
2528 )));
2529 }
2530 tracing::warn!(
2532 "configured CA certificate was rejected by the TLS root store — \
2533 falling back to bundled Mozilla roots"
2534 );
2535 return Ok(mozilla_only());
2536 }
2537 store
2544 .roots
2545 .extend_from_slice(webpki_roots::TLS_SERVER_ROOTS);
2546 Ok(store)
2547}
2548
2549fn mozilla_only() -> rustls::RootCertStore {
2553 rustls::RootCertStore {
2554 roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
2555 }
2556}
2557
2558#[derive(Debug)]
2564struct NoVerifyServerCertVerifier;
2565
2566impl rustls::client::danger::ServerCertVerifier for NoVerifyServerCertVerifier {
2567 fn verify_server_cert(
2568 &self,
2569 _end_entity: &rustls::pki_types::CertificateDer<'_>,
2570 _intermediates: &[rustls::pki_types::CertificateDer<'_>],
2571 _server_name: &rustls::pki_types::ServerName<'_>,
2572 _ocsp_response: &[u8],
2573 _now: rustls::pki_types::UnixTime,
2574 ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
2575 Ok(rustls::client::danger::ServerCertVerified::assertion())
2577 }
2578
2579 fn verify_tls12_signature(
2580 &self,
2581 _message: &[u8],
2582 _cert: &rustls::pki_types::CertificateDer<'_>,
2583 _dss: &rustls::DigitallySignedStruct,
2584 ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
2585 Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
2586 }
2587
2588 fn verify_tls13_signature(
2589 &self,
2590 _message: &[u8],
2591 _cert: &rustls::pki_types::CertificateDer<'_>,
2592 _dss: &rustls::DigitallySignedStruct,
2593 ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
2594 Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
2595 }
2596
2597 fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
2598 vec![
2601 rustls::SignatureScheme::RSA_PKCS1_SHA1,
2602 rustls::SignatureScheme::ECDSA_SHA1_Legacy,
2603 rustls::SignatureScheme::RSA_PKCS1_SHA256,
2604 rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
2605 rustls::SignatureScheme::RSA_PKCS1_SHA384,
2606 rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
2607 rustls::SignatureScheme::RSA_PKCS1_SHA512,
2608 rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
2609 rustls::SignatureScheme::RSA_PSS_SHA256,
2610 rustls::SignatureScheme::RSA_PSS_SHA384,
2611 rustls::SignatureScheme::RSA_PSS_SHA512,
2612 rustls::SignatureScheme::ED25519,
2613 rustls::SignatureScheme::ED448,
2614 ]
2615 }
2616}
2617
2618fn fallback_client_config(
2627 tls: &crate::config::TlsConfig,
2628) -> Result<Option<rustls::ClientConfig>, CamelError> {
2629 if !tls.enabled {
2630 return Ok(None);
2631 }
2632 let material = tls.ca_cert_path.is_some()
2633 || tls.client_cert_path.is_some()
2634 || tls.client_key_path.is_some();
2635 let verification_disabled = tls.insecure || !tls.verify_peer;
2636 if !material && !verification_disabled {
2637 return Ok(None);
2638 }
2639 let provider = rustls::crypto::CryptoProvider::get_default()
2644 .cloned()
2645 .unwrap_or_else(|| std::sync::Arc::new(rustls::crypto::aws_lc_rs::default_provider()));
2646 let store = fallback_root_store(tls.ca_cert_path.as_deref(), tls.strict)?;
2650 let builder = rustls::ClientConfig::builder_with_provider(provider)
2651 .with_safe_default_protocol_versions()
2652 .expect("stock rustls provider supports the safe default protocol versions") ;
2657 let builder = if verification_disabled {
2658 builder
2659 .dangerous()
2660 .with_custom_certificate_verifier(std::sync::Arc::new(NoVerifyServerCertVerifier))
2661 } else {
2662 builder.with_root_certificates(store)
2663 };
2664 let config = match (&tls.client_cert_path, &tls.client_key_path) {
2665 (Some(cert_path), Some(key_path)) => {
2666 match (std::fs::read(cert_path), std::fs::read(key_path)) {
2667 (Ok(cert_bytes), Ok(key_bytes)) => {
2668 let chain: Vec<_> =
2669 rustls_pemfile::certs(&mut std::io::Cursor::new(&cert_bytes))
2670 .filter_map(|r| r.ok())
2671 .collect();
2672 if chain.is_empty() {
2673 if tls.strict {
2674 return Err(CamelError::EndpointCreationFailed(format!(
2675 "tls.strict/webpki-fallback: configured client certificate \
2676 chain '{cert_path}' has no parseable PEM CERTIFICATE section"
2677 )));
2678 }
2679 tracing::warn!(
2681 "configured client certificate chain has no parseable PEM \
2682 CERTIFICATE section — client certificate NOT used"
2683 );
2684 builder.with_no_client_auth()
2685 } else {
2686 match rustls_pemfile::private_key(&mut std::io::Cursor::new(&key_bytes)) {
2687 Ok(Some(key)) => {
2692 match builder.clone().with_client_auth_cert(chain, key) {
2693 Ok(config) => config,
2694 Err(e) => {
2695 if tls.strict {
2696 return Err(CamelError::EndpointCreationFailed(
2697 format!(
2698 "tls.strict/webpki-fallback: configured mTLS \
2699 identity was rejected by the TLS backend: {e}"
2700 ),
2701 ));
2702 }
2703 tracing::warn!(
2705 error = %e,
2706 "configured mTLS identity was rejected by the TLS \
2707 backend — client certificate NOT used"
2708 );
2709 builder.with_no_client_auth()
2710 }
2711 }
2712 }
2713 Ok(None) | Err(_) => {
2717 if tls.strict {
2718 return Err(CamelError::EndpointCreationFailed(format!(
2719 "tls.strict/webpki-fallback: configured client key \
2720 '{key_path}' has no parseable private key section"
2721 )));
2722 }
2723 tracing::warn!(
2725 "configured client key has no parseable private key \
2726 section — client certificate NOT used"
2727 );
2728 builder.with_no_client_auth()
2729 }
2730 }
2731 }
2732 }
2733 (cert_r, key_r) => {
2734 if tls.strict {
2735 return Err(CamelError::EndpointCreationFailed(
2736 "tls.strict/webpki-fallback: configured mTLS cert/key files are \
2737 unreadable"
2738 .to_string(),
2739 ));
2740 }
2741 tracing::warn!(
2743 cert_ok = cert_r.is_ok(),
2744 key_ok = key_r.is_ok(),
2745 "configured mTLS cert/key file unreadable — client certificate NOT used"
2746 );
2747 builder.with_no_client_auth()
2748 }
2749 }
2750 }
2751 (Some(_), None) | (None, Some(_)) => {
2754 if tls.strict {
2755 return Err(CamelError::EndpointCreationFailed(
2756 "tls.strict/webpki-fallback: mTLS requires BOTH client_cert_path and \
2757 client_key_path"
2758 .to_string(),
2759 ));
2760 }
2761 tracing::warn!(
2763 "mTLS requires BOTH client_cert_path and client_key_path — \
2764 client certificate NOT used"
2765 );
2766 builder.with_no_client_auth()
2767 }
2768 (None, None) => builder.with_no_client_auth(),
2769 };
2770 Ok(Some(config))
2771}
2772
2773fn emergency_webpki_client() -> reqwest::Client {
2779 reqwest::Client::builder()
2780 .no_proxy()
2781 .redirect(reqwest::redirect::Policy::none())
2782 .tls_backend_preconfigured(webpki_root_client_config())
2783 .build()
2784 .expect("preconfigured webpki-rooted client build has no fallible stage") }
2786
2787enum FallbackTrigger {
2792 Platform(reqwest::Error),
2793 #[cfg(test)]
2794 Forced,
2795}
2796
2797impl std::fmt::Display for FallbackTrigger {
2798 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2799 match self {
2800 FallbackTrigger::Platform(e) => std::fmt::Display::fmt(e, f),
2803 #[cfg(test)]
2804 FallbackTrigger::Forced => f.write_str("forced webpki fallback entry (test)"),
2805 }
2806 }
2807}
2808
2809fn fallback_rebuild_failed(
2815 config: &HttpConfig,
2816 failure: &dyn std::fmt::Display,
2817) -> Result<reqwest::Client, CamelError> {
2818 tracing::error!(
2820 error = %failure,
2821 "webpki fallback client build failed — TLS stack broken process-wide"
2822 );
2823 if config.tls.as_ref().is_some_and(|tls| tls.strict) {
2824 Err(CamelError::EndpointCreationFailed(format!(
2825 "tls.strict/webpki-fallback: webpki fallback client rebuild failed — \
2826 refusing material-free emergency client under tls.strict: {failure}"
2827 )))
2828 } else {
2829 Ok(emergency_webpki_client())
2830 }
2831}
2832
2833fn webpki_fallback_client(
2851 config: &HttpConfig,
2852 resolve_override: Option<(&str, &[std::net::SocketAddr])>,
2853 trigger: FallbackTrigger,
2854) -> Result<reqwest::Client, CamelError> {
2855 #[cfg(test)]
2856 BUILD_CLIENT_FALLBACKS.with(|c| c.set(c.get() + 1));
2857
2858 tracing::warn!(
2860 error = %trigger,
2861 "HTTP client build failed on platform TLS roots — the platform CA store \
2862 is missing or unreadable (typical on Android/Termux). Retrying with \
2863 bundled Mozilla root certificates; platform trust settings do not \
2864 apply to this client"
2865 );
2866
2867 let build_with_backend =
2879 |backend: rustls::ClientConfig| -> Result<reqwest::Client, CamelError> {
2880 match client_builder(config, resolve_override)
2881 .tls_backend_preconfigured(backend)
2882 .build()
2883 {
2884 Ok(client) => {
2885 #[cfg(test)]
2886 if FORCE_FALLBACK_REBUILD_FAIL.with(std::cell::Cell::get) {
2887 return fallback_rebuild_failed(
2888 config,
2889 &"forced webpki fallback rebuild failure (test)",
2890 );
2891 }
2892 Ok(client)
2893 }
2894 Err(second_error) => fallback_rebuild_failed(config, &second_error),
2895 }
2896 };
2897
2898 let material_configured = config.tls.as_ref().is_some_and(|tls| {
2903 tls.ca_cert_path.is_some()
2904 || tls.client_cert_path.is_some()
2905 || tls.client_key_path.is_some()
2906 });
2907
2908 match config.tls.as_ref().map(fallback_client_config) {
2909 None | Some(Ok(None)) => build_with_backend(webpki_root_client_config()),
2912 Some(Ok(Some(strict_or_parity_config))) => {
2913 if material_configured && config.tls.as_ref().is_some_and(|tls| tls.strict) {
2914 tracing::info!(
2916 "platform CA store unavailable — webpki fallback carries \
2917 configured TLS material"
2918 );
2919 }
2920 build_with_backend(strict_or_parity_config)
2921 }
2922 Some(Err(e)) => Err(e),
2924 }
2925}
2926
2927pub(crate) fn build_client(
2937 config: &HttpConfig,
2938 resolve_override: Option<(&str, &[std::net::SocketAddr])>,
2939) -> Result<reqwest::Client, CamelError> {
2940 #[cfg(test)]
2941 BUILD_CLIENT_CALLS.with(|c| c.set(c.get() + 1));
2942
2943 #[cfg(test)]
2950 if FORCE_WEBPKI_FALLBACK.with(std::cell::Cell::get) {
2951 return webpki_fallback_client(config, resolve_override, FallbackTrigger::Forced);
2952 }
2953
2954 match client_builder(config, resolve_override).build() {
2955 Ok(client) => Ok(client),
2956 Err(first_error) => webpki_fallback_client(
2957 config,
2958 resolve_override,
2959 FallbackTrigger::Platform(first_error),
2960 ),
2961 }
2962}
2963
2964fn strict_tls_error(config: &HttpConfig) -> Option<CamelError> {
2973 let tls = config.tls.as_ref()?;
2974 if !tls.enabled || !tls.strict {
2975 return None;
2976 }
2977 if let Some(ca_path) = &tls.ca_cert_path {
2978 match std::fs::read(ca_path) {
2979 Ok(ca_bytes) => {
2980 let pem_sections = rustls_pemfile::certs(&mut std::io::Cursor::new(&ca_bytes))
2992 .filter(|r| r.is_ok())
2993 .count();
2994 if pem_sections == 0 {
2995 return Some(CamelError::EndpointCreationFailed(format!(
2996 "tls.strict: configured CA certificate '{ca_path}' has no \
2997 parseable PEM CERTIFICATE section (DER bundles are not \
2998 enforced by the TLS backend — convert to PEM)"
2999 )));
3000 }
3001 }
3002 Err(e) => {
3003 return Some(CamelError::EndpointCreationFailed(format!(
3004 "tls.strict: configured CA certificate '{ca_path}' is unreadable: {e}"
3005 )));
3006 }
3007 }
3008 }
3009 if tls.client_cert_path.is_some() != tls.client_key_path.is_some() {
3013 return Some(CamelError::EndpointCreationFailed(
3014 "tls.strict: mTLS requires BOTH client_cert_path and client_key_path".to_string(),
3015 ));
3016 }
3017 if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path) {
3018 match (std::fs::read(cert_path), std::fs::read(key_path)) {
3019 (Ok(mut cert_bytes), Ok(key_bytes)) => {
3020 cert_bytes.extend_from_slice(&key_bytes);
3021 if reqwest::Identity::from_pem(&cert_bytes).is_err() {
3022 return Some(CamelError::EndpointCreationFailed(
3023 "tls.strict: configured mTLS identity failed to parse".to_string(),
3024 ));
3025 }
3026 }
3027 _ => {
3028 return Some(CamelError::EndpointCreationFailed(
3029 "tls.strict: configured mTLS cert/key files are unreadable".to_string(),
3030 ));
3031 }
3032 }
3033 }
3034 None
3035}
3036
3037#[cfg(test)]
3038pub(crate) fn build_client_call_count() -> u64 {
3039 BUILD_CLIENT_CALLS.with(|c| c.get())
3040}
3041
3042#[cfg(test)]
3043pub(crate) fn build_client_fallback_count() -> u64 {
3044 BUILD_CLIENT_FALLBACKS.with(|c| c.get())
3045}
3046
3047fn client_or_emergency(config: &HttpConfig) -> (reqwest::Client, Option<CamelError>) {
3052 match build_client(config, None) {
3053 Ok(c) => (c, None),
3054 Err(e) => (emergency_webpki_client(), Some(e)),
3055 }
3056}
3057
3058impl HttpComponent {
3059 pub fn new() -> Self {
3060 let config = HttpConfig::default();
3061 let strict_err = strict_tls_error(&config);
3062 let (client, build_err) = client_or_emergency(&config);
3063 Self {
3064 client,
3065 config,
3066 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
3067 PINNED_CLIENT_TTL,
3068 PINNED_CLIENT_MAX_ENTRIES,
3069 )),
3070 strict_tls_error: strict_err.or(build_err),
3071 }
3072 }
3073
3074 pub fn with_config(config: HttpConfig) -> Self {
3075 let strict_err = strict_tls_error(&config);
3076 let (client, build_err) = client_or_emergency(&config);
3077 Self {
3078 client,
3079 config,
3080 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
3081 PINNED_CLIENT_TTL,
3082 PINNED_CLIENT_MAX_ENTRIES,
3083 )),
3084 strict_tls_error: strict_err.or(build_err),
3085 }
3086 }
3087
3088 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
3089 match config {
3090 Some(cfg) => Self::with_config(cfg),
3091 None => Self::new(),
3092 }
3093 }
3094}
3095
3096impl Default for HttpComponent {
3097 fn default() -> Self {
3098 Self::new()
3099 }
3100}
3101
3102impl Component for HttpComponent {
3103 fn scheme(&self) -> &str {
3104 "http"
3105 }
3106
3107 fn metadata(&self) -> ComponentMetadata {
3108 HttpEndpointConfig::metadata()
3109 }
3110
3111 fn create_endpoint(
3112 &self,
3113 uri: &str,
3114 ctx: &dyn camel_component_api::ComponentContext,
3115 ) -> Result<Box<dyn Endpoint>, CamelError> {
3116 if let Some(err) = &self.strict_tls_error {
3117 return Err(err.clone());
3118 }
3119 self.config.validate()?;
3120 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
3121 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
3122 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
3123 server_config.host.clone(),
3124 server_config.port,
3125 )));
3126 self.pinned_cache
3127 .wire(HttpComponentKind::Http, ctx.metrics());
3128 Ok(Box::new(HttpEndpoint {
3129 uri: uri.to_string(),
3130 config,
3131 server_config,
3132 client: self.client.clone(),
3133 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
3134 http_config: self.config.clone(),
3135 }))
3136 }
3137}
3138
3139pub struct HttpsComponent {
3140 config: HttpConfig,
3141 pinned_cache: std::sync::Arc<PinnedClientCache>,
3142 client: reqwest::Client,
3143 strict_tls_error: Option<CamelError>,
3147}
3148
3149impl HttpsComponent {
3150 pub fn new() -> Self {
3151 let config = HttpConfig::default();
3152 let strict_err = strict_tls_error(&config);
3153 let (client, build_err) = client_or_emergency(&config);
3154 Self {
3155 client,
3156 config,
3157 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
3158 PINNED_CLIENT_TTL,
3159 PINNED_CLIENT_MAX_ENTRIES,
3160 )),
3161 strict_tls_error: strict_err.or(build_err),
3162 }
3163 }
3164
3165 pub fn with_config(config: HttpConfig) -> Self {
3166 let strict_err = strict_tls_error(&config);
3167 let (client, build_err) = client_or_emergency(&config);
3168 Self {
3169 client,
3170 config,
3171 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
3172 PINNED_CLIENT_TTL,
3173 PINNED_CLIENT_MAX_ENTRIES,
3174 )),
3175 strict_tls_error: strict_err.or(build_err),
3176 }
3177 }
3178
3179 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
3180 match config {
3181 Some(cfg) => Self::with_config(cfg),
3182 None => Self::new(),
3183 }
3184 }
3185}
3186
3187impl Default for HttpsComponent {
3188 fn default() -> Self {
3189 Self::new()
3190 }
3191}
3192
3193impl Component for HttpsComponent {
3194 fn scheme(&self) -> &str {
3195 "https"
3196 }
3197
3198 fn metadata(&self) -> ComponentMetadata {
3199 let mut meta = HttpEndpointConfig::metadata();
3202 meta.scheme = "https".to_string();
3203 meta.description = "HTTPS client and server component (TLS over HTTP)".to_string();
3204 meta
3205 }
3206
3207 fn create_endpoint(
3208 &self,
3209 uri: &str,
3210 ctx: &dyn camel_component_api::ComponentContext,
3211 ) -> Result<Box<dyn Endpoint>, CamelError> {
3212 if let Some(err) = &self.strict_tls_error {
3213 return Err(err.clone());
3214 }
3215 self.config.validate()?;
3216 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
3217 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
3218 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
3219 server_config.host.clone(),
3220 server_config.port,
3221 )));
3222 self.pinned_cache
3223 .wire(HttpComponentKind::Https, ctx.metrics());
3224 Ok(Box::new(HttpEndpoint {
3225 uri: uri.to_string(),
3226 config,
3227 server_config,
3228 client: self.client.clone(),
3229 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
3230 http_config: self.config.clone(),
3231 }))
3232 }
3233}
3234
3235struct HttpEndpoint {
3240 uri: String,
3241 config: HttpEndpointConfig,
3242 server_config: HttpServerConfig,
3243 client: reqwest::Client,
3244 pinned_cache: std::sync::Arc<PinnedClientCache>,
3245 http_config: HttpConfig,
3246}
3247
3248impl Endpoint for HttpEndpoint {
3249 fn uri(&self) -> &str {
3250 &self.uri
3251 }
3252
3253 fn create_consumer(
3254 &self,
3255 rt: Arc<dyn camel_component_api::RuntimeObservability>,
3256 ) -> Result<Box<dyn Consumer>, CamelError> {
3257 let scheme_is_https = self.server_config.scheme == "https";
3260 let has_tls = self.server_config.tls_config.is_some();
3261
3262 if scheme_is_https && !has_tls {
3263 return Err(CamelError::EndpointCreationFailed(
3264 "https:// consumer requires tlsCert and tlsKey parameters".to_string(),
3265 ));
3266 }
3267 if !scheme_is_https && has_tls {
3268 return Err(CamelError::EndpointCreationFailed(
3269 "http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
3270 ));
3271 }
3272 max_inflight_requests_limit(self.server_config.max_inflight_requests)?;
3277 Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
3278 }
3279
3280 fn create_producer(
3281 &self,
3282 rt: Arc<dyn camel_component_api::RuntimeObservability>,
3283 _ctx: &ProducerContext,
3284 ) -> Result<BoxProcessor, CamelError> {
3285 let producer = HttpProducer {
3286 config: Arc::new(self.config.clone()),
3287 client: self.client.clone(),
3288 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
3289 http_config: Arc::new(self.http_config.clone()),
3290 runtime: rt,
3291 };
3292 if let Some(ref provider) = self.config.token_provider {
3293 let layer = BearerTokenLayer::new(Arc::clone(provider));
3294 Ok(BoxProcessor::new(layer.layer(producer)))
3295 } else {
3296 Ok(BoxProcessor::new(producer))
3297 }
3298 }
3299}
3300
3301#[derive(Clone)]
3306struct HttpProducer {
3307 config: Arc<HttpEndpointConfig>,
3308 client: reqwest::Client,
3309 pinned_cache: std::sync::Arc<PinnedClientCache>,
3310 http_config: Arc<HttpConfig>,
3311 runtime: Arc<dyn RuntimeObservability>,
3317}
3318
3319impl HttpProducer {
3320 fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
3321 if let Some(ref method) = config.http_method {
3322 return method.to_uppercase();
3323 }
3324 if let Some(method) = exchange
3325 .input
3326 .header("CamelHttpMethod")
3327 .and_then(|v| v.as_str())
3328 {
3329 return method.to_uppercase();
3330 }
3331 if !exchange.input.body.is_empty() {
3332 return "POST".to_string();
3333 }
3334 "GET".to_string()
3335 }
3336
3337 fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> Result<String, CamelError> {
3338 if config.bridge_endpoint {
3347 let Some(query) = resolve_endpoint_query(config)? else {
3348 return Ok(config.base_url.clone());
3349 };
3350 let _: url::Url = url::Url::parse(&config.base_url).map_err(|e| {
3358 CamelError::ProcessorError(format!(
3359 "invalid base URL '{}': {e}",
3360 redact_url_for_diagnostics(&config.base_url)
3361 ))
3362 })?;
3363 let mut url = config.base_url.clone();
3364 url.push('?');
3365 url.push_str(&query);
3366 return Ok(url);
3367 }
3368
3369 if let Some(uri) = exchange
3370 .input
3371 .header("CamelHttpUri")
3372 .and_then(|v| v.as_str())
3373 {
3374 if let Some(fence) = &config.allowed_uri_hosts
3379 && !uri_host_allowed(uri, fence)?
3380 {
3381 return Err(CamelError::ProcessorError(format!(
3382 "CamelHttpUri host not allowed by allowedUriHosts fence: {}",
3383 redact_url_for_diagnostics(uri)
3384 )));
3385 }
3386 let (base, override_query) = match uri.split_once('?') {
3393 Some((base, query)) => (base, Some(query)),
3394 None => (uri, None),
3395 };
3396 if let Some(query) = override_query {
3402 for (_key, span) in raw_query_pairs(query)? {
3403 validate_raw_query_span(span)?;
3404 }
3405 }
3406 let mut url = base.to_string();
3407 if let Some(path) = exchange
3408 .input
3409 .header("CamelHttpPath")
3410 .and_then(|v| v.as_str())
3411 {
3412 if !url.ends_with('/') && !path.starts_with('/') {
3413 url.push('/');
3414 }
3415 url.push_str(path);
3416 }
3417 if let Some(query) = exchange
3418 .input
3419 .header("CamelHttpQuery")
3420 .and_then(|v| v.as_str())
3421 {
3422 if let Some(merged) = merge_header_query(override_query, query)? {
3423 url.push('?');
3424 url.push_str(&merged);
3425 }
3426 return Ok(url);
3427 }
3428 if let Some(query) = override_query {
3429 url.push('?');
3430 url.push_str(query);
3431 }
3432 return Ok(url);
3433 }
3434
3435 let mut url = config.base_url.clone();
3436
3437 if let Some(path) = exchange
3438 .input
3439 .header("CamelHttpPath")
3440 .and_then(|v| v.as_str())
3441 {
3442 if !url.ends_with('/') && !path.starts_with('/') {
3443 url.push('/');
3444 }
3445 url.push_str(path);
3446 }
3447
3448 if let Some(query) = exchange
3449 .input
3450 .header("CamelHttpQuery")
3451 .and_then(|v| v.as_str())
3452 {
3453 if let Some(merged) =
3458 merge_header_query(resolve_endpoint_query(config)?.as_deref(), query)?
3459 {
3460 url.push('?');
3461 url.push_str(&merged);
3462 }
3463 return Ok(url);
3464 }
3465
3466 if let Some(query) = resolve_endpoint_query(config)? {
3467 url.push('?');
3468 url.push_str(&query);
3469 }
3470
3471 Ok(url)
3472 }
3473
3474 fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
3475 status >= range.0 && status <= range.1
3476 }
3477}
3478
3479#[derive(Clone, Debug, PartialEq, Eq)]
3484pub struct AllowedUriHost {
3485 pub host: String,
3487 pub port: Option<u16>,
3489}
3490
3491fn parse_allowed_uri_hosts(raw: &str) -> Result<Vec<AllowedUriHost>, CamelError> {
3499 let mut entries = Vec::new();
3500 for segment in raw.split(',') {
3501 let segment = segment.trim();
3502 if segment.is_empty() {
3503 continue;
3504 }
3505 let parsed = url::Url::parse(&format!("http://{segment}"))
3506 .map_err(|_| invalid_allowed_uri_host_entry(segment))?;
3507 if parsed.path() != "/" || !parsed.username().is_empty() || parsed.password().is_some() {
3511 return Err(invalid_allowed_uri_host_entry(segment));
3512 }
3513 let Some(host) = parsed.host_str() else {
3514 return Err(invalid_allowed_uri_host_entry(segment));
3515 };
3516 entries.push(AllowedUriHost {
3517 host: host.to_string(),
3518 port: parsed.port(),
3519 });
3520 }
3521 if entries.is_empty() {
3522 return Err(CamelError::InvalidUri(
3523 "allowedUriHosts declares no valid host entries".to_string(),
3524 ));
3525 }
3526 Ok(entries)
3527}
3528
3529fn invalid_allowed_uri_host_entry(segment: &str) -> CamelError {
3530 CamelError::InvalidUri(format!("invalid allowedUriHosts entry '{segment}'"))
3531}
3532
3533pub(crate) fn uri_host_allowed(
3540 url_str: &str,
3541 fence: &[AllowedUriHost],
3542) -> Result<bool, CamelError> {
3543 let Ok(parsed) = url::Url::parse(url_str) else {
3544 return Ok(false);
3545 };
3546 let Some(host) = parsed.host_str() else {
3547 return Ok(false);
3548 };
3549 let effective_port = parsed.port().or(match parsed.scheme() {
3550 "https" => Some(443_u16),
3551 "http" => Some(80),
3552 _ => None,
3553 });
3554 Ok(fence.iter().any(|entry| {
3555 entry.host == host
3556 && match entry.port {
3557 None => true,
3558 Some(port) => effective_port == Some(port),
3559 }
3560 }))
3561}
3562
3563fn resolve_endpoint_query(config: &HttpEndpointConfig) -> Result<Option<String>, CamelError> {
3576 let mut parts: Vec<String> = Vec::new();
3577 let mut authored_keys = std::collections::HashSet::new();
3578
3579 if let Some(raw) = config.raw_query.as_deref() {
3580 for (key, span) in raw_query_pairs(raw)? {
3581 authored_keys.insert(key.clone());
3582 if is_consumed_option(&key) {
3583 continue;
3584 }
3585 validate_raw_query_span(span)?;
3586 parts.push(span.to_string());
3587 }
3588 }
3589
3590 for (key, value) in &config.query_params {
3591 if !authored_keys.contains(key.as_str()) {
3592 parts.push(format!(
3593 "{}={}",
3594 encode_query_component(key),
3595 encode_query_component(value)
3596 ));
3597 }
3598 }
3599
3600 if parts.is_empty() && config.raw_query.as_deref() != Some("") {
3601 return Ok(None);
3602 }
3603 Ok(Some(parts.join("&")))
3604}
3605
3606fn merge_header_query(
3615 higher_precedence: Option<&str>,
3616 header_query: &str,
3617) -> Result<Option<String>, CamelError> {
3618 if header_query.is_empty() {
3619 return Ok(higher_precedence.map(str::to_string));
3620 }
3621 let mut parts: Vec<String> = Vec::new();
3622 let mut higher_keys = std::collections::HashSet::new();
3623 for (key, span) in raw_query_pairs(higher_precedence.unwrap_or(""))? {
3624 higher_keys.insert(key);
3625 parts.push(span.to_string());
3626 }
3627 for (key, span) in raw_query_pairs(header_query)? {
3628 validate_raw_query_span(span)?;
3629 if !higher_keys.contains(key.as_str()) {
3630 parts.push(span.to_string());
3631 }
3632 }
3633 if parts.is_empty() {
3634 return Ok(None);
3635 }
3636 Ok(Some(parts.join("&")))
3637}
3638
3639fn is_legal_query_byte(byte: u8) -> bool {
3650 matches!(byte,
3651 b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z'
3652 | b'-' | b'.' | b'_' | b'~'
3653 | b'!' | b'$' | b'&' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'='
3654 | b':' | b'@' | b'/' | b'?'
3655 | b'%')
3656}
3657
3658fn validate_raw_query_span(span: &str) -> Result<(), CamelError> {
3664 for &byte in span.as_bytes() {
3665 if !is_legal_query_byte(byte) {
3666 return Err(CamelError::ProcessorError(format!(
3667 "raw query pair '{span}' contains byte 0x{byte:02X}, which is not legal in a URL query component"
3668 )));
3669 }
3670 }
3671 Ok(())
3672}
3673
3674fn encode_query_component(component: &str) -> String {
3678 const HEX: &[u8; 16] = b"0123456789ABCDEF";
3679 let mut out = String::with_capacity(component.len());
3680 for &byte in component.as_bytes() {
3681 match byte {
3682 b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z' | b'-' | b'.' | b'_' | b'~' => {
3683 out.push(byte as char);
3684 }
3685 _ => {
3686 out.push('%');
3687 out.push(HEX[(byte >> 4) as usize] as char);
3688 out.push(HEX[(byte & 0x0f) as usize] as char);
3689 }
3690 }
3691 }
3692 out
3693}
3694
3695pub(crate) fn redact_url_for_diagnostics(raw: &str) -> String {
3723 match url::Url::parse(raw) {
3724 Ok(mut u) => {
3725 if !u.cannot_be_a_base()
3732 && u.host_str().is_none()
3733 && camel_api::redact::window_has_at_sign(raw)
3734 {
3735 return "[redacted]".to_string();
3736 }
3737 if !u.username().is_empty() || u.password().is_some() {
3738 let _ = u.set_username("***");
3739 let _ = u.set_password(None);
3740 }
3741 let s = u.to_string();
3744 camel_api::redact::redact_url(&s)
3745 }
3746 Err(_) => camel_api::redact::redact_url_fail_closed(raw),
3747 }
3748}
3749
3750const MAX_ERROR_RESPONSE_BODY_BYTES: usize = 4096;
3755
3756fn truncate_error_body(body: &[u8]) -> String {
3757 if body.len() <= MAX_ERROR_RESPONSE_BODY_BYTES {
3758 String::from_utf8_lossy(body).into_owned()
3759 } else {
3760 let mut s = String::from_utf8_lossy(&body[..MAX_ERROR_RESPONSE_BODY_BYTES]).into_owned();
3761 s.push_str("...[truncated]");
3762 s
3763 }
3764}
3765
3766impl HttpProducer {
3767 fn is_entity_enclosing(method: &str) -> bool {
3772 matches!(method, "POST" | "PUT" | "PATCH")
3773 }
3774}
3775
3776impl Service<Exchange> for HttpProducer {
3777 type Response = Exchange;
3778 type Error = CamelError;
3779 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
3780
3781 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
3782 Poll::Ready(Ok(()))
3783 }
3784
3785 fn call(&mut self, exchange: Exchange) -> Self::Future {
3786 let config = self.config.clone();
3787 let shared_client = self.client.clone();
3788 let pinned_cache = std::sync::Arc::clone(&self.pinned_cache);
3789 let http_config = self.http_config.clone();
3790 let component_metrics = self.runtime.component_metrics();
3791
3792 Box::pin(async move {
3793 let mut exchange = exchange;
3794 let outcome = async {
3795 let method_str = HttpProducer::resolve_method(&exchange, &config);
3796 let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
3801 let url = HttpProducer::resolve_url(&exchange, &config)?;
3802
3803 ssrf::validate_url_for_ssrf(&url, &config)?;
3805
3806 let resolved = ssrf::resolve_initial_url_for_ssrf(
3814 &url,
3815 config.allow_internal,
3816 config.allow_cleartext,
3817 )
3818 .await?;
3819 let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
3820 pinned_cache
3824 .get_or_build(host.as_str(), addrs, || {
3825 build_client(&http_config, Some((host.as_str(), addrs)))
3826 })
3827 .await?
3828 } else {
3829 shared_client.clone()
3830 };
3831
3832 debug!(
3833 correlation_id = %exchange.correlation_id(),
3834 method = %method_str,
3835 url = %redact_url_for_diagnostics(&url),
3836 "HTTP request"
3837 );
3838
3839 let method = method_str.parse::<reqwest::Method>().map_err(|e| {
3840 CamelError::ProcessorError(format!(
3841 "Invalid HTTP method '{}': {}",
3842 method_str, e
3843 ))
3844 })?;
3845
3846 let mut collected_headers: Vec<(
3848 reqwest::header::HeaderName,
3849 reqwest::header::HeaderValue,
3850 )> = Vec::new();
3851
3852 if let Some(user_agent) = &config.user_agent
3853 && !config.bridge_endpoint
3854 {
3855 match constructed_header("user-agent", user_agent) {
3856 Ok((_, val)) => {
3857 collected_headers.push((reqwest::header::USER_AGENT, val));
3858 }
3859 Err(drop) => debug!(
3860 correlation_id = %exchange.correlation_id(),
3861 header = %drop.name,
3862 "outbound header dropped: {}",
3863 drop.reason
3864 ),
3865 }
3866 }
3867
3868 #[cfg(feature = "otel")]
3870 let should_inject_otel = !config.bridge_endpoint;
3871 #[cfg(feature = "otel")]
3872 if should_inject_otel {
3873 let mut otel_headers = HashMap::new();
3874 camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
3875 for (k, v) in otel_headers {
3876 match constructed_header(&k, &v) {
3877 Ok((name, val)) => collected_headers.push((name, val)),
3878 Err(drop) => debug!(
3879 correlation_id = %exchange.correlation_id(),
3880 header = %drop.name,
3881 "outbound header dropped: {}",
3882 drop.reason
3883 ),
3884 }
3885 }
3886 }
3887
3888 let conn_tokens = header_policy::connection_tokens(
3889 exchange
3890 .input
3891 .headers
3892 .iter()
3893 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3894 .filter_map(|(_, v)| v.as_str()),
3895 );
3896
3897 let outbound = select_outbound_headers(
3898 &exchange.input.headers,
3899 &config.skip_request_headers,
3900 &conn_tokens,
3901 );
3902 for drop in &outbound.drops {
3903 if let Some(value_kind) = drop.value_kind {
3904 debug!(
3905 correlation_id = %exchange.correlation_id(),
3906 header = %drop.name,
3907 value_kind = value_kind,
3908 "outbound header dropped: {}",
3909 drop.reason
3910 );
3911 } else {
3912 debug!(
3913 correlation_id = %exchange.correlation_id(),
3914 header = %drop.name,
3915 "outbound header dropped: {}",
3916 drop.reason
3917 );
3918 }
3919 }
3920 collected_headers.extend(outbound.accepted);
3921
3922 if !config.bridge_endpoint {
3924 match &config.auth {
3925 HttpAuth::None => {}
3926 HttpAuth::Basic { username, password } => {
3927 use base64::Engine;
3928 let credentials = format!("{username}:{password}");
3930 let encoded =
3931 base64::engine::general_purpose::STANDARD.encode(credentials);
3932 match constructed_header("authorization", &format!("Basic {encoded}")) {
3935 Ok((_, val)) => {
3936 collected_headers.push((reqwest::header::AUTHORIZATION, val));
3937 }
3938 Err(drop) => debug!(
3939 correlation_id = %exchange.correlation_id(),
3940 header = %drop.name,
3941 "outbound header dropped: {}",
3942 drop.reason
3943 ),
3944 }
3945 }
3946 HttpAuth::Bearer { token } => {
3947 let bearer = format!("Bearer {token}");
3949 match constructed_header("authorization", &bearer) {
3950 Ok((_, val)) => {
3951 collected_headers.push((reqwest::header::AUTHORIZATION, val));
3952 }
3953 Err(drop) => debug!(
3954 correlation_id = %exchange.correlation_id(),
3955 header = %drop.name,
3956 "outbound header dropped: {}",
3957 drop.reason
3958 ),
3959 }
3960 }
3961 }
3962
3963 if config.connection_close {
3964 collected_headers.push((
3965 reqwest::header::CONNECTION,
3966 reqwest::header::HeaderValue::from_static("close"),
3967 ));
3968 }
3969 }
3970
3971 let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
3973 let materialized_body: Option<Vec<u8>> = if is_stream_body {
3974 if suppress_body {
3975 std::mem::take(&mut exchange.input.body);
3983 tracing::warn!(
3985 correlation_id = %exchange.correlation_id(),
3986 method = %method_str,
3987 "dropping request body for non-entity-enclosing HTTP method"
3988 );
3989 }
3990 None } else {
3992 let body = std::mem::take(&mut exchange.input.body);
3993 let bytes = body.into_bytes(config.max_body_size).await?;
3994 if bytes.is_empty() {
3995 None
3997 } else if suppress_body {
3998 tracing::warn!(
4000 correlation_id = %exchange.correlation_id(),
4001 method = %method_str,
4002 "dropping request body for non-entity-enclosing HTTP method"
4003 );
4004 None
4005 } else {
4006 Some(bytes.to_vec())
4007 }
4008 };
4009
4010 let response = if config.follow_redirects && !is_stream_body {
4011 ssrf::send_with_ssrf_safe_redirects(
4017 &client,
4018 &shared_client,
4019 &pinned_cache,
4020 &http_config,
4021 &config,
4022 method,
4023 &url,
4024 collected_headers,
4025 materialized_body,
4026 config.max_redirects,
4027 config.response_timeout,
4028 )
4029 .await?
4030 } else {
4031 let mut request = client.request(method, &url);
4033
4034 if let Some(timeout) = config.response_timeout {
4035 request = request.timeout(timeout);
4036 }
4037
4038 for (name, value) in &collected_headers {
4039 request = request.header(name, value);
4040 }
4041
4042 if is_stream_body {
4043 if let Body::Stream(ref s) = exchange.input.body {
4044 let mut stream_lock = s.stream.lock().await;
4045 if let Some(stream) = stream_lock.take() {
4046 request = request.body(reqwest::Body::wrap_stream(stream));
4047 } else {
4048 return Err(CamelError::AlreadyConsumed);
4049 }
4050 }
4051 } else if let Some(ref body_bytes) = materialized_body {
4052 request = request.body(body_bytes.clone());
4053 }
4054
4055 request.send().await.map_err(|e| {
4056 CamelError::ProcessorError(format!("HTTP request failed: {e}"))
4057 })?
4058 };
4059
4060 let status_code = response.status().as_u16();
4061 let status_text = response
4062 .status()
4063 .canonical_reason()
4064 .unwrap_or("Unknown")
4065 .to_string();
4066
4067 for (key, value) in response.headers() {
4068 if config
4069 .skip_response_headers
4070 .iter()
4071 .any(|h| h.eq_ignore_ascii_case(key.as_str()))
4072 {
4073 continue;
4074 }
4075 if let Ok(val_str) = value.to_str() {
4076 exchange.input.set_header(
4077 title_case_header(key.as_str()),
4078 serde_json::Value::String(val_str.to_string()),
4079 );
4080 }
4081 }
4082
4083 exchange.input.set_header(
4084 "CamelHttpResponseCode",
4085 serde_json::Value::Number(status_code.into()),
4086 );
4087 exchange.input.set_header(
4088 "CamelHttpResponseText",
4089 serde_json::Value::String(status_text.clone()),
4090 );
4091
4092 let read_timeout = Duration::from_millis(config.read_timeout_ms);
4094 let response_body = tokio::time::timeout(read_timeout, async {
4095 if let Some(content_len) = response.content_length()
4097 && content_len > config.max_response_bytes as u64
4098 {
4099 return Err(CamelError::ProcessorError(format!(
4100 "Response body too large: {} bytes exceeds limit of {} bytes",
4101 content_len, config.max_response_bytes
4102 )));
4103 }
4104 use futures::TryStreamExt;
4106 let mut stream = response.bytes_stream();
4107 let mut total: usize = 0;
4108 let mut collected = Vec::new();
4109 while let Some(chunk) = stream.try_next().await.map_err(|e| {
4110 CamelError::ProcessorError(format!("Failed to read response body: {e}"))
4111 })? {
4112 total += chunk.len();
4113 if total > config.max_response_bytes {
4114 return Err(CamelError::ProcessorError(format!(
4115 "Response body too large: {} bytes exceeds limit of {} bytes",
4116 total, config.max_response_bytes
4117 )));
4118 }
4119 collected.push(chunk);
4120 }
4121 let mut result = bytes::BytesMut::with_capacity(total);
4122 for chunk in collected {
4123 result.extend_from_slice(&chunk);
4124 }
4125 Ok::<bytes::Bytes, CamelError>(result.freeze())
4126 })
4127 .await
4128 .map_err(|_| {
4129 CamelError::ProcessorError(format!(
4130 "Read timeout after {}ms",
4131 config.read_timeout_ms
4132 ))
4133 })??;
4134
4135 if config.throw_exception_on_failure
4136 && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
4137 {
4138 return Err(CamelError::HttpOperationFailed {
4139 method: method_str,
4140 url: redact_url_for_diagnostics(&url),
4143 status_code,
4144 status_text,
4145 response_body: Some(truncate_error_body(&response_body)),
4146 });
4147 }
4148
4149 if !response_body.is_empty() {
4150 exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
4151 }
4152
4153 debug!(
4154 correlation_id = %exchange.correlation_id(),
4155 status = status_code,
4156 url = %redact_url_for_diagnostics(&url),
4157 "HTTP response"
4158 );
4159 Ok(exchange)
4160 }
4161 .await;
4162 component_metrics.observe("http", "request", outcome.is_err());
4169 outcome
4170 })
4171 }
4172}
4173
4174#[cfg(test)]
4188pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
4189
4190#[cfg(test)]
4198pub(crate) fn lock_registry_test_mutex() -> std::sync::MutexGuard<'static, ()> {
4199 REGISTRY_TEST_MUTEX
4200 .lock()
4201 .unwrap_or_else(|poisoned| poisoned.into_inner())
4202}
4203
4204#[cfg(test)]
4208static CA_STORE_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
4209
4210#[cfg(test)]
4211pub(crate) fn lock_ca_store_test_mutex() -> std::sync::MutexGuard<'static, ()> {
4212 CA_STORE_TEST_MUTEX
4213 .lock()
4214 .unwrap_or_else(|poisoned| poisoned.into_inner())
4215}
4216
4217fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
4227 match e {
4228 CamelError::Unauthenticated(msg) => {
4229 tracing::warn!(error = %msg, path = %path, "Authentication failed");
4230 HttpReply {
4231 status: 401,
4232 headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
4233 body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
4234 }
4235 }
4236 CamelError::Unauthorized(msg) => {
4237 tracing::warn!(error = %msg, path = %path, "Authorization failed");
4238 HttpReply {
4239 status: 403,
4240 headers: vec![],
4241 body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
4242 }
4243 }
4244 CamelError::TypeConversionFailed(msg) => {
4245 tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
4246 json_error_reply(400, "bad_request", msg)
4247 }
4248 CamelError::ValidationError(msg) => {
4249 tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
4250 json_error_reply(400, "validation_error", msg)
4251 }
4252 CamelError::ConsumerStopping => {
4253 tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
4254 HttpReply {
4255 status: 503,
4256 headers: vec![],
4257 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
4258 }
4259 }
4260 CamelError::UnsupportedMediaType { consumed, declared } => {
4261 tracing::warn!(error = %consumed, declared = %declared, path = %path, "Unsupported media type (bad request)");
4262 json_error_reply(
4263 415,
4264 "unsupported_media_type",
4265 format!("consumed {consumed}, declared {declared}"),
4266 )
4267 }
4268 CamelError::NotAcceptable { accept, produced } => {
4269 tracing::warn!(error = %accept, produced = %produced, path = %path, "Not acceptable (bad request)");
4270 json_error_reply(
4271 406,
4272 "not_acceptable",
4273 format!("accept {accept}, produced {produced}"),
4274 )
4275 }
4276 e => {
4277 tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
4279 HttpReply {
4280 status: 500,
4281 headers: vec![],
4282 body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
4283 }
4284 }
4285 }
4286}
4287
4288fn json_error_reply(status: u16, code: &str, message: String) -> HttpReply {
4296 let body = serde_json::to_string(&serde_json::json!({
4297 "error": code,
4298 "message": message,
4299 }))
4300 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
4302 status,
4303 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
4304 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
4305 }
4306}
4307
4308const fn json_value_kind(v: &serde_json::Value) -> &'static str {
4312 match v {
4313 serde_json::Value::Null => "null",
4314 serde_json::Value::Bool(_) => "bool",
4315 serde_json::Value::Number(_) => "number",
4316 serde_json::Value::String(_) => "string",
4317 serde_json::Value::Array(_) => "array",
4318 serde_json::Value::Object(_) => "object",
4319 }
4320}
4321
4322fn scalar_string_form(v: &serde_json::Value) -> Option<String> {
4327 match v {
4328 serde_json::Value::String(s) => Some(s.clone()),
4329 serde_json::Value::Number(n) => Some(n.to_string()),
4330 serde_json::Value::Bool(b) => Some(b.to_string()),
4331 _ => None,
4332 }
4333}
4334
4335fn select_response_headers(
4350 headers: &HashMap<String, serde_json::Value>,
4351 user_content_type: Option<String>,
4352 inferred_content_type: Option<String>,
4353) -> Vec<(String, String)> {
4354 let conn_tokens = header_policy::connection_tokens(
4355 headers
4356 .iter()
4357 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
4358 .filter_map(|(_, v)| v.as_str()),
4359 );
4360 let mut selected: Vec<(String, String)> = Vec::new();
4361 for (k, v) in headers {
4362 if k.starts_with("Camel") {
4363 debug!(header = %k, "reply header dropped: Camel namespace");
4364 continue;
4365 }
4366 if header_policy::excluded_response(k, &conn_tokens) {
4367 debug!(header = %k, "reply header dropped: emission policy");
4368 continue;
4369 }
4370 match scalar_string_form(v) {
4371 Some(s) => selected.push((k.clone(), s)),
4372 None => debug!(
4373 header = %k,
4374 value_kind = json_value_kind(v),
4375 "reply header dropped: no scalar string form"
4376 ),
4377 }
4378 }
4379 if let Some(ct) = user_content_type.or(inferred_content_type) {
4380 selected.push(("Content-Type".to_string(), ct));
4381 }
4382 selected
4383}
4384
4385#[derive(Debug)]
4390struct OutboundHeaderDrop<'a> {
4391 name: &'a str,
4392 reason: &'static str,
4393 value_kind: Option<&'static str>,
4394}
4395
4396struct OutboundHeaderSelection<'a> {
4399 accepted: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>,
4400 drops: Vec<OutboundHeaderDrop<'a>>,
4401}
4402
4403fn select_outbound_headers<'a>(
4417 headers: &'a HashMap<String, serde_json::Value>,
4418 skip_request_headers: &[String],
4419 conn_tokens: &[String],
4420) -> OutboundHeaderSelection<'a> {
4421 let mut accepted = Vec::new();
4422 let mut drops = Vec::new();
4423 for (key, value) in headers {
4424 if key.starts_with("Camel") {
4425 drops.push(OutboundHeaderDrop {
4426 name: key,
4427 reason: "Camel namespace",
4428 value_kind: None,
4429 });
4430 continue;
4431 }
4432 if skip_request_headers
4433 .iter()
4434 .any(|h| h.eq_ignore_ascii_case(key))
4435 {
4436 drops.push(OutboundHeaderDrop {
4437 name: key,
4438 reason: "skip_request_headers",
4439 value_kind: None,
4440 });
4441 continue;
4442 }
4443 if header_policy::excluded_outbound(key, conn_tokens) {
4444 drops.push(OutboundHeaderDrop {
4445 name: key,
4446 reason: "outbound emission policy",
4447 value_kind: None,
4448 });
4449 continue;
4450 }
4451 let Some(val_str) = scalar_string_form(value) else {
4452 drops.push(OutboundHeaderDrop {
4453 name: key,
4454 reason: "no scalar string form",
4455 value_kind: Some(json_value_kind(value)),
4456 });
4457 continue;
4458 };
4459 match constructed_header(key, &val_str) {
4460 Ok((name, val)) => accepted.push((name, val)),
4461 Err(drop) => drops.push(drop),
4462 }
4463 }
4464 OutboundHeaderSelection { accepted, drops }
4465}
4466
4467fn constructed_header<'a>(
4472 name: &'a str,
4473 value: &str,
4474) -> Result<(reqwest::header::HeaderName, reqwest::header::HeaderValue), OutboundHeaderDrop<'a>> {
4475 let header_name = match reqwest::header::HeaderName::from_bytes(name.as_bytes()) {
4476 Ok(header_name) => header_name,
4477 Err(_) => {
4478 return Err(OutboundHeaderDrop {
4479 name,
4480 reason: "invalid header name",
4481 value_kind: None,
4482 });
4483 }
4484 };
4485 let header_value = match reqwest::header::HeaderValue::from_str(value) {
4486 Ok(header_value) => header_value,
4487 Err(_) => {
4488 return Err(OutboundHeaderDrop {
4489 name,
4490 reason: "invalid header value",
4491 value_kind: None,
4492 });
4493 }
4494 };
4495 Ok((header_name, header_value))
4496}
4497
4498#[cfg(test)]
4499mod tests {
4500 use camel_component_api::test_support::NoopRuntimeObservability;
4501
4502 #[test]
4503 fn test_metadata_authmethod_enum_covers_runtime_vocab() {
4504 use camel_api::component_metadata::OptionKind;
4510 let meta = super::HttpEndpointConfig::metadata();
4511 let auth = meta
4512 .uri_options
4513 .iter()
4514 .find(|o| o.name == "authMethod")
4515 .expect("authMethod option must exist");
4516 let OptionKind::Enum(variants) = &auth.kind else {
4517 panic!("authMethod kind must be Enum; got {:?}", auth.kind);
4518 };
4519 for value in ["None", "Basic", "Bearer"] {
4520 assert!(
4521 variants.iter().any(|v| v.eq_ignore_ascii_case(value)),
4522 "metadata authMethod variants must cover runtime-accepted `{value}`; got {variants:?}"
4523 );
4524 }
4525 }
4526
4527 fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
4531 std::sync::Arc::new(NoopRuntimeObservability)
4532 }
4533 fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
4534 std::sync::Arc::new(NoopRuntimeObservability)
4535 }
4536 fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
4537 std::sync::Arc::new(NoopRuntimeObservability)
4538 }
4539
4540 use super::*;
4541 use crate::config::TlsConfig;
4542 use crate::rest_match::PathSegment;
4543 use crate::tls_harness::*;
4544 use camel_component_api::{Message, NoOpComponentContext};
4545 use std::sync::Arc;
4546 use std::time::Duration;
4547
4548 fn test_producer_ctx() -> ProducerContext {
4549 ProducerContext::new()
4550 }
4551
4552 #[test]
4563 fn canonical_redact_host_pinned() {
4564 assert_eq!(
4565 camel_api::redact::redact_host("host.example:8080"),
4566 "host.example:8080"
4567 );
4568 assert_eq!(camel_api::redact::redact_host("a@b@c"), "***@c");
4569 }
4570
4571 #[test]
4572 fn redact_url_drops_oauth2_fragment_access_token() {
4573 let redacted =
4574 redact_url_for_diagnostics("https://app.example/cb#access_token=SECRET&state=x");
4575 assert!(
4576 !redacted.contains("SECRET"),
4577 "fragment access token leaked: {redacted}"
4578 );
4579 assert!(
4580 !redacted.contains("access_token"),
4581 "fragment key leaked: {redacted}"
4582 );
4583 assert!(
4584 redacted.ends_with("#[redacted]"),
4585 "fragment must be replaced with the sentinel: {redacted}"
4586 );
4587 }
4588
4589 #[test]
4590 fn redact_url_drops_oauth2_fragment_id_token() {
4591 let redacted =
4592 redact_url_for_diagnostics("https://app.example/cb#id_token=eyJhbG.SECRET.SIG&state=y");
4593 assert!(
4594 !redacted.contains("eyJhbG"),
4595 "id token payload leaked: {redacted}"
4596 );
4597 assert!(
4598 !redacted.contains("id_token"),
4599 "id token key leaked: {redacted}"
4600 );
4601 assert!(
4602 !redacted.contains("SECRET"),
4603 "id token signature leaked: {redacted}"
4604 );
4605 assert!(
4606 redacted.ends_with("#[redacted]"),
4607 "fragment must be replaced with the sentinel: {redacted}"
4608 );
4609 }
4610
4611 #[test]
4612 fn redact_url_drops_generic_fragment_kv() {
4613 let redacted = redact_url_for_diagnostics("https://h.example/p/session#session=abc123");
4614 assert!(
4615 !redacted.contains("abc123"),
4616 "fragment value leaked: {redacted}"
4617 );
4618 assert!(
4619 !redacted.contains("session="),
4620 "fragment key leaked: {redacted}"
4621 );
4622 assert!(
4623 redacted.contains("#[redacted]"),
4624 "fragment must be replaced with the sentinel: {redacted}"
4625 );
4626 }
4627
4628 #[test]
4629 fn redact_url_query_and_fragment_sentinels_compose() {
4630 let redacted = redact_url_for_diagnostics("https://h.example/p?a=1#access_token=x");
4631 assert_eq!(
4632 redacted, "https://h.example/p?[redacted]#[redacted]",
4633 "query and fragment sentinels must compose: {redacted}"
4634 );
4635 }
4636
4637 #[test]
4638 fn redact_url_drops_benign_fragment_too() {
4639 let redacted = redact_url_for_diagnostics("https://h.example/docs#section-3");
4642 assert_eq!(
4643 redacted, "https://h.example/docs#[redacted]",
4644 "benign fragment must still be dropped: {redacted}"
4645 );
4646 }
4647
4648 #[test]
4649 fn redact_url_unparseable_fragment_credentials_dropped() {
4650 let raw = "ht tps://app.example/cb#access_token=SECRET";
4651 assert!(
4652 url::Url::parse(raw).is_err(),
4653 "fixture must be unparseable: {raw}"
4654 );
4655 let redacted = redact_url_for_diagnostics(raw);
4656 assert!(
4657 !redacted.contains("SECRET"),
4658 "unparseable fragment token leaked: {redacted}"
4659 );
4660 assert!(
4661 !redacted.contains("access_token"),
4662 "unparseable fragment bytes leaked: {redacted}"
4663 );
4664 assert!(
4665 redacted.contains("#[redacted]"),
4666 "unparseable fragment must end in the sentinel: {redacted}"
4667 );
4668 }
4669
4670 #[test]
4671 fn redact_url_double_slash_evader_sentinel() {
4672 let redacted = redact_url_for_diagnostics("scheme:////user:pass@evil/");
4675 assert_eq!(
4676 redacted, "[redacted]",
4677 "double-slash evader must fail closed: {redacted}"
4678 );
4679 }
4680
4681 #[test]
4682 fn redact_url_triple_slash_evader_sentinel() {
4683 let redacted = redact_url_for_diagnostics("scheme:///user:pass@evil/");
4684 assert_eq!(
4685 redacted, "[redacted]",
4686 "triple-slash evader must fail closed: {redacted}"
4687 );
4688 }
4689
4690 #[test]
4691 fn redact_url_bare_protocol_relative_userinfo_sentinel() {
4692 let redacted = redact_url_for_diagnostics("//user:pass@evil");
4693 assert_eq!(
4694 redacted, "[redacted]",
4695 "protocol-relative userinfo must fail closed: {redacted}"
4696 );
4697 }
4698
4699 #[test]
4700 fn redact_url_empty_host_userinfo_sentinel() {
4701 let redacted = redact_url_for_diagnostics("scheme://user@");
4704 assert_eq!(
4705 redacted, "[redacted]",
4706 "empty-host userinfo must fail closed: {redacted}"
4707 );
4708 }
4709
4710 #[test]
4711 fn redact_url_unparseable_slash_run_evader_sentinel() {
4712 let raw = "schem e:////user:pass@evil/";
4716 assert!(
4717 url::Url::parse(raw).is_err(),
4718 "fixture must be unparseable: {raw}"
4719 );
4720 let redacted = redact_url_for_diagnostics(raw);
4721 assert_eq!(
4722 redacted, "[redacted]",
4723 "unparseable slash-run evader must fail closed: {redacted}"
4724 );
4725 }
4726
4727 #[test]
4728 fn redact_url_unparseable_later_window_userinfo_sentinel() {
4729 let raw = "http://ho st/a//user:pass@evil/";
4733 assert!(
4734 url::Url::parse(raw).is_err(),
4735 "fixture must be unparseable: {raw}"
4736 );
4737 let redacted = redact_url_for_diagnostics(raw);
4738 assert_eq!(
4739 redacted, "[redacted]",
4740 "userinfo in a later // window must fail closed: {redacted}"
4741 );
4742 }
4743
4744 #[test]
4745 fn redact_url_parsed_later_window_userinfo_masked() {
4746 let redacted = redact_url_for_diagnostics("https://h//user:pass@evil/");
4751 assert!(
4752 !redacted.contains("user:pass"),
4753 "parsed later-window userinfo leaked: {redacted}"
4754 );
4755 assert!(
4756 redacted.contains("h//***@evil/"),
4757 "later window must be masked in place: {redacted}"
4758 );
4759 }
4760
4761 #[test]
4762 fn redact_url_parsed_window_mask_idempotent_with_real_userinfo() {
4763 let redacted = redact_url_for_diagnostics("https://user:pass@h//x@y/");
4767 assert!(
4768 redacted.contains("***@h"),
4769 "accessor mask must survive the window surgery: {redacted}"
4770 );
4771 assert!(
4772 !redacted.contains("user:pass"),
4773 "real userinfo leaked: {redacted}"
4774 );
4775 assert!(
4776 !redacted.contains("x@y"),
4777 "later path window leaked: {redacted}"
4778 );
4779 }
4780
4781 #[test]
4782 fn redact_url_backslash_authority_ruling() {
4783 let redacted = redact_url_for_diagnostics("http:\\\\user:pass@evil\\path");
4789 assert!(
4790 redacted.contains("***@"),
4791 "backslash authority must be userinfo-masked: {redacted}"
4792 );
4793 assert!(
4794 !redacted.contains("user:pass"),
4795 "backslash authority must not leak credentials: {redacted}"
4796 );
4797 }
4798
4799 #[test]
4800 fn non_special_backslash_authority_masked() {
4801 let redacted = redact_url_for_diagnostics("foo:\\user:pass@evil/");
4806 assert!(
4807 !redacted.contains("user:pass"),
4808 "non-special backslash authority leaked: {redacted}"
4809 );
4810 assert!(
4811 !redacted.contains("pass"),
4812 "non-special backslash authority leaked a credential byte: {redacted}"
4813 );
4814 assert_eq!(
4816 redact_url_for_diagnostics("foo:\\clean/path"),
4817 "foo:\\clean/path"
4818 );
4819 }
4820
4821 #[test]
4822 fn one_char_scheme_credential_content_masked() {
4823 let redacted = redact_url_for_diagnostics("x:\\user:pass@evil");
4826 assert!(
4827 !redacted.contains("user:pass"),
4828 "one-char-scheme backslash authority leaked: {redacted}"
4829 );
4830 assert!(
4831 !redacted.contains("pass"),
4832 "one-char-scheme backslash authority leaked a credential byte: {redacted}"
4833 );
4834 }
4835
4836 #[test]
4837 fn drive_and_unc_inputs_stay_visible() {
4838 let drive = redact_url_for_diagnostics("C:\\Users\\x@corp\\file");
4844 assert!(
4845 !drive.contains("[redacted]"),
4846 "drive path must not be sentineled: {drive}"
4847 );
4848 assert!(
4849 !drive.contains("***"),
4850 "drive path must not be masked: {drive}"
4851 );
4852 assert!(
4853 drive.contains("x@corp"),
4854 "drive path keeps its at-sign content visible: {drive}"
4855 );
4856 let unc = redact_url_for_diagnostics("\\\\server\\x@y");
4859 assert_eq!(unc, "\\\\server\\x@y");
4860 assert!(
4861 !unc.contains("[redacted]"),
4862 "UNC path must not be sentineled: {unc}"
4863 );
4864 }
4865
4866 #[test]
4867 fn redact_url_masks_userinfo_and_query() {
4868 let redacted =
4869 redact_url_for_diagnostics("http://user:secretpass@internal.example/api?token=abc123");
4870 assert!(
4871 !redacted.contains("secretpass"),
4872 "password must be masked: {redacted}"
4873 );
4874 assert!(
4875 !redacted.contains("token=abc123"),
4876 "query must be masked: {redacted}"
4877 );
4878 assert!(
4879 !redacted.contains("user@"),
4880 "username must be masked: {redacted}"
4881 );
4882 assert!(
4883 redacted.contains("internal.example"),
4884 "host stays visible: {redacted}"
4885 );
4886 assert!(redacted.contains("[redacted]"), "query marked: {redacted}");
4887 }
4888
4889 #[test]
4890 fn redact_url_keeps_clean_urls_visible() {
4891 let redacted = redact_url_for_diagnostics("https://api.example.com/v1/items");
4892 assert_eq!(redacted, "https://api.example.com/v1/items");
4893 }
4894
4895 #[test]
4896 fn redact_url_masks_password_only_userinfo() {
4897 let redacted = redact_url_for_diagnostics("http://:pwsecret@host.example/");
4898 assert!(
4899 !redacted.contains("pwsecret"),
4900 "password-only userinfo leaked: {redacted}"
4901 );
4902 assert_eq!(redacted, "http://***@host.example/");
4903
4904 let redacted = redact_url_for_diagnostics("http://user:pw2@host.example/api");
4905 assert!(!redacted.contains("pw2"), "password leaked: {redacted}");
4906 assert_eq!(redacted, "http://***@host.example/api");
4907
4908 let redacted = redact_url_for_diagnostics("http://host.example/api");
4909 assert_eq!(redacted, "http://host.example/api");
4910 }
4911
4912 #[test]
4913 fn redact_url_truncates_unparseable() {
4914 let long = "x".repeat(1000);
4915 let redacted = redact_url_for_diagnostics(&long);
4916 assert_eq!(redacted.len(), 256, "unparseable URL must be truncated");
4917 }
4918
4919 #[test]
4924 fn redact_url_keeps_sentinels_intact_under_256_cap() {
4925 let parsed = format!("https://example.com/{}?x=1", "a".repeat(230));
4928 assert!(
4929 url::Url::parse(&parsed).is_ok(),
4930 "fixture must parse: {parsed}"
4931 );
4932 let redacted = redact_url_for_diagnostics(&parsed);
4933 assert!(redacted.len() <= 256, "len={}", redacted.len());
4934 assert!(
4935 redacted.ends_with("?[redacted]"),
4936 "parsed-arm sentinel must render intact: {redacted}"
4937 );
4938
4939 let unparseable = format!("http://{} ?x=1", "a".repeat(240));
4942 assert!(
4943 url::Url::parse(&unparseable).is_err(),
4944 "fixture must not parse: {unparseable}"
4945 );
4946 let redacted = redact_url_for_diagnostics(&unparseable);
4947 assert!(redacted.len() <= 256, "len={}", redacted.len());
4948 assert!(
4949 redacted.ends_with("?[redacted]"),
4950 "unparseable-arm sentinel must render intact: {redacted}"
4951 );
4952 }
4953
4954 #[test]
4955 fn redact_url_suppresses_unparseable_authority_credentials() {
4956 let fixtures = [
4957 "http://u:secretpw@/x",
4958 "http://u:secretpw@host:99999/x",
4959 "http://u:secretpw@host:99999",
4960 "//u:secretpw@h/x",
4961 ];
4962 for fixture in fixtures {
4963 assert!(
4964 url::Url::parse(fixture).is_err(),
4965 "fixture must be unparseable: {fixture}"
4966 );
4967 let redacted = redact_url_for_diagnostics(fixture);
4968 assert_eq!(
4969 redacted, "[redacted]",
4970 "credential-bearing authority must be suppressed: {fixture}"
4971 );
4972 }
4973 }
4974
4975 #[test]
4976 fn redact_url_bd_repro_never_leaks_credentials() {
4977 let redacted = redact_url_for_diagnostics("http://user:pa%ss@host/path");
4978 assert!(
4979 !redacted.contains("user:pa%ss"),
4980 "bd rc-2i5c5 repro leaked userinfo: {redacted}"
4981 );
4982 assert!(
4983 !redacted.contains("pa%ss"),
4984 "bd rc-2i5c5 repro leaked password: {redacted}"
4985 );
4986 }
4987
4988 #[test]
4989 fn redact_url_unparseable_query_redacted_short_and_long() {
4990 let short = "http://host:99999/path?token=shortsecret";
4991 assert!(
4992 url::Url::parse(short).is_err(),
4993 "fixture must be unparseable: {short}"
4994 );
4995 let redacted = redact_url_for_diagnostics(short);
4996 assert_eq!(
4997 redacted, "http://host:99999/path?[redacted]",
4998 "short unparseable query must end with the suffix: {redacted}"
4999 );
5000
5001 let mut long = String::from("http://host:99999/");
5002 long.push_str(&"a".repeat(300));
5003 long.push_str("?token=longsecret");
5004 assert!(
5005 url::Url::parse(&long).is_err(),
5006 "fixture must be unparseable: {long}"
5007 );
5008 let redacted = redact_url_for_diagnostics(&long);
5009 assert!(
5010 !redacted.contains("longsecret"),
5011 "long unparseable query leaked a query byte: {redacted}"
5012 );
5013 assert!(
5014 redacted.len() <= 256,
5015 "long unparseable query must be capped: {} bytes",
5016 redacted.len()
5017 );
5018 }
5019
5020 #[test]
5021 fn redact_url_unparseable_sentinels_compose_both() {
5022 let raw = "ht tp://h.example/p?a=1#tok=x";
5025 assert!(
5026 url::Url::parse(raw).is_err(),
5027 "fixture must be unparseable: {raw}"
5028 );
5029 assert_eq!(
5030 redact_url_for_diagnostics(raw),
5031 "ht tp://h.example/p?[redacted]#[redacted]",
5032 "query and fragment sentinels must compose: {raw}"
5033 );
5034 }
5035
5036 #[test]
5037 fn redact_url_unparseable_sentinels_compose_fragment_first() {
5038 let raw = "ht tp://h.example/p#tok=x?a=1";
5039 assert!(
5040 url::Url::parse(raw).is_err(),
5041 "fixture must be unparseable: {raw}"
5042 );
5043 assert_eq!(
5044 redact_url_for_diagnostics(raw),
5045 "ht tp://h.example/p#[redacted]?[redacted]",
5046 "sentinels must follow the introducers' first-occurrence order: {raw}"
5047 );
5048 }
5049
5050 #[test]
5051 fn redact_url_unparseable_utf8_straddle_no_panic() {
5052 let fixture = format!("a{}", "é".repeat(200));
5053 let redacted = redact_url_for_diagnostics(&fixture);
5054 assert!(
5055 redacted.len() <= 256,
5056 "straddle fixture must be capped: {} bytes",
5057 redacted.len()
5058 );
5059 assert!(
5060 redacted.len() >= 253,
5061 "straddle fixture must not over-truncate: {} bytes",
5062 redacted.len()
5063 );
5064 assert!(
5065 fixture.is_char_boundary(redacted.len()),
5066 "cut must land on a UTF-8 char boundary: {} bytes",
5067 redacted.len()
5068 );
5069 }
5070
5071 #[test]
5072 fn redact_url_at_sign_outside_authority_window_visible() {
5073 let at_sign_in_path = "http://host:99999/x@y";
5074 assert!(
5075 url::Url::parse(at_sign_in_path).is_err(),
5076 "fixture must be unparseable: {at_sign_in_path}"
5077 );
5078 assert_eq!(
5079 redact_url_for_diagnostics(at_sign_in_path),
5080 at_sign_in_path,
5081 "at-sign in path must not be suppressed"
5082 );
5083 assert_eq!(
5085 redact_url_for_diagnostics("mailto:user@example.com"),
5086 "mailto:user@example.com",
5087 "at-sign in mailto must round-trip byte-identically"
5088 );
5089 }
5090
5091 #[test]
5092 fn parse_success_fragment_composes() {
5093 assert_eq!(
5096 redact_url_for_diagnostics("https://h/p#access_token=x"),
5097 "https://h/p#[redacted]"
5098 );
5099 assert_eq!(
5102 redact_url_for_diagnostics("https://h/cb#f?state=x"),
5103 "https://h/cb#[redacted]?[redacted]"
5104 );
5105 }
5106
5107 #[test]
5108 fn err_arm_delegation_pin() {
5109 assert_eq!(
5113 redact_url_for_diagnostics("http://u:secretpw@host:99999/x"),
5114 "[redacted]"
5115 );
5116 assert_eq!(
5119 redact_url_for_diagnostics("http://h:99999/p?token=secret"),
5120 "http://h:99999/p?[redacted]"
5121 );
5122 }
5123
5124 #[test]
5125 fn truncate_error_body_caps_attacker_body() {
5126 let big = vec![b'A'; 10 * 1024 * 1024];
5127 let truncated = truncate_error_body(&big);
5128 assert!(
5129 truncated.len() <= MAX_ERROR_RESPONSE_BODY_BYTES + 20,
5130 "body must be capped near {} bytes, got {}",
5131 MAX_ERROR_RESPONSE_BODY_BYTES,
5132 truncated.len()
5133 );
5134 assert!(truncated.ends_with("...[truncated]"));
5135 }
5136
5137 #[test]
5138 fn truncate_error_body_keeps_small_body() {
5139 assert_eq!(truncate_error_body(b"boom"), "boom");
5140 }
5141
5142 #[test]
5143 fn test_http_config_defaults() {
5144 let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
5145 assert_eq!(config.base_url, "http://localhost:8080/api");
5146 assert!(config.http_method.is_none());
5147 assert!(config.throw_exception_on_failure);
5148 assert_eq!(config.ok_status_code_range, (200, 299));
5149 assert!(config.response_timeout.is_none());
5150 assert!(matches!(config.auth, HttpAuth::None));
5151 assert!(!config.bridge_endpoint);
5152 assert!(!config.connection_close);
5153 }
5154
5155 #[test]
5156 fn test_http_config_scheme() {
5157 assert_eq!(HttpEndpointConfig::scheme(), "http");
5159 }
5160
5161 #[test]
5162 fn test_http_config_from_components() {
5163 let components = camel_component_api::UriComponents {
5165 scheme: "https".to_string(),
5166 path: "//api.example.com/v1".to_string(),
5167 params: std::collections::HashMap::from([(
5168 "httpMethod".to_string(),
5169 "POST".to_string(),
5170 )]),
5171 raw_query: None,
5172 };
5173 let config = HttpEndpointConfig::from_components(components).unwrap();
5174 assert_eq!(config.base_url, "https://api.example.com/v1");
5175 assert_eq!(config.http_method, Some("POST".to_string()));
5176 }
5177
5178 #[test]
5179 fn test_http_config_with_options() {
5180 let config = HttpEndpointConfig::from_uri(
5181 "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
5182 ).unwrap();
5183 assert_eq!(config.base_url, "https://api.example.com/v1");
5184 assert_eq!(config.http_method, Some("PUT".to_string()));
5185 assert!(!config.throw_exception_on_failure);
5186 assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
5187 }
5188
5189 #[test]
5190 fn test_http_endpoint_config_auth_and_headers_options() {
5191 let config = HttpEndpointConfig::from_uri(
5192 "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie",
5193 )
5194 .unwrap();
5195
5196 assert!(matches!(
5197 config.auth,
5198 HttpAuth::Basic { username, password } if username == "u" && password == "p"
5199 ));
5200 assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
5201 assert!(config.bridge_endpoint);
5202 assert!(config.connection_close);
5203 assert_eq!(
5204 config.skip_request_headers,
5205 vec!["authorization".to_string(), "x-secret".to_string()]
5206 );
5207 assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
5208 }
5209
5210 #[test]
5211 fn test_http_endpoint_config_bearer_auth() {
5212 let config = HttpEndpointConfig::from_uri(
5213 "http://localhost/api?authMethod=Bearer&authBearerToken=t",
5214 )
5215 .unwrap();
5216 assert!(matches!(
5217 config.auth,
5218 HttpAuth::Bearer { token } if token == "t"
5219 ));
5220 }
5221
5222 #[test]
5223 fn rejects_cookie_handling_inmemory() {
5224 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=InMemory");
5225 match result {
5226 Err(CamelError::InvalidUri(msg)) => {
5227 assert!(
5228 msg.contains("cookieHandling is not supported"),
5229 "expected rejection message, got: {msg}"
5230 );
5231 }
5232 other => panic!("expected InvalidUri error, got: {other:?}"),
5233 }
5234 }
5235
5236 #[test]
5237 fn rejects_cookie_handling_disabled() {
5238 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=Disabled");
5239 match result {
5240 Err(CamelError::InvalidUri(msg)) => {
5241 assert!(
5242 msg.contains("cookieHandling is not supported"),
5243 "expected rejection message, got: {msg}"
5244 );
5245 }
5246 other => panic!("expected InvalidUri error, got: {other:?}"),
5247 }
5248 }
5249
5250 #[test]
5251 fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
5252 let config = HttpConfig::default()
5253 .with_response_timeout_ms(999)
5254 .with_allow_internal(true)
5255 .with_blocked_hosts(vec!["evil.com".to_string()])
5256 .with_max_body_size(12345);
5257 let endpoint =
5258 HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
5259 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
5260 assert!(endpoint.allow_internal);
5261 assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
5262 assert_eq!(endpoint.max_body_size, 12345);
5263 }
5264
5265 #[test]
5266 fn test_from_uri_with_defaults_uri_overrides_config() {
5267 let config = HttpConfig::default()
5268 .with_response_timeout_ms(999)
5269 .with_allow_internal(true)
5270 .with_blocked_hosts(vec!["evil.com".to_string()])
5271 .with_max_body_size(12345);
5272 let endpoint = HttpEndpointConfig::from_uri_with_defaults(
5273 "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
5274 &config,
5275 )
5276 .unwrap();
5277 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
5278 assert!(!endpoint.allow_internal);
5279 assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
5280 assert_eq!(endpoint.max_body_size, 99);
5281 }
5282
5283 #[test]
5284 fn test_http_config_ok_status_range() {
5285 let config =
5286 HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
5287 assert_eq!(config.ok_status_code_range, (200, 204));
5288 }
5289
5290 #[test]
5291 fn test_http_config_wrong_scheme() {
5292 let result = HttpEndpointConfig::from_uri("file:/tmp");
5293 assert!(result.is_err());
5294 }
5295
5296 #[test]
5297 fn test_http_component_scheme() {
5298 let component = HttpComponent::new();
5299 assert_eq!(component.scheme(), "http");
5300 }
5301
5302 #[test]
5309 fn tls_strict_defaults_false_on_deserialize() {
5310 let tls: TlsConfig = serde_json::from_value(serde_json::json!({
5311 "enabled": true
5312 }))
5313 .unwrap();
5314 assert!(!tls.strict, "absent strict must default to false");
5315 }
5316
5317 fn strict_config(ca_path: Option<&str>, strict: bool) -> HttpConfig {
5318 HttpConfig {
5319 tls: Some(TlsConfig {
5320 enabled: true,
5321 strict,
5322 ca_cert_path: ca_path.map(|p| p.to_string()),
5323 ..TlsConfig::default()
5324 }),
5325 ..HttpConfig::default()
5326 }
5327 }
5328
5329 #[test]
5330 fn strict_tls_missing_ca_fails_endpoint_creation() {
5331 let component =
5332 HttpComponent::with_config(strict_config(Some("/nonexistent/ca.pem"), true));
5333 let err = component
5334 .create_endpoint("http://localhost/api", &NoOpComponentContext)
5335 .err()
5336 .expect("strict + missing CA must fail endpoint creation");
5337 assert!(
5338 err.to_string().contains("tls.strict"),
5339 "must name the strict knob: {err}"
5340 );
5341 assert!(
5342 err.to_string().contains("unreadable"),
5343 "must name the failure class: {err}"
5344 );
5345 }
5346
5347 #[test]
5348 fn strict_tls_unparseable_ca_fails_endpoint_creation() {
5349 let path = camel_component_api::test_support::tls::write_pem_tmp(
5350 "strict-bad-ca.pem",
5351 "not a certificate",
5352 );
5353 let component =
5354 HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
5355 let err = component
5356 .create_endpoint("http://localhost/api", &NoOpComponentContext)
5357 .err()
5358 .expect("strict + unparseable CA must fail endpoint creation");
5359 assert!(
5360 err.to_string()
5361 .contains("no parseable PEM CERTIFICATE section"),
5362 "must name the failure class: {err}"
5363 );
5364 }
5365
5366 #[test]
5367 fn strict_tls_der_file_rejected_not_certified() {
5368 let path = camel_component_api::test_support::tls::write_pem_tmp(
5373 "strict-der-ca.pem",
5374 "00garbage-bytes",
5375 );
5376 let component =
5377 HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
5378 let err = component
5379 .create_endpoint("http://localhost/api", &NoOpComponentContext)
5380 .err()
5381 .expect("strict + DER file must fail endpoint creation");
5382 assert!(
5383 err.to_string().contains("convert to PEM"),
5384 "must tell the operator to convert: {err}"
5385 );
5386 }
5387
5388 #[test]
5389 fn strict_tls_half_mtls_pair_rejected() {
5390 let cfg = strict_mtls_config(Some("/any/cert.pem"), None);
5393 let component = HttpComponent::with_config(cfg);
5394 let err = component
5395 .create_endpoint("http://localhost/api", &NoOpComponentContext)
5396 .err()
5397 .expect("strict + half mTLS pair must fail endpoint creation");
5398 assert!(
5399 err.to_string().contains("BOTH"),
5400 "must name the pair requirement: {err}"
5401 );
5402 }
5403
5404 #[test]
5405 fn strict_tls_valid_material_allows_endpoint_creation() {
5406 let (ca, _cert, _key) = camel_component_api::test_support::tls::gen_server_cert();
5407 let path = camel_component_api::test_support::tls::write_pem_tmp("strict-ok-ca.pem", &ca);
5408 let component =
5409 HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
5410 assert!(
5411 component
5412 .create_endpoint("http://localhost/api", &NoOpComponentContext)
5413 .is_ok(),
5414 "valid CA under strict must create the endpoint"
5415 );
5416 }
5417
5418 #[test]
5419 fn permissive_missing_ca_keeps_back_compat() {
5420 let component =
5423 HttpComponent::with_config(strict_config(Some("/nonexistent/ca.pem"), false));
5424 assert!(
5425 component
5426 .create_endpoint("http://localhost/api", &NoOpComponentContext)
5427 .is_ok(),
5428 "permissive mode must keep the back-compat fallback"
5429 );
5430 }
5431
5432 fn strict_mtls_config(cert_path: Option<&str>, key_path: Option<&str>) -> HttpConfig {
5433 HttpConfig {
5434 tls: Some(TlsConfig {
5435 enabled: true,
5436 strict: true,
5437 client_cert_path: cert_path.map(|p| p.to_string()),
5438 client_key_path: key_path.map(|p| p.to_string()),
5439 ..TlsConfig::default()
5440 }),
5441 ..HttpConfig::default()
5442 }
5443 }
5444
5445 #[test]
5446 fn strict_tls_missing_mtls_cert_fails_endpoint_creation() {
5447 let (_ca, _cert, key) = camel_component_api::test_support::tls::gen_server_cert();
5450 let key_path =
5451 camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-key.pem", &key);
5452 let component = HttpComponent::with_config(strict_mtls_config(
5453 Some("/nonexistent/cert.pem"),
5454 Some(key_path.to_str().unwrap()),
5455 ));
5456 let err = component
5457 .create_endpoint("http://localhost/api", &NoOpComponentContext)
5458 .err()
5459 .expect("strict + unreadable mTLS pair must fail endpoint creation");
5460 assert!(
5461 err.to_string().contains("tls.strict"),
5462 "must name the strict knob: {err}"
5463 );
5464 assert!(
5465 err.to_string().contains("unreadable"),
5466 "must name the failure class: {err}"
5467 );
5468 }
5469
5470 #[test]
5471 fn strict_tls_valid_mtls_pair_allows_endpoint_creation() {
5472 let (_ca, cert, key) = camel_component_api::test_support::tls::gen_server_cert();
5473 let cert_path =
5474 camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-cert.pem", &cert);
5475 let key_path =
5476 camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-key2.pem", &key);
5477 let component = HttpComponent::with_config(strict_mtls_config(
5478 Some(cert_path.to_str().unwrap()),
5479 Some(key_path.to_str().unwrap()),
5480 ));
5481 assert!(
5482 component
5483 .create_endpoint("http://localhost/api", &NoOpComponentContext)
5484 .is_ok(),
5485 "valid mTLS pair under strict must create the endpoint"
5486 );
5487 }
5488
5489 #[test]
5490 fn test_https_component_scheme() {
5491 let component = HttpsComponent::new();
5492 assert_eq!(component.scheme(), "https");
5493 }
5494
5495 #[test]
5496 fn test_http_endpoint_creates_consumer() {
5497 let component = HttpComponent::new();
5498 let ctx = NoOpComponentContext;
5499 let endpoint = component
5500 .create_endpoint("http://0.0.0.0:19100/test", &ctx)
5501 .unwrap();
5502 assert!(endpoint.create_consumer(rt()).is_ok());
5503 }
5504
5505 #[test]
5506 fn test_https_endpoint_creates_consumer_errors_without_tls() {
5507 let component = HttpsComponent::new();
5508 let ctx = NoOpComponentContext;
5509 let endpoint = component
5510 .create_endpoint("https://0.0.0.0:8443/test", &ctx)
5511 .unwrap();
5512 assert!(endpoint.create_consumer(rt()).is_err());
5514 }
5515
5516 #[test]
5517 fn test_http_endpoint_creates_producer() {
5518 let ctx = test_producer_ctx();
5519 let component = HttpComponent::new();
5520 let endpoint_ctx = NoOpComponentContext;
5521 let endpoint = component
5522 .create_endpoint("http://localhost/api", &endpoint_ctx)
5523 .unwrap();
5524 assert!(endpoint.create_producer(rt(), &ctx).is_ok());
5525 }
5526
5527 #[tokio::test]
5532 async fn test_producer_with_token_provider() {
5533 use camel_auth::oauth2::TokenProvider;
5534 use tower::ServiceExt;
5535
5536 let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
5537 Arc::new(std::sync::Mutex::new(None));
5538 let captured_clone = Arc::clone(&captured_auth);
5539
5540 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5541 let port = listener.local_addr().unwrap().port();
5542
5543 let _handle = tokio::spawn(async move {
5544 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5545 if let Ok((mut stream, _)) = listener.accept().await {
5546 let mut buf = vec![0u8; 8192];
5547 let n = stream.read(&mut buf).await.unwrap_or(0);
5548 let request = String::from_utf8_lossy(&buf[..n]).to_string();
5549 let auth = request
5550 .lines()
5551 .find(|l| l.to_lowercase().starts_with("authorization:"))
5552 .map(|l| {
5553 l.split(':')
5554 .nth(1)
5555 .map(|s| s.trim().to_string())
5556 .unwrap_or_default()
5557 });
5558 *captured_clone.lock().unwrap() = auth;
5559 let body = r#"{"echo":"ok"}"#;
5560 let resp = format!(
5561 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
5562 body.len(),
5563 body
5564 );
5565 let _ = stream.write_all(resp.as_bytes()).await;
5566 }
5567 });
5568
5569 #[derive(Debug)]
5570 struct StaticProvider;
5571 #[async_trait::async_trait]
5572 impl TokenProvider for StaticProvider {
5573 async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
5574 Ok("injected-token".into())
5575 }
5576 }
5577
5578 let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
5579 let ctx = test_producer_ctx();
5580 let component = HttpComponent::new();
5581 let endpoint_ctx = NoOpComponentContext;
5582 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
5583 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5584
5585 let exchange = Exchange::new(Message::new("hello"));
5586
5587 let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
5588 let mut layered = layer.layer(producer);
5589 let result = layered.ready().await.unwrap().call(exchange).await;
5590 assert!(result.is_ok(), "producer call failed: {:?}", result);
5591
5592 tokio::time::sleep(Duration::from_millis(100)).await;
5593 let auth = captured_auth.lock().unwrap().take();
5594 assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
5595 }
5596
5597 async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
5598 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5599 let addr = listener.local_addr().unwrap();
5600 let url = format!("http://127.0.0.1:{}", addr.port());
5601
5602 let handle = tokio::spawn(async move {
5603 loop {
5604 if let Ok((mut stream, _)) = listener.accept().await {
5605 tokio::spawn(async move {
5606 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5607 let mut buf = vec![0u8; 4096];
5608 let n = stream.read(&mut buf).await.unwrap_or(0);
5609 let request = String::from_utf8_lossy(&buf[..n]).to_string();
5610
5611 let method = request.split_whitespace().next().unwrap_or("GET");
5612
5613 let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
5614 let response = format!(
5615 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
5616 body.len(),
5617 body
5618 );
5619 let _ = stream.write_all(response.as_bytes()).await;
5620 });
5621 }
5622 }
5623 });
5624
5625 (url, handle)
5626 }
5627
5628 async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
5629 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5630 let addr = listener.local_addr().unwrap();
5631 let url = format!("http://127.0.0.1:{}", addr.port());
5632
5633 let handle = tokio::spawn(async move {
5634 loop {
5635 if let Ok((mut stream, _)) = listener.accept().await {
5636 let status = status;
5637 tokio::spawn(async move {
5638 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5639 let mut buf = vec![0u8; 4096];
5640 let _ = stream.read(&mut buf).await;
5641
5642 let status_text = match status {
5643 404 => "Not Found",
5644 500 => "Internal Server Error",
5645 _ => "Error",
5646 };
5647 let body = "error body";
5648 let response = format!(
5649 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
5650 status,
5651 status_text,
5652 body.len(),
5653 body
5654 );
5655 let _ = stream.write_all(response.as_bytes()).await;
5656 });
5657 }
5658 }
5659 });
5660
5661 (url, handle)
5662 }
5663
5664 async fn start_request_capturing_server() -> (
5665 String,
5666 Arc<std::sync::Mutex<Option<String>>>,
5667 tokio::task::JoinHandle<()>,
5668 ) {
5669 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5670 let port = listener.local_addr().unwrap().port();
5671 let url = format!("http://127.0.0.1:{port}");
5672 let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
5673 let captured_clone = Arc::clone(&captured);
5674 let handle = tokio::spawn(async move {
5675 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5676 if let Ok((mut stream, _)) = listener.accept().await {
5677 let mut buf = vec![0u8; 16384];
5678 let n = stream.read(&mut buf).await.unwrap_or(0);
5679 let request = String::from_utf8_lossy(&buf[..n]).to_string();
5680 if request.contains("\r\n\r\n") {
5681 *captured_clone.lock().unwrap() = Some(request);
5682 }
5683 let body = r#"{"echo":"ok"}"#;
5684 let resp = format!(
5685 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
5686 body.len(),
5687 body
5688 );
5689 let _ = stream.write_all(resp.as_bytes()).await;
5690 }
5691 });
5692 (url, captured, handle)
5693 }
5694
5695 #[tokio::test]
5696 async fn test_http_producer_get_request() {
5697 use tower::ServiceExt;
5698
5699 let (url, _handle) = start_test_server().await;
5700 let ctx = test_producer_ctx();
5701
5702 let component = HttpComponent::new();
5703 let endpoint_ctx = NoOpComponentContext;
5704 let endpoint = component
5705 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5706 .unwrap();
5707 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5708
5709 let exchange = Exchange::new(Message::default());
5710 let result = producer.oneshot(exchange).await.unwrap();
5711
5712 let status = result
5713 .input
5714 .header("CamelHttpResponseCode")
5715 .and_then(|v| v.as_u64())
5716 .unwrap();
5717 assert_eq!(status, 200);
5718
5719 assert!(!result.input.body.is_empty());
5720 }
5721
5722 #[tokio::test]
5723 async fn producer_excludes_host_and_framing() {
5724 use tower::ServiceExt;
5725
5726 let (url, captured, _handle) = start_request_capturing_server().await;
5727 let ctx = test_producer_ctx();
5728 let component = HttpComponent::new();
5729 let endpoint_ctx = NoOpComponentContext;
5730 let endpoint = component
5731 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5732 .unwrap();
5733 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5734
5735 let mut exchange = Exchange::new(Message::default());
5736 exchange.input.set_header("Host", "localhost");
5737 exchange.input.set_header("Content-Length", "42");
5738 exchange.input.set_header("Connection", "keep-alive");
5739 exchange.input.set_header("Upgrade", "h2c");
5740
5741 let result = producer.oneshot(exchange).await;
5742 assert!(result.is_ok(), "producer call failed: {:?}", result);
5743
5744 tokio::time::sleep(Duration::from_millis(100)).await;
5745 let request = captured
5746 .lock()
5747 .unwrap()
5748 .take()
5749 .expect("no outbound request captured");
5750 let lower = request.to_ascii_lowercase();
5751 assert!(
5752 !lower.contains("\r\nhost: localhost"),
5753 "forwarded Host: localhost must be stripped\n{request}"
5754 );
5755 assert!(
5756 !lower.contains("content-length: 42"),
5757 "exchange Content-Length must not be copied\n{request}"
5758 );
5759 assert!(
5760 !lower.lines().any(|l| l.starts_with("connection:")),
5761 "Connection header must not be forwarded\n{request}"
5762 );
5763 assert!(
5764 !lower.lines().any(|l| l.starts_with("upgrade:")),
5765 "Upgrade header must not be forwarded\n{request}"
5766 );
5767 let host_header = lower
5768 .lines()
5769 .find(|l| l.starts_with("host:"))
5770 .map(|l| l.split_once(':').map(|(_, v)| v).unwrap_or("").trim())
5771 .expect("outbound Host header must be set by reqwest");
5772 assert!(
5773 host_header.starts_with("127.0.0.1:"),
5774 "outbound Host '{host_header}' must match the capture-server address"
5775 );
5776 }
5777
5778 #[tokio::test]
5779 async fn producer_forwards_request_only_headers() {
5780 use tower::ServiceExt;
5781
5782 let (url, captured, _handle) = start_request_capturing_server().await;
5783 let ctx = test_producer_ctx();
5784 let component = HttpComponent::new();
5785 let endpoint_ctx = NoOpComponentContext;
5786 let endpoint = component
5787 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5788 .unwrap();
5789 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5790
5791 let mut exchange = Exchange::new(Message::default());
5792 exchange.input.set_header("Accept", "application/json");
5793 exchange.input.set_header("User-Agent", "myclient/1.0");
5794
5795 let result = producer.oneshot(exchange).await;
5796 assert!(result.is_ok(), "producer call failed: {:?}", result);
5797
5798 tokio::time::sleep(Duration::from_millis(100)).await;
5799 let request = captured
5800 .lock()
5801 .unwrap()
5802 .take()
5803 .expect("no outbound request captured");
5804 let lower = request.to_ascii_lowercase();
5805 assert!(
5806 lower.contains("accept: application/json"),
5807 "request-only Accept header must be forwarded\n{request}"
5808 );
5809 assert!(
5810 lower.contains("user-agent: myclient/1.0"),
5811 "request-only User-Agent header must be forwarded\n{request}"
5812 );
5813 }
5814
5815 fn endpoint_with_config_overrides(
5824 base_url: &str,
5825 user_agent: Option<String>,
5826 auth: HttpAuth,
5827 ) -> HttpEndpoint {
5828 let uri = format!("{base_url}/api/test?allowInternal=true");
5829 let mut config =
5830 HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses");
5831 config.user_agent = user_agent;
5832 config.auth = auth;
5833 HttpEndpoint {
5834 uri: uri.clone(),
5835 config,
5836 server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
5837 client: plain_http_test_client(),
5838 pinned_cache: Arc::new(PinnedClientCache::new(
5839 PINNED_CLIENT_TTL,
5840 PINNED_CLIENT_MAX_ENTRIES,
5841 )),
5842 http_config: HttpConfig::default(),
5843 }
5844 }
5845
5846 #[tracing_test::traced_test]
5851 #[tokio::test]
5852 async fn producer_invalid_configured_headers_surfaced() {
5853 use tower::ServiceExt;
5854
5855 let (bad_url, bad_captured, _bad_handle) = start_request_capturing_server().await;
5856 let (ok_url, ok_captured, _ok_handle) = start_request_capturing_server().await;
5857 let ctx = test_producer_ctx();
5858
5859 let bad_producer = endpoint_with_config_overrides(
5860 &bad_url,
5861 Some("bad\r\nua".to_string()),
5862 HttpAuth::Bearer {
5863 token: "tok\r\nen".to_string(),
5864 },
5865 )
5866 .create_producer(rt(), &ctx)
5867 .unwrap();
5868 let ok_producer = endpoint_with_config_overrides(
5869 &ok_url,
5870 Some("httpsweep-ok/1".to_string()),
5871 HttpAuth::Bearer {
5872 token: "valid-token".to_string(),
5873 },
5874 )
5875 .create_producer(rt(), &ctx)
5876 .unwrap();
5877
5878 let bad_exchange = Exchange::new(Message::default());
5879 let ok_exchange = Exchange::new(Message::default());
5880 let bad_cid = bad_exchange.correlation_id().to_string();
5881 let ok_cid = ok_exchange.correlation_id().to_string();
5882
5883 let bad_result = bad_producer.oneshot(bad_exchange).await;
5884 assert!(
5885 bad_result.is_ok(),
5886 "invalid-config producer call failed: {bad_result:?}"
5887 );
5888 let ok_result = ok_producer.oneshot(ok_exchange).await;
5889 assert!(
5890 ok_result.is_ok(),
5891 "valid-config producer call failed: {ok_result:?}"
5892 );
5893
5894 tokio::time::sleep(Duration::from_millis(100)).await;
5895 let bad_request = bad_captured
5896 .lock()
5897 .unwrap()
5898 .take()
5899 .expect("no outbound request captured");
5900 let ok_request = ok_captured
5901 .lock()
5902 .unwrap()
5903 .take()
5904 .expect("no outbound request captured");
5905
5906 let bad_lower = bad_request.to_ascii_lowercase();
5909 assert!(
5910 !bad_lower.lines().any(|l| l.starts_with("authorization:")),
5911 "invalid Bearer token must not reach the wire\n{bad_request}"
5912 );
5913 assert!(
5914 !bad_request.contains("bad\r\nua"),
5915 "invalid configured user-agent must not reach the wire\n{bad_request}"
5916 );
5917
5918 logs_assert(|lines: &[&str]| {
5919 let drops: Vec<&&str> = lines
5920 .iter()
5921 .filter(|l| {
5922 l.contains("outbound header dropped")
5923 && l.contains(&format!("correlation_id={bad_cid}"))
5924 })
5925 .collect();
5926 if drops.len() != 2 {
5927 return Err(format!(
5928 "expected exactly 2 drop records for {bad_cid}, found {}",
5929 drops.len()
5930 ));
5931 }
5932 let has_ua = drops.iter().any(|l| l.contains("header=user-agent"));
5933 let has_auth = drops.iter().any(|l| l.contains("header=authorization"));
5934 let reason_ok = drops
5935 .iter()
5936 .all(|l| l.contains("outbound header dropped: invalid header value"));
5937 match (has_ua, has_auth, reason_ok) {
5938 (true, true, true) => Ok(()),
5939 _ => Err(format!(
5940 "drop records mismatched: user-agent={has_ua} \
5941 authorization={has_auth} reason-ok={reason_ok}"
5942 )),
5943 }
5944 });
5945 logs_assert(|lines: &[&str]| {
5946 if lines
5947 .iter()
5948 .any(|l| l.contains("bad\r\nua") || l.contains("tok\r\nen"))
5949 {
5950 Err("sentinel CRLF values leaked into logs".to_string())
5951 } else {
5952 Ok(())
5953 }
5954 });
5955
5956 let ok_lower = ok_request.to_ascii_lowercase();
5959 assert!(
5960 ok_lower.contains("user-agent: httpsweep-ok/1"),
5961 "valid configured user-agent must reach the wire\n{ok_request}"
5962 );
5963 assert!(
5964 ok_lower.contains("authorization: bearer valid-token"),
5965 "valid Bearer token must reach the wire\n{ok_request}"
5966 );
5967 logs_assert(|lines: &[&str]| {
5968 let hits = lines
5969 .iter()
5970 .filter(|l| {
5971 l.contains("outbound header dropped")
5972 && l.contains(&format!("correlation_id={ok_cid}"))
5973 })
5974 .count();
5975 match hits {
5976 0 => Ok(()),
5977 n => Err(format!("expected no drop records for {ok_cid}, found {n}")),
5978 }
5979 });
5980 }
5981
5982 #[tokio::test]
5983 async fn producer_honours_skip_request_headers() {
5984 use tower::ServiceExt;
5985
5986 let (url, captured, _handle) = start_request_capturing_server().await;
5987 let ctx = test_producer_ctx();
5988 let component = HttpComponent::new();
5989 let endpoint_ctx = NoOpComponentContext;
5990 let endpoint = component
5991 .create_endpoint(
5992 &format!("{url}/api/test?allowInternal=true&skipRequestHeaders=Authorization"),
5993 &endpoint_ctx,
5994 )
5995 .unwrap();
5996 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5997
5998 let mut exchange = Exchange::new(Message::default());
5999 exchange.input.set_header("Authorization", "Bearer x");
6000
6001 let result = producer.oneshot(exchange).await;
6002 assert!(result.is_ok(), "producer call failed: {:?}", result);
6003
6004 tokio::time::sleep(Duration::from_millis(100)).await;
6005 let request = captured
6006 .lock()
6007 .unwrap()
6008 .take()
6009 .expect("no outbound request captured");
6010 assert!(
6011 !request.to_ascii_lowercase().contains("authorization"),
6012 "Authorization must be stripped by skipRequestHeaders\n{request}"
6013 );
6014 }
6015
6016 #[tokio::test]
6017 async fn producer_stringifies_scalar_header_values_on_wire() {
6018 use tower::ServiceExt;
6019
6020 let (url, captured, _handle) = start_request_capturing_server().await;
6021 let ctx = test_producer_ctx();
6022 let component = HttpComponent::new();
6023 let endpoint_ctx = NoOpComponentContext;
6024 let endpoint = component
6025 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
6026 .unwrap();
6027 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6028
6029 let mut exchange = Exchange::new(Message::default());
6030 exchange.input.set_header("X-Retries", serde_json::json!(3));
6031 exchange
6032 .input
6033 .set_header("X-Enabled", serde_json::json!(true));
6034 exchange
6035 .input
6036 .set_header("X-Obj", serde_json::json!({"a": 1}));
6037
6038 let result = producer.oneshot(exchange).await;
6039 assert!(result.is_ok(), "producer call failed: {:?}", result);
6040
6041 tokio::time::sleep(Duration::from_millis(100)).await;
6042 let request = captured
6043 .lock()
6044 .unwrap()
6045 .take()
6046 .expect("no outbound request captured");
6047 let lower = request.to_ascii_lowercase();
6048 assert!(
6049 lower.contains("x-retries: 3"),
6050 "numeric header must reach the wire stringified\n{request}"
6051 );
6052 assert!(
6053 lower.contains("x-enabled: true"),
6054 "bool header must reach the wire stringified\n{request}"
6055 );
6056 assert!(
6057 !lower.contains("x-obj:"),
6058 "object header has no single-value form and must not reach the wire\n{request}"
6059 );
6060 }
6061
6062 #[tokio::test]
6063 async fn test_http_producer_post_with_body() {
6064 use tower::ServiceExt;
6065
6066 let (url, _handle) = start_test_server().await;
6067 let ctx = test_producer_ctx();
6068
6069 let component = HttpComponent::new();
6070 let endpoint_ctx = NoOpComponentContext;
6071 let endpoint = component
6072 .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
6073 .unwrap();
6074 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6075
6076 let exchange = Exchange::new(Message::new("request body"));
6077 let result = producer.oneshot(exchange).await.unwrap();
6078
6079 let status = result
6080 .input
6081 .header("CamelHttpResponseCode")
6082 .and_then(|v| v.as_u64())
6083 .unwrap();
6084 assert_eq!(status, 200);
6085 }
6086
6087 #[tokio::test]
6088 async fn test_http_producer_method_from_header() {
6089 use tower::ServiceExt;
6090
6091 let (url, _handle) = start_test_server().await;
6092 let ctx = test_producer_ctx();
6093
6094 let component = HttpComponent::new();
6095 let endpoint_ctx = NoOpComponentContext;
6096 let endpoint = component
6097 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
6098 .unwrap();
6099 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6100
6101 let mut exchange = Exchange::new(Message::default());
6102 exchange.input.set_header(
6103 "CamelHttpMethod",
6104 serde_json::Value::String("DELETE".to_string()),
6105 );
6106
6107 let result = producer.oneshot(exchange).await.unwrap();
6108 let status = result
6109 .input
6110 .header("CamelHttpResponseCode")
6111 .and_then(|v| v.as_u64())
6112 .unwrap();
6113 assert_eq!(status, 200);
6114 }
6115
6116 #[tokio::test]
6117 async fn test_http_producer_forced_method() {
6118 use tower::ServiceExt;
6119
6120 let (url, _handle) = start_test_server().await;
6121 let ctx = test_producer_ctx();
6122
6123 let component = HttpComponent::new();
6124 let endpoint_ctx = NoOpComponentContext;
6125 let endpoint = component
6126 .create_endpoint(
6127 &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
6128 &endpoint_ctx,
6129 )
6130 .unwrap();
6131 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6132
6133 let exchange = Exchange::new(Message::default());
6134 let result = producer.oneshot(exchange).await.unwrap();
6135
6136 let status = result
6137 .input
6138 .header("CamelHttpResponseCode")
6139 .and_then(|v| v.as_u64())
6140 .unwrap();
6141 assert_eq!(status, 200);
6142 }
6143
6144 #[tokio::test]
6145 async fn test_http_producer_throw_exception_on_failure() {
6146 use tower::ServiceExt;
6147
6148 let (url, _handle) = start_status_server(404).await;
6149 let ctx = test_producer_ctx();
6150
6151 let component = HttpComponent::new();
6152 let endpoint_ctx = NoOpComponentContext;
6153 let endpoint = component
6154 .create_endpoint(
6155 &format!("{url}/not-found?allowInternal=true"),
6156 &endpoint_ctx,
6157 )
6158 .unwrap();
6159 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6160
6161 let exchange = Exchange::new(Message::default());
6162 let result = producer.oneshot(exchange).await;
6163 assert!(result.is_err());
6164
6165 match result.unwrap_err() {
6166 CamelError::HttpOperationFailed { status_code, .. } => {
6167 assert_eq!(status_code, 404);
6168 }
6169 e => panic!("Expected HttpOperationFailed, got: {e}"),
6170 }
6171 }
6172
6173 #[tokio::test]
6174 async fn test_http_producer_no_throw_on_failure() {
6175 use tower::ServiceExt;
6176
6177 let (url, _handle) = start_status_server(500).await;
6178 let ctx = test_producer_ctx();
6179
6180 let component = HttpComponent::new();
6181 let endpoint_ctx = NoOpComponentContext;
6182 let endpoint = component
6183 .create_endpoint(
6184 &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
6185 &endpoint_ctx,
6186 )
6187 .unwrap();
6188 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6189
6190 let exchange = Exchange::new(Message::default());
6191 let result = producer.oneshot(exchange).await.unwrap();
6192
6193 let status = result
6194 .input
6195 .header("CamelHttpResponseCode")
6196 .and_then(|v| v.as_u64())
6197 .unwrap();
6198 assert_eq!(status, 500);
6199 }
6200
6201 #[tokio::test]
6202 async fn test_http_producer_uri_override() {
6203 use tower::ServiceExt;
6204
6205 let (url, _handle) = start_test_server().await;
6206 let ctx = test_producer_ctx();
6207
6208 let component = HttpComponent::new();
6209 let endpoint_ctx = NoOpComponentContext;
6210 let endpoint = component
6211 .create_endpoint(
6212 "http://localhost:1/does-not-exist?allowInternal=true",
6213 &endpoint_ctx,
6214 )
6215 .unwrap();
6216 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6217
6218 let mut exchange = Exchange::new(Message::default());
6219 exchange.input.set_header(
6220 "CamelHttpUri",
6221 serde_json::Value::String(format!("{url}/api")),
6222 );
6223
6224 let result = producer.oneshot(exchange).await.unwrap();
6225 let status = result
6226 .input
6227 .header("CamelHttpResponseCode")
6228 .and_then(|v| v.as_u64())
6229 .unwrap();
6230 assert_eq!(status, 200);
6231 }
6232
6233 #[tokio::test]
6234 async fn test_http_producer_response_headers_mapped() {
6235 use tower::ServiceExt;
6236
6237 let (url, _handle) = start_test_server().await;
6238 let ctx = test_producer_ctx();
6239
6240 let component = HttpComponent::new();
6241 let endpoint_ctx = NoOpComponentContext;
6242 let endpoint = component
6243 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
6244 .unwrap();
6245 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6246
6247 let exchange = Exchange::new(Message::default());
6248 let result = producer.oneshot(exchange).await.unwrap();
6249
6250 assert!(
6251 result.input.header("Content-Type").is_some(),
6252 "Response should have Content-Type header"
6253 );
6254 assert!(result.input.header("CamelHttpResponseText").is_some());
6255 }
6256
6257 async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
6262 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6263 let addr = listener.local_addr().unwrap();
6264 let url = format!("http://127.0.0.1:{}", addr.port());
6265
6266 let handle = tokio::spawn(async move {
6267 use tokio::io::{AsyncReadExt, AsyncWriteExt};
6268 loop {
6269 if let Ok((mut stream, _)) = listener.accept().await {
6270 tokio::spawn(async move {
6271 let mut buf = vec![0u8; 4096];
6272 let n = stream.read(&mut buf).await.unwrap_or(0);
6273 let request = String::from_utf8_lossy(&buf[..n]).to_string();
6274
6275 if request.contains("GET /final") {
6277 let body = r#"{"status":"final"}"#;
6278 let response = format!(
6279 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
6280 body.len(),
6281 body
6282 );
6283 let _ = stream.write_all(response.as_bytes()).await;
6284 } else {
6285 let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
6290 let _ = stream.write_all(response.as_bytes()).await;
6291 }
6292 });
6293 }
6294 }
6295 });
6296
6297 (url, handle)
6298 }
6299
6300 struct CapturedRequest {
6301 method: String,
6302 path: String,
6303 body: Vec<u8>,
6304 content_length: Option<String>,
6305 transfer_encoding: Option<String>,
6306 }
6307
6308 async fn capture_request(stream: &mut tokio::net::TcpStream) -> Option<CapturedRequest> {
6314 use tokio::io::AsyncReadExt;
6315
6316 let mut buf: Vec<u8> = Vec::new();
6318 let mut chunk = [0u8; 4096];
6319 let head_end: usize;
6320 loop {
6321 let n = stream.read(&mut chunk).await.unwrap_or(0);
6322 if n == 0 {
6323 return None;
6324 }
6325 buf.extend_from_slice(&chunk[..n]);
6326 if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
6327 head_end = pos + 4;
6328 break;
6329 }
6330 }
6331
6332 let head = String::from_utf8_lossy(&buf[..head_end]);
6334 let mut lines = head.split("\r\n");
6335 let request_line = lines.next().unwrap_or("");
6336 let mut parts = request_line.split_whitespace();
6337 let method = parts.next().unwrap_or("").to_string();
6338 let path = parts.next().unwrap_or("").to_string();
6339
6340 let mut content_length: Option<String> = None;
6341 let mut transfer_encoding: Option<String> = None;
6342 for line in lines {
6343 if let Some((name, value)) = line.split_once(':') {
6344 let name = name.trim().to_ascii_lowercase();
6345 let value = value.trim().to_string();
6346 if name == "content-length" {
6347 content_length = Some(value);
6348 } else if name == "transfer-encoding" {
6349 transfer_encoding = Some(value);
6350 }
6351 }
6352 }
6353
6354 let body_len: usize = content_length
6356 .as_deref()
6357 .and_then(|v| v.parse::<usize>().ok())
6358 .unwrap_or(0);
6359
6360 let mut body: Vec<u8> = buf[head_end..].to_vec();
6361 while body.len() < body_len {
6362 let n = stream.read(&mut chunk).await.unwrap_or(0);
6363 if n == 0 {
6364 break;
6365 }
6366 body.extend_from_slice(&chunk[..n]);
6367 }
6368 body.truncate(body_len);
6369
6370 Some(CapturedRequest {
6371 method,
6372 path,
6373 body,
6374 content_length,
6375 transfer_encoding,
6376 })
6377 }
6378
6379 async fn start_capture_server() -> (
6384 String,
6385 tokio::task::JoinHandle<()>,
6386 Arc<Mutex<Vec<CapturedRequest>>>,
6387 ) {
6388 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6389 let addr = listener.local_addr().unwrap();
6390 let url = format!("http://127.0.0.1:{}", addr.port());
6391
6392 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
6393 let captured_for_return = Arc::clone(&captured);
6394
6395 let handle = tokio::spawn(async move {
6396 use tokio::io::AsyncWriteExt;
6397 loop {
6398 if let Ok((mut stream, _)) = listener.accept().await {
6399 let captured = Arc::clone(&captured);
6400 tokio::spawn(async move {
6401 let Some(req) = capture_request(&mut stream).await else {
6402 return;
6403 };
6404 captured.lock().unwrap().push(req);
6405
6406 let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
6409 let _ = stream.write_all(response.as_bytes()).await;
6410 });
6411 }
6412 }
6413 });
6414
6415 (url, handle, captured_for_return)
6416 }
6417
6418 async fn start_redirect_capture_server() -> (
6424 String,
6425 tokio::task::JoinHandle<()>,
6426 Arc<Mutex<Vec<CapturedRequest>>>,
6427 ) {
6428 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6429 let addr = listener.local_addr().unwrap();
6430 let url = format!("http://127.0.0.1:{}", addr.port());
6431
6432 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
6433 let captured_for_return = Arc::clone(&captured);
6434
6435 let handle = tokio::spawn(async move {
6436 use tokio::io::AsyncWriteExt;
6437 loop {
6438 if let Ok((mut stream, _)) = listener.accept().await {
6439 let captured = Arc::clone(&captured);
6440 tokio::spawn(async move {
6441 let Some(req) = capture_request(&mut stream).await else {
6442 return;
6443 };
6444 let path = req.path.clone();
6445 captured.lock().unwrap().push(req);
6446
6447 let (status_line, location) = match path.as_str() {
6448 "/hop307" => ("HTTP/1.1 307 Temporary Redirect", Some("/final")),
6449 "/hop308" => ("HTTP/1.1 308 Permanent Redirect", Some("/final")),
6450 "/final" => ("HTTP/1.1 200 OK", None),
6451 _ => ("HTTP/1.1 404 Not Found", None),
6452 };
6453
6454 let response = match location {
6455 Some(loc) => format!(
6459 "{status_line}\r\nLocation: {loc}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
6460 ),
6461 None => format!("{status_line}\r\nContent-Length: 0\r\n\r\n"),
6462 };
6463 let _ = stream.write_all(response.as_bytes()).await;
6464 });
6465 }
6466 }
6467 });
6468
6469 (url, handle, captured_for_return)
6470 }
6471
6472 #[tokio::test]
6473 async fn test_get_with_body_sends_no_body_and_no_framing_headers() {
6474 use tower::ServiceExt;
6475
6476 let (url, _handle, captured) = start_capture_server().await;
6477 let ctx = test_producer_ctx();
6478
6479 let component = HttpComponent::with_config(HttpConfig::default());
6480 let endpoint_ctx = NoOpComponentContext;
6481 let endpoint = component
6482 .create_endpoint(
6483 &format!("{url}?httpMethod=GET&allowInternal=true"),
6484 &endpoint_ctx,
6485 )
6486 .unwrap();
6487 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6488
6489 let mut exchange = Exchange::new(Message::default());
6490 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6491
6492 let result = producer.oneshot(exchange).await.unwrap();
6493
6494 let status = result
6495 .input
6496 .header("CamelHttpResponseCode")
6497 .and_then(|v| v.as_u64())
6498 .unwrap();
6499 assert_eq!(status, 200);
6500
6501 let captured = captured.lock().unwrap();
6502 assert_eq!(captured.len(), 1, "expected exactly one captured request");
6503 let req = &captured[0];
6504 assert_eq!(req.method, "GET");
6505 assert_eq!(req.path, "/");
6508 assert!(req.body.is_empty(), "GET must not carry a body");
6509 assert!(
6510 req.content_length.is_none(),
6511 "suppressed request must not carry Content-Length"
6512 );
6513 assert!(
6514 req.transfer_encoding.is_none(),
6515 "suppressed request must not carry Transfer-Encoding"
6516 );
6517
6518 assert!(
6520 result.input.body.is_empty(),
6521 "exchange body must be consumed"
6522 );
6523 }
6524
6525 #[tokio::test]
6526 async fn test_head_with_body_suppressed_via_header() {
6527 use tower::ServiceExt;
6528
6529 let (url, _handle, captured) = start_capture_server().await;
6530 let ctx = test_producer_ctx();
6531
6532 let component = HttpComponent::with_config(HttpConfig::default());
6533 let endpoint_ctx = NoOpComponentContext;
6534 let endpoint = component
6535 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6536 .unwrap();
6537 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6538
6539 let mut exchange = Exchange::new(Message::default());
6540 exchange.input.set_header(
6541 "CamelHttpMethod",
6542 serde_json::Value::String("HEAD".to_string()),
6543 );
6544 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6545
6546 let result = producer.oneshot(exchange).await.unwrap();
6547 let status = result
6548 .input
6549 .header("CamelHttpResponseCode")
6550 .and_then(|v| v.as_u64())
6551 .unwrap();
6552 assert_eq!(status, 200);
6553
6554 let captured = captured.lock().unwrap();
6555 assert_eq!(captured.len(), 1);
6556 let req = &captured[0];
6557 assert_eq!(req.method, "HEAD");
6558 assert!(req.body.is_empty(), "HEAD must not carry a body");
6559 }
6560
6561 #[tokio::test]
6562 async fn test_delete_options_trace_with_body_suppressed() {
6563 use tower::ServiceExt;
6564
6565 let (url, _handle, captured) = start_capture_server().await;
6566 let ctx = test_producer_ctx();
6567 let component = HttpComponent::with_config(HttpConfig::default());
6568 let endpoint_ctx = NoOpComponentContext;
6569
6570 for method in ["DELETE", "OPTIONS", "TRACE"] {
6571 let endpoint = component
6572 .create_endpoint(
6573 &format!("{url}?httpMethod={method}&allowInternal=true"),
6574 &endpoint_ctx,
6575 )
6576 .unwrap();
6577 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6578
6579 let mut exchange = Exchange::new(Message::default());
6580 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6581
6582 let result = producer.oneshot(exchange).await.unwrap();
6583 let status = result
6584 .input
6585 .header("CamelHttpResponseCode")
6586 .and_then(|v| v.as_u64())
6587 .unwrap();
6588 assert_eq!(status, 200, "method {method} should succeed");
6589 }
6590
6591 let captured = captured.lock().unwrap();
6592 assert_eq!(captured.len(), 3, "expected three captured requests");
6593 for method in ["DELETE", "OPTIONS", "TRACE"] {
6594 let req = captured
6595 .iter()
6596 .find(|r| r.method == method)
6597 .unwrap_or_else(|| panic!("missing captured request for {method}"));
6598 assert!(req.body.is_empty(), "{} must not carry a body", req.method);
6599 }
6600 }
6601
6602 #[tokio::test]
6603 async fn test_post_put_patch_with_body_still_sent() {
6604 use tower::ServiceExt;
6605
6606 let (url, _handle, captured) = start_capture_server().await;
6607 let ctx = test_producer_ctx();
6608 let component = HttpComponent::with_config(HttpConfig::default());
6609 let endpoint_ctx = NoOpComponentContext;
6610
6611 for method in ["POST", "PUT", "PATCH"] {
6612 let endpoint = component
6613 .create_endpoint(
6614 &format!("{url}?httpMethod={method}&allowInternal=true"),
6615 &endpoint_ctx,
6616 )
6617 .unwrap();
6618 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6619
6620 let payload = format!("body-for-{method}");
6621 let mut exchange = Exchange::new(Message::default());
6622 exchange.input.body = Body::Bytes(bytes::Bytes::from(payload.as_bytes().to_vec()));
6623
6624 let result = producer.oneshot(exchange).await.unwrap();
6625 let status = result
6626 .input
6627 .header("CamelHttpResponseCode")
6628 .and_then(|v| v.as_u64())
6629 .unwrap();
6630 assert_eq!(status, 200, "method {method} should succeed");
6631 }
6632
6633 let captured = captured.lock().unwrap();
6634 assert_eq!(captured.len(), 3, "expected three captured requests");
6635 for method in ["POST", "PUT", "PATCH"] {
6636 let req = captured
6637 .iter()
6638 .find(|r| r.method == method)
6639 .unwrap_or_else(|| panic!("missing captured request for {method}"));
6640 let expected = format!("body-for-{method}");
6641 assert!(!req.body.is_empty(), "{method} must still carry its body");
6642 assert_eq!(req.body, expected.as_bytes(), "{method} body mismatch");
6643 }
6644 }
6645
6646 #[tokio::test]
6650 async fn test_stream_body_under_get_not_attached() {
6651 use tower::ServiceExt;
6652
6653 let (url, _handle, captured) = start_capture_server().await;
6654 let ctx = test_producer_ctx();
6655
6656 let component = HttpComponent::with_config(HttpConfig::default());
6657 let endpoint_ctx = NoOpComponentContext;
6658 let endpoint = component
6659 .create_endpoint(
6660 &format!("{url}?httpMethod=GET&allowInternal=true"),
6661 &endpoint_ctx,
6662 )
6663 .unwrap();
6664 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6665
6666 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
6667 vec![Ok(bytes::Bytes::from_static(b"stream-body"))];
6668 let stream = Box::pin(futures::stream::iter(chunks));
6669 let mut exchange = Exchange::new(Message::default());
6670 exchange.input.body = Body::Stream(StreamBody {
6671 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
6672 metadata: StreamMetadata::default(),
6673 });
6674
6675 let result = producer.oneshot(exchange).await.unwrap();
6676
6677 let status = result
6678 .input
6679 .header("CamelHttpResponseCode")
6680 .and_then(|v| v.as_u64())
6681 .unwrap();
6682 assert_eq!(status, 200);
6683
6684 let captured = captured.lock().unwrap();
6685 assert_eq!(captured.len(), 1, "expected exactly one captured request");
6686 assert!(
6687 captured[0].body.is_empty(),
6688 "GET must not carry a stream body"
6689 );
6690 assert!(
6691 captured[0].transfer_encoding.is_none(),
6692 "suppressed request must not carry Transfer-Encoding"
6693 );
6694 assert!(
6695 captured[0].content_length.is_none(),
6696 "suppressed request must not carry Content-Length"
6697 );
6698 assert!(
6699 result.input.body.is_empty(),
6700 "exchange body must be consumed to Empty, not left as a stream"
6701 );
6702 }
6703
6704 #[tokio::test]
6708 async fn test_redirect_hops_never_replay_suppressed_body() {
6709 use tower::ServiceExt;
6710
6711 let (url, _handle, captured) = start_redirect_capture_server().await;
6712 let ctx = test_producer_ctx();
6713
6714 let component =
6715 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6716 let endpoint_ctx = NoOpComponentContext;
6717
6718 for path in ["/hop307", "/hop308"] {
6719 let endpoint = component
6720 .create_endpoint(
6721 &format!("{url}{path}?httpMethod=GET&allowInternal=true"),
6722 &endpoint_ctx,
6723 )
6724 .unwrap();
6725 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6726
6727 let mut exchange = Exchange::new(Message::default());
6728 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6729
6730 let result = producer.oneshot(exchange).await.unwrap();
6731 let status = result
6732 .input
6733 .header("CamelHttpResponseCode")
6734 .and_then(|v| v.as_u64())
6735 .unwrap();
6736 assert_eq!(
6737 status, 200,
6738 "redirect chain for {path} should end at /final"
6739 );
6740 }
6741
6742 let captured = captured.lock().unwrap();
6744 assert_eq!(captured.len(), 4, "expected 2 chains × 2 hops");
6745 for req in captured.iter() {
6746 assert!(
6747 req.body.is_empty(),
6748 "hop {} {} must not carry a body",
6749 req.method,
6750 req.path
6751 );
6752 }
6753 }
6754
6755 #[tracing_test::traced_test]
6764 #[tokio::test]
6765 async fn test_suppressed_body_logs_exactly_one_warn() {
6766 use tower::ServiceExt;
6767
6768 let (url, _handle, _captured) = start_capture_server().await;
6769 let ctx = test_producer_ctx();
6770
6771 let component = HttpComponent::with_config(HttpConfig::default());
6772 let endpoint_ctx = NoOpComponentContext;
6773 let endpoint = component
6774 .create_endpoint(
6775 &format!("{url}?httpMethod=GET&allowInternal=true"),
6776 &endpoint_ctx,
6777 )
6778 .unwrap();
6779 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6780
6781 let mut exchange = Exchange::new(Message::default());
6782 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6783 let correlation_id = exchange.correlation_id().to_string();
6784
6785 let result = producer.oneshot(exchange).await.unwrap();
6786 let status = result
6787 .input
6788 .header("CamelHttpResponseCode")
6789 .and_then(|v| v.as_u64())
6790 .unwrap();
6791 assert_eq!(status, 200);
6792
6793 logs_assert(|lines: &[&str]| {
6794 let hits = lines
6795 .iter()
6796 .filter(|l| {
6797 l.contains("dropping request body")
6798 && l.contains("method=GET")
6799 && l.contains(&format!("correlation_id={correlation_id}"))
6800 })
6801 .count();
6802 match hits {
6803 1 => Ok(()),
6804 n => Err(format!("expected exactly one body-drop warn, found {n}")),
6805 }
6806 });
6807 }
6808
6809 #[tracing_test::traced_test]
6810 #[tokio::test]
6811 async fn test_empty_body_get_emits_no_warn() {
6812 use tower::ServiceExt;
6813
6814 let (url, _handle, _captured) = start_capture_server().await;
6815 let ctx = test_producer_ctx();
6816
6817 let component = HttpComponent::with_config(HttpConfig::default());
6818 let endpoint_ctx = NoOpComponentContext;
6819 let endpoint = component
6820 .create_endpoint(
6821 &format!("{url}?httpMethod=GET&allowInternal=true"),
6822 &endpoint_ctx,
6823 )
6824 .unwrap();
6825 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6826
6827 let exchange = Exchange::new(Message::default());
6828 let result = producer.oneshot(exchange).await.unwrap();
6829 let status = result
6830 .input
6831 .header("CamelHttpResponseCode")
6832 .and_then(|v| v.as_u64())
6833 .unwrap();
6834 assert_eq!(status, 200);
6835
6836 logs_assert(|lines: &[&str]| {
6837 let hits = lines
6838 .iter()
6839 .filter(|l| l.contains("dropping request body"))
6840 .count();
6841 match hits {
6842 0 => Ok(()),
6843 n => Err(format!("expected no body-drop warn, found {n}")),
6844 }
6845 });
6846 }
6847
6848 #[tokio::test]
6849 async fn test_follow_redirects_false_does_not_follow() {
6850 use tower::ServiceExt;
6851
6852 let (url, _handle) = start_redirect_server().await;
6853 let ctx = test_producer_ctx();
6854
6855 let component =
6856 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
6857 let endpoint_ctx = NoOpComponentContext;
6858 let endpoint = component
6859 .create_endpoint(
6860 &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
6861 &endpoint_ctx,
6862 )
6863 .unwrap();
6864 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6865
6866 let exchange = Exchange::new(Message::default());
6867 let result = producer.oneshot(exchange).await.unwrap();
6868
6869 let status = result
6871 .input
6872 .header("CamelHttpResponseCode")
6873 .and_then(|v| v.as_u64())
6874 .unwrap();
6875 assert_eq!(
6876 status, 302,
6877 "Should NOT follow redirect when followRedirects=false"
6878 );
6879 }
6880
6881 #[tokio::test]
6882 async fn test_follow_redirects_true_follows_redirect() {
6883 use tower::ServiceExt;
6884
6885 let (url, _handle) = start_redirect_server().await;
6886 let ctx = test_producer_ctx();
6887
6888 let component =
6889 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6890 let endpoint_ctx = NoOpComponentContext;
6891 let endpoint = component
6892 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6893 .unwrap();
6894 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6895
6896 let exchange = Exchange::new(Message::default());
6897 let result = producer.oneshot(exchange).await.unwrap();
6898
6899 let status = result
6901 .input
6902 .header("CamelHttpResponseCode")
6903 .and_then(|v| v.as_u64())
6904 .unwrap();
6905 assert_eq!(
6906 status, 200,
6907 "Should follow redirect when followRedirects=true"
6908 );
6909 }
6910
6911 #[tokio::test]
6914 async fn test_redirect_to_private_ip_is_ssrf_blocked() {
6915 use tower::ServiceExt;
6916
6917 let (url, _handle) = start_redirect_server().await;
6919 let ctx = test_producer_ctx();
6920
6921 let component =
6922 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6923 let endpoint_ctx = NoOpComponentContext;
6924 let endpoint = component
6925 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6926 .unwrap();
6927 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6928
6929 let exchange = Exchange::new(Message::default());
6930 let result = producer.oneshot(exchange).await;
6931
6932 assert!(
6934 result.is_ok(),
6935 "Redirect should succeed with allowInternal=true, got: {:?}",
6936 result
6937 );
6938 let exchange = result.unwrap();
6939 let status = exchange
6940 .input
6941 .header("CamelHttpResponseCode")
6942 .and_then(|v| v.as_u64())
6943 .unwrap();
6944 assert_eq!(status, 200, "Should follow redirect to /final");
6945 }
6946
6947 #[tokio::test]
6949 async fn test_redirect_to_private_ip_allowed_when_configured() {
6950 use tower::ServiceExt;
6951
6952 let (url, _handle) = start_redirect_server().await;
6954 let ctx = test_producer_ctx();
6955
6956 let component =
6957 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6958 let endpoint_ctx = NoOpComponentContext;
6959 let endpoint = component
6960 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6961 .unwrap();
6962 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6963
6964 let exchange = Exchange::new(Message::default());
6965 let result = producer.oneshot(exchange).await.unwrap();
6966
6967 let status = result
6968 .input
6969 .header("CamelHttpResponseCode")
6970 .and_then(|v| v.as_u64())
6971 .unwrap();
6972 assert_eq!(
6973 status, 200,
6974 "Should follow redirect to private IP when allowInternal=true"
6975 );
6976 }
6977
6978 #[tokio::test]
6981 async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
6982 use tower::ServiceExt;
6983
6984 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6986 let addr = listener.local_addr().unwrap();
6987 let url = format!("http://127.0.0.1:{}", addr.port());
6988
6989 let handle = tokio::spawn(async move {
6990 use tokio::io::{AsyncReadExt, AsyncWriteExt};
6991 loop {
6992 if let Ok((mut stream, _)) = listener.accept().await {
6993 tokio::spawn(async move {
6994 let mut buf = vec![0u8; 4096];
6995 let _ = stream.read(&mut buf).await;
6996 let response = "HTTP/1.1 302 Found\r\nLocation: http://169.254.169.254/latest/meta-data/\r\nContent-Length: 0\r\n\r\n";
6998 let _ = stream.write_all(response.as_bytes()).await;
6999 });
7000 }
7001 }
7002 });
7003
7004 let ctx = test_producer_ctx();
7005 let component =
7006 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
7007 let endpoint_ctx = NoOpComponentContext;
7008 let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
7010 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7011
7012 let exchange = Exchange::new(Message::default());
7013 let result = producer.oneshot(exchange).await;
7014
7015 assert!(
7017 result.is_err(),
7018 "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
7019 );
7020 let err = result.unwrap_err().to_string();
7021 assert!(
7022 err.contains("blocked IP")
7023 || err.contains("private IP")
7024 || err.contains("SSRF")
7025 || err.contains("not allowed"),
7026 "Error should mention SSRF/IP blocking, got: {err}"
7027 );
7028
7029 handle.abort();
7030 }
7031
7032 #[tokio::test]
7034 async fn test_too_many_redirects_returns_error() {
7035 use tower::ServiceExt;
7036
7037 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7039 let addr = listener.local_addr().unwrap();
7040 let url = format!("http://127.0.0.1:{}", addr.port());
7041
7042 let handle = tokio::spawn(async move {
7043 use tokio::io::{AsyncReadExt, AsyncWriteExt};
7044 loop {
7045 if let Ok((mut stream, _)) = listener.accept().await {
7046 tokio::spawn(async move {
7047 let mut buf = vec![0u8; 4096];
7048 let _ = stream.read(&mut buf).await;
7049 let response = "HTTP/1.1 302 Found\r\nLocation: /loop\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
7054 let _ = stream.write_all(response.as_bytes()).await;
7055 });
7056 }
7057 }
7058 });
7059
7060 let ctx = test_producer_ctx();
7061 let component =
7062 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
7063 let endpoint_ctx = NoOpComponentContext;
7064 let endpoint = component
7065 .create_endpoint(
7066 &format!("{url}?allowInternal=true&maxRedirects=2"),
7067 &endpoint_ctx,
7068 )
7069 .unwrap();
7070 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7071
7072 let exchange = Exchange::new(Message::default());
7073 let result = producer.oneshot(exchange).await;
7074
7075 match result {
7083 Err(e) => {
7084 let msg = e.to_string();
7086 assert!(
7087 msg.contains("HTTP operation failed") || msg.contains("302"),
7088 "expected redirect-after-exhaustion error, got: {msg}"
7089 );
7090 }
7091 Ok(ex) => {
7092 let response_code = ex
7093 .input
7094 .header("CamelHttpResponseCode")
7095 .and_then(|v| v.as_u64());
7096 assert_eq!(
7097 response_code,
7098 Some(302),
7099 "expected 302 after exhausting redirects"
7100 );
7101 }
7102 }
7103
7104 handle.abort();
7105 }
7106
7107 #[tokio::test]
7108 async fn test_query_params_forwarded_to_http_request() {
7109 use tower::ServiceExt;
7110
7111 let (url, _handle) = start_test_server().await;
7112 let ctx = test_producer_ctx();
7113
7114 let component = HttpComponent::new();
7115 let endpoint_ctx = NoOpComponentContext;
7116 let endpoint = component
7118 .create_endpoint(
7119 &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
7120 &endpoint_ctx,
7121 )
7122 .unwrap();
7123 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7124
7125 let exchange = Exchange::new(Message::default());
7126 let result = producer.oneshot(exchange).await.unwrap();
7127
7128 let status = result
7131 .input
7132 .header("CamelHttpResponseCode")
7133 .and_then(|v| v.as_u64())
7134 .unwrap();
7135 assert_eq!(status, 200);
7136 }
7137
7138 #[test]
7139 fn test_non_camel_query_params_are_forwarded() {
7140 let config = HttpEndpointConfig::from_uri(
7143 "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
7144 )
7145 .unwrap();
7146
7147 assert_eq!(
7150 config.raw_query.as_deref(),
7151 Some("apiKey=secret123&httpMethod=GET&token=abc456")
7152 );
7153 assert!(config.query_params.is_empty());
7154 }
7155
7156 #[test]
7157 fn test_authored_query_bytes_survive_resolve_url() {
7158 let config =
7159 HttpEndpointConfig::from_uri("http://example.com/api?q=hello%20world&tag=a+b").unwrap();
7160 let exchange = Exchange::new(Message::default());
7161
7162 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
7163
7164 assert!(url.contains("q=hello%20world"), "url was: {url}");
7167 assert!(url.contains("tag=a+b"), "url was: {url}");
7168 }
7169
7170 async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
7175 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7176 let addr = listener.local_addr().unwrap();
7177 let url = format!("http://127.0.0.1:{}", addr.port());
7178
7179 let handle = tokio::spawn(async move {
7180 loop {
7181 if let Ok((mut stream, _)) = listener.accept().await {
7182 let delay = delay_ms;
7183 tokio::spawn(async move {
7184 use tokio::io::{AsyncReadExt, AsyncWriteExt};
7185 let mut buf = vec![0u8; 4096];
7186 let _ = stream.read(&mut buf).await;
7187 let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
7189 let _ = stream.write_all(headers.as_bytes()).await;
7190 tokio::time::sleep(Duration::from_millis(delay)).await;
7192 let body = r#"{"status":"slow"}"#;
7193 let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
7194 let _ = stream.write_all(chunk.as_bytes()).await;
7195 });
7196 }
7197 }
7198 });
7199
7200 (url, handle)
7201 }
7202
7203 #[tokio::test]
7204 async fn test_http_producer_timeout() {
7205 use tower::ServiceExt;
7206
7207 let (url, _handle) = start_slow_server(500).await;
7209 let ctx = test_producer_ctx();
7210
7211 let component = HttpComponent::with_config(
7212 HttpConfig::default()
7213 .with_read_timeout_ms(100)
7214 .with_response_timeout_ms(30_000), );
7216 let endpoint_ctx = NoOpComponentContext;
7217 let endpoint = component
7218 .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
7219 .unwrap();
7220 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7221
7222 let exchange = Exchange::new(Message::default());
7223 let result = producer.oneshot(exchange).await;
7224
7225 assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
7226 let err = result.unwrap_err().to_string();
7227 assert!(
7228 err.contains("Read timeout") || err.contains("timeout"),
7229 "Error should mention timeout, got: {}",
7230 err
7231 );
7232 }
7233
7234 #[tokio::test]
7235 async fn test_http_producer_no_timeout_when_fast() {
7236 use tower::ServiceExt;
7237
7238 let (url, _handle) = start_test_server().await;
7239 let ctx = test_producer_ctx();
7240
7241 let component =
7242 HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
7243 let endpoint_ctx = NoOpComponentContext;
7244 let endpoint = component
7245 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
7246 .unwrap();
7247 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7248
7249 let exchange = Exchange::new(Message::default());
7250 let result = producer.oneshot(exchange).await.unwrap();
7251
7252 let status = result
7253 .input
7254 .header("CamelHttpResponseCode")
7255 .and_then(|v| v.as_u64())
7256 .unwrap();
7257 assert_eq!(status, 200);
7258 }
7259
7260 #[tokio::test]
7265 async fn test_http_producer_blocks_metadata_endpoint() {
7266 use tower::ServiceExt;
7267
7268 let ctx = test_producer_ctx();
7269 let component = HttpComponent::new();
7270 let endpoint_ctx = NoOpComponentContext;
7271 let endpoint = component
7272 .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
7273 .unwrap();
7274 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7275
7276 let mut exchange = Exchange::new(Message::default());
7277 exchange.input.set_header(
7278 "CamelHttpUri",
7279 serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
7280 );
7281
7282 let result = producer.oneshot(exchange).await;
7283 assert!(result.is_err(), "Should block AWS metadata endpoint");
7284
7285 let err = result.unwrap_err();
7286 assert!(
7287 err.to_string().contains("Private IP"),
7288 "Error should mention private IP blocking, got: {}",
7289 err
7290 );
7291 }
7292
7293 #[test]
7294 fn test_ssrf_config_defaults() {
7295 let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
7296 assert!(
7297 !config.allow_internal,
7298 "Private IPs should be blocked by default"
7299 );
7300 assert!(
7301 config.blocked_hosts.is_empty(),
7302 "Blocked hosts should be empty by default"
7303 );
7304 }
7305
7306 #[test]
7307 fn test_ssrf_config_allow_internal() {
7308 let config =
7309 HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
7310 assert!(
7311 config.allow_internal,
7312 "Private IPs should be allowed when explicitly set"
7313 );
7314 }
7315
7316 #[test]
7317 fn test_uri_option_allow_cleartext_parses() {
7318 let config =
7319 HttpEndpointConfig::from_uri("http://example.com/?allowCleartext=true").unwrap();
7320 assert!(
7321 config.allow_cleartext,
7322 "allowCleartext=true must parse into the endpoint config"
7323 );
7324
7325 let plain = HttpEndpointConfig::from_uri("http://example.com/").unwrap();
7326 assert!(
7327 !plain.allow_cleartext,
7328 "cleartext consent must default to false"
7329 );
7330
7331 let err =
7332 HttpEndpointConfig::from_uri("http://example.com/?allowCleartext=banana").unwrap_err();
7333 assert!(
7334 matches!(&err, CamelError::InvalidUri(msg) if msg.contains("allowCleartext")),
7335 "bad allowCleartext value must yield InvalidUri naming the option, got: {err:?}"
7336 );
7337 }
7338
7339 #[test]
7343 fn test_camel_http_uri_override_public_cleartext_follows_endpoint_flags() {
7344 let endpoint =
7345 HttpEndpointConfig::from_uri("http://localhost/?allowCleartext=false").unwrap();
7346 let err = crate::ssrf::validate_url_for_ssrf("http://93.184.216.34/exfil", &endpoint)
7347 .expect_err("public cleartext override must be rejected without consent");
7348 assert!(
7349 err.to_string().contains("allowCleartext"),
7350 "error must name the remedy, got: {err}"
7351 );
7352
7353 let endpoint =
7354 HttpEndpointConfig::from_uri("http://localhost/?allowCleartext=true").unwrap();
7355 assert!(
7356 crate::ssrf::validate_url_for_ssrf("http://93.184.216.34/exfil", &endpoint).is_ok(),
7357 "endpoint consent must admit a public cleartext override"
7358 );
7359 }
7360
7361 #[test]
7362 fn test_ssrf_config_blocked_hosts() {
7363 let config = HttpEndpointConfig::from_uri(
7364 "http://example.com/api?blockedHosts=evil.com,malware.net",
7365 )
7366 .unwrap();
7367 assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
7368 }
7369
7370 #[tokio::test]
7371 async fn test_http_producer_blocks_localhost() {
7372 use tower::ServiceExt;
7373
7374 let ctx = test_producer_ctx();
7375 let component = HttpComponent::new();
7376 let endpoint_ctx = NoOpComponentContext;
7377 let endpoint = component
7378 .create_endpoint("http://example.com/api", &endpoint_ctx)
7379 .unwrap();
7380 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7381
7382 let mut exchange = Exchange::new(Message::default());
7383 exchange.input.set_header(
7384 "CamelHttpUri",
7385 serde_json::Value::String("http://localhost:8080/internal".to_string()),
7386 );
7387
7388 let result = producer.oneshot(exchange).await;
7389 assert!(result.is_err(), "Should block localhost");
7390 }
7391
7392 #[tokio::test]
7393 async fn test_http_producer_blocks_loopback_ip() {
7394 use tower::ServiceExt;
7395
7396 let ctx = test_producer_ctx();
7397 let component = HttpComponent::new();
7398 let endpoint_ctx = NoOpComponentContext;
7399 let endpoint = component
7400 .create_endpoint("http://example.com/api", &endpoint_ctx)
7401 .unwrap();
7402 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7403
7404 let mut exchange = Exchange::new(Message::default());
7405 exchange.input.set_header(
7406 "CamelHttpUri",
7407 serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
7408 );
7409
7410 let result = producer.oneshot(exchange).await;
7411 assert!(result.is_err(), "Should block loopback IP");
7412 }
7413
7414 #[tokio::test]
7415 async fn test_http_producer_allows_private_ip_when_enabled() {
7416 use tower::ServiceExt;
7417
7418 let ctx = test_producer_ctx();
7419 let component = HttpComponent::new();
7420 let endpoint_ctx = NoOpComponentContext;
7421 let endpoint = component
7424 .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
7425 .unwrap();
7426 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7427
7428 let exchange = Exchange::new(Message::default());
7429
7430 let result = producer.oneshot(exchange).await;
7433 if let Err(ref e) = result {
7435 let err_str = e.to_string();
7436 assert!(
7437 !err_str.contains("Private IP") && !err_str.contains("not allowed"),
7438 "Should not be SSRF error, got: {}",
7439 err_str
7440 );
7441 }
7442 }
7443
7444 #[test]
7449 fn test_http_server_config_parse() {
7450 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
7451 assert_eq!(cfg.host, "0.0.0.0");
7452 assert_eq!(cfg.port, 8080);
7453 assert_eq!(cfg.path, "/orders");
7454 assert_eq!(cfg.max_inflight_requests, 1024);
7455 }
7456
7457 #[test]
7458 fn test_http_server_config_scheme() {
7459 assert_eq!(HttpServerConfig::scheme(), "http");
7461 }
7462
7463 #[test]
7464 fn test_http_server_config_from_components() {
7465 let components = camel_component_api::UriComponents {
7467 scheme: "https".to_string(),
7468 path: "//0.0.0.0:8443/api".to_string(),
7469 params: std::collections::HashMap::from([
7470 ("maxRequestBody".to_string(), "5242880".to_string()),
7471 ("maxInflightRequests".to_string(), "7".to_string()),
7472 ]),
7473 raw_query: None,
7474 };
7475 let cfg = HttpServerConfig::from_components(components).unwrap();
7476 assert_eq!(cfg.host, "0.0.0.0");
7477 assert_eq!(cfg.port, 8443);
7478 assert_eq!(cfg.path, "/api");
7479 assert_eq!(cfg.max_request_body, 5242880);
7480 assert_eq!(cfg.max_inflight_requests, 7);
7481 }
7482
7483 #[test]
7484 fn test_http_server_config_default_path() {
7485 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
7486 assert_eq!(cfg.path, "/");
7487 }
7488
7489 #[test]
7490 fn test_http_server_config_wrong_scheme() {
7491 assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
7492 }
7493
7494 #[test]
7495 fn test_http_server_config_invalid_port() {
7496 assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
7497 }
7498
7499 #[test]
7500 fn test_http_server_config_default_port_by_scheme() {
7501 let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
7503 assert_eq!(cfg_http.port, 80);
7504
7505 let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
7507 assert_eq!(cfg_https.port, 443);
7508 }
7509
7510 #[test]
7511 fn test_request_envelope_and_reply_are_send() {
7512 fn assert_send<T: Send>() {}
7513 assert_send::<RequestEnvelope>();
7514 assert_send::<HttpReply>();
7515 }
7516
7517 #[test]
7522 fn test_server_registry_global_is_singleton() {
7523 let r1 = ServerRegistry::global();
7524 let r2 = ServerRegistry::global();
7525 assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
7526 }
7527
7528 #[allow(clippy::await_holding_lock)]
7529 #[tokio::test]
7530 async fn test_concurrent_get_or_spawn_returns_same_registry() {
7531 let _guard = lock_registry_test_mutex();
7532 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7533 let port = listener.local_addr().unwrap().port();
7534 drop(listener);
7535
7536 let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
7537 Arc::new(std::sync::Mutex::new(Vec::new()));
7538
7539 let mut handles = Vec::new();
7540 for _ in 0..4 {
7541 let results = results.clone();
7542 handles.push(tokio::spawn(async move {
7543 let registry = ServerRegistry::global()
7544 .get_or_spawn(
7545 "127.0.0.1",
7546 port,
7547 2 * 1024 * 1024,
7548 10 * 1024 * 1024,
7549 1024,
7550 test_rt(),
7551 "test-route".into(),
7552 None,
7553 )
7554 .await
7555 .unwrap();
7556 results.lock().unwrap().push(registry);
7557 }));
7558 }
7559
7560 for h in handles {
7561 h.await.unwrap();
7562 }
7563
7564 let registries = results.lock().unwrap();
7565 assert_eq!(registries.len(), 4);
7566 for i in 1..registries.len() {
7567 assert!(
7568 Arc::ptr_eq(®istries[0].inner, ®istries[i].inner),
7569 "all concurrent callers should get same route registry"
7570 );
7571 }
7572 }
7573
7574 #[test]
7575 fn test_server_registry_distinguishes_host_and_port() {
7576 let _guard = lock_registry_test_mutex();
7577 let rt = tokio::runtime::Runtime::new().expect("runtime");
7578 rt.block_on(async {
7579 let registry = ServerRegistry::global();
7580 let d1 = registry
7584 .get_or_spawn(
7585 "127.0.0.1",
7586 0,
7587 1024 * 1024,
7588 10 * 1024 * 1024,
7589 1024,
7590 test_rt(),
7591 "test-route-1".into(),
7592 None,
7593 )
7594 .await;
7595 let d2 = registry
7596 .get_or_spawn(
7597 "0.0.0.0",
7598 0,
7599 1024 * 1024,
7600 10 * 1024 * 1024,
7601 1024,
7602 test_rt(),
7603 "test-route-2".into(),
7604 None,
7605 )
7606 .await;
7607 assert!(d1.is_ok());
7608 assert!(d2.is_ok());
7609 assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
7610 });
7611 }
7612
7613 #[allow(clippy::await_holding_lock)]
7614 #[tokio::test]
7615 async fn test_shared_server_max_request_body_policy_is_deterministic() {
7616 let _guard = lock_registry_test_mutex();
7617 let registry = ServerRegistry::global();
7618 let d1 = registry
7620 .get_or_spawn(
7621 "127.0.0.1",
7622 9991,
7623 1024 * 1024,
7624 10 * 1024 * 1024,
7625 1024,
7626 test_rt(),
7627 "test-route".into(),
7628 None,
7629 )
7630 .await;
7631 assert!(d1.is_ok());
7632
7633 let d2 = registry
7636 .get_or_spawn(
7637 "127.0.0.1",
7638 9991,
7639 2 * 1024 * 1024,
7640 10 * 1024 * 1024,
7641 1024,
7642 test_rt(),
7643 "test-route-2".into(),
7644 None,
7645 )
7646 .await;
7647 assert!(d2.is_err());
7648 let err = d2.unwrap_err();
7649 assert!(
7650 err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
7651 "Expected incompatible maxRequestBody error, got: {}",
7652 err
7653 );
7654 }
7655
7656 #[test]
7657 fn test_server_registry_reset_clears_entries() {
7658 let _guard = lock_registry_test_mutex();
7659 let rt = tokio::runtime::Runtime::new().expect("runtime");
7660 rt.block_on(async {
7661 let d1 = ServerRegistry::global()
7663 .get_or_spawn(
7664 "127.0.0.1",
7665 9992,
7666 1024 * 1024,
7667 10 * 1024 * 1024,
7668 1024,
7669 test_rt(),
7670 "test-route".into(),
7671 None,
7672 )
7673 .await;
7674 assert!(d1.is_ok());
7675
7676 let guard = ServerRegistry::global().inner.lock().expect("lock");
7678 assert!(guard.entries.contains_key(&("127.0.0.1".to_string(), 9992)));
7679 drop(guard);
7680
7681 ServerRegistry::reset();
7683
7684 let guard = ServerRegistry::global().inner.lock().expect("lock");
7686 assert!(
7687 guard.entries.is_empty(),
7688 "registry should be empty after reset, has {} entries",
7689 guard.entries.len()
7690 );
7691 });
7692 }
7693
7694 #[allow(clippy::await_holding_lock)]
7695 #[tokio::test]
7696 async fn registry_rejects_tls_on_plain_port() {
7697 let _guard = lock_registry_test_mutex();
7702 ServerRegistry::reset();
7703 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
7704
7705 let _r1 = ServerRegistry::global()
7707 .get_or_spawn(
7708 "127.0.0.1",
7709 0,
7710 1024,
7711 1024,
7712 16,
7713 Arc::clone(&rt),
7714 "route-1".into(),
7715 None, )
7717 .await;
7718
7719 let result = ServerRegistry::global()
7721 .get_or_spawn(
7722 "127.0.0.1",
7723 0,
7724 1024,
7725 1024,
7726 16,
7727 Arc::clone(&rt),
7728 "route-2".into(),
7729 Some(crate::config::ServerTlsConfig {
7730 cert_path: "/x.pem".into(),
7731 key_path: "/y.pem".into(),
7732 }),
7733 )
7734 .await;
7735 assert!(result.is_err(), "must reject TLS on plain port");
7736 }
7737
7738 #[allow(clippy::await_holding_lock)]
7744 #[tokio::test]
7745 async fn test_unregister_last_http_route_keeps_server_alive() {
7746 let _guard = lock_registry_test_mutex();
7747 ServerRegistry::reset();
7748 let registry = ServerRegistry::global();
7749
7750 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7751 let port = listener.local_addr().unwrap().port();
7752 drop(listener); let rt = test_rt();
7754
7755 let _r1 = registry
7758 .get_or_spawn(
7759 "127.0.0.1",
7760 port,
7761 1024 * 1024,
7762 10 * 1024 * 1024,
7763 16,
7764 rt.clone(),
7765 "test-route-1".into(),
7766 None,
7767 )
7768 .await
7769 .unwrap();
7770 let _r2 = registry
7771 .get_or_spawn(
7772 "127.0.0.1",
7773 port,
7774 1024 * 1024,
7775 10 * 1024 * 1024,
7776 16,
7777 rt,
7778 "test-route-2".into(),
7779 None,
7780 )
7781 .await
7782 .unwrap();
7783
7784 let key = ("127.0.0.1".to_string(), port);
7785 let cell = {
7786 let guard = registry.inner.lock().expect("lock");
7787 guard.entries.get(&key).expect("entry should exist").clone()
7788 };
7789
7790 registry.unregister("127.0.0.1", port).await;
7792 {
7793 let handle = cell
7794 .get()
7795 .expect("handle should still exist after first unregister");
7796 assert!(
7797 !handle.monitor_task.is_finished(),
7798 "monitor task should still be alive after first unregister"
7799 );
7800 }
7801
7802 registry.unregister("127.0.0.1", port).await;
7804 tokio::time::sleep(Duration::from_millis(20)).await;
7805 {
7806 let handle = cell
7807 .get()
7808 .expect("handle should still exist after last unregister");
7809 assert!(
7810 !handle.monitor_task.is_finished(),
7811 "monitor task should still be alive — server is process-lifetime"
7812 );
7813 }
7814
7815 {
7817 let guard = registry.inner.lock().expect("lock");
7818 assert!(
7819 guard.entries.contains_key(&key),
7820 "entry should remain in registry — server kept alive for restart"
7821 );
7822 }
7823 }
7824
7825 async fn clone_fixture_listener() -> (
7834 tokio::net::TcpListener,
7835 std::net::TcpListener,
7836 std::net::SocketAddr,
7837 ) {
7838 let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind std listener");
7839 let probe = l.try_clone().expect("clone probe");
7840 l.set_nonblocking(true).expect("set_nonblocking");
7841 let listener = tokio::net::TcpListener::from_std(l).expect("from_std");
7842 let addr = listener.local_addr().expect("local_addr");
7843 (listener, probe, addr)
7844 }
7845
7846 fn staged_limits() -> (usize, usize, usize) {
7848 (1024 * 1024, 10 * 1024 * 1024, 1024)
7849 }
7850
7851 #[allow(clippy::await_holding_lock)]
7852 #[tokio::test]
7853 async fn staged_listener_first_spawn_serves_without_second_bind() {
7854 let _guard = lock_registry_test_mutex();
7855 ServerRegistry::reset();
7856 let registry = ServerRegistry::global();
7857 let (listener, _probe, addr) = clone_fixture_listener().await;
7858 let port = addr.port();
7859 registry
7860 .stage_listener(listener)
7861 .await
7862 .expect("stage listener");
7863
7864 let (max_req, max_res, max_inflight) = staged_limits();
7865 let routes = registry
7866 .get_or_spawn(
7867 "127.0.0.1",
7868 port,
7869 max_req,
7870 max_res,
7871 max_inflight,
7872 test_rt(),
7873 "staged-first-spawn".into(),
7874 None,
7875 )
7876 .await
7877 .expect("spawn from staged listener must succeed");
7878
7879 assert_eq!(
7880 registry.bound_addr("127.0.0.1", port),
7881 Some(addr),
7882 "served socket must be the staged listener's addr"
7883 );
7884 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__staged_probe__"))
7887 .await
7888 .expect("http request against staged listener must connect");
7889 assert!(
7890 resp.status().as_u16() >= 200,
7891 "any status proves the staged socket serves"
7892 );
7893 drop(routes);
7894 }
7895
7896 #[allow(clippy::await_holding_lock)]
7897 #[tokio::test]
7898 async fn staged_entry_reused_by_second_caller() {
7899 let _guard = lock_registry_test_mutex();
7900 ServerRegistry::reset();
7901 let registry = ServerRegistry::global();
7902 let (listener, _probe, addr) = clone_fixture_listener().await;
7903 let port = addr.port();
7904 registry
7905 .stage_listener(listener)
7906 .await
7907 .expect("stage listener");
7908
7909 let (max_req, max_res, max_inflight) = staged_limits();
7910 let first = registry
7911 .get_or_spawn(
7912 "127.0.0.1",
7913 port,
7914 max_req,
7915 max_res,
7916 max_inflight,
7917 test_rt(),
7918 "staged-reuse-1".into(),
7919 None,
7920 )
7921 .await
7922 .expect("first spawn from staged listener");
7923 let second = registry
7924 .get_or_spawn(
7925 "127.0.0.1",
7926 port,
7927 max_req,
7928 max_res,
7929 max_inflight,
7930 test_rt(),
7931 "staged-reuse-2".into(),
7932 None,
7933 )
7934 .await
7935 .expect("second caller must reuse the entry");
7936 assert_eq!(
7937 registry.bound_addr("127.0.0.1", port),
7938 Some(addr),
7939 "entry reused — bound addr unchanged, no second bind"
7940 );
7941 drop(first);
7942 drop(second);
7943 }
7944
7945 #[allow(clippy::await_holding_lock)]
7946 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7947 async fn staged_race_two_callers_single_resolver() {
7948 let _guard = lock_registry_test_mutex();
7949 ServerRegistry::reset();
7950 let registry = ServerRegistry::global();
7951 let (listener, _probe, addr) = clone_fixture_listener().await;
7952 let port = addr.port();
7953 registry
7954 .stage_listener(listener)
7955 .await
7956 .expect("stage listener");
7957
7958 let (max_req, max_res, max_inflight) = staged_limits();
7963 let (first, second) = tokio::join!(
7964 registry.get_or_spawn(
7965 "127.0.0.1",
7966 port,
7967 max_req,
7968 max_res,
7969 max_inflight,
7970 test_rt(),
7971 "staged-race-1".into(),
7972 None,
7973 ),
7974 registry.get_or_spawn(
7975 "127.0.0.1",
7976 port,
7977 max_req,
7978 max_res,
7979 max_inflight,
7980 test_rt(),
7981 "staged-race-2".into(),
7982 None,
7983 ),
7984 );
7985 let first = first.expect("first racing caller must succeed");
7986 let second = second.expect("second racing caller must succeed");
7987 assert_eq!(
7988 registry.bound_addr("127.0.0.1", port),
7989 Some(addr),
7990 "single entry must be served from the staged socket — no EADDRINUSE path"
7991 );
7992 drop(first);
7993 drop(second);
7994 }
7995
7996 #[allow(clippy::await_holding_lock)]
7997 #[tokio::test]
7998 async fn unstaged_spawn_binds_legacy() {
7999 let _guard = lock_registry_test_mutex();
8000 ServerRegistry::reset();
8001 let registry = ServerRegistry::global();
8002 let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe bind");
8004 let port = probe.local_addr().expect("local addr").port();
8005 drop(probe);
8006
8007 let (max_req, max_res, max_inflight) = staged_limits();
8008 registry
8009 .get_or_spawn(
8010 "127.0.0.1",
8011 port,
8012 max_req,
8013 max_res,
8014 max_inflight,
8015 test_rt(),
8016 "legacy-bind".into(),
8017 None,
8018 )
8019 .await
8020 .expect("legacy bind spawn");
8021 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__legacy_probe__"))
8022 .await
8023 .expect("connect to freshly bound port must succeed");
8024 assert!(resp.status().as_u16() >= 200);
8025 assert_eq!(
8026 registry.bound_addr("127.0.0.1", port),
8027 Some(std::net::SocketAddr::from(([127, 0, 0, 1], port))),
8028 "bound addr must be the legacy bound (host, port)"
8029 );
8030 }
8031
8032 #[allow(clippy::await_holding_lock)]
8033 #[tokio::test]
8034 async fn wrong_host_staged_port_fails_deterministically() {
8035 let _guard = lock_registry_test_mutex();
8036 ServerRegistry::reset();
8037 let registry = ServerRegistry::global();
8038 let (listener, _probe, addr) = clone_fixture_listener().await;
8039 let port = addr.port();
8040 registry
8041 .stage_listener(listener)
8042 .await
8043 .expect("stage listener under 127.0.0.1");
8044
8045 let (max_req, max_res, max_inflight) = staged_limits();
8046 let err = registry
8047 .get_or_spawn(
8048 "localhost",
8049 port,
8050 max_req,
8051 max_res,
8052 max_inflight,
8053 test_rt(),
8054 "conflict-probe".into(),
8055 None,
8056 )
8057 .await
8058 .expect_err("wrong host on staged port must fail deterministically");
8059 assert!(
8060 err.to_string().contains("staged listener conflict on port"),
8061 "unexpected error: {err}"
8062 );
8063
8064 registry
8066 .get_or_spawn(
8067 "127.0.0.1",
8068 port,
8069 max_req,
8070 max_res,
8071 max_inflight,
8072 test_rt(),
8073 "conflict-after".into(),
8074 None,
8075 )
8076 .await
8077 .expect("correct host must serve the staged listener");
8078 assert_eq!(
8079 registry.bound_addr("127.0.0.1", port),
8080 Some(addr),
8081 "staged slot must be untouched by the conflicting call"
8082 );
8083 }
8084
8085 #[allow(clippy::await_holding_lock)]
8086 #[tokio::test]
8087 async fn duplicate_stage_same_key_rejected() {
8088 let _guard = lock_registry_test_mutex();
8089 ServerRegistry::reset();
8090 let registry = ServerRegistry::global();
8091 let (listener, probe, addr) = clone_fixture_listener().await;
8092 registry
8093 .stage_listener(listener)
8094 .await
8095 .expect("stage listener A");
8096
8097 let dup = probe.try_clone().expect("clone2");
8099 dup.set_nonblocking(true).expect("set_nonblocking2");
8100 let b = tokio::net::TcpListener::from_std(dup).expect("from_std2");
8101
8102 let err = registry
8103 .stage_listener(b)
8104 .await
8105 .expect_err("duplicate stage must be rejected");
8106 assert!(
8107 err.to_string().contains("listener already staged"),
8108 "unexpected error: {err}"
8109 );
8110
8111 let (max_req, max_res, max_inflight) = staged_limits();
8112 registry
8113 .get_or_spawn(
8114 "127.0.0.1",
8115 addr.port(),
8116 max_req,
8117 max_res,
8118 max_inflight,
8119 test_rt(),
8120 "dup-stage-after".into(),
8121 None,
8122 )
8123 .await
8124 .expect("spawn from first staged listener");
8125 assert_eq!(
8126 registry.bound_addr("127.0.0.1", addr.port()),
8127 Some(addr),
8128 "first staged listener retained"
8129 );
8130 }
8131
8132 #[allow(clippy::await_holding_lock)]
8133 #[tokio::test]
8134 async fn distinct_keys_stage_independently() {
8135 let _guard = lock_registry_test_mutex();
8136 ServerRegistry::reset();
8137 let registry = ServerRegistry::global();
8138 let (l1, _p1, addr1) = clone_fixture_listener().await;
8139 let (l2, _p2, addr2) = clone_fixture_listener().await;
8140 registry.stage_listener(l1).await.expect("stage P1");
8141 registry.stage_listener(l2).await.expect("stage P2");
8142
8143 let (max_req, max_res, max_inflight) = staged_limits();
8144 registry
8145 .get_or_spawn(
8146 "127.0.0.1",
8147 addr1.port(),
8148 max_req,
8149 max_res,
8150 max_inflight,
8151 test_rt(),
8152 "distinct-1".into(),
8153 None,
8154 )
8155 .await
8156 .expect("spawn P1");
8157 registry
8158 .get_or_spawn(
8159 "127.0.0.1",
8160 addr2.port(),
8161 max_req,
8162 max_res,
8163 max_inflight,
8164 test_rt(),
8165 "distinct-2".into(),
8166 None,
8167 )
8168 .await
8169 .expect("spawn P2");
8170 assert_eq!(
8171 registry.bound_addr("127.0.0.1", addr1.port()),
8172 Some(addr1),
8173 "P1 bound addr must be its own listener"
8174 );
8175 assert_eq!(
8176 registry.bound_addr("127.0.0.1", addr2.port()),
8177 Some(addr2),
8178 "P2 bound addr must be its own listener"
8179 );
8180 let r1 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr1.port()))
8181 .await
8182 .expect("connect P1");
8183 assert!(r1.status().as_u16() >= 200);
8184 let r2 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr2.port()))
8185 .await
8186 .expect("connect P2");
8187 assert!(r2.status().as_u16() >= 200);
8188 }
8189
8190 #[allow(clippy::await_holding_lock)]
8191 #[tokio::test]
8192 async fn tls_prebound_listener_served() {
8193 use camel_component_api::test_support::tls;
8194
8195 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
8198
8199 let _guard = lock_registry_test_mutex();
8200 ServerRegistry::reset();
8201 let registry = ServerRegistry::global();
8202 let (listener, _probe, addr) = clone_fixture_listener().await;
8203 let port = addr.port();
8204
8205 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
8206 let cert_path = tls::write_pem_tmp("http-staged-tls-cert.pem", &cert_pem);
8207 let key_path = tls::write_pem_tmp("http-staged-tls-key.pem", &key_pem);
8208 let ca_path = tls::write_pem_tmp("http-staged-tls-ca.pem", &ca_pem);
8209
8210 let (max_req, max_res, max_inflight) = staged_limits();
8211 let routes = registry
8212 .get_or_spawn_with_listener(
8213 listener,
8214 max_req,
8215 max_res,
8216 max_inflight,
8217 test_rt(),
8218 "staged-tls".into(),
8219 Some(crate::config::ServerTlsConfig {
8220 cert_path: cert_path.to_string_lossy().into_owned(),
8221 key_path: key_path.to_string_lossy().into_owned(),
8222 }),
8223 )
8224 .await
8225 .expect("spawn TLS server from pre-bound listener");
8226
8227 let ca_bytes = std::fs::read(&ca_path).expect("read ca pem");
8230 let client = reqwest::Client::builder()
8231 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).expect("parse ca pem"))
8232 .build()
8233 .expect("build tls client");
8234
8235 let resp = client
8236 .get(format!("https://127.0.0.1:{port}/__staged_tls__"))
8237 .send()
8238 .await
8239 .expect("TLS handshake + request must succeed");
8240 assert_eq!(resp.status().as_u16(), 404, "unknown path 404s through TLS");
8241 assert_eq!(
8242 registry.bound_addr("127.0.0.1", port),
8243 Some(addr),
8244 "bound addr equals the pre-bound listener addr"
8245 );
8246 drop(routes);
8247 }
8248
8249 #[allow(clippy::await_holding_lock)]
8250 #[tokio::test]
8251 async fn with_listener_direct_spawn_keyed_by_actual_addr() {
8252 let _guard = lock_registry_test_mutex();
8253 ServerRegistry::reset();
8254 let registry = ServerRegistry::global();
8255 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
8256 .await
8257 .expect("bind un-staged listener");
8258 let addr = listener.local_addr().expect("local addr");
8259 let port = addr.port();
8260
8261 let (max_req, max_res, max_inflight) = staged_limits();
8262 registry
8263 .get_or_spawn_with_listener(
8264 listener,
8265 max_req,
8266 max_res,
8267 max_inflight,
8268 test_rt(),
8269 "with-listener".into(),
8270 None,
8271 )
8272 .await
8273 .expect("direct spawn from un-staged listener");
8274 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__with_listener__"))
8275 .await
8276 .expect("connect on actual port");
8277 assert!(resp.status().as_u16() >= 200);
8278 assert_eq!(
8279 registry.bound_addr("127.0.0.1", port),
8280 Some(addr),
8281 "registry key is the listener's actual port"
8282 );
8283
8284 registry
8285 .get_or_spawn(
8286 "127.0.0.1",
8287 port,
8288 max_req,
8289 max_res,
8290 max_inflight,
8291 test_rt(),
8292 "with-listener-reuse".into(),
8293 None,
8294 )
8295 .await
8296 .expect("legacy caller must reuse the entry");
8297 assert_eq!(
8298 registry.bound_addr("127.0.0.1", port),
8299 Some(addr),
8300 "entry reused — no second bind"
8301 );
8302 }
8303
8304 #[tokio::test]
8309 async fn test_dispatch_handler_returns_404_for_unknown_path() {
8310 let registry = HttpRouteRegistry::new();
8311 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8313 let port = listener.local_addr().unwrap().port();
8314 tokio::spawn(run_axum_server(
8315 listener,
8316 registry,
8317 2 * 1024 * 1024,
8318 10 * 1024 * 1024,
8319 Arc::new(tokio::sync::Semaphore::new(1024)),
8320 test_rt(),
8321 "test-route".into(),
8322 ));
8323
8324 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
8326
8327 let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
8328 .await
8329 .unwrap();
8330 assert_eq!(resp.status().as_u16(), 404);
8331 }
8332
8333 #[tokio::test]
8338 async fn test_http_consumer_start_registers_path() {
8339 use camel_component_api::ConsumerContext;
8340
8341 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8343 let port = listener.local_addr().unwrap().port();
8344 drop(listener); let consumer_cfg = HttpServerConfig {
8347 scheme: "http".to_string(),
8348 host: "127.0.0.1".to_string(),
8349 port,
8350 path: "/ping".to_string(),
8351 max_request_body: 2 * 1024 * 1024,
8352 max_response_body: 10 * 1024 * 1024,
8353 max_inflight_requests: 1024,
8354 method: None,
8355 tls_config: None,
8356 };
8357 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8358
8359 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8360 let token = tokio_util::sync::CancellationToken::new();
8361 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8362
8363 tokio::spawn(async move {
8364 consumer.start(ctx).await.unwrap();
8365 });
8366
8367 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8368
8369 let client = plain_http_test_client();
8370 let resp_future = client
8371 .post(format!("http://127.0.0.1:{port}/ping"))
8372 .body("hello world")
8373 .send();
8374
8375 let (http_result, _) = tokio::join!(resp_future, async {
8376 if let Some(mut envelope) = rx.recv().await {
8377 envelope.exchange.input.set_header(
8379 "CamelHttpResponseCode",
8380 serde_json::Value::Number(201.into()),
8381 );
8382 if let Some(reply_tx) = envelope.reply_tx {
8383 let _ = reply_tx.send(Ok(envelope.exchange));
8384 }
8385 }
8386 });
8387
8388 let resp = http_result.unwrap();
8389 assert_eq!(resp.status().as_u16(), 201);
8390
8391 token.cancel();
8392 }
8393
8394 #[tokio::test]
8399 async fn http_consumer_raw_dispatch_carries_in_flight_claim() {
8400 use std::sync::atomic::{AtomicU64, Ordering};
8401
8402 use camel_component_api::ConsumerContext;
8403 use camel_component_api::StartupSignal;
8404
8405 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8406 let port = listener.local_addr().unwrap().port();
8407 drop(listener);
8408
8409 let consumer_cfg = HttpServerConfig {
8410 scheme: "http".to_string(),
8411 host: "127.0.0.1".to_string(),
8412 port,
8413 path: "/claim".to_string(),
8414 max_request_body: 2 * 1024 * 1024,
8415 max_response_body: 10 * 1024 * 1024,
8416 max_inflight_requests: 1024,
8417 method: None,
8418 tls_config: None,
8419 };
8420 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8421
8422 let counter = std::sync::Arc::new(AtomicU64::new(0));
8423 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8424 let token = tokio_util::sync::CancellationToken::new();
8425 let (signal, startup_rx) = StartupSignal::pair();
8426 let ctx = ConsumerContext::new(tx, token.clone(), "http-claim-route".to_string())
8427 .with_startup(signal)
8428 .with_in_flight_counter(std::sync::Arc::clone(&counter));
8429
8430 tokio::spawn(async move {
8431 consumer.start(ctx).await.unwrap();
8432 });
8433
8434 tokio::time::timeout(std::time::Duration::from_secs(5), startup_rx.await_ready())
8437 .await
8438 .expect("startup must resolve within 5s")
8439 .expect("startup must be Ok");
8440
8441 let client = plain_http_test_client();
8442 let (http_result, claim) = tokio::join!(
8443 client
8444 .post(format!("http://127.0.0.1:{port}/claim"))
8445 .body("hello")
8446 .send(),
8447 async {
8448 let mut envelope = rx.recv().await.expect("envelope must arrive");
8449 let claim = envelope
8450 .in_flight_claim
8451 .take()
8452 .expect("raw dispatch must carry an acceptance-minted claim");
8453 assert_eq!(
8454 counter.load(Ordering::Acquire),
8455 1,
8456 "exact total: the acceptance mint is the only live claim"
8457 );
8458 let reply_tx = envelope.reply_tx.take().expect("reply channel must be set");
8462 let reply_exchange = Exchange::new(camel_component_api::Message::new(
8463 camel_component_api::Body::Text("done".to_string()),
8464 ));
8465 reply_tx
8466 .send(Ok(reply_exchange))
8467 .expect("reply must be taken");
8468 claim
8469 },
8470 );
8471
8472 let resp = http_result.expect("http roundtrip must complete");
8473 assert_eq!(resp.status().as_u16(), 200);
8474
8475 drop(claim);
8476 assert_eq!(
8477 counter.load(Ordering::Acquire),
8478 0,
8479 "release exactly once when the holder drops"
8480 );
8481 token.cancel();
8482 }
8483
8484 #[test]
8488 fn test_envelope_channel_capacity_follows_max_inflight() {
8489 assert_eq!(envelope_channel_capacity(0), 1);
8490 assert_eq!(envelope_channel_capacity(1), 1);
8491 assert_eq!(envelope_channel_capacity(7), 7);
8492 assert_eq!(envelope_channel_capacity(64), 64);
8493 assert_eq!(envelope_channel_capacity(1024), 1024);
8494 }
8495
8496 #[tokio::test]
8500 async fn test_http_consumer_start_with_zero_max_inflight_rejects_503() {
8501 use camel_component_api::ConsumerContext;
8502
8503 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8504 let port = listener.local_addr().unwrap().port();
8505 drop(listener);
8506
8507 let consumer_cfg = HttpServerConfig {
8508 scheme: "http".to_string(),
8509 host: "127.0.0.1".to_string(),
8510 port,
8511 path: "/ping".to_string(),
8512 max_request_body: 2 * 1024 * 1024,
8513 max_response_body: 10 * 1024 * 1024,
8514 max_inflight_requests: 0,
8515 method: None,
8516 tls_config: None,
8517 };
8518 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8519
8520 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8521 let token = tokio_util::sync::CancellationToken::new();
8522 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8523
8524 let start_handle = tokio::spawn(async move {
8525 consumer.start(ctx).await.unwrap();
8526 });
8527
8528 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8529
8530 let client = plain_http_test_client();
8531 let resp = client
8532 .post(format!("http://127.0.0.1:{port}/ping"))
8533 .body("hello world")
8534 .send()
8535 .await
8536 .unwrap();
8537 assert_eq!(resp.status().as_u16(), 503);
8538
8539 token.cancel();
8540 let _ = start_handle.await;
8541 }
8542
8543 #[test]
8555 fn test_max_inflight_requests_limit_boundary() {
8556 let l = tokio::sync::Semaphore::MAX_PERMITS;
8557 assert_eq!(max_inflight_requests_limit(l - 1).unwrap(), l - 1);
8558 assert_eq!(max_inflight_requests_limit(l).unwrap(), l);
8559 assert_eq!(max_inflight_requests_limit(0).unwrap(), 0);
8560 match max_inflight_requests_limit(l + 1) {
8561 Err(CamelError::Config(msg)) => {
8562 assert!(msg.contains("maxInflightRequests"), "message was: {msg}");
8563 assert!(msg.contains(&format!("{}", l + 1)), "message was: {msg}");
8564 assert!(msg.contains(&format!("{l}")), "message was: {msg}");
8565 }
8566 other => panic!("expected CamelError::Config, got: {other:?}"),
8567 }
8568 let _ = tokio::sync::Semaphore::new(l);
8569 }
8570
8571 #[test]
8576 fn test_parse_http_uri_max_inflight_at_limit_accepted() {
8577 let l = tokio::sync::Semaphore::MAX_PERMITS;
8578 let uri = format!("http://localhost:8080/api?maxInflightRequests={l}");
8579 let cfg = HttpServerConfig::from_uri(&uri).unwrap();
8580 assert_eq!(cfg.max_inflight_requests, l);
8581 }
8582
8583 #[test]
8587 fn test_parse_http_uri_max_inflight_above_limit_rejected() {
8588 let l = tokio::sync::Semaphore::MAX_PERMITS;
8589 let uri = format!("http://localhost:8080/api?maxInflightRequests={}", l + 1);
8590 match HttpServerConfig::from_uri(&uri) {
8591 Err(CamelError::Config(msg)) => {
8592 assert!(msg.contains("maxInflightRequests"), "message was: {msg}");
8593 assert!(msg.contains(&format!("{}", l + 1)), "message was: {msg}");
8594 assert!(msg.contains(&format!("{l}")), "message was: {msg}");
8595 }
8596 other => panic!("expected CamelError::Config, got: {other:?}"),
8597 }
8598 }
8599
8600 #[test]
8605 fn test_create_consumer_rejects_oversized_max_inflight() {
8606 let l = tokio::sync::Semaphore::MAX_PERMITS;
8607 let endpoint = HttpEndpoint {
8608 uri: "http://0.0.0.0:8080/api".to_string(),
8609 config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
8610 server_config: HttpServerConfig {
8611 scheme: "http".to_string(),
8612 host: "0.0.0.0".to_string(),
8613 port: 8080,
8614 path: "/api".to_string(),
8615 max_request_body: 2 * 1024 * 1024,
8616 max_response_body: 10 * 1024 * 1024,
8617 max_inflight_requests: l + 1,
8618 method: None,
8619 tls_config: None,
8620 },
8621 client: reqwest::Client::new(),
8622 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
8623 PINNED_CLIENT_TTL,
8624 PINNED_CLIENT_MAX_ENTRIES,
8625 )),
8626 http_config: HttpConfig::default(),
8627 };
8628 match endpoint.create_consumer(rt()) {
8629 Err(CamelError::Config(msg)) => {
8630 assert!(msg.contains("maxInflightRequests"), "message was: {msg}");
8631 assert!(msg.contains(&format!("{}", l + 1)), "message was: {msg}");
8632 assert!(msg.contains(&format!("{l}")), "message was: {msg}");
8633 }
8634 Err(other) => panic!("expected CamelError::Config, got: {other:?}"),
8635 Ok(_) => panic!("expected error, got Ok"),
8636 }
8637 }
8638
8639 #[tokio::test]
8645 async fn test_http_consumer_start_rejects_oversized_before_spawn() {
8646 use camel_component_api::ConsumerContext;
8647
8648 let l = tokio::sync::Semaphore::MAX_PERMITS;
8649 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8650 let port = listener.local_addr().unwrap().port();
8651 drop(listener);
8652
8653 let consumer_cfg = HttpServerConfig {
8654 scheme: "http".to_string(),
8655 host: "127.0.0.1".to_string(),
8656 port,
8657 path: "/ping".to_string(),
8658 max_request_body: 2 * 1024 * 1024,
8659 max_response_body: 10 * 1024 * 1024,
8660 max_inflight_requests: l + 1,
8661 method: None,
8662 tls_config: None,
8663 };
8664 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8665
8666 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8667 let token = tokio_util::sync::CancellationToken::new();
8668 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8669
8670 match consumer.start(ctx).await {
8671 Err(CamelError::Config(msg)) => {
8672 assert!(msg.contains("maxInflightRequests"), "message was: {msg}");
8673 assert!(msg.contains(&format!("{}", l + 1)), "message was: {msg}");
8674 assert!(msg.contains(&format!("{l}")), "message was: {msg}");
8675 }
8676 other => panic!("expected CamelError::Config, got: {other:?}"),
8677 }
8678 token.cancel();
8679 }
8680
8681 #[tokio::test]
8685 async fn test_spawn_entry_rejects_oversized_before_semaphore() {
8686 let l = tokio::sync::Semaphore::MAX_PERMITS;
8687 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8688 let addr = listener.local_addr().unwrap();
8689 let key: ServerKey = (addr.ip().to_string(), addr.port());
8690
8691 let result = spawn_entry(
8692 key,
8693 ListenerSource::Staged(listener),
8694 1,
8695 1,
8696 l + 1,
8697 test_rt(),
8698 "route-test".into(),
8699 None,
8700 )
8701 .await;
8702
8703 match result {
8704 Err(CamelError::Config(msg)) => {
8705 assert!(msg.contains("maxInflightRequests"), "message was: {msg}");
8706 assert!(msg.contains(&format!("{}", l + 1)), "message was: {msg}");
8707 assert!(msg.contains(&format!("{l}")), "message was: {msg}");
8708 }
8709 Err(other) => panic!("expected CamelError::Config, got: {other:?}"),
8710 Ok(_) => panic!("expected CamelError::Config, got Ok"),
8711 }
8712 }
8713
8714 #[test]
8717 fn test_http_consumer_startup_mode_is_explicit() {
8718 use camel_component_api::ConsumerStartupMode;
8719 let consumer_cfg = HttpServerConfig {
8720 scheme: "http".to_string(),
8721 host: "127.0.0.1".to_string(),
8722 port: 0,
8723 path: "/x".to_string(),
8724 max_request_body: 2 * 1024 * 1024,
8725 max_response_body: 10 * 1024 * 1024,
8726 max_inflight_requests: 1024,
8727 method: None,
8728 tls_config: None,
8729 };
8730 let consumer = HttpConsumer::new(consumer_cfg, test_rt());
8731 assert_eq!(
8732 consumer.startup_mode(),
8733 ConsumerStartupMode::Explicit,
8734 "HttpConsumer must opt into Explicit startup"
8735 );
8736 }
8737
8738 #[allow(clippy::await_holding_lock)]
8744 #[tokio::test]
8745 async fn test_http_consumer_emits_mark_ready_after_bind() {
8746 use camel_component_api::{ConsumerContext, StartupSignal};
8747
8748 let _guard = lock_registry_test_mutex();
8749
8750 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8751 let port = listener.local_addr().unwrap().port();
8752 drop(listener);
8753
8754 let consumer_cfg = HttpServerConfig {
8755 scheme: "http".to_string(),
8756 host: "127.0.0.1".to_string(),
8757 port,
8758 path: "/ready-probe".to_string(),
8759 max_request_body: 2 * 1024 * 1024,
8760 max_response_body: 10 * 1024 * 1024,
8761 max_inflight_requests: 1024,
8762 method: None,
8763 tls_config: None,
8764 };
8765 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8766
8767 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8768 let token = tokio_util::sync::CancellationToken::new();
8769 let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
8770
8771 let (signal, startup_rx) = StartupSignal::pair();
8773 let ctx = ctx.with_startup(signal);
8774
8775 tokio::spawn(async move {
8778 let _ = consumer.start(ctx).await;
8779 });
8780
8781 let result =
8786 tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
8787 .await
8788 .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
8789 assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
8790
8791 token.cancel();
8793 }
8794
8795 #[derive(Default, Clone)]
8802 struct ErrorRecordingRuntime {
8803 errors: std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>>,
8804 }
8805
8806 impl camel_api::MetricsCollector for ErrorRecordingRuntime {
8807 fn record_exchange_duration(&self, _route_id: &str, _duration: std::time::Duration) {}
8808 fn increment_errors(&self, route_id: &str, error_type: &str) {
8809 self.errors
8810 .lock()
8811 .expect("error recorder lock")
8812 .push((route_id.to_string(), error_type.to_string()));
8813 }
8814 fn increment_exchanges(&self, _route_id: &str) {}
8815 fn set_queue_depth(&self, _queue: &str, _depth: usize) {}
8816 fn record_circuit_breaker_change(&self, _route_id: &str, _from: &str, _to: &str) {}
8817 }
8818
8819 impl camel_component_api::HealthCheckRegistry for ErrorRecordingRuntime {
8820 fn force_unhealthy_for_route(&self, _route_id: &str, _name: &str, _reason: &str) {}
8821 }
8822
8823 impl camel_component_api::RuntimeObservability for ErrorRecordingRuntime {
8824 fn metrics(&self) -> std::sync::Arc<dyn camel_api::MetricsCollector> {
8825 std::sync::Arc::new(self.clone())
8826 }
8827 fn health(&self) -> std::sync::Arc<dyn camel_component_api::HealthCheckRegistry> {
8828 std::sync::Arc::new(self.clone())
8829 }
8830 }
8831
8832 #[allow(clippy::await_holding_lock)]
8847 #[tokio::test]
8848 async fn shared_server_death_fails_every_hosted_consumer() {
8849 use camel_component_api::{ConsumerContext, StartupSignal};
8850
8851 let _guard = lock_registry_test_mutex();
8852 ServerRegistry::reset();
8853
8854 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8856 let port = listener.local_addr().unwrap().port();
8857 drop(listener);
8858
8859 let rt = ErrorRecordingRuntime::default();
8860
8861 let make_consumer = |path: &str| {
8862 HttpConsumer::new(
8863 HttpServerConfig {
8864 scheme: "http".to_string(),
8865 host: "127.0.0.1".to_string(),
8866 port,
8867 path: path.to_string(),
8868 max_request_body: 2 * 1024 * 1024,
8869 max_response_body: 10 * 1024 * 1024,
8870 max_inflight_requests: 16,
8871 method: None,
8872 tls_config: None,
8873 },
8874 std::sync::Arc::new(rt.clone()),
8875 )
8876 };
8877
8878 let spawn_consumer = |path: &str, route_id: &str| {
8879 let mut consumer = make_consumer(path);
8880 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8881 let token = tokio_util::sync::CancellationToken::new();
8882 let ctx = ConsumerContext::new(tx, token, route_id.to_string());
8883 let (signal, startup_rx) = StartupSignal::pair();
8884 let ctx = ctx.with_startup(signal);
8885 let task = tokio::spawn(async move { consumer.start(ctx).await });
8886 (task, startup_rx)
8887 };
8888
8889 let (task_a, ready_a) = spawn_consumer("/zombie-a", "zombie-route-a");
8891 let (task_b, ready_b) = spawn_consumer("/zombie-b", "zombie-route-b");
8892
8893 for (name, ready) in [("a", ready_a), ("b", ready_b)] {
8895 let result =
8896 tokio::time::timeout(std::time::Duration::from_secs(2), ready.await_ready())
8897 .await
8898 .unwrap_or_else(|_| panic!("consumer {name} never became ready"));
8899 assert!(
8900 result.is_ok(),
8901 "consumer {name} readiness must resolve Ok (bind + registration complete)"
8902 );
8903 }
8904
8905 {
8908 let registry = ServerRegistry::global();
8909 let guard = registry.inner.lock().expect("ServerRegistry lock");
8910 let cell = guard
8911 .entries
8912 .get(&("127.0.0.1".to_string(), port))
8913 .expect("shared server entry must exist");
8914 let handle = cell.get().expect("server handle must be initialized");
8915 handle.server_abort.abort();
8916 }
8917
8918 let outcome_a = tokio::time::timeout(std::time::Duration::from_secs(2), task_a)
8921 .await
8922 .expect("ZOMBIE: consumer-a still running after shared server death (rc-szmob)");
8923 let outcome_b = tokio::time::timeout(std::time::Duration::from_secs(2), task_b)
8924 .await
8925 .expect("ZOMBIE: consumer-b still running after shared server death (rc-szmob)");
8926
8927 let err_a = outcome_a
8928 .expect("consumer-a task must join")
8929 .expect_err("consumer-a start() must return Err when the shared server dies");
8930 let err_b = outcome_b
8931 .expect("consumer-b task must join")
8932 .expect_err("consumer-b start() must return Err when the shared server dies");
8933
8934 for (name, err) in [("a", &err_a), ("b", &err_b)] {
8937 assert!(
8938 err.to_string().contains("127.0.0.1")
8939 && err.to_string().contains(&port.to_string()),
8940 "consumer-{name} error must name the dead shared server, got: {err}"
8941 );
8942 }
8943
8944 let recorded = rt.errors.lock().expect("error recorder lock").clone();
8947 assert!(
8948 recorded
8949 .iter()
8950 .any(|(route, label)| label == "e:http:server-task-exited"
8951 && route == "zombie-route-a"),
8952 "expected e:http:server-task-exited for the spawning route, got: {recorded:?}"
8953 );
8954 }
8955
8956 #[tokio::test]
8957 async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
8958 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8959
8960 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8961 let port = listener.local_addr().unwrap().port();
8962 drop(listener);
8963
8964 let consumer_cfg = HttpServerConfig {
8965 scheme: "http".to_string(),
8966 host: "127.0.0.1".to_string(),
8967 port,
8968 path: "/saturation".to_string(),
8969 max_request_body: 2 * 1024 * 1024,
8970 max_response_body: 10 * 1024 * 1024,
8971 max_inflight_requests: 1,
8972 method: None,
8973 tls_config: None,
8974 };
8975 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8976
8977 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8978 let token = tokio_util::sync::CancellationToken::new();
8979 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8980 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8981 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8982
8983 let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
8984 let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
8985
8986 tokio::spawn(async move {
8987 let mut first_seen_tx = Some(first_seen_tx);
8988 let mut unblock_first_rx = Some(unblock_first_rx);
8989
8990 loop {
8991 match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
8992 Ok(Some(envelope)) => {
8993 if let Some(tx) = first_seen_tx.take() {
8994 let _ = tx.send(());
8995 if let Some(rx_unblock) = unblock_first_rx.take() {
8996 let _ = tokio::time::timeout(Duration::from_secs(2), rx_unblock)
9000 .await
9001 .expect("first exchange unblock within 2s");
9002 }
9003 }
9004
9005 if let Some(reply_tx) = envelope.reply_tx {
9006 let _ = reply_tx.send(Ok(envelope.exchange));
9007 }
9008 }
9009 Ok(None) => break, Err(_) => break, }
9014 }
9015 });
9016
9017 let client = plain_http_test_client();
9018 let first_req = {
9019 let client = client.clone();
9020 async move {
9021 client
9022 .get(format!("http://127.0.0.1:{port}/saturation"))
9023 .send()
9024 .await
9025 .unwrap()
9026 }
9027 };
9028
9029 let first_handle = tokio::spawn(first_req);
9030 first_seen_rx.await.unwrap();
9031
9032 let second_resp = client
9033 .get(format!("http://127.0.0.1:{port}/saturation"))
9034 .send()
9035 .await
9036 .unwrap();
9037
9038 assert_eq!(second_resp.status().as_u16(), 503);
9039
9040 let _ = unblock_first_tx.send(());
9041 let first_resp = first_handle.await.unwrap();
9042 assert_eq!(first_resp.status().as_u16(), 200);
9043
9044 token.cancel();
9045 }
9046
9047 #[tokio::test]
9051 async fn test_http_consumer_chunked_body_is_capped() {
9052 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9053
9054 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9055 let port = listener.local_addr().unwrap().port();
9056 drop(listener);
9057
9058 let consumer_cfg = HttpServerConfig {
9059 scheme: "http".to_string(),
9060 host: "127.0.0.1".to_string(),
9061 port,
9062 path: "/chunked-cap".to_string(),
9063 max_request_body: 1024, max_response_body: 10 * 1024 * 1024,
9065 max_inflight_requests: 16,
9066 method: None,
9067 tls_config: None,
9068 };
9069 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
9070
9071 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9072 let token = tokio_util::sync::CancellationToken::new();
9073 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9074 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9075 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9076
9077 let chunks: Vec<Result<bytes::Bytes, std::io::Error>> = (0..8)
9079 .map(|_| Ok(bytes::Bytes::from(vec![b'A'; 512])))
9080 .collect();
9081 let stream_body = reqwest::Body::wrap_stream(futures::stream::iter(chunks));
9082
9083 let client = plain_http_test_client();
9084 let send_fut = client
9085 .post(format!("http://127.0.0.1:{port}/chunked-cap"))
9086 .body(stream_body)
9087 .send();
9088
9089 let (http_result, _) = tokio::join!(send_fut, async {
9090 if let Some(mut envelope) = rx.recv().await {
9091 let materialized = envelope
9093 .exchange
9094 .input
9095 .body
9096 .clone()
9097 .into_bytes(64 * 1024)
9098 .await;
9099 assert!(
9100 materialized.is_err(),
9101 "materializing a 4 KiB chunked body under a 1 KiB cap must fail"
9102 );
9103 let err = materialized.unwrap_err().to_string();
9104 assert!(
9105 err.contains("limit") || err.contains("exceeds"),
9106 "error should mention the limit: {err}"
9107 );
9108 if let Some(reply_tx) = envelope.reply_tx {
9109 envelope.exchange.input.body =
9110 camel_component_api::Body::Text("handled".to_string());
9111 let _ = reply_tx.send(Ok(envelope.exchange));
9112 }
9113 }
9114 });
9115
9116 let resp = http_result.unwrap();
9117 assert_eq!(resp.status().as_u16(), 200);
9118
9119 token.cancel();
9120 }
9121
9122 #[tokio::test]
9123 #[allow(clippy::await_holding_lock)]
9124 async fn test_http_consumer_enforces_max_response_body_for_bytes() {
9125 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9126
9127 let _guard = lock_registry_test_mutex();
9128
9129 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9130 let port = listener.local_addr().unwrap().port();
9131 drop(listener);
9132
9133 let consumer_cfg = HttpServerConfig {
9134 scheme: "http".to_string(),
9135 host: "127.0.0.1".to_string(),
9136 port,
9137 path: "/limit-bytes".to_string(),
9138 max_request_body: 2 * 1024 * 1024,
9139 max_response_body: 16,
9140 max_inflight_requests: 1024,
9141 method: None,
9142 tls_config: None,
9143 };
9144 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
9145
9146 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9147 let token = tokio_util::sync::CancellationToken::new();
9148 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9149 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9150 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9151
9152 let client = plain_http_test_client();
9153 let send_fut = client
9154 .get(format!("http://127.0.0.1:{port}/limit-bytes"))
9155 .send();
9156
9157 let (http_result, _) = tokio::join!(send_fut, async {
9158 if let Some(mut envelope) = rx.recv().await {
9159 envelope.exchange.input.body =
9160 camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
9161 if let Some(reply_tx) = envelope.reply_tx {
9162 let _ = reply_tx.send(Ok(envelope.exchange));
9163 }
9164 }
9165 });
9166
9167 let resp = http_result.unwrap();
9168 assert_eq!(resp.status().as_u16(), 500);
9169 let body = resp.text().await.unwrap();
9170 assert_eq!(body, "Response body exceeds configured limit");
9171 token.cancel();
9172 }
9173
9174 #[tokio::test]
9175 #[allow(clippy::await_holding_lock)]
9176 async fn test_http_consumer_enforces_max_response_body_for_json() {
9177 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9178
9179 let _guard = lock_registry_test_mutex();
9180
9181 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9182 let port = listener.local_addr().unwrap().port();
9183 drop(listener);
9184
9185 let consumer_cfg = HttpServerConfig {
9186 scheme: "http".to_string(),
9187 host: "127.0.0.1".to_string(),
9188 port,
9189 path: "/limit-json".to_string(),
9190 max_request_body: 2 * 1024 * 1024,
9191 max_response_body: 16,
9192 max_inflight_requests: 1024,
9193 method: None,
9194 tls_config: None,
9195 };
9196 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
9197
9198 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9199 let token = tokio_util::sync::CancellationToken::new();
9200 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9201 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9202 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9203
9204 let client = plain_http_test_client();
9205 let send_fut = client
9206 .get(format!("http://127.0.0.1:{port}/limit-json"))
9207 .send();
9208
9209 let (http_result, _) = tokio::join!(send_fut, async {
9210 if let Some(mut envelope) = rx.recv().await {
9211 envelope.exchange.input.body = camel_component_api::Body::Json(
9212 serde_json::json!({"message":"this response is bigger than sixteen"}),
9213 );
9214 if let Some(reply_tx) = envelope.reply_tx {
9215 let _ = reply_tx.send(Ok(envelope.exchange));
9216 }
9217 }
9218 });
9219
9220 let resp = http_result.unwrap();
9221 assert_eq!(resp.status().as_u16(), 500);
9222 let body = resp.text().await.unwrap();
9223 assert_eq!(body, "Response body exceeds configured limit");
9224 token.cancel();
9225 }
9226
9227 #[tokio::test]
9228 #[allow(clippy::await_holding_lock)]
9229 async fn test_http_consumer_enforces_max_response_body_for_xml() {
9230 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9231
9232 let _guard = lock_registry_test_mutex();
9233
9234 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9235 let port = listener.local_addr().unwrap().port();
9236 drop(listener);
9237
9238 let consumer_cfg = HttpServerConfig {
9239 scheme: "http".to_string(),
9240 host: "127.0.0.1".to_string(),
9241 port,
9242 path: "/limit-xml".to_string(),
9243 max_request_body: 2 * 1024 * 1024,
9244 max_response_body: 16,
9245 max_inflight_requests: 1024,
9246 method: None,
9247 tls_config: None,
9248 };
9249 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
9250
9251 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9252 let token = tokio_util::sync::CancellationToken::new();
9253 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9254 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9255 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9256
9257 let client = plain_http_test_client();
9258 let send_fut = client
9259 .get(format!("http://127.0.0.1:{port}/limit-xml"))
9260 .send();
9261
9262 let (http_result, _) = tokio::join!(send_fut, async {
9263 if let Some(mut envelope) = rx.recv().await {
9264 envelope.exchange.input.body = camel_component_api::Body::Xml(
9265 "<root><value>way-too-large</value></root>".into(),
9266 );
9267 if let Some(reply_tx) = envelope.reply_tx {
9268 let _ = reply_tx.send(Ok(envelope.exchange));
9269 }
9270 }
9271 });
9272
9273 let resp = http_result.unwrap();
9274 assert_eq!(resp.status().as_u16(), 500);
9275 let body = resp.text().await.unwrap();
9276 assert_eq!(body, "Response body exceeds configured limit");
9277 token.cancel();
9278 }
9279
9280 #[tokio::test]
9281 #[allow(clippy::await_holding_lock)]
9282 async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
9283 use camel_component_api::{
9284 CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
9285 };
9286 use futures::stream;
9287
9288 let _guard = lock_registry_test_mutex();
9289
9290 let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
9291 let port = listener.local_addr().unwrap().port();
9292 drop(listener);
9293
9294 let consumer_cfg = HttpServerConfig {
9295 scheme: "http".to_string(),
9296 host: "0.0.0.0".to_string(),
9297 port,
9298 path: "/limit-stream".to_string(),
9299 max_request_body: 2 * 1024 * 1024,
9300 max_response_body: 16,
9301 max_inflight_requests: 1024,
9302 method: None,
9303 tls_config: None,
9304 };
9305 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
9306
9307 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9308 let token = tokio_util::sync::CancellationToken::new();
9309 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9310 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9311 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9312
9313 let client = plain_http_test_client();
9314 let send_fut = client
9315 .get(format!("http://127.0.0.1:{port}/limit-stream"))
9316 .send();
9317
9318 let (http_result, _) = tokio::join!(send_fut, async {
9319 if let Some(mut envelope) = rx.recv().await {
9320 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
9321 vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
9322 let stream = Box::pin(stream::iter(chunks));
9323 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
9324 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
9325 metadata: StreamMetadata {
9326 size_hint: Some(32),
9327 content_type: Some("application/octet-stream".into()),
9328 origin: None,
9329 },
9330 });
9331 if let Some(reply_tx) = envelope.reply_tx {
9332 let _ = reply_tx.send(Ok(envelope.exchange));
9333 }
9334 }
9335 });
9336
9337 let resp = http_result.unwrap();
9338 assert_eq!(resp.status().as_u16(), 200);
9339 let body = resp.bytes().await.unwrap();
9340 assert_eq!(body.len(), 32);
9341 token.cancel();
9342 }
9343
9344 #[tokio::test]
9349 #[allow(clippy::await_holding_lock)]
9350 async fn test_integration_single_consumer_round_trip() {
9351 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9352
9353 let _guard = lock_registry_test_mutex();
9357
9358 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9360 let port = listener.local_addr().unwrap().port();
9361 drop(listener); let component = HttpComponent::new();
9364 let endpoint_ctx = NoOpComponentContext;
9365 let endpoint = component
9366 .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
9367 .unwrap();
9368 let mut consumer = endpoint.create_consumer(rt()).unwrap();
9369
9370 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9371 let token = tokio_util::sync::CancellationToken::new();
9372 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9373
9374 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9375 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9376
9377 let client = plain_http_test_client();
9378 let send_fut = client
9379 .post(format!("http://127.0.0.1:{port}/echo"))
9380 .header("Content-Type", "text/plain")
9381 .body("ping")
9382 .send();
9383
9384 let (http_result, _) = tokio::join!(send_fut, async {
9385 if let Some(mut envelope) = rx.recv().await {
9386 assert_eq!(
9387 envelope.exchange.input.header("CamelHttpMethod"),
9388 Some(&serde_json::Value::String("POST".into()))
9389 );
9390 assert_eq!(
9391 envelope.exchange.input.header("CamelHttpPath"),
9392 Some(&serde_json::Value::String("/echo".into()))
9393 );
9394 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
9395 if let Some(reply_tx) = envelope.reply_tx {
9396 let _ = reply_tx.send(Ok(envelope.exchange));
9397 }
9398 }
9399 });
9400
9401 let resp = http_result.unwrap();
9402 assert_eq!(resp.status().as_u16(), 200);
9403 let body = resp.text().await.unwrap();
9404 assert_eq!(body, "pong");
9405
9406 token.cancel();
9407 }
9408
9409 #[tokio::test]
9410 #[allow(clippy::await_holding_lock)]
9411 async fn test_integration_two_consumers_shared_port() {
9412 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9413
9414 let _guard = lock_registry_test_mutex();
9415
9416 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9418 let port = listener.local_addr().unwrap().port();
9419 drop(listener);
9420
9421 let component = HttpComponent::new();
9422 let endpoint_ctx = NoOpComponentContext;
9423
9424 let endpoint_a = component
9426 .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
9427 .unwrap();
9428 let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
9429
9430 let endpoint_b = component
9432 .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
9433 .unwrap();
9434 let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
9435
9436 let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9437 let token_a = tokio_util::sync::CancellationToken::new();
9438 let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
9439
9440 let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9441 let token_b = tokio_util::sync::CancellationToken::new();
9442 let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
9443
9444 tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
9445 tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
9446 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9447
9448 let client = plain_http_test_client();
9449
9450 let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
9452 let (resp_hello, _) = tokio::join!(fut_hello, async {
9453 if let Some(mut envelope) = rx_a.recv().await {
9454 envelope.exchange.input.body =
9455 camel_component_api::Body::Text("hello-response".to_string());
9456 if let Some(reply_tx) = envelope.reply_tx {
9457 let _ = reply_tx.send(Ok(envelope.exchange));
9458 }
9459 }
9460 });
9461
9462 let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
9464 let (resp_world, _) = tokio::join!(fut_world, async {
9465 if let Some(mut envelope) = rx_b.recv().await {
9466 envelope.exchange.input.body =
9467 camel_component_api::Body::Text("world-response".to_string());
9468 if let Some(reply_tx) = envelope.reply_tx {
9469 let _ = reply_tx.send(Ok(envelope.exchange));
9470 }
9471 }
9472 });
9473
9474 let body_a = resp_hello.unwrap().text().await.unwrap();
9475 let body_b = resp_world.unwrap().text().await.unwrap();
9476
9477 assert_eq!(body_a, "hello-response");
9478 assert_eq!(body_b, "world-response");
9479
9480 token_a.cancel();
9481 token_b.cancel();
9482 }
9483
9484 #[tokio::test]
9485 #[allow(clippy::await_holding_lock)]
9486 async fn test_integration_unregistered_path_returns_404() {
9487 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9488
9489 let _guard = lock_registry_test_mutex();
9490
9491 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9493 let port = listener.local_addr().unwrap().port();
9494 drop(listener);
9495
9496 let component = HttpComponent::new();
9497 let endpoint_ctx = NoOpComponentContext;
9498 let endpoint = component
9499 .create_endpoint(
9500 &format!("http://127.0.0.1:{port}/registered"),
9501 &endpoint_ctx,
9502 )
9503 .unwrap();
9504 let mut consumer = endpoint.create_consumer(rt()).unwrap();
9505
9506 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9507 let token = tokio_util::sync::CancellationToken::new();
9508 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9509
9510 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9511
9512 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
9514 loop {
9515 if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
9516 .await
9517 .is_ok()
9518 {
9519 break;
9520 }
9521 if std::time::Instant::now() >= deadline {
9522 panic!("HTTP server did not start within 5s on port {port}");
9523 }
9524 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
9525 }
9526
9527 let client = plain_http_test_client();
9528 let resp = client
9529 .get(format!("http://127.0.0.1:{port}/not-there"))
9530 .send()
9531 .await
9532 .unwrap();
9533 assert_eq!(resp.status().as_u16(), 404);
9534
9535 token.cancel();
9536 }
9537
9538 #[test]
9539 fn test_http_consumer_declares_concurrent() {
9540 use camel_component_api::ConcurrencyModel;
9541
9542 let config = HttpServerConfig {
9543 scheme: "http".to_string(),
9544 host: "127.0.0.1".to_string(),
9545 port: 19999,
9546 path: "/test".to_string(),
9547 max_request_body: 2 * 1024 * 1024,
9548 max_response_body: 10 * 1024 * 1024,
9549 max_inflight_requests: 1024,
9550 method: None,
9551 tls_config: None,
9552 };
9553 let consumer = HttpConsumer::new(config, test_rt());
9554 assert_eq!(
9555 consumer.concurrency_model(),
9556 ConcurrencyModel::Concurrent { max: None }
9557 );
9558 }
9559
9560 #[test]
9561 fn server_config_parses_tls_cert_and_key() {
9562 let cfg = HttpServerConfig::from_uri(
9563 "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
9564 )
9565 .unwrap();
9566 assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
9567 assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
9568 }
9569
9570 #[test]
9571 fn server_config_no_tls_when_params_absent() {
9572 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
9573 assert!(cfg.tls_config.is_none());
9574 }
9575
9576 #[tokio::test]
9581 async fn test_http_reply_body_stream_variant_exists() {
9582 use bytes::Bytes;
9583 use camel_component_api::CamelError;
9584 use futures::stream;
9585
9586 let chunks: Vec<Result<Bytes, CamelError>> =
9587 vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
9588 let stream = Box::pin(stream::iter(chunks));
9589 let reply_body = HttpReplyBody::Stream(stream);
9590 match reply_body {
9592 HttpReplyBody::Stream(_) => {}
9593 HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
9594 }
9595 }
9596
9597 #[cfg(feature = "otel")]
9602 mod otel_tests {
9603 use super::*;
9604 use camel_component_api::Message;
9605 use tower::ServiceExt;
9606
9607 #[tokio::test]
9608 async fn test_producer_injects_traceparent_header() {
9609 let (url, _handle) = start_test_server_with_header_capture().await;
9610 let ctx = test_producer_ctx();
9611
9612 let component = HttpComponent::new();
9613 let endpoint_ctx = NoOpComponentContext;
9614 let endpoint = component
9615 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
9616 .unwrap();
9617 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
9618
9619 let mut exchange = Exchange::new(Message::default());
9621 let mut headers = std::collections::HashMap::new();
9622 headers.insert(
9623 "traceparent".to_string(),
9624 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
9625 );
9626 camel_otel::extract_into_exchange(&mut exchange, &headers);
9627
9628 let result = producer.oneshot(exchange).await.unwrap();
9629
9630 let status = result
9632 .input
9633 .header("CamelHttpResponseCode")
9634 .and_then(|v| v.as_u64())
9635 .unwrap();
9636 assert_eq!(status, 200);
9637
9638 let traceparent = result.input.header("X-Received-Traceparent");
9640 assert!(
9641 traceparent.is_some(),
9642 "traceparent header should have been sent"
9643 );
9644
9645 let traceparent_str = traceparent.unwrap().as_str().unwrap();
9646 let parts: Vec<&str> = traceparent_str.split('-').collect();
9648 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
9649 assert_eq!(parts[0], "00", "version should be 00");
9650 assert_eq!(
9651 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
9652 "trace-id should match"
9653 );
9654 assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
9655 assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
9656 }
9657
9658 #[tokio::test]
9659 async fn test_consumer_extracts_traceparent_header() {
9660 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9661
9662 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9664 let port = listener.local_addr().unwrap().port();
9665 drop(listener);
9666
9667 let component = HttpComponent::new();
9668 let endpoint_ctx = NoOpComponentContext;
9669 let endpoint = component
9670 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
9671 .unwrap();
9672 let mut consumer = endpoint.create_consumer(rt()).unwrap();
9673
9674 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9675 let token = tokio_util::sync::CancellationToken::new();
9676 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9677
9678 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9679 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9680
9681 let client = plain_http_test_client();
9683 let send_fut = client
9684 .post(format!("http://127.0.0.1:{port}/trace"))
9685 .header(
9686 "traceparent",
9687 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
9688 )
9689 .body("test")
9690 .send();
9691
9692 let (http_result, _) = tokio::join!(send_fut, async {
9693 if let Some(envelope) = rx.recv().await {
9694 let mut injected_headers = std::collections::HashMap::new();
9697 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
9698
9699 assert!(
9700 injected_headers.contains_key("traceparent"),
9701 "Exchange should have traceparent after extraction"
9702 );
9703
9704 let traceparent = injected_headers.get("traceparent").unwrap();
9705 let parts: Vec<&str> = traceparent.split('-').collect();
9706 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
9707 assert_eq!(
9708 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
9709 "Trace ID should match the original traceparent header"
9710 );
9711
9712 if let Some(reply_tx) = envelope.reply_tx {
9713 let _ = reply_tx.send(Ok(envelope.exchange));
9714 }
9715 }
9716 });
9717
9718 let resp = http_result.unwrap();
9719 assert_eq!(resp.status().as_u16(), 200);
9720
9721 token.cancel();
9722 }
9723
9724 #[tokio::test]
9725 async fn test_consumer_extracts_mixed_case_traceparent_header() {
9726 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9727
9728 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9730 let port = listener.local_addr().unwrap().port();
9731 drop(listener);
9732
9733 let component = HttpComponent::new();
9734 let endpoint_ctx = NoOpComponentContext;
9735 let endpoint = component
9736 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
9737 .unwrap();
9738 let mut consumer = endpoint.create_consumer(rt()).unwrap();
9739
9740 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9741 let token = tokio_util::sync::CancellationToken::new();
9742 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9743
9744 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9745 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9746
9747 let client = plain_http_test_client();
9749 let send_fut = client
9750 .post(format!("http://127.0.0.1:{port}/trace"))
9751 .header(
9752 "TraceParent",
9753 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
9754 )
9755 .body("test")
9756 .send();
9757
9758 let (http_result, _) = tokio::join!(send_fut, async {
9759 if let Some(envelope) = rx.recv().await {
9760 let mut injected_headers = HashMap::new();
9763 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
9764
9765 assert!(
9766 injected_headers.contains_key("traceparent"),
9767 "Exchange should have traceparent after extraction from mixed-case header"
9768 );
9769
9770 let traceparent = injected_headers.get("traceparent").unwrap();
9771 let parts: Vec<&str> = traceparent.split('-').collect();
9772 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
9773 assert_eq!(
9774 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
9775 "Trace ID should match the original mixed-case TraceParent header"
9776 );
9777
9778 if let Some(reply_tx) = envelope.reply_tx {
9779 let _ = reply_tx.send(Ok(envelope.exchange));
9780 }
9781 }
9782 });
9783
9784 let resp = http_result.unwrap();
9785 assert_eq!(resp.status().as_u16(), 200);
9786
9787 token.cancel();
9788 }
9789
9790 #[tokio::test]
9791 async fn test_producer_no_trace_context_no_crash() {
9792 let (url, _handle) = start_test_server().await;
9793 let ctx = test_producer_ctx();
9794
9795 let component = HttpComponent::new();
9796 let endpoint_ctx = NoOpComponentContext;
9797 let endpoint = component
9798 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
9799 .unwrap();
9800 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
9801
9802 let exchange = Exchange::new(Message::default());
9804
9805 let result = producer.oneshot(exchange).await.unwrap();
9807
9808 let status = result
9810 .input
9811 .header("CamelHttpResponseCode")
9812 .and_then(|v| v.as_u64())
9813 .unwrap();
9814 assert_eq!(status, 200);
9815 }
9816
9817 async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
9819 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9820 let addr = listener.local_addr().unwrap();
9821 let url = format!("http://127.0.0.1:{}", addr.port());
9822
9823 let handle = tokio::spawn(async move {
9824 loop {
9825 if let Ok((mut stream, _)) = listener.accept().await {
9826 tokio::spawn(async move {
9827 use tokio::io::{AsyncReadExt, AsyncWriteExt};
9828 let mut buf = vec![0u8; 8192];
9829 let n = stream.read(&mut buf).await.unwrap_or(0);
9830 let request = String::from_utf8_lossy(&buf[..n]).to_string();
9831
9832 let traceparent = request
9834 .lines()
9835 .find(|line| line.to_lowercase().starts_with("traceparent:"))
9836 .map(|line| {
9837 line.split(':')
9838 .nth(1)
9839 .map(|s| s.trim().to_string())
9840 .unwrap_or_default()
9841 })
9842 .unwrap_or_default();
9843
9844 let body =
9845 format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
9846 let response = format!(
9847 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
9848 body.len(),
9849 traceparent,
9850 body
9851 );
9852 let _ = stream.write_all(response.as_bytes()).await;
9853 });
9854 }
9855 }
9856 });
9857
9858 (url, handle)
9859 }
9860 }
9861
9862 #[tokio::test]
9871 async fn test_request_body_arrives_as_stream() {
9872 use camel_component_api::Body;
9873 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9874
9875 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9876 let port = listener.local_addr().unwrap().port();
9877 drop(listener);
9878
9879 let component = HttpComponent::new();
9880 let endpoint_ctx = NoOpComponentContext;
9881 let endpoint = component
9882 .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
9883 .unwrap();
9884 let mut consumer = endpoint.create_consumer(rt()).unwrap();
9885
9886 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9887 let token = tokio_util::sync::CancellationToken::new();
9888 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9889
9890 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9891 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9892
9893 let client = plain_http_test_client();
9894 let send_fut = client
9895 .post(format!("http://127.0.0.1:{port}/upload"))
9896 .body("hello streaming world")
9897 .send();
9898
9899 let (http_result, _) = tokio::join!(send_fut, async {
9900 if let Some(mut envelope) = rx.recv().await {
9901 assert!(
9903 matches!(envelope.exchange.input.body, Body::Stream(_)),
9904 "expected Body::Stream, got discriminant {:?}",
9905 std::mem::discriminant(&envelope.exchange.input.body)
9906 );
9907 let bytes = envelope
9909 .exchange
9910 .input
9911 .body
9912 .into_bytes(1024 * 1024)
9913 .await
9914 .unwrap();
9915 assert_eq!(&bytes[..], b"hello streaming world");
9916
9917 envelope.exchange.input.body = camel_component_api::Body::Empty;
9918 if let Some(reply_tx) = envelope.reply_tx {
9919 let _ = reply_tx.send(Ok(envelope.exchange));
9920 }
9921 }
9922 });
9923
9924 let resp = http_result.unwrap();
9925 assert_eq!(resp.status().as_u16(), 200);
9926
9927 token.cancel();
9928 }
9929
9930 #[tokio::test]
9935 async fn test_streaming_response_chunked() {
9936 use bytes::Bytes;
9937 use camel_component_api::Body;
9938 use camel_component_api::CamelError;
9939 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9940 use camel_component_api::{StreamBody, StreamMetadata};
9941 use futures::stream;
9942 use std::sync::Arc;
9943 use tokio::sync::Mutex;
9944
9945 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9946 let port = listener.local_addr().unwrap().port();
9947 drop(listener);
9948
9949 let component = HttpComponent::new();
9950 let endpoint_ctx = NoOpComponentContext;
9951 let endpoint = component
9952 .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
9953 .unwrap();
9954 let mut consumer = endpoint.create_consumer(rt()).unwrap();
9955
9956 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9957 let token = tokio_util::sync::CancellationToken::new();
9958 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9959
9960 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9961 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9962
9963 let client = plain_http_test_client();
9964 let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
9965
9966 let (http_result, _) = tokio::join!(send_fut, async {
9967 if let Some(mut envelope) = rx.recv().await {
9968 let chunks: Vec<Result<Bytes, CamelError>> =
9970 vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
9971 let stream = Box::pin(stream::iter(chunks));
9972 envelope.exchange.input.body = Body::Stream(StreamBody {
9973 stream: Arc::new(Mutex::new(Some(stream))),
9974 metadata: StreamMetadata::default(),
9975 });
9976 if let Some(reply_tx) = envelope.reply_tx {
9977 let _ = reply_tx.send(Ok(envelope.exchange));
9978 }
9979 }
9980 });
9981
9982 let resp = http_result.unwrap();
9983 assert_eq!(resp.status().as_u16(), 200);
9984 let body = resp.text().await.unwrap();
9985 assert_eq!(body, "chunk1chunk2");
9986
9987 token.cancel();
9988 }
9989
9990 #[tokio::test]
9995 async fn test_413_when_content_length_exceeds_limit() {
9996 use camel_component_api::ConsumerContext;
9997
9998 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9999 let port = listener.local_addr().unwrap().port();
10000 drop(listener);
10001
10002 let component = HttpComponent::new();
10004 let endpoint_ctx = NoOpComponentContext;
10005 let endpoint = component
10006 .create_endpoint(
10007 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
10008 &endpoint_ctx,
10009 )
10010 .unwrap();
10011 let mut consumer = endpoint.create_consumer(rt()).unwrap();
10012
10013 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
10014 let token = tokio_util::sync::CancellationToken::new();
10015 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
10016
10017 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10018 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
10019
10020 let client = plain_http_test_client();
10021 let resp = client
10022 .post(format!("http://127.0.0.1:{port}/upload"))
10023 .header("Content-Length", "1000") .body("x".repeat(1000))
10025 .send()
10026 .await
10027 .unwrap();
10028
10029 assert_eq!(resp.status().as_u16(), 413);
10030
10031 token.cancel();
10032 }
10033
10034 #[tokio::test]
10038 async fn test_chunked_upload_without_content_length_bypasses_limit() {
10039 use bytes::Bytes;
10040 use camel_component_api::Body;
10041 use camel_component_api::ConsumerContext;
10042 use futures::stream;
10043
10044 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10045 let port = listener.local_addr().unwrap().port();
10046 drop(listener);
10047
10048 let component = HttpComponent::new();
10050 let endpoint_ctx = NoOpComponentContext;
10051 let endpoint = component
10052 .create_endpoint(
10053 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
10054 &endpoint_ctx,
10055 )
10056 .unwrap();
10057 let mut consumer = endpoint.create_consumer(rt()).unwrap();
10058
10059 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
10060 let token = tokio_util::sync::CancellationToken::new();
10061 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
10062
10063 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10064 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
10065
10066 let client = plain_http_test_client();
10067
10068 let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
10072 Ok(Bytes::from("y".repeat(50))),
10073 Ok(Bytes::from("y".repeat(50))),
10074 ];
10075 let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
10076 let send_fut = client
10077 .post(format!("http://127.0.0.1:{port}/upload"))
10078 .body(stream_body)
10079 .send();
10080
10081 let consumer_fut = async {
10082 match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
10084 Ok(Some(mut envelope)) => {
10085 assert!(
10086 matches!(envelope.exchange.input.body, Body::Stream(_)),
10087 "expected Body::Stream"
10088 );
10089 envelope.exchange.input.body = camel_component_api::Body::Empty;
10090 if let Some(reply_tx) = envelope.reply_tx {
10091 let _ = reply_tx.send(Ok(envelope.exchange));
10092 }
10093 }
10094 Ok(None) => panic!("consumer channel closed unexpectedly"),
10095 Err(_) => {
10096 }
10099 }
10100 };
10101
10102 let (http_result, _) = tokio::join!(send_fut, consumer_fut);
10103
10104 let resp = http_result.unwrap();
10105 assert_ne!(
10112 resp.status().as_u16(),
10113 413,
10114 "chunked upload has no Content-Length to pre-check"
10115 );
10116 assert_eq!(resp.status().as_u16(), 200);
10117
10118 token.cancel();
10119 }
10120
10121 #[test]
10122 fn test_is_private_ip_ranges() {
10123 use camel_api::is_ssrf_blocked_ip;
10124 assert!(is_ssrf_blocked_ip(&"10.0.0.1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"172.16.1.10".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"192.168.1.1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"127.0.0.1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"169.254.1.1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"0.1.2.3".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"::1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"fc00::1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"fd12::1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"fe80::1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"::ffff:10.0.0.1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"::ffff:192.168.1.1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"::ffff:127.0.0.1".parse().unwrap())); assert!(!is_ssrf_blocked_ip(&"8.8.8.8".parse().unwrap())); assert!(!is_ssrf_blocked_ip(&"::ffff:8.8.8.8".parse().unwrap())); assert!(!is_ssrf_blocked_ip(
10143 &"2001:4860:4860::8888".parse().unwrap()
10144 )); }
10146
10147 #[test]
10148 fn test_title_case_header() {
10149 assert_eq!(title_case_header("content-type"), "Content-Type");
10150 assert_eq!(title_case_header("authorization"), "Authorization");
10151 assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
10152 assert_eq!(title_case_header("host"), "Host");
10153 assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
10154 assert_eq!(title_case_header("single"), "Single");
10155 assert_eq!(title_case_header(""), "");
10156 }
10157
10158 #[test]
10159 fn test_resolve_url_combines_path_and_query_sources() {
10160 let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
10161 let mut exchange = Exchange::new(Message::default());
10162 exchange.input.set_header(
10163 "CamelHttpPath",
10164 serde_json::Value::String("next".to_string()),
10165 );
10166 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10167 assert!(url.starts_with("http://example.com/base/next?"));
10168 assert!(url.contains("foo=bar"));
10169
10170 exchange.input.set_header(
10171 "CamelHttpUri",
10172 serde_json::Value::String("http://other.test/root".to_string()),
10173 );
10174 exchange.input.set_header(
10175 "CamelHttpQuery",
10176 serde_json::Value::String("a=1&b=2".to_string()),
10177 );
10178
10179 let override_url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10180 assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
10181 }
10182
10183 fn exchange_with_path_and_query(path: &str, query: &str) -> Exchange {
10184 let mut exchange = Exchange::new(Message::default());
10185 exchange
10186 .input
10187 .set_header("CamelHttpPath", serde_json::Value::String(path.to_string()));
10188 exchange.input.set_header(
10189 "CamelHttpQuery",
10190 serde_json::Value::String(query.to_string()),
10191 );
10192 exchange
10193 }
10194
10195 #[test]
10196 fn resolve_url_bridge_endpoint_true_ignores_exchange_path() {
10197 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
10198 cfg.bridge_endpoint = true;
10199 cfg.query_params
10200 .push(("token".to_string(), "secret".to_string()));
10201 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
10202 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10203 assert_eq!(url, "http://x?token=secret");
10207 assert!(!url.contains("/foo"));
10208 assert!(!url.contains("dropme"));
10209 }
10210
10211 #[test]
10212 fn resolve_url_bridge_endpoint_false_merges_path() {
10213 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
10214 cfg.bridge_endpoint = false;
10215 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
10216 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10217 assert!(url.contains("/foo"), "url should contain /foo: {url}");
10218 assert!(
10219 url.contains("dropme=1"),
10220 "url should contain dropme=1: {url}"
10221 );
10222 }
10223
10224 #[test]
10225 fn resolve_url_bridge_endpoint_true_keeps_base_when_no_query_params() {
10226 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
10227 cfg.bridge_endpoint = true;
10228 let mut exchange = Exchange::new(Message::default());
10229 exchange.input.set_header(
10230 "CamelHttpPath",
10231 serde_json::Value::String("/foo".to_string()),
10232 );
10233 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10234 assert_eq!(url, "http://x");
10235 assert!(!url.contains("/foo"));
10236 }
10237
10238 #[test]
10239 fn resolve_url_bridge_endpoint_true_ignores_camel_http_uri() {
10240 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
10241 cfg.bridge_endpoint = true;
10242 let mut exchange = Exchange::new(Message::default());
10244 exchange.input.set_header(
10245 "CamelHttpUri",
10246 serde_json::Value::String("http://dest/explicit".to_string()),
10247 );
10248 exchange.input.set_header(
10249 "CamelHttpPath",
10250 serde_json::Value::String("/foo".to_string()),
10251 );
10252 exchange.input.set_header(
10253 "CamelHttpQuery",
10254 serde_json::Value::String("x=1".to_string()),
10255 );
10256 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10257 assert_eq!(url, "http://x");
10261 }
10262
10263 #[test]
10264 fn bridge_programmatic_params_use_percent20() {
10265 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
10266 cfg.bridge_endpoint = true;
10267 cfg.query_params = vec![("b".to_string(), "x y".to_string())];
10268 let exchange = Exchange::new(Message::default());
10269
10270 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10271
10272 assert_eq!(url, "http://x?b=x%20y");
10276 assert!(!url.contains('+'));
10277 }
10278
10279 #[test]
10280 fn bridge_arm_carries_authored_raw_query() {
10281 let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
10282 let exchange = exchange_with_path_and_query("/ignored", "dropme=1");
10285
10286 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10287
10288 assert_eq!(url, "http://h/p?a=1");
10291 assert!(!url.contains("dropme"), "exchange query leaked: {url}");
10292 assert!(!url.contains("/ignored"), "exchange path leaked: {url}");
10293 }
10294
10295 #[test]
10302 fn resolve_url_bridge_preserves_dot_segments() {
10303 let mut cfg = HttpEndpointConfig::from_uri("http://h/a/../b").unwrap();
10304 cfg.bridge_endpoint = true;
10305 cfg.query_params.push(("k".to_string(), "1".to_string()));
10306 let exchange = Exchange::new(Message::default());
10307
10308 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10309
10310 assert_eq!(url, "http://h/a/../b?k=1");
10313 }
10314
10315 #[test]
10316 fn resolve_url_bridge_preserves_default_port() {
10317 let mut cfg = HttpEndpointConfig::from_uri("http://h:80/p").unwrap();
10318 cfg.bridge_endpoint = true;
10319 cfg.query_params.push(("k".to_string(), "1".to_string()));
10320 let exchange = Exchange::new(Message::default());
10321
10322 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10323
10324 assert_eq!(url, "http://h:80/p?k=1");
10327 }
10328
10329 #[test]
10330 fn resolve_url_bridge_preserves_scheme_and_host_case() {
10331 let mut cfg = HttpEndpointConfig::from_uri("http://ExAMPLE.COM/p").unwrap();
10332 cfg.bridge_endpoint = true;
10333 cfg.query_params.push(("k".to_string(), "1".to_string()));
10334 cfg.base_url = "HTTP://ExAMPLE.COM/p".to_string();
10338 let exchange = Exchange::new(Message::default());
10339
10340 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10341
10342 assert_eq!(url, "HTTP://ExAMPLE.COM/p?k=1");
10345 }
10346
10347 #[test]
10348 fn resolve_url_bridge_no_query_emits_base_verbatim() {
10349 let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10350 cfg.bridge_endpoint = true;
10351 let exchange = Exchange::new(Message::default());
10352
10353 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10354
10355 assert_eq!(url, "http://h/p");
10358 }
10359
10360 #[test]
10361 fn resolve_url_bridge_and_non_bridge_byte_identical() {
10362 let mut bridged = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
10365 bridged.bridge_endpoint = true;
10366 bridged
10367 .query_params
10368 .push(("k".to_string(), "1".to_string()));
10369 let bridge_url =
10370 HttpProducer::resolve_url(&Exchange::new(Message::default()), &bridged).unwrap();
10371
10372 let plain = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
10375 let mut exchange = Exchange::new(Message::default());
10376 exchange.input.set_header(
10377 "CamelHttpQuery",
10378 serde_json::Value::String("k=1".to_string()),
10379 );
10380 let plain_url = HttpProducer::resolve_url(&exchange, &plain).unwrap();
10381
10382 assert_eq!(bridge_url, plain_url);
10383 assert_eq!(bridge_url, "http://H:80/a/../b?k=1");
10384 }
10385
10386 #[test]
10387 fn resolve_url_bridge_preserves_ipv6_authority_verbatim() {
10388 let mut cfg = HttpEndpointConfig::from_uri("http://[::1]:8080/p").unwrap();
10389 cfg.bridge_endpoint = true;
10390 cfg.query_params.push(("k".to_string(), "1".to_string()));
10391 let exchange = Exchange::new(Message::default());
10392
10393 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10394
10395 assert_eq!(url, "http://[::1]:8080/p?k=1");
10396 }
10397
10398 #[test]
10399 fn resolve_url_bridge_empty_base_path_keeps_no_synthetic_slash() {
10400 let cfg = HttpEndpointConfig::from_uri("http://h?x=1&bridgeEndpoint=true").unwrap();
10401 let exchange = Exchange::new(Message::default());
10402
10403 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10404
10405 assert_eq!(url, "http://h?x=1");
10408 }
10409
10410 #[test]
10415 fn resolve_url_preserves_authored_query_order_and_bytes() {
10416 let config =
10417 HttpEndpointConfig::from_uri("http://h/p?a=1&b=x,y&c=t:1&connectTimeout=5000").unwrap();
10418 let exchange = Exchange::new(Message::default());
10419
10420 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10421
10422 assert_eq!(url, "http://h/p?a=1&b=x,y&c=t:1");
10425 }
10426
10427 #[test]
10428 fn resolve_url_consumes_encoded_option_key() {
10429 let config = HttpEndpointConfig::from_uri("http://h/p?connect%54imeout=5000&a=1").unwrap();
10430 let exchange = Exchange::new(Message::default());
10431
10432 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10433
10434 assert_eq!(url, "http://h/p?a=1");
10436 }
10437
10438 #[test]
10439 fn resolve_url_all_options_consumed_drops_query() {
10440 let config = HttpEndpointConfig::from_uri("http://h/p?connectTimeout=5000").unwrap();
10441 let exchange = Exchange::new(Message::default());
10442
10443 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10444
10445 assert_eq!(url, "http://h/p");
10448 assert!(!url.contains('?'));
10449 }
10450
10451 #[test]
10452 fn resolve_url_preserves_empty_query_marker() {
10453 let config = HttpEndpointConfig::from_uri("http://h/p?").unwrap();
10454 let exchange = Exchange::new(Message::default());
10455
10456 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10457
10458 assert_eq!(url, "http://h/p?");
10461 }
10462
10463 #[test]
10464 fn resolve_url_raw_wrapper_not_re_encoded() {
10465 let config = HttpEndpointConfig::from_uri("http://h/p?token=RAW(abc)").unwrap();
10466 let exchange = Exchange::new(Message::default());
10467
10468 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10469
10470 assert_eq!(url, "http://h/p?token=RAW(abc)");
10472 assert!(!url.contains("%28"), "RAW( wrapper re-encoded: {url}");
10473 }
10474
10475 #[test]
10476 fn resolve_url_camel_http_query_composes_verbatim_span() {
10477 let config = HttpEndpointConfig::from_uri("http://h/p?x=1").unwrap();
10478 let mut exchange = Exchange::new(Message::default());
10479 exchange.input.set_header(
10480 "CamelHttpQuery",
10481 serde_json::Value::String("userFilter=a%2Cb".to_string()),
10482 );
10483
10484 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10485
10486 assert_eq!(url, "http://h/p?x=1&userFilter=a%2Cb");
10491 assert!(!url.contains("%252C"), "header bytes re-encoded: {url}");
10492 }
10493
10494 #[test]
10499 fn header_composes_with_endpoint_query() {
10500 let config =
10501 HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret&lang=en").unwrap();
10502 let mut exchange = Exchange::new(Message::default());
10503 exchange.input.set_header(
10504 "CamelHttpQuery",
10505 serde_json::Value::String("lang=es&page=2".to_string()),
10506 );
10507
10508 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10509
10510 assert_eq!(url, "http://upstream/api?apiKey=secret&lang=en&page=2");
10513 }
10514
10515 #[test]
10516 fn header_alone_still_rides() {
10517 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
10518 let mut exchange = Exchange::new(Message::default());
10519 exchange.input.set_header(
10520 "CamelHttpQuery",
10521 serde_json::Value::String("page=2".to_string()),
10522 );
10523
10524 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10525
10526 assert_eq!(url, "http://upstream/api?page=2");
10528 }
10529
10530 #[test]
10531 fn empty_reflected_query_leaves_endpoint_query_intact() {
10532 let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
10533 let mut exchange = Exchange::new(Message::default());
10534 exchange
10537 .input
10538 .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
10539
10540 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10541
10542 assert_eq!(url, "http://upstream/api?apiKey=secret");
10544 assert!(!url.ends_with('?'), "dangling '?' marker: {url}");
10545 }
10546
10547 #[test]
10548 fn forbidden_byte_in_header_query_errors() {
10549 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
10550 let mut exchange = Exchange::new(Message::default());
10551 exchange.input.set_header(
10552 "CamelHttpQuery",
10553 serde_json::Value::String("q=ab<cd".to_string()),
10554 );
10555
10556 let err = HttpProducer::resolve_url(&exchange, &config)
10557 .unwrap_err()
10558 .to_string();
10559
10560 assert!(err.contains("0x3C"), "error must name the byte: {err}");
10563 }
10564
10565 #[test]
10566 fn override_uri_with_query_plus_header_query() {
10567 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
10568 let mut exchange = Exchange::new(Message::default());
10569 exchange.input.set_header(
10570 "CamelHttpUri",
10571 serde_json::Value::String("http://host/api?a=1".to_string()),
10572 );
10573 exchange.input.set_header(
10574 "CamelHttpQuery",
10575 serde_json::Value::String("a=2&b=3".to_string()),
10576 );
10577
10578 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10579
10580 assert_eq!(url, "http://host/api?a=1&b=3");
10583 }
10584
10585 #[test]
10586 fn path_applies_before_query_composition() {
10587 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
10588 let mut exchange = Exchange::new(Message::default());
10589 exchange.input.set_header(
10590 "CamelHttpUri",
10591 serde_json::Value::String("http://host/api?a=1".to_string()),
10592 );
10593 exchange.input.set_header(
10594 "CamelHttpPath",
10595 serde_json::Value::String("/extra".to_string()),
10596 );
10597 exchange.input.set_header(
10598 "CamelHttpQuery",
10599 serde_json::Value::String("b=2".to_string()),
10600 );
10601
10602 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10603
10604 assert_eq!(url, "http://host/api/extra?a=1&b=2");
10607 }
10608
10609 #[test]
10610 fn plain_proxy_reflection_composes() {
10611 let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
10612 let exchange = exchange_with_path_and_query("/in/extra", "page=2");
10614
10615 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10616
10617 assert_eq!(url, "http://upstream/api/in/extra?apiKey=secret&page=2");
10620 }
10621
10622 #[test]
10623 fn bridge_endpoint_ignores_url_headers() {
10624 let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
10625 let mut exchange = Exchange::new(Message::default());
10626 exchange.input.set_header(
10627 "CamelHttpUri",
10628 serde_json::Value::String("http://evil.test/x".to_string()),
10629 );
10630 exchange.input.set_header(
10631 "CamelHttpPath",
10632 serde_json::Value::String("/foo".to_string()),
10633 );
10634 exchange.input.set_header(
10635 "CamelHttpQuery",
10636 serde_json::Value::String("z=9".to_string()),
10637 );
10638
10639 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10640
10641 assert_eq!(url, "http://h/p?a=1");
10644 assert!(!url.contains("evil"), "override leaked: {url}");
10645 assert!(!url.contains("z=9"), "header query leaked: {url}");
10646 assert!(!url.contains("/foo"), "header path leaked: {url}");
10647 }
10648
10649 #[test]
10650 fn resolve_url_programmatic_params_use_percent20_deterministic() {
10651 let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10652 config.query_params = vec![
10653 ("b".to_string(), "x y".to_string()),
10654 ("a".to_string(), "1".to_string()),
10655 ];
10656 let exchange = Exchange::new(Message::default());
10657
10658 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10659
10660 assert_eq!(url, "http://h/p?b=x%20y&a=1");
10663 assert!(!url.contains('+'));
10664 }
10665
10666 #[test]
10667 fn resolve_url_authored_and_programmatic_merge() {
10668 let mut config = HttpEndpointConfig::from_uri("http://h/p?a=1&c=t:1").unwrap();
10669 config.query_params = vec![
10670 ("b".to_string(), "2".to_string()),
10671 ("a".to_string(), "9".to_string()),
10672 ];
10673 let exchange = Exchange::new(Message::default());
10674
10675 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10676
10677 assert_eq!(url, "http://h/p?a=1&c=t:1&b=2");
10680 }
10681
10682 #[test]
10683 fn from_uri_no_longer_fills_query_params_from_uri() {
10684 let config = HttpEndpointConfig::from_uri("http://h/p?a=1&connectTimeout=5000").unwrap();
10685
10686 assert!(
10688 config.query_params.is_empty(),
10689 "query_params is programmatic-only: {:?}",
10690 config.query_params
10691 );
10692 assert_eq!(config.raw_query.as_deref(), Some("a=1&connectTimeout=5000"));
10693 }
10694
10695 #[test]
10696 fn resolve_url_forbidden_raw_byte_errors() {
10697 let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10698 config.raw_query = Some("a=x y".to_string());
10699 let exchange = Exchange::new(Message::default());
10700
10701 let err = HttpProducer::resolve_url(&exchange, &config)
10702 .expect_err("literal space in raw query must error");
10703
10704 assert!(
10706 err.to_string().contains("0x20"),
10707 "error must name the forbidden byte: {err}"
10708 );
10709 }
10710
10711 #[test]
10715 fn resolve_url_override_query_forbidden_byte_errors() {
10716 let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10717 let mut exchange = Exchange::new(Message::default());
10718 exchange.input.set_header(
10719 "CamelHttpUri",
10720 serde_json::Value::String("http://h2/p?a=x y".to_string()),
10721 );
10722
10723 let err = HttpProducer::resolve_url(&exchange, &config)
10724 .expect_err("literal space in the override URI's query must error");
10725
10726 assert!(
10727 err.to_string().contains("0x20"),
10728 "error must name the forbidden byte from the override query: {err}"
10729 );
10730 }
10731
10732 #[test]
10737 fn merge_header_query_decoded_key_collision_drops_header_pair() {
10738 let merged = merge_header_query(Some("a=1"), "%61=2")
10739 .expect("decoded-key collision must not be a parse error");
10740 assert_eq!(
10741 merged.as_deref(),
10742 Some("a=1"),
10743 "the higher-precedence span wins and the colliding header pair is dropped"
10744 );
10745 }
10746
10747 #[test]
10750 fn merge_header_query_duplicate_keys_within_header_ride_verbatim() {
10751 let merged = merge_header_query(None, "k=1&k=2")
10752 .expect("duplicate header keys must not be a parse error");
10753 assert_eq!(
10754 merged.as_deref(),
10755 Some("k=1&k=2"),
10756 "intra-header duplicate keys ride verbatim"
10757 );
10758 }
10759
10760 #[test]
10767 fn endpoint_config_debug_masks_base_url_userinfo() {
10768 let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
10769 config.base_url = "http://user:pass@h.example/p".to_string();
10770 let rendered = format!("{config:?}");
10771 assert!(
10772 rendered.contains("***@h.example"),
10773 "userinfo must render masked: {rendered}"
10774 );
10775 assert!(
10776 !rendered.contains("user:pass"),
10777 "no credentials in Debug output: {rendered}"
10778 );
10779
10780 let plain = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
10781 let rendered_plain = format!("{plain:?}");
10782 assert!(
10783 rendered_plain.contains("http://h.example/p"),
10784 "a base without userinfo renders unchanged: {rendered_plain}"
10785 );
10786 }
10787
10788 #[test]
10793 fn endpoint_config_debug_base_url_converges_on_canonical_redact() {
10794 let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
10795
10796 config.base_url = "http://h.example/p?token=secret#access_token=x".to_string();
10797 let rendered = format!("{config:?}");
10798 assert!(
10799 rendered.contains("base_url: \"http://h.example/p?[redacted]#[redacted]\""),
10800 "query and fragment must render as composed sentinels: {rendered}"
10801 );
10802 assert!(
10803 !rendered.contains("token=secret") && !rendered.contains("access_token"),
10804 "query/fragment credential bytes must not render: {rendered}"
10805 );
10806
10807 config.base_url = "http://h.example//u2:p2@evil/".to_string();
10808 let rendered = format!("{config:?}");
10809 assert!(
10810 rendered.contains("base_url: \"http://h.example//***@evil/\""),
10811 "later //window userinfo must mask (canonical window rule): {rendered}"
10812 );
10813 assert!(
10814 !rendered.contains("u2:p2"),
10815 "later-window credentials must not render: {rendered}"
10816 );
10817
10818 config.base_url = "http://user:pass@h.example/p?token=x".to_string();
10821 let canonical = camel_api::redact::redact_url(&config.base_url);
10822 assert_eq!(canonical, "http://***@h.example/p?[redacted]");
10823 let rendered = format!("{config:?}");
10824 assert!(
10825 rendered.contains(&format!("base_url: \"{canonical}\"")),
10826 "Debug base_url must equal canonical redact_url output: {rendered}"
10827 );
10828 }
10829
10830 #[test]
10836 fn resolve_url_authored_apostrophe_rejected_percent_escape_rides() {
10837 let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10838
10839 config.raw_query = Some("q=it's".to_string());
10840 let exchange = Exchange::new(Message::default());
10841 let err = HttpProducer::resolve_url(&exchange, &config)
10842 .expect_err("authored apostrophe must be rejected, not silently %27-normalized");
10843 assert!(
10844 err.to_string().contains("0x27"),
10845 "error must name the apostrophe byte: {err}"
10846 );
10847
10848 config.raw_query = Some("q=it%27s".to_string());
10849 let url = HttpProducer::resolve_url(&exchange, &config)
10850 .expect("authored %27 escape is wire-legal");
10851 assert!(
10852 url.contains("q=it%27s"),
10853 "the authored escape must ride byte-for-byte: {url}"
10854 );
10855
10856 for &byte in b"\"`<>" {
10860 config.raw_query = Some(format!("k={}x", byte as char));
10861 let err = HttpProducer::resolve_url(&exchange, &config)
10862 .expect_err("WHATWG special-query byte must be rejected");
10863 assert!(
10864 err.to_string().contains(&format!("0x{byte:02X}")),
10865 "error must name byte 0x{byte:02X}: {err}"
10866 );
10867 }
10868 }
10869
10870 #[test]
10871 fn armed_fence_rejects_unknown_host_redacted() {
10872 let cfg = HttpEndpointConfig::from_uri(
10873 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10874 )
10875 .unwrap();
10876 let mut exchange = Exchange::new(Message::default());
10877 exchange.input.set_header(
10878 "CamelHttpUri",
10879 serde_json::Value::String(
10880 "http://user:pass@evil.example.com/x?token=s3cret".to_string(),
10881 ),
10882 );
10883
10884 let err = HttpProducer::resolve_url(&exchange, &cfg)
10885 .expect_err("override host outside the fence must fail resolution");
10886
10887 let message = err.to_string();
10888 assert!(!message.contains("pass"), "userinfo leaked: {message}");
10889 assert!(!message.contains("s3cret"), "query leaked: {message}");
10890 }
10891
10892 #[test]
10893 fn armed_fence_rejects_unparseable_override_redacted() {
10894 let cfg = HttpEndpointConfig::from_uri(
10895 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10896 )
10897 .unwrap();
10898 let mut exchange = Exchange::new(Message::default());
10899 exchange.input.set_header(
10900 "CamelHttpUri",
10901 serde_json::Value::String("http://u:fencesecret@evil.example.com:99999/x".to_string()),
10902 );
10903
10904 let err = HttpProducer::resolve_url(&exchange, &cfg)
10905 .expect_err("unparseable override outside the fence must fail resolution");
10906
10907 let message = err.to_string();
10908 assert!(
10909 message.contains("allowedUriHosts fence"),
10910 "fence must be named: {message}"
10911 );
10912 assert!(
10913 message.contains("[redacted]"),
10914 "suppression sentinel missing: {message}"
10915 );
10916 assert!(
10917 !message.contains("evil.example.com"),
10918 "host leaked: fail-closed arm must render only the sentinel: {message}"
10919 );
10920 assert!(
10921 !message.contains("fencesecret"),
10922 "password leaked: {message}"
10923 );
10924 assert!(!message.contains("u:"), "userinfo leaked: {message}");
10925 }
10926
10927 #[test]
10928 fn armed_fence_rejects_password_only_userinfo_redacted() {
10929 let cfg = HttpEndpointConfig::from_uri(
10930 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10931 )
10932 .unwrap();
10933 let mut exchange = Exchange::new(Message::default());
10934 exchange.input.set_header(
10935 "CamelHttpUri",
10936 serde_json::Value::String(
10937 "http://:passwordonly@evil.example.com/x?token=querysecret".to_string(),
10938 ),
10939 );
10940
10941 let err = HttpProducer::resolve_url(&exchange, &cfg)
10942 .expect_err("password-only override outside the fence must fail resolution");
10943
10944 let message = err.to_string();
10945 assert!(
10946 !message.contains("passwordonly"),
10947 "password-only userinfo leaked: {message}"
10948 );
10949 assert!(!message.contains("querysecret"), "query leaked: {message}");
10950 assert!(
10951 message.contains("http://***@evil.example.com/x?[redacted]"),
10952 "masked shape missing: {message}"
10953 );
10954 }
10955
10956 #[test]
10957 fn armed_fence_allows_listed_host() {
10958 let cfg = HttpEndpointConfig::from_uri(
10959 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10960 )
10961 .unwrap();
10962 let mut exchange = Exchange::new(Message::default());
10963 exchange.input.set_header(
10964 "CamelHttpUri",
10965 serde_json::Value::String("http://cdn.example.com/x".to_string()),
10966 );
10967
10968 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10969 assert_eq!(url, "http://cdn.example.com/x");
10970 }
10971
10972 #[test]
10973 fn host_only_entry_permits_any_port() {
10974 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=cdn.example.com").unwrap();
10975 let mut exchange = Exchange::new(Message::default());
10976 exchange.input.set_header(
10977 "CamelHttpUri",
10978 serde_json::Value::String("http://cdn.example.com:9443/x".to_string()),
10979 );
10980
10981 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10982 assert_eq!(url, "http://cdn.example.com:9443/x");
10983 }
10984
10985 #[test]
10986 fn unarmed_endpoint_unchanged() {
10987 let cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
10988 let mut exchange = Exchange::new(Message::default());
10989 exchange.input.set_header(
10990 "CamelHttpUri",
10991 serde_json::Value::String("http://any.example.com/path".to_string()),
10992 );
10993
10994 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10995 assert_eq!(url, "http://any.example.com/path");
10996 }
10997
10998 #[test]
10999 fn empty_allowlist_fails_endpoint_creation() {
11000 assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=,,").is_err());
11001 }
11002
11003 #[test]
11004 fn malformed_entry_fails_endpoint_creation() {
11005 assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=not a host!").is_err());
11006 }
11007
11008 #[test]
11009 fn fence_entry_with_path_fails_creation() {
11010 assert!(
11013 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=api.internal:8443/v2").is_err()
11014 );
11015 }
11016
11017 #[test]
11018 fn fence_entry_with_userinfo_fails_creation() {
11019 assert!(
11020 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=user@cdn.example.com").is_err()
11021 );
11022 }
11023
11024 #[test]
11025 fn ipv6_fence_entry_allows_bracketed_host() {
11026 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=[::1]:8443").unwrap();
11027 for uri in ["http://[::1]:8443/x", "http://[0:0:0:0:0:0:0:1]:8443/x"] {
11030 let mut exchange = Exchange::new(Message::default());
11031 exchange
11032 .input
11033 .set_header("CamelHttpUri", serde_json::Value::String(uri.to_string()));
11034 let url = HttpProducer::resolve_url(&exchange, &cfg)
11035 .unwrap_or_else(|e| panic!("override {uri} must be honored: {e}"));
11036 assert_eq!(url, uri, "bracketed IPv6 override not honored");
11037 }
11038 }
11039
11040 #[test]
11041 fn dns_case_insensitive_fence_match() {
11042 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=CDN.Example.COM").unwrap();
11045 let mut exchange = Exchange::new(Message::default());
11046 exchange.input.set_header(
11047 "CamelHttpUri",
11048 serde_json::Value::String("http://cdn.example.com/x".to_string()),
11049 );
11050 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
11051 assert_eq!(url, "http://cdn.example.com/x");
11052 }
11053
11054 #[test]
11055 fn fence_allowed_override_query_merges_with_header() {
11056 let cfg =
11059 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example&k=v").unwrap();
11060 let mut exchange = Exchange::new(Message::default());
11061 exchange.input.set_header(
11062 "CamelHttpUri",
11063 serde_json::Value::String("http://host.example/api?a=1".to_string()),
11064 );
11065 exchange.input.set_header(
11066 "CamelHttpQuery",
11067 serde_json::Value::String("b=2".to_string()),
11068 );
11069
11070 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
11071 assert_eq!(url, "http://host.example/api?a=1&b=2");
11072 }
11073
11074 #[test]
11075 fn empty_header_with_armed_fence_leaves_no_query() {
11076 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example").unwrap();
11077 let mut exchange = Exchange::new(Message::default());
11078 exchange.input.set_header(
11079 "CamelHttpUri",
11080 serde_json::Value::String("http://host.example/api".to_string()),
11081 );
11082 exchange
11083 .input
11084 .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
11085
11086 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
11087 assert_eq!(url, "http://host.example/api");
11088 assert!(!url.contains('?'), "query marker leaked: {url}");
11089 }
11090
11091 #[test]
11092 fn fence_option_is_consumed() {
11093 let cfg =
11097 HttpEndpointConfig::from_uri("http://h/p?x=1&allowedUriHosts=cdn.example.com").unwrap();
11098 let exchange = Exchange::new(Message::default());
11099
11100 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
11101 assert!(!url.contains("allowedUriHosts"), "option leaked: {url}");
11102 assert!(url.contains("x=1"), "authored query lost: {url}");
11103 }
11104
11105 #[tokio::test]
11106 async fn resolve_url_malformed_base_url_errors_no_panic() {
11107 use tower::ServiceExt;
11108
11109 let (url, _handle) = start_test_server().await;
11110 let mut config = HttpEndpointConfig::from_uri("http://[::1:bad").unwrap();
11111 config.allow_internal = true; let producer = HttpProducer {
11113 config: Arc::new(config),
11114 client: build_client(&HttpConfig::default(), None).expect("client must build"), pinned_cache: Arc::new(PinnedClientCache::new(
11116 PINNED_CLIENT_TTL,
11117 PINNED_CLIENT_MAX_ENTRIES,
11118 )),
11119 http_config: Arc::new(HttpConfig::default()),
11120 runtime: rt(),
11121 };
11122
11123 let first = producer
11126 .clone()
11127 .oneshot(Exchange::new(Message::default()))
11128 .await;
11129 let err = first.expect_err("malformed base URL must error, not panic");
11130 assert!(
11131 err.to_string().to_lowercase().contains("url"),
11132 "error must name the malformed URL: {err}"
11133 );
11134
11135 let mut exchange = Exchange::new(Message::default());
11138 exchange.input.set_header(
11139 "CamelHttpUri",
11140 serde_json::Value::String(format!("{url}/api")),
11141 );
11142 let response = producer
11143 .oneshot(exchange)
11144 .await
11145 .expect("valid request through same producer must succeed");
11146 let status = response
11147 .input
11148 .header("CamelHttpResponseCode")
11149 .and_then(|v| v.as_u64())
11150 .unwrap();
11151 assert_eq!(status, 200);
11152 }
11153
11154 #[test]
11155 fn resolve_url_bridge_malformed_base_errors_no_panic() {
11156 let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
11157 cfg.bridge_endpoint = true;
11158 cfg.query_params.push(("k".to_string(), "1".to_string()));
11159 cfg.base_url = "http://[::1:bad".to_string();
11164 let exchange = Exchange::new(Message::default());
11165
11166 let err = HttpProducer::resolve_url(&exchange, &cfg)
11167 .expect_err("malformed bridge base URL must error");
11168 assert!(
11169 err.to_string().contains("invalid base URL"),
11170 "error must name the invalid base URL: {err}"
11171 );
11172 }
11173
11174 #[test]
11175 fn test_http_producer_helpers_status_and_size_boundaries() {
11176 assert!(HttpProducer::is_ok_status(200, (200, 299)));
11177 assert!(HttpProducer::is_ok_status(299, (200, 299)));
11178 assert!(!HttpProducer::is_ok_status(199, (200, 299)));
11179 assert!(!HttpProducer::is_ok_status(300, (200, 299)));
11180
11181 assert!(!exceeds_max_response_body(10, 10));
11182 assert!(exceeds_max_response_body(11, 10));
11183 }
11184
11185 #[allow(clippy::await_holding_lock)]
11190 async fn setup_consumer_on_free_port(
11191 path: &str,
11192 ) -> (
11193 u16,
11194 tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
11195 tokio_util::sync::CancellationToken,
11196 ) {
11197 use camel_component_api::ConsumerContext;
11198
11199 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11204 let port = listener.local_addr().unwrap().port();
11205
11206 let _registry_guard = lock_registry_test_mutex();
11215
11216 ServerRegistry::global()
11217 .stage_listener(listener)
11218 .await
11219 .expect("stage consumer test listener");
11220
11221 let consumer_cfg = HttpServerConfig {
11222 scheme: "http".to_string(),
11223 host: "127.0.0.1".to_string(),
11224 port,
11225 path: path.to_string(),
11226 max_request_body: 2 * 1024 * 1024,
11227 max_response_body: 10 * 1024 * 1024,
11228 max_inflight_requests: 1024,
11229 method: None,
11230 tls_config: None,
11231 };
11232 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
11233
11234 let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
11235 let token = tokio_util::sync::CancellationToken::new();
11236 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
11237
11238 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
11239
11240 wait_for_registry_ready("127.0.0.1", port).await;
11246 for _ in 0..8 {
11247 tokio::task::yield_now().await;
11248 }
11249
11250 (port, rx, token)
11251 }
11252
11253 async fn wait_for_registry_ready(host: &str, port: u16) {
11258 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
11259 let mut backoff = std::time::Duration::from_millis(1);
11260 while ServerRegistry::global().bound_addr(host, port).is_none() {
11261 assert!(
11262 tokio::time::Instant::now() < deadline,
11263 "consumer server did not become ready on port {port} — registry entry absent (concurrent reset or starvation)"
11264 );
11265 tokio::time::sleep(backoff).await;
11266 backoff = (backoff * 2).min(std::time::Duration::from_millis(64));
11267 }
11268 }
11269
11270 #[tokio::test(start_paused = true)]
11271 #[should_panic(expected = "registry entry absent (concurrent reset or starvation)")]
11272 async fn readiness_deadline_fires_loud_with_hint() {
11273 let held = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11287 let port = held.local_addr().unwrap().port();
11288 wait_for_registry_ready("httpflake-unreachable-host", port).await;
11289 }
11290
11291 #[tokio::test]
11296 async fn readiness_survives_concurrent_registry_reset() {
11297 let contended = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
11298 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
11299
11300 let contended_hammer = std::sync::Arc::clone(&contended);
11307 let stop_hammer = std::sync::Arc::clone(&stop);
11308 let handle = std::thread::spawn(move || {
11309 while !stop_hammer.load(std::sync::atomic::Ordering::Relaxed) {
11310 let _guard = match REGISTRY_TEST_MUTEX.try_lock() {
11311 Err(std::sync::TryLockError::WouldBlock) => {
11312 contended_hammer.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
11313 lock_registry_test_mutex()
11314 }
11315 Err(std::sync::TryLockError::Poisoned(_)) => lock_registry_test_mutex(),
11316 Ok(guard) => guard,
11317 };
11318 ServerRegistry::reset();
11319 }
11320 });
11321
11322 struct StopHammerOnDrop {
11325 handle: Option<std::thread::JoinHandle<()>>,
11326 stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
11327 }
11328 impl Drop for StopHammerOnDrop {
11329 fn drop(&mut self) {
11330 self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
11331 if let Some(handle) = self.handle.take() {
11332 let _ = handle.join();
11333 }
11334 }
11335 }
11336 let _hammer_guard = StopHammerOnDrop {
11337 handle: Some(handle),
11338 stop,
11339 };
11340
11341 let mut setups = 0;
11344 loop {
11345 setups += 1;
11346 let (_port, rx, token) = setup_consumer_on_free_port("/reset-hammer").await;
11347 drop(rx);
11348 token.cancel();
11349 if (setups >= 25 && contended.load(std::sync::atomic::Ordering::SeqCst) >= 1)
11350 || setups >= 50
11351 {
11352 break;
11353 }
11354 }
11355
11356 let contended_hits = contended.load(std::sync::atomic::Ordering::SeqCst);
11357 assert!(
11358 contended_hits >= 1,
11359 "expected at least one contended registry reset across {setups} setups, got {contended_hits}"
11360 );
11361 }
11362
11363 #[tokio::test]
11364 async fn test_content_type_inferred_for_json_body() {
11365 let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
11366
11367 let client = plain_http_test_client();
11368 let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
11369
11370 let (http_result, _) = tokio::join!(send_fut, async {
11371 if let Some(mut envelope) = rx.recv().await {
11372 envelope.exchange.input.body =
11373 camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
11374 if let Some(reply_tx) = envelope.reply_tx {
11375 let _ = reply_tx.send(Ok(envelope.exchange));
11376 }
11377 }
11378 });
11379
11380 let resp = http_result.unwrap();
11381 assert_eq!(resp.status().as_u16(), 200);
11382 let ct = resp
11383 .headers()
11384 .get("content-type")
11385 .expect("Content-Type header should be present");
11386 assert_eq!(ct, "application/json");
11387 let body = resp.text().await.unwrap();
11388 assert_eq!(body, r#"{"message":"hello"}"#);
11389
11390 token.cancel();
11391 }
11392
11393 #[tokio::test]
11394 async fn test_content_type_inferred_for_text_body() {
11395 let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
11396
11397 let client = build_client(&HttpConfig::default(), None).expect("client must build"); let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
11403
11404 let (http_result, _) = tokio::join!(send_fut, async {
11405 if let Some(mut envelope) = rx.recv().await {
11406 envelope.exchange.input.body =
11407 camel_component_api::Body::Text("plain text response".to_string());
11408 if let Some(reply_tx) = envelope.reply_tx {
11409 let _ = reply_tx.send(Ok(envelope.exchange));
11410 }
11411 }
11412 });
11413
11414 let resp = http_result.unwrap();
11415 assert_eq!(resp.status().as_u16(), 200);
11416 let ct = resp
11417 .headers()
11418 .get("content-type")
11419 .expect("Content-Type header should be present");
11420 assert_eq!(ct, "text/plain; charset=utf-8");
11421 let body = resp.text().await.unwrap();
11422 assert_eq!(body, "plain text response");
11423
11424 token.cancel();
11425 }
11426
11427 #[tokio::test]
11428 async fn test_content_type_inferred_for_xml_body() {
11429 let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
11430
11431 let client = plain_http_test_client();
11432 let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
11433
11434 let (http_result, _) = tokio::join!(send_fut, async {
11435 if let Some(mut envelope) = rx.recv().await {
11436 envelope.exchange.input.body =
11437 camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
11438 if let Some(reply_tx) = envelope.reply_tx {
11439 let _ = reply_tx.send(Ok(envelope.exchange));
11440 }
11441 }
11442 });
11443
11444 let resp = http_result.unwrap();
11445 assert_eq!(resp.status().as_u16(), 200);
11446 let ct = resp
11447 .headers()
11448 .get("content-type")
11449 .expect("Content-Type header should be present");
11450 assert_eq!(ct, "application/xml");
11451 let body = resp.text().await.unwrap();
11452 assert_eq!(body, "<root><item>value</item></root>");
11453
11454 token.cancel();
11455 }
11456
11457 #[tokio::test]
11458 async fn test_no_content_type_for_empty_body() {
11459 let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
11460
11461 let client = plain_http_test_client();
11462 let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
11463
11464 let (http_result, _) = tokio::join!(send_fut, async {
11465 if let Some(mut envelope) = rx.recv().await {
11466 envelope.exchange.input.body = camel_component_api::Body::Empty;
11467 if let Some(reply_tx) = envelope.reply_tx {
11468 let _ = reply_tx.send(Ok(envelope.exchange));
11469 }
11470 }
11471 });
11472
11473 let resp = http_result.unwrap();
11474 assert_eq!(resp.status().as_u16(), 200);
11475 assert!(
11476 resp.headers().get("content-type").is_none(),
11477 "Empty body should not set Content-Type"
11478 );
11479
11480 token.cancel();
11481 }
11482
11483 #[tokio::test]
11484 async fn test_no_content_type_for_raw_bytes_body() {
11485 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
11486
11487 let client = plain_http_test_client();
11488 let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
11489
11490 let (http_result, _) = tokio::join!(send_fut, async {
11491 if let Some(mut envelope) = rx.recv().await {
11492 envelope.exchange.input.body =
11493 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
11494 if let Some(reply_tx) = envelope.reply_tx {
11495 let _ = reply_tx.send(Ok(envelope.exchange));
11496 }
11497 }
11498 });
11499
11500 let resp = http_result.unwrap();
11501 assert_eq!(resp.status().as_u16(), 200);
11502 assert!(
11503 resp.headers().get("content-type").is_none(),
11504 "Raw Bytes body should not set Content-Type"
11505 );
11506
11507 token.cancel();
11508 }
11509
11510 #[tokio::test]
11511 async fn test_content_type_from_stream_metadata() {
11512 use camel_component_api::{StreamBody, StreamMetadata};
11513 use futures::stream;
11514
11515 let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
11516
11517 let client = plain_http_test_client();
11518 let send_fut = client
11519 .get(format!("http://127.0.0.1:{port}/stream-ct"))
11520 .send();
11521
11522 let (http_result, _) = tokio::join!(send_fut, async {
11523 if let Some(mut envelope) = rx.recv().await {
11524 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
11525 vec![Ok(bytes::Bytes::from("audio data"))];
11526 let stream = Box::pin(stream::iter(chunks));
11527 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
11528 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
11529 metadata: StreamMetadata {
11530 size_hint: None,
11531 content_type: Some("audio/mpeg".to_string()),
11532 origin: None,
11533 },
11534 });
11535 if let Some(reply_tx) = envelope.reply_tx {
11536 let _ = reply_tx.send(Ok(envelope.exchange));
11537 }
11538 }
11539 });
11540
11541 let resp = http_result.unwrap();
11542 assert_eq!(resp.status().as_u16(), 200);
11543 let ct = resp
11544 .headers()
11545 .get("content-type")
11546 .expect("Content-Type header should be present");
11547 assert_eq!(ct, "audio/mpeg");
11548 let body = resp.text().await.unwrap();
11549 assert_eq!(body, "audio data");
11550
11551 token.cancel();
11552 }
11553
11554 #[tokio::test]
11555 async fn test_user_content_type_overrides_inferred() {
11556 let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
11557
11558 let client = plain_http_test_client();
11559 let send_fut = client
11560 .get(format!("http://127.0.0.1:{port}/override-ct"))
11561 .send();
11562
11563 let (http_result, _) = tokio::join!(send_fut, async {
11564 if let Some(mut envelope) = rx.recv().await {
11565 envelope.exchange.input.body =
11566 camel_component_api::Body::Json(serde_json::json!({"ok": true}));
11567 envelope.exchange.input.set_header(
11568 "Content-Type",
11569 serde_json::Value::String("text/html".to_string()),
11570 );
11571 if let Some(reply_tx) = envelope.reply_tx {
11572 let _ = reply_tx.send(Ok(envelope.exchange));
11573 }
11574 }
11575 });
11576
11577 let resp = http_result.unwrap();
11578 assert_eq!(resp.status().as_u16(), 200);
11579 let ct = resp
11580 .headers()
11581 .get("content-type")
11582 .expect("Content-Type header should be present");
11583 assert_eq!(
11584 ct, "text/html",
11585 "User-set Content-Type should take precedence over inferred type"
11586 );
11587
11588 token.cancel();
11589 }
11590
11591 #[tokio::test]
11592 async fn test_user_content_type_with_bytes_body() {
11593 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
11594
11595 let client = plain_http_test_client();
11596 let send_fut = client
11597 .get(format!("http://127.0.0.1:{port}/bytes-ct"))
11598 .send();
11599
11600 let (http_result, _) = tokio::join!(send_fut, async {
11601 if let Some(mut envelope) = rx.recv().await {
11602 envelope.exchange.input.body =
11603 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
11604 envelope.exchange.input.set_header(
11605 "Content-Type",
11606 serde_json::Value::String("application/json".to_string()),
11607 );
11608 if let Some(reply_tx) = envelope.reply_tx {
11609 let _ = reply_tx.send(Ok(envelope.exchange));
11610 }
11611 }
11612 });
11613
11614 let resp = http_result.unwrap();
11615 assert_eq!(resp.status().as_u16(), 200);
11616 let ct = resp
11617 .headers()
11618 .get("content-type")
11619 .expect("Content-Type header should be present for Bytes body with user header");
11620 assert_eq!(
11621 ct, "application/json",
11622 "User Content-Type should be sent for Bytes body"
11623 );
11624
11625 token.cancel();
11626 }
11627
11628 #[tokio::test]
11633 async fn monitor_task_silent_on_clean_exit() {
11634 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
11635 let server_exited = tokio_util::sync::CancellationToken::new();
11636 monitor_axum_task(
11638 handle,
11639 "127.0.0.1:0".to_string(),
11640 noop_rt(),
11641 "test-monitor".into(),
11642 server_exited.clone(),
11643 )
11644 .await;
11645 assert!(
11649 !server_exited.is_cancelled(),
11650 "clean server exit must not cancel server_exited"
11651 );
11652 }
11653
11654 #[tokio::test]
11655 async fn monitor_task_handles_panicked_task() {
11656 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
11657 panic!("simulated server crash");
11658 });
11659 let server_exited = tokio_util::sync::CancellationToken::new();
11660 monitor_axum_task(
11662 handle,
11663 "127.0.0.1:9999".to_string(),
11664 noop_rt(),
11665 "test-monitor".into(),
11666 server_exited.clone(),
11667 )
11668 .await;
11669 assert!(
11672 server_exited.is_cancelled(),
11673 "crashed server must cancel server_exited"
11674 );
11675 }
11676
11677 #[test]
11682 fn http_auth_basic_debug_redacts_password() {
11683 let auth = HttpAuth::Basic {
11684 username: "admin".to_string(),
11685 password: "hunter2".to_string(),
11686 };
11687 let debug = format!("{:?}", auth);
11688 assert!(
11689 !debug.contains("hunter2"),
11690 "password must be redacted: {debug}"
11691 );
11692 assert!(debug.contains("admin"), "username should appear: {debug}");
11693 }
11694
11695 #[test]
11696 fn http_auth_bearer_debug_redacts_token() {
11697 let auth = HttpAuth::Bearer {
11698 token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
11699 };
11700 let debug = format!("{:?}", auth);
11701 assert!(
11702 !debug.contains("eyJhbGci"),
11703 "token must be redacted: {debug}"
11704 );
11705 }
11706
11707 #[test]
11708 fn http_auth_none_debug_shows_variant() {
11709 let debug = format!("{:?}", HttpAuth::None);
11710 assert!(
11711 debug.contains("None"),
11712 "None variant should appear: {debug}"
11713 );
11714 }
11715
11716 #[test]
11717 fn http_endpoint_config_debug_redacts_auth_credentials() {
11718 let config = HttpEndpointConfig::from_uri(
11719 "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
11720 )
11721 .unwrap();
11722 let debug = format!("{:?}", config);
11723 assert!(
11724 !debug.contains("secret123"),
11725 "password must be redacted in HttpEndpointConfig debug: {debug}"
11726 );
11727 }
11728
11729 #[test]
11730 fn debug_lists_all_public_fields() {
11731 let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
11732 let debug = format!("{:?}", config);
11733 for field in [
11734 "base_url",
11735 "http_method",
11736 "throw_exception_on_failure",
11737 "ok_status_code_range",
11738 "response_timeout",
11739 "query_params",
11740 "raw_query",
11741 "allow_internal",
11742 "allow_cleartext",
11743 "blocked_hosts",
11744 "max_body_size",
11745 "read_timeout_ms",
11746 "max_response_bytes",
11747 "auth",
11748 "token_provider",
11749 "user_agent",
11750 "bridge_endpoint",
11751 "connection_close",
11752 "skip_request_headers",
11753 "skip_response_headers",
11754 "follow_redirects",
11755 "max_redirects",
11756 ] {
11757 assert!(
11758 debug.contains(field),
11759 "Debug output missing field '{field}': {debug}"
11760 );
11761 }
11762 }
11763
11764 use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
11769 use tower_http::services::ServeDir;
11770
11771 fn make_test_registry() -> HttpRouteRegistry {
11772 HttpRouteRegistry::new()
11773 }
11774
11775 fn make_test_state(registry: HttpRouteRegistry) -> AppState {
11776 AppState {
11777 registry,
11778 max_request_body: 2 * 1024 * 1024,
11779 max_response_body: 10 * 1024 * 1024,
11780 inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
11781 }
11782 }
11783
11784 #[allow(clippy::await_holding_lock)]
11785 #[tokio::test]
11786 async fn test_static_file_serving_serves_file_contents() {
11787 let _guard = lock_registry_test_mutex();
11788 ServerRegistry::reset();
11789
11790 let temp_dir =
11792 std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
11793 std::fs::create_dir_all(&temp_dir).unwrap();
11794 std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
11795 std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
11796
11797 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11798
11799 let registry = make_test_registry();
11800 let serve_dir = ServeDir::new(&canonical_dir)
11801 .precompressed_gzip()
11802 .precompressed_br()
11803 .append_index_html_on_directories(true);
11804
11805 let mount = StaticMount {
11806 mount_path: "/".to_string(),
11807 mode: MountMode::Static,
11808 dir: canonical_dir.clone(),
11809 cache_control: "public, max-age=3600".to_string(),
11810 error_pages: std::collections::HashMap::new(),
11811 serve_dir,
11812 };
11813 registry.register_static_mount(mount).await.unwrap();
11814
11815 let state = make_test_state(registry);
11816
11817 let req = Request::builder()
11819 .uri("/hello.txt")
11820 .body(AxumBody::empty())
11821 .unwrap();
11822 let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
11823 assert_eq!(resp.status(), StatusCode::OK);
11824 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11825 .await
11826 .unwrap();
11827 assert_eq!(&body[..], b"Hello, static world!");
11828
11829 let req = Request::builder()
11831 .uri("/style.css")
11832 .body(AxumBody::empty())
11833 .unwrap();
11834 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
11835 assert_eq!(resp.status(), StatusCode::OK);
11836 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11837 .await
11838 .unwrap();
11839 assert_eq!(&body[..], b"body { color: red; }");
11840
11841 let req = Request::builder()
11843 .uri("/missing.txt")
11844 .body(AxumBody::empty())
11845 .unwrap();
11846 let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
11847 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11848
11849 std::fs::remove_dir_all(&temp_dir).ok();
11851 }
11852
11853 #[allow(clippy::await_holding_lock)]
11854 #[tokio::test]
11855 async fn test_spa_fallback_serves_index_for_unknown_paths() {
11856 let _guard = lock_registry_test_mutex();
11857 ServerRegistry::reset();
11858
11859 let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
11860 std::fs::create_dir_all(&temp_dir).unwrap();
11861 std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
11862 std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
11863
11864 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11865
11866 let registry = make_test_registry();
11867 let serve_dir = ServeDir::new(&canonical_dir)
11868 .precompressed_gzip()
11869 .precompressed_br()
11870 .append_index_html_on_directories(true);
11871
11872 let mount = StaticMount {
11873 mount_path: "/".to_string(),
11874 mode: MountMode::Spa,
11875 dir: canonical_dir.clone(),
11876 cache_control: "public, max-age=0".to_string(),
11877 error_pages: std::collections::HashMap::new(),
11878 serve_dir,
11879 };
11880 registry.register_static_mount(mount).await.unwrap();
11882
11883 let state = make_test_state(registry);
11884
11885 let req = Request::builder()
11887 .method("GET")
11888 .uri("/dashboard")
11889 .header("Accept", "text/html")
11890 .body(AxumBody::empty())
11891 .unwrap();
11892 let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
11893 assert_eq!(resp.status(), StatusCode::OK);
11894 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11895 .await
11896 .unwrap();
11897 assert_eq!(&body[..], b"<h1>SPA App</h1>");
11898
11899 let req = Request::builder()
11901 .method("GET")
11902 .uri("/app.js")
11903 .body(AxumBody::empty())
11904 .unwrap();
11905 let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
11906 assert_eq!(resp.status(), StatusCode::OK);
11907 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11908 .await
11909 .unwrap();
11910 assert_eq!(&body[..], b"console.log('app')");
11911
11912 let req = Request::builder()
11914 .method("GET")
11915 .uri("/api/data")
11916 .header("Accept", "application/json")
11917 .body(AxumBody::empty())
11918 .unwrap();
11919 let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
11920 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11921
11922 let req = Request::builder()
11924 .method("GET")
11925 .uri("/style.css")
11926 .header("Accept", "text/html")
11927 .body(AxumBody::empty())
11928 .unwrap();
11929 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
11930 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11931
11932 std::fs::remove_dir_all(&temp_dir).ok();
11934 }
11935
11936 #[allow(clippy::await_holding_lock)]
11943 async fn run_conditional_get_returns_304(mode: MountMode) {
11944 let _guard = lock_registry_test_mutex();
11945 ServerRegistry::reset();
11946
11947 let temp_dir = std::env::temp_dir().join(format!(
11948 "http_cond_get_{}_{}",
11949 if mode == MountMode::Spa {
11950 "spa"
11951 } else {
11952 "static"
11953 },
11954 std::process::id()
11955 ));
11956 std::fs::create_dir_all(&temp_dir).unwrap();
11957 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
11958
11959 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11960
11961 let registry = make_test_registry();
11962 let serve_dir = ServeDir::new(&canonical_dir)
11963 .precompressed_gzip()
11964 .precompressed_br()
11965 .append_index_html_on_directories(true);
11966
11967 let mount = StaticMount {
11968 mount_path: "/".to_string(),
11969 mode,
11970 dir: canonical_dir.clone(),
11971 cache_control: "public, max-age=3600".to_string(),
11972 error_pages: std::collections::HashMap::new(),
11973 serve_dir,
11974 };
11975 registry.register_static_mount(mount).await.unwrap();
11976
11977 let state = make_test_state(registry);
11978
11979 let req = Request::builder()
11981 .method("GET")
11982 .uri("/index.html")
11983 .body(AxumBody::empty())
11984 .unwrap();
11985 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11986 assert_eq!(
11987 resp.status(),
11988 StatusCode::OK,
11989 "first GET should return 200, got {}",
11990 resp.status()
11991 );
11992 assert!(
11994 resp.headers().contains_key(http::header::CACHE_CONTROL),
11995 "200 response missing Cache-Control"
11996 );
11997 let etag = resp
11998 .headers()
11999 .get(http::header::ETAG)
12000 .expect("ServeDir must emit ETag on 200 for If-None-Match coverage")
12001 .clone();
12002 let last_modified = resp
12003 .headers()
12004 .get(http::header::LAST_MODIFIED)
12005 .expect("ServeDir must emit Last-Modified on 200 for If-Modified-Since coverage")
12006 .clone();
12007 let _ = axum::body::to_bytes(resp.into_body(), usize::MAX)
12009 .await
12010 .unwrap();
12011
12012 let req = Request::builder()
12016 .method("GET")
12017 .uri("/index.html")
12018 .header(http::header::IF_NONE_MATCH, etag.clone())
12019 .body(AxumBody::empty())
12020 .unwrap();
12021 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
12022 assert_eq!(
12023 resp.status(),
12024 StatusCode::NOT_MODIFIED,
12025 "If-None-Match with matching ETag should return 304, got {}",
12026 resp.status()
12027 );
12028 assert!(
12030 resp.headers().contains_key(http::header::CACHE_CONTROL),
12031 "304 (If-None-Match) missing Cache-Control"
12032 );
12033 assert_eq!(
12036 resp.headers().get(http::header::ETAG),
12037 Some(&etag),
12038 "304 (If-None-Match) must echo the ETag validator"
12039 );
12040 assert_eq!(
12041 resp.headers().get(http::header::LAST_MODIFIED),
12042 Some(&last_modified),
12043 "304 (If-None-Match) must carry Last-Modified"
12044 );
12045
12046 let req = Request::builder()
12048 .method("GET")
12049 .uri("/index.html")
12050 .header(http::header::IF_MODIFIED_SINCE, last_modified.clone())
12051 .body(AxumBody::empty())
12052 .unwrap();
12053 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
12054 assert_eq!(
12055 resp.status(),
12056 StatusCode::NOT_MODIFIED,
12057 "If-Modified-Since with matching timestamp should return 304, got {}",
12058 resp.status()
12059 );
12060 assert!(
12061 resp.headers().contains_key(http::header::CACHE_CONTROL),
12062 "304 (If-Modified-Since) missing Cache-Control"
12063 );
12064 assert_eq!(
12065 resp.headers().get(http::header::ETAG),
12066 Some(&etag),
12067 "304 (If-Modified-Since) must carry the ETag validator"
12068 );
12069 assert_eq!(
12070 resp.headers().get(http::header::LAST_MODIFIED),
12071 Some(&last_modified),
12072 "304 (If-Modified-Since) must echo Last-Modified"
12073 );
12074
12075 let req = Request::builder()
12081 .method("GET")
12082 .uri("/index.html")
12083 .header(
12084 http::header::IF_MODIFIED_SINCE,
12085 "Wed, 21 Oct 2000 07:28:00 GMT",
12086 )
12087 .body(AxumBody::empty())
12088 .unwrap();
12089 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
12090 assert_eq!(
12091 resp.status(),
12092 StatusCode::OK,
12093 "past If-Modified-Since should return 200 (file modified after it), got {}",
12094 resp.status()
12095 );
12096
12097 std::fs::remove_dir_all(&temp_dir).ok();
12099 }
12100
12101 #[tokio::test]
12102 async fn test_conditional_get_returns_304_static_mode() {
12103 run_conditional_get_returns_304(MountMode::Static).await;
12104 }
12105
12106 #[tokio::test]
12107 async fn test_conditional_get_returns_304_spa_mode() {
12108 run_conditional_get_returns_304(MountMode::Spa).await;
12109 }
12110
12111 #[allow(clippy::await_holding_lock)]
12112 #[tokio::test]
12113 async fn test_error_page_mapping_serves_custom_404() {
12114 let _guard = lock_registry_test_mutex();
12115 ServerRegistry::reset();
12116
12117 let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
12118 let errors_dir = temp_dir.join("errors");
12119 std::fs::create_dir_all(&errors_dir).unwrap();
12120 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
12121 std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
12122
12123 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
12124 let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
12125
12126 let registry = make_test_registry();
12127 let serve_dir = ServeDir::new(&canonical_dir)
12128 .precompressed_gzip()
12129 .precompressed_br()
12130 .append_index_html_on_directories(true);
12131
12132 let mut error_pages = std::collections::HashMap::new();
12133 error_pages.insert(404, canonical_404);
12134
12135 let mount = StaticMount {
12136 mount_path: "/".to_string(),
12137 mode: MountMode::Static,
12138 dir: canonical_dir.clone(),
12139 cache_control: "public, max-age=0".to_string(),
12140 error_pages,
12141 serve_dir,
12142 };
12143 registry.register_static_mount(mount).await.unwrap();
12144
12145 let state = make_test_state(registry);
12146
12147 let req = Request::builder()
12149 .method("GET")
12150 .uri("/missing.html")
12151 .body(AxumBody::empty())
12152 .unwrap();
12153 let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
12154 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
12155 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
12156 .await
12157 .unwrap();
12158 assert_eq!(&body[..], b"<h1>Custom 404</h1>");
12159
12160 let req = Request::builder()
12162 .method("GET")
12163 .uri("/index.html")
12164 .body(AxumBody::empty())
12165 .unwrap();
12166 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
12167 assert_eq!(resp.status(), StatusCode::OK);
12168 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
12169 .await
12170 .unwrap();
12171 assert_eq!(&body[..], b"<h1>Home</h1>");
12172
12173 std::fs::remove_dir_all(&temp_dir).ok();
12175 }
12176
12177 #[tokio::test]
12178 async fn http_consumer_returns_body_and_code_on_stop() {
12179 use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
12180 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
12181 use tower::ServiceExt;
12182
12183 let set_body_step = CompiledStep::Process {
12185 kind_hint: camel_api::SpanKindHint::Internal,
12186 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
12187 ex.input.body = Body::Text("nope".into());
12188 Box::pin(async move { Ok(ex) })
12189 }),
12190 body_contract: None,
12191 lifecycle: None,
12192 label: None,
12193 to_uri: None,
12194 };
12195 let set_status_step = CompiledStep::Process {
12196 kind_hint: camel_api::SpanKindHint::Internal,
12197 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
12198 ex.input.set_header(
12199 "CamelHttpResponseCode",
12200 serde_json::Value::Number(409.into()),
12201 );
12202 Box::pin(async move { Ok(ex) })
12203 }),
12204 body_contract: None,
12205 lifecycle: None,
12206 label: None,
12207 to_uri: None,
12208 };
12209 let pipeline = compose_pipeline_with_handler(
12210 vec![set_body_step, set_status_step, CompiledStep::Stop],
12211 None,
12212 PipelineRuntimeCtx::compile_time(),
12213 );
12214
12215 let ex = Exchange::new(Message::default());
12216 let result = pipeline.oneshot(ex).await;
12217 assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
12218 let returned = result.unwrap();
12219 assert_eq!(returned.input.body.as_text(), Some("nope"));
12220 assert_eq!(
12221 returned
12222 .input
12223 .header("CamelHttpResponseCode")
12224 .and_then(|v| v.as_u64()),
12225 Some(409)
12226 );
12227 }
12228
12229 #[tokio::test]
12230 async fn http_consumer_returns_200_when_body_empty_on_stop() {
12231 use camel_api::{Exchange, Message};
12239 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
12240 use tower::ServiceExt;
12241
12242 let pipeline = compose_pipeline_with_handler(
12243 vec![CompiledStep::Stop],
12244 None,
12245 PipelineRuntimeCtx::compile_time(),
12246 );
12247 let ex = Exchange::new(Message::default());
12248 let result = pipeline.oneshot(ex).await;
12249 assert!(result.is_ok(), "Stop with empty body arrives as Ok");
12250 }
12253
12254 async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
12262 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12263 let port = listener.local_addr().unwrap().port();
12264 let registry = HttpRouteRegistry::new();
12265 tokio::spawn(run_axum_server(
12266 listener,
12267 registry.clone(),
12268 2 * 1024 * 1024,
12269 10 * 1024 * 1024,
12270 Arc::new(tokio::sync::Semaphore::new(1024)),
12271 test_rt(),
12272 "test-route".into(),
12273 ));
12274 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
12276 (port, registry)
12277 }
12278
12279 fn spawn_responder(
12284 mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
12285 status: u16,
12286 body: String,
12287 ) -> tokio::task::JoinHandle<()> {
12288 tokio::spawn(async move {
12289 if let Some(envelope) = rx.recv().await {
12290 let _ = envelope.reply_tx.send(HttpReply {
12291 status,
12292 headers: vec![],
12293 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
12294 });
12295 }
12296 })
12297 }
12298
12299 #[tokio::test]
12300 async fn method_aware_dispatch_same_path_different_verbs() {
12301 let (port, registry) = spawn_test_server().await;
12302
12303 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12308 registry
12309 .register_rest_endpoint(
12310 "GET".into(),
12311 vec![PathSegment::Literal("users".into())],
12312 get_tx,
12313 )
12314 .await;
12315
12316 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12317 registry
12318 .register_rest_endpoint(
12319 "POST".into(),
12320 vec![PathSegment::Literal("users".into())],
12321 post_tx,
12322 )
12323 .await;
12324
12325 let get_handle = spawn_responder(get_rx, 200, "list".into());
12326 let post_handle = spawn_responder(post_rx, 201, "create".into());
12327
12328 let client = plain_http_test_client();
12329
12330 let resp = client
12332 .get(format!("http://127.0.0.1:{port}/users"))
12333 .send()
12334 .await
12335 .unwrap();
12336 assert_eq!(resp.status().as_u16(), 200);
12337 let body = resp.text().await.unwrap();
12338 assert_eq!(body, "list");
12339
12340 let resp = client
12342 .post(format!("http://127.0.0.1:{port}/users"))
12343 .send()
12344 .await
12345 .unwrap();
12346 assert_eq!(resp.status().as_u16(), 201);
12347 let body = resp.text().await.unwrap();
12348 assert_eq!(body, "create");
12349
12350 let _ = tokio::join!(get_handle, post_handle);
12351 }
12352
12353 #[tokio::test]
12354 async fn method_aware_dispatch_templated_path_extracts_params() {
12355 let (port, registry) = spawn_test_server().await;
12356
12357 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12361 registry
12362 .register_rest_endpoint(
12363 "GET".into(),
12364 vec![
12365 PathSegment::Literal("users".into()),
12366 PathSegment::Param("id".into()),
12367 ],
12368 tx,
12369 )
12370 .await;
12371
12372 let handle = tokio::spawn(async move {
12375 match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
12376 Ok(Some(envelope)) => {
12377 let id = envelope.path_params.get("id").cloned().unwrap_or_default();
12378 let _ = envelope.reply_tx.send(HttpReply {
12379 status: 200,
12380 headers: vec![],
12381 body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
12382 });
12383 }
12384 Ok(None) => {}
12386 Err(_) => {}
12388 }
12389 });
12390
12391 let client = plain_http_test_client();
12392 let resp = client
12393 .get(format!("http://127.0.0.1:{port}/users/42"))
12394 .send()
12395 .await
12396 .unwrap();
12397 assert_eq!(resp.status().as_u16(), 200);
12398 let body = resp.text().await.unwrap();
12399 assert_eq!(body, "id=42");
12400
12401 let _ = handle.await;
12402 }
12403
12404 #[tokio::test]
12405 async fn method_aware_dispatch_unmatched_method_falls_through() {
12406 let (port, _registry) = spawn_test_server().await;
12411
12412 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12414 _registry
12415 .register_rest_endpoint(
12416 "GET".into(),
12417 vec![PathSegment::Literal("users".into())],
12418 get_tx,
12419 )
12420 .await;
12421
12422 let drain = tokio::spawn(async move {
12425 let mut get_rx = get_rx;
12426 loop {
12427 match tokio::time::timeout(Duration::from_secs(2), get_rx.recv()).await {
12428 Ok(Some(_)) => continue,
12429 Ok(None) => break, Err(_) => break, }
12432 }
12433 });
12434
12435 let client = plain_http_test_client();
12436 let resp = client
12437 .delete(format!("http://127.0.0.1:{port}/users"))
12438 .send()
12439 .await
12440 .unwrap();
12441 assert_eq!(resp.status().as_u16(), 404);
12442
12443 drop(drain);
12444 }
12445
12446 #[tokio::test]
12447 async fn regression_legacy_exact_api_route_still_works() {
12448 let (port, registry) = spawn_test_server().await;
12453
12454 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12455 registry.register_api_route("/legacy/path".into(), tx).await;
12456
12457 let handle = tokio::spawn(async move {
12458 match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
12459 Ok(Some(envelope)) => {
12460 let _ = envelope.reply_tx.send(HttpReply {
12461 status: 200,
12462 headers: vec![],
12463 body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
12464 });
12465 }
12466 Ok(None) => {}
12468 Err(_) => {}
12470 }
12471 });
12472
12473 let client = plain_http_test_client();
12474 let resp = client
12475 .get(format!("http://127.0.0.1:{port}/legacy/path"))
12476 .send()
12477 .await
12478 .unwrap();
12479 assert_eq!(resp.status().as_u16(), 200);
12480 let body = resp.text().await.unwrap();
12481 assert_eq!(body, "legacy ok");
12482
12483 let _ = handle.await;
12484 }
12485
12486 #[allow(clippy::await_holding_lock)]
12487 #[tokio::test]
12488 async fn regression_static_mount_still_works() {
12489 let _guard = lock_registry_test_mutex();
12493 ServerRegistry::reset();
12494
12495 let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
12496 std::fs::create_dir_all(&temp_dir).unwrap();
12497 std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
12498 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
12499
12500 let registry = make_test_registry();
12501 let serve_dir = ServeDir::new(&canonical_dir)
12502 .precompressed_gzip()
12503 .precompressed_br()
12504 .append_index_html_on_directories(true);
12505 let mount = StaticMount {
12506 mount_path: "/".to_string(),
12507 mode: MountMode::Static,
12508 dir: canonical_dir.clone(),
12509 cache_control: "public, max-age=3600".to_string(),
12510 error_pages: std::collections::HashMap::new(),
12511 serve_dir,
12512 };
12513 registry.register_static_mount(mount).await.unwrap();
12514
12515 let state = make_test_state(registry);
12516 let req = Request::builder()
12517 .uri("/regress.txt")
12518 .body(AxumBody::empty())
12519 .unwrap();
12520 let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
12521 assert_eq!(resp.status(), StatusCode::OK);
12522 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
12523 .await
12524 .unwrap();
12525 assert_eq!(&body[..], b"static works");
12526
12527 std::fs::remove_dir_all(&temp_dir).ok();
12528 }
12529
12530 #[tokio::test]
12539 async fn deregister_one_method_keeps_sibling_verbs() {
12540 let (port, registry) = spawn_test_server().await;
12544
12545 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12546 registry
12547 .register_rest_endpoint(
12548 "GET".into(),
12549 vec![PathSegment::Literal("users".into())],
12550 get_tx,
12551 )
12552 .await;
12553
12554 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12555 registry
12556 .register_rest_endpoint(
12557 "POST".into(),
12558 vec![PathSegment::Literal("users".into())],
12559 post_tx,
12560 )
12561 .await;
12562
12563 let drain = tokio::spawn(async move {
12565 let mut get_rx = get_rx;
12566 loop {
12567 match tokio::time::timeout(Duration::from_secs(2), get_rx.recv()).await {
12568 Ok(Some(_)) => continue,
12569 Ok(None) => break, Err(_) => break, }
12572 }
12573 });
12574
12575 registry.unregister_rest_endpoint("GET", "/users").await;
12577 drop(drain);
12578
12579 let post_handle = spawn_responder(post_rx, 201, "create".into());
12580
12581 let client = plain_http_test_client();
12582 let resp = client
12584 .post(format!("http://127.0.0.1:{port}/users"))
12585 .send()
12586 .await
12587 .unwrap();
12588 assert_eq!(resp.status().as_u16(), 201);
12589 assert_eq!(resp.text().await.unwrap(), "create");
12590
12591 let _ = post_handle.await;
12592 }
12593
12594 #[tokio::test]
12595 async fn dispatch_exact_legacy_beats_rest_template() {
12596 let (port, registry) = spawn_test_server().await;
12601
12602 let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12604 registry
12605 .register_api_route("/api/users".into(), exact_tx)
12606 .await;
12607 let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
12608
12609 let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12611 registry
12612 .register_rest_endpoint(
12613 "GET".into(),
12614 vec![
12615 PathSegment::Literal("api".into()),
12616 PathSegment::Param("resource".into()),
12617 ],
12618 tpl_tx,
12619 )
12620 .await;
12621 let _tpl_drain = tokio::spawn(async move {
12627 let mut tpl_rx = tpl_rx;
12628 match tokio::time::timeout(Duration::from_secs(2), tpl_rx.recv()).await {
12629 Ok(Some(env)) => {
12630 let _ = env.reply_tx.send(HttpReply {
12631 status: 200,
12632 headers: vec![],
12633 body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
12634 });
12635 }
12636 Ok(None) => {}
12638 Err(_) => {}
12640 }
12641 });
12642
12643 let client = plain_http_test_client();
12644 let resp = client
12645 .get(format!("http://127.0.0.1:{port}/api/users"))
12646 .send()
12647 .await
12648 .unwrap();
12649 assert_eq!(resp.status().as_u16(), 200);
12650 assert_eq!(resp.text().await.unwrap(), "exact");
12652
12653 let _ = exact_handle.await;
12654 }
12655
12656 #[tokio::test]
12657 async fn ambiguous_rest_templates_return_500_not_silent_404() {
12658 let (port, registry) = spawn_test_server().await;
12663
12664 let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12665 registry
12666 .register_rest_endpoint(
12667 "GET".into(),
12668 vec![
12669 PathSegment::Literal("users".into()),
12670 PathSegment::Param("id".into()),
12671 ],
12672 a_tx,
12673 )
12674 .await;
12675
12676 let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12677 registry
12678 .register_rest_endpoint(
12679 "GET".into(),
12680 vec![
12681 PathSegment::Literal("users".into()),
12682 PathSegment::Param("name".into()),
12683 ],
12684 b_tx,
12685 )
12686 .await;
12687
12688 let client = plain_http_test_client();
12689 let resp = client
12690 .get(format!("http://127.0.0.1:{port}/users/42"))
12691 .send()
12692 .await
12693 .unwrap();
12694 assert_eq!(resp.status().as_u16(), 500);
12696 }
12697
12698 #[test]
12699 fn from_uri_round_trips_templated_path_with_http_method() {
12700 use crate::UriConfig;
12706 let cfg =
12707 HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
12708 assert_eq!(cfg.host, "0.0.0.0");
12709 assert_eq!(cfg.port, 8080);
12710 assert_eq!(cfg.path, "/users/{id}");
12711 assert_eq!(cfg.method.as_deref(), Some("GET"));
12712
12713 let cfg_lc =
12715 HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
12716 assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
12717 assert_eq!(cfg_lc.path, "/orders");
12718 }
12719
12720 #[test]
12725 fn type_conversion_failed_maps_to_400() {
12726 let reply = pipeline_error_to_reply(
12727 CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
12728 "/api/users",
12729 );
12730 assert_eq!(reply.status, 400);
12731 let json_ct = reply
12733 .headers
12734 .iter()
12735 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12736 .count();
12737 assert_eq!(json_ct, 1);
12738 let body = match &reply.body {
12740 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12741 _ => panic!("expected bytes body"),
12742 };
12743 let parsed: serde_json::Value =
12744 serde_json::from_str(&body).expect("body must be valid JSON");
12745 assert_eq!(parsed["error"], "bad_request");
12746 assert_eq!(parsed["message"], "invalid JSON at line 1");
12747 }
12748
12749 #[test]
12750 fn other_error_still_maps_to_500() {
12751 let reply =
12752 pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
12753 assert_eq!(reply.status, 500);
12754 }
12755
12756 #[test]
12757 fn unauthenticated_maps_to_401() {
12758 let reply = pipeline_error_to_reply(
12759 CamelError::Unauthenticated("no token".to_string()),
12760 "/api/users",
12761 );
12762 assert_eq!(reply.status, 401);
12763 }
12764
12765 #[test]
12766 fn unauthorized_maps_to_403() {
12767 let reply = pipeline_error_to_reply(
12768 CamelError::Unauthorized("forbidden".to_string()),
12769 "/api/users",
12770 );
12771 assert_eq!(reply.status, 403);
12772 }
12773
12774 #[test]
12775 fn validation_error_maps_to_400() {
12776 let reply = pipeline_error_to_reply(
12777 CamelError::ValidationError("body does not match schema".to_string()),
12778 "/api/users",
12779 );
12780 assert_eq!(reply.status, 400);
12781 let json_ct = reply
12782 .headers
12783 .iter()
12784 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12785 .count();
12786 assert_eq!(json_ct, 1);
12787 let body = match &reply.body {
12788 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12789 _ => panic!("expected bytes body"),
12790 };
12791 let parsed: serde_json::Value =
12792 serde_json::from_str(&body).expect("body must be valid JSON");
12793 assert_eq!(parsed["error"], "validation_error");
12794 assert_eq!(parsed["message"], "body does not match schema");
12795 }
12796
12797 #[test]
12802 fn finalizer_maps_unsupported_media_type() {
12803 let reply = pipeline_error_to_reply(
12804 CamelError::UnsupportedMediaType {
12805 consumed: "text/plain".to_string(),
12806 declared: "application/json".to_string(),
12807 },
12808 "/x",
12809 );
12810 assert_eq!(reply.status, 415);
12811 let json_ct = reply
12812 .headers
12813 .iter()
12814 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12815 .count();
12816 assert_eq!(json_ct, 1);
12817 let body = match &reply.body {
12818 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12819 _ => panic!("expected bytes body"),
12820 };
12821 let parsed: serde_json::Value =
12822 serde_json::from_str(&body).expect("body must be valid JSON");
12823 assert_eq!(parsed["error"], "unsupported_media_type");
12824 assert_eq!(
12825 parsed["message"],
12826 "consumed text/plain, declared application/json"
12827 );
12828 }
12829
12830 #[test]
12831 fn finalizer_maps_not_acceptable() {
12832 let reply = pipeline_error_to_reply(
12833 CamelError::NotAcceptable {
12834 accept: "application/xml".to_string(),
12835 produced: "application/json".to_string(),
12836 },
12837 "/x",
12838 );
12839 assert_eq!(reply.status, 406);
12840 let json_ct = reply
12841 .headers
12842 .iter()
12843 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12844 .count();
12845 assert_eq!(json_ct, 1);
12846 let body = match &reply.body {
12847 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12848 _ => panic!("expected bytes body"),
12849 };
12850 let parsed: serde_json::Value =
12851 serde_json::from_str(&body).expect("body must be valid JSON");
12852 assert_eq!(parsed["error"], "not_acceptable");
12853 assert_eq!(
12854 parsed["message"],
12855 "accept application/xml, produced application/json"
12856 );
12857 }
12858
12859 #[test]
12860 fn json_error_reply_preserves_empty_message() {
12861 let reply = json_error_reply(400, "bad_request", "".to_string());
12862 assert_eq!(reply.status, 400);
12863 let json_ct = reply
12864 .headers
12865 .iter()
12866 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12867 .count();
12868 assert_eq!(json_ct, 1);
12869 let body = match &reply.body {
12870 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12871 _ => panic!("expected bytes body"),
12872 };
12873 let parsed: serde_json::Value =
12874 serde_json::from_str(&body).expect("body must be valid JSON");
12875 assert_eq!(parsed["error"], "bad_request");
12876 assert_eq!(parsed["message"], "");
12877 }
12878
12879 #[test]
12880 fn https_consumer_without_tls_cert_errors() {
12881 let endpoint = HttpEndpoint {
12882 uri: "https://0.0.0.0:8443/api".to_string(),
12883 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
12884 server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
12885 client: plain_http_test_client(),
12886 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
12887 PINNED_CLIENT_TTL,
12888 PINNED_CLIENT_MAX_ENTRIES,
12889 )),
12890 http_config: HttpConfig::default(),
12891 };
12892 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
12893 let result = endpoint.create_consumer(rt);
12894 assert!(result.is_err(), "expected error for https without tls cert");
12895 if let Err(e) = result {
12896 let msg = e.to_string();
12897 assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
12898 }
12899 }
12900
12901 #[test]
12902 fn http_consumer_with_tls_config_errors() {
12903 let endpoint = HttpEndpoint {
12904 uri: "http://0.0.0.0:8080/api".to_string(),
12905 config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
12906 server_config: HttpServerConfig::from_uri(
12907 "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
12908 )
12909 .unwrap(),
12910 client: plain_http_test_client(),
12911 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
12912 PINNED_CLIENT_TTL,
12913 PINNED_CLIENT_MAX_ENTRIES,
12914 )),
12915 http_config: HttpConfig::default(),
12916 };
12917 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
12918 let result = endpoint.create_consumer(rt);
12919 assert!(result.is_err(), "expected error for http with tls config");
12920 if let Err(e) = result {
12921 let msg = e.to_string();
12922 assert!(msg.contains("https"), "error must mention https: {msg}");
12923 }
12924 }
12925
12926 #[test]
12927 fn https_consumer_with_partial_tls_cert_only_errors() {
12928 let server_config =
12931 HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
12932 assert!(
12933 server_config.tls_config.is_none(),
12934 "partial tlsCert must not create ServerTlsConfig"
12935 );
12936 let endpoint = HttpEndpoint {
12937 uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
12938 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
12939 .unwrap(),
12940 server_config,
12941 client: plain_http_test_client(),
12942 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
12943 PINNED_CLIENT_TTL,
12944 PINNED_CLIENT_MAX_ENTRIES,
12945 )),
12946 http_config: HttpConfig::default(),
12947 };
12948 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
12949 let result = endpoint.create_consumer(rt);
12950 assert!(
12951 result.is_err(),
12952 "must error: https:// requires both tlsCert and tlsKey"
12953 );
12954 }
12955
12956 #[test]
12957 fn load_tls_config_parses_valid_pem() {
12958 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
12960 use camel_component_api::test_support::tls;
12961 let (_, cert_pem, key_pem) = tls::gen_server_cert();
12962 let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
12963 let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
12964
12965 let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
12966 assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
12967 }
12968
12969 #[tokio::test(flavor = "multi_thread")]
12970 #[allow(clippy::await_holding_lock)]
12971 async fn consumer_tls_handshake_roundtrip() {
12972 use camel_component_api::test_support::tls;
12973 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
12974
12975 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
12977
12978 let _guard = lock_registry_test_mutex();
12980
12981 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
12983 let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
12984 let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
12985 let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
12986
12987 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12989 let port = probe.local_addr().unwrap().port();
12990 drop(probe);
12991
12992 ServerRegistry::reset();
12993
12994 let component = HttpComponent::new();
12996 let endpoint_ctx = NoOpComponentContext;
12997 let uri = format!(
12998 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
12999 cert_path.to_string_lossy(),
13000 key_path.to_string_lossy(),
13001 );
13002 let endpoint = component
13003 .create_endpoint(&uri, &endpoint_ctx)
13004 .expect("create TLS endpoint");
13005 let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
13006
13007 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
13009 let token = tokio_util::sync::CancellationToken::new();
13010 let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
13011 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
13012
13013 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
13015
13016 let ca_bytes = std::fs::read(&ca_path).unwrap();
13018 let client = reqwest::Client::builder()
13019 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
13020 .build()
13021 .unwrap();
13022
13023 let send_fut = client
13024 .post(format!("https://localhost:{port}/test"))
13025 .body("ping")
13026 .send();
13027
13028 let (http_result, _) = tokio::join!(send_fut, async {
13030 if let Some(mut envelope) = rx.recv().await {
13031 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
13032 if let Some(reply_tx) = envelope.reply_tx {
13033 let _ = reply_tx.send(Ok(envelope.exchange));
13034 }
13035 }
13036 });
13037
13038 let resp = http_result.expect("TLS handshake + request must succeed");
13039
13040 assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
13041 let body = resp.text().await.unwrap();
13042 assert_eq!(body, "pong");
13043
13044 token.cancel();
13045 }
13046
13047 #[tokio::test(flavor = "multi_thread")]
13048 #[allow(clippy::await_holding_lock)]
13049 async fn consumer_tls_rejects_client_without_ca() {
13050 use camel_component_api::test_support::tls;
13051 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
13052
13053 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
13054
13055 let _guard = lock_registry_test_mutex();
13057
13058 let (_, cert_pem, key_pem) = tls::gen_server_cert();
13059 let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
13060 let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
13061
13062 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13063 let port = probe.local_addr().unwrap().port();
13064 drop(probe);
13065
13066 ServerRegistry::reset();
13067
13068 let component = HttpComponent::new();
13070 let endpoint_ctx = NoOpComponentContext;
13071 let uri = format!(
13072 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
13073 cert_path.to_string_lossy(),
13074 key_path.to_string_lossy(),
13075 );
13076 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
13077 let mut consumer = endpoint.create_consumer(rt()).unwrap();
13078 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
13079 let token = tokio_util::sync::CancellationToken::new();
13080 let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
13081 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
13082
13083 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
13084
13085 let client = plain_http_test_client();
13087
13088 let result = client
13089 .get(format!("https://localhost:{port}/test"))
13090 .send()
13091 .await;
13092
13093 assert!(
13094 result.is_err(),
13095 "must reject without CA — proves real verification"
13096 );
13097
13098 token.cancel();
13099 }
13100
13101 #[test]
13102 fn server_config_partial_tls_cert_without_key() {
13103 let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
13105 assert!(cfg.tls_config.is_none());
13107 }
13108
13109 #[test]
13110 fn endpoint_uri_options_count_parity() {
13111 assert_eq!(
13113 HttpEndpointConfig::uri_options().len(),
13114 23,
13115 "HttpEndpointUriConfig #[uri_param] count drifted from parser"
13116 );
13117 }
13118
13119 fn make_headers(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
13120 pairs
13121 .iter()
13122 .map(|(k, v)| {
13123 (
13124 (*k).to_string(),
13125 serde_json::Value::String((*v).to_string()),
13126 )
13127 })
13128 .collect()
13129 }
13130
13131 #[test]
13132 fn response_emits_cache_control_via_pragma_warning() {
13133 let headers = make_headers(&[
13134 ("Cache-Control", "public, max-age=3600"),
13135 ("Via", "1.1 myproxy"),
13136 ("Pragma", "no-cache"),
13137 ("Warning", "199 misc"),
13138 ]);
13139 let selected = select_response_headers(&headers, None, None);
13140 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
13141 for expected in ["Cache-Control", "Via", "Pragma", "Warning"] {
13142 assert!(
13143 names.contains(&expected),
13144 "{expected} should pass through to the response"
13145 );
13146 }
13147 }
13148
13149 #[test]
13150 fn response_excludes_request_only_and_server_owned() {
13151 let headers = make_headers(&[
13152 ("User-Agent", "x"),
13153 ("Accept", "*/*"),
13154 ("Date", "Thu, 01 Jan 2026 00:00:00 GMT"),
13155 ]);
13156 let selected = select_response_headers(&headers, None, None);
13157 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
13158 for excluded in ["User-Agent", "Accept", "Date"] {
13159 assert!(
13160 !names.contains(&excluded),
13161 "{excluded} should NOT appear in the response"
13162 );
13163 }
13164 }
13165
13166 #[test]
13167 fn response_re_derives_content_type() {
13168 let headers = make_headers(&[("Content-Type", "text/plain")]);
13169 let selected = select_response_headers(&headers, Some("application/json".into()), None);
13170 let ct_entries: Vec<&str> = selected
13171 .iter()
13172 .filter(|(k, _)| k == "Content-Type")
13173 .map(|(_, v)| v.as_str())
13174 .collect();
13175 assert_eq!(
13176 ct_entries,
13177 ["application/json"],
13178 "exactly one Content-Type entry, re-derived from user_content_type"
13179 );
13180 }
13181
13182 #[test]
13183 fn response_excludes_camel_headers() {
13184 let headers = make_headers(&[("CamelHttpPath", "/foo"), ("Cache-Control", "public")]);
13185 let selected = select_response_headers(&headers, None, None);
13186 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
13187 assert!(
13188 !names.contains(&"CamelHttpPath"),
13189 "Camel-namespace headers must be excluded"
13190 );
13191 assert!(
13192 names.contains(&"Cache-Control"),
13193 "Cache-Control must pass through"
13194 );
13195 }
13196
13197 #[test]
13198 fn response_stringifies_scalar_header_values() {
13199 let mut headers = make_headers(&[("X-Label", "keep")]);
13200 headers.insert("X-Retries".to_string(), serde_json::json!(3));
13201 headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
13202 headers.insert("X-Enabled".to_string(), serde_json::json!(true));
13203 let selected = select_response_headers(&headers, None, None);
13204 let get = |name: &str| -> Option<&str> {
13205 selected
13206 .iter()
13207 .find(|(k, _)| k == name)
13208 .map(|(_, v)| v.as_str())
13209 };
13210 assert_eq!(
13211 get("X-Retries"),
13212 Some("3"),
13213 "integer header must be stringified"
13214 );
13215 assert_eq!(
13216 get("X-Ratio"),
13217 Some("3.5"),
13218 "float header must be stringified"
13219 );
13220 assert_eq!(
13221 get("X-Enabled"),
13222 Some("true"),
13223 "bool header must be stringified"
13224 );
13225 assert_eq!(
13226 get("X-Label"),
13227 Some("keep"),
13228 "string header must pass through"
13229 );
13230 }
13231
13232 #[test]
13233 fn response_drops_null_and_structured_header_values() {
13234 let mut headers = make_headers(&[("X-Keep", "yes")]);
13235 headers.insert("X-Null".to_string(), serde_json::Value::Null);
13236 headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
13237 headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
13238 let selected = select_response_headers(&headers, None, None);
13239 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
13240 for dropped in ["X-Null", "X-Obj", "X-Arr"] {
13241 assert!(
13242 !names.contains(&dropped),
13243 "{dropped} must not be emitted: no single-value form"
13244 );
13245 }
13246 assert!(names.contains(&"X-Keep"), "scalar headers must survive");
13247 }
13248
13249 #[test]
13250 fn response_stringifies_scalars_despite_excluded_names() {
13251 let mut headers = HashMap::new();
13255 headers.insert("Content-Length".to_string(), serde_json::json!(999));
13256 headers.insert("Date".to_string(), serde_json::json!(12345));
13257 let selected = select_response_headers(&headers, None, None);
13258 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
13259 assert!(
13260 !names.contains(&"Content-Length"),
13261 "content-length is re-derived by the server"
13262 );
13263 assert!(!names.contains(&"Date"), "date is server-owned");
13264 }
13265
13266 #[test]
13267 fn outbound_stringifies_scalar_header_values() {
13268 let mut headers = make_headers(&[("X-Label", "keep")]);
13269 headers.insert("X-Retries".to_string(), serde_json::json!(3));
13270 headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
13271 headers.insert("X-Enabled".to_string(), serde_json::json!(true));
13272 let outbound = select_outbound_headers(&headers, &[], &[]);
13273 let get = |name: &str| -> Option<String> {
13275 outbound
13276 .accepted
13277 .iter()
13278 .find(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
13279 .map(|(_, v)| v.to_str().unwrap().to_string())
13280 };
13281 assert_eq!(
13282 get("X-Retries").as_deref(),
13283 Some("3"),
13284 "integer header must be stringified"
13285 );
13286 assert_eq!(
13287 get("X-Ratio").as_deref(),
13288 Some("3.5"),
13289 "float header must be stringified"
13290 );
13291 assert_eq!(
13292 get("X-Enabled").as_deref(),
13293 Some("true"),
13294 "bool header must be stringified"
13295 );
13296 assert_eq!(
13297 get("X-Label").as_deref(),
13298 Some("keep"),
13299 "string header must pass through"
13300 );
13301 assert!(outbound.drops.is_empty(), "scalar headers must not drop");
13302 }
13303
13304 #[test]
13305 fn outbound_drops_null_and_structured_header_values() {
13306 let mut headers = make_headers(&[("X-Keep", "yes")]);
13307 headers.insert("X-Null".to_string(), serde_json::Value::Null);
13308 headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
13309 headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
13310 let outbound = select_outbound_headers(&headers, &[], &[]);
13311 let has = |name: &str| {
13312 outbound
13313 .accepted
13314 .iter()
13315 .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
13316 };
13317 assert!(has("X-Keep"), "scalar headers must survive");
13318 for (name, kind) in [("X-Null", "null"), ("X-Obj", "object"), ("X-Arr", "array")] {
13319 let dropped = outbound
13320 .drops
13321 .iter()
13322 .find(|d| d.name == name)
13323 .unwrap_or_else(|| panic!("{name} must have a drop record: {:?}", outbound.drops));
13324 assert_eq!(
13325 dropped.reason, "no scalar string form",
13326 "{name} drop reason must name the value kind absence"
13327 );
13328 assert_eq!(dropped.value_kind, Some(kind), "{name} kind recorded");
13329 }
13330 }
13331
13332 #[test]
13333 fn outbound_stringifies_scalars_despite_excluded_names() {
13334 let mut headers = HashMap::new();
13338 headers.insert("Transfer-Encoding".to_string(), serde_json::json!(7));
13339 headers.insert("Host".to_string(), serde_json::json!(12345));
13340 headers.insert("X-Ok".to_string(), serde_json::json!(7));
13341 let outbound = select_outbound_headers(&headers, &[], &[]);
13342 let has = |name: &str| {
13343 outbound
13344 .accepted
13345 .iter()
13346 .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
13347 };
13348 assert!(
13349 !has("Transfer-Encoding"),
13350 "hop-by-hop header must stay excluded"
13351 );
13352 assert!(!has("Host"), "host is destination-derived");
13353 assert!(has("X-Ok"), "non-excluded scalar must be stringified");
13354 assert!(
13355 outbound
13356 .drops
13357 .iter()
13358 .any(|d| d.name == "Transfer-Encoding" && d.reason == "outbound emission policy"),
13359 "policy drop must be recorded before coercion"
13360 );
13361 }
13362
13363 #[test]
13364 fn outbound_drops_invalid_names_values_and_skip_config() {
13365 let mut headers = make_headers(&[("X-Good", "fine")]);
13366 headers.insert("X Bad Name".to_string(), serde_json::json!("v"));
13367 headers.insert(
13368 "X-Control-Value".to_string(),
13369 serde_json::json!("line1\nline2"),
13370 );
13371 headers.insert("X-Secret".to_string(), serde_json::json!("s3cr3t"));
13372 headers.insert("CamelHttpQuery".to_string(), serde_json::json!("q=1"));
13373 let skip = vec!["x-secret".to_string()];
13374 let outbound = select_outbound_headers(&headers, &skip, &[]);
13375 let has = |name: &str| {
13376 outbound
13377 .accepted
13378 .iter()
13379 .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
13380 };
13381 assert!(has("X-Good"), "valid header must survive");
13382 assert!(!has("X Bad Name"), "invalid header name must drop");
13383 assert!(!has("X-Control-Value"), "control-char value must drop");
13384 assert!(!has("X-Secret"), "skipped header must drop");
13385 assert!(!has("CamelHttpQuery"), "Camel-namespace header must drop");
13386 let reason = |n: &str| {
13387 outbound
13388 .drops
13389 .iter()
13390 .find(|d| d.name == n)
13391 .map(|d| d.reason)
13392 };
13393 assert_eq!(reason("X Bad Name"), Some("invalid header name"));
13394 assert_eq!(reason("X-Control-Value"), Some("invalid header value"));
13395 assert_eq!(reason("X-Secret"), Some("skip_request_headers"));
13396 assert_eq!(reason("CamelHttpQuery"), Some("Camel namespace"));
13397 }
13398
13399 #[test]
13400 fn constructed_header_invalid_value_returns_drop_record() {
13401 let result = constructed_header("user-agent", "bad\r\ns3nt1nel");
13402 let Err(record) = result else {
13403 panic!("invalid value must produce a drop record");
13404 };
13405 assert_eq!(record.reason, "invalid header value");
13406 assert_eq!(record.name, "user-agent");
13407 assert!(record.value_kind.is_none());
13408 let debug = format!("{record:?}");
13409 assert!(
13410 !debug.contains("bad\r\n") && !debug.contains("s3nt1nel"),
13411 "drop record debug must not leak the value"
13412 );
13413 }
13414
13415 #[test]
13416 fn constructed_header_invalid_name_returns_drop_record() {
13417 let result = constructed_header("bad name", "ok");
13418 let Err(record) = result else {
13419 panic!("invalid name must produce a drop record");
13420 };
13421 assert_eq!(record.reason, "invalid header name");
13422 assert_eq!(record.name, "bad name");
13423 let debug = format!("{record:?}");
13424 assert!(
13425 !debug.contains("ok"),
13426 "drop record debug must not leak the value"
13427 );
13428 }
13429
13430 #[test]
13431 fn constructed_header_valid_pair_roundtrip() {
13432 let result = constructed_header("authorization", "Bearer abc123");
13433 let Ok((name, val)) = result else {
13434 panic!("valid pair must construct");
13435 };
13436 assert_eq!(name.as_str(), "authorization");
13437 let Ok(roundtrip) = val.to_str() else {
13438 panic!("valid value must roundtrip to str");
13439 };
13440 assert_eq!(roundtrip, "Bearer abc123");
13441 }
13442
13443 async fn start_host_capturing_destination() -> (
13455 String,
13456 Arc<std::sync::Mutex<Option<(String, String)>>>,
13457 tokio::task::JoinHandle<()>,
13458 ) {
13459 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13460 let port = listener.local_addr().unwrap().port();
13461 let url = format!("http://127.0.0.1:{port}");
13462 let captured: Arc<std::sync::Mutex<Option<(String, String)>>> =
13463 Arc::new(std::sync::Mutex::new(None));
13464 let captured_clone = Arc::clone(&captured);
13465 let handle = tokio::spawn(async move {
13466 use tokio::io::{AsyncReadExt, AsyncWriteExt};
13467 if let Ok((mut stream, _)) = listener.accept().await {
13468 let mut buf = vec![0u8; 16384];
13469 let n = stream.read(&mut buf).await.unwrap_or(0);
13470 let request = String::from_utf8_lossy(&buf[..n]).to_string();
13471 if request.contains("\r\n\r\n") {
13472 let request_line = request.lines().next().unwrap_or("").to_string();
13473 let host_value = request
13474 .lines()
13475 .find(|l| l.to_lowercase().starts_with("host:"))
13476 .and_then(|l| l.split_once(':'))
13477 .map(|(_, v)| v.trim().to_string())
13478 .unwrap_or_default();
13479 *captured_clone.lock().unwrap() = Some((host_value, request_line));
13480 }
13481 let body = r#"{"echo":"ok"}"#;
13482 let resp = format!(
13483 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
13484 body.len(),
13485 body
13486 );
13487 let _ = stream.write_all(resp.as_bytes()).await;
13488 }
13489 });
13490 (url, captured, handle)
13491 }
13492
13493 #[tokio::test]
13498 async fn bridge_proxy_outbound_host_matches_destination() {
13499 use tower::ServiceExt;
13500
13501 let (url, captured, _handle) = start_host_capturing_destination().await;
13502 let expected_host = url.strip_prefix("http://").unwrap();
13505
13506 let ctx = test_producer_ctx();
13507 let component = HttpComponent::new();
13508 let endpoint_ctx = NoOpComponentContext;
13509 let endpoint = component
13510 .create_endpoint(
13511 &format!("{url}?bridgeEndpoint=true&allowInternal=true"),
13512 &endpoint_ctx,
13513 )
13514 .unwrap();
13515 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
13516
13517 let mut exchange = Exchange::new(Message::default());
13520 exchange.input.set_header("Host", "localhost");
13521 exchange.input.set_header("CamelHttpPath", "/foo");
13522
13523 let result = producer.oneshot(exchange).await;
13524 assert!(result.is_ok(), "producer call failed: {:?}", result);
13525
13526 tokio::time::sleep(Duration::from_millis(100)).await;
13527 let (host_value, request_line) = captured
13528 .lock()
13529 .unwrap()
13530 .take()
13531 .expect("destination capture mutex empty — producer did not reach the destination");
13532
13533 assert_ne!(
13534 host_value, "localhost",
13535 "bridge producer must not forward the exchange Host: localhost"
13536 );
13537 assert_eq!(
13538 host_value, expected_host,
13539 "Host must be derived from the destination authority (no scheme)"
13540 );
13541 assert!(
13542 !request_line.contains("/foo"),
13543 "bridge_endpoint must drop CamelHttpPath; request line was: {request_line}"
13544 );
13545 }
13546
13547 #[tokio::test]
13552 async fn bridge_proxy_route_set_response_header_survives() {
13553 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
13554
13555 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13556 let port = listener.local_addr().unwrap().port();
13557 drop(listener);
13558
13559 let component = HttpComponent::new();
13560 let endpoint_ctx = NoOpComponentContext;
13561 let endpoint = component
13562 .create_endpoint(&format!("http://127.0.0.1:{port}/cache"), &endpoint_ctx)
13563 .unwrap();
13564 let mut consumer = endpoint.create_consumer(rt()).unwrap();
13565
13566 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
13567 let token = tokio_util::sync::CancellationToken::new();
13568 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
13569
13570 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
13571 tokio::time::sleep(Duration::from_millis(50)).await;
13572
13573 let client = plain_http_test_client();
13574 let send_fut = client.get(format!("http://127.0.0.1:{port}/cache")).send();
13575
13576 let (http_result, _) = tokio::join!(send_fut, async {
13580 if let Some(mut envelope) = rx.recv().await {
13581 envelope
13582 .exchange
13583 .input
13584 .set_header("Cache-Control", "public, max-age=3600");
13585 if let Some(reply_tx) = envelope.reply_tx {
13586 let _ = reply_tx.send(Ok(envelope.exchange));
13587 }
13588 }
13589 });
13590
13591 let resp = http_result.unwrap();
13592 assert_eq!(resp.status().as_u16(), 200);
13593
13594 let cache_control = resp.headers().get("cache-control");
13595 assert!(
13596 cache_control.is_some(),
13597 "Cache-Control header must survive to the wire response"
13598 );
13599 assert_eq!(
13600 cache_control.unwrap().to_str().unwrap(),
13601 "public, max-age=3600"
13602 );
13603
13604 token.cancel();
13605 }
13606
13607 use camel_api::security_policy::CredentialSource;
13626 use camel_auth::credential_source::extract_token_from_exchange;
13627 use camel_auth::native_auth::NativeCredentialStore;
13628 use camel_auth::{StaticTokenAuthenticator, TokenAuthenticator};
13629
13630 const SENTINEL_QRY_42: &str = "SENTINEL_QRY_42"; const SENTINEL_CKY_7: &str = "SENTINEL_CKY_7"; const SENTINEL_BAD_1: &str = "SENTINEL_BAD_1"; fn envelope_to_exchange(envelope: &RequestEnvelope) -> Exchange {
13638 let mut msg = Message::default();
13639 msg.set_header(
13640 "CamelHttpMethod",
13641 serde_json::Value::String(envelope.method.clone()),
13642 );
13643 msg.set_header(
13644 "CamelHttpPath",
13645 serde_json::Value::String(envelope.path.clone()),
13646 );
13647 msg.set_header(
13648 "CamelHttpQuery",
13649 serde_json::Value::String(envelope.query.clone()),
13650 );
13651 for (k, v) in &envelope.headers {
13652 if let Ok(val_str) = v.to_str() {
13653 msg.set_header(
13654 title_case_header(k.as_str()),
13655 serde_json::Value::String(val_str.to_string()),
13656 );
13657 }
13658 }
13659 Exchange::new(msg)
13660 }
13661
13662 async fn spawn_failing_auth_route(
13669 registry: &HttpRouteRegistry,
13670 path: &str,
13671 sources: Vec<CredentialSource>,
13672 ) {
13673 let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(
13674 NativeCredentialStore::try_new(vec![]).unwrap(),
13675 ));
13676 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
13677 registry.register_api_route(path.to_string(), tx).await;
13678 let path_owned = path.to_string();
13679 tokio::spawn(async move {
13680 while let Some(envelope) = rx.recv().await {
13681 let exchange = envelope_to_exchange(&envelope);
13682 let reply_tx = envelope.reply_tx;
13683 let result: Result<(), CamelError> = async {
13684 let token = extract_token_from_exchange(&exchange, &sources)
13685 .map(|extracted| extracted.token)
13686 .ok_or_else(|| {
13687 CamelError::Unauthenticated("no credential in any source".into())
13688 })?;
13689 authenticator.authenticate_bearer(&token).await?;
13690 Ok(())
13691 }
13692 .await;
13693 let reply = match result {
13694 Ok(()) => HttpReply {
13695 status: 200,
13696 headers: vec![],
13697 body: HttpReplyBody::Bytes(bytes::Bytes::from("ok")),
13698 },
13699 Err(e) => pipeline_error_to_reply(e, &path_owned),
13700 };
13701 let _ = reply_tx.send(reply);
13702 }
13703 });
13704 }
13705
13706 fn captured_logs_contain(needle: &str) -> bool {
13710 let buf = tracing_test::internal::global_buf().lock().unwrap();
13711 String::from_utf8_lossy(&buf).contains(needle)
13712 }
13713
13714 #[tracing_test::traced_test]
13715 #[tokio::test]
13716 async fn error_context_redacts_query_sentinel() {
13717 let (port, registry) = spawn_test_server().await;
13718 spawn_failing_auth_route(
13719 ®istry,
13720 "/secure-query",
13721 vec![CredentialSource::QueryParam {
13722 param: "token".to_string(),
13723 }],
13724 )
13725 .await;
13726
13727 let client = plain_http_test_client();
13728 let resp = client
13729 .get(format!(
13731 "http://127.0.0.1:{port}/secure-query?token={SENTINEL_QRY_42}"
13732 ))
13733 .send()
13734 .await
13735 .unwrap();
13736
13737 assert_eq!(resp.status().as_u16(), 401);
13738 let body = resp.text().await.unwrap();
13739 assert_eq!(body, "Unauthorized");
13740 assert!(
13741 !body.contains(SENTINEL_QRY_42),
13742 "reply body must not contain the query credential"
13743 );
13744 assert!(
13745 !captured_logs_contain(SENTINEL_QRY_42),
13746 "no tracing record during request handling may render the query credential"
13747 );
13748 assert!(
13752 captured_logs_contain("Authentication failed"),
13753 "positive control: the failed-auth warn! must be captured by the test subscriber"
13754 );
13755 }
13756
13757 #[tracing_test::traced_test]
13758 #[tokio::test]
13759 async fn error_context_redacts_cookie_sentinel() {
13760 let (port, registry) = spawn_test_server().await;
13761 spawn_failing_auth_route(
13762 ®istry,
13763 "/secure-cookie",
13764 vec![CredentialSource::Cookie {
13765 name: "session".to_string(),
13766 }],
13767 )
13768 .await;
13769
13770 let client = plain_http_test_client();
13771 let resp = client
13772 .get(format!("http://127.0.0.1:{port}/secure-cookie"))
13773 .header("Cookie", format!("session={SENTINEL_CKY_7}"))
13774 .send()
13775 .await
13776 .unwrap();
13777
13778 assert_eq!(resp.status().as_u16(), 401);
13779 let body = resp.text().await.unwrap();
13780 assert_eq!(body, "Unauthorized");
13781 assert!(
13782 !body.contains(SENTINEL_CKY_7),
13783 "reply body must not contain the cookie credential"
13784 );
13785 assert!(
13786 !captured_logs_contain(SENTINEL_CKY_7),
13787 "no tracing record during request handling may render the cookie credential"
13788 );
13789 }
13790
13791 #[tracing_test::traced_test]
13792 #[tokio::test]
13793 async fn error_reply_no_credential_value() {
13794 let (port, registry) = spawn_test_server().await;
13795 spawn_failing_auth_route(
13796 ®istry,
13797 "/secure-bad",
13798 vec![CredentialSource::Cookie {
13799 name: "session".to_string(),
13800 }],
13801 )
13802 .await;
13803
13804 let client = plain_http_test_client();
13805 let resp = client
13806 .get(format!("http://127.0.0.1:{port}/secure-bad"))
13807 .header("Cookie", format!("session={SENTINEL_BAD_1}"))
13808 .send()
13809 .await
13810 .unwrap();
13811
13812 assert_eq!(resp.status().as_u16(), 401);
13813 let body = resp.text().await.unwrap();
13814 assert_eq!(body, "Unauthorized");
13815 assert!(
13816 !body.contains(SENTINEL_BAD_1),
13817 "reply body must not contain the credential value"
13818 );
13819 assert!(
13820 !captured_logs_contain(SENTINEL_BAD_1),
13821 "error logs must not render the credential value"
13822 );
13823 }
13824
13825 async fn spawn_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
13839 use tokio::io::AsyncWriteExt;
13840
13841 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
13842 .await
13843 .expect("bind ephemeral 127.0.0.1 listener");
13844 let port = listener.local_addr().expect("local addr").port();
13845 let base_url = format!("http://localhost:{port}");
13846 let handle = tokio::spawn(async move {
13847 while let Ok((mut conn, _)) = listener.accept().await {
13848 let _ = conn
13849 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
13850 .await;
13851 let _ = conn.shutdown().await;
13852 }
13853 });
13854 (base_url, handle)
13855 }
13856
13857 async fn spawn_tls_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
13864 use tokio::io::AsyncWriteExt;
13865
13866 let (_ca_pem, cert_pem, key_pem) =
13867 camel_component_api::test_support::tls::gen_server_cert();
13868 let certs: Vec<_> = rustls_pemfile::certs(&mut cert_pem.as_bytes())
13869 .collect::<Result<_, _>>()
13870 .expect("parse server cert pem");
13871 let key = rustls_pemfile::private_key(&mut key_pem.as_bytes())
13872 .expect("parse server key pem")
13873 .expect("server key present");
13874 let provider =
13877 std::sync::Arc::new(tokio_rustls::rustls::crypto::aws_lc_rs::default_provider());
13878 let tls_cfg = tokio_rustls::rustls::ServerConfig::builder_with_provider(provider)
13879 .with_safe_default_protocol_versions()
13880 .expect("safe default protocol versions")
13881 .with_no_client_auth()
13882 .with_single_cert(certs, key)
13883 .expect("build rustls server config");
13884 let acceptor = tokio_rustls::TlsAcceptor::from(std::sync::Arc::new(tls_cfg));
13885
13886 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
13887 .await
13888 .expect("bind ephemeral 127.0.0.1 listener");
13889 let port = listener.local_addr().expect("local addr").port();
13890 let base_url = format!("https://localhost:{port}");
13891 let handle = tokio::spawn(async move {
13892 while let Ok((conn, _)) = listener.accept().await {
13893 let acceptor = acceptor.clone();
13894 tokio::spawn(async move {
13895 if let Ok(mut tls) = acceptor.accept(conn).await {
13896 let _ = tls
13897 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
13898 .await;
13899 let _ = tls.shutdown().await;
13900 }
13901 });
13902 }
13903 });
13904 (base_url, handle)
13905 }
13906
13907 fn responder_port(base_url: &str) -> u16 {
13911 url::Url::parse(base_url)
13912 .expect("responder base URL parses")
13913 .port()
13914 .expect("responder base URL carries an explicit port")
13915 }
13916
13917 fn endpoint_with_shared_cache(
13922 base_url: &str,
13923 pinned_cache: &Arc<PinnedClientCache>,
13924 ) -> HttpEndpoint {
13925 let uri = format!("{base_url}?allowInternal=true");
13926 HttpEndpoint {
13927 uri: uri.clone(),
13928 config: HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses"),
13929 server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
13930 client: plain_http_test_client(),
13931 pinned_cache: Arc::clone(pinned_cache),
13932 http_config: HttpConfig::default(),
13933 }
13934 }
13935
13936 #[tokio::test]
13937 async fn producers_share_endpoint_cache() {
13938 use tower::ServiceExt;
13939
13940 let (base_url, _handle) = spawn_multi_accept_200().await;
13941 let pinned_cache = Arc::new(PinnedClientCache::new(
13942 PINNED_CLIENT_TTL,
13943 PINNED_CLIENT_MAX_ENTRIES,
13944 ));
13945
13946 let ctx = test_producer_ctx();
13947 let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
13948 let producer_a = endpoint.create_producer(rt(), &ctx);
13949 let producer_b = endpoint.create_producer(rt(), &ctx);
13950
13951 for producer in [producer_a, producer_b] {
13954 let producer = producer.expect("create producer");
13955 let exchange = Exchange::new(Message::default());
13956 let reply = producer.oneshot(exchange).await;
13957 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
13958 }
13959
13960 assert_eq!(
13961 pinned_cache.build_count(),
13962 1,
13963 "both producers must hit the same shared cache entry; a second \
13964 build means sharing is broken"
13965 );
13966 }
13967
13968 #[tokio::test]
13969 async fn producer_repeated_hostname_requests_build_one_client() {
13970 use tower::ServiceExt;
13971
13972 let (base_url, _handle) = spawn_multi_accept_200().await;
13973 let pinned_cache = Arc::new(PinnedClientCache::new(
13974 PINNED_CLIENT_TTL,
13975 PINNED_CLIENT_MAX_ENTRIES,
13976 ));
13977 let ctx = test_producer_ctx();
13978 let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
13979 let producer = endpoint
13980 .create_producer(rt(), &ctx)
13981 .expect("create producer");
13982
13983 for i in 0..2 {
13986 let exchange = Exchange::new(Message::default());
13987 let reply = producer.clone().oneshot(exchange).await;
13988 assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
13989 }
13990
13991 assert_eq!(
13992 pinned_cache.build_count(),
13993 1,
13994 "repeated hostname requests must reuse the one pinned client; \
13995 0 builds means the producer bypassed the cache, more than 1 \
13996 means the entry was dropped"
13997 );
13998 }
13999
14000 #[tokio::test]
14001 async fn ip_literal_request_never_enters_cache() {
14002 use tower::ServiceExt;
14003
14004 let (base_url, _handle) = spawn_multi_accept_200().await;
14005 let pinned_cache = Arc::new(PinnedClientCache::new(
14006 PINNED_CLIENT_TTL,
14007 PINNED_CLIENT_MAX_ENTRIES,
14008 ));
14009
14010 let ctx = test_producer_ctx();
14011 let destination = format!("http://127.0.0.1:{}/ping", responder_port(&base_url));
14012 let endpoint = endpoint_with_shared_cache(&destination, &pinned_cache);
14013 let producer = endpoint
14014 .create_producer(rt(), &ctx)
14015 .expect("create producer");
14016
14017 let exchange = Exchange::new(Message::default());
14018 let reply = producer.oneshot(exchange).await;
14019 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
14020
14021 assert_eq!(
14022 pinned_cache.build_count(),
14023 0,
14024 "an IP-literal URL must use the shared unpinned client and \
14025 never enter the pinned cache"
14026 );
14027 }
14028
14029 #[tokio::test]
14030 async fn test_component_endpoints_share_pinned_cache() {
14031 use tower::ServiceExt;
14032
14033 let component = HttpComponent::new();
14034 let (base_url, _handle) = spawn_multi_accept_200().await;
14035 let baseline = component.pinned_cache.build_count();
14036
14037 let ctx = test_producer_ctx();
14038 let endpoint_ctx = NoOpComponentContext;
14039 for uri in [
14040 format!("{base_url}/a?allowInternal=true&k=a"),
14041 format!("{base_url}/b?allowInternal=true&k=b"),
14042 ] {
14043 let endpoint = component
14044 .create_endpoint(&uri, &endpoint_ctx)
14045 .expect("create endpoint");
14046 let producer = endpoint
14047 .create_producer(rt(), &ctx)
14048 .expect("create producer");
14049 let exchange = Exchange::new(Message::default());
14050 let reply = producer.oneshot(exchange).await;
14051 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
14052 }
14053
14054 assert_eq!(
14055 component.pinned_cache.build_count() - baseline,
14056 1,
14057 "endpoints created by one component must share its pinned cache; \
14058 0 builds means the endpoints bypassed it, more than 1 means \
14059 per-endpoint caches came back"
14060 );
14061 }
14062
14063 #[tokio::test]
14064 async fn test_dynamic_resolution_sequence_hits_shared_cache() {
14065 use tower::ServiceExt;
14066
14067 let component = HttpComponent::new();
14068 let (base_url, _handle) = spawn_multi_accept_200().await;
14069 let baseline = component.pinned_cache.build_count();
14070
14071 let ctx = test_producer_ctx();
14072 let endpoint_ctx = NoOpComponentContext;
14073 for i in 0..3 {
14074 let endpoint = component
14075 .create_endpoint(
14076 &format!("{base_url}/r{i}?allowInternal=true&k={i}"),
14077 &endpoint_ctx,
14078 )
14079 .expect("create endpoint");
14080 let producer = endpoint
14081 .create_producer(rt(), &ctx)
14082 .expect("create producer");
14083 let exchange = Exchange::new(Message::default());
14084 let reply = producer.oneshot(exchange).await;
14085 assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
14086 }
14087
14088 assert_eq!(
14089 component.pinned_cache.build_count() - baseline,
14090 1,
14091 "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
14092 must reuse the component's one pinned cache entry; 0 builds \
14093 means the endpoints bypassed it, more than 1 means \
14094 per-endpoint caches came back"
14095 );
14096 }
14097
14098 #[tokio::test]
14106 async fn test_https_component_endpoints_share_pinned_cache_behaviorally() {
14107 use tower::ServiceExt;
14108
14109 let http_config = HttpConfig {
14110 tls: Some(crate::config::TlsConfig {
14111 enabled: true,
14112 insecure: true,
14113 ..Default::default()
14114 }),
14115 ..Default::default()
14116 };
14117 let component = HttpsComponent::with_config(http_config);
14118 let (base_url, _handle) = spawn_tls_multi_accept_200().await;
14119 let baseline = component.pinned_cache.build_count();
14120
14121 let ctx = test_producer_ctx();
14122 let endpoint_ctx = NoOpComponentContext;
14123 for uri in [
14124 format!("{base_url}/a?allowInternal=true&k=a"),
14125 format!("{base_url}/b?allowInternal=true&k=b"),
14126 ] {
14127 let endpoint = component
14128 .create_endpoint(&uri, &endpoint_ctx)
14129 .expect("create https endpoint");
14130 let producer = endpoint
14131 .create_producer(rt(), &ctx)
14132 .expect("create producer");
14133 let exchange = Exchange::new(Message::default());
14134 let reply = producer.oneshot(exchange).await;
14135 assert!(reply.is_ok(), "https request failed: {reply:?}");
14136 }
14137
14138 assert_eq!(
14139 component.pinned_cache.build_count() - baseline,
14140 1,
14141 "endpoints of one HttpsComponent must share its pinned cache over \
14142 real https requests; 0 builds means the endpoints bypassed it \
14143 (per-endpoint cache regression), more than 1 means \
14144 per-endpoint caches came back"
14145 );
14146 }
14147
14148 #[test]
14149 fn test_https_component_owns_distinct_cache() {
14150 let http = HttpComponent::new();
14151 let https = HttpsComponent::new();
14152
14153 assert!(
14154 !Arc::ptr_eq(&http.pinned_cache, &https.pinned_cache),
14155 "http and https components must each own their own pinned cache"
14156 );
14157
14158 let endpoint_ctx = NoOpComponentContext;
14159 let _ = http
14160 .create_endpoint("http://localhost:1/?allowInternal=true", &endpoint_ctx)
14161 .expect("http endpoint");
14162 let _ = https
14163 .create_endpoint("https://localhost:1/?allowInternal=true", &endpoint_ctx)
14164 .expect("https endpoint");
14165
14166 assert_eq!(
14167 http.pinned_cache.build_count(),
14168 0,
14169 "endpoint creation must not build a pinned client"
14170 );
14171 assert_eq!(
14172 https.pinned_cache.build_count(),
14173 0,
14174 "endpoint creation must not build a pinned client"
14175 );
14176 }
14177
14178 #[test]
14179 fn test_component_constructor_builds_one_unpinned_client() {
14180 let baseline = build_client_call_count();
14181
14182 let _http = HttpComponent::new();
14183 assert_eq!(
14184 build_client_call_count() - baseline,
14185 1,
14186 "HttpComponent::new() must build exactly one shared unpinned client"
14187 );
14188
14189 let _https = HttpsComponent::new();
14190 assert_eq!(
14191 build_client_call_count() - baseline,
14192 2,
14193 "HttpsComponent::new() must build exactly one more shared unpinned client"
14194 );
14195 }
14196
14197 #[test]
14198 fn test_component_endpoints_share_unpinned_client() {
14199 let component = HttpComponent::new();
14200 let baseline = build_client_call_count();
14201
14202 let endpoint_ctx = NoOpComponentContext;
14203 for uri in [
14204 "http://localhost:1/a?allowInternal=true",
14205 "http://localhost:1/b?allowInternal=true",
14206 ] {
14207 let _endpoint = component
14208 .create_endpoint(uri, &endpoint_ctx)
14209 .expect("create endpoint");
14210 }
14211
14212 assert_eq!(
14213 build_client_call_count() - baseline,
14214 0,
14215 "create_endpoint must clone the component's shared unpinned client, \
14216 never build a fresh one"
14217 );
14218 }
14219
14220 #[test]
14221 fn test_dynamic_resolution_adds_no_unpinned_client_builds() {
14222 let component = HttpComponent::new();
14223 let baseline = build_client_call_count();
14224
14225 let ctx = test_producer_ctx();
14226 let endpoint_ctx = NoOpComponentContext;
14227 for i in 0..3 {
14228 let endpoint = component
14229 .create_endpoint(
14230 &format!("http://localhost:1/r{i}?allowInternal=true&k={i}"),
14231 &endpoint_ctx,
14232 )
14233 .expect("create endpoint");
14234 let _producer = endpoint
14235 .create_producer(rt(), &ctx)
14236 .expect("create producer");
14237 }
14238
14239 assert_eq!(
14240 build_client_call_count() - baseline,
14241 0,
14242 "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
14243 must reuse the component's shared unpinned client and build \
14244 no additional clients"
14245 );
14246 }
14247
14248 #[test]
14249 fn test_webpki_fallback_config_builds_client() {
14250 let client = reqwest::Client::builder()
14255 .tls_backend_preconfigured(webpki_root_client_config())
14256 .build()
14257 .expect("webpki-rooted client must build"); let rendered = format!("{client:?}");
14261 assert!(
14262 !rendered.is_empty(),
14263 "client must render a debug representation"
14264 );
14265 }
14266
14267 fn fallback_test_ca() -> (tempfile::TempDir, String) {
14270 let ca_key = rcgen::KeyPair::generate().expect("ca keygen"); let mut ca_params = rcgen::CertificateParams::default();
14272 ca_params
14273 .distinguished_name
14274 .push(rcgen::DnType::CommonName, "Fallback test CA");
14275 ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
14276 let ca_cert = ca_params.self_signed(&ca_key).expect("ca self-sign"); let dir = tempfile::tempdir().expect("tempdir"); let ca_path = dir.path().join("ca.pem");
14279 std::fs::write(&ca_path, ca_cert.pem()).expect("write ca pem"); (dir, ca_path.display().to_string())
14281 }
14282
14283 struct StrictMaterial {
14293 dir: tempfile::TempDir,
14294 ca: TestCa,
14295 ca_path: std::path::PathBuf,
14296 cert_path: std::path::PathBuf,
14297 key_path: std::path::PathBuf,
14298 }
14299
14300 fn strict_material_fixtures() -> StrictMaterial {
14301 let ca = gen_test_ca();
14302 let dir = tempfile::tempdir().expect("tempdir"); let ca_path = dir.path().join("ca.pem");
14304 std::fs::write(&ca_path, ca.ca_pem.as_bytes()).expect("write ca pem"); let (client_cert_pem, client_key_pem) = gen_client_identity(&ca);
14306 let cert_path = dir.path().join("client-cert.pem");
14307 std::fs::write(&cert_path, client_cert_pem.as_bytes()).expect("write client cert"); let key_path = dir.path().join("client-key.pem");
14309 std::fs::write(&key_path, client_key_pem.as_bytes()).expect("write client key"); StrictMaterial {
14311 dir,
14312 ca,
14313 ca_path,
14314 cert_path,
14315 key_path,
14316 }
14317 }
14318
14319 fn assert_strict_fallback_error(err: CamelError, fragments: &[&str]) {
14322 match err {
14323 CamelError::EndpointCreationFailed(msg) => {
14324 for fragment in fragments {
14325 assert!(msg.contains(fragment), "message: {msg}");
14326 }
14327 }
14328 other => panic!("expected EndpointCreationFailed, got {other:?}"),
14329 }
14330 }
14331
14332 #[test]
14344 fn test_fallback_root_store_union_custom_and_mozilla() {
14345 let (_dir, ca_path) = fallback_test_ca();
14346 let store = fallback_root_store(Some(&ca_path), true).expect("union root store must build"); assert_eq!(
14348 store.roots.len(),
14349 webpki_roots::TLS_SERVER_ROOTS.len() + 1,
14350 "custom CA must UNION with the Mozilla anchors, not replace them"
14351 );
14352 let ca_pem = std::fs::read_to_string(&ca_path).expect("read ca pem back"); let der = rustls_pemfile::certs(&mut std::io::Cursor::new(ca_pem.as_bytes()))
14354 .next()
14355 .expect("exactly one CERTIFICATE section in test CA") .expect("parse test CA pem"); let mut scratch = rustls::RootCertStore::empty();
14360 let (added, _ignored) = scratch.add_parsable_certificates(vec![der]);
14361 assert_eq!(added, 1, "scratch root store must accept the test CA");
14362 let expected_anchor = scratch.roots[0].clone();
14363 assert_eq!(
14364 store.roots.first(),
14365 Some(&expected_anchor),
14366 "custom anchor must sit at index 0 — parity with the primary path's \
14367 extra-roots-before-platform-roots order"
14368 );
14369 assert_eq!(
14370 store.roots[1..],
14371 *webpki_roots::TLS_SERVER_ROOTS,
14372 "everything after the custom anchor must be exactly the bundled Mozilla anchors"
14373 );
14374 let first_custom = store
14375 .roots
14376 .iter()
14377 .position(|anchor| anchor == &expected_anchor)
14378 .expect("custom anchor present in store"); let first_mozilla = store
14380 .roots
14381 .iter()
14382 .position(|anchor| webpki_roots::TLS_SERVER_ROOTS.contains(anchor))
14383 .expect("Mozilla anchors present in store"); assert!(
14385 first_custom < first_mozilla,
14386 "first custom anchor (index {first_custom}) must precede the first \
14387 Mozilla anchor (index {first_mozilla})"
14388 );
14389 }
14390
14391 #[test]
14392 fn test_fallback_root_store_no_ca_is_mozilla_only() {
14393 let store = fallback_root_store(None, true).expect("mozilla-only store must build"); assert_eq!(
14395 store.roots.len(),
14396 webpki_roots::TLS_SERVER_ROOTS.len(),
14397 "no configured CA must leave the bundled Mozilla anchors untouched"
14398 );
14399 assert_eq!(
14400 store.roots,
14401 *webpki_roots::TLS_SERVER_ROOTS,
14402 "with no configured CA the whole store IS the bundled Mozilla anchors, \
14403 byte-for-byte"
14404 );
14405 }
14406
14407 #[test]
14408 fn test_fallback_root_store_strict_unreadable_ca_fails_closed() {
14409 let err = match fallback_root_store(Some("/nonexistent/ca-bundle.pem"), true) {
14410 Err(e) => e,
14411 Ok(_) => panic!("strict mode must fail closed on an unreadable CA file"),
14412 };
14413 assert_strict_fallback_error(err, &["tls.strict/webpki-fallback", "unreadable"]);
14414 }
14415
14416 #[test]
14417 fn test_fallback_root_store_strict_zero_pem_sections_fails_closed() {
14418 let dir = tempfile::tempdir().expect("tempdir"); let ca_path = dir.path().join("key-only.pem");
14420 std::fs::write(
14421 &ca_path,
14422 "-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----\n",
14423 )
14424 .expect("write key-only pem"); let path = ca_path.display().to_string();
14426 let err = match fallback_root_store(Some(&path), true) {
14427 Err(e) => e,
14428 Ok(_) => panic!("strict mode must fail closed on a CA with no CERTIFICATE section"),
14429 };
14430 assert_strict_fallback_error(err, &["no parseable PEM CERTIFICATE"]);
14431 }
14432
14433 #[test]
14434 fn test_fallback_root_store_strict_zero_roots_accepted_fails_closed() {
14435 let dir = tempfile::tempdir().expect("tempdir"); let ca_path = dir.path().join("garbage-cert.pem");
14437 std::fs::write(
14438 &ca_path,
14439 "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n",
14440 )
14441 .expect("write garbage cert pem"); let path = ca_path.display().to_string();
14443 let err = match fallback_root_store(Some(&path), true) {
14444 Err(e) => e,
14445 Ok(_) => panic!("strict mode must fail closed when the root store rejects the CA"),
14446 };
14447 assert_strict_fallback_error(err, &["rejected by the TLS root store"]);
14448 }
14449
14450 #[test]
14451 fn test_fallback_root_store_nonstrict_bad_ca_degrades_to_mozilla() {
14452 let dir = tempfile::tempdir().expect("tempdir"); let ca_path = dir.path().join("garbage.pem");
14454 std::fs::write(&ca_path, "this is not a certificate\n").expect("write garbage pem"); let path = ca_path.display().to_string();
14456 let store = fallback_root_store(Some(&path), false)
14457 .expect("non-strict garbage CA must degrade to Mozilla-only"); assert_eq!(
14459 store.roots.len(),
14460 webpki_roots::TLS_SERVER_ROOTS.len(),
14461 "non-strict bad CA must degrade to the bundled Mozilla anchors"
14462 );
14463 }
14464
14465 #[test]
14466 fn test_fallback_client_config_disabled_tls_returns_none() {
14467 let tls = TlsConfig {
14468 enabled: false,
14469 ..Default::default()
14470 };
14471 assert!(
14472 fallback_client_config(&tls)
14473 .expect("disabled TLS is infallible") .is_none()
14475 );
14476 }
14477
14478 #[test]
14479 fn test_fallback_client_config_no_material_verifying_returns_none() {
14480 let tls = TlsConfig {
14481 enabled: true,
14482 ..Default::default()
14483 };
14484 assert!(
14485 fallback_client_config(&tls)
14486 .expect("plain verifying TLS is infallible") .is_none()
14488 );
14489 }
14490
14491 #[test]
14492 fn test_fallback_client_config_strict_identity_garbage_key_fails_closed() {
14493 let dir = tempfile::tempdir().expect("tempdir"); let client_key = rcgen::KeyPair::generate().expect("client keygen"); let client_params = rcgen::CertificateParams::default();
14496 let client_cert = client_params
14497 .self_signed(&client_key)
14498 .expect("client cert self-sign"); let cert_path = dir.path().join("client.pem");
14500 std::fs::write(&cert_path, client_cert.pem()).expect("write client pem"); let key_path = dir.path().join("key.pem");
14502 std::fs::write(&key_path, "garbage not a key at all\n").expect("write garbage key"); let tls = TlsConfig {
14504 enabled: true,
14505 strict: true,
14506 client_cert_path: Some(cert_path.display().to_string()),
14507 client_key_path: Some(key_path.display().to_string()),
14508 ..Default::default()
14509 };
14510 let err = match fallback_client_config(&tls) {
14511 Err(e) => e,
14512 Ok(_) => panic!("strict mode must fail closed on a garbage client key"),
14513 };
14514 assert_strict_fallback_error(err, &["no parseable private key"]);
14515 }
14516
14517 #[test]
14518 fn test_fallback_client_config_strict_rustls_rejected_key_fails_closed() {
14519 let dir = tempfile::tempdir().expect("tempdir"); let client_key = rcgen::KeyPair::generate().expect("client keygen"); let client_params = rcgen::CertificateParams::default();
14522 let client_cert = client_params
14523 .self_signed(&client_key)
14524 .expect("client cert self-sign"); let cert_path = dir.path().join("client.pem");
14526 std::fs::write(&cert_path, client_cert.pem()).expect("write client pem"); let key_path = dir.path().join("key.pem");
14528 std::fs::write(
14529 &key_path,
14530 "-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----\n",
14531 )
14532 .expect("write pkcs8-framed garbage key"); let tls = TlsConfig {
14534 enabled: true,
14535 strict: true,
14536 client_cert_path: Some(cert_path.display().to_string()),
14537 client_key_path: Some(key_path.display().to_string()),
14538 ..Default::default()
14539 };
14540 let err = match fallback_client_config(&tls) {
14541 Err(e) => e,
14542 Ok(_) => panic!("strict mode must fail closed when rustls rejects the identity"),
14543 };
14544 assert_strict_fallback_error(err, &["rejected by the TLS backend"]);
14545 }
14546
14547 #[test]
14548 fn test_fallback_client_config_half_mtls_pair_strict_fails_nonstrict_no_auth() {
14549 let dir = tempfile::tempdir().expect("tempdir"); let cert_path = dir.path().join("client.pem");
14551 std::fs::write(&cert_path, "cert-only, key missing\n").expect("write placeholder pem"); let tls_strict = TlsConfig {
14553 enabled: true,
14554 strict: true,
14555 client_cert_path: Some(cert_path.display().to_string()),
14556 ..Default::default()
14557 };
14558 let err = match fallback_client_config(&tls_strict) {
14559 Err(e) => e,
14560 Ok(_) => panic!("strict mode must fail closed on a half-configured mTLS pair"),
14561 };
14562 assert_strict_fallback_error(err, &["BOTH client_cert_path"]);
14563 let tls_permissive = TlsConfig {
14564 enabled: true,
14565 client_cert_path: Some(cert_path.display().to_string()),
14566 ..Default::default()
14567 };
14568 assert!(
14569 fallback_client_config(&tls_permissive)
14570 .expect("non-strict half pair degrades to no-auth") .is_some()
14572 );
14573 }
14574
14575 #[test]
14576 fn test_no_verify_verifier_accepts_any_certificate() {
14577 use rustls::client::danger::ServerCertVerifier;
14578
14579 let verifier = NoVerifyServerCertVerifier;
14580 let garbage = rustls::pki_types::CertificateDer::from(vec![0xDE, 0xAD, 0xBE, 0xEF]);
14581 let server_name =
14582 rustls::pki_types::ServerName::try_from("localhost").expect("localhost dns name"); assert!(
14584 verifier
14585 .verify_server_cert(
14586 &garbage,
14587 &[],
14588 &server_name,
14589 &[],
14590 rustls::pki_types::UnixTime::now()
14591 )
14592 .is_ok(),
14593 "no-verify verifier must accept any certificate without validation"
14594 );
14595 }
14596
14597 #[test]
14598 fn test_build_client_falls_back_on_empty_platform_ca_store() {
14599 let _ca_guard = lock_ca_store_test_mutex();
14603 let empty_ca_dir = tempfile::tempdir().expect("tempdir"); let empty_ca_file = empty_ca_dir.path().join("empty-ca-bundle.pem");
14615 std::fs::write(&empty_ca_file, b"").expect("write empty CA file"); let (prev_file, prev_dir) = unsafe {
14621 let prev = (
14622 std::env::var_os("SSL_CERT_FILE"),
14623 std::env::var_os("SSL_CERT_DIR"),
14624 );
14625 std::env::set_var("SSL_CERT_FILE", &empty_ca_file);
14626 std::env::set_var("SSL_CERT_DIR", empty_ca_dir.path());
14627 prev
14628 };
14629
14630 let result = std::panic::catch_unwind(|| {
14631 let fallbacks_before = build_client_fallback_count();
14632 let _client = build_client(&HttpConfig::default(), None).expect("client must build"); build_client_fallback_count() - fallbacks_before
14634 });
14635
14636 unsafe {
14639 match prev_file {
14640 Some(v) => std::env::set_var("SSL_CERT_FILE", v),
14641 None => std::env::remove_var("SSL_CERT_FILE"),
14642 }
14643 match prev_dir {
14644 Some(v) => std::env::set_var("SSL_CERT_DIR", v),
14645 None => std::env::remove_var("SSL_CERT_DIR"),
14646 }
14647 }
14648
14649 let fallbacks_taken = result.expect("build_client must not panic on an empty CA store"); assert_eq!(
14651 fallbacks_taken, 1,
14652 "an empty platform CA store must route exactly one build through \
14653 the webpki fallback (primary build failed, fallback succeeded)"
14654 );
14655 }
14656
14657 const HERMETIC_TEST_ROOT_PEM: &str = "-----BEGIN CERTIFICATE-----\n\
14663 MIIDVzCCAj+gAwIBAgIUQ+hB0JPFtHpUKaNwcTB8ijGqEW0wDQYJKoZIhvcNAQEL\n\
14664 BQAwOjEdMBsGA1UEAwwUY2FtZWwtaHR0cCB0ZXN0IHJvb3QxGTAXBgNVBAoMEGNh\n\
14665 bWVsLWh0dHAgdGVzdHMwIBcNMjYwOTIxMTEyMzUwWhgPMjEyNjA4MjgxMTIzNTBa\n\
14666 MDoxHTAbBgNVBAMMFGNhbWVsLWh0dHAgdGVzdCByb290MRkwFwYDVQQKDBBjYW1l\n\
14667 bC1odHRwIHRlc3RzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAolnF\n\
14668 SiT/bK8pMl9n50HTQj4oxPZGXK34Q/OSq8TMUEcmhUFzBjzwZKoFqvDIt/e0mSi/\n\
14669 h8WQlnrHAvYOnsRlpDsfs4yder+lpEYE84OooJjQvj/kREj7ncK6WQKDY1NTGikx\n\
14670 lF8kIaCrNHNIlUgoSWMR4E6UshLFwSu5lKtcWCeN6FNzGVQ9jxYJSFsmVk+wFyHI\n\
14671 edvpPbnFkD3M1/GKNVuxCCR50sO+cQeB7w9FCyFdHvoTWWuwPV4Qq2LBM2n4eedG\n\
14672 pCXkqmuARtFuOKjpYIRryGybic9u9ZW59FbTbgHSj6rcOXNtBUVV/KnWRDRWo7ZC\n\
14673 cR5SmO2YwtfpISjbmwIDAQABo1MwUTAdBgNVHQ4EFgQUkhwU0Q5JYNLrs7yOqbXE\n\
14674 szzn2/cwHwYDVR0jBBgwFoAUkhwU0Q5JYNLrs7yOqbXEszzn2/cwDwYDVR0TAQH/\n\
14675 BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAXLqN7poLyxlpxN9vY1jISs403P2I\n\
14676 edwgX3eWyJgXQfc/+dhnig8Pi7SkJKNM9PM27qain3+e2/HeetNYGBD8xnzQo5ga\n\
14677 1ScPvm7u2DWIWc5y0bpm5JuCPqXoDl/7kBcaelA2lZRrPJzQuu6uPnH1ubjUPFJj\n\
14678 9SxWhR+QgJW3cFc17C179WSVSJSPLozxX1ODABSyxNXnz14Rq+MOJi2tEsDggyMm\n\
14679 txh6GwgVbiSey9c9bvu3B28iPfNpULuxBzxSLHcRrSSoII+46foY0G63V/jx7huY\n\
14680 Fc2DPwOcO2Ni8fLssMaAnEdWPZaIN/DdjmUFw9XgHE9Eld6aKEXBcRFPzA==\n\
14681 -----END CERTIFICATE-----";
14682
14683 #[test]
14684 fn test_build_client_primary_path_when_platform_store_non_empty() {
14685 let _ca_guard = lock_ca_store_test_mutex();
14693 let ca_dir = tempfile::tempdir().expect("tempdir"); let ca_file = ca_dir.path().join("hermetic-root.pem");
14695 std::fs::write(&ca_file, HERMETIC_TEST_ROOT_PEM).expect("write fixture root"); let (prev_file, prev_dir) = unsafe {
14700 let prev = (
14701 std::env::var_os("SSL_CERT_FILE"),
14702 std::env::var_os("SSL_CERT_DIR"),
14703 );
14704 std::env::set_var("SSL_CERT_FILE", &ca_file);
14705 std::env::set_var("SSL_CERT_DIR", ca_dir.path());
14706 prev
14707 };
14708
14709 let fallbacks_before = build_client_fallback_count();
14710 let _client = build_client(&HttpConfig::default(), None).expect("client must build"); let fallbacks_taken = build_client_fallback_count() - fallbacks_before;
14712
14713 unsafe {
14715 match prev_file {
14716 Some(v) => std::env::set_var("SSL_CERT_FILE", v),
14717 None => std::env::remove_var("SSL_CERT_FILE"),
14718 }
14719 match prev_dir {
14720 Some(v) => std::env::set_var("SSL_CERT_DIR", v),
14721 None => std::env::remove_var("SSL_CERT_DIR"),
14722 }
14723 }
14724
14725 assert_eq!(
14726 fallbacks_taken, 0,
14727 "with at least one platform root loadable, build_client must \
14728 use the platform-verifier primary path, never the webpki \
14729 fallback"
14730 );
14731 }
14732
14733 #[test]
14734 fn test_build_client_forced_fallback_strict_store_rejected_ca_returns_typed_error() {
14735 let dir = tempfile::tempdir().expect("tempdir"); let ca_path = dir.path().join("garbage-cert.pem");
14741 std::fs::write(
14742 &ca_path,
14743 "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n",
14744 )
14745 .expect("write garbage cert pem"); let config = HttpConfig {
14747 tls: Some(TlsConfig {
14748 enabled: true,
14749 strict: true,
14750 ca_cert_path: Some(ca_path.display().to_string()),
14751 ..Default::default()
14752 }),
14753 ..Default::default()
14754 };
14755
14756 let (err, fallbacks_taken) = forced_fallback_env(|| {
14757 let fallbacks_before = build_client_fallback_count();
14758 let err = match build_client(&config, None) {
14759 Err(e) => e,
14760 Ok(_) => panic!("strict mode must fail closed when the root store rejects the CA"),
14761 };
14762 (err, build_client_fallback_count() - fallbacks_before)
14763 });
14764
14765 assert_strict_fallback_error(
14766 err,
14767 &[
14768 "tls.strict/webpki-fallback",
14769 "rejected by the TLS root store",
14770 ],
14771 );
14772 assert_eq!(
14773 fallbacks_taken, 1,
14774 "the typed error must come from exactly one webpki fallback entry"
14775 );
14776 }
14777
14778 #[test]
14779 fn test_build_client_forced_fallback_strict_material_rejection_classes() {
14780 let dir = tempfile::tempdir().expect("tempdir"); let plain_text_ca = dir.path().join("plain-text.pem");
14786 std::fs::write(&plain_text_ca, "this is not a certificate\n").expect("write plain text ca"); let client_key = rcgen::KeyPair::generate().expect("client keygen"); let client_cert = rcgen::CertificateParams::default()
14791 .self_signed(&client_key)
14792 .expect("client self-sign"); let cert_path = dir.path().join("client-cert.pem");
14794 std::fs::write(&cert_path, client_cert.pem()).expect("write client cert"); let valid_key_path = dir.path().join("client-key.pem");
14796 std::fs::write(&valid_key_path, client_key.serialize_pem()).expect("write client key"); let garbage_key_path = dir.path().join("garbage-key.pem");
14798 std::fs::write(&garbage_key_path, "not a key\n").expect("write garbage key"); let rejected_key_path = dir.path().join("rejected-key.pem");
14802 std::fs::write(
14803 &rejected_key_path,
14804 "-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----\n",
14805 )
14806 .expect("write rejected key"); struct RejectionCase {
14809 name: &'static str,
14810 tls: TlsConfig,
14811 fragment: &'static str,
14812 }
14813 let cases = [
14814 RejectionCase {
14815 name: "CA with zero PEM CERTIFICATE sections",
14816 tls: TlsConfig {
14817 enabled: true,
14818 strict: true,
14819 ca_cert_path: Some(plain_text_ca.display().to_string()),
14820 ..Default::default()
14821 },
14822 fragment: "no parseable PEM CERTIFICATE section",
14823 },
14824 RejectionCase {
14825 name: "client key with no parseable section",
14826 tls: TlsConfig {
14827 enabled: true,
14828 strict: true,
14829 client_cert_path: Some(cert_path.display().to_string()),
14830 client_key_path: Some(garbage_key_path.display().to_string()),
14831 ..Default::default()
14832 },
14833 fragment: "no parseable private key section",
14834 },
14835 RejectionCase {
14836 name: "client key rejected by the TLS backend",
14837 tls: TlsConfig {
14838 enabled: true,
14839 strict: true,
14840 client_cert_path: Some(cert_path.display().to_string()),
14841 client_key_path: Some(rejected_key_path.display().to_string()),
14842 ..Default::default()
14843 },
14844 fragment: "rejected by the TLS backend",
14845 },
14846 RejectionCase {
14847 name: "unreadable client cert",
14848 tls: TlsConfig {
14849 enabled: true,
14850 strict: true,
14851 client_cert_path: Some("/nonexistent/client-cert.pem".to_string()),
14852 client_key_path: Some(valid_key_path.display().to_string()),
14853 ..Default::default()
14854 },
14855 fragment: "unreadable",
14856 },
14857 ];
14858
14859 for case in cases {
14860 let config = HttpConfig {
14861 tls: Some(case.tls),
14862 ..Default::default()
14863 };
14864 let err = forced_fallback_env(|| match build_client(&config, None) {
14865 Err(e) => e,
14866 Ok(_) => panic!("strict case '{}' must fail closed", case.name),
14867 });
14868 assert_strict_fallback_error(err, &["tls.strict/webpki-fallback", case.fragment]);
14869 }
14870 }
14871
14872 #[test]
14873 fn test_build_client_forced_fallback_nonstrict_bad_ca_still_builds() {
14874 let dir = tempfile::tempdir().expect("tempdir"); let ca_path = dir.path().join("garbage.pem");
14876 std::fs::write(&ca_path, "this is not a certificate\n").expect("write garbage pem"); let config = HttpConfig {
14878 tls: Some(TlsConfig {
14879 enabled: true,
14880 strict: false,
14881 ca_cert_path: Some(ca_path.display().to_string()),
14882 ..Default::default()
14883 }),
14884 ..Default::default()
14885 };
14886
14887 let (_client, fallbacks_taken) = forced_fallback_env(|| {
14888 let fallbacks_before = build_client_fallback_count();
14889 let client = build_client(&config, None)
14890 .expect("non-strict bad CA must degrade per-item and still build"); (client, build_client_fallback_count() - fallbacks_before)
14892 });
14893
14894 assert_eq!(
14895 fallbacks_taken, 1,
14896 "permissive item downgrade still routes exactly one build \
14897 through the webpki fallback"
14898 );
14899 }
14900
14901 #[test]
14902 fn test_http_component_with_config_folds_fallback_error_without_panic() {
14903 let dir = tempfile::tempdir().expect("tempdir"); let ca_path = dir.path().join("garbage-cert.pem");
14905 std::fs::write(
14906 &ca_path,
14907 "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n",
14908 )
14909 .expect("write garbage cert pem"); let config = HttpConfig {
14911 tls: Some(TlsConfig {
14912 enabled: true,
14913 strict: true,
14914 ca_cert_path: Some(ca_path.display().to_string()),
14915 ..Default::default()
14916 }),
14917 ..Default::default()
14918 };
14919
14920 let endpoint_err = forced_fallback_env(|| {
14921 let component = HttpComponent::with_config(config);
14925 component
14926 .create_endpoint("http://h/p", &NoOpComponentContext)
14927 .err()
14928 });
14929
14930 let err = endpoint_err.expect("folded fallback error must fail endpoint creation"); assert!(
14932 err.to_string().contains("rejected by the TLS root store"),
14933 "folded error must carry the fallback diagnosis: {err}"
14934 );
14935 }
14936
14937 #[test]
14938 fn test_http_component_new_no_panic_on_empty_ca_store() {
14939 let endpoint = forced_fallback_env(|| {
14940 let component = HttpComponent::new();
14941 component
14942 .create_endpoint("http://h/p", &NoOpComponentContext)
14943 .ok()
14944 });
14945 let _endpoint = endpoint.expect("material-free webpki fallback must serve endpoints"); }
14947
14948 #[tracing_test::traced_test]
14976 #[tokio::test]
14977 async fn tls_handshake_strict_custom_ca_carried() {
14978 let ca = gen_test_ca();
14979 let dir = tempfile::tempdir().expect("tempdir"); let ca_path = dir.path().join("ca.pem");
14981 std::fs::write(&ca_path, ca.ca_pem.as_bytes()).expect("write ca pem"); let (server_cert, server_key) =
14983 gen_leaf_signed_by_ca(&ca, "127.0.0.1".parse().expect("loopback ip")); let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
14985
14986 let config = HttpConfig {
14987 tls: Some(TlsConfig {
14988 enabled: true,
14989 strict: true,
14990 ca_cert_path: Some(ca_path.display().to_string()),
14991 ..Default::default()
14992 }),
14993 ..Default::default()
14994 };
14995 let client = build_under_seam(&config);
14996
14997 let resp = tokio::time::timeout(
14998 Duration::from_secs(15),
14999 client.get(format!("https://{addr}/")).send(),
15000 )
15001 .await
15002 .expect("request must complete within the harness timeout") .expect("strict webpki fallback must carry the configured CA into the handshake"); assert_eq!(resp.status().as_u16(), 200);
15005 let body = resp.text().await.expect("response body"); assert_eq!(body, "ok");
15007
15008 assert!(
15013 logs_contain("carries configured TLS material"),
15014 "strict webpki fallback with configured material must log the carry"
15015 );
15016
15017 server.abort();
15018 }
15019
15020 #[tracing_test::traced_test]
15021 #[tokio::test]
15022 async fn tls_handshake_strict_material_free_fails_same_server() {
15023 let ca = gen_test_ca();
15024 let (server_cert, server_key) =
15025 gen_leaf_signed_by_ca(&ca, "127.0.0.1".parse().expect("loopback ip")); let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15027
15028 let config = HttpConfig {
15029 tls: Some(TlsConfig {
15030 enabled: true,
15031 strict: true,
15032 ..Default::default()
15033 }),
15034 ..Default::default()
15035 };
15036 let client = forced_fallback_env(|| {
15037 build_client(&config, None).expect("client must build") });
15039
15040 let send_result = tokio::time::timeout(
15041 Duration::from_secs(15),
15042 client.get(format!("https://{addr}/")).send(),
15043 )
15044 .await
15045 .expect("request must complete within the harness timeout"); assert!(
15047 send_result.is_err(),
15048 "material-free strict fallback trusts only Mozilla roots, which do not \
15049 trust the rcgen CA — the handshake must fail"
15050 );
15051
15052 assert!(
15058 logs_contain("HTTP client build failed on platform TLS roots"),
15059 "positive control: this test's own fallback warn must be captured"
15060 );
15061 assert!(
15062 !logs_contain("client certificate NOT used"),
15063 "no identity was configured — the identity degrade warn must not fire"
15064 );
15065 assert!(
15066 !logs_contain("falling back to bundled Mozilla roots"),
15067 "no CA was configured — the CA degrade warn must not fire"
15068 );
15069
15070 server.abort();
15071 }
15072
15073 #[tracing_test::traced_test]
15074 #[tokio::test]
15075 async fn tls_handshake_strict_mtls_identity_carried() {
15076 let ca = gen_test_ca();
15077 let dir = tempfile::tempdir().expect("tempdir"); let ca_path = dir.path().join("ca.pem");
15079 std::fs::write(&ca_path, ca.ca_pem.as_bytes()).expect("write ca pem"); let (client_cert_pem, client_key_pem) = gen_client_identity(&ca);
15081 let client_cert_path = dir.path().join("client-cert.pem");
15082 std::fs::write(&client_cert_path, client_cert_pem.as_bytes()).expect("write client cert"); let client_key_path = dir.path().join("client-key.pem");
15084 std::fs::write(&client_key_path, client_key_pem.as_bytes()).expect("write client key"); let (server_cert, server_key) =
15086 gen_leaf_signed_by_ca(&ca, "127.0.0.1".parse().expect("loopback ip")); let (addr, server) = spawn_tls_server(&server_cert, &server_key, Some(&ca.ca_pem)).await;
15090
15091 let config = HttpConfig {
15092 tls: Some(TlsConfig {
15093 enabled: true,
15094 strict: true,
15095 ca_cert_path: Some(ca_path.display().to_string()),
15096 client_cert_path: Some(client_cert_path.display().to_string()),
15097 client_key_path: Some(client_key_path.display().to_string()),
15098 ..Default::default()
15099 }),
15100 ..Default::default()
15101 };
15102 let client = build_under_seam(&config);
15103
15104 let resp = tokio::time::timeout(
15105 Duration::from_secs(15),
15106 client.get(format!("https://{addr}/")).send(),
15107 )
15108 .await
15109 .expect("request must complete within the harness timeout") .expect("strict webpki fallback must carry the mTLS identity into the handshake"); assert_eq!(resp.status().as_u16(), 200);
15112 let body = resp.text().await.expect("response body"); assert_eq!(body, "ok");
15114
15115 assert!(
15119 logs_contain("carries configured TLS material"),
15120 "strict webpki fallback with configured material must log the carry"
15121 );
15122
15123 server.abort();
15124 }
15125
15126 #[tokio::test]
15127 async fn tls_handshake_strict_mtls_less_client_rejected() {
15128 let ca = gen_test_ca();
15129 let dir = tempfile::tempdir().expect("tempdir"); let ca_path = dir.path().join("ca.pem");
15131 std::fs::write(&ca_path, ca.ca_pem.as_bytes()).expect("write ca pem"); let (server_cert, server_key) =
15133 gen_leaf_signed_by_ca(&ca, "127.0.0.1".parse().expect("loopback ip")); let (addr, server) = spawn_tls_server(&server_cert, &server_key, Some(&ca.ca_pem)).await;
15135
15136 let config = HttpConfig {
15139 tls: Some(TlsConfig {
15140 enabled: true,
15141 strict: true,
15142 ca_cert_path: Some(ca_path.display().to_string()),
15143 ..Default::default()
15144 }),
15145 ..Default::default()
15146 };
15147 let client = build_under_seam(&config);
15148
15149 let send_result = tokio::time::timeout(
15150 Duration::from_secs(15),
15151 client.get(format!("https://{addr}/")).send(),
15152 )
15153 .await
15154 .expect("request must complete within the harness timeout"); assert!(
15156 send_result.is_err(),
15157 "the server requires client certificates — an identity-less client \
15158 must be rejected at the handshake"
15159 );
15160
15161 server.abort();
15162 }
15163
15164 #[tracing_test::traced_test]
15165 #[tokio::test]
15166 async fn tls_handshake_nonstrict_valid_ca_carried() {
15167 let ca = gen_test_ca();
15168 let dir = tempfile::tempdir().expect("tempdir"); let ca_path = dir.path().join("ca.pem");
15170 std::fs::write(&ca_path, ca.ca_pem.as_bytes()).expect("write ca pem"); let (server_cert, server_key) =
15172 gen_leaf_signed_by_ca(&ca, "127.0.0.1".parse().expect("loopback ip")); let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15174
15175 let config = HttpConfig {
15176 tls: Some(TlsConfig {
15177 enabled: true,
15178 strict: false,
15179 ca_cert_path: Some(ca_path.display().to_string()),
15180 ..Default::default()
15181 }),
15182 ..Default::default()
15183 };
15184 let client = build_under_seam(&config);
15185
15186 let resp = tokio::time::timeout(
15187 Duration::from_secs(15),
15188 client.get(format!("https://{addr}/")).send(),
15189 )
15190 .await
15191 .expect("request must complete within the harness timeout") .expect("non-strict webpki fallback must carry the valid configured CA into the handshake"); assert_eq!(resp.status().as_u16(), 200);
15194 let body = resp.text().await.expect("response body"); assert_eq!(body, "ok");
15196
15197 assert!(
15207 !logs_contain("client certificate NOT used"),
15208 "no identity was configured — the identity degrade warn must not fire"
15209 );
15210 assert!(
15211 !logs_contain("falling back to bundled Mozilla roots"),
15212 "the CA loaded cleanly — the CA degrade warn must not fire"
15213 );
15214
15215 server.abort();
15216 }
15217
15218 #[tracing_test::traced_test]
15232 #[tokio::test]
15233 async fn tls_handshake_mtls_identity_only_genuine_fallback_anchor() {
15234 let ca = gen_test_ca();
15235 let dir = tempfile::tempdir().expect("tempdir"); let (client_cert_pem, client_key_pem) = gen_client_identity(&ca);
15237 let client_cert_path = dir.path().join("client-cert.pem");
15238 std::fs::write(&client_cert_path, client_cert_pem.as_bytes()).expect("write client cert"); let client_key_path = dir.path().join("client-key.pem");
15240 std::fs::write(&client_key_path, client_key_pem.as_bytes()).expect("write client key"); let config = HttpConfig {
15243 tls: Some(TlsConfig {
15244 enabled: true,
15245 strict: true,
15246 client_cert_path: Some(client_cert_path.display().to_string()),
15247 client_key_path: Some(client_key_path.display().to_string()),
15248 ..Default::default()
15249 }),
15250 ..Default::default()
15251 };
15252 let (client, fallbacks_taken) = {
15253 let fallbacks_before = build_client_fallback_count();
15254 let client = forced_fallback_env(|| {
15255 build_client(&config, None).expect("client must build") });
15257 (client, build_client_fallback_count() - fallbacks_before)
15258 };
15259 assert_eq!(
15260 fallbacks_taken, 1,
15261 "the empty-store env window must genuinely fail the platform-verifier primary"
15262 );
15263
15264 drop(client);
15271
15272 assert!(
15277 logs_contain("HTTP client build failed on platform TLS roots"),
15278 "the genuine env-window trigger must emit the real platform warn"
15279 );
15280 }
15281
15282 #[tokio::test]
15299 async fn tls_parity_insecure_self_signed_succeeds() {
15300 let (server_cert, server_key) = gen_self_signed_server_cert();
15301 let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15302
15303 let config = HttpConfig {
15304 tls: Some(TlsConfig {
15305 enabled: true,
15306 strict: false,
15307 insecure: true,
15308 ..Default::default()
15309 }),
15310 ..Default::default()
15311 };
15312 let client = build_under_seam(&config);
15313
15314 let resp = tokio::time::timeout(
15315 Duration::from_secs(15),
15316 client.get(format!("https://{addr}/")).send(),
15317 )
15318 .await
15319 .expect("request must complete within the harness timeout") .expect("verify-off (insecure) fallback must handshake with the self-signed server"); assert_eq!(resp.status().as_u16(), 200);
15322 let body = resp.text().await.expect("response body"); assert_eq!(body, "ok");
15324
15325 server.abort();
15326 }
15327
15328 #[tokio::test]
15329 async fn tls_parity_verify_peer_false_self_signed_succeeds() {
15330 let (server_cert, server_key) = gen_self_signed_server_cert();
15331 let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15332
15333 let config = HttpConfig {
15334 tls: Some(TlsConfig {
15335 enabled: true,
15336 strict: false,
15337 verify_peer: false,
15338 ..Default::default()
15339 }),
15340 ..Default::default()
15341 };
15342 let client = build_under_seam(&config);
15343
15344 let resp = tokio::time::timeout(
15345 Duration::from_secs(15),
15346 client.get(format!("https://{addr}/")).send(),
15347 )
15348 .await
15349 .expect("request must complete within the harness timeout") .expect("verify_peer=false fallback must handshake with the self-signed server"); assert_eq!(resp.status().as_u16(), 200);
15352 let body = resp.text().await.expect("response body"); assert_eq!(body, "ok");
15354
15355 server.abort();
15356 }
15357
15358 #[tokio::test]
15359 async fn tls_parity_insecure_strict_bad_material_still_fails_closed() {
15360 let dir = tempfile::tempdir().expect("tempdir"); let ca_path = dir.path().join("rejected-ca.pem");
15365 std::fs::write(
15366 &ca_path,
15367 b"-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n",
15368 )
15369 .expect("write rejected ca fixture"); let config = HttpConfig {
15372 tls: Some(TlsConfig {
15373 enabled: true,
15374 strict: true,
15375 insecure: true,
15376 ca_cert_path: Some(ca_path.display().to_string()),
15377 ..Default::default()
15378 }),
15379 ..Default::default()
15380 };
15381 let err = match force_webpki_fallback(|| build_client(&config, None)) {
15385 Err(e) => e,
15386 Ok(_) => panic!(
15387 "strict mode must fail closed on store-rejected CA material \
15388 even with insecure=true"
15389 ),
15390 };
15391 assert_strict_fallback_error(err, &["tls.strict/webpki-fallback"]);
15392 }
15393
15394 #[test]
15395 fn strict_rebuild_failure_returns_typed_error() {
15396 let StrictMaterial {
15397 dir: _dir,
15398 ca_path,
15399 cert_path,
15400 key_path,
15401 ..
15402 } = strict_material_fixtures();
15403
15404 let config = HttpConfig {
15408 tls: Some(TlsConfig {
15409 enabled: true,
15410 strict: true,
15411 ca_cert_path: Some(ca_path.display().to_string()),
15412 client_cert_path: Some(cert_path.display().to_string()),
15413 client_key_path: Some(key_path.display().to_string()),
15414 ..Default::default()
15415 }),
15416 ..Default::default()
15417 };
15418 let fallbacks_before = build_client_fallback_count();
15419 let err = match force_webpki_fallback_rebuild_failure(|| build_client(&config, None)) {
15420 Err(e) => e,
15421 Ok(_) => panic!(
15422 "strict mode must fail closed when the webpki fallback client \
15423 rebuild fails — never the material-free emergency client"
15424 ),
15425 };
15426 assert_eq!(
15427 build_client_fallback_count() - fallbacks_before,
15428 1,
15429 "the rebuild-failure call must route through the webpki fallback exactly once"
15430 );
15431 assert_strict_fallback_error(
15432 err,
15433 &[
15434 "tls.strict/webpki-fallback:",
15435 "webpki fallback client rebuild failed",
15436 "forced webpki fallback rebuild failure",
15437 ],
15438 );
15439 }
15440
15441 #[test]
15442 fn non_strict_rebuild_failure_returns_emergency_client() {
15443 let StrictMaterial {
15444 dir: _dir,
15445 ca_path,
15446 cert_path,
15447 key_path,
15448 ..
15449 } = strict_material_fixtures();
15450
15451 let config = HttpConfig {
15452 tls: Some(TlsConfig {
15453 enabled: true,
15454 strict: false,
15455 ca_cert_path: Some(ca_path.display().to_string()),
15456 client_cert_path: Some(cert_path.display().to_string()),
15457 client_key_path: Some(key_path.display().to_string()),
15458 ..Default::default()
15459 }),
15460 ..Default::default()
15461 };
15462 let fallbacks_before = build_client_fallback_count();
15463 let _client = force_webpki_fallback_rebuild_failure(|| build_client(&config, None))
15464 .expect("non-strict rebuild failure must degrade to the emergency client"); assert_eq!(
15466 build_client_fallback_count() - fallbacks_before,
15467 1,
15468 "the rebuild-failure call must route through the webpki fallback exactly once"
15469 );
15470 }
15471
15472 #[test]
15473 fn strict_rebuild_failure_surfaces_at_endpoint_creation() {
15474 let StrictMaterial {
15475 dir: _dir,
15476 ca_path,
15477 cert_path,
15478 key_path,
15479 ..
15480 } = strict_material_fixtures();
15481
15482 let config = HttpConfig {
15486 tls: Some(TlsConfig {
15487 enabled: true,
15488 strict: true,
15489 ca_cert_path: Some(ca_path.display().to_string()),
15490 client_cert_path: Some(cert_path.display().to_string()),
15491 client_key_path: Some(key_path.display().to_string()),
15492 ..Default::default()
15493 }),
15494 ..Default::default()
15495 };
15496
15497 let endpoint_err = force_webpki_fallback_rebuild_failure(|| {
15498 let component = HttpComponent::with_config(config);
15502 component
15503 .create_endpoint("http://h/p", &NoOpComponentContext)
15504 .err()
15505 });
15506
15507 let err = endpoint_err.expect("folded rebuild failure must fail endpoint creation"); assert_strict_fallback_error(
15509 err,
15510 &[
15511 "tls.strict/webpki-fallback:",
15512 "webpki fallback client rebuild failed",
15513 ],
15514 );
15515 }
15516
15517 #[tracing_test::traced_test]
15518 #[tokio::test]
15519 async fn non_strict_rebuild_failure_drops_custom_trust() {
15520 let StrictMaterial {
15521 dir: _dir,
15522 ca,
15523 ca_path,
15524 ..
15525 } = strict_material_fixtures();
15526
15527 let (server_cert, server_key) =
15531 gen_leaf_signed_by_ca(&ca, "127.0.0.1".parse().expect("loopback ip")); let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15533
15534 let config = HttpConfig {
15535 tls: Some(TlsConfig {
15536 enabled: true,
15537 strict: false,
15538 ca_cert_path: Some(ca_path.display().to_string()),
15539 ..Default::default()
15540 }),
15541 ..Default::default()
15542 };
15543 let control = force_webpki_fallback(|| {
15552 build_client(&config, None).expect("control client must build") });
15554 let client = force_webpki_fallback_rebuild_failure(|| {
15558 build_client(&config, None).expect("emergency client must build") });
15560
15561 let control_resp = tokio::time::timeout(
15566 Duration::from_secs(15),
15567 control.get(format!("https://{addr}/")).send(),
15568 )
15569 .await
15570 .expect("request must complete within the harness timeout") .expect("the control client carries the CA — the CA-signed handshake must succeed"); assert_eq!(control_resp.status().as_u16(), 200);
15573
15574 let send_result = tokio::time::timeout(
15575 Duration::from_secs(15),
15576 client.get(format!("https://{addr}/")).send(),
15577 )
15578 .await
15579 .expect("request must complete within the harness timeout"); assert!(
15581 send_result.is_err(),
15582 "the emergency client carries Mozilla roots only — the configured \
15583 CA trust is provably dropped, so the CA-signed handshake must fail"
15584 );
15585
15586 assert!(
15590 logs_contain("webpki fallback client build failed — TLS stack broken process-wide"),
15591 "the rebuild-failure terminal must log the system-broken diagnosis"
15592 );
15593
15594 server.abort();
15595 }
15596
15597 #[tracing_test::traced_test]
15598 #[tokio::test]
15599 async fn tls_parity_nonstrict_itemwise_valid_ca_bad_identity() {
15600 let ca = gen_test_ca();
15601 let dir = tempfile::tempdir().expect("tempdir"); let ca_path = dir.path().join("ca.pem");
15603 std::fs::write(&ca_path, ca.ca_pem.as_bytes()).expect("write ca pem"); let (client_cert_pem, _client_key_pem) = gen_client_identity(&ca);
15605 let client_cert_path = dir.path().join("client-cert.pem");
15606 std::fs::write(&client_cert_path, client_cert_pem.as_bytes()).expect("write client cert"); let (server_cert, server_key) =
15608 gen_leaf_signed_by_ca(&ca, "127.0.0.1".parse().expect("loopback ip")); let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15612
15613 let config = HttpConfig {
15614 tls: Some(TlsConfig {
15615 enabled: true,
15616 strict: false,
15617 ca_cert_path: Some(ca_path.display().to_string()),
15618 client_cert_path: Some(client_cert_path.display().to_string()),
15619 client_key_path: Some("/nonexistent/key.pem".to_string()),
15622 ..Default::default()
15623 }),
15624 ..Default::default()
15625 };
15626 let client = build_under_seam(&config);
15627
15628 let resp = tokio::time::timeout(
15629 Duration::from_secs(15),
15630 client.get(format!("https://{addr}/")).send(),
15631 )
15632 .await
15633 .expect("request must complete within the harness timeout") .expect("the valid CA item must be carried despite the unreadable key"); assert_eq!(resp.status().as_u16(), 200);
15636 let body = resp.text().await.expect("response body"); assert_eq!(body, "ok");
15638
15639 assert!(
15644 logs_contain("client certificate NOT used"),
15645 "the unreadable key must degrade the identity item with the mTLS warn"
15646 );
15647 assert!(
15648 !logs_contain("falling back to bundled Mozilla roots"),
15649 "the CA loaded cleanly — the CA degrade warn must not fire"
15650 );
15651
15652 server.abort();
15653 }
15654
15655 #[tokio::test]
15656 async fn tls_parity_verifying_client_rejects_self_signed_without_insecure() {
15657 let (server_cert, server_key) = gen_self_signed_server_cert();
15658 let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15659
15660 let config = HttpConfig {
15666 tls: Some(TlsConfig {
15667 enabled: true,
15668 strict: true,
15669 ..Default::default()
15670 }),
15671 ..Default::default()
15672 };
15673 let client = build_under_seam(&config);
15674
15675 let send_result = tokio::time::timeout(
15676 Duration::from_secs(15),
15677 client.get(format!("https://{addr}/")).send(),
15678 )
15679 .await
15680 .expect("request must complete within the harness timeout"); assert!(
15682 send_result.is_err(),
15683 "a verifying client must reject the self-signed server at the handshake"
15684 );
15685
15686 server.abort();
15687 }
15688}