1pub mod bundle;
2pub(crate) mod client_cache;
3pub mod config;
4mod header_policy;
5pub mod health;
6pub mod registry;
7pub(crate) mod rest_match;
8pub(crate) mod ssrf;
9pub mod static_config;
10pub mod static_dispatch;
11pub mod static_endpoint;
12pub(crate) mod tls_reload;
13use crate::config::parse_ok_status_code_range;
14pub use bundle::HttpBundle;
15pub use bundle::HttpStaticBundle;
16pub(crate) use client_cache::{
17 HttpComponentKind, PINNED_CLIENT_MAX_ENTRIES, PINNED_CLIENT_TTL, PinnedClientCache,
18};
19pub use config::HttpConfig;
20pub use health::HttpHealthCheck;
21pub use registry::HttpRouteRegistry;
22pub use static_config::HttpStaticConfig;
23pub use static_endpoint::{HttpStaticComponent, HttpStaticConsumer, HttpStaticEndpoint};
24
25use std::collections::HashMap;
26use std::future::Future;
27use std::pin::Pin;
28
29use std::sync::{Arc, Mutex, OnceLock};
30use std::task::{Context, Poll};
31use std::time::Duration;
32
33use tokio::sync::OnceCell;
34use tower::Layer;
35use tower::Service;
36use tracing::debug;
37
38use axum::body::BodyDataStream;
39use camel_api::component_metadata::ComponentMetadata;
40use camel_auth::bearer_token_layer::BearerTokenLayer;
41use camel_auth::oauth2::TokenProvider;
42use camel_component_api::tls_source::ServerTlsSource;
43use camel_component_api::{Body, BoxProcessor, CamelError, Exchange, StreamBody, StreamMetadata};
44use camel_component_api::{Component, Consumer, Endpoint, ProducerContext, RuntimeObservability};
45use camel_component_api::{UriComponents, UriConfig, parse_uri, raw_query_pairs};
46use futures::StreamExt;
47use futures::TryStreamExt;
48use futures::stream::BoxStream;
49
50#[derive(Clone)]
111pub struct HttpEndpointConfig {
112 pub base_url: String,
113 pub http_method: Option<String>,
114 pub throw_exception_on_failure: bool,
115 pub ok_status_code_range: (u16, u16),
116 pub response_timeout: Option<Duration>,
117 pub query_params: Vec<(String, String)>,
121 pub raw_query: Option<String>,
126 pub allow_internal: bool,
127 pub allow_cleartext: bool,
133 pub blocked_hosts: Vec<String>,
134 pub max_body_size: usize,
135 pub read_timeout_ms: u64,
136 pub max_response_bytes: usize,
137 pub auth: HttpAuth,
138 pub token_provider: Option<Arc<dyn TokenProvider>>,
139 pub user_agent: Option<String>,
140 pub bridge_endpoint: bool,
141 pub connection_close: bool,
142 pub skip_request_headers: Vec<String>,
143 pub skip_response_headers: Vec<String>,
144 pub follow_redirects: bool,
145 pub max_redirects: usize,
146 pub allowed_uri_hosts: Option<Vec<AllowedUriHost>>,
151}
152
153impl std::fmt::Debug for HttpEndpointConfig {
166 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167 f.debug_struct("HttpEndpointConfig")
168 .field("base_url", &camel_api::redact::redact_url(&self.base_url))
169 .field("http_method", &self.http_method)
170 .field(
171 "throw_exception_on_failure",
172 &self.throw_exception_on_failure,
173 )
174 .field("ok_status_code_range", &self.ok_status_code_range)
175 .field("response_timeout", &self.response_timeout)
176 .field(
177 "query_params",
178 &self
179 .query_params
180 .iter()
181 .map(|(key, _)| (key, "***"))
182 .collect::<Vec<_>>(),
183 )
184 .field("raw_query", &self.raw_query.as_ref().map(|_| "?[redacted]"))
185 .field("allow_internal", &self.allow_internal)
186 .field("allow_cleartext", &self.allow_cleartext)
187 .field("blocked_hosts", &self.blocked_hosts)
188 .field("max_body_size", &self.max_body_size)
189 .field("read_timeout_ms", &self.read_timeout_ms)
190 .field("max_response_bytes", &self.max_response_bytes)
191 .field("auth", &self.auth)
192 .field("token_provider", &self.token_provider)
193 .field("user_agent", &self.user_agent)
194 .field("bridge_endpoint", &self.bridge_endpoint)
195 .field("connection_close", &self.connection_close)
196 .field("skip_request_headers", &self.skip_request_headers)
197 .field("skip_response_headers", &self.skip_response_headers)
198 .field("follow_redirects", &self.follow_redirects)
199 .field("max_redirects", &self.max_redirects)
200 .field("allowed_uri_hosts", &self.allowed_uri_hosts)
201 .finish()
202 }
203}
204
205#[derive(Clone, PartialEq)]
206pub enum HttpAuth {
207 None,
208 Basic { username: String, password: String },
209 Bearer { token: String },
210}
211
212impl std::fmt::Debug for HttpAuth {
213 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214 match self {
215 HttpAuth::None => f.write_str("None"),
216 HttpAuth::Basic { username, .. } => f
217 .debug_struct("Basic")
218 .field("username", username)
219 .field("password", &"***")
220 .finish(),
221 HttpAuth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
222 }
223 }
224}
225
226fn is_consumed_option(key: &str) -> bool {
235 HttpEndpointConfig::uri_options()
236 .iter()
237 .any(|option| option.name == key || option.aliases.iter().any(|alias| alias == key))
238}
239
240impl UriConfig for HttpEndpointConfig {
241 fn scheme() -> &'static str {
243 "http"
244 }
245
246 fn from_uri(uri: &str) -> Result<Self, CamelError> {
247 let parts = parse_uri(uri)?;
248 Self::from_components(parts)
249 }
250
251 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
252 if parts.scheme != "http" && parts.scheme != "https" {
254 return Err(CamelError::InvalidUri(format!(
255 "expected scheme 'http' or 'https', got '{}'",
256 parts.scheme
257 )));
258 }
259
260 let base_url = format!("{}:{}", parts.scheme, parts.path);
263
264 let http_method = parts.params.get("httpMethod").cloned();
265
266 let throw_exception_on_failure = match parts.params.get("throwExceptionOnFailure") {
267 Some(v) => parse_bool_param_http(v).map_err(|e| {
268 CamelError::InvalidUri(format!("invalid value for throwExceptionOnFailure: {e}"))
269 })?,
270 None => true,
271 };
272
273 let ok_status_code_range = match parts.params.get("okStatusCodeRange") {
275 Some(v) => parse_ok_status_code_range(v)?,
276 None => (200, 299),
277 };
278
279 let response_timeout = match parts.params.get("responseTimeout") {
280 Some(v) => Some(v.parse::<u64>().map(Duration::from_millis).map_err(|e| {
281 CamelError::InvalidUri(format!("invalid value for responseTimeout: {e}"))
282 })?),
283 None => None,
284 };
285
286 let allow_internal = match parts.params.get("allowInternal") {
288 Some(v) => parse_bool_param_http(v).map_err(|e| {
289 CamelError::InvalidUri(format!("invalid value for allowInternal: {e}"))
290 })?,
291 None => false, };
293
294 let allow_cleartext = match parts.params.get("allowCleartext") {
297 Some(v) => parse_bool_param_http(v).map_err(|e| {
298 CamelError::InvalidUri(format!("invalid value for allowCleartext: {e}"))
299 })?,
300 None => false, };
302
303 let blocked_hosts = parts
305 .params
306 .get("blockedHosts")
307 .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
308 .unwrap_or_default();
309
310 let max_body_size = match parts.params.get("maxBodySize") {
311 Some(v) => v.parse::<usize>().map_err(|e| {
312 CamelError::InvalidUri(format!("invalid value for maxBodySize: {e}"))
313 })?,
314 None => 10 * 1024 * 1024, };
316
317 let read_timeout_ms = match parts.params.get("readTimeout") {
318 Some(v) => v.parse::<u64>().map_err(|e| {
319 CamelError::InvalidUri(format!("invalid value for readTimeout: {e}"))
320 })?,
321 None => 30_000, };
323
324 let max_response_bytes = match parts.params.get("maxResponseBytes") {
325 Some(v) => v.parse::<usize>().map_err(|e| {
326 CamelError::InvalidUri(format!("invalid value for maxResponseBytes: {e}"))
327 })?,
328 None => 10 * 1024 * 1024, };
330
331 let auth = parse_auth_from_params(&parts.params)?;
332
333 let user_agent = parts.params.get("userAgent").cloned();
334
335 if parts.params.contains_key("cookieHandling") {
336 return Err(CamelError::InvalidUri(
337 "cookieHandling is not supported".into(),
338 ));
339 }
340
341 let bridge_endpoint = match parts.params.get("bridgeEndpoint") {
342 Some(v) => parse_bool_param_http(v).map_err(|e| {
343 CamelError::InvalidUri(format!("invalid value for bridgeEndpoint: {e}"))
344 })?,
345 None => false,
346 };
347
348 let connection_close = match parts.params.get("connectionClose") {
349 Some(v) => parse_bool_param_http(v).map_err(|e| {
350 CamelError::InvalidUri(format!("invalid value for connectionClose: {e}"))
351 })?,
352 None => false,
353 };
354
355 let skip_request_headers = parts
356 .params
357 .get("skipRequestHeaders")
358 .map(|v| {
359 v.split(',')
360 .map(str::trim)
361 .filter(|s| !s.is_empty())
362 .map(|s| s.to_ascii_lowercase())
363 .collect::<Vec<_>>()
364 })
365 .unwrap_or_default();
366
367 let skip_response_headers = parts
368 .params
369 .get("skipResponseHeaders")
370 .map(|v| {
371 v.split(',')
372 .map(str::trim)
373 .filter(|s| !s.is_empty())
374 .map(|s| s.to_ascii_lowercase())
375 .collect::<Vec<_>>()
376 })
377 .unwrap_or_default();
378
379 let follow_redirects = match parts.params.get("followRedirects") {
380 Some(v) => parse_bool_param_http(v).map_err(|e| {
381 CamelError::InvalidUri(format!("invalid value for followRedirects: {e}"))
382 })?,
383 None => false,
384 };
385
386 let max_redirects = match parts.params.get("maxRedirects") {
387 Some(v) => v.parse::<usize>().map_err(|e| {
388 CamelError::InvalidUri(format!("invalid value for maxRedirects: {e}"))
389 })?,
390 None => 10,
391 };
392
393 let allowed_uri_hosts = match parts.params.get("allowedUriHosts") {
396 Some(v) => Some(parse_allowed_uri_hosts(v)?),
397 None => None,
398 };
399
400 let raw_query = parts.raw_query.clone();
405
406 Ok(Self {
407 base_url,
408 http_method,
409 throw_exception_on_failure,
410 ok_status_code_range,
411 response_timeout,
412 query_params: Vec::new(),
413 raw_query,
414 allow_internal,
415 allow_cleartext,
416 blocked_hosts,
417 max_body_size,
418 read_timeout_ms,
419 max_response_bytes,
420 auth,
421 token_provider: None,
422 user_agent,
423 bridge_endpoint,
424 connection_close,
425 skip_request_headers,
426 skip_response_headers,
427 follow_redirects,
428 max_redirects,
429 allowed_uri_hosts,
430 })
431 }
432}
433
434#[derive(Debug, Clone, UriConfig)]
441#[allow(dead_code)]
442#[uri_scheme = "http"]
443#[uri_config(
444 skip_impl,
445 metadata(
446 scheme = "http",
447 description = "HTTP client and server component",
448 producer,
449 consumer,
450 streaming
451 ),
452 crate = "camel_component_api"
453)]
454struct HttpEndpointUriConfig {
455 #[allow(dead_code)]
456 _base_url: String,
457
458 #[uri_param(
459 name = "httpMethod",
460 desc = "HTTP method. Defaults to CamelHttpMethod header or POST/GET"
461 )]
462 http_method: Option<String>,
463
464 #[uri_param(
465 name = "throwExceptionOnFailure",
466 default = "true",
467 desc = "Throw on non-2xx status"
468 )]
469 throw_exception_on_failure: bool,
470
471 #[uri_param(
472 name = "okStatusCodeRange",
473 default = "200-299",
474 desc = "Success status code range"
475 )]
476 ok_status_code_range: String,
477
478 #[uri_param(name = "responseTimeout", desc = "Response timeout in milliseconds")]
479 response_timeout: Option<u64>,
480
481 #[uri_param(
482 name = "connectTimeout",
483 desc = "Connection timeout in milliseconds (consumed option; effective timeout comes from the global http config)"
484 )]
485 connect_timeout: Option<u64>,
486
487 #[uri_param(
488 name = "allowInternal",
489 default = "false",
490 desc = "Allow private/internal network destinations (SSRF)"
491 )]
492 allow_internal: bool,
493
494 #[uri_param(
495 name = "allowCleartext",
496 default = "false",
497 desc = "Allow cleartext http:// to public targets (transport consent; ADR-0081)"
498 )]
499 allow_cleartext: bool,
500
501 #[uri_param(name = "blockedHosts", desc = "Comma-separated blocked host list")]
502 blocked_hosts: Option<String>,
503
504 #[uri_param(
505 name = "maxBodySize",
506 default = "10485760",
507 desc = "Max request/response body bytes"
508 )]
509 max_body_size: u64,
510
511 #[uri_param(name = "readTimeout", desc = "Socket read timeout in milliseconds")]
512 read_timeout: Option<u64>,
513
514 #[uri_param(name = "maxResponseBytes", desc = "Max response body bytes")]
515 max_response_bytes: Option<u64>,
516
517 #[uri_param(
518 name = "authMethod",
519 kind = "enum:None,Basic,Bearer",
520 desc = "Authentication method"
521 )]
522 auth_method: Option<String>,
523
524 #[uri_param(name = "authUsername", secret, desc = "Basic auth username")]
525 auth_username: Option<String>,
526
527 #[uri_param(name = "authPassword", secret, desc = "Basic auth password")]
528 auth_password: Option<String>,
529
530 #[uri_param(name = "authBearerToken", secret, desc = "Bearer auth token")]
531 auth_bearer_token: Option<String>,
532
533 #[uri_param(name = "userAgent", desc = "User-Agent header")]
534 user_agent: Option<String>,
535
536 #[uri_param(
537 name = "bridgeEndpoint",
538 default = "false",
539 desc = "Bridge endpoint mode"
540 )]
541 bridge_endpoint: bool,
542
543 #[uri_param(
544 name = "connectionClose",
545 default = "false",
546 desc = "Send Connection: close"
547 )]
548 connection_close: bool,
549
550 #[uri_param(
551 name = "skipRequestHeaders",
552 desc = "Comma-separated request headers to skip"
553 )]
554 skip_request_headers: Option<String>,
555
556 #[uri_param(
557 name = "skipResponseHeaders",
558 desc = "Comma-separated response headers to skip"
559 )]
560 skip_response_headers: Option<String>,
561
562 #[uri_param(
563 name = "followRedirects",
564 default = "false",
565 desc = "Follow HTTP redirects"
566 )]
567 follow_redirects: bool,
568
569 #[uri_param(name = "maxRedirects", default = "10", desc = "Max redirect hops")]
570 max_redirects: u64,
571
572 #[uri_param(
573 name = "allowedUriHosts",
574 desc = "Comma-separated allowlist of CamelHttpUri override hosts (host or host:port)"
575 )]
576 allowed_uri_hosts: Option<String>,
577}
578
579impl HttpEndpointConfig {
580 pub fn metadata() -> ComponentMetadata {
583 HttpEndpointUriConfig::metadata()
584 }
585
586 pub fn uri_options() -> Vec<camel_api::component_metadata::UriOption> {
588 HttpEndpointUriConfig::uri_options()
589 }
590}
591
592fn parse_auth_from_params(params: &HashMap<String, String>) -> Result<HttpAuth, CamelError> {
593 let Some(method) = params.get("authMethod") else {
594 return Ok(HttpAuth::None);
595 };
596
597 if method.eq_ignore_ascii_case("none") {
598 return Ok(HttpAuth::None);
599 }
600
601 if method.eq_ignore_ascii_case("basic") {
602 let username = params.get("authUsername").cloned().ok_or_else(|| {
603 CamelError::InvalidUri("authUsername is required for authMethod=Basic".to_string())
604 })?;
605 let password = params.get("authPassword").cloned().ok_or_else(|| {
606 CamelError::InvalidUri("authPassword is required for authMethod=Basic".to_string())
607 })?;
608 return Ok(HttpAuth::Basic { username, password });
609 }
610
611 if method.eq_ignore_ascii_case("bearer") {
612 let token = params.get("authBearerToken").cloned().ok_or_else(|| {
613 CamelError::InvalidUri("authBearerToken is required for authMethod=Bearer".to_string())
614 })?;
615 return Ok(HttpAuth::Bearer { token });
616 }
617
618 Err(CamelError::InvalidUri(format!(
619 "invalid value for authMethod: {method} (expected None, Basic, or Bearer)"
620 )))
621}
622
623fn parse_bool_param_http(value: &str) -> Result<bool, CamelError> {
624 match value.to_ascii_lowercase().as_str() {
625 "true" | "1" | "yes" => Ok(true),
626 "false" | "0" | "no" => Ok(false),
627 _ => Err(CamelError::InvalidUri(format!(
628 "invalid boolean value: '{value}'"
629 ))),
630 }
631}
632
633impl HttpEndpointConfig {
634 pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
635 let parts = parse_uri(uri)?;
636 let mut endpoint = Self::from_components(parts.clone())?;
637 if endpoint.response_timeout.is_none() {
638 endpoint.response_timeout = Some(Duration::from_millis(config.response_timeout_ms));
639 }
640 if !parts.params.contains_key("allowInternal") {
641 endpoint.allow_internal = config.allow_internal;
642 }
643 if !parts.params.contains_key("blockedHosts") {
644 endpoint.blocked_hosts = config.blocked_hosts.clone();
645 }
646 if !parts.params.contains_key("maxBodySize") {
647 endpoint.max_body_size = config.max_body_size;
648 }
649 if !parts.params.contains_key("readTimeout") {
650 endpoint.read_timeout_ms = config.read_timeout_ms;
651 }
652 if !parts.params.contains_key("maxResponseBytes") {
653 endpoint.max_response_bytes = config.max_response_bytes;
654 }
655 if !parts.params.contains_key("okStatusCodeRange")
656 && let Some(range) = &config.ok_status_code_range
657 {
658 endpoint.ok_status_code_range = parse_ok_status_code_range(range)?;
659 }
660 if !parts.params.contains_key("followRedirects") {
661 endpoint.follow_redirects = config.follow_redirects;
662 }
663 if !parts.params.contains_key("maxRedirects") {
664 endpoint.max_redirects = config.max_redirects.unwrap_or(10);
665 }
666
667 Ok(endpoint)
668 }
669}
670
671#[derive(Debug, Clone)]
677pub struct HttpServerConfig {
678 pub scheme: String,
680 pub host: String,
682 pub port: u16,
684 pub path: String,
686 pub max_request_body: usize,
688 pub max_response_body: usize,
690 pub max_inflight_requests: usize,
692 pub method: Option<String>,
699 pub tls_config: Option<crate::config::ServerTlsConfig>,
702}
703
704impl UriConfig for HttpServerConfig {
705 fn scheme() -> &'static str {
707 "http"
708 }
709
710 fn from_uri(uri: &str) -> Result<Self, CamelError> {
711 let parts = parse_uri(uri)?;
712 Self::from_components(parts)
713 }
714
715 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
716 if parts.scheme != "http" && parts.scheme != "https" {
718 return Err(CamelError::InvalidUri(format!(
719 "expected scheme 'http' or 'https', got '{}'",
720 parts.scheme
721 )));
722 }
723
724 let authority_and_path = parts.path.trim_start_matches('/');
727
728 let (authority, path_suffix) = if let Some(idx) = authority_and_path.find('/') {
730 (&authority_and_path[..idx], &authority_and_path[idx..])
731 } else {
732 (authority_and_path, "/")
733 };
734
735 let path = if path_suffix.is_empty() {
736 "/"
737 } else {
738 path_suffix
739 }
740 .to_string();
741
742 let (host, port) = if let Some(colon) = authority.rfind(':') {
744 let port_str = &authority[colon + 1..];
745 match port_str.parse::<u16>() {
746 Ok(p) => (authority[..colon].to_string(), p),
747 Err(_) => {
748 return Err(CamelError::InvalidUri(format!(
749 "invalid port '{}' in authority",
750 port_str
751 )));
752 }
753 }
754 } else {
755 let default_port = if parts.scheme == "https" { 443 } else { 80 };
757 (authority.to_string(), default_port)
758 };
759
760 let max_request_body = parts
761 .params
762 .get("maxRequestBody")
763 .and_then(|v| v.parse::<usize>().ok())
764 .unwrap_or(2 * 1024 * 1024); let max_response_body = parts
767 .params
768 .get("maxResponseBody")
769 .and_then(|v| v.parse::<usize>().ok())
770 .unwrap_or(10 * 1024 * 1024); let max_inflight_requests = parts
773 .params
774 .get("maxInflightRequests")
775 .and_then(|v| v.parse::<usize>().ok())
776 .unwrap_or(1024);
777
778 let method = parts.params.get("httpMethod").map(|m| m.to_uppercase());
784
785 Ok(Self {
786 scheme: parts.scheme,
787 host,
788 port,
789 path,
790 max_request_body,
791 max_response_body,
792 max_inflight_requests,
793 method,
794 tls_config: {
795 let cert = parts.params.get("tlsCert").cloned();
796 let key = parts.params.get("tlsKey").cloned();
797 match (cert, key) {
798 (Some(c), Some(k)) => Some(crate::config::ServerTlsConfig {
799 cert_path: c,
800 key_path: k,
801 }),
802 (None, None) => None,
803 _ => None, }
805 },
806 })
807 }
808}
809
810impl HttpServerConfig {
811 pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
812 let parts = parse_uri(uri)?;
813 let mut server = Self::from_components(parts.clone())?;
814 if !parts.params.contains_key("maxRequestBody") {
815 server.max_request_body = config.max_request_body;
816 }
817 if !parts.params.contains_key("maxResponseBody") {
818 server.max_response_body = config.max_body_size;
820 }
821 Ok(server)
822 }
823}
824
825pub enum HttpReplyBody {
833 Bytes(bytes::Bytes),
834 Stream(BoxStream<'static, Result<bytes::Bytes, CamelError>>),
835}
836
837pub struct RequestEnvelope {
842 pub method: String,
843 pub path: String,
844 pub query: String,
845 pub headers: http::HeaderMap,
846 pub body: StreamBody,
847 pub path_params: std::collections::HashMap<String, String>,
853 pub reply_tx: tokio::sync::oneshot::Sender<HttpReply>,
854}
855
856pub struct HttpReply {
860 pub status: u16,
861 pub headers: Vec<(String, String)>,
862 pub body: HttpReplyBody,
863}
864
865type ServerKey = (String, u16);
870
871struct ServerHandle {
873 registry: HttpRouteRegistry,
874 bound_addr: std::net::SocketAddr,
877 max_request_body: usize,
878 max_response_body: usize,
879 max_inflight_requests: usize,
880 is_tls: bool,
881 tls_cert_path: Option<String>,
882 tls_key_path: Option<String>,
883 monitor_task: tokio::task::JoinHandle<()>,
886 #[allow(dead_code)]
892 server_abort: tokio::task::AbortHandle,
893 tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
896 tls_source: Option<ServerTlsSource>,
897}
898
899#[derive(Default)]
902struct RegistryState {
903 entries: HashMap<ServerKey, Arc<OnceCell<ServerHandle>>>,
904 staged: HashMap<ServerKey, tokio::net::TcpListener>,
905}
906
907pub struct ServerRegistry {
909 inner: Mutex<RegistryState>,
910}
911
912impl ServerRegistry {
913 pub fn global() -> &'static Self {
915 static INSTANCE: OnceLock<ServerRegistry> = OnceLock::new();
916 INSTANCE.get_or_init(|| ServerRegistry {
917 inner: Mutex::new(RegistryState::default()),
918 })
919 }
920
921 #[allow(clippy::too_many_arguments)]
924 pub async fn get_or_spawn(
925 &'static self,
926 host: &str,
927 port: u16,
928 max_request_body: usize,
929 max_response_body: usize,
930 max_inflight_requests: usize,
931 runtime: Arc<dyn RuntimeObservability>,
932 route_id: String,
933 tls_config: Option<crate::config::ServerTlsConfig>,
934 ) -> Result<HttpRouteRegistry, CamelError> {
935 self.get_or_spawn_internal(
936 host,
937 port,
938 max_request_body,
939 max_response_body,
940 max_inflight_requests,
941 runtime,
942 route_id,
943 tls_config,
944 None,
945 )
946 .await
947 }
948
949 #[allow(clippy::too_many_arguments)]
956 pub async fn get_or_spawn_with_listener(
957 &'static self,
958 listener: tokio::net::TcpListener,
959 max_request_body: usize,
960 max_response_body: usize,
961 max_inflight_requests: usize,
962 runtime: Arc<dyn RuntimeObservability>,
963 route_id: String,
964 tls_config: Option<crate::config::ServerTlsConfig>,
965 ) -> Result<HttpRouteRegistry, CamelError> {
966 let addr = listener
967 .local_addr()
968 .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
969 self.get_or_spawn_internal(
970 &addr.ip().to_string(),
971 addr.port(),
972 max_request_body,
973 max_response_body,
974 max_inflight_requests,
975 runtime,
976 route_id,
977 tls_config,
978 Some(listener),
979 )
980 .await
981 }
982
983 pub async fn stage_listener(
990 &'static self,
991 listener: tokio::net::TcpListener,
992 ) -> Result<(), CamelError> {
993 let addr = listener
994 .local_addr()
995 .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
996 let host = addr.ip().to_string();
997 use std::collections::hash_map::Entry;
998 let mut guard = self.inner.lock().map_err(|_| {
999 CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
1000 })?;
1001 match guard.staged.entry((host.clone(), addr.port())) {
1002 Entry::Occupied(_) => Err(CamelError::EndpointCreationFailed(format!(
1003 "listener already staged for {host}:{}",
1004 addr.port()
1005 ))),
1006 Entry::Vacant(slot) => {
1007 slot.insert(listener);
1008 Ok(())
1009 }
1010 }
1011 }
1012
1013 pub fn bound_addr(&'static self, host: &str, port: u16) -> Option<std::net::SocketAddr> {
1016 let guard = self.inner.lock().ok()?;
1017 guard
1018 .entries
1019 .get(&(host.to_string(), port))
1020 .and_then(|cell| cell.get())
1021 .map(|handle| handle.bound_addr)
1022 }
1023
1024 #[allow(clippy::too_many_arguments)]
1025 async fn get_or_spawn_internal(
1026 &'static self,
1027 host: &str,
1028 port: u16,
1029 max_request_body: usize,
1030 max_response_body: usize,
1031 max_inflight_requests: usize,
1032 runtime: Arc<dyn RuntimeObservability>,
1033 route_id: String,
1034 tls_config: Option<crate::config::ServerTlsConfig>,
1035 provided: Option<tokio::net::TcpListener>,
1036 ) -> Result<HttpRouteRegistry, CamelError> {
1037 let host_owned = host.to_string();
1038 let key = (host.to_string(), port);
1039
1040 let cell = {
1041 let mut guard = self.inner.lock().map_err(|_| {
1042 CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
1043 })?;
1044 if let Some(existing) = guard.entries.get(&key)
1048 && let Some(handle) = existing.get()
1049 && handle.monitor_task.is_finished()
1050 {
1051 if handle.is_tls {
1054 let scheme = if handle.is_tls { "https" } else { "http" };
1055 camel_component_api::tls_source::TlsReloadRegistry::global()
1056 .unregister(scheme, host, port);
1057 }
1058 guard.entries.remove(&key);
1059 }
1060 guard
1061 .entries
1062 .entry(key)
1063 .or_insert_with(|| Arc::new(OnceCell::new()))
1064 .clone()
1065 };
1066
1067 if let Some(existing) = cell.get()
1068 && existing.max_request_body != max_request_body
1069 {
1070 return Err(CamelError::EndpointCreationFailed(format!(
1071 "incompatible maxRequestBody for shared server (host={host}, port={port}): {} vs {}",
1072 existing.max_request_body, max_request_body
1073 )));
1074 }
1075
1076 if let Some(existing) = cell.get()
1077 && existing.max_response_body != max_response_body
1078 {
1079 return Err(CamelError::EndpointCreationFailed(format!(
1080 "incompatible maxResponseBody for shared server (host={host}, port={port}): {} vs {}",
1081 existing.max_response_body, max_response_body
1082 )));
1083 }
1084
1085 if let Some(existing) = cell.get()
1086 && existing.max_inflight_requests != max_inflight_requests
1087 {
1088 return Err(CamelError::EndpointCreationFailed(format!(
1089 "incompatible maxInflightRequests for shared server (host={host}, port={port}): {} vs {}",
1090 existing.max_inflight_requests, max_inflight_requests
1091 )));
1092 }
1093
1094 if let Some(existing) = cell.get()
1096 && existing.is_tls != tls_config.is_some()
1097 {
1098 return Err(CamelError::EndpointCreationFailed(format!(
1099 "incompatible TLS mode for shared server (host={host}, port={port}): existing is_tls={}, new has_tls={}",
1100 existing.is_tls,
1101 tls_config.is_some()
1102 )));
1103 }
1104
1105 if let (Some(existing), Some(new_tls)) = (cell.get(), &tls_config)
1107 && (existing.tls_cert_path.as_deref() != Some(&new_tls.cert_path)
1108 || existing.tls_key_path.as_deref() != Some(&new_tls.key_path))
1109 {
1110 return Err(CamelError::EndpointCreationFailed(format!(
1111 "incompatible TLS cert/key for shared server (host={host}, port={port}): routes on the same TLS port must use the same cert and key"
1112 )));
1113 }
1114
1115 let handle = cell
1116 .get_or_try_init(|| {
1117 let rt = Arc::clone(&runtime);
1118 let rid = route_id.clone();
1119 let key = (host_owned.clone(), port);
1120 async move {
1121 let source = match provided {
1130 Some(listener) => ListenerSource::Staged(listener),
1131 None => {
1132 let mut guard = self.inner.lock().map_err(|_| {
1133 CamelError::EndpointCreationFailed(
1134 "ServerRegistry lock poisoned".into(),
1135 )
1136 })?;
1137 match guard.staged.remove(&key) {
1138 Some(listener) => ListenerSource::Staged(listener),
1139 None => {
1143 if let Some((staged_host, _)) = guard
1144 .staged
1145 .keys()
1146 .find(|(_, staged_port)| *staged_port == port)
1147 {
1148 let staged_host = staged_host.clone();
1149 return Err(CamelError::EndpointCreationFailed(
1150 format!(
1151 "staged listener conflict on port {port}: staged under host {staged_host}, requested {host_owned}"
1152 ),
1153 ));
1154 }
1155 ListenerSource::Bind
1156 }
1157 }
1158 }
1159 };
1160 spawn_entry(
1161 key,
1162 source,
1163 max_request_body,
1164 max_response_body,
1165 max_inflight_requests,
1166 rt,
1167 rid,
1168 tls_config,
1169 )
1170 .await
1171 .and_then(|handle| {
1172 Arc::try_unwrap(handle).map_err(|_| {
1176 CamelError::EndpointCreationFailed(
1177 "spawned server handle has dangling clones".into(),
1178 )
1179 })
1180 })
1181 }
1182 })
1183 .await?;
1184
1185 Ok(handle.registry.clone())
1186 }
1187
1188 pub async fn unregister(&self, host: &str, port: u16) {
1192 debug!(
1193 host = camel_api::redact::redact_host(host),
1194 port = port,
1195 "consumer unregistered from HTTP server"
1196 );
1197 }
1198
1199 #[cfg(test)]
1206 pub fn reset() {
1207 let instance = Self::global();
1208 let mut guard = instance
1209 .inner
1210 .lock()
1211 .expect("ServerRegistry lock poisoned during test reset");
1212 guard.entries.clear();
1213 guard.staged.clear();
1214 }
1215}
1216
1217enum ListenerSource {
1220 Bind,
1221 Staged(tokio::net::TcpListener),
1222}
1223
1224#[allow(clippy::too_many_arguments)]
1229async fn spawn_entry(
1230 key: ServerKey,
1231 source: ListenerSource,
1232 max_request_body: usize,
1233 max_response_body: usize,
1234 max_inflight_requests: usize,
1235 runtime: Arc<dyn RuntimeObservability>,
1236 route_id: String,
1237 tls_config: Option<crate::config::ServerTlsConfig>,
1238) -> Result<Arc<ServerHandle>, CamelError> {
1239 let rt = Arc::clone(&runtime);
1240 let rid = route_id.clone();
1241 let (host_owned, port) = key;
1242 let listener = match source {
1243 ListenerSource::Bind => {
1244 let addr = format!("{host_owned}:{port}");
1245 tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
1246 CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
1247 })?
1248 }
1249 ListenerSource::Staged(listener) => listener,
1250 };
1251 let bound_addr = listener
1252 .local_addr()
1253 .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
1254 let server_exited = tokio_util::sync::CancellationToken::new();
1255 let registry = HttpRouteRegistry::new_with_server_exited(server_exited.clone());
1256 let inflight = Arc::new(tokio::sync::Semaphore::new(max_inflight_requests));
1257 let tls_rustls_cfg: Option<axum_server::tls_rustls::RustlsConfig>;
1260 let tls_source: Option<ServerTlsSource>;
1261 let server_task = if let Some(ref tls) = tls_config {
1262 let rustls_config = load_tls_config(&tls.cert_path, &tls.key_path)?;
1263 let source = ServerTlsSource {
1264 cert_path: std::path::PathBuf::from(&tls.cert_path),
1265 key_path: std::path::PathBuf::from(&tls.key_path),
1266 client_ca_path: None,
1267 };
1268 let rustls_cfg =
1272 axum_server::tls_rustls::RustlsConfig::from_config(std::sync::Arc::new(rustls_config));
1273 tls_rustls_cfg = Some(rustls_cfg.clone());
1274 tls_source = Some(source);
1275 let std_listener = listener.into_std().map_err(|e| {
1277 CamelError::EndpointCreationFailed(format!("TLS listener conversion: {e}"))
1278 })?;
1279 tokio::spawn(run_axum_server_tls(
1280 std_listener,
1281 rustls_cfg,
1282 registry.clone(),
1283 max_request_body,
1284 max_response_body,
1285 Arc::clone(&inflight),
1286 Arc::clone(&rt),
1287 rid.clone(),
1288 ))
1289 } else {
1290 tls_rustls_cfg = None;
1291 tls_source = None;
1292 tokio::spawn(run_axum_server(
1293 listener,
1294 registry.clone(),
1295 max_request_body,
1296 max_response_body,
1297 Arc::clone(&inflight),
1298 Arc::clone(&rt),
1299 rid.clone(),
1300 ))
1301 };
1302 let addr_for_monitor = format!("{host_owned}:{port}");
1303 let server_abort = server_task.abort_handle();
1304 let monitor_task = tokio::spawn(monitor_axum_task(
1305 server_task,
1306 addr_for_monitor,
1307 Arc::clone(&rt),
1308 rid,
1309 server_exited,
1310 ));
1311 let handle = ServerHandle {
1312 registry,
1313 bound_addr,
1314 max_request_body,
1315 max_response_body,
1316 max_inflight_requests,
1317 is_tls: tls_config.is_some(),
1318 tls_cert_path: tls_config.as_ref().map(|t| t.cert_path.clone()),
1319 tls_key_path: tls_config.as_ref().map(|t| t.key_path.clone()),
1320 monitor_task,
1321 server_abort,
1322 tls_config: tls_rustls_cfg,
1323 tls_source,
1324 };
1325 if let (Some(tls_cfg), Some(source)) = (handle.tls_config.as_ref(), handle.tls_source.as_ref())
1330 {
1331 let handler = Arc::new(crate::tls_reload::HttpReloadHandler::new(
1332 tls_cfg.clone(),
1333 source.clone(),
1334 host_owned.clone(),
1335 port,
1336 ));
1337 camel_component_api::tls_source::TlsReloadRegistry::global().register(handler);
1338 }
1339 Ok(Arc::new(handle))
1340}
1341
1342use axum::{
1347 Router,
1348 body::Body as AxumBody,
1349 extract::{Request, State},
1350 http::{Response, StatusCode},
1351 response::IntoResponse,
1352};
1353
1354#[derive(Clone)]
1355pub(crate) struct AppState {
1356 registry: HttpRouteRegistry,
1357 max_request_body: usize,
1358 max_response_body: usize,
1359 inflight: Arc<tokio::sync::Semaphore>,
1360}
1361
1362const CONSUMER_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
1369
1370async fn run_axum_server(
1371 listener: tokio::net::TcpListener,
1372 registry: HttpRouteRegistry,
1373 max_request_body: usize,
1374 max_response_body: usize,
1375 inflight: Arc<tokio::sync::Semaphore>,
1376 runtime: Arc<dyn RuntimeObservability>,
1377 route_id: String,
1378) {
1379 let state = AppState {
1380 registry,
1381 max_request_body,
1382 max_response_body,
1383 inflight,
1384 };
1385 let app = Router::new()
1386 .fallback(dispatch_handler)
1387 .with_state(state)
1388 .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1389 StatusCode::REQUEST_TIMEOUT,
1390 CONSUMER_REQUEST_TIMEOUT,
1391 ));
1392
1393 axum::serve(listener, app).await.unwrap_or_else(|e| {
1394 runtime
1395 .metrics()
1396 .increment_errors(&route_id, "e:http:accept");
1397 tracing::error!(error = %e, "Axum server error");
1399 });
1400}
1401
1402#[allow(clippy::too_many_arguments)]
1403async fn run_axum_server_tls(
1404 listener: std::net::TcpListener,
1405 tls_cfg: axum_server::tls_rustls::RustlsConfig,
1406 registry: HttpRouteRegistry,
1407 max_request_body: usize,
1408 max_response_body: usize,
1409 inflight: Arc<tokio::sync::Semaphore>,
1410 runtime: Arc<dyn RuntimeObservability>,
1411 route_id: String,
1412) {
1413 let state = AppState {
1414 registry,
1415 max_request_body,
1416 max_response_body,
1417 inflight,
1418 };
1419 let app = Router::new()
1420 .fallback(dispatch_handler)
1421 .with_state(state)
1422 .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1423 StatusCode::REQUEST_TIMEOUT,
1424 CONSUMER_REQUEST_TIMEOUT,
1425 ));
1426
1427 let server = match axum_server::from_tcp_rustls(listener, tls_cfg) {
1432 Ok(server) => server,
1433 Err(e) => {
1434 runtime
1435 .metrics()
1436 .increment_errors(&route_id, "e:http:accept-tls");
1437 tracing::error!(error = %e, "Axum TLS server setup error");
1439 return;
1440 }
1441 };
1442
1443 server
1444 .serve(app.into_make_service())
1445 .await
1446 .unwrap_or_else(|e| {
1447 runtime
1448 .metrics()
1449 .increment_errors(&route_id, "e:http:accept-tls");
1450 tracing::error!(error = %e, "Axum TLS server error");
1452 });
1453}
1454
1455async fn monitor_axum_task(
1465 handle: tokio::task::JoinHandle<()>,
1466 addr: String,
1467 runtime: Arc<dyn RuntimeObservability>,
1468 route_id: String,
1469 server_exited: tokio_util::sync::CancellationToken,
1470) {
1471 match handle.await {
1472 Ok(()) => {
1473 }
1475 Err(join_err) => {
1476 runtime
1477 .metrics()
1478 .increment_errors(&route_id, "e:http:server-task-exited");
1479 tracing::error!(
1481 addr = %addr,
1482 error = %join_err,
1483 "Axum server task exited unexpectedly — all routes on this port are now dead"
1484 );
1485 server_exited.cancel();
1489 }
1490 }
1491}
1492
1493fn load_tls_config(
1496 cert_path: &str,
1497 key_path: &str,
1498) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
1499 use std::fs::File;
1500 use std::io::BufReader;
1501
1502 let cert_file = File::open(cert_path)
1503 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
1504 let key_file = File::open(key_path)
1505 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
1506
1507 let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
1508 .collect::<Result<Vec<_>, _>>()
1509 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
1510
1511 let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
1512 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
1513 .ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
1514
1515 tokio_rustls::rustls::ServerConfig::builder()
1516 .with_no_client_auth()
1517 .with_single_cert(certs, key)
1518 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
1519}
1520
1521async fn dispatch_handler(State(state): State<AppState>, req: Request) -> impl IntoResponse {
1522 let path = req.uri().path().to_owned();
1523 let method = req.method().to_string();
1524
1525 let api_sender = {
1542 let inner = state.registry.inner.read().await;
1543 inner.api_routes.get(&path).cloned()
1544 }; let (rest_sender, path_params) = if api_sender.is_some() {
1547 (None, Default::default())
1549 } else {
1550 let inner = state.registry.inner.read().await;
1551 match rest_match::match_endpoint(&method, &path, &inner.rest_endpoints) {
1552 rest_match::MatchOutcome::Found(m) => (Some(m.payload), m.path_params),
1553 rest_match::MatchOutcome::Ambiguous => {
1554 tracing::warn!(
1560 method = %method,
1561 path = %path,
1562 "ambiguous REST template match — returning 500"
1563 );
1564 return Response::builder()
1565 .status(StatusCode::INTERNAL_SERVER_ERROR)
1566 .body(AxumBody::from("Internal Server Error"))
1567 .expect("infallible"); }
1569 rest_match::MatchOutcome::NotFound => (None, Default::default()),
1570 }
1571 }; let sender = api_sender.or(rest_sender);
1574
1575 if let Some(sender) = sender {
1576 let query = req.uri().query().unwrap_or("").to_string();
1577 let headers = req.headers().clone();
1578
1579 let content_length: Option<u64> = headers
1581 .get(http::header::CONTENT_LENGTH)
1582 .and_then(|v| v.to_str().ok())
1583 .and_then(|s| s.parse().ok());
1584
1585 if let Some(len) = content_length
1586 && len > state.max_request_body as u64
1587 {
1588 return Response::builder()
1589 .status(StatusCode::PAYLOAD_TOO_LARGE)
1590 .body(AxumBody::from("Request body exceeds configured limit"))
1591 .expect("infallible"); }
1593
1594 let _permit = match Arc::clone(&state.inflight).try_acquire_owned() {
1595 Ok(permit) => permit,
1596 Err(_) => {
1597 return Response::builder()
1598 .status(StatusCode::SERVICE_UNAVAILABLE)
1599 .body(AxumBody::from("Service Unavailable"))
1600 .expect("infallible"); }
1602 };
1603
1604 let content_type = headers
1610 .get(http::header::CONTENT_TYPE)
1611 .and_then(|v| v.to_str().ok())
1612 .map(|s| s.to_string());
1613
1614 let data_stream: BodyDataStream = req.into_body().into_data_stream();
1615 let max_body = state.max_request_body;
1616 let mut seen: u64 = 0;
1617 let capped_stream =
1618 data_stream
1619 .map_err(|e| CamelError::Io(e.to_string()))
1620 .map(move |chunk| match chunk {
1621 Ok(bytes) => {
1622 seen = seen.saturating_add(bytes.len() as u64);
1623 if seen > max_body as u64 {
1624 Err(CamelError::ProcessorError(format!(
1625 "Request body exceeds configured limit of {max_body} bytes"
1626 )))
1627 } else {
1628 Ok(bytes)
1629 }
1630 }
1631 Err(e) => Err(e),
1632 });
1633 let boxed: BoxStream<'static, Result<bytes::Bytes, CamelError>> = Box::pin(capped_stream);
1634
1635 let stream_body = StreamBody {
1636 stream: Arc::new(tokio::sync::Mutex::new(Some(boxed))),
1637 metadata: StreamMetadata {
1638 size_hint: content_length,
1639 content_type,
1640 origin: None,
1641 },
1642 };
1643
1644 let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<HttpReply>();
1645 let envelope = RequestEnvelope {
1646 method,
1647 path,
1648 query,
1649 headers,
1650 body: stream_body,
1651 path_params,
1652 reply_tx,
1653 };
1654
1655 if sender.send(envelope).await.is_err() {
1656 return Response::builder()
1657 .status(StatusCode::SERVICE_UNAVAILABLE)
1658 .body(AxumBody::from("Consumer unavailable"))
1659 .expect("infallible"); }
1661
1662 match reply_rx.await {
1663 Ok(reply) => {
1664 let reply = match reply.body {
1665 HttpReplyBody::Bytes(b)
1666 if exceeds_max_response_body(b.len(), state.max_response_body) =>
1667 {
1668 HttpReply {
1669 status: 500,
1670 headers: vec![],
1671 body: HttpReplyBody::Bytes(bytes::Bytes::from(
1672 "Response body exceeds configured limit",
1673 )),
1674 }
1675 }
1676 _ => reply,
1677 };
1678
1679 let status =
1680 StatusCode::from_u16(reply.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1681 let mut builder = Response::builder().status(status);
1682 for (k, v) in &reply.headers {
1683 builder = builder.header(k.as_str(), v.as_str());
1684 }
1685 match reply.body {
1686 HttpReplyBody::Bytes(b) => {
1687 builder.body(AxumBody::from(b)).unwrap_or_else(|_| {
1688 Response::builder()
1689 .status(StatusCode::INTERNAL_SERVER_ERROR)
1690 .body(AxumBody::from("Invalid response headers from consumer"))
1691 .expect("infallible") })
1693 }
1694 HttpReplyBody::Stream(stream) => builder
1695 .body(AxumBody::from_stream(stream))
1696 .unwrap_or_else(|_| {
1697 Response::builder()
1698 .status(StatusCode::INTERNAL_SERVER_ERROR)
1699 .body(AxumBody::from("Invalid response headers from consumer"))
1700 .expect("infallible") }),
1702 }
1703 }
1704 Err(_) => Response::builder()
1705 .status(StatusCode::INTERNAL_SERVER_ERROR)
1706 .body(AxumBody::from("Pipeline error"))
1707 .expect("infallible"), }
1709 } else {
1710 static_dispatch::dispatch_static(&state, req, &path).await
1712 }
1713}
1714
1715fn exceeds_max_response_body(len: usize, max: usize) -> bool {
1716 len > max
1717}
1718
1719fn title_case_header(name: &str) -> String {
1720 name.split('-')
1721 .map(|part| {
1722 let mut chars = part.chars();
1723 match chars.next() {
1724 None => String::new(),
1725 Some(first) => first.to_uppercase().chain(chars.as_str().chars()).collect(),
1726 }
1727 })
1728 .collect::<Vec<_>>()
1729 .join("-")
1730}
1731
1732pub(crate) struct HttpKernelAuth {
1747 pub(crate) plan: camel_api::security_policy::RouteSecurityPlan,
1748 pub(crate) providers: Arc<camel_auth::ProviderRegistry>,
1749}
1750
1751impl HttpKernelAuth {
1752 pub(crate) fn from_security_context(
1757 ctx: &camel_component_api::SecurityContext,
1758 ) -> Option<Self> {
1759 Some(Self {
1760 plan: ctx.plan.clone()?,
1761 providers: ctx.providers.clone()?,
1762 })
1763 }
1764}
1765
1766fn envelope_channel_capacity(max_inflight_requests: usize) -> usize {
1780 max_inflight_requests.max(1)
1781}
1782
1783pub struct HttpConsumer {
1784 config: HttpServerConfig,
1785 runtime: Arc<dyn RuntimeObservability>,
1787 kernel: Option<Arc<HttpKernelAuth>>,
1791}
1792
1793impl HttpConsumer {
1794 pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
1795 Self {
1796 config,
1797 runtime,
1798 kernel: None,
1799 }
1800 }
1801}
1802
1803#[async_trait::async_trait]
1804impl Consumer for HttpConsumer {
1805 async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
1806 use camel_component_api::{Body, Exchange, Message};
1807
1808 let registry = ServerRegistry::global()
1809 .get_or_spawn(
1810 &self.config.host,
1811 self.config.port,
1812 self.config.max_request_body,
1813 self.config.max_response_body,
1814 self.config.max_inflight_requests,
1815 self.runtime.clone(),
1816 ctx.route_id().to_string(),
1817 self.config.tls_config.clone(),
1818 )
1819 .await?;
1820
1821 let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(
1825 envelope_channel_capacity(self.config.max_inflight_requests),
1826 );
1827 if let Some(method) = self.config.method.clone() {
1834 let segments = rest_match::parse_path_template(&self.config.path);
1835 registry
1836 .register_rest_endpoint(method, segments, env_tx)
1837 .await;
1838 } else {
1839 registry
1840 .register_api_route(self.config.path.clone(), env_tx)
1841 .await;
1842 }
1843
1844 ctx.mark_ready();
1853
1854 let in_flight = ctx.in_flight_counter();
1858
1859 let path = self.config.path.clone();
1860 let registry_for_cleanup = registry.clone();
1861 let server_exited = registry.server_exited.clone();
1862 let cancel_token = ctx.cancel_token();
1863 let kernel = self.kernel.clone();
1864 let mut server_died = false;
1869 loop {
1870 tokio::select! {
1871 _ = ctx.cancelled() => {
1872 break;
1873 }
1874 _ = server_exited.cancelled() => {
1875 server_died = true;
1880 break;
1881 }
1882 envelope = env_rx.recv() => {
1883 let Some(envelope) = envelope else { break; };
1884
1885 let claim =
1893 in_flight.as_ref().map(camel_component_api::InFlightClaim::attach);
1894
1895 let mut msg = Message::default();
1897
1898 msg.set_header("CamelHttpMethod",
1900 serde_json::Value::String(envelope.method.clone()));
1901 msg.set_header("CamelHttpPath",
1902 serde_json::Value::String(envelope.path.clone()));
1903 msg.set_header("CamelHttpQuery",
1904 serde_json::Value::String(envelope.query.clone()));
1905
1906 for (param_name, param_value) in &envelope.path_params {
1913 msg.set_header(
1914 format!("CamelHttpPath_{param_name}"),
1915 serde_json::Value::String(param_value.clone()),
1916 );
1917 }
1918
1919 for (k, v) in &envelope.headers {
1921 if let Ok(val_str) = v.to_str() {
1922 msg.set_header(
1923 title_case_header(k.as_str()),
1924 serde_json::Value::String(val_str.to_string()),
1925 );
1926 }
1927 }
1928
1929 msg.body = Body::Stream(envelope.body);
1932
1933 #[allow(unused_mut)]
1934 let mut exchange = Exchange::new(msg);
1935
1936 #[cfg(feature = "otel")]
1938 {
1939 let headers: HashMap<String, String> = envelope
1940 .headers
1941 .iter()
1942 .filter_map(|(k, v)| {
1943 Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
1944 })
1945 .collect();
1946 camel_otel::extract_into_exchange(&mut exchange, &headers);
1947 }
1948
1949 let reply_tx = envelope.reply_tx;
1950 let sender = ctx.sender().clone();
1951 let path_clone = path.clone();
1952 let cancel = cancel_token.clone();
1953 let auth_headers = envelope.headers.clone();
1957 let auth_uri: http::Uri = {
1958 let full = if envelope.query.is_empty() {
1959 envelope.path.clone()
1960 } else {
1961 format!("{}?{}", envelope.path, envelope.query)
1962 };
1963 full.parse().unwrap_or_default()
1967 };
1968 let kernel = kernel.clone();
1969
1970 tokio::spawn(async move {
1990 if cancel.is_cancelled() {
1998 let _ = reply_tx.send(HttpReply {
1999 status: 503,
2000 headers: vec![],
2001 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
2002 });
2003 return;
2004 }
2005
2006 if let Some(kernel) = kernel.as_ref()
2015 && !matches!(
2016 kernel.plan.access_mode,
2017 camel_api::security_policy::AccessMode::Public
2018 )
2019 {
2020 let principal = match camel_auth::extract_token_multi(
2021 &auth_headers,
2022 &auth_uri,
2023 &kernel.plan.credential_sources,
2024 ) {
2025 Some(extracted) => {
2026 match camel_auth::kernel_authenticate(
2027 &kernel.plan,
2028 &kernel.providers,
2029 &extracted,
2030 )
2031 .await
2032 {
2033 Ok(principal) => principal,
2034 Err(e) => {
2035 tracing::warn!(
2037 path = %path_clone,
2038 error = %e,
2039 "HTTP request authentication failed"
2040 );
2041 let _ = reply_tx.send(pipeline_error_to_reply(
2042 e,
2043 &path_clone,
2044 ));
2045 return;
2046 }
2047 }
2048 }
2049 None => {
2050 tracing::warn!(
2052 path = %path_clone,
2053 "HTTP request rejected: no credential found in any source"
2054 );
2055 let _ = reply_tx.send(pipeline_error_to_reply(
2056 CamelError::Unauthenticated(
2057 "no credential found in any source".to_string(),
2058 ),
2059 &path_clone,
2060 ));
2061 return;
2062 }
2063 };
2064 camel_auth::install_carrier(&mut exchange, &principal);
2065 }
2066
2067 let (tx, rx) = tokio::sync::oneshot::channel();
2069 let envelope = camel_component_api::consumer::ExchangeEnvelope {
2070 exchange,
2071 reply_tx: Some(tx),
2072 in_flight_claim: claim,
2078 };
2079
2080 let result = match sender.send(envelope).await {
2081 Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
2082 Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
2083 }
2084 .and_then(|r| r);
2085
2086 let reply = match result {
2087 Ok(out) => {
2088 let status = out
2089 .input
2090 .header("CamelHttpResponseCode")
2091 .and_then(|v| {
2092 let raw = v.as_u64()
2093 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
2094 let code = raw as u16;
2095 (100..1000).contains(&code).then_some(code)
2096 })
2097 .unwrap_or(200);
2098
2099 let user_content_type = out
2100 .input
2101 .header("Content-Type")
2102 .and_then(|v| v.as_str().map(|s| s.to_string()));
2103
2104 let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
2105 Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
2106 Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
2107 Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
2108 Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
2109 v.to_string().into_bytes(),
2110 )), Some("application/json".to_string())),
2111 Body::Stream(s) => {
2112 let ct = s.metadata.content_type.clone();
2113 match s.stream.lock().await.take() {
2114 Some(stream) => (
2115 HttpReplyBody::Stream(stream),
2116 ct,
2117 ),
2118 None => {
2119 tracing::error!(
2121 "Body::Stream already consumed before HTTP reply — returning 500"
2122 );
2123 let error_reply = HttpReply {
2124 status: 500,
2125 headers: vec![],
2126 body: HttpReplyBody::Bytes(bytes::Bytes::new()),
2127 };
2128 if reply_tx.send(error_reply).is_err() {
2129 debug!("reply_tx dropped before error reply could be sent");
2130 }
2131 return;
2132 }
2133 }
2134 }
2135 _ => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
2137 };
2138
2139 let resp_headers = select_response_headers(
2140 &out.input.headers,
2141 user_content_type,
2142 inferred_content_type,
2143 );
2144
2145 HttpReply {
2146 status,
2147 headers: resp_headers,
2148 body: reply_body,
2149 }
2150 }
2151 Err(e) => {
2152 pipeline_error_to_reply(e, &path_clone)
2153 }
2154 };
2155
2156 let _ = reply_tx.send(reply);
2158 });
2159 }
2160 }
2161 }
2162
2163 if let Some(method) = &self.config.method {
2168 registry_for_cleanup
2169 .unregister_rest_endpoint(method, &path)
2170 .await;
2171 } else {
2172 registry_for_cleanup.unregister_api_route(&path).await;
2173 }
2174
2175 ServerRegistry::global()
2181 .unregister(&self.config.host, self.config.port)
2182 .await;
2183
2184 if server_died {
2185 tracing::error!(
2187 host = %camel_api::redact::redact_host(&self.config.host),
2188 port = self.config.port,
2189 path = %path,
2190 "Shared HTTP server exited — failing consumer to engage route supervision (ADR-0007)"
2191 );
2192 return Err(CamelError::RouteError(format!(
2196 "shared HTTP server for {}:{} exited unexpectedly; route transport is dead",
2197 camel_api::redact::redact_host(&self.config.host),
2198 self.config.port
2199 )));
2200 }
2201
2202 Ok(())
2203 }
2204
2205 async fn stop(&mut self) -> Result<(), CamelError> {
2206 Ok(())
2207 }
2208
2209 fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
2210 camel_component_api::ConcurrencyModel::Concurrent { max: None }
2211 }
2212
2213 fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
2220 camel_component_api::ConsumerStartupMode::Explicit
2221 }
2222
2223 fn set_security_context(&mut self, ctx: camel_component_api::SecurityContext) {
2226 self.kernel = HttpKernelAuth::from_security_context(&ctx).map(Arc::new);
2227 }
2228}
2229
2230pub struct HttpComponent {
2235 config: HttpConfig,
2236 pinned_cache: std::sync::Arc<PinnedClientCache>,
2237 client: reqwest::Client,
2238 strict_tls_error: Option<CamelError>,
2242}
2243
2244#[cfg(test)]
2245thread_local! {
2246 static BUILD_CLIENT_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
2247 static BUILD_CLIENT_FALLBACKS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
2248}
2249
2250fn client_builder(
2255 config: &HttpConfig,
2256 resolve_override: Option<(&str, &[std::net::SocketAddr])>,
2257) -> reqwest::ClientBuilder {
2258 let mut builder = reqwest::Client::builder()
2259 .no_proxy() .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
2261 .pool_max_idle_per_host(config.pool_max_idle_per_host)
2262 .pool_idle_timeout(Duration::from_millis(config.pool_idle_timeout_ms));
2263
2264 builder = builder.redirect(reqwest::redirect::Policy::none());
2268
2269 if let Some((host, addrs)) = resolve_override {
2270 builder = builder.resolve_to_addrs(host, addrs);
2271 }
2272
2273 if let Some(tls) = &config.tls
2274 && tls.enabled
2275 {
2276 if tls.insecure || !tls.verify_peer {
2277 tracing::warn!("HTTP TLS verification disabled — connections are vulnerable to MitM");
2279 builder = builder.danger_accept_invalid_certs(true);
2280 }
2281
2282 if let Some(ca_path) = &tls.ca_cert_path {
2283 match std::fs::read(ca_path) {
2288 Ok(ca_bytes) => {
2289 let pem_sections = rustls_pemfile::certs(&mut std::io::Cursor::new(&ca_bytes))
2296 .filter(|r| r.is_ok())
2297 .count();
2298 if pem_sections == 0 {
2299 tracing::warn!(
2301 "configured CA certificate contains no parseable PEM CERTIFICATE section — falling back to system roots"
2302 );
2303 }
2304 match reqwest::Certificate::from_pem(&ca_bytes)
2305 .or_else(|_| reqwest::Certificate::from_der(&ca_bytes))
2306 {
2307 Ok(ca_cert) => {
2308 builder = builder.add_root_certificate(ca_cert);
2309 }
2310 Err(e) => {
2311 tracing::warn!(
2313 error = %e,
2314 "configured CA certificate failed to parse — falling back to system roots"
2315 );
2316 }
2317 }
2318 }
2319 Err(e) => {
2320 tracing::warn!(
2322 error = %e,
2323 "configured CA certificate file unreadable — falling back to system roots"
2324 );
2325 }
2326 }
2327 }
2328
2329 if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path) {
2333 match (std::fs::read(cert_path), std::fs::read(key_path)) {
2334 (Ok(cert_bytes), Ok(key_bytes)) => {
2335 let mut identity_pem = cert_bytes;
2336 identity_pem.extend_from_slice(&key_bytes);
2337 match reqwest::Identity::from_pem(&identity_pem) {
2338 Ok(identity) => {
2339 builder = builder.identity(identity);
2340 }
2341 Err(e) => {
2342 tracing::warn!(
2344 error = %e,
2345 "configured mTLS identity failed to parse — client certificate NOT used"
2346 );
2347 }
2348 }
2349 }
2350 (cert_r, key_r) => {
2351 tracing::warn!(
2353 cert_ok = cert_r.is_ok(),
2354 key_ok = key_r.is_ok(),
2355 "configured mTLS cert/key file unreadable — client certificate NOT used"
2356 );
2357 }
2358 }
2359 }
2360 }
2361
2362 builder
2363}
2364
2365fn webpki_root_client_config() -> rustls::ClientConfig {
2380 let provider = rustls::crypto::CryptoProvider::get_default()
2385 .cloned()
2386 .unwrap_or_else(|| std::sync::Arc::new(rustls::crypto::aws_lc_rs::default_provider()));
2387 rustls::ClientConfig::builder_with_provider(provider)
2388 .with_safe_default_protocol_versions()
2389 .expect("stock rustls provider supports the safe default protocol versions") .with_root_certificates(rustls::RootCertStore {
2394 roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
2395 })
2396 .with_no_client_auth()
2397 }
2402
2403fn webpki_fallback_client(
2411 config: &HttpConfig,
2412 resolve_override: Option<(&str, &[std::net::SocketAddr])>,
2413 first_error: reqwest::Error,
2414) -> reqwest::Client {
2415 #[cfg(test)]
2416 BUILD_CLIENT_FALLBACKS.with(|c| c.set(c.get() + 1));
2417
2418 tracing::warn!(
2420 error = %first_error,
2421 "HTTP client build failed on platform TLS roots — the platform CA store \
2422 is missing or unreadable (typical on Android/Termux). Retrying with \
2423 bundled Mozilla root certificates; platform trust settings do not \
2424 apply to this client"
2425 );
2426 if let Some(tls) = &config.tls
2427 && tls.enabled
2428 && (tls.ca_cert_path.is_some()
2429 || tls.client_cert_path.is_some()
2430 || tls.client_key_path.is_some())
2431 {
2432 tracing::warn!(
2437 "configured TLS material (custom CA / mTLS identity) is not carried \
2438 into the webpki fallback client — the material failed to load or \
2439 the platform verifier rejected it"
2440 );
2441 }
2442
2443 match client_builder(config, resolve_override)
2444 .tls_backend_preconfigured(webpki_root_client_config())
2445 .build()
2446 {
2447 Ok(client) => client,
2448 Err(second_error) => {
2449 tracing::error!(
2457 error = %second_error,
2458 "webpki fallback client build failed — TLS stack broken process-wide"
2459 );
2460 reqwest::Client::builder()
2461 .no_proxy()
2462 .redirect(reqwest::redirect::Policy::none())
2463 .tls_backend_preconfigured(webpki_root_client_config())
2464 .build()
2465 .expect("preconfigured webpki-rooted client build has no fallible stage") }
2467 }
2468}
2469
2470pub(crate) fn build_client(
2475 config: &HttpConfig,
2476 resolve_override: Option<(&str, &[std::net::SocketAddr])>,
2477) -> reqwest::Client {
2478 #[cfg(test)]
2479 BUILD_CLIENT_CALLS.with(|c| c.set(c.get() + 1));
2480
2481 match client_builder(config, resolve_override).build() {
2482 Ok(client) => client,
2483 Err(first_error) => webpki_fallback_client(config, resolve_override, first_error),
2484 }
2485}
2486
2487fn strict_tls_error(config: &HttpConfig) -> Option<CamelError> {
2496 let tls = config.tls.as_ref()?;
2497 if !tls.enabled || !tls.strict {
2498 return None;
2499 }
2500 if let Some(ca_path) = &tls.ca_cert_path {
2501 match std::fs::read(ca_path) {
2502 Ok(ca_bytes) => {
2503 let pem_sections = rustls_pemfile::certs(&mut std::io::Cursor::new(&ca_bytes))
2515 .filter(|r| r.is_ok())
2516 .count();
2517 if pem_sections == 0 {
2518 return Some(CamelError::EndpointCreationFailed(format!(
2519 "tls.strict: configured CA certificate '{ca_path}' has no \
2520 parseable PEM CERTIFICATE section (DER bundles are not \
2521 enforced by the TLS backend — convert to PEM)"
2522 )));
2523 }
2524 }
2525 Err(e) => {
2526 return Some(CamelError::EndpointCreationFailed(format!(
2527 "tls.strict: configured CA certificate '{ca_path}' is unreadable: {e}"
2528 )));
2529 }
2530 }
2531 }
2532 if tls.client_cert_path.is_some() != tls.client_key_path.is_some() {
2536 return Some(CamelError::EndpointCreationFailed(
2537 "tls.strict: mTLS requires BOTH client_cert_path and client_key_path".to_string(),
2538 ));
2539 }
2540 if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path) {
2541 match (std::fs::read(cert_path), std::fs::read(key_path)) {
2542 (Ok(mut cert_bytes), Ok(key_bytes)) => {
2543 cert_bytes.extend_from_slice(&key_bytes);
2544 if reqwest::Identity::from_pem(&cert_bytes).is_err() {
2545 return Some(CamelError::EndpointCreationFailed(
2546 "tls.strict: configured mTLS identity failed to parse".to_string(),
2547 ));
2548 }
2549 }
2550 _ => {
2551 return Some(CamelError::EndpointCreationFailed(
2552 "tls.strict: configured mTLS cert/key files are unreadable".to_string(),
2553 ));
2554 }
2555 }
2556 }
2557 None
2558}
2559
2560#[cfg(test)]
2561pub(crate) fn build_client_call_count() -> u64 {
2562 BUILD_CLIENT_CALLS.with(|c| c.get())
2563}
2564
2565#[cfg(test)]
2566pub(crate) fn build_client_fallback_count() -> u64 {
2567 BUILD_CLIENT_FALLBACKS.with(|c| c.get())
2568}
2569
2570impl HttpComponent {
2571 pub fn new() -> Self {
2572 let config = HttpConfig::default();
2573 let strict_err = strict_tls_error(&config);
2574 Self {
2575 client: build_client(&config, None),
2576 config,
2577 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2578 PINNED_CLIENT_TTL,
2579 PINNED_CLIENT_MAX_ENTRIES,
2580 )),
2581 strict_tls_error: strict_err,
2582 }
2583 }
2584
2585 pub fn with_config(config: HttpConfig) -> Self {
2586 let strict_err = strict_tls_error(&config);
2587 Self {
2588 client: build_client(&config, None),
2589 config,
2590 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2591 PINNED_CLIENT_TTL,
2592 PINNED_CLIENT_MAX_ENTRIES,
2593 )),
2594 strict_tls_error: strict_err,
2595 }
2596 }
2597
2598 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2599 match config {
2600 Some(cfg) => Self::with_config(cfg),
2601 None => Self::new(),
2602 }
2603 }
2604}
2605
2606impl Default for HttpComponent {
2607 fn default() -> Self {
2608 Self::new()
2609 }
2610}
2611
2612impl Component for HttpComponent {
2613 fn scheme(&self) -> &str {
2614 "http"
2615 }
2616
2617 fn metadata(&self) -> ComponentMetadata {
2618 HttpEndpointConfig::metadata()
2619 }
2620
2621 fn create_endpoint(
2622 &self,
2623 uri: &str,
2624 ctx: &dyn camel_component_api::ComponentContext,
2625 ) -> Result<Box<dyn Endpoint>, CamelError> {
2626 if let Some(err) = &self.strict_tls_error {
2627 return Err(err.clone());
2628 }
2629 self.config.validate()?;
2630 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2631 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2632 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2633 server_config.host.clone(),
2634 server_config.port,
2635 )));
2636 self.pinned_cache
2637 .wire(HttpComponentKind::Http, ctx.metrics());
2638 Ok(Box::new(HttpEndpoint {
2639 uri: uri.to_string(),
2640 config,
2641 server_config,
2642 client: self.client.clone(),
2643 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2644 http_config: self.config.clone(),
2645 }))
2646 }
2647}
2648
2649pub struct HttpsComponent {
2650 config: HttpConfig,
2651 pinned_cache: std::sync::Arc<PinnedClientCache>,
2652 client: reqwest::Client,
2653 strict_tls_error: Option<CamelError>,
2657}
2658
2659impl HttpsComponent {
2660 pub fn new() -> Self {
2661 let config = HttpConfig::default();
2662 let strict_err = strict_tls_error(&config);
2663 Self {
2664 client: build_client(&config, None),
2665 config,
2666 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2667 PINNED_CLIENT_TTL,
2668 PINNED_CLIENT_MAX_ENTRIES,
2669 )),
2670 strict_tls_error: strict_err,
2671 }
2672 }
2673
2674 pub fn with_config(config: HttpConfig) -> Self {
2675 let strict_err = strict_tls_error(&config);
2676 Self {
2677 client: build_client(&config, None),
2678 config,
2679 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2680 PINNED_CLIENT_TTL,
2681 PINNED_CLIENT_MAX_ENTRIES,
2682 )),
2683 strict_tls_error: strict_err,
2684 }
2685 }
2686
2687 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2688 match config {
2689 Some(cfg) => Self::with_config(cfg),
2690 None => Self::new(),
2691 }
2692 }
2693}
2694
2695impl Default for HttpsComponent {
2696 fn default() -> Self {
2697 Self::new()
2698 }
2699}
2700
2701impl Component for HttpsComponent {
2702 fn scheme(&self) -> &str {
2703 "https"
2704 }
2705
2706 fn metadata(&self) -> ComponentMetadata {
2707 let mut meta = HttpEndpointConfig::metadata();
2710 meta.scheme = "https".to_string();
2711 meta.description = "HTTPS client and server component (TLS over HTTP)".to_string();
2712 meta
2713 }
2714
2715 fn create_endpoint(
2716 &self,
2717 uri: &str,
2718 ctx: &dyn camel_component_api::ComponentContext,
2719 ) -> Result<Box<dyn Endpoint>, CamelError> {
2720 if let Some(err) = &self.strict_tls_error {
2721 return Err(err.clone());
2722 }
2723 self.config.validate()?;
2724 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2725 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2726 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2727 server_config.host.clone(),
2728 server_config.port,
2729 )));
2730 self.pinned_cache
2731 .wire(HttpComponentKind::Https, ctx.metrics());
2732 Ok(Box::new(HttpEndpoint {
2733 uri: uri.to_string(),
2734 config,
2735 server_config,
2736 client: self.client.clone(),
2737 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2738 http_config: self.config.clone(),
2739 }))
2740 }
2741}
2742
2743struct HttpEndpoint {
2748 uri: String,
2749 config: HttpEndpointConfig,
2750 server_config: HttpServerConfig,
2751 client: reqwest::Client,
2752 pinned_cache: std::sync::Arc<PinnedClientCache>,
2753 http_config: HttpConfig,
2754}
2755
2756impl Endpoint for HttpEndpoint {
2757 fn uri(&self) -> &str {
2758 &self.uri
2759 }
2760
2761 fn create_consumer(
2762 &self,
2763 rt: Arc<dyn camel_component_api::RuntimeObservability>,
2764 ) -> Result<Box<dyn Consumer>, CamelError> {
2765 let scheme_is_https = self.server_config.scheme == "https";
2768 let has_tls = self.server_config.tls_config.is_some();
2769
2770 if scheme_is_https && !has_tls {
2771 return Err(CamelError::EndpointCreationFailed(
2772 "https:// consumer requires tlsCert and tlsKey parameters".to_string(),
2773 ));
2774 }
2775 if !scheme_is_https && has_tls {
2776 return Err(CamelError::EndpointCreationFailed(
2777 "http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
2778 ));
2779 }
2780 Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
2781 }
2782
2783 fn create_producer(
2784 &self,
2785 rt: Arc<dyn camel_component_api::RuntimeObservability>,
2786 _ctx: &ProducerContext,
2787 ) -> Result<BoxProcessor, CamelError> {
2788 let producer = HttpProducer {
2789 config: Arc::new(self.config.clone()),
2790 client: self.client.clone(),
2791 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2792 http_config: Arc::new(self.http_config.clone()),
2793 runtime: rt,
2794 };
2795 if let Some(ref provider) = self.config.token_provider {
2796 let layer = BearerTokenLayer::new(Arc::clone(provider));
2797 Ok(BoxProcessor::new(layer.layer(producer)))
2798 } else {
2799 Ok(BoxProcessor::new(producer))
2800 }
2801 }
2802}
2803
2804#[derive(Clone)]
2809struct HttpProducer {
2810 config: Arc<HttpEndpointConfig>,
2811 client: reqwest::Client,
2812 pinned_cache: std::sync::Arc<PinnedClientCache>,
2813 http_config: Arc<HttpConfig>,
2814 runtime: Arc<dyn RuntimeObservability>,
2820}
2821
2822impl HttpProducer {
2823 fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
2824 if let Some(ref method) = config.http_method {
2825 return method.to_uppercase();
2826 }
2827 if let Some(method) = exchange
2828 .input
2829 .header("CamelHttpMethod")
2830 .and_then(|v| v.as_str())
2831 {
2832 return method.to_uppercase();
2833 }
2834 if !exchange.input.body.is_empty() {
2835 return "POST".to_string();
2836 }
2837 "GET".to_string()
2838 }
2839
2840 fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> Result<String, CamelError> {
2841 if config.bridge_endpoint {
2850 let Some(query) = resolve_endpoint_query(config)? else {
2851 return Ok(config.base_url.clone());
2852 };
2853 let _: url::Url = url::Url::parse(&config.base_url).map_err(|e| {
2861 CamelError::ProcessorError(format!(
2862 "invalid base URL '{}': {e}",
2863 redact_url_for_diagnostics(&config.base_url)
2864 ))
2865 })?;
2866 let mut url = config.base_url.clone();
2867 url.push('?');
2868 url.push_str(&query);
2869 return Ok(url);
2870 }
2871
2872 if let Some(uri) = exchange
2873 .input
2874 .header("CamelHttpUri")
2875 .and_then(|v| v.as_str())
2876 {
2877 if let Some(fence) = &config.allowed_uri_hosts
2882 && !uri_host_allowed(uri, fence)?
2883 {
2884 return Err(CamelError::ProcessorError(format!(
2885 "CamelHttpUri host not allowed by allowedUriHosts fence: {}",
2886 redact_url_for_diagnostics(uri)
2887 )));
2888 }
2889 let (base, override_query) = match uri.split_once('?') {
2896 Some((base, query)) => (base, Some(query)),
2897 None => (uri, None),
2898 };
2899 if let Some(query) = override_query {
2905 for (_key, span) in raw_query_pairs(query)? {
2906 validate_raw_query_span(span)?;
2907 }
2908 }
2909 let mut url = base.to_string();
2910 if let Some(path) = exchange
2911 .input
2912 .header("CamelHttpPath")
2913 .and_then(|v| v.as_str())
2914 {
2915 if !url.ends_with('/') && !path.starts_with('/') {
2916 url.push('/');
2917 }
2918 url.push_str(path);
2919 }
2920 if let Some(query) = exchange
2921 .input
2922 .header("CamelHttpQuery")
2923 .and_then(|v| v.as_str())
2924 {
2925 if let Some(merged) = merge_header_query(override_query, query)? {
2926 url.push('?');
2927 url.push_str(&merged);
2928 }
2929 return Ok(url);
2930 }
2931 if let Some(query) = override_query {
2932 url.push('?');
2933 url.push_str(query);
2934 }
2935 return Ok(url);
2936 }
2937
2938 let mut url = config.base_url.clone();
2939
2940 if let Some(path) = exchange
2941 .input
2942 .header("CamelHttpPath")
2943 .and_then(|v| v.as_str())
2944 {
2945 if !url.ends_with('/') && !path.starts_with('/') {
2946 url.push('/');
2947 }
2948 url.push_str(path);
2949 }
2950
2951 if let Some(query) = exchange
2952 .input
2953 .header("CamelHttpQuery")
2954 .and_then(|v| v.as_str())
2955 {
2956 if let Some(merged) =
2961 merge_header_query(resolve_endpoint_query(config)?.as_deref(), query)?
2962 {
2963 url.push('?');
2964 url.push_str(&merged);
2965 }
2966 return Ok(url);
2967 }
2968
2969 if let Some(query) = resolve_endpoint_query(config)? {
2970 url.push('?');
2971 url.push_str(&query);
2972 }
2973
2974 Ok(url)
2975 }
2976
2977 fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
2978 status >= range.0 && status <= range.1
2979 }
2980}
2981
2982#[derive(Clone, Debug, PartialEq, Eq)]
2987pub struct AllowedUriHost {
2988 pub host: String,
2990 pub port: Option<u16>,
2992}
2993
2994fn parse_allowed_uri_hosts(raw: &str) -> Result<Vec<AllowedUriHost>, CamelError> {
3002 let mut entries = Vec::new();
3003 for segment in raw.split(',') {
3004 let segment = segment.trim();
3005 if segment.is_empty() {
3006 continue;
3007 }
3008 let parsed = url::Url::parse(&format!("http://{segment}"))
3009 .map_err(|_| invalid_allowed_uri_host_entry(segment))?;
3010 if parsed.path() != "/" || !parsed.username().is_empty() || parsed.password().is_some() {
3014 return Err(invalid_allowed_uri_host_entry(segment));
3015 }
3016 let Some(host) = parsed.host_str() else {
3017 return Err(invalid_allowed_uri_host_entry(segment));
3018 };
3019 entries.push(AllowedUriHost {
3020 host: host.to_string(),
3021 port: parsed.port(),
3022 });
3023 }
3024 if entries.is_empty() {
3025 return Err(CamelError::InvalidUri(
3026 "allowedUriHosts declares no valid host entries".to_string(),
3027 ));
3028 }
3029 Ok(entries)
3030}
3031
3032fn invalid_allowed_uri_host_entry(segment: &str) -> CamelError {
3033 CamelError::InvalidUri(format!("invalid allowedUriHosts entry '{segment}'"))
3034}
3035
3036pub(crate) fn uri_host_allowed(
3043 url_str: &str,
3044 fence: &[AllowedUriHost],
3045) -> Result<bool, CamelError> {
3046 let Ok(parsed) = url::Url::parse(url_str) else {
3047 return Ok(false);
3048 };
3049 let Some(host) = parsed.host_str() else {
3050 return Ok(false);
3051 };
3052 let effective_port = parsed.port().or(match parsed.scheme() {
3053 "https" => Some(443_u16),
3054 "http" => Some(80),
3055 _ => None,
3056 });
3057 Ok(fence.iter().any(|entry| {
3058 entry.host == host
3059 && match entry.port {
3060 None => true,
3061 Some(port) => effective_port == Some(port),
3062 }
3063 }))
3064}
3065
3066fn resolve_endpoint_query(config: &HttpEndpointConfig) -> Result<Option<String>, CamelError> {
3079 let mut parts: Vec<String> = Vec::new();
3080 let mut authored_keys = std::collections::HashSet::new();
3081
3082 if let Some(raw) = config.raw_query.as_deref() {
3083 for (key, span) in raw_query_pairs(raw)? {
3084 authored_keys.insert(key.clone());
3085 if is_consumed_option(&key) {
3086 continue;
3087 }
3088 validate_raw_query_span(span)?;
3089 parts.push(span.to_string());
3090 }
3091 }
3092
3093 for (key, value) in &config.query_params {
3094 if !authored_keys.contains(key.as_str()) {
3095 parts.push(format!(
3096 "{}={}",
3097 encode_query_component(key),
3098 encode_query_component(value)
3099 ));
3100 }
3101 }
3102
3103 if parts.is_empty() && config.raw_query.as_deref() != Some("") {
3104 return Ok(None);
3105 }
3106 Ok(Some(parts.join("&")))
3107}
3108
3109fn merge_header_query(
3118 higher_precedence: Option<&str>,
3119 header_query: &str,
3120) -> Result<Option<String>, CamelError> {
3121 if header_query.is_empty() {
3122 return Ok(higher_precedence.map(str::to_string));
3123 }
3124 let mut parts: Vec<String> = Vec::new();
3125 let mut higher_keys = std::collections::HashSet::new();
3126 for (key, span) in raw_query_pairs(higher_precedence.unwrap_or(""))? {
3127 higher_keys.insert(key);
3128 parts.push(span.to_string());
3129 }
3130 for (key, span) in raw_query_pairs(header_query)? {
3131 validate_raw_query_span(span)?;
3132 if !higher_keys.contains(key.as_str()) {
3133 parts.push(span.to_string());
3134 }
3135 }
3136 if parts.is_empty() {
3137 return Ok(None);
3138 }
3139 Ok(Some(parts.join("&")))
3140}
3141
3142fn is_legal_query_byte(byte: u8) -> bool {
3153 matches!(byte,
3154 b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z'
3155 | b'-' | b'.' | b'_' | b'~'
3156 | b'!' | b'$' | b'&' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'='
3157 | b':' | b'@' | b'/' | b'?'
3158 | b'%')
3159}
3160
3161fn validate_raw_query_span(span: &str) -> Result<(), CamelError> {
3167 for &byte in span.as_bytes() {
3168 if !is_legal_query_byte(byte) {
3169 return Err(CamelError::ProcessorError(format!(
3170 "raw query pair '{span}' contains byte 0x{byte:02X}, which is not legal in a URL query component"
3171 )));
3172 }
3173 }
3174 Ok(())
3175}
3176
3177fn encode_query_component(component: &str) -> String {
3181 const HEX: &[u8; 16] = b"0123456789ABCDEF";
3182 let mut out = String::with_capacity(component.len());
3183 for &byte in component.as_bytes() {
3184 match byte {
3185 b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z' | b'-' | b'.' | b'_' | b'~' => {
3186 out.push(byte as char);
3187 }
3188 _ => {
3189 out.push('%');
3190 out.push(HEX[(byte >> 4) as usize] as char);
3191 out.push(HEX[(byte & 0x0f) as usize] as char);
3192 }
3193 }
3194 }
3195 out
3196}
3197
3198pub(crate) fn redact_url_for_diagnostics(raw: &str) -> String {
3226 match url::Url::parse(raw) {
3227 Ok(mut u) => {
3228 if !u.cannot_be_a_base()
3235 && u.host_str().is_none()
3236 && camel_api::redact::window_has_at_sign(raw)
3237 {
3238 return "[redacted]".to_string();
3239 }
3240 if !u.username().is_empty() || u.password().is_some() {
3241 let _ = u.set_username("***");
3242 let _ = u.set_password(None);
3243 }
3244 let s = u.to_string();
3247 camel_api::redact::redact_url(&s)
3248 }
3249 Err(_) => camel_api::redact::redact_url_fail_closed(raw),
3250 }
3251}
3252
3253const MAX_ERROR_RESPONSE_BODY_BYTES: usize = 4096;
3258
3259fn truncate_error_body(body: &[u8]) -> String {
3260 if body.len() <= MAX_ERROR_RESPONSE_BODY_BYTES {
3261 String::from_utf8_lossy(body).into_owned()
3262 } else {
3263 let mut s = String::from_utf8_lossy(&body[..MAX_ERROR_RESPONSE_BODY_BYTES]).into_owned();
3264 s.push_str("...[truncated]");
3265 s
3266 }
3267}
3268
3269impl HttpProducer {
3270 fn is_entity_enclosing(method: &str) -> bool {
3275 matches!(method, "POST" | "PUT" | "PATCH")
3276 }
3277}
3278
3279impl Service<Exchange> for HttpProducer {
3280 type Response = Exchange;
3281 type Error = CamelError;
3282 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
3283
3284 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
3285 Poll::Ready(Ok(()))
3286 }
3287
3288 fn call(&mut self, exchange: Exchange) -> Self::Future {
3289 let config = self.config.clone();
3290 let shared_client = self.client.clone();
3291 let pinned_cache = std::sync::Arc::clone(&self.pinned_cache);
3292 let http_config = self.http_config.clone();
3293 let component_metrics = self.runtime.component_metrics();
3294
3295 Box::pin(async move {
3296 let mut exchange = exchange;
3297 let outcome = async {
3298 let method_str = HttpProducer::resolve_method(&exchange, &config);
3299 let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
3304 let url = HttpProducer::resolve_url(&exchange, &config)?;
3305
3306 ssrf::validate_url_for_ssrf(&url, &config)?;
3308
3309 let resolved = ssrf::resolve_initial_url_for_ssrf(
3317 &url,
3318 config.allow_internal,
3319 config.allow_cleartext,
3320 )
3321 .await?;
3322 let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
3323 pinned_cache
3324 .get_or_build(host.as_str(), addrs, || {
3325 build_client(&http_config, Some((host.as_str(), addrs)))
3326 })
3327 .await
3328 } else {
3329 shared_client.clone()
3330 };
3331
3332 debug!(
3333 correlation_id = %exchange.correlation_id(),
3334 method = %method_str,
3335 url = %redact_url_for_diagnostics(&url),
3336 "HTTP request"
3337 );
3338
3339 let method = method_str.parse::<reqwest::Method>().map_err(|e| {
3340 CamelError::ProcessorError(format!(
3341 "Invalid HTTP method '{}': {}",
3342 method_str, e
3343 ))
3344 })?;
3345
3346 let mut collected_headers: Vec<(
3348 reqwest::header::HeaderName,
3349 reqwest::header::HeaderValue,
3350 )> = Vec::new();
3351
3352 if let Some(user_agent) = &config.user_agent
3353 && !config.bridge_endpoint
3354 {
3355 match constructed_header("user-agent", user_agent) {
3356 Ok((_, val)) => {
3357 collected_headers.push((reqwest::header::USER_AGENT, val));
3358 }
3359 Err(drop) => debug!(
3360 correlation_id = %exchange.correlation_id(),
3361 header = %drop.name,
3362 "outbound header dropped: {}",
3363 drop.reason
3364 ),
3365 }
3366 }
3367
3368 #[cfg(feature = "otel")]
3370 let should_inject_otel = !config.bridge_endpoint;
3371 #[cfg(feature = "otel")]
3372 if should_inject_otel {
3373 let mut otel_headers = HashMap::new();
3374 camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
3375 for (k, v) in otel_headers {
3376 match constructed_header(&k, &v) {
3377 Ok((name, val)) => collected_headers.push((name, val)),
3378 Err(drop) => debug!(
3379 correlation_id = %exchange.correlation_id(),
3380 header = %drop.name,
3381 "outbound header dropped: {}",
3382 drop.reason
3383 ),
3384 }
3385 }
3386 }
3387
3388 let conn_tokens = header_policy::connection_tokens(
3389 exchange
3390 .input
3391 .headers
3392 .iter()
3393 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3394 .filter_map(|(_, v)| v.as_str()),
3395 );
3396
3397 let outbound = select_outbound_headers(
3398 &exchange.input.headers,
3399 &config.skip_request_headers,
3400 &conn_tokens,
3401 );
3402 for drop in &outbound.drops {
3403 if let Some(value_kind) = drop.value_kind {
3404 debug!(
3405 correlation_id = %exchange.correlation_id(),
3406 header = %drop.name,
3407 value_kind = value_kind,
3408 "outbound header dropped: {}",
3409 drop.reason
3410 );
3411 } else {
3412 debug!(
3413 correlation_id = %exchange.correlation_id(),
3414 header = %drop.name,
3415 "outbound header dropped: {}",
3416 drop.reason
3417 );
3418 }
3419 }
3420 collected_headers.extend(outbound.accepted);
3421
3422 if !config.bridge_endpoint {
3424 match &config.auth {
3425 HttpAuth::None => {}
3426 HttpAuth::Basic { username, password } => {
3427 use base64::Engine;
3428 let credentials = format!("{username}:{password}");
3430 let encoded =
3431 base64::engine::general_purpose::STANDARD.encode(credentials);
3432 match constructed_header("authorization", &format!("Basic {encoded}")) {
3435 Ok((_, val)) => {
3436 collected_headers.push((reqwest::header::AUTHORIZATION, val));
3437 }
3438 Err(drop) => debug!(
3439 correlation_id = %exchange.correlation_id(),
3440 header = %drop.name,
3441 "outbound header dropped: {}",
3442 drop.reason
3443 ),
3444 }
3445 }
3446 HttpAuth::Bearer { token } => {
3447 let bearer = format!("Bearer {token}");
3449 match constructed_header("authorization", &bearer) {
3450 Ok((_, val)) => {
3451 collected_headers.push((reqwest::header::AUTHORIZATION, val));
3452 }
3453 Err(drop) => debug!(
3454 correlation_id = %exchange.correlation_id(),
3455 header = %drop.name,
3456 "outbound header dropped: {}",
3457 drop.reason
3458 ),
3459 }
3460 }
3461 }
3462
3463 if config.connection_close {
3464 collected_headers.push((
3465 reqwest::header::CONNECTION,
3466 reqwest::header::HeaderValue::from_static("close"),
3467 ));
3468 }
3469 }
3470
3471 let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
3473 let materialized_body: Option<Vec<u8>> = if is_stream_body {
3474 if suppress_body {
3475 std::mem::take(&mut exchange.input.body);
3483 tracing::warn!(
3485 correlation_id = %exchange.correlation_id(),
3486 method = %method_str,
3487 "dropping request body for non-entity-enclosing HTTP method"
3488 );
3489 }
3490 None } else {
3492 let body = std::mem::take(&mut exchange.input.body);
3493 let bytes = body.into_bytes(config.max_body_size).await?;
3494 if bytes.is_empty() {
3495 None
3497 } else if suppress_body {
3498 tracing::warn!(
3500 correlation_id = %exchange.correlation_id(),
3501 method = %method_str,
3502 "dropping request body for non-entity-enclosing HTTP method"
3503 );
3504 None
3505 } else {
3506 Some(bytes.to_vec())
3507 }
3508 };
3509
3510 let response = if config.follow_redirects && !is_stream_body {
3511 ssrf::send_with_ssrf_safe_redirects(
3517 &client,
3518 &shared_client,
3519 &pinned_cache,
3520 &http_config,
3521 &config,
3522 method,
3523 &url,
3524 collected_headers,
3525 materialized_body,
3526 config.max_redirects,
3527 config.response_timeout,
3528 )
3529 .await?
3530 } else {
3531 let mut request = client.request(method, &url);
3533
3534 if let Some(timeout) = config.response_timeout {
3535 request = request.timeout(timeout);
3536 }
3537
3538 for (name, value) in &collected_headers {
3539 request = request.header(name, value);
3540 }
3541
3542 if is_stream_body {
3543 if let Body::Stream(ref s) = exchange.input.body {
3544 let mut stream_lock = s.stream.lock().await;
3545 if let Some(stream) = stream_lock.take() {
3546 request = request.body(reqwest::Body::wrap_stream(stream));
3547 } else {
3548 return Err(CamelError::AlreadyConsumed);
3549 }
3550 }
3551 } else if let Some(ref body_bytes) = materialized_body {
3552 request = request.body(body_bytes.clone());
3553 }
3554
3555 request.send().await.map_err(|e| {
3556 CamelError::ProcessorError(format!("HTTP request failed: {e}"))
3557 })?
3558 };
3559
3560 let status_code = response.status().as_u16();
3561 let status_text = response
3562 .status()
3563 .canonical_reason()
3564 .unwrap_or("Unknown")
3565 .to_string();
3566
3567 for (key, value) in response.headers() {
3568 if config
3569 .skip_response_headers
3570 .iter()
3571 .any(|h| h.eq_ignore_ascii_case(key.as_str()))
3572 {
3573 continue;
3574 }
3575 if let Ok(val_str) = value.to_str() {
3576 exchange.input.set_header(
3577 title_case_header(key.as_str()),
3578 serde_json::Value::String(val_str.to_string()),
3579 );
3580 }
3581 }
3582
3583 exchange.input.set_header(
3584 "CamelHttpResponseCode",
3585 serde_json::Value::Number(status_code.into()),
3586 );
3587 exchange.input.set_header(
3588 "CamelHttpResponseText",
3589 serde_json::Value::String(status_text.clone()),
3590 );
3591
3592 let read_timeout = Duration::from_millis(config.read_timeout_ms);
3594 let response_body = tokio::time::timeout(read_timeout, async {
3595 if let Some(content_len) = response.content_length()
3597 && content_len > config.max_response_bytes as u64
3598 {
3599 return Err(CamelError::ProcessorError(format!(
3600 "Response body too large: {} bytes exceeds limit of {} bytes",
3601 content_len, config.max_response_bytes
3602 )));
3603 }
3604 use futures::TryStreamExt;
3606 let mut stream = response.bytes_stream();
3607 let mut total: usize = 0;
3608 let mut collected = Vec::new();
3609 while let Some(chunk) = stream.try_next().await.map_err(|e| {
3610 CamelError::ProcessorError(format!("Failed to read response body: {e}"))
3611 })? {
3612 total += chunk.len();
3613 if total > config.max_response_bytes {
3614 return Err(CamelError::ProcessorError(format!(
3615 "Response body too large: {} bytes exceeds limit of {} bytes",
3616 total, config.max_response_bytes
3617 )));
3618 }
3619 collected.push(chunk);
3620 }
3621 let mut result = bytes::BytesMut::with_capacity(total);
3622 for chunk in collected {
3623 result.extend_from_slice(&chunk);
3624 }
3625 Ok::<bytes::Bytes, CamelError>(result.freeze())
3626 })
3627 .await
3628 .map_err(|_| {
3629 CamelError::ProcessorError(format!(
3630 "Read timeout after {}ms",
3631 config.read_timeout_ms
3632 ))
3633 })??;
3634
3635 if config.throw_exception_on_failure
3636 && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
3637 {
3638 return Err(CamelError::HttpOperationFailed {
3639 method: method_str,
3640 url: redact_url_for_diagnostics(&url),
3643 status_code,
3644 status_text,
3645 response_body: Some(truncate_error_body(&response_body)),
3646 });
3647 }
3648
3649 if !response_body.is_empty() {
3650 exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
3651 }
3652
3653 debug!(
3654 correlation_id = %exchange.correlation_id(),
3655 status = status_code,
3656 url = %redact_url_for_diagnostics(&url),
3657 "HTTP response"
3658 );
3659 Ok(exchange)
3660 }
3661 .await;
3662 component_metrics.observe("http", "request", outcome.is_err());
3669 outcome
3670 })
3671 }
3672}
3673
3674#[cfg(test)]
3688pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
3689
3690#[cfg(test)]
3698pub(crate) fn lock_registry_test_mutex() -> std::sync::MutexGuard<'static, ()> {
3699 REGISTRY_TEST_MUTEX
3700 .lock()
3701 .unwrap_or_else(|poisoned| poisoned.into_inner())
3702}
3703
3704#[cfg(test)]
3708static CA_STORE_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
3709
3710#[cfg(test)]
3711pub(crate) fn lock_ca_store_test_mutex() -> std::sync::MutexGuard<'static, ()> {
3712 CA_STORE_TEST_MUTEX
3713 .lock()
3714 .unwrap_or_else(|poisoned| poisoned.into_inner())
3715}
3716
3717fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
3727 match e {
3728 CamelError::Unauthenticated(msg) => {
3729 tracing::warn!(error = %msg, path = %path, "Authentication failed");
3730 HttpReply {
3731 status: 401,
3732 headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
3733 body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
3734 }
3735 }
3736 CamelError::Unauthorized(msg) => {
3737 tracing::warn!(error = %msg, path = %path, "Authorization failed");
3738 HttpReply {
3739 status: 403,
3740 headers: vec![],
3741 body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
3742 }
3743 }
3744 CamelError::TypeConversionFailed(msg) => {
3745 tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
3746 json_error_reply(400, "bad_request", msg)
3747 }
3748 CamelError::ValidationError(msg) => {
3749 tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
3750 json_error_reply(400, "validation_error", msg)
3751 }
3752 CamelError::ConsumerStopping => {
3753 tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
3754 HttpReply {
3755 status: 503,
3756 headers: vec![],
3757 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
3758 }
3759 }
3760 CamelError::UnsupportedMediaType { consumed, declared } => {
3761 tracing::warn!(error = %consumed, declared = %declared, path = %path, "Unsupported media type (bad request)");
3762 json_error_reply(
3763 415,
3764 "unsupported_media_type",
3765 format!("consumed {consumed}, declared {declared}"),
3766 )
3767 }
3768 CamelError::NotAcceptable { accept, produced } => {
3769 tracing::warn!(error = %accept, produced = %produced, path = %path, "Not acceptable (bad request)");
3770 json_error_reply(
3771 406,
3772 "not_acceptable",
3773 format!("accept {accept}, produced {produced}"),
3774 )
3775 }
3776 e => {
3777 tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
3779 HttpReply {
3780 status: 500,
3781 headers: vec![],
3782 body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
3783 }
3784 }
3785 }
3786}
3787
3788fn json_error_reply(status: u16, code: &str, message: String) -> HttpReply {
3796 let body = serde_json::to_string(&serde_json::json!({
3797 "error": code,
3798 "message": message,
3799 }))
3800 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
3802 status,
3803 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
3804 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
3805 }
3806}
3807
3808const fn json_value_kind(v: &serde_json::Value) -> &'static str {
3812 match v {
3813 serde_json::Value::Null => "null",
3814 serde_json::Value::Bool(_) => "bool",
3815 serde_json::Value::Number(_) => "number",
3816 serde_json::Value::String(_) => "string",
3817 serde_json::Value::Array(_) => "array",
3818 serde_json::Value::Object(_) => "object",
3819 }
3820}
3821
3822fn scalar_string_form(v: &serde_json::Value) -> Option<String> {
3827 match v {
3828 serde_json::Value::String(s) => Some(s.clone()),
3829 serde_json::Value::Number(n) => Some(n.to_string()),
3830 serde_json::Value::Bool(b) => Some(b.to_string()),
3831 _ => None,
3832 }
3833}
3834
3835fn select_response_headers(
3850 headers: &HashMap<String, serde_json::Value>,
3851 user_content_type: Option<String>,
3852 inferred_content_type: Option<String>,
3853) -> Vec<(String, String)> {
3854 let conn_tokens = header_policy::connection_tokens(
3855 headers
3856 .iter()
3857 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3858 .filter_map(|(_, v)| v.as_str()),
3859 );
3860 let mut selected: Vec<(String, String)> = Vec::new();
3861 for (k, v) in headers {
3862 if k.starts_with("Camel") {
3863 debug!(header = %k, "reply header dropped: Camel namespace");
3864 continue;
3865 }
3866 if header_policy::excluded_response(k, &conn_tokens) {
3867 debug!(header = %k, "reply header dropped: emission policy");
3868 continue;
3869 }
3870 match scalar_string_form(v) {
3871 Some(s) => selected.push((k.clone(), s)),
3872 None => debug!(
3873 header = %k,
3874 value_kind = json_value_kind(v),
3875 "reply header dropped: no scalar string form"
3876 ),
3877 }
3878 }
3879 if let Some(ct) = user_content_type.or(inferred_content_type) {
3880 selected.push(("Content-Type".to_string(), ct));
3881 }
3882 selected
3883}
3884
3885#[derive(Debug)]
3890struct OutboundHeaderDrop<'a> {
3891 name: &'a str,
3892 reason: &'static str,
3893 value_kind: Option<&'static str>,
3894}
3895
3896struct OutboundHeaderSelection<'a> {
3899 accepted: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>,
3900 drops: Vec<OutboundHeaderDrop<'a>>,
3901}
3902
3903fn select_outbound_headers<'a>(
3917 headers: &'a HashMap<String, serde_json::Value>,
3918 skip_request_headers: &[String],
3919 conn_tokens: &[String],
3920) -> OutboundHeaderSelection<'a> {
3921 let mut accepted = Vec::new();
3922 let mut drops = Vec::new();
3923 for (key, value) in headers {
3924 if key.starts_with("Camel") {
3925 drops.push(OutboundHeaderDrop {
3926 name: key,
3927 reason: "Camel namespace",
3928 value_kind: None,
3929 });
3930 continue;
3931 }
3932 if skip_request_headers
3933 .iter()
3934 .any(|h| h.eq_ignore_ascii_case(key))
3935 {
3936 drops.push(OutboundHeaderDrop {
3937 name: key,
3938 reason: "skip_request_headers",
3939 value_kind: None,
3940 });
3941 continue;
3942 }
3943 if header_policy::excluded_outbound(key, conn_tokens) {
3944 drops.push(OutboundHeaderDrop {
3945 name: key,
3946 reason: "outbound emission policy",
3947 value_kind: None,
3948 });
3949 continue;
3950 }
3951 let Some(val_str) = scalar_string_form(value) else {
3952 drops.push(OutboundHeaderDrop {
3953 name: key,
3954 reason: "no scalar string form",
3955 value_kind: Some(json_value_kind(value)),
3956 });
3957 continue;
3958 };
3959 match constructed_header(key, &val_str) {
3960 Ok((name, val)) => accepted.push((name, val)),
3961 Err(drop) => drops.push(drop),
3962 }
3963 }
3964 OutboundHeaderSelection { accepted, drops }
3965}
3966
3967fn constructed_header<'a>(
3972 name: &'a str,
3973 value: &str,
3974) -> Result<(reqwest::header::HeaderName, reqwest::header::HeaderValue), OutboundHeaderDrop<'a>> {
3975 let header_name = match reqwest::header::HeaderName::from_bytes(name.as_bytes()) {
3976 Ok(header_name) => header_name,
3977 Err(_) => {
3978 return Err(OutboundHeaderDrop {
3979 name,
3980 reason: "invalid header name",
3981 value_kind: None,
3982 });
3983 }
3984 };
3985 let header_value = match reqwest::header::HeaderValue::from_str(value) {
3986 Ok(header_value) => header_value,
3987 Err(_) => {
3988 return Err(OutboundHeaderDrop {
3989 name,
3990 reason: "invalid header value",
3991 value_kind: None,
3992 });
3993 }
3994 };
3995 Ok((header_name, header_value))
3996}
3997
3998#[cfg(test)]
3999mod tests {
4000 use camel_component_api::test_support::NoopRuntimeObservability;
4001
4002 #[test]
4003 fn test_metadata_authmethod_enum_covers_runtime_vocab() {
4004 use camel_api::component_metadata::OptionKind;
4010 let meta = super::HttpEndpointConfig::metadata();
4011 let auth = meta
4012 .uri_options
4013 .iter()
4014 .find(|o| o.name == "authMethod")
4015 .expect("authMethod option must exist");
4016 let OptionKind::Enum(variants) = &auth.kind else {
4017 panic!("authMethod kind must be Enum; got {:?}", auth.kind);
4018 };
4019 for value in ["None", "Basic", "Bearer"] {
4020 assert!(
4021 variants.iter().any(|v| v.eq_ignore_ascii_case(value)),
4022 "metadata authMethod variants must cover runtime-accepted `{value}`; got {variants:?}"
4023 );
4024 }
4025 }
4026
4027 fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
4031 std::sync::Arc::new(NoopRuntimeObservability)
4032 }
4033 fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
4034 std::sync::Arc::new(NoopRuntimeObservability)
4035 }
4036 fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
4037 std::sync::Arc::new(NoopRuntimeObservability)
4038 }
4039
4040 use super::*;
4041 use crate::config::TlsConfig;
4042 use crate::rest_match::PathSegment;
4043 use camel_component_api::{Message, NoOpComponentContext};
4044 use std::sync::Arc;
4045 use std::time::Duration;
4046
4047 fn test_producer_ctx() -> ProducerContext {
4048 ProducerContext::new()
4049 }
4050
4051 #[test]
4062 fn canonical_redact_host_pinned() {
4063 assert_eq!(
4064 camel_api::redact::redact_host("host.example:8080"),
4065 "host.example:8080"
4066 );
4067 assert_eq!(camel_api::redact::redact_host("a@b@c"), "***@c");
4068 }
4069
4070 #[test]
4071 fn redact_url_drops_oauth2_fragment_access_token() {
4072 let redacted =
4073 redact_url_for_diagnostics("https://app.example/cb#access_token=SECRET&state=x");
4074 assert!(
4075 !redacted.contains("SECRET"),
4076 "fragment access token leaked: {redacted}"
4077 );
4078 assert!(
4079 !redacted.contains("access_token"),
4080 "fragment key leaked: {redacted}"
4081 );
4082 assert!(
4083 redacted.ends_with("#[redacted]"),
4084 "fragment must be replaced with the sentinel: {redacted}"
4085 );
4086 }
4087
4088 #[test]
4089 fn redact_url_drops_oauth2_fragment_id_token() {
4090 let redacted =
4091 redact_url_for_diagnostics("https://app.example/cb#id_token=eyJhbG.SECRET.SIG&state=y");
4092 assert!(
4093 !redacted.contains("eyJhbG"),
4094 "id token payload leaked: {redacted}"
4095 );
4096 assert!(
4097 !redacted.contains("id_token"),
4098 "id token key leaked: {redacted}"
4099 );
4100 assert!(
4101 !redacted.contains("SECRET"),
4102 "id token signature leaked: {redacted}"
4103 );
4104 assert!(
4105 redacted.ends_with("#[redacted]"),
4106 "fragment must be replaced with the sentinel: {redacted}"
4107 );
4108 }
4109
4110 #[test]
4111 fn redact_url_drops_generic_fragment_kv() {
4112 let redacted = redact_url_for_diagnostics("https://h.example/p/session#session=abc123");
4113 assert!(
4114 !redacted.contains("abc123"),
4115 "fragment value leaked: {redacted}"
4116 );
4117 assert!(
4118 !redacted.contains("session="),
4119 "fragment key leaked: {redacted}"
4120 );
4121 assert!(
4122 redacted.contains("#[redacted]"),
4123 "fragment must be replaced with the sentinel: {redacted}"
4124 );
4125 }
4126
4127 #[test]
4128 fn redact_url_query_and_fragment_sentinels_compose() {
4129 let redacted = redact_url_for_diagnostics("https://h.example/p?a=1#access_token=x");
4130 assert_eq!(
4131 redacted, "https://h.example/p?[redacted]#[redacted]",
4132 "query and fragment sentinels must compose: {redacted}"
4133 );
4134 }
4135
4136 #[test]
4137 fn redact_url_drops_benign_fragment_too() {
4138 let redacted = redact_url_for_diagnostics("https://h.example/docs#section-3");
4141 assert_eq!(
4142 redacted, "https://h.example/docs#[redacted]",
4143 "benign fragment must still be dropped: {redacted}"
4144 );
4145 }
4146
4147 #[test]
4148 fn redact_url_unparseable_fragment_credentials_dropped() {
4149 let raw = "ht tps://app.example/cb#access_token=SECRET";
4150 assert!(
4151 url::Url::parse(raw).is_err(),
4152 "fixture must be unparseable: {raw}"
4153 );
4154 let redacted = redact_url_for_diagnostics(raw);
4155 assert!(
4156 !redacted.contains("SECRET"),
4157 "unparseable fragment token leaked: {redacted}"
4158 );
4159 assert!(
4160 !redacted.contains("access_token"),
4161 "unparseable fragment bytes leaked: {redacted}"
4162 );
4163 assert!(
4164 redacted.contains("#[redacted]"),
4165 "unparseable fragment must end in the sentinel: {redacted}"
4166 );
4167 }
4168
4169 #[test]
4170 fn redact_url_double_slash_evader_sentinel() {
4171 let redacted = redact_url_for_diagnostics("scheme:////user:pass@evil/");
4174 assert_eq!(
4175 redacted, "[redacted]",
4176 "double-slash evader must fail closed: {redacted}"
4177 );
4178 }
4179
4180 #[test]
4181 fn redact_url_triple_slash_evader_sentinel() {
4182 let redacted = redact_url_for_diagnostics("scheme:///user:pass@evil/");
4183 assert_eq!(
4184 redacted, "[redacted]",
4185 "triple-slash evader must fail closed: {redacted}"
4186 );
4187 }
4188
4189 #[test]
4190 fn redact_url_bare_protocol_relative_userinfo_sentinel() {
4191 let redacted = redact_url_for_diagnostics("//user:pass@evil");
4192 assert_eq!(
4193 redacted, "[redacted]",
4194 "protocol-relative userinfo must fail closed: {redacted}"
4195 );
4196 }
4197
4198 #[test]
4199 fn redact_url_empty_host_userinfo_sentinel() {
4200 let redacted = redact_url_for_diagnostics("scheme://user@");
4203 assert_eq!(
4204 redacted, "[redacted]",
4205 "empty-host userinfo must fail closed: {redacted}"
4206 );
4207 }
4208
4209 #[test]
4210 fn redact_url_unparseable_slash_run_evader_sentinel() {
4211 let raw = "schem e:////user:pass@evil/";
4215 assert!(
4216 url::Url::parse(raw).is_err(),
4217 "fixture must be unparseable: {raw}"
4218 );
4219 let redacted = redact_url_for_diagnostics(raw);
4220 assert_eq!(
4221 redacted, "[redacted]",
4222 "unparseable slash-run evader must fail closed: {redacted}"
4223 );
4224 }
4225
4226 #[test]
4227 fn redact_url_unparseable_later_window_userinfo_sentinel() {
4228 let raw = "http://ho st/a//user:pass@evil/";
4232 assert!(
4233 url::Url::parse(raw).is_err(),
4234 "fixture must be unparseable: {raw}"
4235 );
4236 let redacted = redact_url_for_diagnostics(raw);
4237 assert_eq!(
4238 redacted, "[redacted]",
4239 "userinfo in a later // window must fail closed: {redacted}"
4240 );
4241 }
4242
4243 #[test]
4244 fn redact_url_parsed_later_window_userinfo_masked() {
4245 let redacted = redact_url_for_diagnostics("https://h//user:pass@evil/");
4250 assert!(
4251 !redacted.contains("user:pass"),
4252 "parsed later-window userinfo leaked: {redacted}"
4253 );
4254 assert!(
4255 redacted.contains("h//***@evil/"),
4256 "later window must be masked in place: {redacted}"
4257 );
4258 }
4259
4260 #[test]
4261 fn redact_url_parsed_window_mask_idempotent_with_real_userinfo() {
4262 let redacted = redact_url_for_diagnostics("https://user:pass@h//x@y/");
4266 assert!(
4267 redacted.contains("***@h"),
4268 "accessor mask must survive the window surgery: {redacted}"
4269 );
4270 assert!(
4271 !redacted.contains("user:pass"),
4272 "real userinfo leaked: {redacted}"
4273 );
4274 assert!(
4275 !redacted.contains("x@y"),
4276 "later path window leaked: {redacted}"
4277 );
4278 }
4279
4280 #[test]
4281 fn redact_url_backslash_authority_ruling() {
4282 let redacted = redact_url_for_diagnostics("http:\\\\user:pass@evil\\path");
4288 assert!(
4289 redacted.contains("***@"),
4290 "backslash authority must be userinfo-masked: {redacted}"
4291 );
4292 assert!(
4293 !redacted.contains("user:pass"),
4294 "backslash authority must not leak credentials: {redacted}"
4295 );
4296 }
4297
4298 #[test]
4299 fn non_special_backslash_authority_masked() {
4300 let redacted = redact_url_for_diagnostics("foo:\\user:pass@evil/");
4305 assert!(
4306 !redacted.contains("user:pass"),
4307 "non-special backslash authority leaked: {redacted}"
4308 );
4309 assert!(
4310 !redacted.contains("pass"),
4311 "non-special backslash authority leaked a credential byte: {redacted}"
4312 );
4313 assert_eq!(
4315 redact_url_for_diagnostics("foo:\\clean/path"),
4316 "foo:\\clean/path"
4317 );
4318 }
4319
4320 #[test]
4321 fn one_char_scheme_credential_content_masked() {
4322 let redacted = redact_url_for_diagnostics("x:\\user:pass@evil");
4325 assert!(
4326 !redacted.contains("user:pass"),
4327 "one-char-scheme backslash authority leaked: {redacted}"
4328 );
4329 assert!(
4330 !redacted.contains("pass"),
4331 "one-char-scheme backslash authority leaked a credential byte: {redacted}"
4332 );
4333 }
4334
4335 #[test]
4336 fn drive_and_unc_inputs_stay_visible() {
4337 let drive = redact_url_for_diagnostics("C:\\Users\\x@corp\\file");
4343 assert!(
4344 !drive.contains("[redacted]"),
4345 "drive path must not be sentineled: {drive}"
4346 );
4347 assert!(
4348 !drive.contains("***"),
4349 "drive path must not be masked: {drive}"
4350 );
4351 assert!(
4352 drive.contains("x@corp"),
4353 "drive path keeps its at-sign content visible: {drive}"
4354 );
4355 let unc = redact_url_for_diagnostics("\\\\server\\x@y");
4358 assert_eq!(unc, "\\\\server\\x@y");
4359 assert!(
4360 !unc.contains("[redacted]"),
4361 "UNC path must not be sentineled: {unc}"
4362 );
4363 }
4364
4365 #[test]
4366 fn redact_url_masks_userinfo_and_query() {
4367 let redacted =
4368 redact_url_for_diagnostics("http://user:secretpass@internal.example/api?token=abc123");
4369 assert!(
4370 !redacted.contains("secretpass"),
4371 "password must be masked: {redacted}"
4372 );
4373 assert!(
4374 !redacted.contains("token=abc123"),
4375 "query must be masked: {redacted}"
4376 );
4377 assert!(
4378 !redacted.contains("user@"),
4379 "username must be masked: {redacted}"
4380 );
4381 assert!(
4382 redacted.contains("internal.example"),
4383 "host stays visible: {redacted}"
4384 );
4385 assert!(redacted.contains("[redacted]"), "query marked: {redacted}");
4386 }
4387
4388 #[test]
4389 fn redact_url_keeps_clean_urls_visible() {
4390 let redacted = redact_url_for_diagnostics("https://api.example.com/v1/items");
4391 assert_eq!(redacted, "https://api.example.com/v1/items");
4392 }
4393
4394 #[test]
4395 fn redact_url_masks_password_only_userinfo() {
4396 let redacted = redact_url_for_diagnostics("http://:pwsecret@host.example/");
4397 assert!(
4398 !redacted.contains("pwsecret"),
4399 "password-only userinfo leaked: {redacted}"
4400 );
4401 assert_eq!(redacted, "http://***@host.example/");
4402
4403 let redacted = redact_url_for_diagnostics("http://user:pw2@host.example/api");
4404 assert!(!redacted.contains("pw2"), "password leaked: {redacted}");
4405 assert_eq!(redacted, "http://***@host.example/api");
4406
4407 let redacted = redact_url_for_diagnostics("http://host.example/api");
4408 assert_eq!(redacted, "http://host.example/api");
4409 }
4410
4411 #[test]
4412 fn redact_url_truncates_unparseable() {
4413 let long = "x".repeat(1000);
4414 let redacted = redact_url_for_diagnostics(&long);
4415 assert_eq!(redacted.len(), 256, "unparseable URL must be truncated");
4416 }
4417
4418 #[test]
4423 fn redact_url_keeps_sentinels_intact_under_256_cap() {
4424 let parsed = format!("https://example.com/{}?x=1", "a".repeat(230));
4427 assert!(
4428 url::Url::parse(&parsed).is_ok(),
4429 "fixture must parse: {parsed}"
4430 );
4431 let redacted = redact_url_for_diagnostics(&parsed);
4432 assert!(redacted.len() <= 256, "len={}", redacted.len());
4433 assert!(
4434 redacted.ends_with("?[redacted]"),
4435 "parsed-arm sentinel must render intact: {redacted}"
4436 );
4437
4438 let unparseable = format!("http://{} ?x=1", "a".repeat(240));
4441 assert!(
4442 url::Url::parse(&unparseable).is_err(),
4443 "fixture must not parse: {unparseable}"
4444 );
4445 let redacted = redact_url_for_diagnostics(&unparseable);
4446 assert!(redacted.len() <= 256, "len={}", redacted.len());
4447 assert!(
4448 redacted.ends_with("?[redacted]"),
4449 "unparseable-arm sentinel must render intact: {redacted}"
4450 );
4451 }
4452
4453 #[test]
4454 fn redact_url_suppresses_unparseable_authority_credentials() {
4455 let fixtures = [
4456 "http://u:secretpw@/x",
4457 "http://u:secretpw@host:99999/x",
4458 "http://u:secretpw@host:99999",
4459 "//u:secretpw@h/x",
4460 ];
4461 for fixture in fixtures {
4462 assert!(
4463 url::Url::parse(fixture).is_err(),
4464 "fixture must be unparseable: {fixture}"
4465 );
4466 let redacted = redact_url_for_diagnostics(fixture);
4467 assert_eq!(
4468 redacted, "[redacted]",
4469 "credential-bearing authority must be suppressed: {fixture}"
4470 );
4471 }
4472 }
4473
4474 #[test]
4475 fn redact_url_bd_repro_never_leaks_credentials() {
4476 let redacted = redact_url_for_diagnostics("http://user:pa%ss@host/path");
4477 assert!(
4478 !redacted.contains("user:pa%ss"),
4479 "bd rc-2i5c5 repro leaked userinfo: {redacted}"
4480 );
4481 assert!(
4482 !redacted.contains("pa%ss"),
4483 "bd rc-2i5c5 repro leaked password: {redacted}"
4484 );
4485 }
4486
4487 #[test]
4488 fn redact_url_unparseable_query_redacted_short_and_long() {
4489 let short = "http://host:99999/path?token=shortsecret";
4490 assert!(
4491 url::Url::parse(short).is_err(),
4492 "fixture must be unparseable: {short}"
4493 );
4494 let redacted = redact_url_for_diagnostics(short);
4495 assert_eq!(
4496 redacted, "http://host:99999/path?[redacted]",
4497 "short unparseable query must end with the suffix: {redacted}"
4498 );
4499
4500 let mut long = String::from("http://host:99999/");
4501 long.push_str(&"a".repeat(300));
4502 long.push_str("?token=longsecret");
4503 assert!(
4504 url::Url::parse(&long).is_err(),
4505 "fixture must be unparseable: {long}"
4506 );
4507 let redacted = redact_url_for_diagnostics(&long);
4508 assert!(
4509 !redacted.contains("longsecret"),
4510 "long unparseable query leaked a query byte: {redacted}"
4511 );
4512 assert!(
4513 redacted.len() <= 256,
4514 "long unparseable query must be capped: {} bytes",
4515 redacted.len()
4516 );
4517 }
4518
4519 #[test]
4520 fn redact_url_unparseable_sentinels_compose_both() {
4521 let raw = "ht tp://h.example/p?a=1#tok=x";
4524 assert!(
4525 url::Url::parse(raw).is_err(),
4526 "fixture must be unparseable: {raw}"
4527 );
4528 assert_eq!(
4529 redact_url_for_diagnostics(raw),
4530 "ht tp://h.example/p?[redacted]#[redacted]",
4531 "query and fragment sentinels must compose: {raw}"
4532 );
4533 }
4534
4535 #[test]
4536 fn redact_url_unparseable_sentinels_compose_fragment_first() {
4537 let raw = "ht tp://h.example/p#tok=x?a=1";
4538 assert!(
4539 url::Url::parse(raw).is_err(),
4540 "fixture must be unparseable: {raw}"
4541 );
4542 assert_eq!(
4543 redact_url_for_diagnostics(raw),
4544 "ht tp://h.example/p#[redacted]?[redacted]",
4545 "sentinels must follow the introducers' first-occurrence order: {raw}"
4546 );
4547 }
4548
4549 #[test]
4550 fn redact_url_unparseable_utf8_straddle_no_panic() {
4551 let fixture = format!("a{}", "é".repeat(200));
4552 let redacted = redact_url_for_diagnostics(&fixture);
4553 assert!(
4554 redacted.len() <= 256,
4555 "straddle fixture must be capped: {} bytes",
4556 redacted.len()
4557 );
4558 assert!(
4559 redacted.len() >= 253,
4560 "straddle fixture must not over-truncate: {} bytes",
4561 redacted.len()
4562 );
4563 assert!(
4564 fixture.is_char_boundary(redacted.len()),
4565 "cut must land on a UTF-8 char boundary: {} bytes",
4566 redacted.len()
4567 );
4568 }
4569
4570 #[test]
4571 fn redact_url_at_sign_outside_authority_window_visible() {
4572 let at_sign_in_path = "http://host:99999/x@y";
4573 assert!(
4574 url::Url::parse(at_sign_in_path).is_err(),
4575 "fixture must be unparseable: {at_sign_in_path}"
4576 );
4577 assert_eq!(
4578 redact_url_for_diagnostics(at_sign_in_path),
4579 at_sign_in_path,
4580 "at-sign in path must not be suppressed"
4581 );
4582 assert_eq!(
4584 redact_url_for_diagnostics("mailto:user@example.com"),
4585 "mailto:user@example.com",
4586 "at-sign in mailto must round-trip byte-identically"
4587 );
4588 }
4589
4590 #[test]
4591 fn parse_success_fragment_composes() {
4592 assert_eq!(
4595 redact_url_for_diagnostics("https://h/p#access_token=x"),
4596 "https://h/p#[redacted]"
4597 );
4598 assert_eq!(
4601 redact_url_for_diagnostics("https://h/cb#f?state=x"),
4602 "https://h/cb#[redacted]?[redacted]"
4603 );
4604 }
4605
4606 #[test]
4607 fn err_arm_delegation_pin() {
4608 assert_eq!(
4612 redact_url_for_diagnostics("http://u:secretpw@host:99999/x"),
4613 "[redacted]"
4614 );
4615 assert_eq!(
4618 redact_url_for_diagnostics("http://h:99999/p?token=secret"),
4619 "http://h:99999/p?[redacted]"
4620 );
4621 }
4622
4623 #[test]
4624 fn truncate_error_body_caps_attacker_body() {
4625 let big = vec![b'A'; 10 * 1024 * 1024];
4626 let truncated = truncate_error_body(&big);
4627 assert!(
4628 truncated.len() <= MAX_ERROR_RESPONSE_BODY_BYTES + 20,
4629 "body must be capped near {} bytes, got {}",
4630 MAX_ERROR_RESPONSE_BODY_BYTES,
4631 truncated.len()
4632 );
4633 assert!(truncated.ends_with("...[truncated]"));
4634 }
4635
4636 #[test]
4637 fn truncate_error_body_keeps_small_body() {
4638 assert_eq!(truncate_error_body(b"boom"), "boom");
4639 }
4640
4641 #[test]
4642 fn test_http_config_defaults() {
4643 let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
4644 assert_eq!(config.base_url, "http://localhost:8080/api");
4645 assert!(config.http_method.is_none());
4646 assert!(config.throw_exception_on_failure);
4647 assert_eq!(config.ok_status_code_range, (200, 299));
4648 assert!(config.response_timeout.is_none());
4649 assert!(matches!(config.auth, HttpAuth::None));
4650 assert!(!config.bridge_endpoint);
4651 assert!(!config.connection_close);
4652 }
4653
4654 #[test]
4655 fn test_http_config_scheme() {
4656 assert_eq!(HttpEndpointConfig::scheme(), "http");
4658 }
4659
4660 #[test]
4661 fn test_http_config_from_components() {
4662 let components = camel_component_api::UriComponents {
4664 scheme: "https".to_string(),
4665 path: "//api.example.com/v1".to_string(),
4666 params: std::collections::HashMap::from([(
4667 "httpMethod".to_string(),
4668 "POST".to_string(),
4669 )]),
4670 raw_query: None,
4671 };
4672 let config = HttpEndpointConfig::from_components(components).unwrap();
4673 assert_eq!(config.base_url, "https://api.example.com/v1");
4674 assert_eq!(config.http_method, Some("POST".to_string()));
4675 }
4676
4677 #[test]
4678 fn test_http_config_with_options() {
4679 let config = HttpEndpointConfig::from_uri(
4680 "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
4681 ).unwrap();
4682 assert_eq!(config.base_url, "https://api.example.com/v1");
4683 assert_eq!(config.http_method, Some("PUT".to_string()));
4684 assert!(!config.throw_exception_on_failure);
4685 assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
4686 }
4687
4688 #[test]
4689 fn test_http_endpoint_config_auth_and_headers_options() {
4690 let config = HttpEndpointConfig::from_uri(
4691 "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie",
4692 )
4693 .unwrap();
4694
4695 assert!(matches!(
4696 config.auth,
4697 HttpAuth::Basic { username, password } if username == "u" && password == "p"
4698 ));
4699 assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
4700 assert!(config.bridge_endpoint);
4701 assert!(config.connection_close);
4702 assert_eq!(
4703 config.skip_request_headers,
4704 vec!["authorization".to_string(), "x-secret".to_string()]
4705 );
4706 assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
4707 }
4708
4709 #[test]
4710 fn test_http_endpoint_config_bearer_auth() {
4711 let config = HttpEndpointConfig::from_uri(
4712 "http://localhost/api?authMethod=Bearer&authBearerToken=t",
4713 )
4714 .unwrap();
4715 assert!(matches!(
4716 config.auth,
4717 HttpAuth::Bearer { token } if token == "t"
4718 ));
4719 }
4720
4721 #[test]
4722 fn rejects_cookie_handling_inmemory() {
4723 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=InMemory");
4724 match result {
4725 Err(CamelError::InvalidUri(msg)) => {
4726 assert!(
4727 msg.contains("cookieHandling is not supported"),
4728 "expected rejection message, got: {msg}"
4729 );
4730 }
4731 other => panic!("expected InvalidUri error, got: {other:?}"),
4732 }
4733 }
4734
4735 #[test]
4736 fn rejects_cookie_handling_disabled() {
4737 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=Disabled");
4738 match result {
4739 Err(CamelError::InvalidUri(msg)) => {
4740 assert!(
4741 msg.contains("cookieHandling is not supported"),
4742 "expected rejection message, got: {msg}"
4743 );
4744 }
4745 other => panic!("expected InvalidUri error, got: {other:?}"),
4746 }
4747 }
4748
4749 #[test]
4750 fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
4751 let config = HttpConfig::default()
4752 .with_response_timeout_ms(999)
4753 .with_allow_internal(true)
4754 .with_blocked_hosts(vec!["evil.com".to_string()])
4755 .with_max_body_size(12345);
4756 let endpoint =
4757 HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
4758 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
4759 assert!(endpoint.allow_internal);
4760 assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
4761 assert_eq!(endpoint.max_body_size, 12345);
4762 }
4763
4764 #[test]
4765 fn test_from_uri_with_defaults_uri_overrides_config() {
4766 let config = HttpConfig::default()
4767 .with_response_timeout_ms(999)
4768 .with_allow_internal(true)
4769 .with_blocked_hosts(vec!["evil.com".to_string()])
4770 .with_max_body_size(12345);
4771 let endpoint = HttpEndpointConfig::from_uri_with_defaults(
4772 "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
4773 &config,
4774 )
4775 .unwrap();
4776 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
4777 assert!(!endpoint.allow_internal);
4778 assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
4779 assert_eq!(endpoint.max_body_size, 99);
4780 }
4781
4782 #[test]
4783 fn test_http_config_ok_status_range() {
4784 let config =
4785 HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
4786 assert_eq!(config.ok_status_code_range, (200, 204));
4787 }
4788
4789 #[test]
4790 fn test_http_config_wrong_scheme() {
4791 let result = HttpEndpointConfig::from_uri("file:/tmp");
4792 assert!(result.is_err());
4793 }
4794
4795 #[test]
4796 fn test_http_component_scheme() {
4797 let component = HttpComponent::new();
4798 assert_eq!(component.scheme(), "http");
4799 }
4800
4801 #[test]
4808 fn tls_strict_defaults_false_on_deserialize() {
4809 let tls: TlsConfig = serde_json::from_value(serde_json::json!({
4810 "enabled": true
4811 }))
4812 .unwrap();
4813 assert!(!tls.strict, "absent strict must default to false");
4814 }
4815
4816 fn strict_config(ca_path: Option<&str>, strict: bool) -> HttpConfig {
4817 HttpConfig {
4818 tls: Some(TlsConfig {
4819 enabled: true,
4820 strict,
4821 ca_cert_path: ca_path.map(|p| p.to_string()),
4822 ..TlsConfig::default()
4823 }),
4824 ..HttpConfig::default()
4825 }
4826 }
4827
4828 #[test]
4829 fn strict_tls_missing_ca_fails_endpoint_creation() {
4830 let component =
4831 HttpComponent::with_config(strict_config(Some("/nonexistent/ca.pem"), true));
4832 let err = component
4833 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4834 .err()
4835 .expect("strict + missing CA must fail endpoint creation");
4836 assert!(
4837 err.to_string().contains("tls.strict"),
4838 "must name the strict knob: {err}"
4839 );
4840 assert!(
4841 err.to_string().contains("unreadable"),
4842 "must name the failure class: {err}"
4843 );
4844 }
4845
4846 #[test]
4847 fn strict_tls_unparseable_ca_fails_endpoint_creation() {
4848 let path = camel_component_api::test_support::tls::write_pem_tmp(
4849 "strict-bad-ca.pem",
4850 "not a certificate",
4851 );
4852 let component =
4853 HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
4854 let err = component
4855 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4856 .err()
4857 .expect("strict + unparseable CA must fail endpoint creation");
4858 assert!(
4859 err.to_string()
4860 .contains("no parseable PEM CERTIFICATE section"),
4861 "must name the failure class: {err}"
4862 );
4863 }
4864
4865 #[test]
4866 fn strict_tls_der_file_rejected_not_certified() {
4867 let path = camel_component_api::test_support::tls::write_pem_tmp(
4872 "strict-der-ca.pem",
4873 "00garbage-bytes",
4874 );
4875 let component =
4876 HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
4877 let err = component
4878 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4879 .err()
4880 .expect("strict + DER file must fail endpoint creation");
4881 assert!(
4882 err.to_string().contains("convert to PEM"),
4883 "must tell the operator to convert: {err}"
4884 );
4885 }
4886
4887 #[test]
4888 fn strict_tls_half_mtls_pair_rejected() {
4889 let cfg = strict_mtls_config(Some("/any/cert.pem"), None);
4892 let component = HttpComponent::with_config(cfg);
4893 let err = component
4894 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4895 .err()
4896 .expect("strict + half mTLS pair must fail endpoint creation");
4897 assert!(
4898 err.to_string().contains("BOTH"),
4899 "must name the pair requirement: {err}"
4900 );
4901 }
4902
4903 #[test]
4904 fn strict_tls_valid_material_allows_endpoint_creation() {
4905 let (ca, _cert, _key) = camel_component_api::test_support::tls::gen_server_cert();
4906 let path = camel_component_api::test_support::tls::write_pem_tmp("strict-ok-ca.pem", &ca);
4907 let component =
4908 HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
4909 assert!(
4910 component
4911 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4912 .is_ok(),
4913 "valid CA under strict must create the endpoint"
4914 );
4915 }
4916
4917 #[test]
4918 fn permissive_missing_ca_keeps_back_compat() {
4919 let component =
4922 HttpComponent::with_config(strict_config(Some("/nonexistent/ca.pem"), false));
4923 assert!(
4924 component
4925 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4926 .is_ok(),
4927 "permissive mode must keep the back-compat fallback"
4928 );
4929 }
4930
4931 fn strict_mtls_config(cert_path: Option<&str>, key_path: Option<&str>) -> HttpConfig {
4932 HttpConfig {
4933 tls: Some(TlsConfig {
4934 enabled: true,
4935 strict: true,
4936 client_cert_path: cert_path.map(|p| p.to_string()),
4937 client_key_path: key_path.map(|p| p.to_string()),
4938 ..TlsConfig::default()
4939 }),
4940 ..HttpConfig::default()
4941 }
4942 }
4943
4944 #[test]
4945 fn strict_tls_missing_mtls_cert_fails_endpoint_creation() {
4946 let (_ca, _cert, key) = camel_component_api::test_support::tls::gen_server_cert();
4949 let key_path =
4950 camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-key.pem", &key);
4951 let component = HttpComponent::with_config(strict_mtls_config(
4952 Some("/nonexistent/cert.pem"),
4953 Some(key_path.to_str().unwrap()),
4954 ));
4955 let err = component
4956 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4957 .err()
4958 .expect("strict + unreadable mTLS pair must fail endpoint creation");
4959 assert!(
4960 err.to_string().contains("tls.strict"),
4961 "must name the strict knob: {err}"
4962 );
4963 assert!(
4964 err.to_string().contains("unreadable"),
4965 "must name the failure class: {err}"
4966 );
4967 }
4968
4969 #[test]
4970 fn strict_tls_valid_mtls_pair_allows_endpoint_creation() {
4971 let (_ca, cert, key) = camel_component_api::test_support::tls::gen_server_cert();
4972 let cert_path =
4973 camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-cert.pem", &cert);
4974 let key_path =
4975 camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-key2.pem", &key);
4976 let component = HttpComponent::with_config(strict_mtls_config(
4977 Some(cert_path.to_str().unwrap()),
4978 Some(key_path.to_str().unwrap()),
4979 ));
4980 assert!(
4981 component
4982 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4983 .is_ok(),
4984 "valid mTLS pair under strict must create the endpoint"
4985 );
4986 }
4987
4988 #[test]
4989 fn test_https_component_scheme() {
4990 let component = HttpsComponent::new();
4991 assert_eq!(component.scheme(), "https");
4992 }
4993
4994 #[test]
4995 fn test_http_endpoint_creates_consumer() {
4996 let component = HttpComponent::new();
4997 let ctx = NoOpComponentContext;
4998 let endpoint = component
4999 .create_endpoint("http://0.0.0.0:19100/test", &ctx)
5000 .unwrap();
5001 assert!(endpoint.create_consumer(rt()).is_ok());
5002 }
5003
5004 #[test]
5005 fn test_https_endpoint_creates_consumer_errors_without_tls() {
5006 let component = HttpsComponent::new();
5007 let ctx = NoOpComponentContext;
5008 let endpoint = component
5009 .create_endpoint("https://0.0.0.0:8443/test", &ctx)
5010 .unwrap();
5011 assert!(endpoint.create_consumer(rt()).is_err());
5013 }
5014
5015 #[test]
5016 fn test_http_endpoint_creates_producer() {
5017 let ctx = test_producer_ctx();
5018 let component = HttpComponent::new();
5019 let endpoint_ctx = NoOpComponentContext;
5020 let endpoint = component
5021 .create_endpoint("http://localhost/api", &endpoint_ctx)
5022 .unwrap();
5023 assert!(endpoint.create_producer(rt(), &ctx).is_ok());
5024 }
5025
5026 #[tokio::test]
5031 async fn test_producer_with_token_provider() {
5032 use camel_auth::oauth2::TokenProvider;
5033 use tower::ServiceExt;
5034
5035 let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
5036 Arc::new(std::sync::Mutex::new(None));
5037 let captured_clone = Arc::clone(&captured_auth);
5038
5039 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5040 let port = listener.local_addr().unwrap().port();
5041
5042 let _handle = tokio::spawn(async move {
5043 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5044 if let Ok((mut stream, _)) = listener.accept().await {
5045 let mut buf = vec![0u8; 8192];
5046 let n = stream.read(&mut buf).await.unwrap_or(0);
5047 let request = String::from_utf8_lossy(&buf[..n]).to_string();
5048 let auth = request
5049 .lines()
5050 .find(|l| l.to_lowercase().starts_with("authorization:"))
5051 .map(|l| {
5052 l.split(':')
5053 .nth(1)
5054 .map(|s| s.trim().to_string())
5055 .unwrap_or_default()
5056 });
5057 *captured_clone.lock().unwrap() = auth;
5058 let body = r#"{"echo":"ok"}"#;
5059 let resp = format!(
5060 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
5061 body.len(),
5062 body
5063 );
5064 let _ = stream.write_all(resp.as_bytes()).await;
5065 }
5066 });
5067
5068 #[derive(Debug)]
5069 struct StaticProvider;
5070 #[async_trait::async_trait]
5071 impl TokenProvider for StaticProvider {
5072 async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
5073 Ok("injected-token".into())
5074 }
5075 }
5076
5077 let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
5078 let ctx = test_producer_ctx();
5079 let component = HttpComponent::new();
5080 let endpoint_ctx = NoOpComponentContext;
5081 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
5082 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5083
5084 let exchange = Exchange::new(Message::new("hello"));
5085
5086 let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
5087 let mut layered = layer.layer(producer);
5088 let result = layered.ready().await.unwrap().call(exchange).await;
5089 assert!(result.is_ok(), "producer call failed: {:?}", result);
5090
5091 tokio::time::sleep(Duration::from_millis(100)).await;
5092 let auth = captured_auth.lock().unwrap().take();
5093 assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
5094 }
5095
5096 async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
5097 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5098 let addr = listener.local_addr().unwrap();
5099 let url = format!("http://127.0.0.1:{}", addr.port());
5100
5101 let handle = tokio::spawn(async move {
5102 loop {
5103 if let Ok((mut stream, _)) = listener.accept().await {
5104 tokio::spawn(async move {
5105 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5106 let mut buf = vec![0u8; 4096];
5107 let n = stream.read(&mut buf).await.unwrap_or(0);
5108 let request = String::from_utf8_lossy(&buf[..n]).to_string();
5109
5110 let method = request.split_whitespace().next().unwrap_or("GET");
5111
5112 let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
5113 let response = format!(
5114 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
5115 body.len(),
5116 body
5117 );
5118 let _ = stream.write_all(response.as_bytes()).await;
5119 });
5120 }
5121 }
5122 });
5123
5124 (url, handle)
5125 }
5126
5127 async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
5128 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5129 let addr = listener.local_addr().unwrap();
5130 let url = format!("http://127.0.0.1:{}", addr.port());
5131
5132 let handle = tokio::spawn(async move {
5133 loop {
5134 if let Ok((mut stream, _)) = listener.accept().await {
5135 let status = status;
5136 tokio::spawn(async move {
5137 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5138 let mut buf = vec![0u8; 4096];
5139 let _ = stream.read(&mut buf).await;
5140
5141 let status_text = match status {
5142 404 => "Not Found",
5143 500 => "Internal Server Error",
5144 _ => "Error",
5145 };
5146 let body = "error body";
5147 let response = format!(
5148 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
5149 status,
5150 status_text,
5151 body.len(),
5152 body
5153 );
5154 let _ = stream.write_all(response.as_bytes()).await;
5155 });
5156 }
5157 }
5158 });
5159
5160 (url, handle)
5161 }
5162
5163 async fn start_request_capturing_server() -> (
5164 String,
5165 Arc<std::sync::Mutex<Option<String>>>,
5166 tokio::task::JoinHandle<()>,
5167 ) {
5168 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5169 let port = listener.local_addr().unwrap().port();
5170 let url = format!("http://127.0.0.1:{port}");
5171 let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
5172 let captured_clone = Arc::clone(&captured);
5173 let handle = tokio::spawn(async move {
5174 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5175 if let Ok((mut stream, _)) = listener.accept().await {
5176 let mut buf = vec![0u8; 16384];
5177 let n = stream.read(&mut buf).await.unwrap_or(0);
5178 let request = String::from_utf8_lossy(&buf[..n]).to_string();
5179 if request.contains("\r\n\r\n") {
5180 *captured_clone.lock().unwrap() = Some(request);
5181 }
5182 let body = r#"{"echo":"ok"}"#;
5183 let resp = format!(
5184 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
5185 body.len(),
5186 body
5187 );
5188 let _ = stream.write_all(resp.as_bytes()).await;
5189 }
5190 });
5191 (url, captured, handle)
5192 }
5193
5194 #[tokio::test]
5195 async fn test_http_producer_get_request() {
5196 use tower::ServiceExt;
5197
5198 let (url, _handle) = start_test_server().await;
5199 let ctx = test_producer_ctx();
5200
5201 let component = HttpComponent::new();
5202 let endpoint_ctx = NoOpComponentContext;
5203 let endpoint = component
5204 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5205 .unwrap();
5206 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5207
5208 let exchange = Exchange::new(Message::default());
5209 let result = producer.oneshot(exchange).await.unwrap();
5210
5211 let status = result
5212 .input
5213 .header("CamelHttpResponseCode")
5214 .and_then(|v| v.as_u64())
5215 .unwrap();
5216 assert_eq!(status, 200);
5217
5218 assert!(!result.input.body.is_empty());
5219 }
5220
5221 #[tokio::test]
5222 async fn producer_excludes_host_and_framing() {
5223 use tower::ServiceExt;
5224
5225 let (url, captured, _handle) = start_request_capturing_server().await;
5226 let ctx = test_producer_ctx();
5227 let component = HttpComponent::new();
5228 let endpoint_ctx = NoOpComponentContext;
5229 let endpoint = component
5230 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5231 .unwrap();
5232 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5233
5234 let mut exchange = Exchange::new(Message::default());
5235 exchange.input.set_header("Host", "localhost");
5236 exchange.input.set_header("Content-Length", "42");
5237 exchange.input.set_header("Connection", "keep-alive");
5238 exchange.input.set_header("Upgrade", "h2c");
5239
5240 let result = producer.oneshot(exchange).await;
5241 assert!(result.is_ok(), "producer call failed: {:?}", result);
5242
5243 tokio::time::sleep(Duration::from_millis(100)).await;
5244 let request = captured
5245 .lock()
5246 .unwrap()
5247 .take()
5248 .expect("no outbound request captured");
5249 let lower = request.to_ascii_lowercase();
5250 assert!(
5251 !lower.contains("\r\nhost: localhost"),
5252 "forwarded Host: localhost must be stripped\n{request}"
5253 );
5254 assert!(
5255 !lower.contains("content-length: 42"),
5256 "exchange Content-Length must not be copied\n{request}"
5257 );
5258 assert!(
5259 !lower.lines().any(|l| l.starts_with("connection:")),
5260 "Connection header must not be forwarded\n{request}"
5261 );
5262 assert!(
5263 !lower.lines().any(|l| l.starts_with("upgrade:")),
5264 "Upgrade header must not be forwarded\n{request}"
5265 );
5266 let host_header = lower
5267 .lines()
5268 .find(|l| l.starts_with("host:"))
5269 .map(|l| l.split_once(':').map(|(_, v)| v).unwrap_or("").trim())
5270 .expect("outbound Host header must be set by reqwest");
5271 assert!(
5272 host_header.starts_with("127.0.0.1:"),
5273 "outbound Host '{host_header}' must match the capture-server address"
5274 );
5275 }
5276
5277 #[tokio::test]
5278 async fn producer_forwards_request_only_headers() {
5279 use tower::ServiceExt;
5280
5281 let (url, captured, _handle) = start_request_capturing_server().await;
5282 let ctx = test_producer_ctx();
5283 let component = HttpComponent::new();
5284 let endpoint_ctx = NoOpComponentContext;
5285 let endpoint = component
5286 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5287 .unwrap();
5288 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5289
5290 let mut exchange = Exchange::new(Message::default());
5291 exchange.input.set_header("Accept", "application/json");
5292 exchange.input.set_header("User-Agent", "myclient/1.0");
5293
5294 let result = producer.oneshot(exchange).await;
5295 assert!(result.is_ok(), "producer call failed: {:?}", result);
5296
5297 tokio::time::sleep(Duration::from_millis(100)).await;
5298 let request = captured
5299 .lock()
5300 .unwrap()
5301 .take()
5302 .expect("no outbound request captured");
5303 let lower = request.to_ascii_lowercase();
5304 assert!(
5305 lower.contains("accept: application/json"),
5306 "request-only Accept header must be forwarded\n{request}"
5307 );
5308 assert!(
5309 lower.contains("user-agent: myclient/1.0"),
5310 "request-only User-Agent header must be forwarded\n{request}"
5311 );
5312 }
5313
5314 fn endpoint_with_config_overrides(
5323 base_url: &str,
5324 user_agent: Option<String>,
5325 auth: HttpAuth,
5326 ) -> HttpEndpoint {
5327 let uri = format!("{base_url}/api/test?allowInternal=true");
5328 let mut config =
5329 HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses");
5330 config.user_agent = user_agent;
5331 config.auth = auth;
5332 HttpEndpoint {
5333 uri: uri.clone(),
5334 config,
5335 server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
5336 client: reqwest::Client::new(),
5337 pinned_cache: Arc::new(PinnedClientCache::new(
5338 PINNED_CLIENT_TTL,
5339 PINNED_CLIENT_MAX_ENTRIES,
5340 )),
5341 http_config: HttpConfig::default(),
5342 }
5343 }
5344
5345 #[tracing_test::traced_test]
5350 #[tokio::test]
5351 async fn producer_invalid_configured_headers_surfaced() {
5352 use tower::ServiceExt;
5353
5354 let (bad_url, bad_captured, _bad_handle) = start_request_capturing_server().await;
5355 let (ok_url, ok_captured, _ok_handle) = start_request_capturing_server().await;
5356 let ctx = test_producer_ctx();
5357
5358 let bad_producer = endpoint_with_config_overrides(
5359 &bad_url,
5360 Some("bad\r\nua".to_string()),
5361 HttpAuth::Bearer {
5362 token: "tok\r\nen".to_string(),
5363 },
5364 )
5365 .create_producer(rt(), &ctx)
5366 .unwrap();
5367 let ok_producer = endpoint_with_config_overrides(
5368 &ok_url,
5369 Some("httpsweep-ok/1".to_string()),
5370 HttpAuth::Bearer {
5371 token: "valid-token".to_string(),
5372 },
5373 )
5374 .create_producer(rt(), &ctx)
5375 .unwrap();
5376
5377 let bad_exchange = Exchange::new(Message::default());
5378 let ok_exchange = Exchange::new(Message::default());
5379 let bad_cid = bad_exchange.correlation_id().to_string();
5380 let ok_cid = ok_exchange.correlation_id().to_string();
5381
5382 let bad_result = bad_producer.oneshot(bad_exchange).await;
5383 assert!(
5384 bad_result.is_ok(),
5385 "invalid-config producer call failed: {bad_result:?}"
5386 );
5387 let ok_result = ok_producer.oneshot(ok_exchange).await;
5388 assert!(
5389 ok_result.is_ok(),
5390 "valid-config producer call failed: {ok_result:?}"
5391 );
5392
5393 tokio::time::sleep(Duration::from_millis(100)).await;
5394 let bad_request = bad_captured
5395 .lock()
5396 .unwrap()
5397 .take()
5398 .expect("no outbound request captured");
5399 let ok_request = ok_captured
5400 .lock()
5401 .unwrap()
5402 .take()
5403 .expect("no outbound request captured");
5404
5405 let bad_lower = bad_request.to_ascii_lowercase();
5408 assert!(
5409 !bad_lower.lines().any(|l| l.starts_with("authorization:")),
5410 "invalid Bearer token must not reach the wire\n{bad_request}"
5411 );
5412 assert!(
5413 !bad_request.contains("bad\r\nua"),
5414 "invalid configured user-agent must not reach the wire\n{bad_request}"
5415 );
5416
5417 logs_assert(|lines: &[&str]| {
5418 let drops: Vec<&&str> = lines
5419 .iter()
5420 .filter(|l| {
5421 l.contains("outbound header dropped")
5422 && l.contains(&format!("correlation_id={bad_cid}"))
5423 })
5424 .collect();
5425 if drops.len() != 2 {
5426 return Err(format!(
5427 "expected exactly 2 drop records for {bad_cid}, found {}",
5428 drops.len()
5429 ));
5430 }
5431 let has_ua = drops.iter().any(|l| l.contains("header=user-agent"));
5432 let has_auth = drops.iter().any(|l| l.contains("header=authorization"));
5433 let reason_ok = drops
5434 .iter()
5435 .all(|l| l.contains("outbound header dropped: invalid header value"));
5436 match (has_ua, has_auth, reason_ok) {
5437 (true, true, true) => Ok(()),
5438 _ => Err(format!(
5439 "drop records mismatched: user-agent={has_ua} \
5440 authorization={has_auth} reason-ok={reason_ok}"
5441 )),
5442 }
5443 });
5444 logs_assert(|lines: &[&str]| {
5445 if lines
5446 .iter()
5447 .any(|l| l.contains("bad\r\nua") || l.contains("tok\r\nen"))
5448 {
5449 Err("sentinel CRLF values leaked into logs".to_string())
5450 } else {
5451 Ok(())
5452 }
5453 });
5454
5455 let ok_lower = ok_request.to_ascii_lowercase();
5458 assert!(
5459 ok_lower.contains("user-agent: httpsweep-ok/1"),
5460 "valid configured user-agent must reach the wire\n{ok_request}"
5461 );
5462 assert!(
5463 ok_lower.contains("authorization: bearer valid-token"),
5464 "valid Bearer token must reach the wire\n{ok_request}"
5465 );
5466 logs_assert(|lines: &[&str]| {
5467 let hits = lines
5468 .iter()
5469 .filter(|l| {
5470 l.contains("outbound header dropped")
5471 && l.contains(&format!("correlation_id={ok_cid}"))
5472 })
5473 .count();
5474 match hits {
5475 0 => Ok(()),
5476 n => Err(format!("expected no drop records for {ok_cid}, found {n}")),
5477 }
5478 });
5479 }
5480
5481 #[tokio::test]
5482 async fn producer_honours_skip_request_headers() {
5483 use tower::ServiceExt;
5484
5485 let (url, captured, _handle) = start_request_capturing_server().await;
5486 let ctx = test_producer_ctx();
5487 let component = HttpComponent::new();
5488 let endpoint_ctx = NoOpComponentContext;
5489 let endpoint = component
5490 .create_endpoint(
5491 &format!("{url}/api/test?allowInternal=true&skipRequestHeaders=Authorization"),
5492 &endpoint_ctx,
5493 )
5494 .unwrap();
5495 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5496
5497 let mut exchange = Exchange::new(Message::default());
5498 exchange.input.set_header("Authorization", "Bearer x");
5499
5500 let result = producer.oneshot(exchange).await;
5501 assert!(result.is_ok(), "producer call failed: {:?}", result);
5502
5503 tokio::time::sleep(Duration::from_millis(100)).await;
5504 let request = captured
5505 .lock()
5506 .unwrap()
5507 .take()
5508 .expect("no outbound request captured");
5509 assert!(
5510 !request.to_ascii_lowercase().contains("authorization"),
5511 "Authorization must be stripped by skipRequestHeaders\n{request}"
5512 );
5513 }
5514
5515 #[tokio::test]
5516 async fn producer_stringifies_scalar_header_values_on_wire() {
5517 use tower::ServiceExt;
5518
5519 let (url, captured, _handle) = start_request_capturing_server().await;
5520 let ctx = test_producer_ctx();
5521 let component = HttpComponent::new();
5522 let endpoint_ctx = NoOpComponentContext;
5523 let endpoint = component
5524 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5525 .unwrap();
5526 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5527
5528 let mut exchange = Exchange::new(Message::default());
5529 exchange.input.set_header("X-Retries", serde_json::json!(3));
5530 exchange
5531 .input
5532 .set_header("X-Enabled", serde_json::json!(true));
5533 exchange
5534 .input
5535 .set_header("X-Obj", serde_json::json!({"a": 1}));
5536
5537 let result = producer.oneshot(exchange).await;
5538 assert!(result.is_ok(), "producer call failed: {:?}", result);
5539
5540 tokio::time::sleep(Duration::from_millis(100)).await;
5541 let request = captured
5542 .lock()
5543 .unwrap()
5544 .take()
5545 .expect("no outbound request captured");
5546 let lower = request.to_ascii_lowercase();
5547 assert!(
5548 lower.contains("x-retries: 3"),
5549 "numeric header must reach the wire stringified\n{request}"
5550 );
5551 assert!(
5552 lower.contains("x-enabled: true"),
5553 "bool header must reach the wire stringified\n{request}"
5554 );
5555 assert!(
5556 !lower.contains("x-obj:"),
5557 "object header has no single-value form and must not reach the wire\n{request}"
5558 );
5559 }
5560
5561 #[tokio::test]
5562 async fn test_http_producer_post_with_body() {
5563 use tower::ServiceExt;
5564
5565 let (url, _handle) = start_test_server().await;
5566 let ctx = test_producer_ctx();
5567
5568 let component = HttpComponent::new();
5569 let endpoint_ctx = NoOpComponentContext;
5570 let endpoint = component
5571 .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
5572 .unwrap();
5573 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5574
5575 let exchange = Exchange::new(Message::new("request body"));
5576 let result = producer.oneshot(exchange).await.unwrap();
5577
5578 let status = result
5579 .input
5580 .header("CamelHttpResponseCode")
5581 .and_then(|v| v.as_u64())
5582 .unwrap();
5583 assert_eq!(status, 200);
5584 }
5585
5586 #[tokio::test]
5587 async fn test_http_producer_method_from_header() {
5588 use tower::ServiceExt;
5589
5590 let (url, _handle) = start_test_server().await;
5591 let ctx = test_producer_ctx();
5592
5593 let component = HttpComponent::new();
5594 let endpoint_ctx = NoOpComponentContext;
5595 let endpoint = component
5596 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5597 .unwrap();
5598 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5599
5600 let mut exchange = Exchange::new(Message::default());
5601 exchange.input.set_header(
5602 "CamelHttpMethod",
5603 serde_json::Value::String("DELETE".to_string()),
5604 );
5605
5606 let result = producer.oneshot(exchange).await.unwrap();
5607 let status = result
5608 .input
5609 .header("CamelHttpResponseCode")
5610 .and_then(|v| v.as_u64())
5611 .unwrap();
5612 assert_eq!(status, 200);
5613 }
5614
5615 #[tokio::test]
5616 async fn test_http_producer_forced_method() {
5617 use tower::ServiceExt;
5618
5619 let (url, _handle) = start_test_server().await;
5620 let ctx = test_producer_ctx();
5621
5622 let component = HttpComponent::new();
5623 let endpoint_ctx = NoOpComponentContext;
5624 let endpoint = component
5625 .create_endpoint(
5626 &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
5627 &endpoint_ctx,
5628 )
5629 .unwrap();
5630 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5631
5632 let exchange = Exchange::new(Message::default());
5633 let result = producer.oneshot(exchange).await.unwrap();
5634
5635 let status = result
5636 .input
5637 .header("CamelHttpResponseCode")
5638 .and_then(|v| v.as_u64())
5639 .unwrap();
5640 assert_eq!(status, 200);
5641 }
5642
5643 #[tokio::test]
5644 async fn test_http_producer_throw_exception_on_failure() {
5645 use tower::ServiceExt;
5646
5647 let (url, _handle) = start_status_server(404).await;
5648 let ctx = test_producer_ctx();
5649
5650 let component = HttpComponent::new();
5651 let endpoint_ctx = NoOpComponentContext;
5652 let endpoint = component
5653 .create_endpoint(
5654 &format!("{url}/not-found?allowInternal=true"),
5655 &endpoint_ctx,
5656 )
5657 .unwrap();
5658 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5659
5660 let exchange = Exchange::new(Message::default());
5661 let result = producer.oneshot(exchange).await;
5662 assert!(result.is_err());
5663
5664 match result.unwrap_err() {
5665 CamelError::HttpOperationFailed { status_code, .. } => {
5666 assert_eq!(status_code, 404);
5667 }
5668 e => panic!("Expected HttpOperationFailed, got: {e}"),
5669 }
5670 }
5671
5672 #[tokio::test]
5673 async fn test_http_producer_no_throw_on_failure() {
5674 use tower::ServiceExt;
5675
5676 let (url, _handle) = start_status_server(500).await;
5677 let ctx = test_producer_ctx();
5678
5679 let component = HttpComponent::new();
5680 let endpoint_ctx = NoOpComponentContext;
5681 let endpoint = component
5682 .create_endpoint(
5683 &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
5684 &endpoint_ctx,
5685 )
5686 .unwrap();
5687 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5688
5689 let exchange = Exchange::new(Message::default());
5690 let result = producer.oneshot(exchange).await.unwrap();
5691
5692 let status = result
5693 .input
5694 .header("CamelHttpResponseCode")
5695 .and_then(|v| v.as_u64())
5696 .unwrap();
5697 assert_eq!(status, 500);
5698 }
5699
5700 #[tokio::test]
5701 async fn test_http_producer_uri_override() {
5702 use tower::ServiceExt;
5703
5704 let (url, _handle) = start_test_server().await;
5705 let ctx = test_producer_ctx();
5706
5707 let component = HttpComponent::new();
5708 let endpoint_ctx = NoOpComponentContext;
5709 let endpoint = component
5710 .create_endpoint(
5711 "http://localhost:1/does-not-exist?allowInternal=true",
5712 &endpoint_ctx,
5713 )
5714 .unwrap();
5715 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5716
5717 let mut exchange = Exchange::new(Message::default());
5718 exchange.input.set_header(
5719 "CamelHttpUri",
5720 serde_json::Value::String(format!("{url}/api")),
5721 );
5722
5723 let result = producer.oneshot(exchange).await.unwrap();
5724 let status = result
5725 .input
5726 .header("CamelHttpResponseCode")
5727 .and_then(|v| v.as_u64())
5728 .unwrap();
5729 assert_eq!(status, 200);
5730 }
5731
5732 #[tokio::test]
5733 async fn test_http_producer_response_headers_mapped() {
5734 use tower::ServiceExt;
5735
5736 let (url, _handle) = start_test_server().await;
5737 let ctx = test_producer_ctx();
5738
5739 let component = HttpComponent::new();
5740 let endpoint_ctx = NoOpComponentContext;
5741 let endpoint = component
5742 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5743 .unwrap();
5744 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5745
5746 let exchange = Exchange::new(Message::default());
5747 let result = producer.oneshot(exchange).await.unwrap();
5748
5749 assert!(
5750 result.input.header("Content-Type").is_some(),
5751 "Response should have Content-Type header"
5752 );
5753 assert!(result.input.header("CamelHttpResponseText").is_some());
5754 }
5755
5756 async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
5761 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5762 let addr = listener.local_addr().unwrap();
5763 let url = format!("http://127.0.0.1:{}", addr.port());
5764
5765 let handle = tokio::spawn(async move {
5766 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5767 loop {
5768 if let Ok((mut stream, _)) = listener.accept().await {
5769 tokio::spawn(async move {
5770 let mut buf = vec![0u8; 4096];
5771 let n = stream.read(&mut buf).await.unwrap_or(0);
5772 let request = String::from_utf8_lossy(&buf[..n]).to_string();
5773
5774 if request.contains("GET /final") {
5776 let body = r#"{"status":"final"}"#;
5777 let response = format!(
5778 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
5779 body.len(),
5780 body
5781 );
5782 let _ = stream.write_all(response.as_bytes()).await;
5783 } else {
5784 let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
5789 let _ = stream.write_all(response.as_bytes()).await;
5790 }
5791 });
5792 }
5793 }
5794 });
5795
5796 (url, handle)
5797 }
5798
5799 struct CapturedRequest {
5800 method: String,
5801 path: String,
5802 body: Vec<u8>,
5803 content_length: Option<String>,
5804 transfer_encoding: Option<String>,
5805 }
5806
5807 async fn capture_request(stream: &mut tokio::net::TcpStream) -> Option<CapturedRequest> {
5813 use tokio::io::AsyncReadExt;
5814
5815 let mut buf: Vec<u8> = Vec::new();
5817 let mut chunk = [0u8; 4096];
5818 let head_end: usize;
5819 loop {
5820 let n = stream.read(&mut chunk).await.unwrap_or(0);
5821 if n == 0 {
5822 return None;
5823 }
5824 buf.extend_from_slice(&chunk[..n]);
5825 if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
5826 head_end = pos + 4;
5827 break;
5828 }
5829 }
5830
5831 let head = String::from_utf8_lossy(&buf[..head_end]);
5833 let mut lines = head.split("\r\n");
5834 let request_line = lines.next().unwrap_or("");
5835 let mut parts = request_line.split_whitespace();
5836 let method = parts.next().unwrap_or("").to_string();
5837 let path = parts.next().unwrap_or("").to_string();
5838
5839 let mut content_length: Option<String> = None;
5840 let mut transfer_encoding: Option<String> = None;
5841 for line in lines {
5842 if let Some((name, value)) = line.split_once(':') {
5843 let name = name.trim().to_ascii_lowercase();
5844 let value = value.trim().to_string();
5845 if name == "content-length" {
5846 content_length = Some(value);
5847 } else if name == "transfer-encoding" {
5848 transfer_encoding = Some(value);
5849 }
5850 }
5851 }
5852
5853 let body_len: usize = content_length
5855 .as_deref()
5856 .and_then(|v| v.parse::<usize>().ok())
5857 .unwrap_or(0);
5858
5859 let mut body: Vec<u8> = buf[head_end..].to_vec();
5860 while body.len() < body_len {
5861 let n = stream.read(&mut chunk).await.unwrap_or(0);
5862 if n == 0 {
5863 break;
5864 }
5865 body.extend_from_slice(&chunk[..n]);
5866 }
5867 body.truncate(body_len);
5868
5869 Some(CapturedRequest {
5870 method,
5871 path,
5872 body,
5873 content_length,
5874 transfer_encoding,
5875 })
5876 }
5877
5878 async fn start_capture_server() -> (
5883 String,
5884 tokio::task::JoinHandle<()>,
5885 Arc<Mutex<Vec<CapturedRequest>>>,
5886 ) {
5887 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5888 let addr = listener.local_addr().unwrap();
5889 let url = format!("http://127.0.0.1:{}", addr.port());
5890
5891 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
5892 let captured_for_return = Arc::clone(&captured);
5893
5894 let handle = tokio::spawn(async move {
5895 use tokio::io::AsyncWriteExt;
5896 loop {
5897 if let Ok((mut stream, _)) = listener.accept().await {
5898 let captured = Arc::clone(&captured);
5899 tokio::spawn(async move {
5900 let Some(req) = capture_request(&mut stream).await else {
5901 return;
5902 };
5903 captured.lock().unwrap().push(req);
5904
5905 let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
5908 let _ = stream.write_all(response.as_bytes()).await;
5909 });
5910 }
5911 }
5912 });
5913
5914 (url, handle, captured_for_return)
5915 }
5916
5917 async fn start_redirect_capture_server() -> (
5923 String,
5924 tokio::task::JoinHandle<()>,
5925 Arc<Mutex<Vec<CapturedRequest>>>,
5926 ) {
5927 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5928 let addr = listener.local_addr().unwrap();
5929 let url = format!("http://127.0.0.1:{}", addr.port());
5930
5931 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
5932 let captured_for_return = Arc::clone(&captured);
5933
5934 let handle = tokio::spawn(async move {
5935 use tokio::io::AsyncWriteExt;
5936 loop {
5937 if let Ok((mut stream, _)) = listener.accept().await {
5938 let captured = Arc::clone(&captured);
5939 tokio::spawn(async move {
5940 let Some(req) = capture_request(&mut stream).await else {
5941 return;
5942 };
5943 let path = req.path.clone();
5944 captured.lock().unwrap().push(req);
5945
5946 let (status_line, location) = match path.as_str() {
5947 "/hop307" => ("HTTP/1.1 307 Temporary Redirect", Some("/final")),
5948 "/hop308" => ("HTTP/1.1 308 Permanent Redirect", Some("/final")),
5949 "/final" => ("HTTP/1.1 200 OK", None),
5950 _ => ("HTTP/1.1 404 Not Found", None),
5951 };
5952
5953 let response = match location {
5954 Some(loc) => format!(
5958 "{status_line}\r\nLocation: {loc}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
5959 ),
5960 None => format!("{status_line}\r\nContent-Length: 0\r\n\r\n"),
5961 };
5962 let _ = stream.write_all(response.as_bytes()).await;
5963 });
5964 }
5965 }
5966 });
5967
5968 (url, handle, captured_for_return)
5969 }
5970
5971 #[tokio::test]
5972 async fn test_get_with_body_sends_no_body_and_no_framing_headers() {
5973 use tower::ServiceExt;
5974
5975 let (url, _handle, captured) = start_capture_server().await;
5976 let ctx = test_producer_ctx();
5977
5978 let component = HttpComponent::with_config(HttpConfig::default());
5979 let endpoint_ctx = NoOpComponentContext;
5980 let endpoint = component
5981 .create_endpoint(
5982 &format!("{url}?httpMethod=GET&allowInternal=true"),
5983 &endpoint_ctx,
5984 )
5985 .unwrap();
5986 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5987
5988 let mut exchange = Exchange::new(Message::default());
5989 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5990
5991 let result = producer.oneshot(exchange).await.unwrap();
5992
5993 let status = result
5994 .input
5995 .header("CamelHttpResponseCode")
5996 .and_then(|v| v.as_u64())
5997 .unwrap();
5998 assert_eq!(status, 200);
5999
6000 let captured = captured.lock().unwrap();
6001 assert_eq!(captured.len(), 1, "expected exactly one captured request");
6002 let req = &captured[0];
6003 assert_eq!(req.method, "GET");
6004 assert_eq!(req.path, "/");
6007 assert!(req.body.is_empty(), "GET must not carry a body");
6008 assert!(
6009 req.content_length.is_none(),
6010 "suppressed request must not carry Content-Length"
6011 );
6012 assert!(
6013 req.transfer_encoding.is_none(),
6014 "suppressed request must not carry Transfer-Encoding"
6015 );
6016
6017 assert!(
6019 result.input.body.is_empty(),
6020 "exchange body must be consumed"
6021 );
6022 }
6023
6024 #[tokio::test]
6025 async fn test_head_with_body_suppressed_via_header() {
6026 use tower::ServiceExt;
6027
6028 let (url, _handle, captured) = start_capture_server().await;
6029 let ctx = test_producer_ctx();
6030
6031 let component = HttpComponent::with_config(HttpConfig::default());
6032 let endpoint_ctx = NoOpComponentContext;
6033 let endpoint = component
6034 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6035 .unwrap();
6036 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6037
6038 let mut exchange = Exchange::new(Message::default());
6039 exchange.input.set_header(
6040 "CamelHttpMethod",
6041 serde_json::Value::String("HEAD".to_string()),
6042 );
6043 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6044
6045 let result = producer.oneshot(exchange).await.unwrap();
6046 let status = result
6047 .input
6048 .header("CamelHttpResponseCode")
6049 .and_then(|v| v.as_u64())
6050 .unwrap();
6051 assert_eq!(status, 200);
6052
6053 let captured = captured.lock().unwrap();
6054 assert_eq!(captured.len(), 1);
6055 let req = &captured[0];
6056 assert_eq!(req.method, "HEAD");
6057 assert!(req.body.is_empty(), "HEAD must not carry a body");
6058 }
6059
6060 #[tokio::test]
6061 async fn test_delete_options_trace_with_body_suppressed() {
6062 use tower::ServiceExt;
6063
6064 let (url, _handle, captured) = start_capture_server().await;
6065 let ctx = test_producer_ctx();
6066 let component = HttpComponent::with_config(HttpConfig::default());
6067 let endpoint_ctx = NoOpComponentContext;
6068
6069 for method in ["DELETE", "OPTIONS", "TRACE"] {
6070 let endpoint = component
6071 .create_endpoint(
6072 &format!("{url}?httpMethod={method}&allowInternal=true"),
6073 &endpoint_ctx,
6074 )
6075 .unwrap();
6076 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6077
6078 let mut exchange = Exchange::new(Message::default());
6079 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6080
6081 let result = producer.oneshot(exchange).await.unwrap();
6082 let status = result
6083 .input
6084 .header("CamelHttpResponseCode")
6085 .and_then(|v| v.as_u64())
6086 .unwrap();
6087 assert_eq!(status, 200, "method {method} should succeed");
6088 }
6089
6090 let captured = captured.lock().unwrap();
6091 assert_eq!(captured.len(), 3, "expected three captured requests");
6092 for method in ["DELETE", "OPTIONS", "TRACE"] {
6093 let req = captured
6094 .iter()
6095 .find(|r| r.method == method)
6096 .unwrap_or_else(|| panic!("missing captured request for {method}"));
6097 assert!(req.body.is_empty(), "{} must not carry a body", req.method);
6098 }
6099 }
6100
6101 #[tokio::test]
6102 async fn test_post_put_patch_with_body_still_sent() {
6103 use tower::ServiceExt;
6104
6105 let (url, _handle, captured) = start_capture_server().await;
6106 let ctx = test_producer_ctx();
6107 let component = HttpComponent::with_config(HttpConfig::default());
6108 let endpoint_ctx = NoOpComponentContext;
6109
6110 for method in ["POST", "PUT", "PATCH"] {
6111 let endpoint = component
6112 .create_endpoint(
6113 &format!("{url}?httpMethod={method}&allowInternal=true"),
6114 &endpoint_ctx,
6115 )
6116 .unwrap();
6117 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6118
6119 let payload = format!("body-for-{method}");
6120 let mut exchange = Exchange::new(Message::default());
6121 exchange.input.body = Body::Bytes(bytes::Bytes::from(payload.as_bytes().to_vec()));
6122
6123 let result = producer.oneshot(exchange).await.unwrap();
6124 let status = result
6125 .input
6126 .header("CamelHttpResponseCode")
6127 .and_then(|v| v.as_u64())
6128 .unwrap();
6129 assert_eq!(status, 200, "method {method} should succeed");
6130 }
6131
6132 let captured = captured.lock().unwrap();
6133 assert_eq!(captured.len(), 3, "expected three captured requests");
6134 for method in ["POST", "PUT", "PATCH"] {
6135 let req = captured
6136 .iter()
6137 .find(|r| r.method == method)
6138 .unwrap_or_else(|| panic!("missing captured request for {method}"));
6139 let expected = format!("body-for-{method}");
6140 assert!(!req.body.is_empty(), "{method} must still carry its body");
6141 assert_eq!(req.body, expected.as_bytes(), "{method} body mismatch");
6142 }
6143 }
6144
6145 #[tokio::test]
6149 async fn test_stream_body_under_get_not_attached() {
6150 use tower::ServiceExt;
6151
6152 let (url, _handle, captured) = start_capture_server().await;
6153 let ctx = test_producer_ctx();
6154
6155 let component = HttpComponent::with_config(HttpConfig::default());
6156 let endpoint_ctx = NoOpComponentContext;
6157 let endpoint = component
6158 .create_endpoint(
6159 &format!("{url}?httpMethod=GET&allowInternal=true"),
6160 &endpoint_ctx,
6161 )
6162 .unwrap();
6163 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6164
6165 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
6166 vec![Ok(bytes::Bytes::from_static(b"stream-body"))];
6167 let stream = Box::pin(futures::stream::iter(chunks));
6168 let mut exchange = Exchange::new(Message::default());
6169 exchange.input.body = Body::Stream(StreamBody {
6170 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
6171 metadata: StreamMetadata::default(),
6172 });
6173
6174 let result = producer.oneshot(exchange).await.unwrap();
6175
6176 let status = result
6177 .input
6178 .header("CamelHttpResponseCode")
6179 .and_then(|v| v.as_u64())
6180 .unwrap();
6181 assert_eq!(status, 200);
6182
6183 let captured = captured.lock().unwrap();
6184 assert_eq!(captured.len(), 1, "expected exactly one captured request");
6185 assert!(
6186 captured[0].body.is_empty(),
6187 "GET must not carry a stream body"
6188 );
6189 assert!(
6190 captured[0].transfer_encoding.is_none(),
6191 "suppressed request must not carry Transfer-Encoding"
6192 );
6193 assert!(
6194 captured[0].content_length.is_none(),
6195 "suppressed request must not carry Content-Length"
6196 );
6197 assert!(
6198 result.input.body.is_empty(),
6199 "exchange body must be consumed to Empty, not left as a stream"
6200 );
6201 }
6202
6203 #[tokio::test]
6207 async fn test_redirect_hops_never_replay_suppressed_body() {
6208 use tower::ServiceExt;
6209
6210 let (url, _handle, captured) = start_redirect_capture_server().await;
6211 let ctx = test_producer_ctx();
6212
6213 let component =
6214 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6215 let endpoint_ctx = NoOpComponentContext;
6216
6217 for path in ["/hop307", "/hop308"] {
6218 let endpoint = component
6219 .create_endpoint(
6220 &format!("{url}{path}?httpMethod=GET&allowInternal=true"),
6221 &endpoint_ctx,
6222 )
6223 .unwrap();
6224 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6225
6226 let mut exchange = Exchange::new(Message::default());
6227 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6228
6229 let result = producer.oneshot(exchange).await.unwrap();
6230 let status = result
6231 .input
6232 .header("CamelHttpResponseCode")
6233 .and_then(|v| v.as_u64())
6234 .unwrap();
6235 assert_eq!(
6236 status, 200,
6237 "redirect chain for {path} should end at /final"
6238 );
6239 }
6240
6241 let captured = captured.lock().unwrap();
6243 assert_eq!(captured.len(), 4, "expected 2 chains × 2 hops");
6244 for req in captured.iter() {
6245 assert!(
6246 req.body.is_empty(),
6247 "hop {} {} must not carry a body",
6248 req.method,
6249 req.path
6250 );
6251 }
6252 }
6253
6254 #[tracing_test::traced_test]
6263 #[tokio::test]
6264 async fn test_suppressed_body_logs_exactly_one_warn() {
6265 use tower::ServiceExt;
6266
6267 let (url, _handle, _captured) = start_capture_server().await;
6268 let ctx = test_producer_ctx();
6269
6270 let component = HttpComponent::with_config(HttpConfig::default());
6271 let endpoint_ctx = NoOpComponentContext;
6272 let endpoint = component
6273 .create_endpoint(
6274 &format!("{url}?httpMethod=GET&allowInternal=true"),
6275 &endpoint_ctx,
6276 )
6277 .unwrap();
6278 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6279
6280 let mut exchange = Exchange::new(Message::default());
6281 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6282 let correlation_id = exchange.correlation_id().to_string();
6283
6284 let result = producer.oneshot(exchange).await.unwrap();
6285 let status = result
6286 .input
6287 .header("CamelHttpResponseCode")
6288 .and_then(|v| v.as_u64())
6289 .unwrap();
6290 assert_eq!(status, 200);
6291
6292 logs_assert(|lines: &[&str]| {
6293 let hits = lines
6294 .iter()
6295 .filter(|l| {
6296 l.contains("dropping request body")
6297 && l.contains("method=GET")
6298 && l.contains(&format!("correlation_id={correlation_id}"))
6299 })
6300 .count();
6301 match hits {
6302 1 => Ok(()),
6303 n => Err(format!("expected exactly one body-drop warn, found {n}")),
6304 }
6305 });
6306 }
6307
6308 #[tracing_test::traced_test]
6309 #[tokio::test]
6310 async fn test_empty_body_get_emits_no_warn() {
6311 use tower::ServiceExt;
6312
6313 let (url, _handle, _captured) = start_capture_server().await;
6314 let ctx = test_producer_ctx();
6315
6316 let component = HttpComponent::with_config(HttpConfig::default());
6317 let endpoint_ctx = NoOpComponentContext;
6318 let endpoint = component
6319 .create_endpoint(
6320 &format!("{url}?httpMethod=GET&allowInternal=true"),
6321 &endpoint_ctx,
6322 )
6323 .unwrap();
6324 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6325
6326 let exchange = Exchange::new(Message::default());
6327 let result = producer.oneshot(exchange).await.unwrap();
6328 let status = result
6329 .input
6330 .header("CamelHttpResponseCode")
6331 .and_then(|v| v.as_u64())
6332 .unwrap();
6333 assert_eq!(status, 200);
6334
6335 logs_assert(|lines: &[&str]| {
6336 let hits = lines
6337 .iter()
6338 .filter(|l| l.contains("dropping request body"))
6339 .count();
6340 match hits {
6341 0 => Ok(()),
6342 n => Err(format!("expected no body-drop warn, found {n}")),
6343 }
6344 });
6345 }
6346
6347 #[tokio::test]
6348 async fn test_follow_redirects_false_does_not_follow() {
6349 use tower::ServiceExt;
6350
6351 let (url, _handle) = start_redirect_server().await;
6352 let ctx = test_producer_ctx();
6353
6354 let component =
6355 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
6356 let endpoint_ctx = NoOpComponentContext;
6357 let endpoint = component
6358 .create_endpoint(
6359 &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
6360 &endpoint_ctx,
6361 )
6362 .unwrap();
6363 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6364
6365 let exchange = Exchange::new(Message::default());
6366 let result = producer.oneshot(exchange).await.unwrap();
6367
6368 let status = result
6370 .input
6371 .header("CamelHttpResponseCode")
6372 .and_then(|v| v.as_u64())
6373 .unwrap();
6374 assert_eq!(
6375 status, 302,
6376 "Should NOT follow redirect when followRedirects=false"
6377 );
6378 }
6379
6380 #[tokio::test]
6381 async fn test_follow_redirects_true_follows_redirect() {
6382 use tower::ServiceExt;
6383
6384 let (url, _handle) = start_redirect_server().await;
6385 let ctx = test_producer_ctx();
6386
6387 let component =
6388 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6389 let endpoint_ctx = NoOpComponentContext;
6390 let endpoint = component
6391 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6392 .unwrap();
6393 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6394
6395 let exchange = Exchange::new(Message::default());
6396 let result = producer.oneshot(exchange).await.unwrap();
6397
6398 let status = result
6400 .input
6401 .header("CamelHttpResponseCode")
6402 .and_then(|v| v.as_u64())
6403 .unwrap();
6404 assert_eq!(
6405 status, 200,
6406 "Should follow redirect when followRedirects=true"
6407 );
6408 }
6409
6410 #[tokio::test]
6413 async fn test_redirect_to_private_ip_is_ssrf_blocked() {
6414 use tower::ServiceExt;
6415
6416 let (url, _handle) = start_redirect_server().await;
6418 let ctx = test_producer_ctx();
6419
6420 let component =
6421 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6422 let endpoint_ctx = NoOpComponentContext;
6423 let endpoint = component
6424 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6425 .unwrap();
6426 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6427
6428 let exchange = Exchange::new(Message::default());
6429 let result = producer.oneshot(exchange).await;
6430
6431 assert!(
6433 result.is_ok(),
6434 "Redirect should succeed with allowInternal=true, got: {:?}",
6435 result
6436 );
6437 let exchange = result.unwrap();
6438 let status = exchange
6439 .input
6440 .header("CamelHttpResponseCode")
6441 .and_then(|v| v.as_u64())
6442 .unwrap();
6443 assert_eq!(status, 200, "Should follow redirect to /final");
6444 }
6445
6446 #[tokio::test]
6448 async fn test_redirect_to_private_ip_allowed_when_configured() {
6449 use tower::ServiceExt;
6450
6451 let (url, _handle) = start_redirect_server().await;
6453 let ctx = test_producer_ctx();
6454
6455 let component =
6456 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6457 let endpoint_ctx = NoOpComponentContext;
6458 let endpoint = component
6459 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6460 .unwrap();
6461 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6462
6463 let exchange = Exchange::new(Message::default());
6464 let result = producer.oneshot(exchange).await.unwrap();
6465
6466 let status = result
6467 .input
6468 .header("CamelHttpResponseCode")
6469 .and_then(|v| v.as_u64())
6470 .unwrap();
6471 assert_eq!(
6472 status, 200,
6473 "Should follow redirect to private IP when allowInternal=true"
6474 );
6475 }
6476
6477 #[tokio::test]
6480 async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
6481 use tower::ServiceExt;
6482
6483 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6485 let addr = listener.local_addr().unwrap();
6486 let url = format!("http://127.0.0.1:{}", addr.port());
6487
6488 let handle = tokio::spawn(async move {
6489 use tokio::io::{AsyncReadExt, AsyncWriteExt};
6490 loop {
6491 if let Ok((mut stream, _)) = listener.accept().await {
6492 tokio::spawn(async move {
6493 let mut buf = vec![0u8; 4096];
6494 let _ = stream.read(&mut buf).await;
6495 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";
6497 let _ = stream.write_all(response.as_bytes()).await;
6498 });
6499 }
6500 }
6501 });
6502
6503 let ctx = test_producer_ctx();
6504 let component =
6505 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6506 let endpoint_ctx = NoOpComponentContext;
6507 let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
6509 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6510
6511 let exchange = Exchange::new(Message::default());
6512 let result = producer.oneshot(exchange).await;
6513
6514 assert!(
6516 result.is_err(),
6517 "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
6518 );
6519 let err = result.unwrap_err().to_string();
6520 assert!(
6521 err.contains("blocked IP")
6522 || err.contains("private IP")
6523 || err.contains("SSRF")
6524 || err.contains("not allowed"),
6525 "Error should mention SSRF/IP blocking, got: {err}"
6526 );
6527
6528 handle.abort();
6529 }
6530
6531 #[tokio::test]
6533 async fn test_too_many_redirects_returns_error() {
6534 use tower::ServiceExt;
6535
6536 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6538 let addr = listener.local_addr().unwrap();
6539 let url = format!("http://127.0.0.1:{}", addr.port());
6540
6541 let handle = tokio::spawn(async move {
6542 use tokio::io::{AsyncReadExt, AsyncWriteExt};
6543 loop {
6544 if let Ok((mut stream, _)) = listener.accept().await {
6545 tokio::spawn(async move {
6546 let mut buf = vec![0u8; 4096];
6547 let _ = stream.read(&mut buf).await;
6548 let response = "HTTP/1.1 302 Found\r\nLocation: /loop\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
6553 let _ = stream.write_all(response.as_bytes()).await;
6554 });
6555 }
6556 }
6557 });
6558
6559 let ctx = test_producer_ctx();
6560 let component =
6561 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6562 let endpoint_ctx = NoOpComponentContext;
6563 let endpoint = component
6564 .create_endpoint(
6565 &format!("{url}?allowInternal=true&maxRedirects=2"),
6566 &endpoint_ctx,
6567 )
6568 .unwrap();
6569 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6570
6571 let exchange = Exchange::new(Message::default());
6572 let result = producer.oneshot(exchange).await;
6573
6574 match result {
6582 Err(e) => {
6583 let msg = e.to_string();
6585 assert!(
6586 msg.contains("HTTP operation failed") || msg.contains("302"),
6587 "expected redirect-after-exhaustion error, got: {msg}"
6588 );
6589 }
6590 Ok(ex) => {
6591 let response_code = ex
6592 .input
6593 .header("CamelHttpResponseCode")
6594 .and_then(|v| v.as_u64());
6595 assert_eq!(
6596 response_code,
6597 Some(302),
6598 "expected 302 after exhausting redirects"
6599 );
6600 }
6601 }
6602
6603 handle.abort();
6604 }
6605
6606 #[tokio::test]
6607 async fn test_query_params_forwarded_to_http_request() {
6608 use tower::ServiceExt;
6609
6610 let (url, _handle) = start_test_server().await;
6611 let ctx = test_producer_ctx();
6612
6613 let component = HttpComponent::new();
6614 let endpoint_ctx = NoOpComponentContext;
6615 let endpoint = component
6617 .create_endpoint(
6618 &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
6619 &endpoint_ctx,
6620 )
6621 .unwrap();
6622 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6623
6624 let exchange = Exchange::new(Message::default());
6625 let result = producer.oneshot(exchange).await.unwrap();
6626
6627 let status = result
6630 .input
6631 .header("CamelHttpResponseCode")
6632 .and_then(|v| v.as_u64())
6633 .unwrap();
6634 assert_eq!(status, 200);
6635 }
6636
6637 #[test]
6638 fn test_non_camel_query_params_are_forwarded() {
6639 let config = HttpEndpointConfig::from_uri(
6642 "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
6643 )
6644 .unwrap();
6645
6646 assert_eq!(
6649 config.raw_query.as_deref(),
6650 Some("apiKey=secret123&httpMethod=GET&token=abc456")
6651 );
6652 assert!(config.query_params.is_empty());
6653 }
6654
6655 #[test]
6656 fn test_authored_query_bytes_survive_resolve_url() {
6657 let config =
6658 HttpEndpointConfig::from_uri("http://example.com/api?q=hello%20world&tag=a+b").unwrap();
6659 let exchange = Exchange::new(Message::default());
6660
6661 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
6662
6663 assert!(url.contains("q=hello%20world"), "url was: {url}");
6666 assert!(url.contains("tag=a+b"), "url was: {url}");
6667 }
6668
6669 async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
6674 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6675 let addr = listener.local_addr().unwrap();
6676 let url = format!("http://127.0.0.1:{}", addr.port());
6677
6678 let handle = tokio::spawn(async move {
6679 loop {
6680 if let Ok((mut stream, _)) = listener.accept().await {
6681 let delay = delay_ms;
6682 tokio::spawn(async move {
6683 use tokio::io::{AsyncReadExt, AsyncWriteExt};
6684 let mut buf = vec![0u8; 4096];
6685 let _ = stream.read(&mut buf).await;
6686 let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
6688 let _ = stream.write_all(headers.as_bytes()).await;
6689 tokio::time::sleep(Duration::from_millis(delay)).await;
6691 let body = r#"{"status":"slow"}"#;
6692 let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
6693 let _ = stream.write_all(chunk.as_bytes()).await;
6694 });
6695 }
6696 }
6697 });
6698
6699 (url, handle)
6700 }
6701
6702 #[tokio::test]
6703 async fn test_http_producer_timeout() {
6704 use tower::ServiceExt;
6705
6706 let (url, _handle) = start_slow_server(500).await;
6708 let ctx = test_producer_ctx();
6709
6710 let component = HttpComponent::with_config(
6711 HttpConfig::default()
6712 .with_read_timeout_ms(100)
6713 .with_response_timeout_ms(30_000), );
6715 let endpoint_ctx = NoOpComponentContext;
6716 let endpoint = component
6717 .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
6718 .unwrap();
6719 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6720
6721 let exchange = Exchange::new(Message::default());
6722 let result = producer.oneshot(exchange).await;
6723
6724 assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
6725 let err = result.unwrap_err().to_string();
6726 assert!(
6727 err.contains("Read timeout") || err.contains("timeout"),
6728 "Error should mention timeout, got: {}",
6729 err
6730 );
6731 }
6732
6733 #[tokio::test]
6734 async fn test_http_producer_no_timeout_when_fast() {
6735 use tower::ServiceExt;
6736
6737 let (url, _handle) = start_test_server().await;
6738 let ctx = test_producer_ctx();
6739
6740 let component =
6741 HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
6742 let endpoint_ctx = NoOpComponentContext;
6743 let endpoint = component
6744 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
6745 .unwrap();
6746 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6747
6748 let exchange = Exchange::new(Message::default());
6749 let result = producer.oneshot(exchange).await.unwrap();
6750
6751 let status = result
6752 .input
6753 .header("CamelHttpResponseCode")
6754 .and_then(|v| v.as_u64())
6755 .unwrap();
6756 assert_eq!(status, 200);
6757 }
6758
6759 #[tokio::test]
6764 async fn test_http_producer_blocks_metadata_endpoint() {
6765 use tower::ServiceExt;
6766
6767 let ctx = test_producer_ctx();
6768 let component = HttpComponent::new();
6769 let endpoint_ctx = NoOpComponentContext;
6770 let endpoint = component
6771 .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
6772 .unwrap();
6773 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6774
6775 let mut exchange = Exchange::new(Message::default());
6776 exchange.input.set_header(
6777 "CamelHttpUri",
6778 serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
6779 );
6780
6781 let result = producer.oneshot(exchange).await;
6782 assert!(result.is_err(), "Should block AWS metadata endpoint");
6783
6784 let err = result.unwrap_err();
6785 assert!(
6786 err.to_string().contains("Private IP"),
6787 "Error should mention private IP blocking, got: {}",
6788 err
6789 );
6790 }
6791
6792 #[test]
6793 fn test_ssrf_config_defaults() {
6794 let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
6795 assert!(
6796 !config.allow_internal,
6797 "Private IPs should be blocked by default"
6798 );
6799 assert!(
6800 config.blocked_hosts.is_empty(),
6801 "Blocked hosts should be empty by default"
6802 );
6803 }
6804
6805 #[test]
6806 fn test_ssrf_config_allow_internal() {
6807 let config =
6808 HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
6809 assert!(
6810 config.allow_internal,
6811 "Private IPs should be allowed when explicitly set"
6812 );
6813 }
6814
6815 #[test]
6816 fn test_uri_option_allow_cleartext_parses() {
6817 let config =
6818 HttpEndpointConfig::from_uri("http://example.com/?allowCleartext=true").unwrap();
6819 assert!(
6820 config.allow_cleartext,
6821 "allowCleartext=true must parse into the endpoint config"
6822 );
6823
6824 let plain = HttpEndpointConfig::from_uri("http://example.com/").unwrap();
6825 assert!(
6826 !plain.allow_cleartext,
6827 "cleartext consent must default to false"
6828 );
6829
6830 let err =
6831 HttpEndpointConfig::from_uri("http://example.com/?allowCleartext=banana").unwrap_err();
6832 assert!(
6833 matches!(&err, CamelError::InvalidUri(msg) if msg.contains("allowCleartext")),
6834 "bad allowCleartext value must yield InvalidUri naming the option, got: {err:?}"
6835 );
6836 }
6837
6838 #[test]
6842 fn test_camel_http_uri_override_public_cleartext_follows_endpoint_flags() {
6843 let endpoint =
6844 HttpEndpointConfig::from_uri("http://localhost/?allowCleartext=false").unwrap();
6845 let err = crate::ssrf::validate_url_for_ssrf("http://93.184.216.34/exfil", &endpoint)
6846 .expect_err("public cleartext override must be rejected without consent");
6847 assert!(
6848 err.to_string().contains("allowCleartext"),
6849 "error must name the remedy, got: {err}"
6850 );
6851
6852 let endpoint =
6853 HttpEndpointConfig::from_uri("http://localhost/?allowCleartext=true").unwrap();
6854 assert!(
6855 crate::ssrf::validate_url_for_ssrf("http://93.184.216.34/exfil", &endpoint).is_ok(),
6856 "endpoint consent must admit a public cleartext override"
6857 );
6858 }
6859
6860 #[test]
6861 fn test_ssrf_config_blocked_hosts() {
6862 let config = HttpEndpointConfig::from_uri(
6863 "http://example.com/api?blockedHosts=evil.com,malware.net",
6864 )
6865 .unwrap();
6866 assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
6867 }
6868
6869 #[tokio::test]
6870 async fn test_http_producer_blocks_localhost() {
6871 use tower::ServiceExt;
6872
6873 let ctx = test_producer_ctx();
6874 let component = HttpComponent::new();
6875 let endpoint_ctx = NoOpComponentContext;
6876 let endpoint = component
6877 .create_endpoint("http://example.com/api", &endpoint_ctx)
6878 .unwrap();
6879 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6880
6881 let mut exchange = Exchange::new(Message::default());
6882 exchange.input.set_header(
6883 "CamelHttpUri",
6884 serde_json::Value::String("http://localhost:8080/internal".to_string()),
6885 );
6886
6887 let result = producer.oneshot(exchange).await;
6888 assert!(result.is_err(), "Should block localhost");
6889 }
6890
6891 #[tokio::test]
6892 async fn test_http_producer_blocks_loopback_ip() {
6893 use tower::ServiceExt;
6894
6895 let ctx = test_producer_ctx();
6896 let component = HttpComponent::new();
6897 let endpoint_ctx = NoOpComponentContext;
6898 let endpoint = component
6899 .create_endpoint("http://example.com/api", &endpoint_ctx)
6900 .unwrap();
6901 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6902
6903 let mut exchange = Exchange::new(Message::default());
6904 exchange.input.set_header(
6905 "CamelHttpUri",
6906 serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
6907 );
6908
6909 let result = producer.oneshot(exchange).await;
6910 assert!(result.is_err(), "Should block loopback IP");
6911 }
6912
6913 #[tokio::test]
6914 async fn test_http_producer_allows_private_ip_when_enabled() {
6915 use tower::ServiceExt;
6916
6917 let ctx = test_producer_ctx();
6918 let component = HttpComponent::new();
6919 let endpoint_ctx = NoOpComponentContext;
6920 let endpoint = component
6923 .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
6924 .unwrap();
6925 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6926
6927 let exchange = Exchange::new(Message::default());
6928
6929 let result = producer.oneshot(exchange).await;
6932 if let Err(ref e) = result {
6934 let err_str = e.to_string();
6935 assert!(
6936 !err_str.contains("Private IP") && !err_str.contains("not allowed"),
6937 "Should not be SSRF error, got: {}",
6938 err_str
6939 );
6940 }
6941 }
6942
6943 #[test]
6948 fn test_http_server_config_parse() {
6949 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
6950 assert_eq!(cfg.host, "0.0.0.0");
6951 assert_eq!(cfg.port, 8080);
6952 assert_eq!(cfg.path, "/orders");
6953 assert_eq!(cfg.max_inflight_requests, 1024);
6954 }
6955
6956 #[test]
6957 fn test_http_server_config_scheme() {
6958 assert_eq!(HttpServerConfig::scheme(), "http");
6960 }
6961
6962 #[test]
6963 fn test_http_server_config_from_components() {
6964 let components = camel_component_api::UriComponents {
6966 scheme: "https".to_string(),
6967 path: "//0.0.0.0:8443/api".to_string(),
6968 params: std::collections::HashMap::from([
6969 ("maxRequestBody".to_string(), "5242880".to_string()),
6970 ("maxInflightRequests".to_string(), "7".to_string()),
6971 ]),
6972 raw_query: None,
6973 };
6974 let cfg = HttpServerConfig::from_components(components).unwrap();
6975 assert_eq!(cfg.host, "0.0.0.0");
6976 assert_eq!(cfg.port, 8443);
6977 assert_eq!(cfg.path, "/api");
6978 assert_eq!(cfg.max_request_body, 5242880);
6979 assert_eq!(cfg.max_inflight_requests, 7);
6980 }
6981
6982 #[test]
6983 fn test_http_server_config_default_path() {
6984 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
6985 assert_eq!(cfg.path, "/");
6986 }
6987
6988 #[test]
6989 fn test_http_server_config_wrong_scheme() {
6990 assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
6991 }
6992
6993 #[test]
6994 fn test_http_server_config_invalid_port() {
6995 assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
6996 }
6997
6998 #[test]
6999 fn test_http_server_config_default_port_by_scheme() {
7000 let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
7002 assert_eq!(cfg_http.port, 80);
7003
7004 let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
7006 assert_eq!(cfg_https.port, 443);
7007 }
7008
7009 #[test]
7010 fn test_request_envelope_and_reply_are_send() {
7011 fn assert_send<T: Send>() {}
7012 assert_send::<RequestEnvelope>();
7013 assert_send::<HttpReply>();
7014 }
7015
7016 #[test]
7021 fn test_server_registry_global_is_singleton() {
7022 let r1 = ServerRegistry::global();
7023 let r2 = ServerRegistry::global();
7024 assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
7025 }
7026
7027 #[allow(clippy::await_holding_lock)]
7028 #[tokio::test]
7029 async fn test_concurrent_get_or_spawn_returns_same_registry() {
7030 let _guard = lock_registry_test_mutex();
7031 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7032 let port = listener.local_addr().unwrap().port();
7033 drop(listener);
7034
7035 let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
7036 Arc::new(std::sync::Mutex::new(Vec::new()));
7037
7038 let mut handles = Vec::new();
7039 for _ in 0..4 {
7040 let results = results.clone();
7041 handles.push(tokio::spawn(async move {
7042 let registry = ServerRegistry::global()
7043 .get_or_spawn(
7044 "127.0.0.1",
7045 port,
7046 2 * 1024 * 1024,
7047 10 * 1024 * 1024,
7048 1024,
7049 test_rt(),
7050 "test-route".into(),
7051 None,
7052 )
7053 .await
7054 .unwrap();
7055 results.lock().unwrap().push(registry);
7056 }));
7057 }
7058
7059 for h in handles {
7060 h.await.unwrap();
7061 }
7062
7063 let registries = results.lock().unwrap();
7064 assert_eq!(registries.len(), 4);
7065 for i in 1..registries.len() {
7066 assert!(
7067 Arc::ptr_eq(®istries[0].inner, ®istries[i].inner),
7068 "all concurrent callers should get same route registry"
7069 );
7070 }
7071 }
7072
7073 #[test]
7074 fn test_server_registry_distinguishes_host_and_port() {
7075 let _guard = lock_registry_test_mutex();
7076 let rt = tokio::runtime::Runtime::new().expect("runtime");
7077 rt.block_on(async {
7078 let registry = ServerRegistry::global();
7079 let d1 = registry
7083 .get_or_spawn(
7084 "127.0.0.1",
7085 0,
7086 1024 * 1024,
7087 10 * 1024 * 1024,
7088 1024,
7089 test_rt(),
7090 "test-route-1".into(),
7091 None,
7092 )
7093 .await;
7094 let d2 = registry
7095 .get_or_spawn(
7096 "0.0.0.0",
7097 0,
7098 1024 * 1024,
7099 10 * 1024 * 1024,
7100 1024,
7101 test_rt(),
7102 "test-route-2".into(),
7103 None,
7104 )
7105 .await;
7106 assert!(d1.is_ok());
7107 assert!(d2.is_ok());
7108 assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
7109 });
7110 }
7111
7112 #[allow(clippy::await_holding_lock)]
7113 #[tokio::test]
7114 async fn test_shared_server_max_request_body_policy_is_deterministic() {
7115 let _guard = lock_registry_test_mutex();
7116 let registry = ServerRegistry::global();
7117 let d1 = registry
7119 .get_or_spawn(
7120 "127.0.0.1",
7121 9991,
7122 1024 * 1024,
7123 10 * 1024 * 1024,
7124 1024,
7125 test_rt(),
7126 "test-route".into(),
7127 None,
7128 )
7129 .await;
7130 assert!(d1.is_ok());
7131
7132 let d2 = registry
7135 .get_or_spawn(
7136 "127.0.0.1",
7137 9991,
7138 2 * 1024 * 1024,
7139 10 * 1024 * 1024,
7140 1024,
7141 test_rt(),
7142 "test-route-2".into(),
7143 None,
7144 )
7145 .await;
7146 assert!(d2.is_err());
7147 let err = d2.unwrap_err();
7148 assert!(
7149 err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
7150 "Expected incompatible maxRequestBody error, got: {}",
7151 err
7152 );
7153 }
7154
7155 #[test]
7156 fn test_server_registry_reset_clears_entries() {
7157 let _guard = lock_registry_test_mutex();
7158 let rt = tokio::runtime::Runtime::new().expect("runtime");
7159 rt.block_on(async {
7160 let d1 = ServerRegistry::global()
7162 .get_or_spawn(
7163 "127.0.0.1",
7164 9992,
7165 1024 * 1024,
7166 10 * 1024 * 1024,
7167 1024,
7168 test_rt(),
7169 "test-route".into(),
7170 None,
7171 )
7172 .await;
7173 assert!(d1.is_ok());
7174
7175 let guard = ServerRegistry::global().inner.lock().expect("lock");
7177 assert!(guard.entries.contains_key(&("127.0.0.1".to_string(), 9992)));
7178 drop(guard);
7179
7180 ServerRegistry::reset();
7182
7183 let guard = ServerRegistry::global().inner.lock().expect("lock");
7185 assert!(
7186 guard.entries.is_empty(),
7187 "registry should be empty after reset, has {} entries",
7188 guard.entries.len()
7189 );
7190 });
7191 }
7192
7193 #[allow(clippy::await_holding_lock)]
7194 #[tokio::test]
7195 async fn registry_rejects_tls_on_plain_port() {
7196 let _guard = lock_registry_test_mutex();
7201 ServerRegistry::reset();
7202 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
7203
7204 let _r1 = ServerRegistry::global()
7206 .get_or_spawn(
7207 "127.0.0.1",
7208 0,
7209 1024,
7210 1024,
7211 16,
7212 Arc::clone(&rt),
7213 "route-1".into(),
7214 None, )
7216 .await;
7217
7218 let result = ServerRegistry::global()
7220 .get_or_spawn(
7221 "127.0.0.1",
7222 0,
7223 1024,
7224 1024,
7225 16,
7226 Arc::clone(&rt),
7227 "route-2".into(),
7228 Some(crate::config::ServerTlsConfig {
7229 cert_path: "/x.pem".into(),
7230 key_path: "/y.pem".into(),
7231 }),
7232 )
7233 .await;
7234 assert!(result.is_err(), "must reject TLS on plain port");
7235 }
7236
7237 #[allow(clippy::await_holding_lock)]
7243 #[tokio::test]
7244 async fn test_unregister_last_http_route_keeps_server_alive() {
7245 let _guard = lock_registry_test_mutex();
7246 ServerRegistry::reset();
7247 let registry = ServerRegistry::global();
7248
7249 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7250 let port = listener.local_addr().unwrap().port();
7251 drop(listener); let rt = test_rt();
7253
7254 let _r1 = registry
7257 .get_or_spawn(
7258 "127.0.0.1",
7259 port,
7260 1024 * 1024,
7261 10 * 1024 * 1024,
7262 16,
7263 rt.clone(),
7264 "test-route-1".into(),
7265 None,
7266 )
7267 .await
7268 .unwrap();
7269 let _r2 = registry
7270 .get_or_spawn(
7271 "127.0.0.1",
7272 port,
7273 1024 * 1024,
7274 10 * 1024 * 1024,
7275 16,
7276 rt,
7277 "test-route-2".into(),
7278 None,
7279 )
7280 .await
7281 .unwrap();
7282
7283 let key = ("127.0.0.1".to_string(), port);
7284 let cell = {
7285 let guard = registry.inner.lock().expect("lock");
7286 guard.entries.get(&key).expect("entry should exist").clone()
7287 };
7288
7289 registry.unregister("127.0.0.1", port).await;
7291 {
7292 let handle = cell
7293 .get()
7294 .expect("handle should still exist after first unregister");
7295 assert!(
7296 !handle.monitor_task.is_finished(),
7297 "monitor task should still be alive after first unregister"
7298 );
7299 }
7300
7301 registry.unregister("127.0.0.1", port).await;
7303 tokio::time::sleep(Duration::from_millis(20)).await;
7304 {
7305 let handle = cell
7306 .get()
7307 .expect("handle should still exist after last unregister");
7308 assert!(
7309 !handle.monitor_task.is_finished(),
7310 "monitor task should still be alive — server is process-lifetime"
7311 );
7312 }
7313
7314 {
7316 let guard = registry.inner.lock().expect("lock");
7317 assert!(
7318 guard.entries.contains_key(&key),
7319 "entry should remain in registry — server kept alive for restart"
7320 );
7321 }
7322 }
7323
7324 async fn clone_fixture_listener() -> (
7333 tokio::net::TcpListener,
7334 std::net::TcpListener,
7335 std::net::SocketAddr,
7336 ) {
7337 let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind std listener");
7338 let probe = l.try_clone().expect("clone probe");
7339 l.set_nonblocking(true).expect("set_nonblocking");
7340 let listener = tokio::net::TcpListener::from_std(l).expect("from_std");
7341 let addr = listener.local_addr().expect("local_addr");
7342 (listener, probe, addr)
7343 }
7344
7345 fn staged_limits() -> (usize, usize, usize) {
7347 (1024 * 1024, 10 * 1024 * 1024, 1024)
7348 }
7349
7350 #[allow(clippy::await_holding_lock)]
7351 #[tokio::test]
7352 async fn staged_listener_first_spawn_serves_without_second_bind() {
7353 let _guard = lock_registry_test_mutex();
7354 ServerRegistry::reset();
7355 let registry = ServerRegistry::global();
7356 let (listener, _probe, addr) = clone_fixture_listener().await;
7357 let port = addr.port();
7358 registry
7359 .stage_listener(listener)
7360 .await
7361 .expect("stage listener");
7362
7363 let (max_req, max_res, max_inflight) = staged_limits();
7364 let routes = registry
7365 .get_or_spawn(
7366 "127.0.0.1",
7367 port,
7368 max_req,
7369 max_res,
7370 max_inflight,
7371 test_rt(),
7372 "staged-first-spawn".into(),
7373 None,
7374 )
7375 .await
7376 .expect("spawn from staged listener must succeed");
7377
7378 assert_eq!(
7379 registry.bound_addr("127.0.0.1", port),
7380 Some(addr),
7381 "served socket must be the staged listener's addr"
7382 );
7383 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__staged_probe__"))
7386 .await
7387 .expect("http request against staged listener must connect");
7388 assert!(
7389 resp.status().as_u16() >= 200,
7390 "any status proves the staged socket serves"
7391 );
7392 drop(routes);
7393 }
7394
7395 #[allow(clippy::await_holding_lock)]
7396 #[tokio::test]
7397 async fn staged_entry_reused_by_second_caller() {
7398 let _guard = lock_registry_test_mutex();
7399 ServerRegistry::reset();
7400 let registry = ServerRegistry::global();
7401 let (listener, _probe, addr) = clone_fixture_listener().await;
7402 let port = addr.port();
7403 registry
7404 .stage_listener(listener)
7405 .await
7406 .expect("stage listener");
7407
7408 let (max_req, max_res, max_inflight) = staged_limits();
7409 let first = registry
7410 .get_or_spawn(
7411 "127.0.0.1",
7412 port,
7413 max_req,
7414 max_res,
7415 max_inflight,
7416 test_rt(),
7417 "staged-reuse-1".into(),
7418 None,
7419 )
7420 .await
7421 .expect("first spawn from staged listener");
7422 let second = registry
7423 .get_or_spawn(
7424 "127.0.0.1",
7425 port,
7426 max_req,
7427 max_res,
7428 max_inflight,
7429 test_rt(),
7430 "staged-reuse-2".into(),
7431 None,
7432 )
7433 .await
7434 .expect("second caller must reuse the entry");
7435 assert_eq!(
7436 registry.bound_addr("127.0.0.1", port),
7437 Some(addr),
7438 "entry reused — bound addr unchanged, no second bind"
7439 );
7440 drop(first);
7441 drop(second);
7442 }
7443
7444 #[allow(clippy::await_holding_lock)]
7445 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7446 async fn staged_race_two_callers_single_resolver() {
7447 let _guard = lock_registry_test_mutex();
7448 ServerRegistry::reset();
7449 let registry = ServerRegistry::global();
7450 let (listener, _probe, addr) = clone_fixture_listener().await;
7451 let port = addr.port();
7452 registry
7453 .stage_listener(listener)
7454 .await
7455 .expect("stage listener");
7456
7457 let (max_req, max_res, max_inflight) = staged_limits();
7462 let (first, second) = tokio::join!(
7463 registry.get_or_spawn(
7464 "127.0.0.1",
7465 port,
7466 max_req,
7467 max_res,
7468 max_inflight,
7469 test_rt(),
7470 "staged-race-1".into(),
7471 None,
7472 ),
7473 registry.get_or_spawn(
7474 "127.0.0.1",
7475 port,
7476 max_req,
7477 max_res,
7478 max_inflight,
7479 test_rt(),
7480 "staged-race-2".into(),
7481 None,
7482 ),
7483 );
7484 let first = first.expect("first racing caller must succeed");
7485 let second = second.expect("second racing caller must succeed");
7486 assert_eq!(
7487 registry.bound_addr("127.0.0.1", port),
7488 Some(addr),
7489 "single entry must be served from the staged socket — no EADDRINUSE path"
7490 );
7491 drop(first);
7492 drop(second);
7493 }
7494
7495 #[allow(clippy::await_holding_lock)]
7496 #[tokio::test]
7497 async fn unstaged_spawn_binds_legacy() {
7498 let _guard = lock_registry_test_mutex();
7499 ServerRegistry::reset();
7500 let registry = ServerRegistry::global();
7501 let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe bind");
7503 let port = probe.local_addr().expect("local addr").port();
7504 drop(probe);
7505
7506 let (max_req, max_res, max_inflight) = staged_limits();
7507 registry
7508 .get_or_spawn(
7509 "127.0.0.1",
7510 port,
7511 max_req,
7512 max_res,
7513 max_inflight,
7514 test_rt(),
7515 "legacy-bind".into(),
7516 None,
7517 )
7518 .await
7519 .expect("legacy bind spawn");
7520 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__legacy_probe__"))
7521 .await
7522 .expect("connect to freshly bound port must succeed");
7523 assert!(resp.status().as_u16() >= 200);
7524 assert_eq!(
7525 registry.bound_addr("127.0.0.1", port),
7526 Some(std::net::SocketAddr::from(([127, 0, 0, 1], port))),
7527 "bound addr must be the legacy bound (host, port)"
7528 );
7529 }
7530
7531 #[allow(clippy::await_holding_lock)]
7532 #[tokio::test]
7533 async fn wrong_host_staged_port_fails_deterministically() {
7534 let _guard = lock_registry_test_mutex();
7535 ServerRegistry::reset();
7536 let registry = ServerRegistry::global();
7537 let (listener, _probe, addr) = clone_fixture_listener().await;
7538 let port = addr.port();
7539 registry
7540 .stage_listener(listener)
7541 .await
7542 .expect("stage listener under 127.0.0.1");
7543
7544 let (max_req, max_res, max_inflight) = staged_limits();
7545 let err = registry
7546 .get_or_spawn(
7547 "localhost",
7548 port,
7549 max_req,
7550 max_res,
7551 max_inflight,
7552 test_rt(),
7553 "conflict-probe".into(),
7554 None,
7555 )
7556 .await
7557 .expect_err("wrong host on staged port must fail deterministically");
7558 assert!(
7559 err.to_string().contains("staged listener conflict on port"),
7560 "unexpected error: {err}"
7561 );
7562
7563 registry
7565 .get_or_spawn(
7566 "127.0.0.1",
7567 port,
7568 max_req,
7569 max_res,
7570 max_inflight,
7571 test_rt(),
7572 "conflict-after".into(),
7573 None,
7574 )
7575 .await
7576 .expect("correct host must serve the staged listener");
7577 assert_eq!(
7578 registry.bound_addr("127.0.0.1", port),
7579 Some(addr),
7580 "staged slot must be untouched by the conflicting call"
7581 );
7582 }
7583
7584 #[allow(clippy::await_holding_lock)]
7585 #[tokio::test]
7586 async fn duplicate_stage_same_key_rejected() {
7587 let _guard = lock_registry_test_mutex();
7588 ServerRegistry::reset();
7589 let registry = ServerRegistry::global();
7590 let (listener, probe, addr) = clone_fixture_listener().await;
7591 registry
7592 .stage_listener(listener)
7593 .await
7594 .expect("stage listener A");
7595
7596 let dup = probe.try_clone().expect("clone2");
7598 dup.set_nonblocking(true).expect("set_nonblocking2");
7599 let b = tokio::net::TcpListener::from_std(dup).expect("from_std2");
7600
7601 let err = registry
7602 .stage_listener(b)
7603 .await
7604 .expect_err("duplicate stage must be rejected");
7605 assert!(
7606 err.to_string().contains("listener already staged"),
7607 "unexpected error: {err}"
7608 );
7609
7610 let (max_req, max_res, max_inflight) = staged_limits();
7611 registry
7612 .get_or_spawn(
7613 "127.0.0.1",
7614 addr.port(),
7615 max_req,
7616 max_res,
7617 max_inflight,
7618 test_rt(),
7619 "dup-stage-after".into(),
7620 None,
7621 )
7622 .await
7623 .expect("spawn from first staged listener");
7624 assert_eq!(
7625 registry.bound_addr("127.0.0.1", addr.port()),
7626 Some(addr),
7627 "first staged listener retained"
7628 );
7629 }
7630
7631 #[allow(clippy::await_holding_lock)]
7632 #[tokio::test]
7633 async fn distinct_keys_stage_independently() {
7634 let _guard = lock_registry_test_mutex();
7635 ServerRegistry::reset();
7636 let registry = ServerRegistry::global();
7637 let (l1, _p1, addr1) = clone_fixture_listener().await;
7638 let (l2, _p2, addr2) = clone_fixture_listener().await;
7639 registry.stage_listener(l1).await.expect("stage P1");
7640 registry.stage_listener(l2).await.expect("stage P2");
7641
7642 let (max_req, max_res, max_inflight) = staged_limits();
7643 registry
7644 .get_or_spawn(
7645 "127.0.0.1",
7646 addr1.port(),
7647 max_req,
7648 max_res,
7649 max_inflight,
7650 test_rt(),
7651 "distinct-1".into(),
7652 None,
7653 )
7654 .await
7655 .expect("spawn P1");
7656 registry
7657 .get_or_spawn(
7658 "127.0.0.1",
7659 addr2.port(),
7660 max_req,
7661 max_res,
7662 max_inflight,
7663 test_rt(),
7664 "distinct-2".into(),
7665 None,
7666 )
7667 .await
7668 .expect("spawn P2");
7669 assert_eq!(
7670 registry.bound_addr("127.0.0.1", addr1.port()),
7671 Some(addr1),
7672 "P1 bound addr must be its own listener"
7673 );
7674 assert_eq!(
7675 registry.bound_addr("127.0.0.1", addr2.port()),
7676 Some(addr2),
7677 "P2 bound addr must be its own listener"
7678 );
7679 let r1 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr1.port()))
7680 .await
7681 .expect("connect P1");
7682 assert!(r1.status().as_u16() >= 200);
7683 let r2 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr2.port()))
7684 .await
7685 .expect("connect P2");
7686 assert!(r2.status().as_u16() >= 200);
7687 }
7688
7689 #[allow(clippy::await_holding_lock)]
7690 #[tokio::test]
7691 async fn tls_prebound_listener_served() {
7692 use camel_component_api::test_support::tls;
7693
7694 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7697
7698 let _guard = lock_registry_test_mutex();
7699 ServerRegistry::reset();
7700 let registry = ServerRegistry::global();
7701 let (listener, _probe, addr) = clone_fixture_listener().await;
7702 let port = addr.port();
7703
7704 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
7705 let cert_path = tls::write_pem_tmp("http-staged-tls-cert.pem", &cert_pem);
7706 let key_path = tls::write_pem_tmp("http-staged-tls-key.pem", &key_pem);
7707 let ca_path = tls::write_pem_tmp("http-staged-tls-ca.pem", &ca_pem);
7708
7709 let (max_req, max_res, max_inflight) = staged_limits();
7710 let routes = registry
7711 .get_or_spawn_with_listener(
7712 listener,
7713 max_req,
7714 max_res,
7715 max_inflight,
7716 test_rt(),
7717 "staged-tls".into(),
7718 Some(crate::config::ServerTlsConfig {
7719 cert_path: cert_path.to_string_lossy().into_owned(),
7720 key_path: key_path.to_string_lossy().into_owned(),
7721 }),
7722 )
7723 .await
7724 .expect("spawn TLS server from pre-bound listener");
7725
7726 let ca_bytes = std::fs::read(&ca_path).expect("read ca pem");
7729 let client = reqwest::Client::builder()
7730 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).expect("parse ca pem"))
7731 .build()
7732 .expect("build tls client");
7733
7734 let resp = client
7735 .get(format!("https://127.0.0.1:{port}/__staged_tls__"))
7736 .send()
7737 .await
7738 .expect("TLS handshake + request must succeed");
7739 assert_eq!(resp.status().as_u16(), 404, "unknown path 404s through TLS");
7740 assert_eq!(
7741 registry.bound_addr("127.0.0.1", port),
7742 Some(addr),
7743 "bound addr equals the pre-bound listener addr"
7744 );
7745 drop(routes);
7746 }
7747
7748 #[allow(clippy::await_holding_lock)]
7749 #[tokio::test]
7750 async fn with_listener_direct_spawn_keyed_by_actual_addr() {
7751 let _guard = lock_registry_test_mutex();
7752 ServerRegistry::reset();
7753 let registry = ServerRegistry::global();
7754 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
7755 .await
7756 .expect("bind un-staged listener");
7757 let addr = listener.local_addr().expect("local addr");
7758 let port = addr.port();
7759
7760 let (max_req, max_res, max_inflight) = staged_limits();
7761 registry
7762 .get_or_spawn_with_listener(
7763 listener,
7764 max_req,
7765 max_res,
7766 max_inflight,
7767 test_rt(),
7768 "with-listener".into(),
7769 None,
7770 )
7771 .await
7772 .expect("direct spawn from un-staged listener");
7773 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__with_listener__"))
7774 .await
7775 .expect("connect on actual port");
7776 assert!(resp.status().as_u16() >= 200);
7777 assert_eq!(
7778 registry.bound_addr("127.0.0.1", port),
7779 Some(addr),
7780 "registry key is the listener's actual port"
7781 );
7782
7783 registry
7784 .get_or_spawn(
7785 "127.0.0.1",
7786 port,
7787 max_req,
7788 max_res,
7789 max_inflight,
7790 test_rt(),
7791 "with-listener-reuse".into(),
7792 None,
7793 )
7794 .await
7795 .expect("legacy caller must reuse the entry");
7796 assert_eq!(
7797 registry.bound_addr("127.0.0.1", port),
7798 Some(addr),
7799 "entry reused — no second bind"
7800 );
7801 }
7802
7803 #[tokio::test]
7808 async fn test_dispatch_handler_returns_404_for_unknown_path() {
7809 let registry = HttpRouteRegistry::new();
7810 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7812 let port = listener.local_addr().unwrap().port();
7813 tokio::spawn(run_axum_server(
7814 listener,
7815 registry,
7816 2 * 1024 * 1024,
7817 10 * 1024 * 1024,
7818 Arc::new(tokio::sync::Semaphore::new(1024)),
7819 test_rt(),
7820 "test-route".into(),
7821 ));
7822
7823 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
7825
7826 let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
7827 .await
7828 .unwrap();
7829 assert_eq!(resp.status().as_u16(), 404);
7830 }
7831
7832 #[tokio::test]
7837 async fn test_http_consumer_start_registers_path() {
7838 use camel_component_api::ConsumerContext;
7839
7840 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7842 let port = listener.local_addr().unwrap().port();
7843 drop(listener); let consumer_cfg = HttpServerConfig {
7846 scheme: "http".to_string(),
7847 host: "127.0.0.1".to_string(),
7848 port,
7849 path: "/ping".to_string(),
7850 max_request_body: 2 * 1024 * 1024,
7851 max_response_body: 10 * 1024 * 1024,
7852 max_inflight_requests: 1024,
7853 method: None,
7854 tls_config: None,
7855 };
7856 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7857
7858 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7859 let token = tokio_util::sync::CancellationToken::new();
7860 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7861
7862 tokio::spawn(async move {
7863 consumer.start(ctx).await.unwrap();
7864 });
7865
7866 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7867
7868 let client = reqwest::Client::new();
7869 let resp_future = client
7870 .post(format!("http://127.0.0.1:{port}/ping"))
7871 .body("hello world")
7872 .send();
7873
7874 let (http_result, _) = tokio::join!(resp_future, async {
7875 if let Some(mut envelope) = rx.recv().await {
7876 envelope.exchange.input.set_header(
7878 "CamelHttpResponseCode",
7879 serde_json::Value::Number(201.into()),
7880 );
7881 if let Some(reply_tx) = envelope.reply_tx {
7882 let _ = reply_tx.send(Ok(envelope.exchange));
7883 }
7884 }
7885 });
7886
7887 let resp = http_result.unwrap();
7888 assert_eq!(resp.status().as_u16(), 201);
7889
7890 token.cancel();
7891 }
7892
7893 #[tokio::test]
7898 async fn http_consumer_raw_dispatch_carries_in_flight_claim() {
7899 use std::sync::atomic::{AtomicU64, Ordering};
7900
7901 use camel_component_api::ConsumerContext;
7902 use camel_component_api::StartupSignal;
7903
7904 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7905 let port = listener.local_addr().unwrap().port();
7906 drop(listener);
7907
7908 let consumer_cfg = HttpServerConfig {
7909 scheme: "http".to_string(),
7910 host: "127.0.0.1".to_string(),
7911 port,
7912 path: "/claim".to_string(),
7913 max_request_body: 2 * 1024 * 1024,
7914 max_response_body: 10 * 1024 * 1024,
7915 max_inflight_requests: 1024,
7916 method: None,
7917 tls_config: None,
7918 };
7919 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7920
7921 let counter = std::sync::Arc::new(AtomicU64::new(0));
7922 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7923 let token = tokio_util::sync::CancellationToken::new();
7924 let (signal, startup_rx) = StartupSignal::pair();
7925 let ctx = ConsumerContext::new(tx, token.clone(), "http-claim-route".to_string())
7926 .with_startup(signal)
7927 .with_in_flight_counter(std::sync::Arc::clone(&counter));
7928
7929 tokio::spawn(async move {
7930 consumer.start(ctx).await.unwrap();
7931 });
7932
7933 tokio::time::timeout(std::time::Duration::from_secs(5), startup_rx.await_ready())
7936 .await
7937 .expect("startup must resolve within 5s")
7938 .expect("startup must be Ok");
7939
7940 let client = reqwest::Client::new();
7941 let (http_result, claim) = tokio::join!(
7942 client
7943 .post(format!("http://127.0.0.1:{port}/claim"))
7944 .body("hello")
7945 .send(),
7946 async {
7947 let mut envelope = rx.recv().await.expect("envelope must arrive");
7948 let claim = envelope
7949 .in_flight_claim
7950 .take()
7951 .expect("raw dispatch must carry an acceptance-minted claim");
7952 assert_eq!(
7953 counter.load(Ordering::Acquire),
7954 1,
7955 "exact total: the acceptance mint is the only live claim"
7956 );
7957 let reply_tx = envelope.reply_tx.take().expect("reply channel must be set");
7961 let reply_exchange = Exchange::new(camel_component_api::Message::new(
7962 camel_component_api::Body::Text("done".to_string()),
7963 ));
7964 reply_tx
7965 .send(Ok(reply_exchange))
7966 .expect("reply must be taken");
7967 claim
7968 },
7969 );
7970
7971 let resp = http_result.expect("http roundtrip must complete");
7972 assert_eq!(resp.status().as_u16(), 200);
7973
7974 drop(claim);
7975 assert_eq!(
7976 counter.load(Ordering::Acquire),
7977 0,
7978 "release exactly once when the holder drops"
7979 );
7980 token.cancel();
7981 }
7982
7983 #[test]
7987 fn test_envelope_channel_capacity_follows_max_inflight() {
7988 assert_eq!(envelope_channel_capacity(0), 1);
7989 assert_eq!(envelope_channel_capacity(1), 1);
7990 assert_eq!(envelope_channel_capacity(7), 7);
7991 assert_eq!(envelope_channel_capacity(64), 64);
7992 assert_eq!(envelope_channel_capacity(1024), 1024);
7993 }
7994
7995 #[tokio::test]
7999 async fn test_http_consumer_start_with_zero_max_inflight_rejects_503() {
8000 use camel_component_api::ConsumerContext;
8001
8002 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8003 let port = listener.local_addr().unwrap().port();
8004 drop(listener);
8005
8006 let consumer_cfg = HttpServerConfig {
8007 scheme: "http".to_string(),
8008 host: "127.0.0.1".to_string(),
8009 port,
8010 path: "/ping".to_string(),
8011 max_request_body: 2 * 1024 * 1024,
8012 max_response_body: 10 * 1024 * 1024,
8013 max_inflight_requests: 0,
8014 method: None,
8015 tls_config: None,
8016 };
8017 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8018
8019 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8020 let token = tokio_util::sync::CancellationToken::new();
8021 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8022
8023 let start_handle = tokio::spawn(async move {
8024 consumer.start(ctx).await.unwrap();
8025 });
8026
8027 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8028
8029 let client = reqwest::Client::new();
8030 let resp = client
8031 .post(format!("http://127.0.0.1:{port}/ping"))
8032 .body("hello world")
8033 .send()
8034 .await
8035 .unwrap();
8036 assert_eq!(resp.status().as_u16(), 503);
8037
8038 token.cancel();
8039 let _ = start_handle.await;
8040 }
8041
8042 #[test]
8045 fn test_http_consumer_startup_mode_is_explicit() {
8046 use camel_component_api::ConsumerStartupMode;
8047 let consumer_cfg = HttpServerConfig {
8048 scheme: "http".to_string(),
8049 host: "127.0.0.1".to_string(),
8050 port: 0,
8051 path: "/x".to_string(),
8052 max_request_body: 2 * 1024 * 1024,
8053 max_response_body: 10 * 1024 * 1024,
8054 max_inflight_requests: 1024,
8055 method: None,
8056 tls_config: None,
8057 };
8058 let consumer = HttpConsumer::new(consumer_cfg, test_rt());
8059 assert_eq!(
8060 consumer.startup_mode(),
8061 ConsumerStartupMode::Explicit,
8062 "HttpConsumer must opt into Explicit startup"
8063 );
8064 }
8065
8066 #[allow(clippy::await_holding_lock)]
8072 #[tokio::test]
8073 async fn test_http_consumer_emits_mark_ready_after_bind() {
8074 use camel_component_api::{ConsumerContext, StartupSignal};
8075
8076 let _guard = lock_registry_test_mutex();
8077
8078 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8079 let port = listener.local_addr().unwrap().port();
8080 drop(listener);
8081
8082 let consumer_cfg = HttpServerConfig {
8083 scheme: "http".to_string(),
8084 host: "127.0.0.1".to_string(),
8085 port,
8086 path: "/ready-probe".to_string(),
8087 max_request_body: 2 * 1024 * 1024,
8088 max_response_body: 10 * 1024 * 1024,
8089 max_inflight_requests: 1024,
8090 method: None,
8091 tls_config: None,
8092 };
8093 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8094
8095 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8096 let token = tokio_util::sync::CancellationToken::new();
8097 let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
8098
8099 let (signal, startup_rx) = StartupSignal::pair();
8101 let ctx = ctx.with_startup(signal);
8102
8103 tokio::spawn(async move {
8106 let _ = consumer.start(ctx).await;
8107 });
8108
8109 let result =
8114 tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
8115 .await
8116 .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
8117 assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
8118
8119 token.cancel();
8121 }
8122
8123 #[derive(Default, Clone)]
8130 struct ErrorRecordingRuntime {
8131 errors: std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>>,
8132 }
8133
8134 impl camel_api::MetricsCollector for ErrorRecordingRuntime {
8135 fn record_exchange_duration(&self, _route_id: &str, _duration: std::time::Duration) {}
8136 fn increment_errors(&self, route_id: &str, error_type: &str) {
8137 self.errors
8138 .lock()
8139 .expect("error recorder lock")
8140 .push((route_id.to_string(), error_type.to_string()));
8141 }
8142 fn increment_exchanges(&self, _route_id: &str) {}
8143 fn set_queue_depth(&self, _queue: &str, _depth: usize) {}
8144 fn record_circuit_breaker_change(&self, _route_id: &str, _from: &str, _to: &str) {}
8145 }
8146
8147 impl camel_component_api::HealthCheckRegistry for ErrorRecordingRuntime {
8148 fn force_unhealthy_for_route(&self, _route_id: &str, _name: &str, _reason: &str) {}
8149 }
8150
8151 impl camel_component_api::RuntimeObservability for ErrorRecordingRuntime {
8152 fn metrics(&self) -> std::sync::Arc<dyn camel_api::MetricsCollector> {
8153 std::sync::Arc::new(self.clone())
8154 }
8155 fn health(&self) -> std::sync::Arc<dyn camel_component_api::HealthCheckRegistry> {
8156 std::sync::Arc::new(self.clone())
8157 }
8158 }
8159
8160 #[allow(clippy::await_holding_lock)]
8175 #[tokio::test]
8176 async fn shared_server_death_fails_every_hosted_consumer() {
8177 use camel_component_api::{ConsumerContext, StartupSignal};
8178
8179 let _guard = lock_registry_test_mutex();
8180 ServerRegistry::reset();
8181
8182 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8184 let port = listener.local_addr().unwrap().port();
8185 drop(listener);
8186
8187 let rt = ErrorRecordingRuntime::default();
8188
8189 let make_consumer = |path: &str| {
8190 HttpConsumer::new(
8191 HttpServerConfig {
8192 scheme: "http".to_string(),
8193 host: "127.0.0.1".to_string(),
8194 port,
8195 path: path.to_string(),
8196 max_request_body: 2 * 1024 * 1024,
8197 max_response_body: 10 * 1024 * 1024,
8198 max_inflight_requests: 16,
8199 method: None,
8200 tls_config: None,
8201 },
8202 std::sync::Arc::new(rt.clone()),
8203 )
8204 };
8205
8206 let spawn_consumer = |path: &str, route_id: &str| {
8207 let mut consumer = make_consumer(path);
8208 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8209 let token = tokio_util::sync::CancellationToken::new();
8210 let ctx = ConsumerContext::new(tx, token, route_id.to_string());
8211 let (signal, startup_rx) = StartupSignal::pair();
8212 let ctx = ctx.with_startup(signal);
8213 let task = tokio::spawn(async move { consumer.start(ctx).await });
8214 (task, startup_rx)
8215 };
8216
8217 let (task_a, ready_a) = spawn_consumer("/zombie-a", "zombie-route-a");
8219 let (task_b, ready_b) = spawn_consumer("/zombie-b", "zombie-route-b");
8220
8221 for (name, ready) in [("a", ready_a), ("b", ready_b)] {
8223 let result =
8224 tokio::time::timeout(std::time::Duration::from_secs(2), ready.await_ready())
8225 .await
8226 .unwrap_or_else(|_| panic!("consumer {name} never became ready"));
8227 assert!(
8228 result.is_ok(),
8229 "consumer {name} readiness must resolve Ok (bind + registration complete)"
8230 );
8231 }
8232
8233 {
8236 let registry = ServerRegistry::global();
8237 let guard = registry.inner.lock().expect("ServerRegistry lock");
8238 let cell = guard
8239 .entries
8240 .get(&("127.0.0.1".to_string(), port))
8241 .expect("shared server entry must exist");
8242 let handle = cell.get().expect("server handle must be initialized");
8243 handle.server_abort.abort();
8244 }
8245
8246 let outcome_a = tokio::time::timeout(std::time::Duration::from_secs(2), task_a)
8249 .await
8250 .expect("ZOMBIE: consumer-a still running after shared server death (rc-szmob)");
8251 let outcome_b = tokio::time::timeout(std::time::Duration::from_secs(2), task_b)
8252 .await
8253 .expect("ZOMBIE: consumer-b still running after shared server death (rc-szmob)");
8254
8255 let err_a = outcome_a
8256 .expect("consumer-a task must join")
8257 .expect_err("consumer-a start() must return Err when the shared server dies");
8258 let err_b = outcome_b
8259 .expect("consumer-b task must join")
8260 .expect_err("consumer-b start() must return Err when the shared server dies");
8261
8262 for (name, err) in [("a", &err_a), ("b", &err_b)] {
8265 assert!(
8266 err.to_string().contains("127.0.0.1")
8267 && err.to_string().contains(&port.to_string()),
8268 "consumer-{name} error must name the dead shared server, got: {err}"
8269 );
8270 }
8271
8272 let recorded = rt.errors.lock().expect("error recorder lock").clone();
8275 assert!(
8276 recorded
8277 .iter()
8278 .any(|(route, label)| label == "e:http:server-task-exited"
8279 && route == "zombie-route-a"),
8280 "expected e:http:server-task-exited for the spawning route, got: {recorded:?}"
8281 );
8282 }
8283
8284 #[tokio::test]
8285 async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
8286 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8287
8288 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8289 let port = listener.local_addr().unwrap().port();
8290 drop(listener);
8291
8292 let consumer_cfg = HttpServerConfig {
8293 scheme: "http".to_string(),
8294 host: "127.0.0.1".to_string(),
8295 port,
8296 path: "/saturation".to_string(),
8297 max_request_body: 2 * 1024 * 1024,
8298 max_response_body: 10 * 1024 * 1024,
8299 max_inflight_requests: 1,
8300 method: None,
8301 tls_config: None,
8302 };
8303 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8304
8305 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8306 let token = tokio_util::sync::CancellationToken::new();
8307 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8308 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8309 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8310
8311 let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
8312 let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
8313
8314 tokio::spawn(async move {
8315 let mut first_seen_tx = Some(first_seen_tx);
8316 let mut unblock_first_rx = Some(unblock_first_rx);
8317
8318 while let Some(envelope) = rx.recv().await {
8319 if let Some(tx) = first_seen_tx.take() {
8320 let _ = tx.send(());
8321 if let Some(rx_unblock) = unblock_first_rx.take() {
8322 let _ = rx_unblock.await;
8323 }
8324 }
8325
8326 if let Some(reply_tx) = envelope.reply_tx {
8327 let _ = reply_tx.send(Ok(envelope.exchange));
8328 }
8329 }
8330 });
8331
8332 let client = reqwest::Client::new();
8333 let first_req = {
8334 let client = client.clone();
8335 async move {
8336 client
8337 .get(format!("http://127.0.0.1:{port}/saturation"))
8338 .send()
8339 .await
8340 .unwrap()
8341 }
8342 };
8343
8344 let first_handle = tokio::spawn(first_req);
8345 first_seen_rx.await.unwrap();
8346
8347 let second_resp = client
8348 .get(format!("http://127.0.0.1:{port}/saturation"))
8349 .send()
8350 .await
8351 .unwrap();
8352
8353 assert_eq!(second_resp.status().as_u16(), 503);
8354
8355 let _ = unblock_first_tx.send(());
8356 let first_resp = first_handle.await.unwrap();
8357 assert_eq!(first_resp.status().as_u16(), 200);
8358
8359 token.cancel();
8360 }
8361
8362 #[tokio::test]
8366 async fn test_http_consumer_chunked_body_is_capped() {
8367 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8368
8369 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8370 let port = listener.local_addr().unwrap().port();
8371 drop(listener);
8372
8373 let consumer_cfg = HttpServerConfig {
8374 scheme: "http".to_string(),
8375 host: "127.0.0.1".to_string(),
8376 port,
8377 path: "/chunked-cap".to_string(),
8378 max_request_body: 1024, max_response_body: 10 * 1024 * 1024,
8380 max_inflight_requests: 16,
8381 method: None,
8382 tls_config: None,
8383 };
8384 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8385
8386 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8387 let token = tokio_util::sync::CancellationToken::new();
8388 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8389 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8390 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8391
8392 let chunks: Vec<Result<bytes::Bytes, std::io::Error>> = (0..8)
8394 .map(|_| Ok(bytes::Bytes::from(vec![b'A'; 512])))
8395 .collect();
8396 let stream_body = reqwest::Body::wrap_stream(futures::stream::iter(chunks));
8397
8398 let client = reqwest::Client::new();
8399 let send_fut = client
8400 .post(format!("http://127.0.0.1:{port}/chunked-cap"))
8401 .body(stream_body)
8402 .send();
8403
8404 let (http_result, _) = tokio::join!(send_fut, async {
8405 if let Some(mut envelope) = rx.recv().await {
8406 let materialized = envelope
8408 .exchange
8409 .input
8410 .body
8411 .clone()
8412 .into_bytes(64 * 1024)
8413 .await;
8414 assert!(
8415 materialized.is_err(),
8416 "materializing a 4 KiB chunked body under a 1 KiB cap must fail"
8417 );
8418 let err = materialized.unwrap_err().to_string();
8419 assert!(
8420 err.contains("limit") || err.contains("exceeds"),
8421 "error should mention the limit: {err}"
8422 );
8423 if let Some(reply_tx) = envelope.reply_tx {
8424 envelope.exchange.input.body =
8425 camel_component_api::Body::Text("handled".to_string());
8426 let _ = reply_tx.send(Ok(envelope.exchange));
8427 }
8428 }
8429 });
8430
8431 let resp = http_result.unwrap();
8432 assert_eq!(resp.status().as_u16(), 200);
8433
8434 token.cancel();
8435 }
8436
8437 #[tokio::test]
8438 #[allow(clippy::await_holding_lock)]
8439 async fn test_http_consumer_enforces_max_response_body_for_bytes() {
8440 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8441
8442 let _guard = lock_registry_test_mutex();
8443
8444 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8445 let port = listener.local_addr().unwrap().port();
8446 drop(listener);
8447
8448 let consumer_cfg = HttpServerConfig {
8449 scheme: "http".to_string(),
8450 host: "127.0.0.1".to_string(),
8451 port,
8452 path: "/limit-bytes".to_string(),
8453 max_request_body: 2 * 1024 * 1024,
8454 max_response_body: 16,
8455 max_inflight_requests: 1024,
8456 method: None,
8457 tls_config: None,
8458 };
8459 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8460
8461 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8462 let token = tokio_util::sync::CancellationToken::new();
8463 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8464 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8465 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8466
8467 let client = reqwest::Client::new();
8468 let send_fut = client
8469 .get(format!("http://127.0.0.1:{port}/limit-bytes"))
8470 .send();
8471
8472 let (http_result, _) = tokio::join!(send_fut, async {
8473 if let Some(mut envelope) = rx.recv().await {
8474 envelope.exchange.input.body =
8475 camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
8476 if let Some(reply_tx) = envelope.reply_tx {
8477 let _ = reply_tx.send(Ok(envelope.exchange));
8478 }
8479 }
8480 });
8481
8482 let resp = http_result.unwrap();
8483 assert_eq!(resp.status().as_u16(), 500);
8484 let body = resp.text().await.unwrap();
8485 assert_eq!(body, "Response body exceeds configured limit");
8486 token.cancel();
8487 }
8488
8489 #[tokio::test]
8490 #[allow(clippy::await_holding_lock)]
8491 async fn test_http_consumer_enforces_max_response_body_for_json() {
8492 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8493
8494 let _guard = lock_registry_test_mutex();
8495
8496 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8497 let port = listener.local_addr().unwrap().port();
8498 drop(listener);
8499
8500 let consumer_cfg = HttpServerConfig {
8501 scheme: "http".to_string(),
8502 host: "127.0.0.1".to_string(),
8503 port,
8504 path: "/limit-json".to_string(),
8505 max_request_body: 2 * 1024 * 1024,
8506 max_response_body: 16,
8507 max_inflight_requests: 1024,
8508 method: None,
8509 tls_config: None,
8510 };
8511 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8512
8513 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8514 let token = tokio_util::sync::CancellationToken::new();
8515 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8516 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8517 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8518
8519 let client = reqwest::Client::new();
8520 let send_fut = client
8521 .get(format!("http://127.0.0.1:{port}/limit-json"))
8522 .send();
8523
8524 let (http_result, _) = tokio::join!(send_fut, async {
8525 if let Some(mut envelope) = rx.recv().await {
8526 envelope.exchange.input.body = camel_component_api::Body::Json(
8527 serde_json::json!({"message":"this response is bigger than sixteen"}),
8528 );
8529 if let Some(reply_tx) = envelope.reply_tx {
8530 let _ = reply_tx.send(Ok(envelope.exchange));
8531 }
8532 }
8533 });
8534
8535 let resp = http_result.unwrap();
8536 assert_eq!(resp.status().as_u16(), 500);
8537 let body = resp.text().await.unwrap();
8538 assert_eq!(body, "Response body exceeds configured limit");
8539 token.cancel();
8540 }
8541
8542 #[tokio::test]
8543 #[allow(clippy::await_holding_lock)]
8544 async fn test_http_consumer_enforces_max_response_body_for_xml() {
8545 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8546
8547 let _guard = lock_registry_test_mutex();
8548
8549 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8550 let port = listener.local_addr().unwrap().port();
8551 drop(listener);
8552
8553 let consumer_cfg = HttpServerConfig {
8554 scheme: "http".to_string(),
8555 host: "127.0.0.1".to_string(),
8556 port,
8557 path: "/limit-xml".to_string(),
8558 max_request_body: 2 * 1024 * 1024,
8559 max_response_body: 16,
8560 max_inflight_requests: 1024,
8561 method: None,
8562 tls_config: None,
8563 };
8564 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8565
8566 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8567 let token = tokio_util::sync::CancellationToken::new();
8568 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8569 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8570 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8571
8572 let client = reqwest::Client::new();
8573 let send_fut = client
8574 .get(format!("http://127.0.0.1:{port}/limit-xml"))
8575 .send();
8576
8577 let (http_result, _) = tokio::join!(send_fut, async {
8578 if let Some(mut envelope) = rx.recv().await {
8579 envelope.exchange.input.body = camel_component_api::Body::Xml(
8580 "<root><value>way-too-large</value></root>".into(),
8581 );
8582 if let Some(reply_tx) = envelope.reply_tx {
8583 let _ = reply_tx.send(Ok(envelope.exchange));
8584 }
8585 }
8586 });
8587
8588 let resp = http_result.unwrap();
8589 assert_eq!(resp.status().as_u16(), 500);
8590 let body = resp.text().await.unwrap();
8591 assert_eq!(body, "Response body exceeds configured limit");
8592 token.cancel();
8593 }
8594
8595 #[tokio::test]
8596 #[allow(clippy::await_holding_lock)]
8597 async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
8598 use camel_component_api::{
8599 CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
8600 };
8601 use futures::stream;
8602
8603 let _guard = lock_registry_test_mutex();
8604
8605 let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
8606 let port = listener.local_addr().unwrap().port();
8607 drop(listener);
8608
8609 let consumer_cfg = HttpServerConfig {
8610 scheme: "http".to_string(),
8611 host: "0.0.0.0".to_string(),
8612 port,
8613 path: "/limit-stream".to_string(),
8614 max_request_body: 2 * 1024 * 1024,
8615 max_response_body: 16,
8616 max_inflight_requests: 1024,
8617 method: None,
8618 tls_config: None,
8619 };
8620 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8621
8622 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8623 let token = tokio_util::sync::CancellationToken::new();
8624 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8625 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8626 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8627
8628 let client = reqwest::Client::new();
8629 let send_fut = client
8630 .get(format!("http://127.0.0.1:{port}/limit-stream"))
8631 .send();
8632
8633 let (http_result, _) = tokio::join!(send_fut, async {
8634 if let Some(mut envelope) = rx.recv().await {
8635 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
8636 vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
8637 let stream = Box::pin(stream::iter(chunks));
8638 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
8639 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
8640 metadata: StreamMetadata {
8641 size_hint: Some(32),
8642 content_type: Some("application/octet-stream".into()),
8643 origin: None,
8644 },
8645 });
8646 if let Some(reply_tx) = envelope.reply_tx {
8647 let _ = reply_tx.send(Ok(envelope.exchange));
8648 }
8649 }
8650 });
8651
8652 let resp = http_result.unwrap();
8653 assert_eq!(resp.status().as_u16(), 200);
8654 let body = resp.bytes().await.unwrap();
8655 assert_eq!(body.len(), 32);
8656 token.cancel();
8657 }
8658
8659 #[tokio::test]
8664 #[allow(clippy::await_holding_lock)]
8665 async fn test_integration_single_consumer_round_trip() {
8666 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8667
8668 let _guard = lock_registry_test_mutex();
8672
8673 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8675 let port = listener.local_addr().unwrap().port();
8676 drop(listener); let component = HttpComponent::new();
8679 let endpoint_ctx = NoOpComponentContext;
8680 let endpoint = component
8681 .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
8682 .unwrap();
8683 let mut consumer = endpoint.create_consumer(rt()).unwrap();
8684
8685 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8686 let token = tokio_util::sync::CancellationToken::new();
8687 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8688
8689 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8690 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8691
8692 let client = reqwest::Client::new();
8693 let send_fut = client
8694 .post(format!("http://127.0.0.1:{port}/echo"))
8695 .header("Content-Type", "text/plain")
8696 .body("ping")
8697 .send();
8698
8699 let (http_result, _) = tokio::join!(send_fut, async {
8700 if let Some(mut envelope) = rx.recv().await {
8701 assert_eq!(
8702 envelope.exchange.input.header("CamelHttpMethod"),
8703 Some(&serde_json::Value::String("POST".into()))
8704 );
8705 assert_eq!(
8706 envelope.exchange.input.header("CamelHttpPath"),
8707 Some(&serde_json::Value::String("/echo".into()))
8708 );
8709 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
8710 if let Some(reply_tx) = envelope.reply_tx {
8711 let _ = reply_tx.send(Ok(envelope.exchange));
8712 }
8713 }
8714 });
8715
8716 let resp = http_result.unwrap();
8717 assert_eq!(resp.status().as_u16(), 200);
8718 let body = resp.text().await.unwrap();
8719 assert_eq!(body, "pong");
8720
8721 token.cancel();
8722 }
8723
8724 #[tokio::test]
8725 #[allow(clippy::await_holding_lock)]
8726 async fn test_integration_two_consumers_shared_port() {
8727 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8728
8729 let _guard = lock_registry_test_mutex();
8730
8731 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8733 let port = listener.local_addr().unwrap().port();
8734 drop(listener);
8735
8736 let component = HttpComponent::new();
8737 let endpoint_ctx = NoOpComponentContext;
8738
8739 let endpoint_a = component
8741 .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
8742 .unwrap();
8743 let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
8744
8745 let endpoint_b = component
8747 .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
8748 .unwrap();
8749 let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
8750
8751 let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8752 let token_a = tokio_util::sync::CancellationToken::new();
8753 let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
8754
8755 let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8756 let token_b = tokio_util::sync::CancellationToken::new();
8757 let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
8758
8759 tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
8760 tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
8761 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8762
8763 let client = reqwest::Client::new();
8764
8765 let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
8767 let (resp_hello, _) = tokio::join!(fut_hello, async {
8768 if let Some(mut envelope) = rx_a.recv().await {
8769 envelope.exchange.input.body =
8770 camel_component_api::Body::Text("hello-response".to_string());
8771 if let Some(reply_tx) = envelope.reply_tx {
8772 let _ = reply_tx.send(Ok(envelope.exchange));
8773 }
8774 }
8775 });
8776
8777 let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
8779 let (resp_world, _) = tokio::join!(fut_world, async {
8780 if let Some(mut envelope) = rx_b.recv().await {
8781 envelope.exchange.input.body =
8782 camel_component_api::Body::Text("world-response".to_string());
8783 if let Some(reply_tx) = envelope.reply_tx {
8784 let _ = reply_tx.send(Ok(envelope.exchange));
8785 }
8786 }
8787 });
8788
8789 let body_a = resp_hello.unwrap().text().await.unwrap();
8790 let body_b = resp_world.unwrap().text().await.unwrap();
8791
8792 assert_eq!(body_a, "hello-response");
8793 assert_eq!(body_b, "world-response");
8794
8795 token_a.cancel();
8796 token_b.cancel();
8797 }
8798
8799 #[tokio::test]
8800 #[allow(clippy::await_holding_lock)]
8801 async fn test_integration_unregistered_path_returns_404() {
8802 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8803
8804 let _guard = lock_registry_test_mutex();
8805
8806 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8808 let port = listener.local_addr().unwrap().port();
8809 drop(listener);
8810
8811 let component = HttpComponent::new();
8812 let endpoint_ctx = NoOpComponentContext;
8813 let endpoint = component
8814 .create_endpoint(
8815 &format!("http://127.0.0.1:{port}/registered"),
8816 &endpoint_ctx,
8817 )
8818 .unwrap();
8819 let mut consumer = endpoint.create_consumer(rt()).unwrap();
8820
8821 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8822 let token = tokio_util::sync::CancellationToken::new();
8823 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8824
8825 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8826
8827 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
8829 loop {
8830 if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
8831 .await
8832 .is_ok()
8833 {
8834 break;
8835 }
8836 if std::time::Instant::now() >= deadline {
8837 panic!("HTTP server did not start within 5s on port {port}");
8838 }
8839 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
8840 }
8841
8842 let client = reqwest::Client::new();
8843 let resp = client
8844 .get(format!("http://127.0.0.1:{port}/not-there"))
8845 .send()
8846 .await
8847 .unwrap();
8848 assert_eq!(resp.status().as_u16(), 404);
8849
8850 token.cancel();
8851 }
8852
8853 #[test]
8854 fn test_http_consumer_declares_concurrent() {
8855 use camel_component_api::ConcurrencyModel;
8856
8857 let config = HttpServerConfig {
8858 scheme: "http".to_string(),
8859 host: "127.0.0.1".to_string(),
8860 port: 19999,
8861 path: "/test".to_string(),
8862 max_request_body: 2 * 1024 * 1024,
8863 max_response_body: 10 * 1024 * 1024,
8864 max_inflight_requests: 1024,
8865 method: None,
8866 tls_config: None,
8867 };
8868 let consumer = HttpConsumer::new(config, test_rt());
8869 assert_eq!(
8870 consumer.concurrency_model(),
8871 ConcurrencyModel::Concurrent { max: None }
8872 );
8873 }
8874
8875 #[test]
8876 fn server_config_parses_tls_cert_and_key() {
8877 let cfg = HttpServerConfig::from_uri(
8878 "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
8879 )
8880 .unwrap();
8881 assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
8882 assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
8883 }
8884
8885 #[test]
8886 fn server_config_no_tls_when_params_absent() {
8887 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
8888 assert!(cfg.tls_config.is_none());
8889 }
8890
8891 #[tokio::test]
8896 async fn test_http_reply_body_stream_variant_exists() {
8897 use bytes::Bytes;
8898 use camel_component_api::CamelError;
8899 use futures::stream;
8900
8901 let chunks: Vec<Result<Bytes, CamelError>> =
8902 vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
8903 let stream = Box::pin(stream::iter(chunks));
8904 let reply_body = HttpReplyBody::Stream(stream);
8905 match reply_body {
8907 HttpReplyBody::Stream(_) => {}
8908 HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
8909 }
8910 }
8911
8912 #[cfg(feature = "otel")]
8917 mod otel_tests {
8918 use super::*;
8919 use camel_component_api::Message;
8920 use tower::ServiceExt;
8921
8922 #[tokio::test]
8923 async fn test_producer_injects_traceparent_header() {
8924 let (url, _handle) = start_test_server_with_header_capture().await;
8925 let ctx = test_producer_ctx();
8926
8927 let component = HttpComponent::new();
8928 let endpoint_ctx = NoOpComponentContext;
8929 let endpoint = component
8930 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
8931 .unwrap();
8932 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
8933
8934 let mut exchange = Exchange::new(Message::default());
8936 let mut headers = std::collections::HashMap::new();
8937 headers.insert(
8938 "traceparent".to_string(),
8939 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
8940 );
8941 camel_otel::extract_into_exchange(&mut exchange, &headers);
8942
8943 let result = producer.oneshot(exchange).await.unwrap();
8944
8945 let status = result
8947 .input
8948 .header("CamelHttpResponseCode")
8949 .and_then(|v| v.as_u64())
8950 .unwrap();
8951 assert_eq!(status, 200);
8952
8953 let traceparent = result.input.header("X-Received-Traceparent");
8955 assert!(
8956 traceparent.is_some(),
8957 "traceparent header should have been sent"
8958 );
8959
8960 let traceparent_str = traceparent.unwrap().as_str().unwrap();
8961 let parts: Vec<&str> = traceparent_str.split('-').collect();
8963 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
8964 assert_eq!(parts[0], "00", "version should be 00");
8965 assert_eq!(
8966 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
8967 "trace-id should match"
8968 );
8969 assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
8970 assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
8971 }
8972
8973 #[tokio::test]
8974 async fn test_consumer_extracts_traceparent_header() {
8975 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8976
8977 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8979 let port = listener.local_addr().unwrap().port();
8980 drop(listener);
8981
8982 let component = HttpComponent::new();
8983 let endpoint_ctx = NoOpComponentContext;
8984 let endpoint = component
8985 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
8986 .unwrap();
8987 let mut consumer = endpoint.create_consumer(rt()).unwrap();
8988
8989 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8990 let token = tokio_util::sync::CancellationToken::new();
8991 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8992
8993 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8994 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8995
8996 let client = reqwest::Client::new();
8998 let send_fut = client
8999 .post(format!("http://127.0.0.1:{port}/trace"))
9000 .header(
9001 "traceparent",
9002 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
9003 )
9004 .body("test")
9005 .send();
9006
9007 let (http_result, _) = tokio::join!(send_fut, async {
9008 if let Some(envelope) = rx.recv().await {
9009 let mut injected_headers = std::collections::HashMap::new();
9012 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
9013
9014 assert!(
9015 injected_headers.contains_key("traceparent"),
9016 "Exchange should have traceparent after extraction"
9017 );
9018
9019 let traceparent = injected_headers.get("traceparent").unwrap();
9020 let parts: Vec<&str> = traceparent.split('-').collect();
9021 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
9022 assert_eq!(
9023 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
9024 "Trace ID should match the original traceparent header"
9025 );
9026
9027 if let Some(reply_tx) = envelope.reply_tx {
9028 let _ = reply_tx.send(Ok(envelope.exchange));
9029 }
9030 }
9031 });
9032
9033 let resp = http_result.unwrap();
9034 assert_eq!(resp.status().as_u16(), 200);
9035
9036 token.cancel();
9037 }
9038
9039 #[tokio::test]
9040 async fn test_consumer_extracts_mixed_case_traceparent_header() {
9041 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9042
9043 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9045 let port = listener.local_addr().unwrap().port();
9046 drop(listener);
9047
9048 let component = HttpComponent::new();
9049 let endpoint_ctx = NoOpComponentContext;
9050 let endpoint = component
9051 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
9052 .unwrap();
9053 let mut consumer = endpoint.create_consumer(rt()).unwrap();
9054
9055 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9056 let token = tokio_util::sync::CancellationToken::new();
9057 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9058
9059 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9060 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9061
9062 let client = reqwest::Client::new();
9064 let send_fut = client
9065 .post(format!("http://127.0.0.1:{port}/trace"))
9066 .header(
9067 "TraceParent",
9068 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
9069 )
9070 .body("test")
9071 .send();
9072
9073 let (http_result, _) = tokio::join!(send_fut, async {
9074 if let Some(envelope) = rx.recv().await {
9075 let mut injected_headers = HashMap::new();
9078 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
9079
9080 assert!(
9081 injected_headers.contains_key("traceparent"),
9082 "Exchange should have traceparent after extraction from mixed-case header"
9083 );
9084
9085 let traceparent = injected_headers.get("traceparent").unwrap();
9086 let parts: Vec<&str> = traceparent.split('-').collect();
9087 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
9088 assert_eq!(
9089 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
9090 "Trace ID should match the original mixed-case TraceParent header"
9091 );
9092
9093 if let Some(reply_tx) = envelope.reply_tx {
9094 let _ = reply_tx.send(Ok(envelope.exchange));
9095 }
9096 }
9097 });
9098
9099 let resp = http_result.unwrap();
9100 assert_eq!(resp.status().as_u16(), 200);
9101
9102 token.cancel();
9103 }
9104
9105 #[tokio::test]
9106 async fn test_producer_no_trace_context_no_crash() {
9107 let (url, _handle) = start_test_server().await;
9108 let ctx = test_producer_ctx();
9109
9110 let component = HttpComponent::new();
9111 let endpoint_ctx = NoOpComponentContext;
9112 let endpoint = component
9113 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
9114 .unwrap();
9115 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
9116
9117 let exchange = Exchange::new(Message::default());
9119
9120 let result = producer.oneshot(exchange).await.unwrap();
9122
9123 let status = result
9125 .input
9126 .header("CamelHttpResponseCode")
9127 .and_then(|v| v.as_u64())
9128 .unwrap();
9129 assert_eq!(status, 200);
9130 }
9131
9132 async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
9134 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9135 let addr = listener.local_addr().unwrap();
9136 let url = format!("http://127.0.0.1:{}", addr.port());
9137
9138 let handle = tokio::spawn(async move {
9139 loop {
9140 if let Ok((mut stream, _)) = listener.accept().await {
9141 tokio::spawn(async move {
9142 use tokio::io::{AsyncReadExt, AsyncWriteExt};
9143 let mut buf = vec![0u8; 8192];
9144 let n = stream.read(&mut buf).await.unwrap_or(0);
9145 let request = String::from_utf8_lossy(&buf[..n]).to_string();
9146
9147 let traceparent = request
9149 .lines()
9150 .find(|line| line.to_lowercase().starts_with("traceparent:"))
9151 .map(|line| {
9152 line.split(':')
9153 .nth(1)
9154 .map(|s| s.trim().to_string())
9155 .unwrap_or_default()
9156 })
9157 .unwrap_or_default();
9158
9159 let body =
9160 format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
9161 let response = format!(
9162 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
9163 body.len(),
9164 traceparent,
9165 body
9166 );
9167 let _ = stream.write_all(response.as_bytes()).await;
9168 });
9169 }
9170 }
9171 });
9172
9173 (url, handle)
9174 }
9175 }
9176
9177 #[tokio::test]
9186 async fn test_request_body_arrives_as_stream() {
9187 use camel_component_api::Body;
9188 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9189
9190 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9191 let port = listener.local_addr().unwrap().port();
9192 drop(listener);
9193
9194 let component = HttpComponent::new();
9195 let endpoint_ctx = NoOpComponentContext;
9196 let endpoint = component
9197 .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
9198 .unwrap();
9199 let mut consumer = endpoint.create_consumer(rt()).unwrap();
9200
9201 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9202 let token = tokio_util::sync::CancellationToken::new();
9203 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9204
9205 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9206 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9207
9208 let client = reqwest::Client::new();
9209 let send_fut = client
9210 .post(format!("http://127.0.0.1:{port}/upload"))
9211 .body("hello streaming world")
9212 .send();
9213
9214 let (http_result, _) = tokio::join!(send_fut, async {
9215 if let Some(mut envelope) = rx.recv().await {
9216 assert!(
9218 matches!(envelope.exchange.input.body, Body::Stream(_)),
9219 "expected Body::Stream, got discriminant {:?}",
9220 std::mem::discriminant(&envelope.exchange.input.body)
9221 );
9222 let bytes = envelope
9224 .exchange
9225 .input
9226 .body
9227 .into_bytes(1024 * 1024)
9228 .await
9229 .unwrap();
9230 assert_eq!(&bytes[..], b"hello streaming world");
9231
9232 envelope.exchange.input.body = camel_component_api::Body::Empty;
9233 if let Some(reply_tx) = envelope.reply_tx {
9234 let _ = reply_tx.send(Ok(envelope.exchange));
9235 }
9236 }
9237 });
9238
9239 let resp = http_result.unwrap();
9240 assert_eq!(resp.status().as_u16(), 200);
9241
9242 token.cancel();
9243 }
9244
9245 #[tokio::test]
9250 async fn test_streaming_response_chunked() {
9251 use bytes::Bytes;
9252 use camel_component_api::Body;
9253 use camel_component_api::CamelError;
9254 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9255 use camel_component_api::{StreamBody, StreamMetadata};
9256 use futures::stream;
9257 use std::sync::Arc;
9258 use tokio::sync::Mutex;
9259
9260 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9261 let port = listener.local_addr().unwrap().port();
9262 drop(listener);
9263
9264 let component = HttpComponent::new();
9265 let endpoint_ctx = NoOpComponentContext;
9266 let endpoint = component
9267 .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
9268 .unwrap();
9269 let mut consumer = endpoint.create_consumer(rt()).unwrap();
9270
9271 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9272 let token = tokio_util::sync::CancellationToken::new();
9273 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9274
9275 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9276 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9277
9278 let client = reqwest::Client::new();
9279 let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
9280
9281 let (http_result, _) = tokio::join!(send_fut, async {
9282 if let Some(mut envelope) = rx.recv().await {
9283 let chunks: Vec<Result<Bytes, CamelError>> =
9285 vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
9286 let stream = Box::pin(stream::iter(chunks));
9287 envelope.exchange.input.body = Body::Stream(StreamBody {
9288 stream: Arc::new(Mutex::new(Some(stream))),
9289 metadata: StreamMetadata::default(),
9290 });
9291 if let Some(reply_tx) = envelope.reply_tx {
9292 let _ = reply_tx.send(Ok(envelope.exchange));
9293 }
9294 }
9295 });
9296
9297 let resp = http_result.unwrap();
9298 assert_eq!(resp.status().as_u16(), 200);
9299 let body = resp.text().await.unwrap();
9300 assert_eq!(body, "chunk1chunk2");
9301
9302 token.cancel();
9303 }
9304
9305 #[tokio::test]
9310 async fn test_413_when_content_length_exceeds_limit() {
9311 use camel_component_api::ConsumerContext;
9312
9313 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9314 let port = listener.local_addr().unwrap().port();
9315 drop(listener);
9316
9317 let component = HttpComponent::new();
9319 let endpoint_ctx = NoOpComponentContext;
9320 let endpoint = component
9321 .create_endpoint(
9322 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
9323 &endpoint_ctx,
9324 )
9325 .unwrap();
9326 let mut consumer = endpoint.create_consumer(rt()).unwrap();
9327
9328 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
9329 let token = tokio_util::sync::CancellationToken::new();
9330 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9331
9332 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9333 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9334
9335 let client = reqwest::Client::new();
9336 let resp = client
9337 .post(format!("http://127.0.0.1:{port}/upload"))
9338 .header("Content-Length", "1000") .body("x".repeat(1000))
9340 .send()
9341 .await
9342 .unwrap();
9343
9344 assert_eq!(resp.status().as_u16(), 413);
9345
9346 token.cancel();
9347 }
9348
9349 #[tokio::test]
9353 async fn test_chunked_upload_without_content_length_bypasses_limit() {
9354 use bytes::Bytes;
9355 use camel_component_api::Body;
9356 use camel_component_api::ConsumerContext;
9357 use futures::stream;
9358
9359 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9360 let port = listener.local_addr().unwrap().port();
9361 drop(listener);
9362
9363 let component = HttpComponent::new();
9365 let endpoint_ctx = NoOpComponentContext;
9366 let endpoint = component
9367 .create_endpoint(
9368 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
9369 &endpoint_ctx,
9370 )
9371 .unwrap();
9372 let mut consumer = endpoint.create_consumer(rt()).unwrap();
9373
9374 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
9375 let token = tokio_util::sync::CancellationToken::new();
9376 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9377
9378 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9379 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9380
9381 let client = reqwest::Client::new();
9382
9383 let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
9387 Ok(Bytes::from("y".repeat(50))),
9388 Ok(Bytes::from("y".repeat(50))),
9389 ];
9390 let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
9391 let send_fut = client
9392 .post(format!("http://127.0.0.1:{port}/upload"))
9393 .body(stream_body)
9394 .send();
9395
9396 let consumer_fut = async {
9397 match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
9399 Ok(Some(mut envelope)) => {
9400 assert!(
9401 matches!(envelope.exchange.input.body, Body::Stream(_)),
9402 "expected Body::Stream"
9403 );
9404 envelope.exchange.input.body = camel_component_api::Body::Empty;
9405 if let Some(reply_tx) = envelope.reply_tx {
9406 let _ = reply_tx.send(Ok(envelope.exchange));
9407 }
9408 }
9409 Ok(None) => panic!("consumer channel closed unexpectedly"),
9410 Err(_) => {
9411 }
9414 }
9415 };
9416
9417 let (http_result, _) = tokio::join!(send_fut, consumer_fut);
9418
9419 let resp = http_result.unwrap();
9420 assert_ne!(
9427 resp.status().as_u16(),
9428 413,
9429 "chunked upload has no Content-Length to pre-check"
9430 );
9431 assert_eq!(resp.status().as_u16(), 200);
9432
9433 token.cancel();
9434 }
9435
9436 #[test]
9437 fn test_is_private_ip_ranges() {
9438 use camel_api::is_ssrf_blocked_ip;
9439 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(
9458 &"2001:4860:4860::8888".parse().unwrap()
9459 )); }
9461
9462 #[test]
9463 fn test_title_case_header() {
9464 assert_eq!(title_case_header("content-type"), "Content-Type");
9465 assert_eq!(title_case_header("authorization"), "Authorization");
9466 assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
9467 assert_eq!(title_case_header("host"), "Host");
9468 assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
9469 assert_eq!(title_case_header("single"), "Single");
9470 assert_eq!(title_case_header(""), "");
9471 }
9472
9473 #[test]
9474 fn test_resolve_url_combines_path_and_query_sources() {
9475 let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
9476 let mut exchange = Exchange::new(Message::default());
9477 exchange.input.set_header(
9478 "CamelHttpPath",
9479 serde_json::Value::String("next".to_string()),
9480 );
9481 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9482 assert!(url.starts_with("http://example.com/base/next?"));
9483 assert!(url.contains("foo=bar"));
9484
9485 exchange.input.set_header(
9486 "CamelHttpUri",
9487 serde_json::Value::String("http://other.test/root".to_string()),
9488 );
9489 exchange.input.set_header(
9490 "CamelHttpQuery",
9491 serde_json::Value::String("a=1&b=2".to_string()),
9492 );
9493
9494 let override_url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9495 assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
9496 }
9497
9498 fn exchange_with_path_and_query(path: &str, query: &str) -> Exchange {
9499 let mut exchange = Exchange::new(Message::default());
9500 exchange
9501 .input
9502 .set_header("CamelHttpPath", serde_json::Value::String(path.to_string()));
9503 exchange.input.set_header(
9504 "CamelHttpQuery",
9505 serde_json::Value::String(query.to_string()),
9506 );
9507 exchange
9508 }
9509
9510 #[test]
9511 fn resolve_url_bridge_endpoint_true_ignores_exchange_path() {
9512 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9513 cfg.bridge_endpoint = true;
9514 cfg.query_params
9515 .push(("token".to_string(), "secret".to_string()));
9516 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
9517 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9518 assert_eq!(url, "http://x?token=secret");
9522 assert!(!url.contains("/foo"));
9523 assert!(!url.contains("dropme"));
9524 }
9525
9526 #[test]
9527 fn resolve_url_bridge_endpoint_false_merges_path() {
9528 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9529 cfg.bridge_endpoint = false;
9530 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
9531 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9532 assert!(url.contains("/foo"), "url should contain /foo: {url}");
9533 assert!(
9534 url.contains("dropme=1"),
9535 "url should contain dropme=1: {url}"
9536 );
9537 }
9538
9539 #[test]
9540 fn resolve_url_bridge_endpoint_true_keeps_base_when_no_query_params() {
9541 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9542 cfg.bridge_endpoint = true;
9543 let mut exchange = Exchange::new(Message::default());
9544 exchange.input.set_header(
9545 "CamelHttpPath",
9546 serde_json::Value::String("/foo".to_string()),
9547 );
9548 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9549 assert_eq!(url, "http://x");
9550 assert!(!url.contains("/foo"));
9551 }
9552
9553 #[test]
9554 fn resolve_url_bridge_endpoint_true_ignores_camel_http_uri() {
9555 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9556 cfg.bridge_endpoint = true;
9557 let mut exchange = Exchange::new(Message::default());
9559 exchange.input.set_header(
9560 "CamelHttpUri",
9561 serde_json::Value::String("http://dest/explicit".to_string()),
9562 );
9563 exchange.input.set_header(
9564 "CamelHttpPath",
9565 serde_json::Value::String("/foo".to_string()),
9566 );
9567 exchange.input.set_header(
9568 "CamelHttpQuery",
9569 serde_json::Value::String("x=1".to_string()),
9570 );
9571 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9572 assert_eq!(url, "http://x");
9576 }
9577
9578 #[test]
9579 fn bridge_programmatic_params_use_percent20() {
9580 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9581 cfg.bridge_endpoint = true;
9582 cfg.query_params = vec![("b".to_string(), "x y".to_string())];
9583 let exchange = Exchange::new(Message::default());
9584
9585 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9586
9587 assert_eq!(url, "http://x?b=x%20y");
9591 assert!(!url.contains('+'));
9592 }
9593
9594 #[test]
9595 fn bridge_arm_carries_authored_raw_query() {
9596 let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
9597 let exchange = exchange_with_path_and_query("/ignored", "dropme=1");
9600
9601 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9602
9603 assert_eq!(url, "http://h/p?a=1");
9606 assert!(!url.contains("dropme"), "exchange query leaked: {url}");
9607 assert!(!url.contains("/ignored"), "exchange path leaked: {url}");
9608 }
9609
9610 #[test]
9617 fn resolve_url_bridge_preserves_dot_segments() {
9618 let mut cfg = HttpEndpointConfig::from_uri("http://h/a/../b").unwrap();
9619 cfg.bridge_endpoint = true;
9620 cfg.query_params.push(("k".to_string(), "1".to_string()));
9621 let exchange = Exchange::new(Message::default());
9622
9623 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9624
9625 assert_eq!(url, "http://h/a/../b?k=1");
9628 }
9629
9630 #[test]
9631 fn resolve_url_bridge_preserves_default_port() {
9632 let mut cfg = HttpEndpointConfig::from_uri("http://h:80/p").unwrap();
9633 cfg.bridge_endpoint = true;
9634 cfg.query_params.push(("k".to_string(), "1".to_string()));
9635 let exchange = Exchange::new(Message::default());
9636
9637 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9638
9639 assert_eq!(url, "http://h:80/p?k=1");
9642 }
9643
9644 #[test]
9645 fn resolve_url_bridge_preserves_scheme_and_host_case() {
9646 let mut cfg = HttpEndpointConfig::from_uri("http://ExAMPLE.COM/p").unwrap();
9647 cfg.bridge_endpoint = true;
9648 cfg.query_params.push(("k".to_string(), "1".to_string()));
9649 cfg.base_url = "HTTP://ExAMPLE.COM/p".to_string();
9653 let exchange = Exchange::new(Message::default());
9654
9655 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9656
9657 assert_eq!(url, "HTTP://ExAMPLE.COM/p?k=1");
9660 }
9661
9662 #[test]
9663 fn resolve_url_bridge_no_query_emits_base_verbatim() {
9664 let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9665 cfg.bridge_endpoint = true;
9666 let exchange = Exchange::new(Message::default());
9667
9668 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9669
9670 assert_eq!(url, "http://h/p");
9673 }
9674
9675 #[test]
9676 fn resolve_url_bridge_and_non_bridge_byte_identical() {
9677 let mut bridged = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
9680 bridged.bridge_endpoint = true;
9681 bridged
9682 .query_params
9683 .push(("k".to_string(), "1".to_string()));
9684 let bridge_url =
9685 HttpProducer::resolve_url(&Exchange::new(Message::default()), &bridged).unwrap();
9686
9687 let plain = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
9690 let mut exchange = Exchange::new(Message::default());
9691 exchange.input.set_header(
9692 "CamelHttpQuery",
9693 serde_json::Value::String("k=1".to_string()),
9694 );
9695 let plain_url = HttpProducer::resolve_url(&exchange, &plain).unwrap();
9696
9697 assert_eq!(bridge_url, plain_url);
9698 assert_eq!(bridge_url, "http://H:80/a/../b?k=1");
9699 }
9700
9701 #[test]
9702 fn resolve_url_bridge_preserves_ipv6_authority_verbatim() {
9703 let mut cfg = HttpEndpointConfig::from_uri("http://[::1]:8080/p").unwrap();
9704 cfg.bridge_endpoint = true;
9705 cfg.query_params.push(("k".to_string(), "1".to_string()));
9706 let exchange = Exchange::new(Message::default());
9707
9708 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9709
9710 assert_eq!(url, "http://[::1]:8080/p?k=1");
9711 }
9712
9713 #[test]
9714 fn resolve_url_bridge_empty_base_path_keeps_no_synthetic_slash() {
9715 let cfg = HttpEndpointConfig::from_uri("http://h?x=1&bridgeEndpoint=true").unwrap();
9716 let exchange = Exchange::new(Message::default());
9717
9718 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9719
9720 assert_eq!(url, "http://h?x=1");
9723 }
9724
9725 #[test]
9730 fn resolve_url_preserves_authored_query_order_and_bytes() {
9731 let config =
9732 HttpEndpointConfig::from_uri("http://h/p?a=1&b=x,y&c=t:1&connectTimeout=5000").unwrap();
9733 let exchange = Exchange::new(Message::default());
9734
9735 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9736
9737 assert_eq!(url, "http://h/p?a=1&b=x,y&c=t:1");
9740 }
9741
9742 #[test]
9743 fn resolve_url_consumes_encoded_option_key() {
9744 let config = HttpEndpointConfig::from_uri("http://h/p?connect%54imeout=5000&a=1").unwrap();
9745 let exchange = Exchange::new(Message::default());
9746
9747 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9748
9749 assert_eq!(url, "http://h/p?a=1");
9751 }
9752
9753 #[test]
9754 fn resolve_url_all_options_consumed_drops_query() {
9755 let config = HttpEndpointConfig::from_uri("http://h/p?connectTimeout=5000").unwrap();
9756 let exchange = Exchange::new(Message::default());
9757
9758 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9759
9760 assert_eq!(url, "http://h/p");
9763 assert!(!url.contains('?'));
9764 }
9765
9766 #[test]
9767 fn resolve_url_preserves_empty_query_marker() {
9768 let config = HttpEndpointConfig::from_uri("http://h/p?").unwrap();
9769 let exchange = Exchange::new(Message::default());
9770
9771 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9772
9773 assert_eq!(url, "http://h/p?");
9776 }
9777
9778 #[test]
9779 fn resolve_url_raw_wrapper_not_re_encoded() {
9780 let config = HttpEndpointConfig::from_uri("http://h/p?token=RAW(abc)").unwrap();
9781 let exchange = Exchange::new(Message::default());
9782
9783 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9784
9785 assert_eq!(url, "http://h/p?token=RAW(abc)");
9787 assert!(!url.contains("%28"), "RAW( wrapper re-encoded: {url}");
9788 }
9789
9790 #[test]
9791 fn resolve_url_camel_http_query_composes_verbatim_span() {
9792 let config = HttpEndpointConfig::from_uri("http://h/p?x=1").unwrap();
9793 let mut exchange = Exchange::new(Message::default());
9794 exchange.input.set_header(
9795 "CamelHttpQuery",
9796 serde_json::Value::String("userFilter=a%2Cb".to_string()),
9797 );
9798
9799 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9800
9801 assert_eq!(url, "http://h/p?x=1&userFilter=a%2Cb");
9806 assert!(!url.contains("%252C"), "header bytes re-encoded: {url}");
9807 }
9808
9809 #[test]
9814 fn header_composes_with_endpoint_query() {
9815 let config =
9816 HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret&lang=en").unwrap();
9817 let mut exchange = Exchange::new(Message::default());
9818 exchange.input.set_header(
9819 "CamelHttpQuery",
9820 serde_json::Value::String("lang=es&page=2".to_string()),
9821 );
9822
9823 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9824
9825 assert_eq!(url, "http://upstream/api?apiKey=secret&lang=en&page=2");
9828 }
9829
9830 #[test]
9831 fn header_alone_still_rides() {
9832 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9833 let mut exchange = Exchange::new(Message::default());
9834 exchange.input.set_header(
9835 "CamelHttpQuery",
9836 serde_json::Value::String("page=2".to_string()),
9837 );
9838
9839 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9840
9841 assert_eq!(url, "http://upstream/api?page=2");
9843 }
9844
9845 #[test]
9846 fn empty_reflected_query_leaves_endpoint_query_intact() {
9847 let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
9848 let mut exchange = Exchange::new(Message::default());
9849 exchange
9852 .input
9853 .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
9854
9855 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9856
9857 assert_eq!(url, "http://upstream/api?apiKey=secret");
9859 assert!(!url.ends_with('?'), "dangling '?' marker: {url}");
9860 }
9861
9862 #[test]
9863 fn forbidden_byte_in_header_query_errors() {
9864 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9865 let mut exchange = Exchange::new(Message::default());
9866 exchange.input.set_header(
9867 "CamelHttpQuery",
9868 serde_json::Value::String("q=ab<cd".to_string()),
9869 );
9870
9871 let err = HttpProducer::resolve_url(&exchange, &config)
9872 .unwrap_err()
9873 .to_string();
9874
9875 assert!(err.contains("0x3C"), "error must name the byte: {err}");
9878 }
9879
9880 #[test]
9881 fn override_uri_with_query_plus_header_query() {
9882 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9883 let mut exchange = Exchange::new(Message::default());
9884 exchange.input.set_header(
9885 "CamelHttpUri",
9886 serde_json::Value::String("http://host/api?a=1".to_string()),
9887 );
9888 exchange.input.set_header(
9889 "CamelHttpQuery",
9890 serde_json::Value::String("a=2&b=3".to_string()),
9891 );
9892
9893 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9894
9895 assert_eq!(url, "http://host/api?a=1&b=3");
9898 }
9899
9900 #[test]
9901 fn path_applies_before_query_composition() {
9902 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9903 let mut exchange = Exchange::new(Message::default());
9904 exchange.input.set_header(
9905 "CamelHttpUri",
9906 serde_json::Value::String("http://host/api?a=1".to_string()),
9907 );
9908 exchange.input.set_header(
9909 "CamelHttpPath",
9910 serde_json::Value::String("/extra".to_string()),
9911 );
9912 exchange.input.set_header(
9913 "CamelHttpQuery",
9914 serde_json::Value::String("b=2".to_string()),
9915 );
9916
9917 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9918
9919 assert_eq!(url, "http://host/api/extra?a=1&b=2");
9922 }
9923
9924 #[test]
9925 fn plain_proxy_reflection_composes() {
9926 let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
9927 let exchange = exchange_with_path_and_query("/in/extra", "page=2");
9929
9930 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9931
9932 assert_eq!(url, "http://upstream/api/in/extra?apiKey=secret&page=2");
9935 }
9936
9937 #[test]
9938 fn bridge_endpoint_ignores_url_headers() {
9939 let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
9940 let mut exchange = Exchange::new(Message::default());
9941 exchange.input.set_header(
9942 "CamelHttpUri",
9943 serde_json::Value::String("http://evil.test/x".to_string()),
9944 );
9945 exchange.input.set_header(
9946 "CamelHttpPath",
9947 serde_json::Value::String("/foo".to_string()),
9948 );
9949 exchange.input.set_header(
9950 "CamelHttpQuery",
9951 serde_json::Value::String("z=9".to_string()),
9952 );
9953
9954 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9955
9956 assert_eq!(url, "http://h/p?a=1");
9959 assert!(!url.contains("evil"), "override leaked: {url}");
9960 assert!(!url.contains("z=9"), "header query leaked: {url}");
9961 assert!(!url.contains("/foo"), "header path leaked: {url}");
9962 }
9963
9964 #[test]
9965 fn resolve_url_programmatic_params_use_percent20_deterministic() {
9966 let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9967 config.query_params = vec![
9968 ("b".to_string(), "x y".to_string()),
9969 ("a".to_string(), "1".to_string()),
9970 ];
9971 let exchange = Exchange::new(Message::default());
9972
9973 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9974
9975 assert_eq!(url, "http://h/p?b=x%20y&a=1");
9978 assert!(!url.contains('+'));
9979 }
9980
9981 #[test]
9982 fn resolve_url_authored_and_programmatic_merge() {
9983 let mut config = HttpEndpointConfig::from_uri("http://h/p?a=1&c=t:1").unwrap();
9984 config.query_params = vec![
9985 ("b".to_string(), "2".to_string()),
9986 ("a".to_string(), "9".to_string()),
9987 ];
9988 let exchange = Exchange::new(Message::default());
9989
9990 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9991
9992 assert_eq!(url, "http://h/p?a=1&c=t:1&b=2");
9995 }
9996
9997 #[test]
9998 fn from_uri_no_longer_fills_query_params_from_uri() {
9999 let config = HttpEndpointConfig::from_uri("http://h/p?a=1&connectTimeout=5000").unwrap();
10000
10001 assert!(
10003 config.query_params.is_empty(),
10004 "query_params is programmatic-only: {:?}",
10005 config.query_params
10006 );
10007 assert_eq!(config.raw_query.as_deref(), Some("a=1&connectTimeout=5000"));
10008 }
10009
10010 #[test]
10011 fn resolve_url_forbidden_raw_byte_errors() {
10012 let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10013 config.raw_query = Some("a=x y".to_string());
10014 let exchange = Exchange::new(Message::default());
10015
10016 let err = HttpProducer::resolve_url(&exchange, &config)
10017 .expect_err("literal space in raw query must error");
10018
10019 assert!(
10021 err.to_string().contains("0x20"),
10022 "error must name the forbidden byte: {err}"
10023 );
10024 }
10025
10026 #[test]
10030 fn resolve_url_override_query_forbidden_byte_errors() {
10031 let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10032 let mut exchange = Exchange::new(Message::default());
10033 exchange.input.set_header(
10034 "CamelHttpUri",
10035 serde_json::Value::String("http://h2/p?a=x y".to_string()),
10036 );
10037
10038 let err = HttpProducer::resolve_url(&exchange, &config)
10039 .expect_err("literal space in the override URI's query must error");
10040
10041 assert!(
10042 err.to_string().contains("0x20"),
10043 "error must name the forbidden byte from the override query: {err}"
10044 );
10045 }
10046
10047 #[test]
10052 fn merge_header_query_decoded_key_collision_drops_header_pair() {
10053 let merged = merge_header_query(Some("a=1"), "%61=2")
10054 .expect("decoded-key collision must not be a parse error");
10055 assert_eq!(
10056 merged.as_deref(),
10057 Some("a=1"),
10058 "the higher-precedence span wins and the colliding header pair is dropped"
10059 );
10060 }
10061
10062 #[test]
10065 fn merge_header_query_duplicate_keys_within_header_ride_verbatim() {
10066 let merged = merge_header_query(None, "k=1&k=2")
10067 .expect("duplicate header keys must not be a parse error");
10068 assert_eq!(
10069 merged.as_deref(),
10070 Some("k=1&k=2"),
10071 "intra-header duplicate keys ride verbatim"
10072 );
10073 }
10074
10075 #[test]
10082 fn endpoint_config_debug_masks_base_url_userinfo() {
10083 let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
10084 config.base_url = "http://user:pass@h.example/p".to_string();
10085 let rendered = format!("{config:?}");
10086 assert!(
10087 rendered.contains("***@h.example"),
10088 "userinfo must render masked: {rendered}"
10089 );
10090 assert!(
10091 !rendered.contains("user:pass"),
10092 "no credentials in Debug output: {rendered}"
10093 );
10094
10095 let plain = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
10096 let rendered_plain = format!("{plain:?}");
10097 assert!(
10098 rendered_plain.contains("http://h.example/p"),
10099 "a base without userinfo renders unchanged: {rendered_plain}"
10100 );
10101 }
10102
10103 #[test]
10108 fn endpoint_config_debug_base_url_converges_on_canonical_redact() {
10109 let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
10110
10111 config.base_url = "http://h.example/p?token=secret#access_token=x".to_string();
10112 let rendered = format!("{config:?}");
10113 assert!(
10114 rendered.contains("base_url: \"http://h.example/p?[redacted]#[redacted]\""),
10115 "query and fragment must render as composed sentinels: {rendered}"
10116 );
10117 assert!(
10118 !rendered.contains("token=secret") && !rendered.contains("access_token"),
10119 "query/fragment credential bytes must not render: {rendered}"
10120 );
10121
10122 config.base_url = "http://h.example//u2:p2@evil/".to_string();
10123 let rendered = format!("{config:?}");
10124 assert!(
10125 rendered.contains("base_url: \"http://h.example//***@evil/\""),
10126 "later //window userinfo must mask (canonical window rule): {rendered}"
10127 );
10128 assert!(
10129 !rendered.contains("u2:p2"),
10130 "later-window credentials must not render: {rendered}"
10131 );
10132
10133 config.base_url = "http://user:pass@h.example/p?token=x".to_string();
10136 let canonical = camel_api::redact::redact_url(&config.base_url);
10137 assert_eq!(canonical, "http://***@h.example/p?[redacted]");
10138 let rendered = format!("{config:?}");
10139 assert!(
10140 rendered.contains(&format!("base_url: \"{canonical}\"")),
10141 "Debug base_url must equal canonical redact_url output: {rendered}"
10142 );
10143 }
10144
10145 #[test]
10151 fn resolve_url_authored_apostrophe_rejected_percent_escape_rides() {
10152 let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10153
10154 config.raw_query = Some("q=it's".to_string());
10155 let exchange = Exchange::new(Message::default());
10156 let err = HttpProducer::resolve_url(&exchange, &config)
10157 .expect_err("authored apostrophe must be rejected, not silently %27-normalized");
10158 assert!(
10159 err.to_string().contains("0x27"),
10160 "error must name the apostrophe byte: {err}"
10161 );
10162
10163 config.raw_query = Some("q=it%27s".to_string());
10164 let url = HttpProducer::resolve_url(&exchange, &config)
10165 .expect("authored %27 escape is wire-legal");
10166 assert!(
10167 url.contains("q=it%27s"),
10168 "the authored escape must ride byte-for-byte: {url}"
10169 );
10170
10171 for &byte in b"\"`<>" {
10175 config.raw_query = Some(format!("k={}x", byte as char));
10176 let err = HttpProducer::resolve_url(&exchange, &config)
10177 .expect_err("WHATWG special-query byte must be rejected");
10178 assert!(
10179 err.to_string().contains(&format!("0x{byte:02X}")),
10180 "error must name byte 0x{byte:02X}: {err}"
10181 );
10182 }
10183 }
10184
10185 #[test]
10186 fn armed_fence_rejects_unknown_host_redacted() {
10187 let cfg = HttpEndpointConfig::from_uri(
10188 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10189 )
10190 .unwrap();
10191 let mut exchange = Exchange::new(Message::default());
10192 exchange.input.set_header(
10193 "CamelHttpUri",
10194 serde_json::Value::String(
10195 "http://user:pass@evil.example.com/x?token=s3cret".to_string(),
10196 ),
10197 );
10198
10199 let err = HttpProducer::resolve_url(&exchange, &cfg)
10200 .expect_err("override host outside the fence must fail resolution");
10201
10202 let message = err.to_string();
10203 assert!(!message.contains("pass"), "userinfo leaked: {message}");
10204 assert!(!message.contains("s3cret"), "query leaked: {message}");
10205 }
10206
10207 #[test]
10208 fn armed_fence_rejects_unparseable_override_redacted() {
10209 let cfg = HttpEndpointConfig::from_uri(
10210 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10211 )
10212 .unwrap();
10213 let mut exchange = Exchange::new(Message::default());
10214 exchange.input.set_header(
10215 "CamelHttpUri",
10216 serde_json::Value::String("http://u:fencesecret@evil.example.com:99999/x".to_string()),
10217 );
10218
10219 let err = HttpProducer::resolve_url(&exchange, &cfg)
10220 .expect_err("unparseable override outside the fence must fail resolution");
10221
10222 let message = err.to_string();
10223 assert!(
10224 message.contains("allowedUriHosts fence"),
10225 "fence must be named: {message}"
10226 );
10227 assert!(
10228 message.contains("[redacted]"),
10229 "suppression sentinel missing: {message}"
10230 );
10231 assert!(
10232 !message.contains("evil.example.com"),
10233 "host leaked: fail-closed arm must render only the sentinel: {message}"
10234 );
10235 assert!(
10236 !message.contains("fencesecret"),
10237 "password leaked: {message}"
10238 );
10239 assert!(!message.contains("u:"), "userinfo leaked: {message}");
10240 }
10241
10242 #[test]
10243 fn armed_fence_rejects_password_only_userinfo_redacted() {
10244 let cfg = HttpEndpointConfig::from_uri(
10245 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10246 )
10247 .unwrap();
10248 let mut exchange = Exchange::new(Message::default());
10249 exchange.input.set_header(
10250 "CamelHttpUri",
10251 serde_json::Value::String(
10252 "http://:passwordonly@evil.example.com/x?token=querysecret".to_string(),
10253 ),
10254 );
10255
10256 let err = HttpProducer::resolve_url(&exchange, &cfg)
10257 .expect_err("password-only override outside the fence must fail resolution");
10258
10259 let message = err.to_string();
10260 assert!(
10261 !message.contains("passwordonly"),
10262 "password-only userinfo leaked: {message}"
10263 );
10264 assert!(!message.contains("querysecret"), "query leaked: {message}");
10265 assert!(
10266 message.contains("http://***@evil.example.com/x?[redacted]"),
10267 "masked shape missing: {message}"
10268 );
10269 }
10270
10271 #[test]
10272 fn armed_fence_allows_listed_host() {
10273 let cfg = HttpEndpointConfig::from_uri(
10274 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10275 )
10276 .unwrap();
10277 let mut exchange = Exchange::new(Message::default());
10278 exchange.input.set_header(
10279 "CamelHttpUri",
10280 serde_json::Value::String("http://cdn.example.com/x".to_string()),
10281 );
10282
10283 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10284 assert_eq!(url, "http://cdn.example.com/x");
10285 }
10286
10287 #[test]
10288 fn host_only_entry_permits_any_port() {
10289 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=cdn.example.com").unwrap();
10290 let mut exchange = Exchange::new(Message::default());
10291 exchange.input.set_header(
10292 "CamelHttpUri",
10293 serde_json::Value::String("http://cdn.example.com:9443/x".to_string()),
10294 );
10295
10296 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10297 assert_eq!(url, "http://cdn.example.com:9443/x");
10298 }
10299
10300 #[test]
10301 fn unarmed_endpoint_unchanged() {
10302 let cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
10303 let mut exchange = Exchange::new(Message::default());
10304 exchange.input.set_header(
10305 "CamelHttpUri",
10306 serde_json::Value::String("http://any.example.com/path".to_string()),
10307 );
10308
10309 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10310 assert_eq!(url, "http://any.example.com/path");
10311 }
10312
10313 #[test]
10314 fn empty_allowlist_fails_endpoint_creation() {
10315 assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=,,").is_err());
10316 }
10317
10318 #[test]
10319 fn malformed_entry_fails_endpoint_creation() {
10320 assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=not a host!").is_err());
10321 }
10322
10323 #[test]
10324 fn fence_entry_with_path_fails_creation() {
10325 assert!(
10328 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=api.internal:8443/v2").is_err()
10329 );
10330 }
10331
10332 #[test]
10333 fn fence_entry_with_userinfo_fails_creation() {
10334 assert!(
10335 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=user@cdn.example.com").is_err()
10336 );
10337 }
10338
10339 #[test]
10340 fn ipv6_fence_entry_allows_bracketed_host() {
10341 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=[::1]:8443").unwrap();
10342 for uri in ["http://[::1]:8443/x", "http://[0:0:0:0:0:0:0:1]:8443/x"] {
10345 let mut exchange = Exchange::new(Message::default());
10346 exchange
10347 .input
10348 .set_header("CamelHttpUri", serde_json::Value::String(uri.to_string()));
10349 let url = HttpProducer::resolve_url(&exchange, &cfg)
10350 .unwrap_or_else(|e| panic!("override {uri} must be honored: {e}"));
10351 assert_eq!(url, uri, "bracketed IPv6 override not honored");
10352 }
10353 }
10354
10355 #[test]
10356 fn dns_case_insensitive_fence_match() {
10357 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=CDN.Example.COM").unwrap();
10360 let mut exchange = Exchange::new(Message::default());
10361 exchange.input.set_header(
10362 "CamelHttpUri",
10363 serde_json::Value::String("http://cdn.example.com/x".to_string()),
10364 );
10365 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10366 assert_eq!(url, "http://cdn.example.com/x");
10367 }
10368
10369 #[test]
10370 fn fence_allowed_override_query_merges_with_header() {
10371 let cfg =
10374 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example&k=v").unwrap();
10375 let mut exchange = Exchange::new(Message::default());
10376 exchange.input.set_header(
10377 "CamelHttpUri",
10378 serde_json::Value::String("http://host.example/api?a=1".to_string()),
10379 );
10380 exchange.input.set_header(
10381 "CamelHttpQuery",
10382 serde_json::Value::String("b=2".to_string()),
10383 );
10384
10385 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10386 assert_eq!(url, "http://host.example/api?a=1&b=2");
10387 }
10388
10389 #[test]
10390 fn empty_header_with_armed_fence_leaves_no_query() {
10391 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example").unwrap();
10392 let mut exchange = Exchange::new(Message::default());
10393 exchange.input.set_header(
10394 "CamelHttpUri",
10395 serde_json::Value::String("http://host.example/api".to_string()),
10396 );
10397 exchange
10398 .input
10399 .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
10400
10401 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10402 assert_eq!(url, "http://host.example/api");
10403 assert!(!url.contains('?'), "query marker leaked: {url}");
10404 }
10405
10406 #[test]
10407 fn fence_option_is_consumed() {
10408 let cfg =
10412 HttpEndpointConfig::from_uri("http://h/p?x=1&allowedUriHosts=cdn.example.com").unwrap();
10413 let exchange = Exchange::new(Message::default());
10414
10415 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10416 assert!(!url.contains("allowedUriHosts"), "option leaked: {url}");
10417 assert!(url.contains("x=1"), "authored query lost: {url}");
10418 }
10419
10420 #[tokio::test]
10421 async fn resolve_url_malformed_base_url_errors_no_panic() {
10422 use tower::ServiceExt;
10423
10424 let (url, _handle) = start_test_server().await;
10425 let mut config = HttpEndpointConfig::from_uri("http://[::1:bad").unwrap();
10426 config.allow_internal = true; let producer = HttpProducer {
10428 config: Arc::new(config),
10429 client: build_client(&HttpConfig::default(), None),
10430 pinned_cache: Arc::new(PinnedClientCache::new(
10431 PINNED_CLIENT_TTL,
10432 PINNED_CLIENT_MAX_ENTRIES,
10433 )),
10434 http_config: Arc::new(HttpConfig::default()),
10435 runtime: rt(),
10436 };
10437
10438 let first = producer
10441 .clone()
10442 .oneshot(Exchange::new(Message::default()))
10443 .await;
10444 let err = first.expect_err("malformed base URL must error, not panic");
10445 assert!(
10446 err.to_string().to_lowercase().contains("url"),
10447 "error must name the malformed URL: {err}"
10448 );
10449
10450 let mut exchange = Exchange::new(Message::default());
10453 exchange.input.set_header(
10454 "CamelHttpUri",
10455 serde_json::Value::String(format!("{url}/api")),
10456 );
10457 let response = producer
10458 .oneshot(exchange)
10459 .await
10460 .expect("valid request through same producer must succeed");
10461 let status = response
10462 .input
10463 .header("CamelHttpResponseCode")
10464 .and_then(|v| v.as_u64())
10465 .unwrap();
10466 assert_eq!(status, 200);
10467 }
10468
10469 #[test]
10470 fn resolve_url_bridge_malformed_base_errors_no_panic() {
10471 let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10472 cfg.bridge_endpoint = true;
10473 cfg.query_params.push(("k".to_string(), "1".to_string()));
10474 cfg.base_url = "http://[::1:bad".to_string();
10479 let exchange = Exchange::new(Message::default());
10480
10481 let err = HttpProducer::resolve_url(&exchange, &cfg)
10482 .expect_err("malformed bridge base URL must error");
10483 assert!(
10484 err.to_string().contains("invalid base URL"),
10485 "error must name the invalid base URL: {err}"
10486 );
10487 }
10488
10489 #[test]
10490 fn test_http_producer_helpers_status_and_size_boundaries() {
10491 assert!(HttpProducer::is_ok_status(200, (200, 299)));
10492 assert!(HttpProducer::is_ok_status(299, (200, 299)));
10493 assert!(!HttpProducer::is_ok_status(199, (200, 299)));
10494 assert!(!HttpProducer::is_ok_status(300, (200, 299)));
10495
10496 assert!(!exceeds_max_response_body(10, 10));
10497 assert!(exceeds_max_response_body(11, 10));
10498 }
10499
10500 #[allow(clippy::await_holding_lock)]
10505 async fn setup_consumer_on_free_port(
10506 path: &str,
10507 ) -> (
10508 u16,
10509 tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
10510 tokio_util::sync::CancellationToken,
10511 ) {
10512 use camel_component_api::ConsumerContext;
10513
10514 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10519 let port = listener.local_addr().unwrap().port();
10520
10521 let _registry_guard = lock_registry_test_mutex();
10530
10531 ServerRegistry::global()
10532 .stage_listener(listener)
10533 .await
10534 .expect("stage consumer test listener");
10535
10536 let consumer_cfg = HttpServerConfig {
10537 scheme: "http".to_string(),
10538 host: "127.0.0.1".to_string(),
10539 port,
10540 path: path.to_string(),
10541 max_request_body: 2 * 1024 * 1024,
10542 max_response_body: 10 * 1024 * 1024,
10543 max_inflight_requests: 1024,
10544 method: None,
10545 tls_config: None,
10546 };
10547 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
10548
10549 let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
10550 let token = tokio_util::sync::CancellationToken::new();
10551 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
10552
10553 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10554
10555 wait_for_registry_ready("127.0.0.1", port).await;
10561 for _ in 0..8 {
10562 tokio::task::yield_now().await;
10563 }
10564
10565 (port, rx, token)
10566 }
10567
10568 async fn wait_for_registry_ready(host: &str, port: u16) {
10573 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
10574 let mut backoff = std::time::Duration::from_millis(1);
10575 while ServerRegistry::global().bound_addr(host, port).is_none() {
10576 assert!(
10577 tokio::time::Instant::now() < deadline,
10578 "consumer server did not become ready on port {port} — registry entry absent (concurrent reset or starvation)"
10579 );
10580 tokio::time::sleep(backoff).await;
10581 backoff = (backoff * 2).min(std::time::Duration::from_millis(64));
10582 }
10583 }
10584
10585 #[tokio::test(start_paused = true)]
10586 #[should_panic(expected = "registry entry absent (concurrent reset or starvation)")]
10587 async fn readiness_deadline_fires_loud_with_hint() {
10588 let held = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10602 let port = held.local_addr().unwrap().port();
10603 wait_for_registry_ready("httpflake-unreachable-host", port).await;
10604 }
10605
10606 #[tokio::test]
10611 async fn readiness_survives_concurrent_registry_reset() {
10612 let contended = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
10613 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
10614
10615 let contended_hammer = std::sync::Arc::clone(&contended);
10622 let stop_hammer = std::sync::Arc::clone(&stop);
10623 let handle = std::thread::spawn(move || {
10624 while !stop_hammer.load(std::sync::atomic::Ordering::Relaxed) {
10625 let _guard = match REGISTRY_TEST_MUTEX.try_lock() {
10626 Err(std::sync::TryLockError::WouldBlock) => {
10627 contended_hammer.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
10628 lock_registry_test_mutex()
10629 }
10630 Err(std::sync::TryLockError::Poisoned(_)) => lock_registry_test_mutex(),
10631 Ok(guard) => guard,
10632 };
10633 ServerRegistry::reset();
10634 }
10635 });
10636
10637 struct StopHammerOnDrop {
10640 handle: Option<std::thread::JoinHandle<()>>,
10641 stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
10642 }
10643 impl Drop for StopHammerOnDrop {
10644 fn drop(&mut self) {
10645 self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
10646 if let Some(handle) = self.handle.take() {
10647 let _ = handle.join();
10648 }
10649 }
10650 }
10651 let _hammer_guard = StopHammerOnDrop {
10652 handle: Some(handle),
10653 stop,
10654 };
10655
10656 let mut setups = 0;
10659 loop {
10660 setups += 1;
10661 let (_port, rx, token) = setup_consumer_on_free_port("/reset-hammer").await;
10662 drop(rx);
10663 token.cancel();
10664 if (setups >= 25 && contended.load(std::sync::atomic::Ordering::SeqCst) >= 1)
10665 || setups >= 50
10666 {
10667 break;
10668 }
10669 }
10670
10671 let contended_hits = contended.load(std::sync::atomic::Ordering::SeqCst);
10672 assert!(
10673 contended_hits >= 1,
10674 "expected at least one contended registry reset across {setups} setups, got {contended_hits}"
10675 );
10676 }
10677
10678 #[tokio::test]
10679 async fn test_content_type_inferred_for_json_body() {
10680 let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
10681
10682 let client = reqwest::Client::new();
10683 let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
10684
10685 let (http_result, _) = tokio::join!(send_fut, async {
10686 if let Some(mut envelope) = rx.recv().await {
10687 envelope.exchange.input.body =
10688 camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
10689 if let Some(reply_tx) = envelope.reply_tx {
10690 let _ = reply_tx.send(Ok(envelope.exchange));
10691 }
10692 }
10693 });
10694
10695 let resp = http_result.unwrap();
10696 assert_eq!(resp.status().as_u16(), 200);
10697 let ct = resp
10698 .headers()
10699 .get("content-type")
10700 .expect("Content-Type header should be present");
10701 assert_eq!(ct, "application/json");
10702 let body = resp.text().await.unwrap();
10703 assert_eq!(body, r#"{"message":"hello"}"#);
10704
10705 token.cancel();
10706 }
10707
10708 #[tokio::test]
10709 async fn test_content_type_inferred_for_text_body() {
10710 let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
10711
10712 let client = reqwest::Client::new();
10713 let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
10714
10715 let (http_result, _) = tokio::join!(send_fut, async {
10716 if let Some(mut envelope) = rx.recv().await {
10717 envelope.exchange.input.body =
10718 camel_component_api::Body::Text("plain text response".to_string());
10719 if let Some(reply_tx) = envelope.reply_tx {
10720 let _ = reply_tx.send(Ok(envelope.exchange));
10721 }
10722 }
10723 });
10724
10725 let resp = http_result.unwrap();
10726 assert_eq!(resp.status().as_u16(), 200);
10727 let ct = resp
10728 .headers()
10729 .get("content-type")
10730 .expect("Content-Type header should be present");
10731 assert_eq!(ct, "text/plain; charset=utf-8");
10732 let body = resp.text().await.unwrap();
10733 assert_eq!(body, "plain text response");
10734
10735 token.cancel();
10736 }
10737
10738 #[tokio::test]
10739 async fn test_content_type_inferred_for_xml_body() {
10740 let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
10741
10742 let client = reqwest::Client::new();
10743 let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
10744
10745 let (http_result, _) = tokio::join!(send_fut, async {
10746 if let Some(mut envelope) = rx.recv().await {
10747 envelope.exchange.input.body =
10748 camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
10749 if let Some(reply_tx) = envelope.reply_tx {
10750 let _ = reply_tx.send(Ok(envelope.exchange));
10751 }
10752 }
10753 });
10754
10755 let resp = http_result.unwrap();
10756 assert_eq!(resp.status().as_u16(), 200);
10757 let ct = resp
10758 .headers()
10759 .get("content-type")
10760 .expect("Content-Type header should be present");
10761 assert_eq!(ct, "application/xml");
10762 let body = resp.text().await.unwrap();
10763 assert_eq!(body, "<root><item>value</item></root>");
10764
10765 token.cancel();
10766 }
10767
10768 #[tokio::test]
10769 async fn test_no_content_type_for_empty_body() {
10770 let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
10771
10772 let client = reqwest::Client::new();
10773 let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
10774
10775 let (http_result, _) = tokio::join!(send_fut, async {
10776 if let Some(mut envelope) = rx.recv().await {
10777 envelope.exchange.input.body = camel_component_api::Body::Empty;
10778 if let Some(reply_tx) = envelope.reply_tx {
10779 let _ = reply_tx.send(Ok(envelope.exchange));
10780 }
10781 }
10782 });
10783
10784 let resp = http_result.unwrap();
10785 assert_eq!(resp.status().as_u16(), 200);
10786 assert!(
10787 resp.headers().get("content-type").is_none(),
10788 "Empty body should not set Content-Type"
10789 );
10790
10791 token.cancel();
10792 }
10793
10794 #[tokio::test]
10795 async fn test_no_content_type_for_raw_bytes_body() {
10796 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
10797
10798 let client = reqwest::Client::new();
10799 let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
10800
10801 let (http_result, _) = tokio::join!(send_fut, async {
10802 if let Some(mut envelope) = rx.recv().await {
10803 envelope.exchange.input.body =
10804 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
10805 if let Some(reply_tx) = envelope.reply_tx {
10806 let _ = reply_tx.send(Ok(envelope.exchange));
10807 }
10808 }
10809 });
10810
10811 let resp = http_result.unwrap();
10812 assert_eq!(resp.status().as_u16(), 200);
10813 assert!(
10814 resp.headers().get("content-type").is_none(),
10815 "Raw Bytes body should not set Content-Type"
10816 );
10817
10818 token.cancel();
10819 }
10820
10821 #[tokio::test]
10822 async fn test_content_type_from_stream_metadata() {
10823 use camel_component_api::{StreamBody, StreamMetadata};
10824 use futures::stream;
10825
10826 let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
10827
10828 let client = reqwest::Client::new();
10829 let send_fut = client
10830 .get(format!("http://127.0.0.1:{port}/stream-ct"))
10831 .send();
10832
10833 let (http_result, _) = tokio::join!(send_fut, async {
10834 if let Some(mut envelope) = rx.recv().await {
10835 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
10836 vec![Ok(bytes::Bytes::from("audio data"))];
10837 let stream = Box::pin(stream::iter(chunks));
10838 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
10839 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
10840 metadata: StreamMetadata {
10841 size_hint: None,
10842 content_type: Some("audio/mpeg".to_string()),
10843 origin: None,
10844 },
10845 });
10846 if let Some(reply_tx) = envelope.reply_tx {
10847 let _ = reply_tx.send(Ok(envelope.exchange));
10848 }
10849 }
10850 });
10851
10852 let resp = http_result.unwrap();
10853 assert_eq!(resp.status().as_u16(), 200);
10854 let ct = resp
10855 .headers()
10856 .get("content-type")
10857 .expect("Content-Type header should be present");
10858 assert_eq!(ct, "audio/mpeg");
10859 let body = resp.text().await.unwrap();
10860 assert_eq!(body, "audio data");
10861
10862 token.cancel();
10863 }
10864
10865 #[tokio::test]
10866 async fn test_user_content_type_overrides_inferred() {
10867 let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
10868
10869 let client = reqwest::Client::new();
10870 let send_fut = client
10871 .get(format!("http://127.0.0.1:{port}/override-ct"))
10872 .send();
10873
10874 let (http_result, _) = tokio::join!(send_fut, async {
10875 if let Some(mut envelope) = rx.recv().await {
10876 envelope.exchange.input.body =
10877 camel_component_api::Body::Json(serde_json::json!({"ok": true}));
10878 envelope.exchange.input.set_header(
10879 "Content-Type",
10880 serde_json::Value::String("text/html".to_string()),
10881 );
10882 if let Some(reply_tx) = envelope.reply_tx {
10883 let _ = reply_tx.send(Ok(envelope.exchange));
10884 }
10885 }
10886 });
10887
10888 let resp = http_result.unwrap();
10889 assert_eq!(resp.status().as_u16(), 200);
10890 let ct = resp
10891 .headers()
10892 .get("content-type")
10893 .expect("Content-Type header should be present");
10894 assert_eq!(
10895 ct, "text/html",
10896 "User-set Content-Type should take precedence over inferred type"
10897 );
10898
10899 token.cancel();
10900 }
10901
10902 #[tokio::test]
10903 async fn test_user_content_type_with_bytes_body() {
10904 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
10905
10906 let client = reqwest::Client::new();
10907 let send_fut = client
10908 .get(format!("http://127.0.0.1:{port}/bytes-ct"))
10909 .send();
10910
10911 let (http_result, _) = tokio::join!(send_fut, async {
10912 if let Some(mut envelope) = rx.recv().await {
10913 envelope.exchange.input.body =
10914 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
10915 envelope.exchange.input.set_header(
10916 "Content-Type",
10917 serde_json::Value::String("application/json".to_string()),
10918 );
10919 if let Some(reply_tx) = envelope.reply_tx {
10920 let _ = reply_tx.send(Ok(envelope.exchange));
10921 }
10922 }
10923 });
10924
10925 let resp = http_result.unwrap();
10926 assert_eq!(resp.status().as_u16(), 200);
10927 let ct = resp
10928 .headers()
10929 .get("content-type")
10930 .expect("Content-Type header should be present for Bytes body with user header");
10931 assert_eq!(
10932 ct, "application/json",
10933 "User Content-Type should be sent for Bytes body"
10934 );
10935
10936 token.cancel();
10937 }
10938
10939 #[tokio::test]
10944 async fn monitor_task_silent_on_clean_exit() {
10945 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
10946 let server_exited = tokio_util::sync::CancellationToken::new();
10947 monitor_axum_task(
10949 handle,
10950 "127.0.0.1:0".to_string(),
10951 noop_rt(),
10952 "test-monitor".into(),
10953 server_exited.clone(),
10954 )
10955 .await;
10956 assert!(
10960 !server_exited.is_cancelled(),
10961 "clean server exit must not cancel server_exited"
10962 );
10963 }
10964
10965 #[tokio::test]
10966 async fn monitor_task_handles_panicked_task() {
10967 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
10968 panic!("simulated server crash");
10969 });
10970 let server_exited = tokio_util::sync::CancellationToken::new();
10971 monitor_axum_task(
10973 handle,
10974 "127.0.0.1:9999".to_string(),
10975 noop_rt(),
10976 "test-monitor".into(),
10977 server_exited.clone(),
10978 )
10979 .await;
10980 assert!(
10983 server_exited.is_cancelled(),
10984 "crashed server must cancel server_exited"
10985 );
10986 }
10987
10988 #[test]
10993 fn http_auth_basic_debug_redacts_password() {
10994 let auth = HttpAuth::Basic {
10995 username: "admin".to_string(),
10996 password: "hunter2".to_string(),
10997 };
10998 let debug = format!("{:?}", auth);
10999 assert!(
11000 !debug.contains("hunter2"),
11001 "password must be redacted: {debug}"
11002 );
11003 assert!(debug.contains("admin"), "username should appear: {debug}");
11004 }
11005
11006 #[test]
11007 fn http_auth_bearer_debug_redacts_token() {
11008 let auth = HttpAuth::Bearer {
11009 token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
11010 };
11011 let debug = format!("{:?}", auth);
11012 assert!(
11013 !debug.contains("eyJhbGci"),
11014 "token must be redacted: {debug}"
11015 );
11016 }
11017
11018 #[test]
11019 fn http_auth_none_debug_shows_variant() {
11020 let debug = format!("{:?}", HttpAuth::None);
11021 assert!(
11022 debug.contains("None"),
11023 "None variant should appear: {debug}"
11024 );
11025 }
11026
11027 #[test]
11028 fn http_endpoint_config_debug_redacts_auth_credentials() {
11029 let config = HttpEndpointConfig::from_uri(
11030 "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
11031 )
11032 .unwrap();
11033 let debug = format!("{:?}", config);
11034 assert!(
11035 !debug.contains("secret123"),
11036 "password must be redacted in HttpEndpointConfig debug: {debug}"
11037 );
11038 }
11039
11040 #[test]
11041 fn debug_lists_all_public_fields() {
11042 let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
11043 let debug = format!("{:?}", config);
11044 for field in [
11045 "base_url",
11046 "http_method",
11047 "throw_exception_on_failure",
11048 "ok_status_code_range",
11049 "response_timeout",
11050 "query_params",
11051 "raw_query",
11052 "allow_internal",
11053 "allow_cleartext",
11054 "blocked_hosts",
11055 "max_body_size",
11056 "read_timeout_ms",
11057 "max_response_bytes",
11058 "auth",
11059 "token_provider",
11060 "user_agent",
11061 "bridge_endpoint",
11062 "connection_close",
11063 "skip_request_headers",
11064 "skip_response_headers",
11065 "follow_redirects",
11066 "max_redirects",
11067 ] {
11068 assert!(
11069 debug.contains(field),
11070 "Debug output missing field '{field}': {debug}"
11071 );
11072 }
11073 }
11074
11075 use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
11080 use tower_http::services::ServeDir;
11081
11082 fn make_test_registry() -> HttpRouteRegistry {
11083 HttpRouteRegistry::new()
11084 }
11085
11086 fn make_test_state(registry: HttpRouteRegistry) -> AppState {
11087 AppState {
11088 registry,
11089 max_request_body: 2 * 1024 * 1024,
11090 max_response_body: 10 * 1024 * 1024,
11091 inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
11092 }
11093 }
11094
11095 #[allow(clippy::await_holding_lock)]
11096 #[tokio::test]
11097 async fn test_static_file_serving_serves_file_contents() {
11098 let _guard = lock_registry_test_mutex();
11099 ServerRegistry::reset();
11100
11101 let temp_dir =
11103 std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
11104 std::fs::create_dir_all(&temp_dir).unwrap();
11105 std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
11106 std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
11107
11108 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11109
11110 let registry = make_test_registry();
11111 let serve_dir = ServeDir::new(&canonical_dir)
11112 .precompressed_gzip()
11113 .precompressed_br()
11114 .append_index_html_on_directories(true);
11115
11116 let mount = StaticMount {
11117 mount_path: "/".to_string(),
11118 mode: MountMode::Static,
11119 dir: canonical_dir.clone(),
11120 cache_control: "public, max-age=3600".to_string(),
11121 error_pages: std::collections::HashMap::new(),
11122 serve_dir,
11123 };
11124 registry.register_static_mount(mount).await.unwrap();
11125
11126 let state = make_test_state(registry);
11127
11128 let req = Request::builder()
11130 .uri("/hello.txt")
11131 .body(AxumBody::empty())
11132 .unwrap();
11133 let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
11134 assert_eq!(resp.status(), StatusCode::OK);
11135 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11136 .await
11137 .unwrap();
11138 assert_eq!(&body[..], b"Hello, static world!");
11139
11140 let req = Request::builder()
11142 .uri("/style.css")
11143 .body(AxumBody::empty())
11144 .unwrap();
11145 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
11146 assert_eq!(resp.status(), StatusCode::OK);
11147 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11148 .await
11149 .unwrap();
11150 assert_eq!(&body[..], b"body { color: red; }");
11151
11152 let req = Request::builder()
11154 .uri("/missing.txt")
11155 .body(AxumBody::empty())
11156 .unwrap();
11157 let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
11158 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11159
11160 std::fs::remove_dir_all(&temp_dir).ok();
11162 }
11163
11164 #[allow(clippy::await_holding_lock)]
11165 #[tokio::test]
11166 async fn test_spa_fallback_serves_index_for_unknown_paths() {
11167 let _guard = lock_registry_test_mutex();
11168 ServerRegistry::reset();
11169
11170 let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
11171 std::fs::create_dir_all(&temp_dir).unwrap();
11172 std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
11173 std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
11174
11175 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11176
11177 let registry = make_test_registry();
11178 let serve_dir = ServeDir::new(&canonical_dir)
11179 .precompressed_gzip()
11180 .precompressed_br()
11181 .append_index_html_on_directories(true);
11182
11183 let mount = StaticMount {
11184 mount_path: "/".to_string(),
11185 mode: MountMode::Spa,
11186 dir: canonical_dir.clone(),
11187 cache_control: "public, max-age=0".to_string(),
11188 error_pages: std::collections::HashMap::new(),
11189 serve_dir,
11190 };
11191 registry.register_static_mount(mount).await.unwrap();
11193
11194 let state = make_test_state(registry);
11195
11196 let req = Request::builder()
11198 .method("GET")
11199 .uri("/dashboard")
11200 .header("Accept", "text/html")
11201 .body(AxumBody::empty())
11202 .unwrap();
11203 let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
11204 assert_eq!(resp.status(), StatusCode::OK);
11205 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11206 .await
11207 .unwrap();
11208 assert_eq!(&body[..], b"<h1>SPA App</h1>");
11209
11210 let req = Request::builder()
11212 .method("GET")
11213 .uri("/app.js")
11214 .body(AxumBody::empty())
11215 .unwrap();
11216 let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
11217 assert_eq!(resp.status(), StatusCode::OK);
11218 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11219 .await
11220 .unwrap();
11221 assert_eq!(&body[..], b"console.log('app')");
11222
11223 let req = Request::builder()
11225 .method("GET")
11226 .uri("/api/data")
11227 .header("Accept", "application/json")
11228 .body(AxumBody::empty())
11229 .unwrap();
11230 let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
11231 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11232
11233 let req = Request::builder()
11235 .method("GET")
11236 .uri("/style.css")
11237 .header("Accept", "text/html")
11238 .body(AxumBody::empty())
11239 .unwrap();
11240 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
11241 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11242
11243 std::fs::remove_dir_all(&temp_dir).ok();
11245 }
11246
11247 #[allow(clippy::await_holding_lock)]
11254 async fn run_conditional_get_returns_304(mode: MountMode) {
11255 let _guard = lock_registry_test_mutex();
11256 ServerRegistry::reset();
11257
11258 let temp_dir = std::env::temp_dir().join(format!(
11259 "http_cond_get_{}_{}",
11260 if mode == MountMode::Spa {
11261 "spa"
11262 } else {
11263 "static"
11264 },
11265 std::process::id()
11266 ));
11267 std::fs::create_dir_all(&temp_dir).unwrap();
11268 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
11269
11270 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11271
11272 let registry = make_test_registry();
11273 let serve_dir = ServeDir::new(&canonical_dir)
11274 .precompressed_gzip()
11275 .precompressed_br()
11276 .append_index_html_on_directories(true);
11277
11278 let mount = StaticMount {
11279 mount_path: "/".to_string(),
11280 mode,
11281 dir: canonical_dir.clone(),
11282 cache_control: "public, max-age=3600".to_string(),
11283 error_pages: std::collections::HashMap::new(),
11284 serve_dir,
11285 };
11286 registry.register_static_mount(mount).await.unwrap();
11287
11288 let state = make_test_state(registry);
11289
11290 let req = Request::builder()
11292 .method("GET")
11293 .uri("/index.html")
11294 .body(AxumBody::empty())
11295 .unwrap();
11296 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11297 assert_eq!(
11298 resp.status(),
11299 StatusCode::OK,
11300 "first GET should return 200, got {}",
11301 resp.status()
11302 );
11303 assert!(
11305 resp.headers().contains_key(http::header::CACHE_CONTROL),
11306 "200 response missing Cache-Control"
11307 );
11308 let etag = resp
11309 .headers()
11310 .get(http::header::ETAG)
11311 .expect("ServeDir must emit ETag on 200 for If-None-Match coverage")
11312 .clone();
11313 let last_modified = resp
11314 .headers()
11315 .get(http::header::LAST_MODIFIED)
11316 .expect("ServeDir must emit Last-Modified on 200 for If-Modified-Since coverage")
11317 .clone();
11318 let _ = axum::body::to_bytes(resp.into_body(), usize::MAX)
11320 .await
11321 .unwrap();
11322
11323 let req = Request::builder()
11327 .method("GET")
11328 .uri("/index.html")
11329 .header(http::header::IF_NONE_MATCH, etag.clone())
11330 .body(AxumBody::empty())
11331 .unwrap();
11332 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11333 assert_eq!(
11334 resp.status(),
11335 StatusCode::NOT_MODIFIED,
11336 "If-None-Match with matching ETag should return 304, got {}",
11337 resp.status()
11338 );
11339 assert!(
11341 resp.headers().contains_key(http::header::CACHE_CONTROL),
11342 "304 (If-None-Match) missing Cache-Control"
11343 );
11344 assert_eq!(
11347 resp.headers().get(http::header::ETAG),
11348 Some(&etag),
11349 "304 (If-None-Match) must echo the ETag validator"
11350 );
11351 assert_eq!(
11352 resp.headers().get(http::header::LAST_MODIFIED),
11353 Some(&last_modified),
11354 "304 (If-None-Match) must carry Last-Modified"
11355 );
11356
11357 let req = Request::builder()
11359 .method("GET")
11360 .uri("/index.html")
11361 .header(http::header::IF_MODIFIED_SINCE, last_modified.clone())
11362 .body(AxumBody::empty())
11363 .unwrap();
11364 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11365 assert_eq!(
11366 resp.status(),
11367 StatusCode::NOT_MODIFIED,
11368 "If-Modified-Since with matching timestamp should return 304, got {}",
11369 resp.status()
11370 );
11371 assert!(
11372 resp.headers().contains_key(http::header::CACHE_CONTROL),
11373 "304 (If-Modified-Since) missing Cache-Control"
11374 );
11375 assert_eq!(
11376 resp.headers().get(http::header::ETAG),
11377 Some(&etag),
11378 "304 (If-Modified-Since) must carry the ETag validator"
11379 );
11380 assert_eq!(
11381 resp.headers().get(http::header::LAST_MODIFIED),
11382 Some(&last_modified),
11383 "304 (If-Modified-Since) must echo Last-Modified"
11384 );
11385
11386 let req = Request::builder()
11392 .method("GET")
11393 .uri("/index.html")
11394 .header(
11395 http::header::IF_MODIFIED_SINCE,
11396 "Wed, 21 Oct 2000 07:28:00 GMT",
11397 )
11398 .body(AxumBody::empty())
11399 .unwrap();
11400 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11401 assert_eq!(
11402 resp.status(),
11403 StatusCode::OK,
11404 "past If-Modified-Since should return 200 (file modified after it), got {}",
11405 resp.status()
11406 );
11407
11408 std::fs::remove_dir_all(&temp_dir).ok();
11410 }
11411
11412 #[tokio::test]
11413 async fn test_conditional_get_returns_304_static_mode() {
11414 run_conditional_get_returns_304(MountMode::Static).await;
11415 }
11416
11417 #[tokio::test]
11418 async fn test_conditional_get_returns_304_spa_mode() {
11419 run_conditional_get_returns_304(MountMode::Spa).await;
11420 }
11421
11422 #[allow(clippy::await_holding_lock)]
11423 #[tokio::test]
11424 async fn test_error_page_mapping_serves_custom_404() {
11425 let _guard = lock_registry_test_mutex();
11426 ServerRegistry::reset();
11427
11428 let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
11429 let errors_dir = temp_dir.join("errors");
11430 std::fs::create_dir_all(&errors_dir).unwrap();
11431 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
11432 std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
11433
11434 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11435 let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
11436
11437 let registry = make_test_registry();
11438 let serve_dir = ServeDir::new(&canonical_dir)
11439 .precompressed_gzip()
11440 .precompressed_br()
11441 .append_index_html_on_directories(true);
11442
11443 let mut error_pages = std::collections::HashMap::new();
11444 error_pages.insert(404, canonical_404);
11445
11446 let mount = StaticMount {
11447 mount_path: "/".to_string(),
11448 mode: MountMode::Static,
11449 dir: canonical_dir.clone(),
11450 cache_control: "public, max-age=0".to_string(),
11451 error_pages,
11452 serve_dir,
11453 };
11454 registry.register_static_mount(mount).await.unwrap();
11455
11456 let state = make_test_state(registry);
11457
11458 let req = Request::builder()
11460 .method("GET")
11461 .uri("/missing.html")
11462 .body(AxumBody::empty())
11463 .unwrap();
11464 let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
11465 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11466 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11467 .await
11468 .unwrap();
11469 assert_eq!(&body[..], b"<h1>Custom 404</h1>");
11470
11471 let req = Request::builder()
11473 .method("GET")
11474 .uri("/index.html")
11475 .body(AxumBody::empty())
11476 .unwrap();
11477 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11478 assert_eq!(resp.status(), StatusCode::OK);
11479 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11480 .await
11481 .unwrap();
11482 assert_eq!(&body[..], b"<h1>Home</h1>");
11483
11484 std::fs::remove_dir_all(&temp_dir).ok();
11486 }
11487
11488 #[tokio::test]
11489 async fn http_consumer_returns_body_and_code_on_stop() {
11490 use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
11491 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
11492 use tower::ServiceExt;
11493
11494 let set_body_step = CompiledStep::Process {
11496 kind_hint: camel_api::SpanKindHint::Internal,
11497 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
11498 ex.input.body = Body::Text("nope".into());
11499 Box::pin(async move { Ok(ex) })
11500 }),
11501 body_contract: None,
11502 lifecycle: None,
11503 label: None,
11504 to_uri: None,
11505 };
11506 let set_status_step = CompiledStep::Process {
11507 kind_hint: camel_api::SpanKindHint::Internal,
11508 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
11509 ex.input.set_header(
11510 "CamelHttpResponseCode",
11511 serde_json::Value::Number(409.into()),
11512 );
11513 Box::pin(async move { Ok(ex) })
11514 }),
11515 body_contract: None,
11516 lifecycle: None,
11517 label: None,
11518 to_uri: None,
11519 };
11520 let pipeline = compose_pipeline_with_handler(
11521 vec![set_body_step, set_status_step, CompiledStep::Stop],
11522 None,
11523 PipelineRuntimeCtx::compile_time(),
11524 );
11525
11526 let ex = Exchange::new(Message::default());
11527 let result = pipeline.oneshot(ex).await;
11528 assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
11529 let returned = result.unwrap();
11530 assert_eq!(returned.input.body.as_text(), Some("nope"));
11531 assert_eq!(
11532 returned
11533 .input
11534 .header("CamelHttpResponseCode")
11535 .and_then(|v| v.as_u64()),
11536 Some(409)
11537 );
11538 }
11539
11540 #[tokio::test]
11541 async fn http_consumer_returns_200_when_body_empty_on_stop() {
11542 use camel_api::{Exchange, Message};
11550 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
11551 use tower::ServiceExt;
11552
11553 let pipeline = compose_pipeline_with_handler(
11554 vec![CompiledStep::Stop],
11555 None,
11556 PipelineRuntimeCtx::compile_time(),
11557 );
11558 let ex = Exchange::new(Message::default());
11559 let result = pipeline.oneshot(ex).await;
11560 assert!(result.is_ok(), "Stop with empty body arrives as Ok");
11561 }
11564
11565 async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
11573 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11574 let port = listener.local_addr().unwrap().port();
11575 let registry = HttpRouteRegistry::new();
11576 tokio::spawn(run_axum_server(
11577 listener,
11578 registry.clone(),
11579 2 * 1024 * 1024,
11580 10 * 1024 * 1024,
11581 Arc::new(tokio::sync::Semaphore::new(1024)),
11582 test_rt(),
11583 "test-route".into(),
11584 ));
11585 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
11587 (port, registry)
11588 }
11589
11590 fn spawn_responder(
11595 mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
11596 status: u16,
11597 body: String,
11598 ) -> tokio::task::JoinHandle<()> {
11599 tokio::spawn(async move {
11600 if let Some(envelope) = rx.recv().await {
11601 let _ = envelope.reply_tx.send(HttpReply {
11602 status,
11603 headers: vec![],
11604 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
11605 });
11606 }
11607 })
11608 }
11609
11610 #[tokio::test]
11611 async fn method_aware_dispatch_same_path_different_verbs() {
11612 let (port, registry) = spawn_test_server().await;
11613
11614 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11619 registry
11620 .register_rest_endpoint(
11621 "GET".into(),
11622 vec![PathSegment::Literal("users".into())],
11623 get_tx,
11624 )
11625 .await;
11626
11627 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11628 registry
11629 .register_rest_endpoint(
11630 "POST".into(),
11631 vec![PathSegment::Literal("users".into())],
11632 post_tx,
11633 )
11634 .await;
11635
11636 let get_handle = spawn_responder(get_rx, 200, "list".into());
11637 let post_handle = spawn_responder(post_rx, 201, "create".into());
11638
11639 let client = reqwest::Client::new();
11640
11641 let resp = client
11643 .get(format!("http://127.0.0.1:{port}/users"))
11644 .send()
11645 .await
11646 .unwrap();
11647 assert_eq!(resp.status().as_u16(), 200);
11648 let body = resp.text().await.unwrap();
11649 assert_eq!(body, "list");
11650
11651 let resp = client
11653 .post(format!("http://127.0.0.1:{port}/users"))
11654 .send()
11655 .await
11656 .unwrap();
11657 assert_eq!(resp.status().as_u16(), 201);
11658 let body = resp.text().await.unwrap();
11659 assert_eq!(body, "create");
11660
11661 let _ = tokio::join!(get_handle, post_handle);
11662 }
11663
11664 #[tokio::test]
11665 async fn method_aware_dispatch_templated_path_extracts_params() {
11666 let (port, registry) = spawn_test_server().await;
11667
11668 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11672 registry
11673 .register_rest_endpoint(
11674 "GET".into(),
11675 vec![
11676 PathSegment::Literal("users".into()),
11677 PathSegment::Param("id".into()),
11678 ],
11679 tx,
11680 )
11681 .await;
11682
11683 let handle = tokio::spawn(async move {
11686 if let Some(envelope) = rx.recv().await {
11687 let id = envelope.path_params.get("id").cloned().unwrap_or_default();
11688 let _ = envelope.reply_tx.send(HttpReply {
11689 status: 200,
11690 headers: vec![],
11691 body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
11692 });
11693 }
11694 });
11695
11696 let client = reqwest::Client::new();
11697 let resp = client
11698 .get(format!("http://127.0.0.1:{port}/users/42"))
11699 .send()
11700 .await
11701 .unwrap();
11702 assert_eq!(resp.status().as_u16(), 200);
11703 let body = resp.text().await.unwrap();
11704 assert_eq!(body, "id=42");
11705
11706 let _ = handle.await;
11707 }
11708
11709 #[tokio::test]
11710 async fn method_aware_dispatch_unmatched_method_falls_through() {
11711 let (port, _registry) = spawn_test_server().await;
11716
11717 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11719 _registry
11720 .register_rest_endpoint(
11721 "GET".into(),
11722 vec![PathSegment::Literal("users".into())],
11723 get_tx,
11724 )
11725 .await;
11726
11727 let drain = tokio::spawn(async move {
11730 let mut get_rx = get_rx;
11731 while get_rx.recv().await.is_some() {}
11732 });
11733
11734 let client = reqwest::Client::new();
11735 let resp = client
11736 .delete(format!("http://127.0.0.1:{port}/users"))
11737 .send()
11738 .await
11739 .unwrap();
11740 assert_eq!(resp.status().as_u16(), 404);
11741
11742 drop(drain);
11743 }
11744
11745 #[tokio::test]
11746 async fn regression_legacy_exact_api_route_still_works() {
11747 let (port, registry) = spawn_test_server().await;
11752
11753 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11754 registry.register_api_route("/legacy/path".into(), tx).await;
11755
11756 let handle = tokio::spawn(async move {
11757 if let Some(envelope) = rx.recv().await {
11758 let _ = envelope.reply_tx.send(HttpReply {
11759 status: 200,
11760 headers: vec![],
11761 body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
11762 });
11763 }
11764 });
11765
11766 let client = reqwest::Client::new();
11767 let resp = client
11768 .get(format!("http://127.0.0.1:{port}/legacy/path"))
11769 .send()
11770 .await
11771 .unwrap();
11772 assert_eq!(resp.status().as_u16(), 200);
11773 let body = resp.text().await.unwrap();
11774 assert_eq!(body, "legacy ok");
11775
11776 let _ = handle.await;
11777 }
11778
11779 #[allow(clippy::await_holding_lock)]
11780 #[tokio::test]
11781 async fn regression_static_mount_still_works() {
11782 let _guard = lock_registry_test_mutex();
11786 ServerRegistry::reset();
11787
11788 let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
11789 std::fs::create_dir_all(&temp_dir).unwrap();
11790 std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
11791 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11792
11793 let registry = make_test_registry();
11794 let serve_dir = ServeDir::new(&canonical_dir)
11795 .precompressed_gzip()
11796 .precompressed_br()
11797 .append_index_html_on_directories(true);
11798 let mount = StaticMount {
11799 mount_path: "/".to_string(),
11800 mode: MountMode::Static,
11801 dir: canonical_dir.clone(),
11802 cache_control: "public, max-age=3600".to_string(),
11803 error_pages: std::collections::HashMap::new(),
11804 serve_dir,
11805 };
11806 registry.register_static_mount(mount).await.unwrap();
11807
11808 let state = make_test_state(registry);
11809 let req = Request::builder()
11810 .uri("/regress.txt")
11811 .body(AxumBody::empty())
11812 .unwrap();
11813 let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
11814 assert_eq!(resp.status(), StatusCode::OK);
11815 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11816 .await
11817 .unwrap();
11818 assert_eq!(&body[..], b"static works");
11819
11820 std::fs::remove_dir_all(&temp_dir).ok();
11821 }
11822
11823 #[tokio::test]
11832 async fn deregister_one_method_keeps_sibling_verbs() {
11833 let (port, registry) = spawn_test_server().await;
11837
11838 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11839 registry
11840 .register_rest_endpoint(
11841 "GET".into(),
11842 vec![PathSegment::Literal("users".into())],
11843 get_tx,
11844 )
11845 .await;
11846
11847 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11848 registry
11849 .register_rest_endpoint(
11850 "POST".into(),
11851 vec![PathSegment::Literal("users".into())],
11852 post_tx,
11853 )
11854 .await;
11855
11856 let drain = tokio::spawn(async move {
11858 let mut get_rx = get_rx;
11859 while get_rx.recv().await.is_some() {}
11860 });
11861
11862 registry.unregister_rest_endpoint("GET", "/users").await;
11864 drop(drain);
11865
11866 let post_handle = spawn_responder(post_rx, 201, "create".into());
11867
11868 let client = reqwest::Client::new();
11869 let resp = client
11871 .post(format!("http://127.0.0.1:{port}/users"))
11872 .send()
11873 .await
11874 .unwrap();
11875 assert_eq!(resp.status().as_u16(), 201);
11876 assert_eq!(resp.text().await.unwrap(), "create");
11877
11878 let _ = post_handle.await;
11879 }
11880
11881 #[tokio::test]
11882 async fn dispatch_exact_legacy_beats_rest_template() {
11883 let (port, registry) = spawn_test_server().await;
11888
11889 let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11891 registry
11892 .register_api_route("/api/users".into(), exact_tx)
11893 .await;
11894 let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
11895
11896 let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11898 registry
11899 .register_rest_endpoint(
11900 "GET".into(),
11901 vec![
11902 PathSegment::Literal("api".into()),
11903 PathSegment::Param("resource".into()),
11904 ],
11905 tpl_tx,
11906 )
11907 .await;
11908 let _tpl_drain = tokio::spawn(async move {
11914 let mut tpl_rx = tpl_rx;
11915 if let Some(env) = tpl_rx.recv().await {
11916 let _ = env.reply_tx.send(HttpReply {
11917 status: 200,
11918 headers: vec![],
11919 body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
11920 });
11921 }
11922 });
11923
11924 let client = reqwest::Client::new();
11925 let resp = client
11926 .get(format!("http://127.0.0.1:{port}/api/users"))
11927 .send()
11928 .await
11929 .unwrap();
11930 assert_eq!(resp.status().as_u16(), 200);
11931 assert_eq!(resp.text().await.unwrap(), "exact");
11933
11934 let _ = exact_handle.await;
11935 }
11936
11937 #[tokio::test]
11938 async fn ambiguous_rest_templates_return_500_not_silent_404() {
11939 let (port, registry) = spawn_test_server().await;
11944
11945 let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11946 registry
11947 .register_rest_endpoint(
11948 "GET".into(),
11949 vec![
11950 PathSegment::Literal("users".into()),
11951 PathSegment::Param("id".into()),
11952 ],
11953 a_tx,
11954 )
11955 .await;
11956
11957 let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11958 registry
11959 .register_rest_endpoint(
11960 "GET".into(),
11961 vec![
11962 PathSegment::Literal("users".into()),
11963 PathSegment::Param("name".into()),
11964 ],
11965 b_tx,
11966 )
11967 .await;
11968
11969 let client = reqwest::Client::new();
11970 let resp = client
11971 .get(format!("http://127.0.0.1:{port}/users/42"))
11972 .send()
11973 .await
11974 .unwrap();
11975 assert_eq!(resp.status().as_u16(), 500);
11977 }
11978
11979 #[test]
11980 fn from_uri_round_trips_templated_path_with_http_method() {
11981 use crate::UriConfig;
11987 let cfg =
11988 HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
11989 assert_eq!(cfg.host, "0.0.0.0");
11990 assert_eq!(cfg.port, 8080);
11991 assert_eq!(cfg.path, "/users/{id}");
11992 assert_eq!(cfg.method.as_deref(), Some("GET"));
11993
11994 let cfg_lc =
11996 HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
11997 assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
11998 assert_eq!(cfg_lc.path, "/orders");
11999 }
12000
12001 #[test]
12006 fn type_conversion_failed_maps_to_400() {
12007 let reply = pipeline_error_to_reply(
12008 CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
12009 "/api/users",
12010 );
12011 assert_eq!(reply.status, 400);
12012 let json_ct = reply
12014 .headers
12015 .iter()
12016 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12017 .count();
12018 assert_eq!(json_ct, 1);
12019 let body = match &reply.body {
12021 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12022 _ => panic!("expected bytes body"),
12023 };
12024 let parsed: serde_json::Value =
12025 serde_json::from_str(&body).expect("body must be valid JSON");
12026 assert_eq!(parsed["error"], "bad_request");
12027 assert_eq!(parsed["message"], "invalid JSON at line 1");
12028 }
12029
12030 #[test]
12031 fn other_error_still_maps_to_500() {
12032 let reply =
12033 pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
12034 assert_eq!(reply.status, 500);
12035 }
12036
12037 #[test]
12038 fn unauthenticated_maps_to_401() {
12039 let reply = pipeline_error_to_reply(
12040 CamelError::Unauthenticated("no token".to_string()),
12041 "/api/users",
12042 );
12043 assert_eq!(reply.status, 401);
12044 }
12045
12046 #[test]
12047 fn unauthorized_maps_to_403() {
12048 let reply = pipeline_error_to_reply(
12049 CamelError::Unauthorized("forbidden".to_string()),
12050 "/api/users",
12051 );
12052 assert_eq!(reply.status, 403);
12053 }
12054
12055 #[test]
12056 fn validation_error_maps_to_400() {
12057 let reply = pipeline_error_to_reply(
12058 CamelError::ValidationError("body does not match schema".to_string()),
12059 "/api/users",
12060 );
12061 assert_eq!(reply.status, 400);
12062 let json_ct = reply
12063 .headers
12064 .iter()
12065 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12066 .count();
12067 assert_eq!(json_ct, 1);
12068 let body = match &reply.body {
12069 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12070 _ => panic!("expected bytes body"),
12071 };
12072 let parsed: serde_json::Value =
12073 serde_json::from_str(&body).expect("body must be valid JSON");
12074 assert_eq!(parsed["error"], "validation_error");
12075 assert_eq!(parsed["message"], "body does not match schema");
12076 }
12077
12078 #[test]
12083 fn finalizer_maps_unsupported_media_type() {
12084 let reply = pipeline_error_to_reply(
12085 CamelError::UnsupportedMediaType {
12086 consumed: "text/plain".to_string(),
12087 declared: "application/json".to_string(),
12088 },
12089 "/x",
12090 );
12091 assert_eq!(reply.status, 415);
12092 let json_ct = reply
12093 .headers
12094 .iter()
12095 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12096 .count();
12097 assert_eq!(json_ct, 1);
12098 let body = match &reply.body {
12099 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12100 _ => panic!("expected bytes body"),
12101 };
12102 let parsed: serde_json::Value =
12103 serde_json::from_str(&body).expect("body must be valid JSON");
12104 assert_eq!(parsed["error"], "unsupported_media_type");
12105 assert_eq!(
12106 parsed["message"],
12107 "consumed text/plain, declared application/json"
12108 );
12109 }
12110
12111 #[test]
12112 fn finalizer_maps_not_acceptable() {
12113 let reply = pipeline_error_to_reply(
12114 CamelError::NotAcceptable {
12115 accept: "application/xml".to_string(),
12116 produced: "application/json".to_string(),
12117 },
12118 "/x",
12119 );
12120 assert_eq!(reply.status, 406);
12121 let json_ct = reply
12122 .headers
12123 .iter()
12124 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12125 .count();
12126 assert_eq!(json_ct, 1);
12127 let body = match &reply.body {
12128 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12129 _ => panic!("expected bytes body"),
12130 };
12131 let parsed: serde_json::Value =
12132 serde_json::from_str(&body).expect("body must be valid JSON");
12133 assert_eq!(parsed["error"], "not_acceptable");
12134 assert_eq!(
12135 parsed["message"],
12136 "accept application/xml, produced application/json"
12137 );
12138 }
12139
12140 #[test]
12141 fn json_error_reply_preserves_empty_message() {
12142 let reply = json_error_reply(400, "bad_request", "".to_string());
12143 assert_eq!(reply.status, 400);
12144 let json_ct = reply
12145 .headers
12146 .iter()
12147 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12148 .count();
12149 assert_eq!(json_ct, 1);
12150 let body = match &reply.body {
12151 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12152 _ => panic!("expected bytes body"),
12153 };
12154 let parsed: serde_json::Value =
12155 serde_json::from_str(&body).expect("body must be valid JSON");
12156 assert_eq!(parsed["error"], "bad_request");
12157 assert_eq!(parsed["message"], "");
12158 }
12159
12160 #[test]
12161 fn https_consumer_without_tls_cert_errors() {
12162 let endpoint = HttpEndpoint {
12163 uri: "https://0.0.0.0:8443/api".to_string(),
12164 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
12165 server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
12166 client: reqwest::Client::new(),
12167 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
12168 PINNED_CLIENT_TTL,
12169 PINNED_CLIENT_MAX_ENTRIES,
12170 )),
12171 http_config: HttpConfig::default(),
12172 };
12173 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
12174 let result = endpoint.create_consumer(rt);
12175 assert!(result.is_err(), "expected error for https without tls cert");
12176 if let Err(e) = result {
12177 let msg = e.to_string();
12178 assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
12179 }
12180 }
12181
12182 #[test]
12183 fn http_consumer_with_tls_config_errors() {
12184 let endpoint = HttpEndpoint {
12185 uri: "http://0.0.0.0:8080/api".to_string(),
12186 config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
12187 server_config: HttpServerConfig::from_uri(
12188 "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
12189 )
12190 .unwrap(),
12191 client: reqwest::Client::new(),
12192 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
12193 PINNED_CLIENT_TTL,
12194 PINNED_CLIENT_MAX_ENTRIES,
12195 )),
12196 http_config: HttpConfig::default(),
12197 };
12198 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
12199 let result = endpoint.create_consumer(rt);
12200 assert!(result.is_err(), "expected error for http with tls config");
12201 if let Err(e) = result {
12202 let msg = e.to_string();
12203 assert!(msg.contains("https"), "error must mention https: {msg}");
12204 }
12205 }
12206
12207 #[test]
12208 fn https_consumer_with_partial_tls_cert_only_errors() {
12209 let server_config =
12212 HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
12213 assert!(
12214 server_config.tls_config.is_none(),
12215 "partial tlsCert must not create ServerTlsConfig"
12216 );
12217 let endpoint = HttpEndpoint {
12218 uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
12219 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
12220 .unwrap(),
12221 server_config,
12222 client: reqwest::Client::new(),
12223 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
12224 PINNED_CLIENT_TTL,
12225 PINNED_CLIENT_MAX_ENTRIES,
12226 )),
12227 http_config: HttpConfig::default(),
12228 };
12229 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
12230 let result = endpoint.create_consumer(rt);
12231 assert!(
12232 result.is_err(),
12233 "must error: https:// requires both tlsCert and tlsKey"
12234 );
12235 }
12236
12237 #[test]
12238 fn load_tls_config_parses_valid_pem() {
12239 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
12241 use camel_component_api::test_support::tls;
12242 let (_, cert_pem, key_pem) = tls::gen_server_cert();
12243 let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
12244 let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
12245
12246 let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
12247 assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
12248 }
12249
12250 #[tokio::test(flavor = "multi_thread")]
12251 #[allow(clippy::await_holding_lock)]
12252 async fn consumer_tls_handshake_roundtrip() {
12253 use camel_component_api::test_support::tls;
12254 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
12255
12256 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
12258
12259 let _guard = lock_registry_test_mutex();
12261
12262 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
12264 let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
12265 let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
12266 let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
12267
12268 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12270 let port = probe.local_addr().unwrap().port();
12271 drop(probe);
12272
12273 ServerRegistry::reset();
12274
12275 let component = HttpComponent::new();
12277 let endpoint_ctx = NoOpComponentContext;
12278 let uri = format!(
12279 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
12280 cert_path.to_string_lossy(),
12281 key_path.to_string_lossy(),
12282 );
12283 let endpoint = component
12284 .create_endpoint(&uri, &endpoint_ctx)
12285 .expect("create TLS endpoint");
12286 let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
12287
12288 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
12290 let token = tokio_util::sync::CancellationToken::new();
12291 let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
12292 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
12293
12294 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
12296
12297 let ca_bytes = std::fs::read(&ca_path).unwrap();
12299 let client = reqwest::Client::builder()
12300 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
12301 .build()
12302 .unwrap();
12303
12304 let send_fut = client
12305 .post(format!("https://localhost:{port}/test"))
12306 .body("ping")
12307 .send();
12308
12309 let (http_result, _) = tokio::join!(send_fut, async {
12311 if let Some(mut envelope) = rx.recv().await {
12312 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
12313 if let Some(reply_tx) = envelope.reply_tx {
12314 let _ = reply_tx.send(Ok(envelope.exchange));
12315 }
12316 }
12317 });
12318
12319 let resp = http_result.expect("TLS handshake + request must succeed");
12320
12321 assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
12322 let body = resp.text().await.unwrap();
12323 assert_eq!(body, "pong");
12324
12325 token.cancel();
12326 }
12327
12328 #[tokio::test(flavor = "multi_thread")]
12329 #[allow(clippy::await_holding_lock)]
12330 async fn consumer_tls_rejects_client_without_ca() {
12331 use camel_component_api::test_support::tls;
12332 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
12333
12334 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
12335
12336 let _guard = lock_registry_test_mutex();
12338
12339 let (_, cert_pem, key_pem) = tls::gen_server_cert();
12340 let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
12341 let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
12342
12343 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12344 let port = probe.local_addr().unwrap().port();
12345 drop(probe);
12346
12347 ServerRegistry::reset();
12348
12349 let component = HttpComponent::new();
12351 let endpoint_ctx = NoOpComponentContext;
12352 let uri = format!(
12353 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
12354 cert_path.to_string_lossy(),
12355 key_path.to_string_lossy(),
12356 );
12357 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
12358 let mut consumer = endpoint.create_consumer(rt()).unwrap();
12359 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
12360 let token = tokio_util::sync::CancellationToken::new();
12361 let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
12362 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
12363
12364 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
12365
12366 let client = reqwest::Client::builder().build().unwrap();
12368
12369 let result = client
12370 .get(format!("https://localhost:{port}/test"))
12371 .send()
12372 .await;
12373
12374 assert!(
12375 result.is_err(),
12376 "must reject without CA — proves real verification"
12377 );
12378
12379 token.cancel();
12380 }
12381
12382 #[test]
12383 fn server_config_partial_tls_cert_without_key() {
12384 let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
12386 assert!(cfg.tls_config.is_none());
12388 }
12389
12390 #[test]
12391 fn endpoint_uri_options_count_parity() {
12392 assert_eq!(
12394 HttpEndpointConfig::uri_options().len(),
12395 23,
12396 "HttpEndpointUriConfig #[uri_param] count drifted from parser"
12397 );
12398 }
12399
12400 fn make_headers(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
12401 pairs
12402 .iter()
12403 .map(|(k, v)| {
12404 (
12405 (*k).to_string(),
12406 serde_json::Value::String((*v).to_string()),
12407 )
12408 })
12409 .collect()
12410 }
12411
12412 #[test]
12413 fn response_emits_cache_control_via_pragma_warning() {
12414 let headers = make_headers(&[
12415 ("Cache-Control", "public, max-age=3600"),
12416 ("Via", "1.1 myproxy"),
12417 ("Pragma", "no-cache"),
12418 ("Warning", "199 misc"),
12419 ]);
12420 let selected = select_response_headers(&headers, None, None);
12421 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12422 for expected in ["Cache-Control", "Via", "Pragma", "Warning"] {
12423 assert!(
12424 names.contains(&expected),
12425 "{expected} should pass through to the response"
12426 );
12427 }
12428 }
12429
12430 #[test]
12431 fn response_excludes_request_only_and_server_owned() {
12432 let headers = make_headers(&[
12433 ("User-Agent", "x"),
12434 ("Accept", "*/*"),
12435 ("Date", "Thu, 01 Jan 2026 00:00:00 GMT"),
12436 ]);
12437 let selected = select_response_headers(&headers, None, None);
12438 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12439 for excluded in ["User-Agent", "Accept", "Date"] {
12440 assert!(
12441 !names.contains(&excluded),
12442 "{excluded} should NOT appear in the response"
12443 );
12444 }
12445 }
12446
12447 #[test]
12448 fn response_re_derives_content_type() {
12449 let headers = make_headers(&[("Content-Type", "text/plain")]);
12450 let selected = select_response_headers(&headers, Some("application/json".into()), None);
12451 let ct_entries: Vec<&str> = selected
12452 .iter()
12453 .filter(|(k, _)| k == "Content-Type")
12454 .map(|(_, v)| v.as_str())
12455 .collect();
12456 assert_eq!(
12457 ct_entries,
12458 ["application/json"],
12459 "exactly one Content-Type entry, re-derived from user_content_type"
12460 );
12461 }
12462
12463 #[test]
12464 fn response_excludes_camel_headers() {
12465 let headers = make_headers(&[("CamelHttpPath", "/foo"), ("Cache-Control", "public")]);
12466 let selected = select_response_headers(&headers, None, None);
12467 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12468 assert!(
12469 !names.contains(&"CamelHttpPath"),
12470 "Camel-namespace headers must be excluded"
12471 );
12472 assert!(
12473 names.contains(&"Cache-Control"),
12474 "Cache-Control must pass through"
12475 );
12476 }
12477
12478 #[test]
12479 fn response_stringifies_scalar_header_values() {
12480 let mut headers = make_headers(&[("X-Label", "keep")]);
12481 headers.insert("X-Retries".to_string(), serde_json::json!(3));
12482 headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
12483 headers.insert("X-Enabled".to_string(), serde_json::json!(true));
12484 let selected = select_response_headers(&headers, None, None);
12485 let get = |name: &str| -> Option<&str> {
12486 selected
12487 .iter()
12488 .find(|(k, _)| k == name)
12489 .map(|(_, v)| v.as_str())
12490 };
12491 assert_eq!(
12492 get("X-Retries"),
12493 Some("3"),
12494 "integer header must be stringified"
12495 );
12496 assert_eq!(
12497 get("X-Ratio"),
12498 Some("3.5"),
12499 "float header must be stringified"
12500 );
12501 assert_eq!(
12502 get("X-Enabled"),
12503 Some("true"),
12504 "bool header must be stringified"
12505 );
12506 assert_eq!(
12507 get("X-Label"),
12508 Some("keep"),
12509 "string header must pass through"
12510 );
12511 }
12512
12513 #[test]
12514 fn response_drops_null_and_structured_header_values() {
12515 let mut headers = make_headers(&[("X-Keep", "yes")]);
12516 headers.insert("X-Null".to_string(), serde_json::Value::Null);
12517 headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
12518 headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
12519 let selected = select_response_headers(&headers, None, None);
12520 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12521 for dropped in ["X-Null", "X-Obj", "X-Arr"] {
12522 assert!(
12523 !names.contains(&dropped),
12524 "{dropped} must not be emitted: no single-value form"
12525 );
12526 }
12527 assert!(names.contains(&"X-Keep"), "scalar headers must survive");
12528 }
12529
12530 #[test]
12531 fn response_stringifies_scalars_despite_excluded_names() {
12532 let mut headers = HashMap::new();
12536 headers.insert("Content-Length".to_string(), serde_json::json!(999));
12537 headers.insert("Date".to_string(), serde_json::json!(12345));
12538 let selected = select_response_headers(&headers, None, None);
12539 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12540 assert!(
12541 !names.contains(&"Content-Length"),
12542 "content-length is re-derived by the server"
12543 );
12544 assert!(!names.contains(&"Date"), "date is server-owned");
12545 }
12546
12547 #[test]
12548 fn outbound_stringifies_scalar_header_values() {
12549 let mut headers = make_headers(&[("X-Label", "keep")]);
12550 headers.insert("X-Retries".to_string(), serde_json::json!(3));
12551 headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
12552 headers.insert("X-Enabled".to_string(), serde_json::json!(true));
12553 let outbound = select_outbound_headers(&headers, &[], &[]);
12554 let get = |name: &str| -> Option<String> {
12556 outbound
12557 .accepted
12558 .iter()
12559 .find(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12560 .map(|(_, v)| v.to_str().unwrap().to_string())
12561 };
12562 assert_eq!(
12563 get("X-Retries").as_deref(),
12564 Some("3"),
12565 "integer header must be stringified"
12566 );
12567 assert_eq!(
12568 get("X-Ratio").as_deref(),
12569 Some("3.5"),
12570 "float header must be stringified"
12571 );
12572 assert_eq!(
12573 get("X-Enabled").as_deref(),
12574 Some("true"),
12575 "bool header must be stringified"
12576 );
12577 assert_eq!(
12578 get("X-Label").as_deref(),
12579 Some("keep"),
12580 "string header must pass through"
12581 );
12582 assert!(outbound.drops.is_empty(), "scalar headers must not drop");
12583 }
12584
12585 #[test]
12586 fn outbound_drops_null_and_structured_header_values() {
12587 let mut headers = make_headers(&[("X-Keep", "yes")]);
12588 headers.insert("X-Null".to_string(), serde_json::Value::Null);
12589 headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
12590 headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
12591 let outbound = select_outbound_headers(&headers, &[], &[]);
12592 let has = |name: &str| {
12593 outbound
12594 .accepted
12595 .iter()
12596 .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12597 };
12598 assert!(has("X-Keep"), "scalar headers must survive");
12599 for (name, kind) in [("X-Null", "null"), ("X-Obj", "object"), ("X-Arr", "array")] {
12600 let dropped = outbound
12601 .drops
12602 .iter()
12603 .find(|d| d.name == name)
12604 .unwrap_or_else(|| panic!("{name} must have a drop record: {:?}", outbound.drops));
12605 assert_eq!(
12606 dropped.reason, "no scalar string form",
12607 "{name} drop reason must name the value kind absence"
12608 );
12609 assert_eq!(dropped.value_kind, Some(kind), "{name} kind recorded");
12610 }
12611 }
12612
12613 #[test]
12614 fn outbound_stringifies_scalars_despite_excluded_names() {
12615 let mut headers = HashMap::new();
12619 headers.insert("Transfer-Encoding".to_string(), serde_json::json!(7));
12620 headers.insert("Host".to_string(), serde_json::json!(12345));
12621 headers.insert("X-Ok".to_string(), serde_json::json!(7));
12622 let outbound = select_outbound_headers(&headers, &[], &[]);
12623 let has = |name: &str| {
12624 outbound
12625 .accepted
12626 .iter()
12627 .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12628 };
12629 assert!(
12630 !has("Transfer-Encoding"),
12631 "hop-by-hop header must stay excluded"
12632 );
12633 assert!(!has("Host"), "host is destination-derived");
12634 assert!(has("X-Ok"), "non-excluded scalar must be stringified");
12635 assert!(
12636 outbound
12637 .drops
12638 .iter()
12639 .any(|d| d.name == "Transfer-Encoding" && d.reason == "outbound emission policy"),
12640 "policy drop must be recorded before coercion"
12641 );
12642 }
12643
12644 #[test]
12645 fn outbound_drops_invalid_names_values_and_skip_config() {
12646 let mut headers = make_headers(&[("X-Good", "fine")]);
12647 headers.insert("X Bad Name".to_string(), serde_json::json!("v"));
12648 headers.insert(
12649 "X-Control-Value".to_string(),
12650 serde_json::json!("line1\nline2"),
12651 );
12652 headers.insert("X-Secret".to_string(), serde_json::json!("s3cr3t"));
12653 headers.insert("CamelHttpQuery".to_string(), serde_json::json!("q=1"));
12654 let skip = vec!["x-secret".to_string()];
12655 let outbound = select_outbound_headers(&headers, &skip, &[]);
12656 let has = |name: &str| {
12657 outbound
12658 .accepted
12659 .iter()
12660 .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12661 };
12662 assert!(has("X-Good"), "valid header must survive");
12663 assert!(!has("X Bad Name"), "invalid header name must drop");
12664 assert!(!has("X-Control-Value"), "control-char value must drop");
12665 assert!(!has("X-Secret"), "skipped header must drop");
12666 assert!(!has("CamelHttpQuery"), "Camel-namespace header must drop");
12667 let reason = |n: &str| {
12668 outbound
12669 .drops
12670 .iter()
12671 .find(|d| d.name == n)
12672 .map(|d| d.reason)
12673 };
12674 assert_eq!(reason("X Bad Name"), Some("invalid header name"));
12675 assert_eq!(reason("X-Control-Value"), Some("invalid header value"));
12676 assert_eq!(reason("X-Secret"), Some("skip_request_headers"));
12677 assert_eq!(reason("CamelHttpQuery"), Some("Camel namespace"));
12678 }
12679
12680 #[test]
12681 fn constructed_header_invalid_value_returns_drop_record() {
12682 let result = constructed_header("user-agent", "bad\r\ns3nt1nel");
12683 let Err(record) = result else {
12684 panic!("invalid value must produce a drop record");
12685 };
12686 assert_eq!(record.reason, "invalid header value");
12687 assert_eq!(record.name, "user-agent");
12688 assert!(record.value_kind.is_none());
12689 let debug = format!("{record:?}");
12690 assert!(
12691 !debug.contains("bad\r\n") && !debug.contains("s3nt1nel"),
12692 "drop record debug must not leak the value"
12693 );
12694 }
12695
12696 #[test]
12697 fn constructed_header_invalid_name_returns_drop_record() {
12698 let result = constructed_header("bad name", "ok");
12699 let Err(record) = result else {
12700 panic!("invalid name must produce a drop record");
12701 };
12702 assert_eq!(record.reason, "invalid header name");
12703 assert_eq!(record.name, "bad name");
12704 let debug = format!("{record:?}");
12705 assert!(
12706 !debug.contains("ok"),
12707 "drop record debug must not leak the value"
12708 );
12709 }
12710
12711 #[test]
12712 fn constructed_header_valid_pair_roundtrip() {
12713 let result = constructed_header("authorization", "Bearer abc123");
12714 let Ok((name, val)) = result else {
12715 panic!("valid pair must construct");
12716 };
12717 assert_eq!(name.as_str(), "authorization");
12718 let Ok(roundtrip) = val.to_str() else {
12719 panic!("valid value must roundtrip to str");
12720 };
12721 assert_eq!(roundtrip, "Bearer abc123");
12722 }
12723
12724 async fn start_host_capturing_destination() -> (
12736 String,
12737 Arc<std::sync::Mutex<Option<(String, String)>>>,
12738 tokio::task::JoinHandle<()>,
12739 ) {
12740 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12741 let port = listener.local_addr().unwrap().port();
12742 let url = format!("http://127.0.0.1:{port}");
12743 let captured: Arc<std::sync::Mutex<Option<(String, String)>>> =
12744 Arc::new(std::sync::Mutex::new(None));
12745 let captured_clone = Arc::clone(&captured);
12746 let handle = tokio::spawn(async move {
12747 use tokio::io::{AsyncReadExt, AsyncWriteExt};
12748 if let Ok((mut stream, _)) = listener.accept().await {
12749 let mut buf = vec![0u8; 16384];
12750 let n = stream.read(&mut buf).await.unwrap_or(0);
12751 let request = String::from_utf8_lossy(&buf[..n]).to_string();
12752 if request.contains("\r\n\r\n") {
12753 let request_line = request.lines().next().unwrap_or("").to_string();
12754 let host_value = request
12755 .lines()
12756 .find(|l| l.to_lowercase().starts_with("host:"))
12757 .and_then(|l| l.split_once(':'))
12758 .map(|(_, v)| v.trim().to_string())
12759 .unwrap_or_default();
12760 *captured_clone.lock().unwrap() = Some((host_value, request_line));
12761 }
12762 let body = r#"{"echo":"ok"}"#;
12763 let resp = format!(
12764 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
12765 body.len(),
12766 body
12767 );
12768 let _ = stream.write_all(resp.as_bytes()).await;
12769 }
12770 });
12771 (url, captured, handle)
12772 }
12773
12774 #[tokio::test]
12779 async fn bridge_proxy_outbound_host_matches_destination() {
12780 use tower::ServiceExt;
12781
12782 let (url, captured, _handle) = start_host_capturing_destination().await;
12783 let expected_host = url.strip_prefix("http://").unwrap();
12786
12787 let ctx = test_producer_ctx();
12788 let component = HttpComponent::new();
12789 let endpoint_ctx = NoOpComponentContext;
12790 let endpoint = component
12791 .create_endpoint(
12792 &format!("{url}?bridgeEndpoint=true&allowInternal=true"),
12793 &endpoint_ctx,
12794 )
12795 .unwrap();
12796 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
12797
12798 let mut exchange = Exchange::new(Message::default());
12801 exchange.input.set_header("Host", "localhost");
12802 exchange.input.set_header("CamelHttpPath", "/foo");
12803
12804 let result = producer.oneshot(exchange).await;
12805 assert!(result.is_ok(), "producer call failed: {:?}", result);
12806
12807 tokio::time::sleep(Duration::from_millis(100)).await;
12808 let (host_value, request_line) = captured
12809 .lock()
12810 .unwrap()
12811 .take()
12812 .expect("destination capture mutex empty — producer did not reach the destination");
12813
12814 assert_ne!(
12815 host_value, "localhost",
12816 "bridge producer must not forward the exchange Host: localhost"
12817 );
12818 assert_eq!(
12819 host_value, expected_host,
12820 "Host must be derived from the destination authority (no scheme)"
12821 );
12822 assert!(
12823 !request_line.contains("/foo"),
12824 "bridge_endpoint must drop CamelHttpPath; request line was: {request_line}"
12825 );
12826 }
12827
12828 #[tokio::test]
12833 async fn bridge_proxy_route_set_response_header_survives() {
12834 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
12835
12836 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12837 let port = listener.local_addr().unwrap().port();
12838 drop(listener);
12839
12840 let component = HttpComponent::new();
12841 let endpoint_ctx = NoOpComponentContext;
12842 let endpoint = component
12843 .create_endpoint(&format!("http://127.0.0.1:{port}/cache"), &endpoint_ctx)
12844 .unwrap();
12845 let mut consumer = endpoint.create_consumer(rt()).unwrap();
12846
12847 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
12848 let token = tokio_util::sync::CancellationToken::new();
12849 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
12850
12851 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
12852 tokio::time::sleep(Duration::from_millis(50)).await;
12853
12854 let client = reqwest::Client::new();
12855 let send_fut = client.get(format!("http://127.0.0.1:{port}/cache")).send();
12856
12857 let (http_result, _) = tokio::join!(send_fut, async {
12861 if let Some(mut envelope) = rx.recv().await {
12862 envelope
12863 .exchange
12864 .input
12865 .set_header("Cache-Control", "public, max-age=3600");
12866 if let Some(reply_tx) = envelope.reply_tx {
12867 let _ = reply_tx.send(Ok(envelope.exchange));
12868 }
12869 }
12870 });
12871
12872 let resp = http_result.unwrap();
12873 assert_eq!(resp.status().as_u16(), 200);
12874
12875 let cache_control = resp.headers().get("cache-control");
12876 assert!(
12877 cache_control.is_some(),
12878 "Cache-Control header must survive to the wire response"
12879 );
12880 assert_eq!(
12881 cache_control.unwrap().to_str().unwrap(),
12882 "public, max-age=3600"
12883 );
12884
12885 token.cancel();
12886 }
12887
12888 use camel_api::security_policy::CredentialSource;
12907 use camel_auth::credential_source::extract_token_from_exchange;
12908 use camel_auth::native_auth::NativeCredentialStore;
12909 use camel_auth::{StaticTokenAuthenticator, TokenAuthenticator};
12910
12911 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 {
12919 let mut msg = Message::default();
12920 msg.set_header(
12921 "CamelHttpMethod",
12922 serde_json::Value::String(envelope.method.clone()),
12923 );
12924 msg.set_header(
12925 "CamelHttpPath",
12926 serde_json::Value::String(envelope.path.clone()),
12927 );
12928 msg.set_header(
12929 "CamelHttpQuery",
12930 serde_json::Value::String(envelope.query.clone()),
12931 );
12932 for (k, v) in &envelope.headers {
12933 if let Ok(val_str) = v.to_str() {
12934 msg.set_header(
12935 title_case_header(k.as_str()),
12936 serde_json::Value::String(val_str.to_string()),
12937 );
12938 }
12939 }
12940 Exchange::new(msg)
12941 }
12942
12943 async fn spawn_failing_auth_route(
12950 registry: &HttpRouteRegistry,
12951 path: &str,
12952 sources: Vec<CredentialSource>,
12953 ) {
12954 let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(
12955 NativeCredentialStore::try_new(vec![]).unwrap(),
12956 ));
12957 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12958 registry.register_api_route(path.to_string(), tx).await;
12959 let path_owned = path.to_string();
12960 tokio::spawn(async move {
12961 while let Some(envelope) = rx.recv().await {
12962 let exchange = envelope_to_exchange(&envelope);
12963 let reply_tx = envelope.reply_tx;
12964 let result: Result<(), CamelError> = async {
12965 let token = extract_token_from_exchange(&exchange, &sources)
12966 .map(|extracted| extracted.token)
12967 .ok_or_else(|| {
12968 CamelError::Unauthenticated("no credential in any source".into())
12969 })?;
12970 authenticator.authenticate_bearer(&token).await?;
12971 Ok(())
12972 }
12973 .await;
12974 let reply = match result {
12975 Ok(()) => HttpReply {
12976 status: 200,
12977 headers: vec![],
12978 body: HttpReplyBody::Bytes(bytes::Bytes::from("ok")),
12979 },
12980 Err(e) => pipeline_error_to_reply(e, &path_owned),
12981 };
12982 let _ = reply_tx.send(reply);
12983 }
12984 });
12985 }
12986
12987 fn captured_logs_contain(needle: &str) -> bool {
12991 let buf = tracing_test::internal::global_buf().lock().unwrap();
12992 String::from_utf8_lossy(&buf).contains(needle)
12993 }
12994
12995 #[tracing_test::traced_test]
12996 #[tokio::test]
12997 async fn error_context_redacts_query_sentinel() {
12998 let (port, registry) = spawn_test_server().await;
12999 spawn_failing_auth_route(
13000 ®istry,
13001 "/secure-query",
13002 vec![CredentialSource::QueryParam {
13003 param: "token".to_string(),
13004 }],
13005 )
13006 .await;
13007
13008 let client = reqwest::Client::new();
13009 let resp = client
13010 .get(format!(
13012 "http://127.0.0.1:{port}/secure-query?token={SENTINEL_QRY_42}"
13013 ))
13014 .send()
13015 .await
13016 .unwrap();
13017
13018 assert_eq!(resp.status().as_u16(), 401);
13019 let body = resp.text().await.unwrap();
13020 assert_eq!(body, "Unauthorized");
13021 assert!(
13022 !body.contains(SENTINEL_QRY_42),
13023 "reply body must not contain the query credential"
13024 );
13025 assert!(
13026 !captured_logs_contain(SENTINEL_QRY_42),
13027 "no tracing record during request handling may render the query credential"
13028 );
13029 assert!(
13033 captured_logs_contain("Authentication failed"),
13034 "positive control: the failed-auth warn! must be captured by the test subscriber"
13035 );
13036 }
13037
13038 #[tracing_test::traced_test]
13039 #[tokio::test]
13040 async fn error_context_redacts_cookie_sentinel() {
13041 let (port, registry) = spawn_test_server().await;
13042 spawn_failing_auth_route(
13043 ®istry,
13044 "/secure-cookie",
13045 vec![CredentialSource::Cookie {
13046 name: "session".to_string(),
13047 }],
13048 )
13049 .await;
13050
13051 let client = reqwest::Client::new();
13052 let resp = client
13053 .get(format!("http://127.0.0.1:{port}/secure-cookie"))
13054 .header("Cookie", format!("session={SENTINEL_CKY_7}"))
13055 .send()
13056 .await
13057 .unwrap();
13058
13059 assert_eq!(resp.status().as_u16(), 401);
13060 let body = resp.text().await.unwrap();
13061 assert_eq!(body, "Unauthorized");
13062 assert!(
13063 !body.contains(SENTINEL_CKY_7),
13064 "reply body must not contain the cookie credential"
13065 );
13066 assert!(
13067 !captured_logs_contain(SENTINEL_CKY_7),
13068 "no tracing record during request handling may render the cookie credential"
13069 );
13070 }
13071
13072 #[tracing_test::traced_test]
13073 #[tokio::test]
13074 async fn error_reply_no_credential_value() {
13075 let (port, registry) = spawn_test_server().await;
13076 spawn_failing_auth_route(
13077 ®istry,
13078 "/secure-bad",
13079 vec![CredentialSource::Cookie {
13080 name: "session".to_string(),
13081 }],
13082 )
13083 .await;
13084
13085 let client = reqwest::Client::new();
13086 let resp = client
13087 .get(format!("http://127.0.0.1:{port}/secure-bad"))
13088 .header("Cookie", format!("session={SENTINEL_BAD_1}"))
13089 .send()
13090 .await
13091 .unwrap();
13092
13093 assert_eq!(resp.status().as_u16(), 401);
13094 let body = resp.text().await.unwrap();
13095 assert_eq!(body, "Unauthorized");
13096 assert!(
13097 !body.contains(SENTINEL_BAD_1),
13098 "reply body must not contain the credential value"
13099 );
13100 assert!(
13101 !captured_logs_contain(SENTINEL_BAD_1),
13102 "error logs must not render the credential value"
13103 );
13104 }
13105
13106 async fn spawn_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
13120 use tokio::io::AsyncWriteExt;
13121
13122 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
13123 .await
13124 .expect("bind ephemeral 127.0.0.1 listener");
13125 let port = listener.local_addr().expect("local addr").port();
13126 let base_url = format!("http://localhost:{port}");
13127 let handle = tokio::spawn(async move {
13128 while let Ok((mut conn, _)) = listener.accept().await {
13129 let _ = conn
13130 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
13131 .await;
13132 let _ = conn.shutdown().await;
13133 }
13134 });
13135 (base_url, handle)
13136 }
13137
13138 async fn spawn_tls_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
13145 use tokio::io::AsyncWriteExt;
13146
13147 let (_ca_pem, cert_pem, key_pem) =
13148 camel_component_api::test_support::tls::gen_server_cert();
13149 let certs: Vec<_> = rustls_pemfile::certs(&mut cert_pem.as_bytes())
13150 .collect::<Result<_, _>>()
13151 .expect("parse server cert pem");
13152 let key = rustls_pemfile::private_key(&mut key_pem.as_bytes())
13153 .expect("parse server key pem")
13154 .expect("server key present");
13155 let provider =
13158 std::sync::Arc::new(tokio_rustls::rustls::crypto::aws_lc_rs::default_provider());
13159 let tls_cfg = tokio_rustls::rustls::ServerConfig::builder_with_provider(provider)
13160 .with_safe_default_protocol_versions()
13161 .expect("safe default protocol versions")
13162 .with_no_client_auth()
13163 .with_single_cert(certs, key)
13164 .expect("build rustls server config");
13165 let acceptor = tokio_rustls::TlsAcceptor::from(std::sync::Arc::new(tls_cfg));
13166
13167 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
13168 .await
13169 .expect("bind ephemeral 127.0.0.1 listener");
13170 let port = listener.local_addr().expect("local addr").port();
13171 let base_url = format!("https://localhost:{port}");
13172 let handle = tokio::spawn(async move {
13173 while let Ok((conn, _)) = listener.accept().await {
13174 let acceptor = acceptor.clone();
13175 tokio::spawn(async move {
13176 if let Ok(mut tls) = acceptor.accept(conn).await {
13177 let _ = tls
13178 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
13179 .await;
13180 let _ = tls.shutdown().await;
13181 }
13182 });
13183 }
13184 });
13185 (base_url, handle)
13186 }
13187
13188 fn responder_port(base_url: &str) -> u16 {
13192 url::Url::parse(base_url)
13193 .expect("responder base URL parses")
13194 .port()
13195 .expect("responder base URL carries an explicit port")
13196 }
13197
13198 fn endpoint_with_shared_cache(
13203 base_url: &str,
13204 pinned_cache: &Arc<PinnedClientCache>,
13205 ) -> HttpEndpoint {
13206 let uri = format!("{base_url}?allowInternal=true");
13207 HttpEndpoint {
13208 uri: uri.clone(),
13209 config: HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses"),
13210 server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
13211 client: reqwest::Client::new(),
13212 pinned_cache: Arc::clone(pinned_cache),
13213 http_config: HttpConfig::default(),
13214 }
13215 }
13216
13217 #[tokio::test]
13218 async fn producers_share_endpoint_cache() {
13219 use tower::ServiceExt;
13220
13221 let (base_url, _handle) = spawn_multi_accept_200().await;
13222 let pinned_cache = Arc::new(PinnedClientCache::new(
13223 PINNED_CLIENT_TTL,
13224 PINNED_CLIENT_MAX_ENTRIES,
13225 ));
13226
13227 let ctx = test_producer_ctx();
13228 let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
13229 let producer_a = endpoint.create_producer(rt(), &ctx);
13230 let producer_b = endpoint.create_producer(rt(), &ctx);
13231
13232 for producer in [producer_a, producer_b] {
13235 let producer = producer.expect("create producer");
13236 let exchange = Exchange::new(Message::default());
13237 let reply = producer.oneshot(exchange).await;
13238 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
13239 }
13240
13241 assert_eq!(
13242 pinned_cache.build_count(),
13243 1,
13244 "both producers must hit the same shared cache entry; a second \
13245 build means sharing is broken"
13246 );
13247 }
13248
13249 #[tokio::test]
13250 async fn producer_repeated_hostname_requests_build_one_client() {
13251 use tower::ServiceExt;
13252
13253 let (base_url, _handle) = spawn_multi_accept_200().await;
13254 let pinned_cache = Arc::new(PinnedClientCache::new(
13255 PINNED_CLIENT_TTL,
13256 PINNED_CLIENT_MAX_ENTRIES,
13257 ));
13258 let ctx = test_producer_ctx();
13259 let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
13260 let producer = endpoint
13261 .create_producer(rt(), &ctx)
13262 .expect("create producer");
13263
13264 for i in 0..2 {
13267 let exchange = Exchange::new(Message::default());
13268 let reply = producer.clone().oneshot(exchange).await;
13269 assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
13270 }
13271
13272 assert_eq!(
13273 pinned_cache.build_count(),
13274 1,
13275 "repeated hostname requests must reuse the one pinned client; \
13276 0 builds means the producer bypassed the cache, more than 1 \
13277 means the entry was dropped"
13278 );
13279 }
13280
13281 #[tokio::test]
13282 async fn ip_literal_request_never_enters_cache() {
13283 use tower::ServiceExt;
13284
13285 let (base_url, _handle) = spawn_multi_accept_200().await;
13286 let pinned_cache = Arc::new(PinnedClientCache::new(
13287 PINNED_CLIENT_TTL,
13288 PINNED_CLIENT_MAX_ENTRIES,
13289 ));
13290
13291 let ctx = test_producer_ctx();
13292 let destination = format!("http://127.0.0.1:{}/ping", responder_port(&base_url));
13293 let endpoint = endpoint_with_shared_cache(&destination, &pinned_cache);
13294 let producer = endpoint
13295 .create_producer(rt(), &ctx)
13296 .expect("create producer");
13297
13298 let exchange = Exchange::new(Message::default());
13299 let reply = producer.oneshot(exchange).await;
13300 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
13301
13302 assert_eq!(
13303 pinned_cache.build_count(),
13304 0,
13305 "an IP-literal URL must use the shared unpinned client and \
13306 never enter the pinned cache"
13307 );
13308 }
13309
13310 #[tokio::test]
13311 async fn test_component_endpoints_share_pinned_cache() {
13312 use tower::ServiceExt;
13313
13314 let component = HttpComponent::new();
13315 let (base_url, _handle) = spawn_multi_accept_200().await;
13316 let baseline = component.pinned_cache.build_count();
13317
13318 let ctx = test_producer_ctx();
13319 let endpoint_ctx = NoOpComponentContext;
13320 for uri in [
13321 format!("{base_url}/a?allowInternal=true&k=a"),
13322 format!("{base_url}/b?allowInternal=true&k=b"),
13323 ] {
13324 let endpoint = component
13325 .create_endpoint(&uri, &endpoint_ctx)
13326 .expect("create endpoint");
13327 let producer = endpoint
13328 .create_producer(rt(), &ctx)
13329 .expect("create producer");
13330 let exchange = Exchange::new(Message::default());
13331 let reply = producer.oneshot(exchange).await;
13332 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
13333 }
13334
13335 assert_eq!(
13336 component.pinned_cache.build_count() - baseline,
13337 1,
13338 "endpoints created by one component must share its pinned cache; \
13339 0 builds means the endpoints bypassed it, more than 1 means \
13340 per-endpoint caches came back"
13341 );
13342 }
13343
13344 #[tokio::test]
13345 async fn test_dynamic_resolution_sequence_hits_shared_cache() {
13346 use tower::ServiceExt;
13347
13348 let component = HttpComponent::new();
13349 let (base_url, _handle) = spawn_multi_accept_200().await;
13350 let baseline = component.pinned_cache.build_count();
13351
13352 let ctx = test_producer_ctx();
13353 let endpoint_ctx = NoOpComponentContext;
13354 for i in 0..3 {
13355 let endpoint = component
13356 .create_endpoint(
13357 &format!("{base_url}/r{i}?allowInternal=true&k={i}"),
13358 &endpoint_ctx,
13359 )
13360 .expect("create endpoint");
13361 let producer = endpoint
13362 .create_producer(rt(), &ctx)
13363 .expect("create producer");
13364 let exchange = Exchange::new(Message::default());
13365 let reply = producer.oneshot(exchange).await;
13366 assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
13367 }
13368
13369 assert_eq!(
13370 component.pinned_cache.build_count() - baseline,
13371 1,
13372 "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
13373 must reuse the component's one pinned cache entry; 0 builds \
13374 means the endpoints bypassed it, more than 1 means \
13375 per-endpoint caches came back"
13376 );
13377 }
13378
13379 #[tokio::test]
13387 async fn test_https_component_endpoints_share_pinned_cache_behaviorally() {
13388 use tower::ServiceExt;
13389
13390 let http_config = HttpConfig {
13391 tls: Some(crate::config::TlsConfig {
13392 enabled: true,
13393 insecure: true,
13394 ..Default::default()
13395 }),
13396 ..Default::default()
13397 };
13398 let component = HttpsComponent::with_config(http_config);
13399 let (base_url, _handle) = spawn_tls_multi_accept_200().await;
13400 let baseline = component.pinned_cache.build_count();
13401
13402 let ctx = test_producer_ctx();
13403 let endpoint_ctx = NoOpComponentContext;
13404 for uri in [
13405 format!("{base_url}/a?allowInternal=true&k=a"),
13406 format!("{base_url}/b?allowInternal=true&k=b"),
13407 ] {
13408 let endpoint = component
13409 .create_endpoint(&uri, &endpoint_ctx)
13410 .expect("create https endpoint");
13411 let producer = endpoint
13412 .create_producer(rt(), &ctx)
13413 .expect("create producer");
13414 let exchange = Exchange::new(Message::default());
13415 let reply = producer.oneshot(exchange).await;
13416 assert!(reply.is_ok(), "https request failed: {reply:?}");
13417 }
13418
13419 assert_eq!(
13420 component.pinned_cache.build_count() - baseline,
13421 1,
13422 "endpoints of one HttpsComponent must share its pinned cache over \
13423 real https requests; 0 builds means the endpoints bypassed it \
13424 (per-endpoint cache regression), more than 1 means \
13425 per-endpoint caches came back"
13426 );
13427 }
13428
13429 #[test]
13430 fn test_https_component_owns_distinct_cache() {
13431 let http = HttpComponent::new();
13432 let https = HttpsComponent::new();
13433
13434 assert!(
13435 !Arc::ptr_eq(&http.pinned_cache, &https.pinned_cache),
13436 "http and https components must each own their own pinned cache"
13437 );
13438
13439 let endpoint_ctx = NoOpComponentContext;
13440 let _ = http
13441 .create_endpoint("http://localhost:1/?allowInternal=true", &endpoint_ctx)
13442 .expect("http endpoint");
13443 let _ = https
13444 .create_endpoint("https://localhost:1/?allowInternal=true", &endpoint_ctx)
13445 .expect("https endpoint");
13446
13447 assert_eq!(
13448 http.pinned_cache.build_count(),
13449 0,
13450 "endpoint creation must not build a pinned client"
13451 );
13452 assert_eq!(
13453 https.pinned_cache.build_count(),
13454 0,
13455 "endpoint creation must not build a pinned client"
13456 );
13457 }
13458
13459 #[test]
13460 fn test_component_constructor_builds_one_unpinned_client() {
13461 let baseline = build_client_call_count();
13462
13463 let _http = HttpComponent::new();
13464 assert_eq!(
13465 build_client_call_count() - baseline,
13466 1,
13467 "HttpComponent::new() must build exactly one shared unpinned client"
13468 );
13469
13470 let _https = HttpsComponent::new();
13471 assert_eq!(
13472 build_client_call_count() - baseline,
13473 2,
13474 "HttpsComponent::new() must build exactly one more shared unpinned client"
13475 );
13476 }
13477
13478 #[test]
13479 fn test_component_endpoints_share_unpinned_client() {
13480 let component = HttpComponent::new();
13481 let baseline = build_client_call_count();
13482
13483 let endpoint_ctx = NoOpComponentContext;
13484 for uri in [
13485 "http://localhost:1/a?allowInternal=true",
13486 "http://localhost:1/b?allowInternal=true",
13487 ] {
13488 let _endpoint = component
13489 .create_endpoint(uri, &endpoint_ctx)
13490 .expect("create endpoint");
13491 }
13492
13493 assert_eq!(
13494 build_client_call_count() - baseline,
13495 0,
13496 "create_endpoint must clone the component's shared unpinned client, \
13497 never build a fresh one"
13498 );
13499 }
13500
13501 #[test]
13502 fn test_dynamic_resolution_adds_no_unpinned_client_builds() {
13503 let component = HttpComponent::new();
13504 let baseline = build_client_call_count();
13505
13506 let ctx = test_producer_ctx();
13507 let endpoint_ctx = NoOpComponentContext;
13508 for i in 0..3 {
13509 let endpoint = component
13510 .create_endpoint(
13511 &format!("http://localhost:1/r{i}?allowInternal=true&k={i}"),
13512 &endpoint_ctx,
13513 )
13514 .expect("create endpoint");
13515 let _producer = endpoint
13516 .create_producer(rt(), &ctx)
13517 .expect("create producer");
13518 }
13519
13520 assert_eq!(
13521 build_client_call_count() - baseline,
13522 0,
13523 "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
13524 must reuse the component's shared unpinned client and build \
13525 no additional clients"
13526 );
13527 }
13528
13529 #[test]
13530 fn test_webpki_fallback_config_builds_client() {
13531 let client = reqwest::Client::builder()
13536 .tls_backend_preconfigured(webpki_root_client_config())
13537 .build()
13538 .expect("webpki-rooted client must build"); let rendered = format!("{client:?}");
13542 assert!(
13543 !rendered.is_empty(),
13544 "client must render a debug representation"
13545 );
13546 }
13547
13548 #[test]
13549 fn test_build_client_falls_back_on_empty_platform_ca_store() {
13550 let _ca_guard = lock_ca_store_test_mutex();
13554 let empty_ca_dir = tempfile::tempdir().expect("tempdir"); let empty_ca_file = empty_ca_dir.path().join("empty-ca-bundle.pem");
13566 std::fs::write(&empty_ca_file, b"").expect("write empty CA file"); let (prev_file, prev_dir) = unsafe {
13572 let prev = (
13573 std::env::var_os("SSL_CERT_FILE"),
13574 std::env::var_os("SSL_CERT_DIR"),
13575 );
13576 std::env::set_var("SSL_CERT_FILE", &empty_ca_file);
13577 std::env::set_var("SSL_CERT_DIR", empty_ca_dir.path());
13578 prev
13579 };
13580
13581 let result = std::panic::catch_unwind(|| {
13582 let fallbacks_before = build_client_fallback_count();
13583 let _client = build_client(&HttpConfig::default(), None);
13584 build_client_fallback_count() - fallbacks_before
13585 });
13586
13587 unsafe {
13590 match prev_file {
13591 Some(v) => std::env::set_var("SSL_CERT_FILE", v),
13592 None => std::env::remove_var("SSL_CERT_FILE"),
13593 }
13594 match prev_dir {
13595 Some(v) => std::env::set_var("SSL_CERT_DIR", v),
13596 None => std::env::remove_var("SSL_CERT_DIR"),
13597 }
13598 }
13599
13600 let fallbacks_taken = result.expect("build_client must not panic on an empty CA store"); assert_eq!(
13602 fallbacks_taken, 1,
13603 "an empty platform CA store must route exactly one build through \
13604 the webpki fallback (primary build failed, fallback succeeded)"
13605 );
13606 }
13607
13608 const HERMETIC_TEST_ROOT_PEM: &str = "-----BEGIN CERTIFICATE-----\n\
13614 MIIDVzCCAj+gAwIBAgIUQ+hB0JPFtHpUKaNwcTB8ijGqEW0wDQYJKoZIhvcNAQEL\n\
13615 BQAwOjEdMBsGA1UEAwwUY2FtZWwtaHR0cCB0ZXN0IHJvb3QxGTAXBgNVBAoMEGNh\n\
13616 bWVsLWh0dHAgdGVzdHMwIBcNMjYwOTIxMTEyMzUwWhgPMjEyNjA4MjgxMTIzNTBa\n\
13617 MDoxHTAbBgNVBAMMFGNhbWVsLWh0dHAgdGVzdCByb290MRkwFwYDVQQKDBBjYW1l\n\
13618 bC1odHRwIHRlc3RzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAolnF\n\
13619 SiT/bK8pMl9n50HTQj4oxPZGXK34Q/OSq8TMUEcmhUFzBjzwZKoFqvDIt/e0mSi/\n\
13620 h8WQlnrHAvYOnsRlpDsfs4yder+lpEYE84OooJjQvj/kREj7ncK6WQKDY1NTGikx\n\
13621 lF8kIaCrNHNIlUgoSWMR4E6UshLFwSu5lKtcWCeN6FNzGVQ9jxYJSFsmVk+wFyHI\n\
13622 edvpPbnFkD3M1/GKNVuxCCR50sO+cQeB7w9FCyFdHvoTWWuwPV4Qq2LBM2n4eedG\n\
13623 pCXkqmuARtFuOKjpYIRryGybic9u9ZW59FbTbgHSj6rcOXNtBUVV/KnWRDRWo7ZC\n\
13624 cR5SmO2YwtfpISjbmwIDAQABo1MwUTAdBgNVHQ4EFgQUkhwU0Q5JYNLrs7yOqbXE\n\
13625 szzn2/cwHwYDVR0jBBgwFoAUkhwU0Q5JYNLrs7yOqbXEszzn2/cwDwYDVR0TAQH/\n\
13626 BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAXLqN7poLyxlpxN9vY1jISs403P2I\n\
13627 edwgX3eWyJgXQfc/+dhnig8Pi7SkJKNM9PM27qain3+e2/HeetNYGBD8xnzQo5ga\n\
13628 1ScPvm7u2DWIWc5y0bpm5JuCPqXoDl/7kBcaelA2lZRrPJzQuu6uPnH1ubjUPFJj\n\
13629 9SxWhR+QgJW3cFc17C179WSVSJSPLozxX1ODABSyxNXnz14Rq+MOJi2tEsDggyMm\n\
13630 txh6GwgVbiSey9c9bvu3B28iPfNpULuxBzxSLHcRrSSoII+46foY0G63V/jx7huY\n\
13631 Fc2DPwOcO2Ni8fLssMaAnEdWPZaIN/DdjmUFw9XgHE9Eld6aKEXBcRFPzA==\n\
13632 -----END CERTIFICATE-----";
13633
13634 #[test]
13635 fn test_build_client_primary_path_when_platform_store_non_empty() {
13636 let _ca_guard = lock_ca_store_test_mutex();
13644 let ca_dir = tempfile::tempdir().expect("tempdir"); let ca_file = ca_dir.path().join("hermetic-root.pem");
13646 std::fs::write(&ca_file, HERMETIC_TEST_ROOT_PEM).expect("write fixture root"); let (prev_file, prev_dir) = unsafe {
13651 let prev = (
13652 std::env::var_os("SSL_CERT_FILE"),
13653 std::env::var_os("SSL_CERT_DIR"),
13654 );
13655 std::env::set_var("SSL_CERT_FILE", &ca_file);
13656 std::env::set_var("SSL_CERT_DIR", ca_dir.path());
13657 prev
13658 };
13659
13660 let fallbacks_before = build_client_fallback_count();
13661 let _client = build_client(&HttpConfig::default(), None);
13662 let fallbacks_taken = build_client_fallback_count() - fallbacks_before;
13663
13664 unsafe {
13666 match prev_file {
13667 Some(v) => std::env::set_var("SSL_CERT_FILE", v),
13668 None => std::env::remove_var("SSL_CERT_FILE"),
13669 }
13670 match prev_dir {
13671 Some(v) => std::env::set_var("SSL_CERT_DIR", v),
13672 None => std::env::remove_var("SSL_CERT_DIR"),
13673 }
13674 }
13675
13676 assert_eq!(
13677 fallbacks_taken, 0,
13678 "with at least one platform root loadable, build_client must \
13679 use the platform-verifier primary path, never the webpki \
13680 fallback"
13681 );
13682 }
13683}