1pub mod bundle;
2pub(crate) mod client_cache;
3pub mod config;
4mod header_policy;
5pub mod health;
6pub mod registry;
7pub(crate) mod rest_match;
8pub(crate) mod ssrf;
9pub mod static_config;
10pub mod static_dispatch;
11pub mod static_endpoint;
12#[cfg(test)]
13mod tls_harness;
14pub(crate) mod tls_reload;
15use crate::config::parse_ok_status_code_range;
16pub use bundle::HttpBundle;
17pub use bundle::HttpStaticBundle;
18pub(crate) use client_cache::{
19 HttpComponentKind, PINNED_CLIENT_MAX_ENTRIES, PINNED_CLIENT_TTL, PinnedClientCache,
20};
21pub use config::HttpConfig;
22pub use health::HttpHealthCheck;
23pub use registry::HttpRouteRegistry;
24pub use static_config::HttpStaticConfig;
25pub use static_endpoint::{HttpStaticComponent, HttpStaticConsumer, HttpStaticEndpoint};
26
27use std::collections::HashMap;
28use std::future::Future;
29use std::pin::Pin;
30
31use std::sync::{Arc, Mutex, OnceLock};
32use std::task::{Context, Poll};
33use std::time::Duration;
34
35use tokio::sync::OnceCell;
36use tower::Layer;
37use tower::Service;
38use tracing::debug;
39
40use axum::body::BodyDataStream;
41use camel_api::component_metadata::ComponentMetadata;
42use camel_auth::bearer_token_layer::BearerTokenLayer;
43use camel_auth::oauth2::TokenProvider;
44use camel_component_api::tls_source::ServerTlsSource;
45use camel_component_api::{Body, BoxProcessor, CamelError, Exchange, StreamBody, StreamMetadata};
46use camel_component_api::{Component, Consumer, Endpoint, ProducerContext, RuntimeObservability};
47use camel_component_api::{UriComponents, UriConfig, parse_uri, raw_query_pairs};
48use futures::StreamExt;
49use futures::TryStreamExt;
50use futures::stream::BoxStream;
51
52#[derive(Clone)]
113pub struct HttpEndpointConfig {
114 pub base_url: String,
115 pub http_method: Option<String>,
116 pub throw_exception_on_failure: bool,
117 pub ok_status_code_range: (u16, u16),
118 pub response_timeout: Option<Duration>,
119 pub query_params: Vec<(String, String)>,
123 pub raw_query: Option<String>,
128 pub allow_internal: bool,
129 pub allow_cleartext: bool,
135 pub blocked_hosts: Vec<String>,
136 pub max_body_size: usize,
137 pub read_timeout_ms: u64,
138 pub max_response_bytes: usize,
139 pub auth: HttpAuth,
140 pub token_provider: Option<Arc<dyn TokenProvider>>,
141 pub user_agent: Option<String>,
142 pub bridge_endpoint: bool,
143 pub connection_close: bool,
144 pub skip_request_headers: Vec<String>,
145 pub skip_response_headers: Vec<String>,
146 pub follow_redirects: bool,
147 pub max_redirects: usize,
148 pub allowed_uri_hosts: Option<Vec<AllowedUriHost>>,
153}
154
155impl std::fmt::Debug for HttpEndpointConfig {
168 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169 f.debug_struct("HttpEndpointConfig")
170 .field("base_url", &camel_api::redact::redact_url(&self.base_url))
171 .field("http_method", &self.http_method)
172 .field(
173 "throw_exception_on_failure",
174 &self.throw_exception_on_failure,
175 )
176 .field("ok_status_code_range", &self.ok_status_code_range)
177 .field("response_timeout", &self.response_timeout)
178 .field(
179 "query_params",
180 &self
181 .query_params
182 .iter()
183 .map(|(key, _)| (key, "***"))
184 .collect::<Vec<_>>(),
185 )
186 .field("raw_query", &self.raw_query.as_ref().map(|_| "?[redacted]"))
187 .field("allow_internal", &self.allow_internal)
188 .field("allow_cleartext", &self.allow_cleartext)
189 .field("blocked_hosts", &self.blocked_hosts)
190 .field("max_body_size", &self.max_body_size)
191 .field("read_timeout_ms", &self.read_timeout_ms)
192 .field("max_response_bytes", &self.max_response_bytes)
193 .field("auth", &self.auth)
194 .field("token_provider", &self.token_provider)
195 .field("user_agent", &self.user_agent)
196 .field("bridge_endpoint", &self.bridge_endpoint)
197 .field("connection_close", &self.connection_close)
198 .field("skip_request_headers", &self.skip_request_headers)
199 .field("skip_response_headers", &self.skip_response_headers)
200 .field("follow_redirects", &self.follow_redirects)
201 .field("max_redirects", &self.max_redirects)
202 .field("allowed_uri_hosts", &self.allowed_uri_hosts)
203 .finish()
204 }
205}
206
207#[derive(Clone, PartialEq)]
208pub enum HttpAuth {
209 None,
210 Basic { username: String, password: String },
211 Bearer { token: String },
212}
213
214impl std::fmt::Debug for HttpAuth {
215 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216 match self {
217 HttpAuth::None => f.write_str("None"),
218 HttpAuth::Basic { username, .. } => f
219 .debug_struct("Basic")
220 .field("username", username)
221 .field("password", &"***")
222 .finish(),
223 HttpAuth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
224 }
225 }
226}
227
228fn is_consumed_option(key: &str) -> bool {
237 HttpEndpointConfig::uri_options()
238 .iter()
239 .any(|option| option.name == key || option.aliases.iter().any(|alias| alias == key))
240}
241
242impl UriConfig for HttpEndpointConfig {
243 fn scheme() -> &'static str {
245 "http"
246 }
247
248 fn from_uri(uri: &str) -> Result<Self, CamelError> {
249 let parts = parse_uri(uri)?;
250 Self::from_components(parts)
251 }
252
253 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
254 if parts.scheme != "http" && parts.scheme != "https" {
256 return Err(CamelError::InvalidUri(format!(
257 "expected scheme 'http' or 'https', got '{}'",
258 parts.scheme
259 )));
260 }
261
262 let base_url = format!("{}:{}", parts.scheme, parts.path);
265
266 let http_method = parts.params.get("httpMethod").cloned();
267
268 let throw_exception_on_failure = match parts.params.get("throwExceptionOnFailure") {
269 Some(v) => parse_bool_param_http(v).map_err(|e| {
270 CamelError::InvalidUri(format!("invalid value for throwExceptionOnFailure: {e}"))
271 })?,
272 None => true,
273 };
274
275 let ok_status_code_range = match parts.params.get("okStatusCodeRange") {
277 Some(v) => parse_ok_status_code_range(v)?,
278 None => (200, 299),
279 };
280
281 let response_timeout = match parts.params.get("responseTimeout") {
282 Some(v) => Some(v.parse::<u64>().map(Duration::from_millis).map_err(|e| {
283 CamelError::InvalidUri(format!("invalid value for responseTimeout: {e}"))
284 })?),
285 None => None,
286 };
287
288 let allow_internal = match parts.params.get("allowInternal") {
290 Some(v) => parse_bool_param_http(v).map_err(|e| {
291 CamelError::InvalidUri(format!("invalid value for allowInternal: {e}"))
292 })?,
293 None => false, };
295
296 let allow_cleartext = match parts.params.get("allowCleartext") {
299 Some(v) => parse_bool_param_http(v).map_err(|e| {
300 CamelError::InvalidUri(format!("invalid value for allowCleartext: {e}"))
301 })?,
302 None => false, };
304
305 let blocked_hosts = parts
307 .params
308 .get("blockedHosts")
309 .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
310 .unwrap_or_default();
311
312 let max_body_size = match parts.params.get("maxBodySize") {
313 Some(v) => v.parse::<usize>().map_err(|e| {
314 CamelError::InvalidUri(format!("invalid value for maxBodySize: {e}"))
315 })?,
316 None => 10 * 1024 * 1024, };
318
319 let read_timeout_ms = match parts.params.get("readTimeout") {
320 Some(v) => v.parse::<u64>().map_err(|e| {
321 CamelError::InvalidUri(format!("invalid value for readTimeout: {e}"))
322 })?,
323 None => 30_000, };
325
326 let max_response_bytes = match parts.params.get("maxResponseBytes") {
327 Some(v) => v.parse::<usize>().map_err(|e| {
328 CamelError::InvalidUri(format!("invalid value for maxResponseBytes: {e}"))
329 })?,
330 None => 10 * 1024 * 1024, };
332
333 let auth = parse_auth_from_params(&parts.params)?;
334
335 let user_agent = parts.params.get("userAgent").cloned();
336
337 if parts.params.contains_key("cookieHandling") {
338 return Err(CamelError::InvalidUri(
339 "cookieHandling is not supported".into(),
340 ));
341 }
342
343 let bridge_endpoint = match parts.params.get("bridgeEndpoint") {
344 Some(v) => parse_bool_param_http(v).map_err(|e| {
345 CamelError::InvalidUri(format!("invalid value for bridgeEndpoint: {e}"))
346 })?,
347 None => false,
348 };
349
350 let connection_close = match parts.params.get("connectionClose") {
351 Some(v) => parse_bool_param_http(v).map_err(|e| {
352 CamelError::InvalidUri(format!("invalid value for connectionClose: {e}"))
353 })?,
354 None => false,
355 };
356
357 let skip_request_headers = parts
358 .params
359 .get("skipRequestHeaders")
360 .map(|v| {
361 v.split(',')
362 .map(str::trim)
363 .filter(|s| !s.is_empty())
364 .map(|s| s.to_ascii_lowercase())
365 .collect::<Vec<_>>()
366 })
367 .unwrap_or_default();
368
369 let skip_response_headers = parts
370 .params
371 .get("skipResponseHeaders")
372 .map(|v| {
373 v.split(',')
374 .map(str::trim)
375 .filter(|s| !s.is_empty())
376 .map(|s| s.to_ascii_lowercase())
377 .collect::<Vec<_>>()
378 })
379 .unwrap_or_default();
380
381 let follow_redirects = match parts.params.get("followRedirects") {
382 Some(v) => parse_bool_param_http(v).map_err(|e| {
383 CamelError::InvalidUri(format!("invalid value for followRedirects: {e}"))
384 })?,
385 None => false,
386 };
387
388 let max_redirects = match parts.params.get("maxRedirects") {
389 Some(v) => v.parse::<usize>().map_err(|e| {
390 CamelError::InvalidUri(format!("invalid value for maxRedirects: {e}"))
391 })?,
392 None => 10,
393 };
394
395 let allowed_uri_hosts = match parts.params.get("allowedUriHosts") {
398 Some(v) => Some(parse_allowed_uri_hosts(v)?),
399 None => None,
400 };
401
402 let raw_query = parts.raw_query.clone();
407
408 Ok(Self {
409 base_url,
410 http_method,
411 throw_exception_on_failure,
412 ok_status_code_range,
413 response_timeout,
414 query_params: Vec::new(),
415 raw_query,
416 allow_internal,
417 allow_cleartext,
418 blocked_hosts,
419 max_body_size,
420 read_timeout_ms,
421 max_response_bytes,
422 auth,
423 token_provider: None,
424 user_agent,
425 bridge_endpoint,
426 connection_close,
427 skip_request_headers,
428 skip_response_headers,
429 follow_redirects,
430 max_redirects,
431 allowed_uri_hosts,
432 })
433 }
434}
435
436#[derive(Debug, Clone, UriConfig)]
443#[allow(dead_code)]
444#[uri_scheme = "http"]
445#[uri_config(
446 skip_impl,
447 metadata(
448 scheme = "http",
449 description = "HTTP client and server component",
450 producer,
451 consumer,
452 streaming
453 ),
454 crate = "camel_component_api"
455)]
456struct HttpEndpointUriConfig {
457 #[allow(dead_code)]
458 _base_url: String,
459
460 #[uri_param(
461 name = "httpMethod",
462 desc = "HTTP method. Defaults to CamelHttpMethod header or POST/GET"
463 )]
464 http_method: Option<String>,
465
466 #[uri_param(
467 name = "throwExceptionOnFailure",
468 default = "true",
469 desc = "Throw on non-2xx status"
470 )]
471 throw_exception_on_failure: bool,
472
473 #[uri_param(
474 name = "okStatusCodeRange",
475 default = "200-299",
476 desc = "Success status code range"
477 )]
478 ok_status_code_range: String,
479
480 #[uri_param(name = "responseTimeout", desc = "Response timeout in milliseconds")]
481 response_timeout: Option<u64>,
482
483 #[uri_param(
484 name = "connectTimeout",
485 desc = "Connection timeout in milliseconds (consumed option; effective timeout comes from the global http config)"
486 )]
487 connect_timeout: Option<u64>,
488
489 #[uri_param(
490 name = "allowInternal",
491 default = "false",
492 desc = "Allow private/internal network destinations (SSRF)"
493 )]
494 allow_internal: bool,
495
496 #[uri_param(
497 name = "allowCleartext",
498 default = "false",
499 desc = "Allow cleartext http:// to public targets (transport consent; ADR-0081)"
500 )]
501 allow_cleartext: bool,
502
503 #[uri_param(name = "blockedHosts", desc = "Comma-separated blocked host list")]
504 blocked_hosts: Option<String>,
505
506 #[uri_param(
507 name = "maxBodySize",
508 default = "10485760",
509 desc = "Max request/response body bytes"
510 )]
511 max_body_size: u64,
512
513 #[uri_param(name = "readTimeout", desc = "Socket read timeout in milliseconds")]
514 read_timeout: Option<u64>,
515
516 #[uri_param(name = "maxResponseBytes", desc = "Max response body bytes")]
517 max_response_bytes: Option<u64>,
518
519 #[uri_param(
520 name = "authMethod",
521 kind = "enum:None,Basic,Bearer",
522 desc = "Authentication method"
523 )]
524 auth_method: Option<String>,
525
526 #[uri_param(name = "authUsername", secret, desc = "Basic auth username")]
527 auth_username: Option<String>,
528
529 #[uri_param(name = "authPassword", secret, desc = "Basic auth password")]
530 auth_password: Option<String>,
531
532 #[uri_param(name = "authBearerToken", secret, desc = "Bearer auth token")]
533 auth_bearer_token: Option<String>,
534
535 #[uri_param(name = "userAgent", desc = "User-Agent header")]
536 user_agent: Option<String>,
537
538 #[uri_param(
539 name = "bridgeEndpoint",
540 default = "false",
541 desc = "Bridge endpoint mode"
542 )]
543 bridge_endpoint: bool,
544
545 #[uri_param(
546 name = "connectionClose",
547 default = "false",
548 desc = "Send Connection: close"
549 )]
550 connection_close: bool,
551
552 #[uri_param(
553 name = "skipRequestHeaders",
554 desc = "Comma-separated request headers to skip"
555 )]
556 skip_request_headers: Option<String>,
557
558 #[uri_param(
559 name = "skipResponseHeaders",
560 desc = "Comma-separated response headers to skip"
561 )]
562 skip_response_headers: Option<String>,
563
564 #[uri_param(
565 name = "followRedirects",
566 default = "false",
567 desc = "Follow HTTP redirects"
568 )]
569 follow_redirects: bool,
570
571 #[uri_param(name = "maxRedirects", default = "10", desc = "Max redirect hops")]
572 max_redirects: u64,
573
574 #[uri_param(
575 name = "allowedUriHosts",
576 desc = "Comma-separated allowlist of CamelHttpUri override hosts (host or host:port)"
577 )]
578 allowed_uri_hosts: Option<String>,
579}
580
581impl HttpEndpointConfig {
582 pub fn metadata() -> ComponentMetadata {
585 HttpEndpointUriConfig::metadata()
586 }
587
588 pub fn uri_options() -> Vec<camel_api::component_metadata::UriOption> {
590 HttpEndpointUriConfig::uri_options()
591 }
592}
593
594fn parse_auth_from_params(params: &HashMap<String, String>) -> Result<HttpAuth, CamelError> {
595 let Some(method) = params.get("authMethod") else {
596 return Ok(HttpAuth::None);
597 };
598
599 if method.eq_ignore_ascii_case("none") {
600 return Ok(HttpAuth::None);
601 }
602
603 if method.eq_ignore_ascii_case("basic") {
604 let username = params.get("authUsername").cloned().ok_or_else(|| {
605 CamelError::InvalidUri("authUsername is required for authMethod=Basic".to_string())
606 })?;
607 let password = params.get("authPassword").cloned().ok_or_else(|| {
608 CamelError::InvalidUri("authPassword is required for authMethod=Basic".to_string())
609 })?;
610 return Ok(HttpAuth::Basic { username, password });
611 }
612
613 if method.eq_ignore_ascii_case("bearer") {
614 let token = params.get("authBearerToken").cloned().ok_or_else(|| {
615 CamelError::InvalidUri("authBearerToken is required for authMethod=Bearer".to_string())
616 })?;
617 return Ok(HttpAuth::Bearer { token });
618 }
619
620 Err(CamelError::InvalidUri(format!(
621 "invalid value for authMethod: {method} (expected None, Basic, or Bearer)"
622 )))
623}
624
625fn parse_bool_param_http(value: &str) -> Result<bool, CamelError> {
626 match value.to_ascii_lowercase().as_str() {
627 "true" | "1" | "yes" => Ok(true),
628 "false" | "0" | "no" => Ok(false),
629 _ => Err(CamelError::InvalidUri(format!(
630 "invalid boolean value: '{value}'"
631 ))),
632 }
633}
634
635impl HttpEndpointConfig {
636 pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
637 let parts = parse_uri(uri)?;
638 let mut endpoint = Self::from_components(parts.clone())?;
639 if endpoint.response_timeout.is_none() {
640 endpoint.response_timeout = Some(Duration::from_millis(config.response_timeout_ms));
641 }
642 if !parts.params.contains_key("allowInternal") {
643 endpoint.allow_internal = config.allow_internal;
644 }
645 if !parts.params.contains_key("blockedHosts") {
646 endpoint.blocked_hosts = config.blocked_hosts.clone();
647 }
648 if !parts.params.contains_key("maxBodySize") {
649 endpoint.max_body_size = config.max_body_size;
650 }
651 if !parts.params.contains_key("readTimeout") {
652 endpoint.read_timeout_ms = config.read_timeout_ms;
653 }
654 if !parts.params.contains_key("maxResponseBytes") {
655 endpoint.max_response_bytes = config.max_response_bytes;
656 }
657 if !parts.params.contains_key("okStatusCodeRange")
658 && let Some(range) = &config.ok_status_code_range
659 {
660 endpoint.ok_status_code_range = parse_ok_status_code_range(range)?;
661 }
662 if !parts.params.contains_key("followRedirects") {
663 endpoint.follow_redirects = config.follow_redirects;
664 }
665 if !parts.params.contains_key("maxRedirects") {
666 endpoint.max_redirects = config.max_redirects.unwrap_or(10);
667 }
668
669 Ok(endpoint)
670 }
671}
672
673#[derive(Debug, Clone)]
679pub struct HttpServerConfig {
680 pub scheme: String,
682 pub host: String,
684 pub port: u16,
686 pub path: String,
688 pub max_request_body: usize,
690 pub max_response_body: usize,
692 pub max_inflight_requests: usize,
698 pub method: Option<String>,
705 pub tls_config: Option<crate::config::ServerTlsConfig>,
708}
709
710impl UriConfig for HttpServerConfig {
711 fn scheme() -> &'static str {
713 "http"
714 }
715
716 fn from_uri(uri: &str) -> Result<Self, CamelError> {
717 let parts = parse_uri(uri)?;
718 Self::from_components(parts)
719 }
720
721 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
722 if parts.scheme != "http" && parts.scheme != "https" {
724 return Err(CamelError::InvalidUri(format!(
725 "expected scheme 'http' or 'https', got '{}'",
726 parts.scheme
727 )));
728 }
729
730 let authority_and_path = parts.path.trim_start_matches('/');
733
734 let (authority, path_suffix) = if let Some(idx) = authority_and_path.find('/') {
736 (&authority_and_path[..idx], &authority_and_path[idx..])
737 } else {
738 (authority_and_path, "/")
739 };
740
741 let path = if path_suffix.is_empty() {
742 "/"
743 } else {
744 path_suffix
745 }
746 .to_string();
747
748 let (host, port) = if let Some(colon) = authority.rfind(':') {
750 let port_str = &authority[colon + 1..];
751 match port_str.parse::<u16>() {
752 Ok(p) => (authority[..colon].to_string(), p),
753 Err(_) => {
754 return Err(CamelError::InvalidUri(format!(
755 "invalid port '{}' in authority",
756 port_str
757 )));
758 }
759 }
760 } else {
761 let default_port = if parts.scheme == "https" { 443 } else { 80 };
763 (authority.to_string(), default_port)
764 };
765
766 let max_request_body = parts
767 .params
768 .get("maxRequestBody")
769 .and_then(|v| v.parse::<usize>().ok())
770 .unwrap_or(2 * 1024 * 1024); let max_response_body = parts
773 .params
774 .get("maxResponseBody")
775 .and_then(|v| v.parse::<usize>().ok())
776 .unwrap_or(10 * 1024 * 1024); let max_inflight_requests = parts
779 .params
780 .get("maxInflightRequests")
781 .and_then(|v| v.parse::<usize>().ok())
782 .unwrap_or(1024);
783 let max_inflight_requests = max_inflight_requests_limit(max_inflight_requests)?;
787
788 let method = parts.params.get("httpMethod").map(|m| m.to_uppercase());
794
795 Ok(Self {
796 scheme: parts.scheme,
797 host,
798 port,
799 path,
800 max_request_body,
801 max_response_body,
802 max_inflight_requests,
803 method,
804 tls_config: {
805 let cert = parts.params.get("tlsCert").cloned();
806 let key = parts.params.get("tlsKey").cloned();
807 match (cert, key) {
808 (Some(c), Some(k)) => Some(crate::config::ServerTlsConfig {
809 cert_path: c,
810 key_path: k,
811 }),
812 (None, None) => None,
813 _ => None, }
815 },
816 })
817 }
818}
819
820impl HttpServerConfig {
821 pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
822 let parts = parse_uri(uri)?;
823 let mut server = Self::from_components(parts.clone())?;
824 if !parts.params.contains_key("maxRequestBody") {
825 server.max_request_body = config.max_request_body;
826 }
827 if !parts.params.contains_key("maxResponseBody") {
828 server.max_response_body = config.max_body_size;
830 }
831 Ok(server)
832 }
833}
834
835pub enum HttpReplyBody {
843 Bytes(bytes::Bytes),
844 Stream(BoxStream<'static, Result<bytes::Bytes, CamelError>>),
845}
846
847pub struct RequestEnvelope {
852 pub method: String,
853 pub path: String,
854 pub query: String,
855 pub headers: http::HeaderMap,
856 pub body: StreamBody,
857 pub path_params: std::collections::HashMap<String, String>,
863 pub reply_tx: tokio::sync::oneshot::Sender<HttpReply>,
864}
865
866pub struct HttpReply {
870 pub status: u16,
871 pub headers: Vec<(String, String)>,
872 pub body: HttpReplyBody,
873}
874
875type ServerKey = (String, u16);
880
881struct ServerHandle {
883 registry: HttpRouteRegistry,
884 bound_addr: std::net::SocketAddr,
887 max_request_body: usize,
888 max_response_body: usize,
889 max_inflight_requests: usize,
890 is_tls: bool,
891 tls_cert_path: Option<String>,
892 tls_key_path: Option<String>,
893 monitor_task: tokio::task::JoinHandle<()>,
896 #[allow(dead_code)]
902 server_abort: tokio::task::AbortHandle,
903 tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
906 tls_source: Option<ServerTlsSource>,
907}
908
909#[derive(Default)]
912struct RegistryState {
913 entries: HashMap<ServerKey, Arc<OnceCell<ServerHandle>>>,
914 staged: HashMap<ServerKey, tokio::net::TcpListener>,
915}
916
917pub struct ServerRegistry {
919 inner: Mutex<RegistryState>,
920}
921
922impl ServerRegistry {
923 pub fn global() -> &'static Self {
925 static INSTANCE: OnceLock<ServerRegistry> = OnceLock::new();
926 INSTANCE.get_or_init(|| ServerRegistry {
927 inner: Mutex::new(RegistryState::default()),
928 })
929 }
930
931 #[allow(clippy::too_many_arguments)]
934 pub async fn get_or_spawn(
935 &'static self,
936 host: &str,
937 port: u16,
938 max_request_body: usize,
939 max_response_body: usize,
940 max_inflight_requests: usize,
941 runtime: Arc<dyn RuntimeObservability>,
942 route_id: String,
943 tls_config: Option<crate::config::ServerTlsConfig>,
944 ) -> Result<HttpRouteRegistry, CamelError> {
945 self.get_or_spawn_internal(
946 host,
947 port,
948 max_request_body,
949 max_response_body,
950 max_inflight_requests,
951 runtime,
952 route_id,
953 tls_config,
954 None,
955 )
956 .await
957 }
958
959 #[allow(clippy::too_many_arguments)]
966 pub async fn get_or_spawn_with_listener(
967 &'static self,
968 listener: tokio::net::TcpListener,
969 max_request_body: usize,
970 max_response_body: usize,
971 max_inflight_requests: usize,
972 runtime: Arc<dyn RuntimeObservability>,
973 route_id: String,
974 tls_config: Option<crate::config::ServerTlsConfig>,
975 ) -> Result<HttpRouteRegistry, CamelError> {
976 let addr = listener
977 .local_addr()
978 .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
979 self.get_or_spawn_internal(
980 &addr.ip().to_string(),
981 addr.port(),
982 max_request_body,
983 max_response_body,
984 max_inflight_requests,
985 runtime,
986 route_id,
987 tls_config,
988 Some(listener),
989 )
990 .await
991 }
992
993 pub async fn stage_listener(
1000 &'static self,
1001 listener: tokio::net::TcpListener,
1002 ) -> Result<(), CamelError> {
1003 let addr = listener
1004 .local_addr()
1005 .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
1006 let host = addr.ip().to_string();
1007 use std::collections::hash_map::Entry;
1008 let mut guard = self.inner.lock().map_err(|_| {
1009 CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
1010 })?;
1011 match guard.staged.entry((host.clone(), addr.port())) {
1012 Entry::Occupied(_) => Err(CamelError::EndpointCreationFailed(format!(
1013 "listener already staged for {host}:{}",
1014 addr.port()
1015 ))),
1016 Entry::Vacant(slot) => {
1017 slot.insert(listener);
1018 Ok(())
1019 }
1020 }
1021 }
1022
1023 pub fn bound_addr(&'static self, host: &str, port: u16) -> Option<std::net::SocketAddr> {
1026 let guard = self.inner.lock().ok()?;
1027 guard
1028 .entries
1029 .get(&(host.to_string(), port))
1030 .and_then(|cell| cell.get())
1031 .map(|handle| handle.bound_addr)
1032 }
1033
1034 #[allow(clippy::too_many_arguments)]
1035 async fn get_or_spawn_internal(
1036 &'static self,
1037 host: &str,
1038 port: u16,
1039 max_request_body: usize,
1040 max_response_body: usize,
1041 max_inflight_requests: usize,
1042 runtime: Arc<dyn RuntimeObservability>,
1043 route_id: String,
1044 tls_config: Option<crate::config::ServerTlsConfig>,
1045 provided: Option<tokio::net::TcpListener>,
1046 ) -> Result<HttpRouteRegistry, CamelError> {
1047 let host_owned = host.to_string();
1048 let key = (host.to_string(), port);
1049
1050 let cell = {
1051 let mut guard = self.inner.lock().map_err(|_| {
1052 CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
1053 })?;
1054 if let Some(existing) = guard.entries.get(&key)
1058 && let Some(handle) = existing.get()
1059 && handle.monitor_task.is_finished()
1060 {
1061 if handle.is_tls {
1064 let scheme = if handle.is_tls { "https" } else { "http" };
1065 camel_component_api::tls_source::TlsReloadRegistry::global()
1066 .unregister(scheme, host, port);
1067 }
1068 guard.entries.remove(&key);
1069 }
1070 guard
1071 .entries
1072 .entry(key)
1073 .or_insert_with(|| Arc::new(OnceCell::new()))
1074 .clone()
1075 };
1076
1077 if let Some(existing) = cell.get()
1078 && existing.max_request_body != max_request_body
1079 {
1080 return Err(CamelError::EndpointCreationFailed(format!(
1081 "incompatible maxRequestBody for shared server (host={host}, port={port}): {} vs {}",
1082 existing.max_request_body, max_request_body
1083 )));
1084 }
1085
1086 if let Some(existing) = cell.get()
1087 && existing.max_response_body != max_response_body
1088 {
1089 return Err(CamelError::EndpointCreationFailed(format!(
1090 "incompatible maxResponseBody for shared server (host={host}, port={port}): {} vs {}",
1091 existing.max_response_body, max_response_body
1092 )));
1093 }
1094
1095 if let Some(existing) = cell.get()
1096 && existing.max_inflight_requests != max_inflight_requests
1097 {
1098 return Err(CamelError::EndpointCreationFailed(format!(
1099 "incompatible maxInflightRequests for shared server (host={host}, port={port}): {} vs {}",
1100 existing.max_inflight_requests, max_inflight_requests
1101 )));
1102 }
1103
1104 if let Some(existing) = cell.get()
1106 && existing.is_tls != tls_config.is_some()
1107 {
1108 return Err(CamelError::EndpointCreationFailed(format!(
1109 "incompatible TLS mode for shared server (host={host}, port={port}): existing is_tls={}, new has_tls={}",
1110 existing.is_tls,
1111 tls_config.is_some()
1112 )));
1113 }
1114
1115 if let (Some(existing), Some(new_tls)) = (cell.get(), &tls_config)
1117 && (existing.tls_cert_path.as_deref() != Some(&new_tls.cert_path)
1118 || existing.tls_key_path.as_deref() != Some(&new_tls.key_path))
1119 {
1120 return Err(CamelError::EndpointCreationFailed(format!(
1121 "incompatible TLS cert/key for shared server (host={host}, port={port}): routes on the same TLS port must use the same cert and key"
1122 )));
1123 }
1124
1125 let handle = cell
1126 .get_or_try_init(|| {
1127 let rt = Arc::clone(&runtime);
1128 let rid = route_id.clone();
1129 let key = (host_owned.clone(), port);
1130 async move {
1131 let source = match provided {
1140 Some(listener) => ListenerSource::Staged(listener),
1141 None => {
1142 let mut guard = self.inner.lock().map_err(|_| {
1143 CamelError::EndpointCreationFailed(
1144 "ServerRegistry lock poisoned".into(),
1145 )
1146 })?;
1147 match guard.staged.remove(&key) {
1148 Some(listener) => ListenerSource::Staged(listener),
1149 None => {
1153 if let Some((staged_host, _)) = guard
1154 .staged
1155 .keys()
1156 .find(|(_, staged_port)| *staged_port == port)
1157 {
1158 let staged_host = staged_host.clone();
1159 return Err(CamelError::EndpointCreationFailed(
1160 format!(
1161 "staged listener conflict on port {port}: staged under host {staged_host}, requested {host_owned}"
1162 ),
1163 ));
1164 }
1165 ListenerSource::Bind
1166 }
1167 }
1168 }
1169 };
1170 spawn_entry(
1171 key,
1172 source,
1173 max_request_body,
1174 max_response_body,
1175 max_inflight_requests,
1176 rt,
1177 rid,
1178 tls_config,
1179 )
1180 .await
1181 .and_then(|handle| {
1182 Arc::try_unwrap(handle).map_err(|_| {
1186 CamelError::EndpointCreationFailed(
1187 "spawned server handle has dangling clones".into(),
1188 )
1189 })
1190 })
1191 }
1192 })
1193 .await?;
1194
1195 Ok(handle.registry.clone())
1196 }
1197
1198 pub async fn unregister(&self, host: &str, port: u16) {
1202 debug!(
1203 host = camel_api::redact::redact_host(host),
1204 port = port,
1205 "consumer unregistered from HTTP server"
1206 );
1207 }
1208
1209 #[cfg(test)]
1216 pub fn reset() {
1217 let instance = Self::global();
1218 let mut guard = instance
1219 .inner
1220 .lock()
1221 .expect("ServerRegistry lock poisoned during test reset");
1222 guard.entries.clear();
1223 guard.staged.clear();
1224 }
1225}
1226
1227enum ListenerSource {
1230 Bind,
1231 Staged(tokio::net::TcpListener),
1232}
1233
1234#[allow(clippy::too_many_arguments)]
1239async fn spawn_entry(
1240 key: ServerKey,
1241 source: ListenerSource,
1242 max_request_body: usize,
1243 max_response_body: usize,
1244 max_inflight_requests: usize,
1245 runtime: Arc<dyn RuntimeObservability>,
1246 route_id: String,
1247 tls_config: Option<crate::config::ServerTlsConfig>,
1248) -> Result<Arc<ServerHandle>, CamelError> {
1249 max_inflight_requests_limit(max_inflight_requests)?;
1255 let rt = Arc::clone(&runtime);
1256 let rid = route_id.clone();
1257 let (host_owned, port) = key;
1258 let listener = match source {
1259 ListenerSource::Bind => {
1260 let addr = format!("{host_owned}:{port}");
1261 tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
1262 CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
1263 })?
1264 }
1265 ListenerSource::Staged(listener) => listener,
1266 };
1267 let bound_addr = listener
1268 .local_addr()
1269 .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
1270 let server_exited = tokio_util::sync::CancellationToken::new();
1271 let registry = HttpRouteRegistry::new_with_server_exited(server_exited.clone());
1272 let inflight = Arc::new(tokio::sync::Semaphore::new(max_inflight_requests));
1273 let tls_rustls_cfg: Option<axum_server::tls_rustls::RustlsConfig>;
1276 let tls_source: Option<ServerTlsSource>;
1277 let server_task = if let Some(ref tls) = tls_config {
1278 let rustls_config = load_tls_config(&tls.cert_path, &tls.key_path)?;
1279 let source = ServerTlsSource {
1280 cert_path: std::path::PathBuf::from(&tls.cert_path),
1281 key_path: std::path::PathBuf::from(&tls.key_path),
1282 client_ca_path: None,
1283 };
1284 let rustls_cfg =
1288 axum_server::tls_rustls::RustlsConfig::from_config(std::sync::Arc::new(rustls_config));
1289 tls_rustls_cfg = Some(rustls_cfg.clone());
1290 tls_source = Some(source);
1291 let std_listener = listener.into_std().map_err(|e| {
1293 CamelError::EndpointCreationFailed(format!("TLS listener conversion: {e}"))
1294 })?;
1295 tokio::spawn(run_axum_server_tls(
1296 std_listener,
1297 rustls_cfg,
1298 registry.clone(),
1299 max_request_body,
1300 max_response_body,
1301 Arc::clone(&inflight),
1302 Arc::clone(&rt),
1303 rid.clone(),
1304 ))
1305 } else {
1306 tls_rustls_cfg = None;
1307 tls_source = None;
1308 tokio::spawn(run_axum_server(
1309 listener,
1310 registry.clone(),
1311 max_request_body,
1312 max_response_body,
1313 Arc::clone(&inflight),
1314 Arc::clone(&rt),
1315 rid.clone(),
1316 ))
1317 };
1318 let addr_for_monitor = format!("{host_owned}:{port}");
1319 let server_abort = server_task.abort_handle();
1320 let monitor_task = tokio::spawn(monitor_axum_task(
1321 server_task,
1322 addr_for_monitor,
1323 Arc::clone(&rt),
1324 rid,
1325 server_exited,
1326 ));
1327 let handle = ServerHandle {
1328 registry,
1329 bound_addr,
1330 max_request_body,
1331 max_response_body,
1332 max_inflight_requests,
1333 is_tls: tls_config.is_some(),
1334 tls_cert_path: tls_config.as_ref().map(|t| t.cert_path.clone()),
1335 tls_key_path: tls_config.as_ref().map(|t| t.key_path.clone()),
1336 monitor_task,
1337 server_abort,
1338 tls_config: tls_rustls_cfg,
1339 tls_source,
1340 };
1341 if let (Some(tls_cfg), Some(source)) = (handle.tls_config.as_ref(), handle.tls_source.as_ref())
1346 {
1347 let handler = Arc::new(crate::tls_reload::HttpReloadHandler::new(
1348 tls_cfg.clone(),
1349 source.clone(),
1350 host_owned.clone(),
1351 port,
1352 ));
1353 camel_component_api::tls_source::TlsReloadRegistry::global().register(handler);
1354 }
1355 Ok(Arc::new(handle))
1356}
1357
1358use axum::{
1363 Router,
1364 body::Body as AxumBody,
1365 extract::{Request, State},
1366 http::{Response, StatusCode},
1367 response::IntoResponse,
1368};
1369
1370#[derive(Clone)]
1371pub(crate) struct AppState {
1372 registry: HttpRouteRegistry,
1373 max_request_body: usize,
1374 max_response_body: usize,
1375 inflight: Arc<tokio::sync::Semaphore>,
1376}
1377
1378const CONSUMER_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
1385
1386async fn run_axum_server(
1387 listener: tokio::net::TcpListener,
1388 registry: HttpRouteRegistry,
1389 max_request_body: usize,
1390 max_response_body: usize,
1391 inflight: Arc<tokio::sync::Semaphore>,
1392 runtime: Arc<dyn RuntimeObservability>,
1393 route_id: String,
1394) {
1395 let state = AppState {
1396 registry,
1397 max_request_body,
1398 max_response_body,
1399 inflight,
1400 };
1401 let app = Router::new()
1402 .fallback(dispatch_handler)
1403 .with_state(state)
1404 .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1405 StatusCode::REQUEST_TIMEOUT,
1406 CONSUMER_REQUEST_TIMEOUT,
1407 ));
1408
1409 axum::serve(listener, app).await.unwrap_or_else(|e| {
1410 runtime
1411 .metrics()
1412 .increment_errors(&route_id, "e:http:accept");
1413 tracing::error!(error = %e, "Axum server error");
1415 });
1416}
1417
1418#[allow(clippy::too_many_arguments)]
1419async fn run_axum_server_tls(
1420 listener: std::net::TcpListener,
1421 tls_cfg: axum_server::tls_rustls::RustlsConfig,
1422 registry: HttpRouteRegistry,
1423 max_request_body: usize,
1424 max_response_body: usize,
1425 inflight: Arc<tokio::sync::Semaphore>,
1426 runtime: Arc<dyn RuntimeObservability>,
1427 route_id: String,
1428) {
1429 let state = AppState {
1430 registry,
1431 max_request_body,
1432 max_response_body,
1433 inflight,
1434 };
1435 let app = Router::new()
1436 .fallback(dispatch_handler)
1437 .with_state(state)
1438 .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1439 StatusCode::REQUEST_TIMEOUT,
1440 CONSUMER_REQUEST_TIMEOUT,
1441 ));
1442
1443 let server = match axum_server::from_tcp_rustls(listener, tls_cfg) {
1448 Ok(server) => server,
1449 Err(e) => {
1450 runtime
1451 .metrics()
1452 .increment_errors(&route_id, "e:http:accept-tls");
1453 tracing::error!(error = %e, "Axum TLS server setup error");
1455 return;
1456 }
1457 };
1458
1459 server
1460 .serve(app.into_make_service())
1461 .await
1462 .unwrap_or_else(|e| {
1463 runtime
1464 .metrics()
1465 .increment_errors(&route_id, "e:http:accept-tls");
1466 tracing::error!(error = %e, "Axum TLS server error");
1468 });
1469}
1470
1471async fn monitor_axum_task(
1481 handle: tokio::task::JoinHandle<()>,
1482 addr: String,
1483 runtime: Arc<dyn RuntimeObservability>,
1484 route_id: String,
1485 server_exited: tokio_util::sync::CancellationToken,
1486) {
1487 match handle.await {
1488 Ok(()) => {
1489 }
1491 Err(join_err) => {
1492 runtime
1493 .metrics()
1494 .increment_errors(&route_id, "e:http:server-task-exited");
1495 tracing::error!(
1497 addr = %addr,
1498 error = %join_err,
1499 "Axum server task exited unexpectedly — all routes on this port are now dead"
1500 );
1501 server_exited.cancel();
1505 }
1506 }
1507}
1508
1509fn load_tls_config(
1512 cert_path: &str,
1513 key_path: &str,
1514) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
1515 use std::fs::File;
1516 use std::io::BufReader;
1517
1518 let cert_file = File::open(cert_path)
1519 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
1520 let key_file = File::open(key_path)
1521 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
1522
1523 let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
1524 .collect::<Result<Vec<_>, _>>()
1525 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
1526
1527 let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
1528 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
1529 .ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
1530
1531 tokio_rustls::rustls::ServerConfig::builder()
1532 .with_no_client_auth()
1533 .with_single_cert(certs, key)
1534 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
1535}
1536
1537async fn dispatch_handler(State(state): State<AppState>, req: Request) -> impl IntoResponse {
1538 let path = req.uri().path().to_owned();
1539 let method = req.method().to_string();
1540
1541 let api_sender = {
1558 let inner = state.registry.inner.read().await;
1559 inner.api_routes.get(&path).cloned()
1560 }; let (rest_sender, path_params) = if api_sender.is_some() {
1563 (None, Default::default())
1565 } else {
1566 let inner = state.registry.inner.read().await;
1567 match rest_match::match_endpoint(&method, &path, &inner.rest_endpoints) {
1568 rest_match::MatchOutcome::Found(m) => (Some(m.payload), m.path_params),
1569 rest_match::MatchOutcome::Ambiguous => {
1570 tracing::warn!(
1576 method = %method,
1577 path = %path,
1578 "ambiguous REST template match — returning 500"
1579 );
1580 return Response::builder()
1581 .status(StatusCode::INTERNAL_SERVER_ERROR)
1582 .body(AxumBody::from("Internal Server Error"))
1583 .expect("infallible"); }
1585 rest_match::MatchOutcome::NotFound => (None, Default::default()),
1586 }
1587 }; let sender = api_sender.or(rest_sender);
1590
1591 if let Some(sender) = sender {
1592 let query = req.uri().query().unwrap_or("").to_string();
1593 let headers = req.headers().clone();
1594
1595 let content_length: Option<u64> = headers
1597 .get(http::header::CONTENT_LENGTH)
1598 .and_then(|v| v.to_str().ok())
1599 .and_then(|s| s.parse().ok());
1600
1601 if let Some(len) = content_length
1602 && len > state.max_request_body as u64
1603 {
1604 return Response::builder()
1605 .status(StatusCode::PAYLOAD_TOO_LARGE)
1606 .body(AxumBody::from("Request body exceeds configured limit"))
1607 .expect("infallible"); }
1609
1610 let _permit = match Arc::clone(&state.inflight).try_acquire_owned() {
1611 Ok(permit) => permit,
1612 Err(_) => {
1613 return Response::builder()
1614 .status(StatusCode::SERVICE_UNAVAILABLE)
1615 .body(AxumBody::from("Service Unavailable"))
1616 .expect("infallible"); }
1618 };
1619
1620 let content_type = headers
1626 .get(http::header::CONTENT_TYPE)
1627 .and_then(|v| v.to_str().ok())
1628 .map(|s| s.to_string());
1629
1630 let data_stream: BodyDataStream = req.into_body().into_data_stream();
1631 let max_body = state.max_request_body;
1632 let mut seen: u64 = 0;
1633 let capped_stream =
1634 data_stream
1635 .map_err(|e| CamelError::Io(e.to_string()))
1636 .map(move |chunk| match chunk {
1637 Ok(bytes) => {
1638 seen = seen.saturating_add(bytes.len() as u64);
1639 if seen > max_body as u64 {
1640 Err(CamelError::ProcessorError(format!(
1641 "Request body exceeds configured limit of {max_body} bytes"
1642 )))
1643 } else {
1644 Ok(bytes)
1645 }
1646 }
1647 Err(e) => Err(e),
1648 });
1649 let boxed: BoxStream<'static, Result<bytes::Bytes, CamelError>> = Box::pin(capped_stream);
1650
1651 let stream_body = StreamBody {
1652 stream: Arc::new(tokio::sync::Mutex::new(Some(boxed))),
1653 metadata: StreamMetadata {
1654 size_hint: content_length,
1655 content_type,
1656 origin: None,
1657 },
1658 };
1659
1660 let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<HttpReply>();
1661 let envelope = RequestEnvelope {
1662 method,
1663 path,
1664 query,
1665 headers,
1666 body: stream_body,
1667 path_params,
1668 reply_tx,
1669 };
1670
1671 if sender.send(envelope).await.is_err() {
1672 return Response::builder()
1673 .status(StatusCode::SERVICE_UNAVAILABLE)
1674 .body(AxumBody::from("Consumer unavailable"))
1675 .expect("infallible"); }
1677
1678 match reply_rx.await {
1679 Ok(reply) => {
1680 let reply = match reply.body {
1681 HttpReplyBody::Bytes(b)
1682 if exceeds_max_response_body(b.len(), state.max_response_body) =>
1683 {
1684 HttpReply {
1685 status: 500,
1686 headers: vec![],
1687 body: HttpReplyBody::Bytes(bytes::Bytes::from(
1688 "Response body exceeds configured limit",
1689 )),
1690 }
1691 }
1692 _ => reply,
1693 };
1694
1695 let status =
1696 StatusCode::from_u16(reply.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1697 let mut builder = Response::builder().status(status);
1698 for (k, v) in &reply.headers {
1699 builder = builder.header(k.as_str(), v.as_str());
1700 }
1701 match reply.body {
1702 HttpReplyBody::Bytes(b) => {
1703 builder.body(AxumBody::from(b)).unwrap_or_else(|_| {
1704 Response::builder()
1705 .status(StatusCode::INTERNAL_SERVER_ERROR)
1706 .body(AxumBody::from("Invalid response headers from consumer"))
1707 .expect("infallible") })
1709 }
1710 HttpReplyBody::Stream(stream) => builder
1711 .body(AxumBody::from_stream(stream))
1712 .unwrap_or_else(|_| {
1713 Response::builder()
1714 .status(StatusCode::INTERNAL_SERVER_ERROR)
1715 .body(AxumBody::from("Invalid response headers from consumer"))
1716 .expect("infallible") }),
1718 }
1719 }
1720 Err(_) => Response::builder()
1721 .status(StatusCode::INTERNAL_SERVER_ERROR)
1722 .body(AxumBody::from("Pipeline error"))
1723 .expect("infallible"), }
1725 } else {
1726 static_dispatch::dispatch_static(&state, req, &path).await
1728 }
1729}
1730
1731fn exceeds_max_response_body(len: usize, max: usize) -> bool {
1732 len > max
1733}
1734
1735fn title_case_header(name: &str) -> String {
1736 name.split('-')
1737 .map(|part| {
1738 let mut chars = part.chars();
1739 match chars.next() {
1740 None => String::new(),
1741 Some(first) => first.to_uppercase().chain(chars.as_str().chars()).collect(),
1742 }
1743 })
1744 .collect::<Vec<_>>()
1745 .join("-")
1746}
1747
1748pub(crate) struct HttpKernelAuth {
1763 pub(crate) plan: camel_api::security_policy::RouteSecurityPlan,
1764 pub(crate) providers: Arc<camel_auth::ProviderRegistry>,
1765}
1766
1767impl HttpKernelAuth {
1768 pub(crate) fn from_security_context(
1773 ctx: &camel_component_api::SecurityContext,
1774 ) -> Option<Self> {
1775 Some(Self {
1776 plan: ctx.plan.clone()?,
1777 providers: ctx.providers.clone()?,
1778 })
1779 }
1780}
1781
1782fn envelope_channel_capacity(max_inflight_requests: usize) -> usize {
1796 max_inflight_requests.max(1)
1797}
1798
1799pub(crate) fn max_inflight_requests_limit(configured: usize) -> Result<usize, CamelError> {
1814 if configured > tokio::sync::Semaphore::MAX_PERMITS {
1815 return Err(CamelError::Config(format!(
1816 "maxInflightRequests {configured} exceeds the supported upper bound {} (tokio::sync::Semaphore::MAX_PERMITS)",
1817 tokio::sync::Semaphore::MAX_PERMITS
1818 )));
1819 }
1820 Ok(configured)
1821}
1822
1823pub struct HttpConsumer {
1824 config: HttpServerConfig,
1825 runtime: Arc<dyn RuntimeObservability>,
1827 kernel: Option<Arc<HttpKernelAuth>>,
1831}
1832
1833impl HttpConsumer {
1834 pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
1835 Self {
1836 config,
1837 runtime,
1838 kernel: None,
1839 }
1840 }
1841}
1842
1843#[async_trait::async_trait]
1844impl Consumer for HttpConsumer {
1845 async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
1846 use camel_component_api::{Body, Exchange, Message};
1847
1848 max_inflight_requests_limit(self.config.max_inflight_requests)?;
1853
1854 let registry = ServerRegistry::global()
1855 .get_or_spawn(
1856 &self.config.host,
1857 self.config.port,
1858 self.config.max_request_body,
1859 self.config.max_response_body,
1860 self.config.max_inflight_requests,
1861 self.runtime.clone(),
1862 ctx.route_id().to_string(),
1863 self.config.tls_config.clone(),
1864 )
1865 .await?;
1866
1867 let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(
1871 envelope_channel_capacity(self.config.max_inflight_requests),
1872 );
1873 if let Some(method) = self.config.method.clone() {
1880 let segments = rest_match::parse_path_template(&self.config.path);
1881 registry
1882 .register_rest_endpoint(method, segments, env_tx)
1883 .await;
1884 } else {
1885 registry
1886 .register_api_route(self.config.path.clone(), env_tx)
1887 .await;
1888 }
1889
1890 ctx.mark_ready();
1899
1900 let in_flight = ctx.in_flight_counter();
1904
1905 let path = self.config.path.clone();
1906 let registry_for_cleanup = registry.clone();
1907 let server_exited = registry.server_exited.clone();
1908 let cancel_token = ctx.cancel_token();
1909 let kernel = self.kernel.clone();
1910 let mut server_died = false;
1915 loop {
1916 tokio::select! {
1917 _ = ctx.cancelled() => {
1918 break;
1919 }
1920 _ = server_exited.cancelled() => {
1921 server_died = true;
1926 break;
1927 }
1928 envelope = env_rx.recv() => {
1929 let Some(envelope) = envelope else { break; };
1930
1931 let claim =
1939 in_flight.as_ref().map(camel_component_api::InFlightClaim::attach);
1940
1941 let mut msg = Message::default();
1943
1944 msg.set_header("CamelHttpMethod",
1946 serde_json::Value::String(envelope.method.clone()));
1947 msg.set_header("CamelHttpPath",
1948 serde_json::Value::String(envelope.path.clone()));
1949 msg.set_header("CamelHttpQuery",
1950 serde_json::Value::String(envelope.query.clone()));
1951
1952 for (param_name, param_value) in &envelope.path_params {
1959 msg.set_header(
1960 format!("CamelHttpPath_{param_name}"),
1961 serde_json::Value::String(param_value.clone()),
1962 );
1963 }
1964
1965 for (k, v) in &envelope.headers {
1967 if let Ok(val_str) = v.to_str() {
1968 msg.set_header(
1969 title_case_header(k.as_str()),
1970 serde_json::Value::String(val_str.to_string()),
1971 );
1972 }
1973 }
1974
1975 msg.body = Body::Stream(envelope.body);
1978
1979 #[allow(unused_mut)]
1980 let mut exchange = Exchange::new(msg);
1981
1982 #[cfg(feature = "otel")]
1984 {
1985 let headers: HashMap<String, String> = envelope
1986 .headers
1987 .iter()
1988 .filter_map(|(k, v)| {
1989 Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
1990 })
1991 .collect();
1992 camel_otel::extract_into_exchange(&mut exchange, &headers);
1993 }
1994
1995 let reply_tx = envelope.reply_tx;
1996 let sender = ctx.sender().clone();
1997 let path_clone = path.clone();
1998 let cancel = cancel_token.clone();
1999 let auth_headers = envelope.headers.clone();
2003 let auth_uri: http::Uri = {
2004 let full = if envelope.query.is_empty() {
2005 envelope.path.clone()
2006 } else {
2007 format!("{}?{}", envelope.path, envelope.query)
2008 };
2009 full.parse().unwrap_or_default()
2013 };
2014 let kernel = kernel.clone();
2015
2016 tokio::spawn(async move {
2036 if cancel.is_cancelled() {
2044 let _ = reply_tx.send(HttpReply {
2045 status: 503,
2046 headers: vec![],
2047 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
2048 });
2049 return;
2050 }
2051
2052 if let Some(kernel) = kernel.as_ref()
2061 && !matches!(
2062 kernel.plan.access_mode,
2063 camel_api::security_policy::AccessMode::Public
2064 )
2065 {
2066 let principal = match camel_auth::extract_token_multi(
2067 &auth_headers,
2068 &auth_uri,
2069 &kernel.plan.credential_sources,
2070 ) {
2071 Some(extracted) => {
2072 match camel_auth::kernel_authenticate(
2073 &kernel.plan,
2074 &kernel.providers,
2075 &extracted,
2076 )
2077 .await
2078 {
2079 Ok(principal) => principal,
2080 Err(e) => {
2081 tracing::warn!(
2083 path = %path_clone,
2084 error = %e,
2085 "HTTP request authentication failed"
2086 );
2087 let _ = reply_tx.send(pipeline_error_to_reply(
2088 e,
2089 &path_clone,
2090 ));
2091 return;
2092 }
2093 }
2094 }
2095 None => {
2096 tracing::warn!(
2098 path = %path_clone,
2099 "HTTP request rejected: no credential found in any source"
2100 );
2101 let _ = reply_tx.send(pipeline_error_to_reply(
2102 CamelError::Unauthenticated(
2103 "no credential found in any source".to_string(),
2104 ),
2105 &path_clone,
2106 ));
2107 return;
2108 }
2109 };
2110 camel_auth::install_carrier(&mut exchange, &principal);
2111 }
2112
2113 let (tx, rx) = tokio::sync::oneshot::channel();
2115 let envelope = camel_component_api::consumer::ExchangeEnvelope {
2116 exchange,
2117 reply_tx: Some(tx),
2118 in_flight_claim: claim,
2124 };
2125
2126 let result = match sender.send(envelope).await {
2127 Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
2128 Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
2129 }
2130 .and_then(|r| r);
2131
2132 let reply = match result {
2133 Ok(out) => {
2134 let status = out
2135 .input
2136 .header("CamelHttpResponseCode")
2137 .and_then(|v| {
2138 let raw = v.as_u64()
2139 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
2140 let code = raw as u16;
2141 (100..1000).contains(&code).then_some(code)
2142 })
2143 .unwrap_or(200);
2144
2145 let user_content_type = out
2146 .input
2147 .header("Content-Type")
2148 .and_then(|v| v.as_str().map(|s| s.to_string()));
2149
2150 let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
2151 Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
2152 Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
2153 Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
2154 Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
2155 v.to_string().into_bytes(),
2156 )), Some("application/json".to_string())),
2157 Body::Stream(s) => {
2158 let ct = s.metadata.content_type.clone();
2159 match s.stream.lock().await.take() {
2160 Some(stream) => (
2161 HttpReplyBody::Stream(stream),
2162 ct,
2163 ),
2164 None => {
2165 tracing::error!(
2167 "Body::Stream already consumed before HTTP reply — returning 500"
2168 );
2169 let error_reply = HttpReply {
2170 status: 500,
2171 headers: vec![],
2172 body: HttpReplyBody::Bytes(bytes::Bytes::new()),
2173 };
2174 if reply_tx.send(error_reply).is_err() {
2175 debug!("reply_tx dropped before error reply could be sent");
2176 }
2177 return;
2178 }
2179 }
2180 }
2181 _ => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
2183 };
2184
2185 let resp_headers = select_response_headers(
2186 &out.input.headers,
2187 user_content_type,
2188 inferred_content_type,
2189 );
2190
2191 HttpReply {
2192 status,
2193 headers: resp_headers,
2194 body: reply_body,
2195 }
2196 }
2197 Err(e) => {
2198 pipeline_error_to_reply(e, &path_clone)
2199 }
2200 };
2201
2202 let _ = reply_tx.send(reply);
2204 });
2205 }
2206 }
2207 }
2208
2209 if let Some(method) = &self.config.method {
2214 registry_for_cleanup
2215 .unregister_rest_endpoint(method, &path)
2216 .await;
2217 } else {
2218 registry_for_cleanup.unregister_api_route(&path).await;
2219 }
2220
2221 ServerRegistry::global()
2227 .unregister(&self.config.host, self.config.port)
2228 .await;
2229
2230 if server_died {
2231 tracing::error!(
2233 host = %camel_api::redact::redact_host(&self.config.host),
2234 port = self.config.port,
2235 path = %path,
2236 "Shared HTTP server exited — failing consumer to engage route supervision (ADR-0007)"
2237 );
2238 return Err(CamelError::RouteError(format!(
2242 "shared HTTP server for {}:{} exited unexpectedly; route transport is dead",
2243 camel_api::redact::redact_host(&self.config.host),
2244 self.config.port
2245 )));
2246 }
2247
2248 Ok(())
2249 }
2250
2251 async fn stop(&mut self) -> Result<(), CamelError> {
2252 Ok(())
2253 }
2254
2255 fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
2256 camel_component_api::ConcurrencyModel::Concurrent { max: None }
2257 }
2258
2259 fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
2266 camel_component_api::ConsumerStartupMode::Explicit
2267 }
2268
2269 fn set_security_context(&mut self, ctx: camel_component_api::SecurityContext) {
2272 self.kernel = HttpKernelAuth::from_security_context(&ctx).map(Arc::new);
2273 }
2274}
2275
2276pub struct HttpComponent {
2281 config: HttpConfig,
2282 pinned_cache: std::sync::Arc<PinnedClientCache>,
2283 client: reqwest::Client,
2284 strict_tls_error: Option<CamelError>,
2288}
2289
2290#[cfg(test)]
2291thread_local! {
2292 static BUILD_CLIENT_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
2293 static BUILD_CLIENT_FALLBACKS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
2294 static FORCE_WEBPKI_FALLBACK: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
2299 static FORCE_FALLBACK_REBUILD_FAIL: std::cell::Cell<bool> =
2307 const { std::cell::Cell::new(false) };
2308}
2309
2310fn client_builder(
2315 config: &HttpConfig,
2316 resolve_override: Option<(&str, &[std::net::SocketAddr])>,
2317) -> reqwest::ClientBuilder {
2318 let mut builder = reqwest::Client::builder()
2319 .no_proxy() .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
2321 .pool_max_idle_per_host(config.pool_max_idle_per_host)
2322 .pool_idle_timeout(Duration::from_millis(config.pool_idle_timeout_ms));
2323
2324 builder = builder.redirect(reqwest::redirect::Policy::none());
2328
2329 if let Some((host, addrs)) = resolve_override {
2330 builder = builder.resolve_to_addrs(host, addrs);
2331 }
2332
2333 if let Some(tls) = &config.tls
2334 && tls.enabled
2335 {
2336 if tls.insecure || !tls.verify_peer {
2337 tracing::warn!("HTTP TLS verification disabled — connections are vulnerable to MitM");
2339 builder = builder.danger_accept_invalid_certs(true);
2340 }
2341
2342 if let Some(ca_path) = &tls.ca_cert_path {
2343 match std::fs::read(ca_path) {
2348 Ok(ca_bytes) => {
2349 let pem_sections = rustls_pemfile::certs(&mut std::io::Cursor::new(&ca_bytes))
2356 .filter(|r| r.is_ok())
2357 .count();
2358 if pem_sections == 0 {
2359 tracing::warn!(
2361 "configured CA certificate contains no parseable PEM CERTIFICATE section — falling back to system roots"
2362 );
2363 }
2364 match reqwest::Certificate::from_pem(&ca_bytes)
2365 .or_else(|_| reqwest::Certificate::from_der(&ca_bytes))
2366 {
2367 Ok(ca_cert) => {
2368 builder = builder.add_root_certificate(ca_cert);
2369 }
2370 Err(e) => {
2371 tracing::warn!(
2373 error = %e,
2374 "configured CA certificate failed to parse — falling back to system roots"
2375 );
2376 }
2377 }
2378 }
2379 Err(e) => {
2380 tracing::warn!(
2382 error = %e,
2383 "configured CA certificate file unreadable — falling back to system roots"
2384 );
2385 }
2386 }
2387 }
2388
2389 if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path) {
2393 match (std::fs::read(cert_path), std::fs::read(key_path)) {
2394 (Ok(cert_bytes), Ok(key_bytes)) => {
2395 let mut identity_pem = cert_bytes;
2396 identity_pem.extend_from_slice(&key_bytes);
2397 match reqwest::Identity::from_pem(&identity_pem) {
2398 Ok(identity) => {
2399 builder = builder.identity(identity);
2400 }
2401 Err(e) => {
2402 tracing::warn!(
2404 error = %e,
2405 "configured mTLS identity failed to parse — client certificate NOT used"
2406 );
2407 }
2408 }
2409 }
2410 (cert_r, key_r) => {
2411 tracing::warn!(
2413 cert_ok = cert_r.is_ok(),
2414 key_ok = key_r.is_ok(),
2415 "configured mTLS cert/key file unreadable — client certificate NOT used"
2416 );
2417 }
2418 }
2419 }
2420 }
2421
2422 builder
2423}
2424
2425fn webpki_root_client_config() -> rustls::ClientConfig {
2440 let provider = rustls::crypto::CryptoProvider::get_default()
2445 .cloned()
2446 .unwrap_or_else(|| std::sync::Arc::new(rustls::crypto::aws_lc_rs::default_provider()));
2447 rustls::ClientConfig::builder_with_provider(provider)
2448 .with_safe_default_protocol_versions()
2449 .expect("stock rustls provider supports the safe default protocol versions") .with_root_certificates(mozilla_only())
2454 .with_no_client_auth()
2455 }
2460
2461fn fallback_root_store(
2475 custom_ca: Option<&str>,
2476 strict: bool,
2477) -> Result<rustls::RootCertStore, CamelError> {
2478 let mut store = rustls::RootCertStore::empty();
2485 let Some(ca_path) = custom_ca else {
2486 return Ok(mozilla_only());
2487 };
2488 let ca_bytes = match std::fs::read(ca_path) {
2489 Ok(bytes) => bytes,
2490 Err(e) => {
2491 if strict {
2492 return Err(CamelError::EndpointCreationFailed(format!(
2493 "tls.strict/webpki-fallback: configured CA certificate '{ca_path}' is unreadable: {e}"
2494 )));
2495 }
2496 tracing::warn!(
2498 error = %e,
2499 "configured CA certificate file unreadable — falling back to bundled Mozilla roots"
2500 );
2501 return Ok(mozilla_only());
2502 }
2503 };
2504 let certs: Vec<_> = rustls_pemfile::certs(&mut std::io::Cursor::new(&ca_bytes))
2505 .filter_map(|r| r.ok())
2506 .collect();
2507 if certs.is_empty() {
2508 if strict {
2509 return Err(CamelError::EndpointCreationFailed(format!(
2510 "tls.strict/webpki-fallback: configured CA certificate '{ca_path}' has no \
2511 parseable PEM CERTIFICATE section"
2512 )));
2513 }
2514 tracing::warn!(
2516 "configured CA certificate contains no parseable PEM CERTIFICATE section — \
2517 falling back to bundled Mozilla roots"
2518 );
2519 return Ok(mozilla_only());
2520 }
2521 let certs_len = certs.len();
2522 let (added, _ignored) = store.add_parsable_certificates(certs);
2523 if added == 0 {
2524 if strict {
2525 return Err(CamelError::EndpointCreationFailed(format!(
2526 "tls.strict/webpki-fallback: configured CA certificate '{ca_path}' was \
2527 rejected by the TLS root store (0 of {certs_len} accepted)"
2528 )));
2529 }
2530 tracing::warn!(
2532 "configured CA certificate was rejected by the TLS root store — \
2533 falling back to bundled Mozilla roots"
2534 );
2535 return Ok(mozilla_only());
2536 }
2537 store
2544 .roots
2545 .extend_from_slice(webpki_roots::TLS_SERVER_ROOTS);
2546 Ok(store)
2547}
2548
2549fn mozilla_only() -> rustls::RootCertStore {
2553 rustls::RootCertStore {
2554 roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
2555 }
2556}
2557
2558#[derive(Debug)]
2564struct NoVerifyServerCertVerifier;
2565
2566impl rustls::client::danger::ServerCertVerifier for NoVerifyServerCertVerifier {
2567 fn verify_server_cert(
2568 &self,
2569 _end_entity: &rustls::pki_types::CertificateDer<'_>,
2570 _intermediates: &[rustls::pki_types::CertificateDer<'_>],
2571 _server_name: &rustls::pki_types::ServerName<'_>,
2572 _ocsp_response: &[u8],
2573 _now: rustls::pki_types::UnixTime,
2574 ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
2575 Ok(rustls::client::danger::ServerCertVerified::assertion())
2577 }
2578
2579 fn verify_tls12_signature(
2580 &self,
2581 _message: &[u8],
2582 _cert: &rustls::pki_types::CertificateDer<'_>,
2583 _dss: &rustls::DigitallySignedStruct,
2584 ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
2585 Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
2586 }
2587
2588 fn verify_tls13_signature(
2589 &self,
2590 _message: &[u8],
2591 _cert: &rustls::pki_types::CertificateDer<'_>,
2592 _dss: &rustls::DigitallySignedStruct,
2593 ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
2594 Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
2595 }
2596
2597 fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
2598 vec![
2601 rustls::SignatureScheme::RSA_PKCS1_SHA1,
2602 rustls::SignatureScheme::ECDSA_SHA1_Legacy,
2603 rustls::SignatureScheme::RSA_PKCS1_SHA256,
2604 rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
2605 rustls::SignatureScheme::RSA_PKCS1_SHA384,
2606 rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
2607 rustls::SignatureScheme::RSA_PKCS1_SHA512,
2608 rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
2609 rustls::SignatureScheme::RSA_PSS_SHA256,
2610 rustls::SignatureScheme::RSA_PSS_SHA384,
2611 rustls::SignatureScheme::RSA_PSS_SHA512,
2612 rustls::SignatureScheme::ED25519,
2613 rustls::SignatureScheme::ED448,
2614 ]
2615 }
2616}
2617
2618fn fallback_client_config(
2627 tls: &crate::config::TlsConfig,
2628) -> Result<Option<rustls::ClientConfig>, CamelError> {
2629 if !tls.enabled {
2630 return Ok(None);
2631 }
2632 let material = tls.ca_cert_path.is_some()
2633 || tls.client_cert_path.is_some()
2634 || tls.client_key_path.is_some();
2635 let verification_disabled = tls.insecure || !tls.verify_peer;
2636 if !material && !verification_disabled {
2637 return Ok(None);
2638 }
2639 let provider = rustls::crypto::CryptoProvider::get_default()
2644 .cloned()
2645 .unwrap_or_else(|| std::sync::Arc::new(rustls::crypto::aws_lc_rs::default_provider()));
2646 let store = fallback_root_store(tls.ca_cert_path.as_deref(), tls.strict)?;
2650 let builder = rustls::ClientConfig::builder_with_provider(provider)
2651 .with_safe_default_protocol_versions()
2652 .expect("stock rustls provider supports the safe default protocol versions") ;
2657 let builder = if verification_disabled {
2658 builder
2659 .dangerous()
2660 .with_custom_certificate_verifier(std::sync::Arc::new(NoVerifyServerCertVerifier))
2661 } else {
2662 builder.with_root_certificates(store)
2663 };
2664 let config = match (&tls.client_cert_path, &tls.client_key_path) {
2665 (Some(cert_path), Some(key_path)) => {
2666 match (std::fs::read(cert_path), std::fs::read(key_path)) {
2667 (Ok(cert_bytes), Ok(key_bytes)) => {
2668 let chain: Vec<_> =
2669 rustls_pemfile::certs(&mut std::io::Cursor::new(&cert_bytes))
2670 .filter_map(|r| r.ok())
2671 .collect();
2672 if chain.is_empty() {
2673 if tls.strict {
2674 return Err(CamelError::EndpointCreationFailed(format!(
2675 "tls.strict/webpki-fallback: configured client certificate \
2676 chain '{cert_path}' has no parseable PEM CERTIFICATE section"
2677 )));
2678 }
2679 tracing::warn!(
2681 "configured client certificate chain has no parseable PEM \
2682 CERTIFICATE section — client certificate NOT used"
2683 );
2684 builder.with_no_client_auth()
2685 } else {
2686 match rustls_pemfile::private_key(&mut std::io::Cursor::new(&key_bytes)) {
2687 Ok(Some(key)) => {
2692 match builder.clone().with_client_auth_cert(chain, key) {
2693 Ok(config) => config,
2694 Err(e) => {
2695 if tls.strict {
2696 return Err(CamelError::EndpointCreationFailed(
2697 format!(
2698 "tls.strict/webpki-fallback: configured mTLS \
2699 identity was rejected by the TLS backend: {e}"
2700 ),
2701 ));
2702 }
2703 tracing::warn!(
2705 error = %e,
2706 "configured mTLS identity was rejected by the TLS \
2707 backend — client certificate NOT used"
2708 );
2709 builder.with_no_client_auth()
2710 }
2711 }
2712 }
2713 Ok(None) | Err(_) => {
2717 if tls.strict {
2718 return Err(CamelError::EndpointCreationFailed(format!(
2719 "tls.strict/webpki-fallback: configured client key \
2720 '{key_path}' has no parseable private key section"
2721 )));
2722 }
2723 tracing::warn!(
2725 "configured client key has no parseable private key \
2726 section — client certificate NOT used"
2727 );
2728 builder.with_no_client_auth()
2729 }
2730 }
2731 }
2732 }
2733 (cert_r, key_r) => {
2734 if tls.strict {
2735 return Err(CamelError::EndpointCreationFailed(
2736 "tls.strict/webpki-fallback: configured mTLS cert/key files are \
2737 unreadable"
2738 .to_string(),
2739 ));
2740 }
2741 tracing::warn!(
2743 cert_ok = cert_r.is_ok(),
2744 key_ok = key_r.is_ok(),
2745 "configured mTLS cert/key file unreadable — client certificate NOT used"
2746 );
2747 builder.with_no_client_auth()
2748 }
2749 }
2750 }
2751 (Some(_), None) | (None, Some(_)) => {
2754 if tls.strict {
2755 return Err(CamelError::EndpointCreationFailed(
2756 "tls.strict/webpki-fallback: mTLS requires BOTH client_cert_path and \
2757 client_key_path"
2758 .to_string(),
2759 ));
2760 }
2761 tracing::warn!(
2763 "mTLS requires BOTH client_cert_path and client_key_path — \
2764 client certificate NOT used"
2765 );
2766 builder.with_no_client_auth()
2767 }
2768 (None, None) => builder.with_no_client_auth(),
2769 };
2770 Ok(Some(config))
2771}
2772
2773fn emergency_webpki_client() -> reqwest::Client {
2779 reqwest::Client::builder()
2780 .no_proxy()
2781 .redirect(reqwest::redirect::Policy::none())
2782 .tls_backend_preconfigured(webpki_root_client_config())
2783 .build()
2784 .expect("preconfigured webpki-rooted client build has no fallible stage") }
2786
2787enum FallbackTrigger {
2792 Platform(reqwest::Error),
2793 #[cfg(test)]
2794 Forced,
2795}
2796
2797impl std::fmt::Display for FallbackTrigger {
2798 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2799 match self {
2800 FallbackTrigger::Platform(e) => std::fmt::Display::fmt(e, f),
2803 #[cfg(test)]
2804 FallbackTrigger::Forced => f.write_str("forced webpki fallback entry (test)"),
2805 }
2806 }
2807}
2808
2809fn fallback_rebuild_failed(
2815 config: &HttpConfig,
2816 failure: &dyn std::fmt::Display,
2817) -> Result<reqwest::Client, CamelError> {
2818 tracing::error!(
2820 error = %failure,
2821 "webpki fallback client build failed — TLS stack broken process-wide"
2822 );
2823 if config.tls.as_ref().is_some_and(|tls| tls.strict) {
2824 Err(CamelError::EndpointCreationFailed(format!(
2825 "tls.strict/webpki-fallback: webpki fallback client rebuild failed — \
2826 refusing material-free emergency client under tls.strict: {failure}"
2827 )))
2828 } else {
2829 Ok(emergency_webpki_client())
2830 }
2831}
2832
2833fn webpki_fallback_client(
2851 config: &HttpConfig,
2852 resolve_override: Option<(&str, &[std::net::SocketAddr])>,
2853 trigger: FallbackTrigger,
2854) -> Result<reqwest::Client, CamelError> {
2855 #[cfg(test)]
2856 BUILD_CLIENT_FALLBACKS.with(|c| c.set(c.get() + 1));
2857
2858 tracing::warn!(
2860 error = %trigger,
2861 "HTTP client build failed on platform TLS roots — the platform CA store \
2862 is missing or unreadable (typical on Android/Termux). Retrying with \
2863 bundled Mozilla root certificates; platform trust settings do not \
2864 apply to this client"
2865 );
2866
2867 let build_with_backend =
2879 |backend: rustls::ClientConfig| -> Result<reqwest::Client, CamelError> {
2880 match client_builder(config, resolve_override)
2881 .tls_backend_preconfigured(backend)
2882 .build()
2883 {
2884 Ok(client) => {
2885 #[cfg(test)]
2886 if FORCE_FALLBACK_REBUILD_FAIL.with(std::cell::Cell::get) {
2887 return fallback_rebuild_failed(
2888 config,
2889 &"forced webpki fallback rebuild failure (test)",
2890 );
2891 }
2892 Ok(client)
2893 }
2894 Err(second_error) => fallback_rebuild_failed(config, &second_error),
2895 }
2896 };
2897
2898 let material_configured = config.tls.as_ref().is_some_and(|tls| {
2903 tls.ca_cert_path.is_some()
2904 || tls.client_cert_path.is_some()
2905 || tls.client_key_path.is_some()
2906 });
2907
2908 match config.tls.as_ref().map(fallback_client_config) {
2909 None | Some(Ok(None)) => build_with_backend(webpki_root_client_config()),
2912 Some(Ok(Some(strict_or_parity_config))) => {
2913 if material_configured && config.tls.as_ref().is_some_and(|tls| tls.strict) {
2914 tracing::info!(
2916 "platform CA store unavailable — webpki fallback carries \
2917 configured TLS material"
2918 );
2919 }
2920 build_with_backend(strict_or_parity_config)
2921 }
2922 Some(Err(e)) => Err(e),
2924 }
2925}
2926
2927pub(crate) fn build_client(
2937 config: &HttpConfig,
2938 resolve_override: Option<(&str, &[std::net::SocketAddr])>,
2939) -> Result<reqwest::Client, CamelError> {
2940 #[cfg(test)]
2941 BUILD_CLIENT_CALLS.with(|c| c.set(c.get() + 1));
2942
2943 #[cfg(test)]
2950 if FORCE_WEBPKI_FALLBACK.with(std::cell::Cell::get) {
2951 return webpki_fallback_client(config, resolve_override, FallbackTrigger::Forced);
2952 }
2953
2954 match client_builder(config, resolve_override).build() {
2955 Ok(client) => Ok(client),
2956 Err(first_error) => webpki_fallback_client(
2957 config,
2958 resolve_override,
2959 FallbackTrigger::Platform(first_error),
2960 ),
2961 }
2962}
2963
2964fn strict_tls_error(config: &HttpConfig) -> Option<CamelError> {
2973 let tls = config.tls.as_ref()?;
2974 if !tls.enabled || !tls.strict {
2975 return None;
2976 }
2977 if let Some(ca_path) = &tls.ca_cert_path {
2978 match std::fs::read(ca_path) {
2979 Ok(ca_bytes) => {
2980 let pem_sections = rustls_pemfile::certs(&mut std::io::Cursor::new(&ca_bytes))
2992 .filter(|r| r.is_ok())
2993 .count();
2994 if pem_sections == 0 {
2995 return Some(CamelError::EndpointCreationFailed(format!(
2996 "tls.strict: configured CA certificate '{ca_path}' has no \
2997 parseable PEM CERTIFICATE section (DER bundles are not \
2998 enforced by the TLS backend — convert to PEM)"
2999 )));
3000 }
3001 }
3002 Err(e) => {
3003 return Some(CamelError::EndpointCreationFailed(format!(
3004 "tls.strict: configured CA certificate '{ca_path}' is unreadable: {e}"
3005 )));
3006 }
3007 }
3008 }
3009 if tls.client_cert_path.is_some() != tls.client_key_path.is_some() {
3013 return Some(CamelError::EndpointCreationFailed(
3014 "tls.strict: mTLS requires BOTH client_cert_path and client_key_path".to_string(),
3015 ));
3016 }
3017 if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path) {
3018 match (std::fs::read(cert_path), std::fs::read(key_path)) {
3019 (Ok(mut cert_bytes), Ok(key_bytes)) => {
3020 cert_bytes.extend_from_slice(&key_bytes);
3021 if reqwest::Identity::from_pem(&cert_bytes).is_err() {
3022 return Some(CamelError::EndpointCreationFailed(
3023 "tls.strict: configured mTLS identity failed to parse".to_string(),
3024 ));
3025 }
3026 }
3027 _ => {
3028 return Some(CamelError::EndpointCreationFailed(
3029 "tls.strict: configured mTLS cert/key files are unreadable".to_string(),
3030 ));
3031 }
3032 }
3033 }
3034 None
3035}
3036
3037#[cfg(test)]
3038pub(crate) fn build_client_call_count() -> u64 {
3039 BUILD_CLIENT_CALLS.with(|c| c.get())
3040}
3041
3042#[cfg(test)]
3043pub(crate) fn build_client_fallback_count() -> u64 {
3044 BUILD_CLIENT_FALLBACKS.with(|c| c.get())
3045}
3046
3047fn client_or_emergency(config: &HttpConfig) -> (reqwest::Client, Option<CamelError>) {
3052 match build_client(config, None) {
3053 Ok(c) => (c, None),
3054 Err(e) => (emergency_webpki_client(), Some(e)),
3055 }
3056}
3057
3058impl HttpComponent {
3059 pub fn new() -> Self {
3060 let config = HttpConfig::default();
3061 let strict_err = strict_tls_error(&config);
3062 let (client, build_err) = client_or_emergency(&config);
3063 Self {
3064 client,
3065 config,
3066 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
3067 PINNED_CLIENT_TTL,
3068 PINNED_CLIENT_MAX_ENTRIES,
3069 )),
3070 strict_tls_error: strict_err.or(build_err),
3071 }
3072 }
3073
3074 pub fn with_config(config: HttpConfig) -> Self {
3075 let strict_err = strict_tls_error(&config);
3076 let (client, build_err) = client_or_emergency(&config);
3077 Self {
3078 client,
3079 config,
3080 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
3081 PINNED_CLIENT_TTL,
3082 PINNED_CLIENT_MAX_ENTRIES,
3083 )),
3084 strict_tls_error: strict_err.or(build_err),
3085 }
3086 }
3087
3088 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
3089 match config {
3090 Some(cfg) => Self::with_config(cfg),
3091 None => Self::new(),
3092 }
3093 }
3094}
3095
3096impl Default for HttpComponent {
3097 fn default() -> Self {
3098 Self::new()
3099 }
3100}
3101
3102impl Component for HttpComponent {
3103 fn scheme(&self) -> &str {
3104 "http"
3105 }
3106
3107 fn metadata(&self) -> ComponentMetadata {
3108 HttpEndpointConfig::metadata()
3109 }
3110
3111 fn create_endpoint(
3112 &self,
3113 uri: &str,
3114 ctx: &dyn camel_component_api::ComponentContext,
3115 ) -> Result<Box<dyn Endpoint>, CamelError> {
3116 if let Some(err) = &self.strict_tls_error {
3117 return Err(err.clone());
3118 }
3119 self.config.validate()?;
3120 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
3121 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
3122 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
3123 server_config.host.clone(),
3124 server_config.port,
3125 )));
3126 self.pinned_cache
3127 .wire(HttpComponentKind::Http, ctx.metrics());
3128 Ok(Box::new(HttpEndpoint {
3129 uri: uri.to_string(),
3130 config,
3131 server_config,
3132 client: self.client.clone(),
3133 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
3134 http_config: self.config.clone(),
3135 }))
3136 }
3137}
3138
3139pub struct HttpsComponent {
3140 config: HttpConfig,
3141 pinned_cache: std::sync::Arc<PinnedClientCache>,
3142 client: reqwest::Client,
3143 strict_tls_error: Option<CamelError>,
3147}
3148
3149impl HttpsComponent {
3150 pub fn new() -> Self {
3151 let config = HttpConfig::default();
3152 let strict_err = strict_tls_error(&config);
3153 let (client, build_err) = client_or_emergency(&config);
3154 Self {
3155 client,
3156 config,
3157 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
3158 PINNED_CLIENT_TTL,
3159 PINNED_CLIENT_MAX_ENTRIES,
3160 )),
3161 strict_tls_error: strict_err.or(build_err),
3162 }
3163 }
3164
3165 pub fn with_config(config: HttpConfig) -> Self {
3166 let strict_err = strict_tls_error(&config);
3167 let (client, build_err) = client_or_emergency(&config);
3168 Self {
3169 client,
3170 config,
3171 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
3172 PINNED_CLIENT_TTL,
3173 PINNED_CLIENT_MAX_ENTRIES,
3174 )),
3175 strict_tls_error: strict_err.or(build_err),
3176 }
3177 }
3178
3179 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
3180 match config {
3181 Some(cfg) => Self::with_config(cfg),
3182 None => Self::new(),
3183 }
3184 }
3185}
3186
3187impl Default for HttpsComponent {
3188 fn default() -> Self {
3189 Self::new()
3190 }
3191}
3192
3193impl Component for HttpsComponent {
3194 fn scheme(&self) -> &str {
3195 "https"
3196 }
3197
3198 fn metadata(&self) -> ComponentMetadata {
3199 let mut meta = HttpEndpointConfig::metadata();
3202 meta.scheme = "https".to_string();
3203 meta.description = "HTTPS client and server component (TLS over HTTP)".to_string();
3204 meta
3205 }
3206
3207 fn create_endpoint(
3208 &self,
3209 uri: &str,
3210 ctx: &dyn camel_component_api::ComponentContext,
3211 ) -> Result<Box<dyn Endpoint>, CamelError> {
3212 if let Some(err) = &self.strict_tls_error {
3213 return Err(err.clone());
3214 }
3215 self.config.validate()?;
3216 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
3217 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
3218 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
3219 server_config.host.clone(),
3220 server_config.port,
3221 )));
3222 self.pinned_cache
3223 .wire(HttpComponentKind::Https, ctx.metrics());
3224 Ok(Box::new(HttpEndpoint {
3225 uri: uri.to_string(),
3226 config,
3227 server_config,
3228 client: self.client.clone(),
3229 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
3230 http_config: self.config.clone(),
3231 }))
3232 }
3233}
3234
3235struct HttpEndpoint {
3240 uri: String,
3241 config: HttpEndpointConfig,
3242 server_config: HttpServerConfig,
3243 client: reqwest::Client,
3244 pinned_cache: std::sync::Arc<PinnedClientCache>,
3245 http_config: HttpConfig,
3246}
3247
3248impl Endpoint for HttpEndpoint {
3249 fn uri(&self) -> &str {
3250 &self.uri
3251 }
3252
3253 fn create_consumer(
3254 &self,
3255 rt: Arc<dyn camel_component_api::RuntimeObservability>,
3256 ) -> Result<Box<dyn Consumer>, CamelError> {
3257 let scheme_is_https = self.server_config.scheme == "https";
3260 let has_tls = self.server_config.tls_config.is_some();
3261
3262 if scheme_is_https && !has_tls {
3263 return Err(CamelError::EndpointCreationFailed(
3264 "https:// consumer requires tlsCert and tlsKey parameters".to_string(),
3265 ));
3266 }
3267 if !scheme_is_https && has_tls {
3268 return Err(CamelError::EndpointCreationFailed(
3269 "http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
3270 ));
3271 }
3272 max_inflight_requests_limit(self.server_config.max_inflight_requests)?;
3277 Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
3278 }
3279
3280 fn create_producer(
3281 &self,
3282 rt: Arc<dyn camel_component_api::RuntimeObservability>,
3283 _ctx: &ProducerContext,
3284 ) -> Result<BoxProcessor, CamelError> {
3285 let producer = HttpProducer {
3286 config: Arc::new(self.config.clone()),
3287 client: self.client.clone(),
3288 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
3289 http_config: Arc::new(self.http_config.clone()),
3290 runtime: rt,
3291 };
3292 if let Some(ref provider) = self.config.token_provider {
3293 let layer = BearerTokenLayer::new(Arc::clone(provider));
3294 Ok(BoxProcessor::new(layer.layer(producer)))
3295 } else {
3296 Ok(BoxProcessor::new(producer))
3297 }
3298 }
3299}
3300
3301#[derive(Clone)]
3306struct HttpProducer {
3307 config: Arc<HttpEndpointConfig>,
3308 client: reqwest::Client,
3309 pinned_cache: std::sync::Arc<PinnedClientCache>,
3310 http_config: Arc<HttpConfig>,
3311 runtime: Arc<dyn RuntimeObservability>,
3317}
3318
3319impl HttpProducer {
3320 fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
3321 if let Some(ref method) = config.http_method {
3322 return method.to_uppercase();
3323 }
3324 if let Some(method) = exchange
3325 .input
3326 .header("CamelHttpMethod")
3327 .and_then(|v| v.as_str())
3328 {
3329 return method.to_uppercase();
3330 }
3331 if !exchange.input.body.is_empty() {
3332 return "POST".to_string();
3333 }
3334 "GET".to_string()
3335 }
3336
3337 fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> Result<String, CamelError> {
3338 if config.bridge_endpoint {
3347 let Some(query) = resolve_endpoint_query(config)? else {
3348 return Ok(config.base_url.clone());
3349 };
3350 let _: url::Url = url::Url::parse(&config.base_url).map_err(|e| {
3358 CamelError::ProcessorError(format!(
3359 "invalid base URL '{}': {e}",
3360 redact_url_for_diagnostics(&config.base_url)
3361 ))
3362 })?;
3363 let mut url = config.base_url.clone();
3364 url.push('?');
3365 url.push_str(&query);
3366 return Ok(url);
3367 }
3368
3369 if let Some(uri) = exchange
3370 .input
3371 .header("CamelHttpUri")
3372 .and_then(|v| v.as_str())
3373 {
3374 if let Some(fence) = &config.allowed_uri_hosts
3379 && !uri_host_allowed(uri, fence)?
3380 {
3381 return Err(CamelError::ProcessorError(format!(
3382 "CamelHttpUri host not allowed by allowedUriHosts fence: {}",
3383 redact_url_for_diagnostics(uri)
3384 )));
3385 }
3386 let (base, override_query) = match uri.split_once('?') {
3393 Some((base, query)) => (base, Some(query)),
3394 None => (uri, None),
3395 };
3396 if let Some(query) = override_query {
3402 for (_key, span) in raw_query_pairs(query)? {
3403 validate_raw_query_span(span)?;
3404 }
3405 }
3406 let mut url = base.to_string();
3407 if let Some(path) = exchange
3408 .input
3409 .header("CamelHttpPath")
3410 .and_then(|v| v.as_str())
3411 {
3412 if !url.ends_with('/') && !path.starts_with('/') {
3413 url.push('/');
3414 }
3415 url.push_str(path);
3416 }
3417 if let Some(query) = exchange
3418 .input
3419 .header("CamelHttpQuery")
3420 .and_then(|v| v.as_str())
3421 {
3422 if let Some(merged) = merge_header_query(override_query, query)? {
3423 url.push('?');
3424 url.push_str(&merged);
3425 }
3426 return Ok(url);
3427 }
3428 if let Some(query) = override_query {
3429 url.push('?');
3430 url.push_str(query);
3431 }
3432 return Ok(url);
3433 }
3434
3435 let mut url = config.base_url.clone();
3436
3437 if let Some(path) = exchange
3438 .input
3439 .header("CamelHttpPath")
3440 .and_then(|v| v.as_str())
3441 {
3442 if !url.ends_with('/') && !path.starts_with('/') {
3443 url.push('/');
3444 }
3445 url.push_str(path);
3446 }
3447
3448 if let Some(query) = exchange
3449 .input
3450 .header("CamelHttpQuery")
3451 .and_then(|v| v.as_str())
3452 {
3453 if let Some(merged) =
3458 merge_header_query(resolve_endpoint_query(config)?.as_deref(), query)?
3459 {
3460 url.push('?');
3461 url.push_str(&merged);
3462 }
3463 return Ok(url);
3464 }
3465
3466 if let Some(query) = resolve_endpoint_query(config)? {
3467 url.push('?');
3468 url.push_str(&query);
3469 }
3470
3471 Ok(url)
3472 }
3473
3474 fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
3475 status >= range.0 && status <= range.1
3476 }
3477}
3478
3479#[derive(Clone, Debug, PartialEq, Eq)]
3484pub struct AllowedUriHost {
3485 pub host: String,
3487 pub port: Option<u16>,
3489}
3490
3491fn parse_allowed_uri_hosts(raw: &str) -> Result<Vec<AllowedUriHost>, CamelError> {
3499 let mut entries = Vec::new();
3500 for segment in raw.split(',') {
3501 let segment = segment.trim();
3502 if segment.is_empty() {
3503 continue;
3504 }
3505 let parsed = url::Url::parse(&format!("http://{segment}"))
3506 .map_err(|_| invalid_allowed_uri_host_entry(segment))?;
3507 if parsed.path() != "/" || !parsed.username().is_empty() || parsed.password().is_some() {
3511 return Err(invalid_allowed_uri_host_entry(segment));
3512 }
3513 let Some(host) = parsed.host_str() else {
3514 return Err(invalid_allowed_uri_host_entry(segment));
3515 };
3516 entries.push(AllowedUriHost {
3517 host: host.to_string(),
3518 port: parsed.port(),
3519 });
3520 }
3521 if entries.is_empty() {
3522 return Err(CamelError::InvalidUri(
3523 "allowedUriHosts declares no valid host entries".to_string(),
3524 ));
3525 }
3526 Ok(entries)
3527}
3528
3529fn invalid_allowed_uri_host_entry(segment: &str) -> CamelError {
3530 CamelError::InvalidUri(format!("invalid allowedUriHosts entry '{segment}'"))
3531}
3532
3533pub(crate) fn uri_host_allowed(
3540 url_str: &str,
3541 fence: &[AllowedUriHost],
3542) -> Result<bool, CamelError> {
3543 let Ok(parsed) = url::Url::parse(url_str) else {
3544 return Ok(false);
3545 };
3546 let Some(host) = parsed.host_str() else {
3547 return Ok(false);
3548 };
3549 let effective_port = parsed.port().or(match parsed.scheme() {
3550 "https" => Some(443_u16),
3551 "http" => Some(80),
3552 _ => None,
3553 });
3554 Ok(fence.iter().any(|entry| {
3555 entry.host == host
3556 && match entry.port {
3557 None => true,
3558 Some(port) => effective_port == Some(port),
3559 }
3560 }))
3561}
3562
3563fn resolve_endpoint_query(config: &HttpEndpointConfig) -> Result<Option<String>, CamelError> {
3576 let mut parts: Vec<String> = Vec::new();
3577 let mut authored_keys = std::collections::HashSet::new();
3578
3579 if let Some(raw) = config.raw_query.as_deref() {
3580 for (key, span) in raw_query_pairs(raw)? {
3581 authored_keys.insert(key.clone());
3582 if is_consumed_option(&key) {
3583 continue;
3584 }
3585 validate_raw_query_span(span)?;
3586 parts.push(span.to_string());
3587 }
3588 }
3589
3590 for (key, value) in &config.query_params {
3591 if !authored_keys.contains(key.as_str()) {
3592 parts.push(format!(
3593 "{}={}",
3594 encode_query_component(key),
3595 encode_query_component(value)
3596 ));
3597 }
3598 }
3599
3600 if parts.is_empty() && config.raw_query.as_deref() != Some("") {
3601 return Ok(None);
3602 }
3603 Ok(Some(parts.join("&")))
3604}
3605
3606fn merge_header_query(
3615 higher_precedence: Option<&str>,
3616 header_query: &str,
3617) -> Result<Option<String>, CamelError> {
3618 if header_query.is_empty() {
3619 return Ok(higher_precedence.map(str::to_string));
3620 }
3621 let mut parts: Vec<String> = Vec::new();
3622 let mut higher_keys = std::collections::HashSet::new();
3623 for (key, span) in raw_query_pairs(higher_precedence.unwrap_or(""))? {
3624 higher_keys.insert(key);
3625 parts.push(span.to_string());
3626 }
3627 for (key, span) in raw_query_pairs(header_query)? {
3628 validate_raw_query_span(span)?;
3629 if !higher_keys.contains(key.as_str()) {
3630 parts.push(span.to_string());
3631 }
3632 }
3633 if parts.is_empty() {
3634 return Ok(None);
3635 }
3636 Ok(Some(parts.join("&")))
3637}
3638
3639fn is_legal_query_byte(byte: u8) -> bool {
3650 matches!(byte,
3651 b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z'
3652 | b'-' | b'.' | b'_' | b'~'
3653 | b'!' | b'$' | b'&' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'='
3654 | b':' | b'@' | b'/' | b'?'
3655 | b'%')
3656}
3657
3658fn validate_raw_query_span(span: &str) -> Result<(), CamelError> {
3664 for &byte in span.as_bytes() {
3665 if !is_legal_query_byte(byte) {
3666 return Err(CamelError::ProcessorError(format!(
3667 "raw query pair '{span}' contains byte 0x{byte:02X}, which is not legal in a URL query component"
3668 )));
3669 }
3670 }
3671 Ok(())
3672}
3673
3674fn encode_query_component(component: &str) -> String {
3678 const HEX: &[u8; 16] = b"0123456789ABCDEF";
3679 let mut out = String::with_capacity(component.len());
3680 for &byte in component.as_bytes() {
3681 match byte {
3682 b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z' | b'-' | b'.' | b'_' | b'~' => {
3683 out.push(byte as char);
3684 }
3685 _ => {
3686 out.push('%');
3687 out.push(HEX[(byte >> 4) as usize] as char);
3688 out.push(HEX[(byte & 0x0f) as usize] as char);
3689 }
3690 }
3691 }
3692 out
3693}
3694
3695pub(crate) fn redact_url_for_diagnostics(raw: &str) -> String {
3723 match url::Url::parse(raw) {
3724 Ok(mut u) => {
3725 if !u.cannot_be_a_base()
3732 && u.host_str().is_none()
3733 && camel_api::redact::window_has_at_sign(raw)
3734 {
3735 return "[redacted]".to_string();
3736 }
3737 if !u.username().is_empty() || u.password().is_some() {
3738 let _ = u.set_username("***");
3739 let _ = u.set_password(None);
3740 }
3741 let s = u.to_string();
3744 camel_api::redact::redact_url(&s)
3745 }
3746 Err(_) => camel_api::redact::redact_url_fail_closed(raw),
3747 }
3748}
3749
3750const MAX_ERROR_RESPONSE_BODY_BYTES: usize = 4096;
3755
3756fn truncate_error_body(body: &[u8]) -> String {
3757 if body.len() <= MAX_ERROR_RESPONSE_BODY_BYTES {
3758 String::from_utf8_lossy(body).into_owned()
3759 } else {
3760 let mut s = String::from_utf8_lossy(&body[..MAX_ERROR_RESPONSE_BODY_BYTES]).into_owned();
3761 s.push_str("...[truncated]");
3762 s
3763 }
3764}
3765
3766impl HttpProducer {
3767 fn is_entity_enclosing(method: &str) -> bool {
3772 matches!(method, "POST" | "PUT" | "PATCH")
3773 }
3774}
3775
3776impl Service<Exchange> for HttpProducer {
3777 type Response = Exchange;
3778 type Error = CamelError;
3779 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
3780
3781 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
3782 Poll::Ready(Ok(()))
3783 }
3784
3785 fn call(&mut self, exchange: Exchange) -> Self::Future {
3786 let config = self.config.clone();
3787 let shared_client = self.client.clone();
3788 let pinned_cache = std::sync::Arc::clone(&self.pinned_cache);
3789 let http_config = self.http_config.clone();
3790 let component_metrics = self.runtime.component_metrics();
3791
3792 Box::pin(async move {
3793 let mut exchange = exchange;
3794 let outcome = async {
3795 let method_str = HttpProducer::resolve_method(&exchange, &config);
3796 let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
3801 let url = HttpProducer::resolve_url(&exchange, &config)?;
3802
3803 ssrf::validate_url_for_ssrf(&url, &config)?;
3805
3806 let resolved = ssrf::resolve_initial_url_for_ssrf(
3814 &url,
3815 config.allow_internal,
3816 config.allow_cleartext,
3817 )
3818 .await?;
3819 let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
3820 pinned_cache
3824 .get_or_build(host.as_str(), addrs, || {
3825 build_client(&http_config, Some((host.as_str(), addrs)))
3826 })
3827 .await?
3828 } else {
3829 shared_client.clone()
3830 };
3831
3832 debug!(
3833 correlation_id = %exchange.correlation_id(),
3834 method = %method_str,
3835 url = %redact_url_for_diagnostics(&url),
3836 "HTTP request"
3837 );
3838
3839 let method = method_str.parse::<reqwest::Method>().map_err(|e| {
3840 CamelError::ProcessorError(format!(
3841 "Invalid HTTP method '{}': {}",
3842 method_str, e
3843 ))
3844 })?;
3845
3846 let mut collected_headers: Vec<(
3848 reqwest::header::HeaderName,
3849 reqwest::header::HeaderValue,
3850 )> = Vec::new();
3851
3852 if let Some(user_agent) = &config.user_agent
3853 && !config.bridge_endpoint
3854 {
3855 match constructed_header("user-agent", user_agent) {
3856 Ok((_, val)) => {
3857 collected_headers.push((reqwest::header::USER_AGENT, val));
3858 }
3859 Err(drop) => debug!(
3860 correlation_id = %exchange.correlation_id(),
3861 header = %drop.name,
3862 "outbound header dropped: {}",
3863 drop.reason
3864 ),
3865 }
3866 }
3867
3868 #[cfg(feature = "otel")]
3870 let should_inject_otel = !config.bridge_endpoint;
3871 #[cfg(feature = "otel")]
3872 if should_inject_otel {
3873 let mut otel_headers = HashMap::new();
3874 camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
3875 for (k, v) in otel_headers {
3876 match constructed_header(&k, &v) {
3877 Ok((name, val)) => collected_headers.push((name, val)),
3878 Err(drop) => debug!(
3879 correlation_id = %exchange.correlation_id(),
3880 header = %drop.name,
3881 "outbound header dropped: {}",
3882 drop.reason
3883 ),
3884 }
3885 }
3886 }
3887
3888 let conn_tokens = header_policy::connection_tokens(
3889 exchange
3890 .input
3891 .headers
3892 .iter()
3893 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3894 .filter_map(|(_, v)| v.as_str()),
3895 );
3896
3897 let outbound = select_outbound_headers(
3898 &exchange.input.headers,
3899 &config.skip_request_headers,
3900 &conn_tokens,
3901 );
3902 for drop in &outbound.drops {
3903 if let Some(value_kind) = drop.value_kind {
3904 debug!(
3905 correlation_id = %exchange.correlation_id(),
3906 header = %drop.name,
3907 value_kind = value_kind,
3908 "outbound header dropped: {}",
3909 drop.reason
3910 );
3911 } else {
3912 debug!(
3913 correlation_id = %exchange.correlation_id(),
3914 header = %drop.name,
3915 "outbound header dropped: {}",
3916 drop.reason
3917 );
3918 }
3919 }
3920 collected_headers.extend(outbound.accepted);
3921
3922 if !config.bridge_endpoint {
3924 match &config.auth {
3925 HttpAuth::None => {}
3926 HttpAuth::Basic { username, password } => {
3927 use base64::Engine;
3928 let credentials = format!("{username}:{password}");
3930 let encoded =
3931 base64::engine::general_purpose::STANDARD.encode(credentials);
3932 match constructed_header("authorization", &format!("Basic {encoded}")) {
3935 Ok((_, val)) => {
3936 collected_headers.push((reqwest::header::AUTHORIZATION, val));
3937 }
3938 Err(drop) => debug!(
3939 correlation_id = %exchange.correlation_id(),
3940 header = %drop.name,
3941 "outbound header dropped: {}",
3942 drop.reason
3943 ),
3944 }
3945 }
3946 HttpAuth::Bearer { token } => {
3947 let bearer = format!("Bearer {token}");
3949 match constructed_header("authorization", &bearer) {
3950 Ok((_, val)) => {
3951 collected_headers.push((reqwest::header::AUTHORIZATION, val));
3952 }
3953 Err(drop) => debug!(
3954 correlation_id = %exchange.correlation_id(),
3955 header = %drop.name,
3956 "outbound header dropped: {}",
3957 drop.reason
3958 ),
3959 }
3960 }
3961 }
3962
3963 if config.connection_close {
3964 collected_headers.push((
3965 reqwest::header::CONNECTION,
3966 reqwest::header::HeaderValue::from_static("close"),
3967 ));
3968 }
3969 }
3970
3971 let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
3973 let materialized_body: Option<Vec<u8>> = if is_stream_body {
3974 if suppress_body {
3975 std::mem::take(&mut exchange.input.body);
3983 tracing::warn!(
3985 correlation_id = %exchange.correlation_id(),
3986 method = %method_str,
3987 "dropping request body for non-entity-enclosing HTTP method"
3988 );
3989 }
3990 None } else {
3992 let body = std::mem::take(&mut exchange.input.body);
3993 let bytes = body.into_bytes(config.max_body_size).await?;
3994 if bytes.is_empty() {
3995 None
3997 } else if suppress_body {
3998 tracing::warn!(
4000 correlation_id = %exchange.correlation_id(),
4001 method = %method_str,
4002 "dropping request body for non-entity-enclosing HTTP method"
4003 );
4004 None
4005 } else {
4006 Some(bytes.to_vec())
4007 }
4008 };
4009
4010 let response = if config.follow_redirects && !is_stream_body {
4011 ssrf::send_with_ssrf_safe_redirects(
4017 &client,
4018 &shared_client,
4019 &pinned_cache,
4020 &http_config,
4021 &config,
4022 method,
4023 &url,
4024 collected_headers,
4025 materialized_body,
4026 config.max_redirects,
4027 config.response_timeout,
4028 )
4029 .await?
4030 } else {
4031 let mut request = client.request(method, &url);
4033
4034 if let Some(timeout) = config.response_timeout {
4035 request = request.timeout(timeout);
4036 }
4037
4038 for (name, value) in &collected_headers {
4039 request = request.header(name, value);
4040 }
4041
4042 if is_stream_body {
4043 if let Body::Stream(ref s) = exchange.input.body {
4044 let mut stream_lock = s.stream.lock().await;
4045 if let Some(stream) = stream_lock.take() {
4046 request = request.body(reqwest::Body::wrap_stream(stream));
4047 } else {
4048 return Err(CamelError::AlreadyConsumed);
4049 }
4050 }
4051 } else if let Some(ref body_bytes) = materialized_body {
4052 request = request.body(body_bytes.clone());
4053 }
4054
4055 request.send().await.map_err(|e| {
4056 CamelError::ProcessorError(format!("HTTP request failed: {e}"))
4057 })?
4058 };
4059
4060 let status_code = response.status().as_u16();
4061 let status_text = response
4062 .status()
4063 .canonical_reason()
4064 .unwrap_or("Unknown")
4065 .to_string();
4066
4067 for (key, value) in response.headers() {
4068 if config
4069 .skip_response_headers
4070 .iter()
4071 .any(|h| h.eq_ignore_ascii_case(key.as_str()))
4072 {
4073 continue;
4074 }
4075 if let Ok(val_str) = value.to_str() {
4076 exchange.input.set_header(
4077 title_case_header(key.as_str()),
4078 serde_json::Value::String(val_str.to_string()),
4079 );
4080 }
4081 }
4082
4083 exchange.input.set_header(
4084 "CamelHttpResponseCode",
4085 serde_json::Value::Number(status_code.into()),
4086 );
4087 exchange.input.set_header(
4088 "CamelHttpResponseText",
4089 serde_json::Value::String(status_text.clone()),
4090 );
4091
4092 let read_timeout = Duration::from_millis(config.read_timeout_ms);
4094 let response_body = tokio::time::timeout(read_timeout, async {
4095 if let Some(content_len) = response.content_length()
4097 && content_len > config.max_response_bytes as u64
4098 {
4099 return Err(CamelError::ProcessorError(format!(
4100 "Response body too large: {} bytes exceeds limit of {} bytes",
4101 content_len, config.max_response_bytes
4102 )));
4103 }
4104 use futures::TryStreamExt;
4106 let mut stream = response.bytes_stream();
4107 let mut total: usize = 0;
4108 let mut collected = Vec::new();
4109 while let Some(chunk) = stream.try_next().await.map_err(|e| {
4110 CamelError::ProcessorError(format!("Failed to read response body: {e}"))
4111 })? {
4112 total += chunk.len();
4113 if total > config.max_response_bytes {
4114 return Err(CamelError::ProcessorError(format!(
4115 "Response body too large: {} bytes exceeds limit of {} bytes",
4116 total, config.max_response_bytes
4117 )));
4118 }
4119 collected.push(chunk);
4120 }
4121 let mut result = bytes::BytesMut::with_capacity(total);
4122 for chunk in collected {
4123 result.extend_from_slice(&chunk);
4124 }
4125 Ok::<bytes::Bytes, CamelError>(result.freeze())
4126 })
4127 .await
4128 .map_err(|_| {
4129 CamelError::ProcessorError(format!(
4130 "Read timeout after {}ms",
4131 config.read_timeout_ms
4132 ))
4133 })??;
4134
4135 if config.throw_exception_on_failure
4136 && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
4137 {
4138 return Err(CamelError::HttpOperationFailed {
4139 method: method_str,
4140 url: redact_url_for_diagnostics(&url),
4143 status_code,
4144 status_text,
4145 response_body: Some(truncate_error_body(&response_body)),
4146 });
4147 }
4148
4149 if !response_body.is_empty() {
4150 exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
4151 }
4152
4153 debug!(
4154 correlation_id = %exchange.correlation_id(),
4155 status = status_code,
4156 url = %redact_url_for_diagnostics(&url),
4157 "HTTP response"
4158 );
4159 Ok(exchange)
4160 }
4161 .await;
4162 component_metrics.observe("http", "request", outcome.is_err());
4169 outcome
4170 })
4171 }
4172}
4173
4174#[cfg(test)]
4188pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
4189
4190#[cfg(test)]
4198pub(crate) fn lock_registry_test_mutex() -> std::sync::MutexGuard<'static, ()> {
4199 REGISTRY_TEST_MUTEX
4200 .lock()
4201 .unwrap_or_else(|poisoned| poisoned.into_inner())
4202}
4203
4204#[cfg(test)]
4208static CA_STORE_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
4209
4210#[cfg(test)]
4211pub(crate) fn lock_ca_store_test_mutex() -> std::sync::MutexGuard<'static, ()> {
4212 CA_STORE_TEST_MUTEX
4213 .lock()
4214 .unwrap_or_else(|poisoned| poisoned.into_inner())
4215}
4216
4217fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
4227 match e {
4228 CamelError::Unauthenticated(msg) => {
4229 tracing::warn!(error = %msg, path = %path, "Authentication failed");
4230 HttpReply {
4231 status: 401,
4232 headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
4233 body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
4234 }
4235 }
4236 CamelError::Unauthorized(msg) => {
4237 tracing::warn!(error = %msg, path = %path, "Authorization failed");
4238 HttpReply {
4239 status: 403,
4240 headers: vec![],
4241 body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
4242 }
4243 }
4244 CamelError::TypeConversionFailed(msg) => {
4245 tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
4246 json_error_reply(400, "bad_request", msg)
4247 }
4248 CamelError::ValidationError(msg) => {
4249 tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
4250 json_error_reply(400, "validation_error", msg)
4251 }
4252 CamelError::ConsumerStopping => {
4253 tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
4254 HttpReply {
4255 status: 503,
4256 headers: vec![],
4257 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
4258 }
4259 }
4260 CamelError::UnsupportedMediaType { consumed, declared } => {
4261 tracing::warn!(error = %consumed, declared = %declared, path = %path, "Unsupported media type (bad request)");
4262 json_error_reply(
4263 415,
4264 "unsupported_media_type",
4265 format!("consumed {consumed}, declared {declared}"),
4266 )
4267 }
4268 CamelError::NotAcceptable { accept, produced } => {
4269 tracing::warn!(error = %accept, produced = %produced, path = %path, "Not acceptable (bad request)");
4270 json_error_reply(
4271 406,
4272 "not_acceptable",
4273 format!("accept {accept}, produced {produced}"),
4274 )
4275 }
4276 e => {
4277 tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
4279 HttpReply {
4280 status: 500,
4281 headers: vec![],
4282 body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
4283 }
4284 }
4285 }
4286}
4287
4288fn json_error_reply(status: u16, code: &str, message: String) -> HttpReply {
4296 let body = serde_json::to_string(&serde_json::json!({
4297 "error": code,
4298 "message": message,
4299 }))
4300 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
4302 status,
4303 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
4304 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
4305 }
4306}
4307
4308const fn json_value_kind(v: &serde_json::Value) -> &'static str {
4312 match v {
4313 serde_json::Value::Null => "null",
4314 serde_json::Value::Bool(_) => "bool",
4315 serde_json::Value::Number(_) => "number",
4316 serde_json::Value::String(_) => "string",
4317 serde_json::Value::Array(_) => "array",
4318 serde_json::Value::Object(_) => "object",
4319 }
4320}
4321
4322fn scalar_string_form(v: &serde_json::Value) -> Option<String> {
4327 match v {
4328 serde_json::Value::String(s) => Some(s.clone()),
4329 serde_json::Value::Number(n) => Some(n.to_string()),
4330 serde_json::Value::Bool(b) => Some(b.to_string()),
4331 _ => None,
4332 }
4333}
4334
4335fn select_response_headers(
4350 headers: &HashMap<String, serde_json::Value>,
4351 user_content_type: Option<String>,
4352 inferred_content_type: Option<String>,
4353) -> Vec<(String, String)> {
4354 let conn_tokens = header_policy::connection_tokens(
4355 headers
4356 .iter()
4357 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
4358 .filter_map(|(_, v)| v.as_str()),
4359 );
4360 let mut selected: Vec<(String, String)> = Vec::new();
4361 for (k, v) in headers {
4362 if k.starts_with("Camel") {
4363 debug!(header = %k, "reply header dropped: Camel namespace");
4364 continue;
4365 }
4366 if header_policy::excluded_response(k, &conn_tokens) {
4367 debug!(header = %k, "reply header dropped: emission policy");
4368 continue;
4369 }
4370 match scalar_string_form(v) {
4371 Some(s) => selected.push((k.clone(), s)),
4372 None => debug!(
4373 header = %k,
4374 value_kind = json_value_kind(v),
4375 "reply header dropped: no scalar string form"
4376 ),
4377 }
4378 }
4379 if let Some(ct) = user_content_type.or(inferred_content_type) {
4380 selected.push(("Content-Type".to_string(), ct));
4381 }
4382 selected
4383}
4384
4385#[derive(Debug)]
4390struct OutboundHeaderDrop<'a> {
4391 name: &'a str,
4392 reason: &'static str,
4393 value_kind: Option<&'static str>,
4394}
4395
4396struct OutboundHeaderSelection<'a> {
4399 accepted: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>,
4400 drops: Vec<OutboundHeaderDrop<'a>>,
4401}
4402
4403fn select_outbound_headers<'a>(
4417 headers: &'a HashMap<String, serde_json::Value>,
4418 skip_request_headers: &[String],
4419 conn_tokens: &[String],
4420) -> OutboundHeaderSelection<'a> {
4421 let mut accepted = Vec::new();
4422 let mut drops = Vec::new();
4423 for (key, value) in headers {
4424 if key.starts_with("Camel") {
4425 drops.push(OutboundHeaderDrop {
4426 name: key,
4427 reason: "Camel namespace",
4428 value_kind: None,
4429 });
4430 continue;
4431 }
4432 if skip_request_headers
4433 .iter()
4434 .any(|h| h.eq_ignore_ascii_case(key))
4435 {
4436 drops.push(OutboundHeaderDrop {
4437 name: key,
4438 reason: "skip_request_headers",
4439 value_kind: None,
4440 });
4441 continue;
4442 }
4443 if header_policy::excluded_outbound(key, conn_tokens) {
4444 drops.push(OutboundHeaderDrop {
4445 name: key,
4446 reason: "outbound emission policy",
4447 value_kind: None,
4448 });
4449 continue;
4450 }
4451 let Some(val_str) = scalar_string_form(value) else {
4452 drops.push(OutboundHeaderDrop {
4453 name: key,
4454 reason: "no scalar string form",
4455 value_kind: Some(json_value_kind(value)),
4456 });
4457 continue;
4458 };
4459 match constructed_header(key, &val_str) {
4460 Ok((name, val)) => accepted.push((name, val)),
4461 Err(drop) => drops.push(drop),
4462 }
4463 }
4464 OutboundHeaderSelection { accepted, drops }
4465}
4466
4467fn constructed_header<'a>(
4472 name: &'a str,
4473 value: &str,
4474) -> Result<(reqwest::header::HeaderName, reqwest::header::HeaderValue), OutboundHeaderDrop<'a>> {
4475 let header_name = match reqwest::header::HeaderName::from_bytes(name.as_bytes()) {
4476 Ok(header_name) => header_name,
4477 Err(_) => {
4478 return Err(OutboundHeaderDrop {
4479 name,
4480 reason: "invalid header name",
4481 value_kind: None,
4482 });
4483 }
4484 };
4485 let header_value = match reqwest::header::HeaderValue::from_str(value) {
4486 Ok(header_value) => header_value,
4487 Err(_) => {
4488 return Err(OutboundHeaderDrop {
4489 name,
4490 reason: "invalid header value",
4491 value_kind: None,
4492 });
4493 }
4494 };
4495 Ok((header_name, header_value))
4496}
4497
4498#[cfg(test)]
4499mod tests {
4500 use camel_component_api::test_support::NoopRuntimeObservability;
4501
4502 #[test]
4503 fn test_metadata_authmethod_enum_covers_runtime_vocab() {
4504 use camel_api::component_metadata::OptionKind;
4510 let meta = super::HttpEndpointConfig::metadata();
4511 let auth = meta
4512 .uri_options
4513 .iter()
4514 .find(|o| o.name == "authMethod")
4515 .expect("authMethod option must exist");
4516 let OptionKind::Enum(variants) = &auth.kind else {
4517 panic!("authMethod kind must be Enum; got {:?}", auth.kind);
4518 };
4519 for value in ["None", "Basic", "Bearer"] {
4520 assert!(
4521 variants.iter().any(|v| v.eq_ignore_ascii_case(value)),
4522 "metadata authMethod variants must cover runtime-accepted `{value}`; got {variants:?}"
4523 );
4524 }
4525 }
4526
4527 fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
4531 std::sync::Arc::new(NoopRuntimeObservability)
4532 }
4533 fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
4534 std::sync::Arc::new(NoopRuntimeObservability)
4535 }
4536 fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
4537 std::sync::Arc::new(NoopRuntimeObservability)
4538 }
4539
4540 use super::*;
4541 use crate::config::TlsConfig;
4542 use crate::rest_match::PathSegment;
4543 use crate::tls_harness::*;
4544 use camel_component_api::{Message, NoOpComponentContext};
4545 use std::sync::Arc;
4546 use std::time::Duration;
4547
4548 fn test_producer_ctx() -> ProducerContext {
4549 ProducerContext::new()
4550 }
4551
4552 #[test]
4563 fn canonical_redact_host_pinned() {
4564 assert_eq!(
4565 camel_api::redact::redact_host("host.example:8080"),
4566 "host.example:8080"
4567 );
4568 assert_eq!(camel_api::redact::redact_host("a@b@c"), "***@c");
4569 }
4570
4571 #[test]
4572 fn redact_url_drops_oauth2_fragment_access_token() {
4573 let redacted =
4574 redact_url_for_diagnostics("https://app.example/cb#access_token=SECRET&state=x");
4575 assert!(
4576 !redacted.contains("SECRET"),
4577 "fragment access token leaked: {redacted}"
4578 );
4579 assert!(
4580 !redacted.contains("access_token"),
4581 "fragment key leaked: {redacted}"
4582 );
4583 assert!(
4584 redacted.ends_with("#[redacted]"),
4585 "fragment must be replaced with the sentinel: {redacted}"
4586 );
4587 }
4588
4589 #[test]
4590 fn redact_url_drops_oauth2_fragment_id_token() {
4591 let redacted =
4592 redact_url_for_diagnostics("https://app.example/cb#id_token=eyJhbG.SECRET.SIG&state=y");
4593 assert!(
4594 !redacted.contains("eyJhbG"),
4595 "id token payload leaked: {redacted}"
4596 );
4597 assert!(
4598 !redacted.contains("id_token"),
4599 "id token key leaked: {redacted}"
4600 );
4601 assert!(
4602 !redacted.contains("SECRET"),
4603 "id token signature leaked: {redacted}"
4604 );
4605 assert!(
4606 redacted.ends_with("#[redacted]"),
4607 "fragment must be replaced with the sentinel: {redacted}"
4608 );
4609 }
4610
4611 #[test]
4612 fn redact_url_drops_generic_fragment_kv() {
4613 let redacted = redact_url_for_diagnostics("https://h.example/p/session#session=abc123");
4614 assert!(
4615 !redacted.contains("abc123"),
4616 "fragment value leaked: {redacted}"
4617 );
4618 assert!(
4619 !redacted.contains("session="),
4620 "fragment key leaked: {redacted}"
4621 );
4622 assert!(
4623 redacted.contains("#[redacted]"),
4624 "fragment must be replaced with the sentinel: {redacted}"
4625 );
4626 }
4627
4628 #[test]
4629 fn redact_url_query_and_fragment_sentinels_compose() {
4630 let redacted = redact_url_for_diagnostics("https://h.example/p?a=1#access_token=x");
4631 assert_eq!(
4632 redacted, "https://h.example/p?[redacted]#[redacted]",
4633 "query and fragment sentinels must compose: {redacted}"
4634 );
4635 }
4636
4637 #[test]
4638 fn redact_url_drops_benign_fragment_too() {
4639 let redacted = redact_url_for_diagnostics("https://h.example/docs#section-3");
4642 assert_eq!(
4643 redacted, "https://h.example/docs#[redacted]",
4644 "benign fragment must still be dropped: {redacted}"
4645 );
4646 }
4647
4648 #[test]
4649 fn redact_url_unparseable_fragment_credentials_dropped() {
4650 let raw = "ht tps://app.example/cb#access_token=SECRET";
4651 assert!(
4652 url::Url::parse(raw).is_err(),
4653 "fixture must be unparseable: {raw}"
4654 );
4655 let redacted = redact_url_for_diagnostics(raw);
4656 assert!(
4657 !redacted.contains("SECRET"),
4658 "unparseable fragment token leaked: {redacted}"
4659 );
4660 assert!(
4661 !redacted.contains("access_token"),
4662 "unparseable fragment bytes leaked: {redacted}"
4663 );
4664 assert!(
4665 redacted.contains("#[redacted]"),
4666 "unparseable fragment must end in the sentinel: {redacted}"
4667 );
4668 }
4669
4670 #[test]
4671 fn redact_url_double_slash_evader_sentinel() {
4672 let redacted = redact_url_for_diagnostics("scheme:////user:pass@evil/");
4675 assert_eq!(
4676 redacted, "[redacted]",
4677 "double-slash evader must fail closed: {redacted}"
4678 );
4679 }
4680
4681 #[test]
4682 fn redact_url_triple_slash_evader_sentinel() {
4683 let redacted = redact_url_for_diagnostics("scheme:///user:pass@evil/");
4684 assert_eq!(
4685 redacted, "[redacted]",
4686 "triple-slash evader must fail closed: {redacted}"
4687 );
4688 }
4689
4690 #[test]
4691 fn redact_url_bare_protocol_relative_userinfo_sentinel() {
4692 let redacted = redact_url_for_diagnostics("//user:pass@evil");
4693 assert_eq!(
4694 redacted, "[redacted]",
4695 "protocol-relative userinfo must fail closed: {redacted}"
4696 );
4697 }
4698
4699 #[test]
4700 fn redact_url_empty_host_userinfo_sentinel() {
4701 let redacted = redact_url_for_diagnostics("scheme://user@");
4704 assert_eq!(
4705 redacted, "[redacted]",
4706 "empty-host userinfo must fail closed: {redacted}"
4707 );
4708 }
4709
4710 #[test]
4711 fn redact_url_unparseable_slash_run_evader_sentinel() {
4712 let raw = "schem e:////user:pass@evil/";
4716 assert!(
4717 url::Url::parse(raw).is_err(),
4718 "fixture must be unparseable: {raw}"
4719 );
4720 let redacted = redact_url_for_diagnostics(raw);
4721 assert_eq!(
4722 redacted, "[redacted]",
4723 "unparseable slash-run evader must fail closed: {redacted}"
4724 );
4725 }
4726
4727 #[test]
4728 fn redact_url_unparseable_later_window_userinfo_sentinel() {
4729 let raw = "http://ho st/a//user:pass@evil/";
4733 assert!(
4734 url::Url::parse(raw).is_err(),
4735 "fixture must be unparseable: {raw}"
4736 );
4737 let redacted = redact_url_for_diagnostics(raw);
4738 assert_eq!(
4739 redacted, "[redacted]",
4740 "userinfo in a later // window must fail closed: {redacted}"
4741 );
4742 }
4743
4744 #[test]
4745 fn redact_url_parsed_later_window_userinfo_masked() {
4746 let redacted = redact_url_for_diagnostics("https://h//user:pass@evil/");
4751 assert!(
4752 !redacted.contains("user:pass"),
4753 "parsed later-window userinfo leaked: {redacted}"
4754 );
4755 assert!(
4756 redacted.contains("h//***@evil/"),
4757 "later window must be masked in place: {redacted}"
4758 );
4759 }
4760
4761 #[test]
4762 fn redact_url_parsed_window_mask_idempotent_with_real_userinfo() {
4763 let redacted = redact_url_for_diagnostics("https://user:pass@h//x@y/");
4767 assert!(
4768 redacted.contains("***@h"),
4769 "accessor mask must survive the window surgery: {redacted}"
4770 );
4771 assert!(
4772 !redacted.contains("user:pass"),
4773 "real userinfo leaked: {redacted}"
4774 );
4775 assert!(
4776 !redacted.contains("x@y"),
4777 "later path window leaked: {redacted}"
4778 );
4779 }
4780
4781 #[test]
4782 fn redact_url_backslash_authority_ruling() {
4783 let redacted = redact_url_for_diagnostics("http:\\\\user:pass@evil\\path");
4789 assert!(
4790 redacted.contains("***@"),
4791 "backslash authority must be userinfo-masked: {redacted}"
4792 );
4793 assert!(
4794 !redacted.contains("user:pass"),
4795 "backslash authority must not leak credentials: {redacted}"
4796 );
4797 }
4798
4799 #[test]
4800 fn non_special_backslash_authority_masked() {
4801 let redacted = redact_url_for_diagnostics("foo:\\user:pass@evil/");
4806 assert!(
4807 !redacted.contains("user:pass"),
4808 "non-special backslash authority leaked: {redacted}"
4809 );
4810 assert!(
4811 !redacted.contains("pass"),
4812 "non-special backslash authority leaked a credential byte: {redacted}"
4813 );
4814 assert_eq!(
4816 redact_url_for_diagnostics("foo:\\clean/path"),
4817 "foo:\\clean/path"
4818 );
4819 }
4820
4821 #[test]
4822 fn one_char_scheme_credential_content_masked() {
4823 let redacted = redact_url_for_diagnostics("x:\\user:pass@evil");
4826 assert!(
4827 !redacted.contains("user:pass"),
4828 "one-char-scheme backslash authority leaked: {redacted}"
4829 );
4830 assert!(
4831 !redacted.contains("pass"),
4832 "one-char-scheme backslash authority leaked a credential byte: {redacted}"
4833 );
4834 }
4835
4836 #[test]
4837 fn drive_and_unc_inputs_stay_visible() {
4838 let drive = redact_url_for_diagnostics("C:\\Users\\x@corp\\file");
4844 assert!(
4845 !drive.contains("[redacted]"),
4846 "drive path must not be sentineled: {drive}"
4847 );
4848 assert!(
4849 !drive.contains("***"),
4850 "drive path must not be masked: {drive}"
4851 );
4852 assert!(
4853 drive.contains("x@corp"),
4854 "drive path keeps its at-sign content visible: {drive}"
4855 );
4856 let unc = redact_url_for_diagnostics("\\\\server\\x@y");
4859 assert_eq!(unc, "\\\\server\\x@y");
4860 assert!(
4861 !unc.contains("[redacted]"),
4862 "UNC path must not be sentineled: {unc}"
4863 );
4864 }
4865
4866 #[test]
4867 fn redact_url_masks_userinfo_and_query() {
4868 let redacted =
4869 redact_url_for_diagnostics("http://user:secretpass@internal.example/api?token=abc123");
4870 assert!(
4871 !redacted.contains("secretpass"),
4872 "password must be masked: {redacted}"
4873 );
4874 assert!(
4875 !redacted.contains("token=abc123"),
4876 "query must be masked: {redacted}"
4877 );
4878 assert!(
4879 !redacted.contains("user@"),
4880 "username must be masked: {redacted}"
4881 );
4882 assert!(
4883 redacted.contains("internal.example"),
4884 "host stays visible: {redacted}"
4885 );
4886 assert!(redacted.contains("[redacted]"), "query marked: {redacted}");
4887 }
4888
4889 #[test]
4890 fn redact_url_keeps_clean_urls_visible() {
4891 let redacted = redact_url_for_diagnostics("https://api.example.com/v1/items");
4892 assert_eq!(redacted, "https://api.example.com/v1/items");
4893 }
4894
4895 #[test]
4896 fn redact_url_masks_password_only_userinfo() {
4897 let redacted = redact_url_for_diagnostics("http://:pwsecret@host.example/");
4898 assert!(
4899 !redacted.contains("pwsecret"),
4900 "password-only userinfo leaked: {redacted}"
4901 );
4902 assert_eq!(redacted, "http://***@host.example/");
4903
4904 let redacted = redact_url_for_diagnostics("http://user:pw2@host.example/api");
4905 assert!(!redacted.contains("pw2"), "password leaked: {redacted}");
4906 assert_eq!(redacted, "http://***@host.example/api");
4907
4908 let redacted = redact_url_for_diagnostics("http://host.example/api");
4909 assert_eq!(redacted, "http://host.example/api");
4910 }
4911
4912 #[test]
4913 fn redact_url_truncates_unparseable() {
4914 let long = "x".repeat(1000);
4915 let redacted = redact_url_for_diagnostics(&long);
4916 assert_eq!(redacted.len(), 256, "unparseable URL must be truncated");
4917 }
4918
4919 #[test]
4924 fn redact_url_keeps_sentinels_intact_under_256_cap() {
4925 let parsed = format!("https://example.com/{}?x=1", "a".repeat(230));
4928 assert!(
4929 url::Url::parse(&parsed).is_ok(),
4930 "fixture must parse: {parsed}"
4931 );
4932 let redacted = redact_url_for_diagnostics(&parsed);
4933 assert!(redacted.len() <= 256, "len={}", redacted.len());
4934 assert!(
4935 redacted.ends_with("?[redacted]"),
4936 "parsed-arm sentinel must render intact: {redacted}"
4937 );
4938
4939 let unparseable = format!("http://{} ?x=1", "a".repeat(240));
4942 assert!(
4943 url::Url::parse(&unparseable).is_err(),
4944 "fixture must not parse: {unparseable}"
4945 );
4946 let redacted = redact_url_for_diagnostics(&unparseable);
4947 assert!(redacted.len() <= 256, "len={}", redacted.len());
4948 assert!(
4949 redacted.ends_with("?[redacted]"),
4950 "unparseable-arm sentinel must render intact: {redacted}"
4951 );
4952 }
4953
4954 #[test]
4955 fn redact_url_suppresses_unparseable_authority_credentials() {
4956 let fixtures = [
4957 "http://u:secretpw@/x",
4958 "http://u:secretpw@host:99999/x",
4959 "http://u:secretpw@host:99999",
4960 "//u:secretpw@h/x",
4961 ];
4962 for fixture in fixtures {
4963 assert!(
4964 url::Url::parse(fixture).is_err(),
4965 "fixture must be unparseable: {fixture}"
4966 );
4967 let redacted = redact_url_for_diagnostics(fixture);
4968 assert_eq!(
4969 redacted, "[redacted]",
4970 "credential-bearing authority must be suppressed: {fixture}"
4971 );
4972 }
4973 }
4974
4975 #[test]
4976 fn redact_url_bd_repro_never_leaks_credentials() {
4977 let redacted = redact_url_for_diagnostics("http://user:pa%ss@host/path");
4978 assert!(
4979 !redacted.contains("user:pa%ss"),
4980 "bd rc-2i5c5 repro leaked userinfo: {redacted}"
4981 );
4982 assert!(
4983 !redacted.contains("pa%ss"),
4984 "bd rc-2i5c5 repro leaked password: {redacted}"
4985 );
4986 }
4987
4988 #[test]
4989 fn redact_url_unparseable_query_redacted_short_and_long() {
4990 let short = "http://host:99999/path?token=shortsecret";
4991 assert!(
4992 url::Url::parse(short).is_err(),
4993 "fixture must be unparseable: {short}"
4994 );
4995 let redacted = redact_url_for_diagnostics(short);
4996 assert_eq!(
4997 redacted, "http://host:99999/path?[redacted]",
4998 "short unparseable query must end with the suffix: {redacted}"
4999 );
5000
5001 let mut long = String::from("http://host:99999/");
5002 long.push_str(&"a".repeat(300));
5003 long.push_str("?token=longsecret");
5004 assert!(
5005 url::Url::parse(&long).is_err(),
5006 "fixture must be unparseable: {long}"
5007 );
5008 let redacted = redact_url_for_diagnostics(&long);
5009 assert!(
5010 !redacted.contains("longsecret"),
5011 "long unparseable query leaked a query byte: {redacted}"
5012 );
5013 assert!(
5014 redacted.len() <= 256,
5015 "long unparseable query must be capped: {} bytes",
5016 redacted.len()
5017 );
5018 }
5019
5020 #[test]
5021 fn redact_url_unparseable_sentinels_compose_both() {
5022 let raw = "ht tp://h.example/p?a=1#tok=x";
5025 assert!(
5026 url::Url::parse(raw).is_err(),
5027 "fixture must be unparseable: {raw}"
5028 );
5029 assert_eq!(
5030 redact_url_for_diagnostics(raw),
5031 "ht tp://h.example/p?[redacted]#[redacted]",
5032 "query and fragment sentinels must compose: {raw}"
5033 );
5034 }
5035
5036 #[test]
5037 fn redact_url_unparseable_sentinels_compose_fragment_first() {
5038 let raw = "ht tp://h.example/p#tok=x?a=1";
5039 assert!(
5040 url::Url::parse(raw).is_err(),
5041 "fixture must be unparseable: {raw}"
5042 );
5043 assert_eq!(
5044 redact_url_for_diagnostics(raw),
5045 "ht tp://h.example/p#[redacted]?[redacted]",
5046 "sentinels must follow the introducers' first-occurrence order: {raw}"
5047 );
5048 }
5049
5050 #[test]
5051 fn redact_url_unparseable_utf8_straddle_no_panic() {
5052 let fixture = format!("a{}", "é".repeat(200));
5053 let redacted = redact_url_for_diagnostics(&fixture);
5054 assert!(
5055 redacted.len() <= 256,
5056 "straddle fixture must be capped: {} bytes",
5057 redacted.len()
5058 );
5059 assert!(
5060 redacted.len() >= 253,
5061 "straddle fixture must not over-truncate: {} bytes",
5062 redacted.len()
5063 );
5064 assert!(
5065 fixture.is_char_boundary(redacted.len()),
5066 "cut must land on a UTF-8 char boundary: {} bytes",
5067 redacted.len()
5068 );
5069 }
5070
5071 #[test]
5072 fn redact_url_at_sign_outside_authority_window_visible() {
5073 let at_sign_in_path = "http://host:99999/x@y";
5074 assert!(
5075 url::Url::parse(at_sign_in_path).is_err(),
5076 "fixture must be unparseable: {at_sign_in_path}"
5077 );
5078 assert_eq!(
5079 redact_url_for_diagnostics(at_sign_in_path),
5080 at_sign_in_path,
5081 "at-sign in path must not be suppressed"
5082 );
5083 assert_eq!(
5085 redact_url_for_diagnostics("mailto:user@example.com"),
5086 "mailto:user@example.com",
5087 "at-sign in mailto must round-trip byte-identically"
5088 );
5089 }
5090
5091 #[test]
5092 fn parse_success_fragment_composes() {
5093 assert_eq!(
5096 redact_url_for_diagnostics("https://h/p#access_token=x"),
5097 "https://h/p#[redacted]"
5098 );
5099 assert_eq!(
5102 redact_url_for_diagnostics("https://h/cb#f?state=x"),
5103 "https://h/cb#[redacted]?[redacted]"
5104 );
5105 }
5106
5107 #[test]
5108 fn err_arm_delegation_pin() {
5109 assert_eq!(
5113 redact_url_for_diagnostics("http://u:secretpw@host:99999/x"),
5114 "[redacted]"
5115 );
5116 assert_eq!(
5119 redact_url_for_diagnostics("http://h:99999/p?token=secret"),
5120 "http://h:99999/p?[redacted]"
5121 );
5122 }
5123
5124 #[test]
5125 fn truncate_error_body_caps_attacker_body() {
5126 let big = vec![b'A'; 10 * 1024 * 1024];
5127 let truncated = truncate_error_body(&big);
5128 assert!(
5129 truncated.len() <= MAX_ERROR_RESPONSE_BODY_BYTES + 20,
5130 "body must be capped near {} bytes, got {}",
5131 MAX_ERROR_RESPONSE_BODY_BYTES,
5132 truncated.len()
5133 );
5134 assert!(truncated.ends_with("...[truncated]"));
5135 }
5136
5137 #[test]
5138 fn truncate_error_body_keeps_small_body() {
5139 assert_eq!(truncate_error_body(b"boom"), "boom");
5140 }
5141
5142 #[test]
5143 fn test_http_config_defaults() {
5144 let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
5145 assert_eq!(config.base_url, "http://localhost:8080/api");
5146 assert!(config.http_method.is_none());
5147 assert!(config.throw_exception_on_failure);
5148 assert_eq!(config.ok_status_code_range, (200, 299));
5149 assert!(config.response_timeout.is_none());
5150 assert!(matches!(config.auth, HttpAuth::None));
5151 assert!(!config.bridge_endpoint);
5152 assert!(!config.connection_close);
5153 }
5154
5155 #[test]
5156 fn test_http_config_scheme() {
5157 assert_eq!(HttpEndpointConfig::scheme(), "http");
5159 }
5160
5161 #[test]
5162 fn test_http_config_from_components() {
5163 let components = camel_component_api::UriComponents {
5165 scheme: "https".to_string(),
5166 path: "//api.example.com/v1".to_string(),
5167 params: std::collections::HashMap::from([(
5168 "httpMethod".to_string(),
5169 "POST".to_string(),
5170 )]),
5171 raw_query: None,
5172 };
5173 let config = HttpEndpointConfig::from_components(components).unwrap();
5174 assert_eq!(config.base_url, "https://api.example.com/v1");
5175 assert_eq!(config.http_method, Some("POST".to_string()));
5176 }
5177
5178 #[test]
5179 fn test_http_config_with_options() {
5180 let config = HttpEndpointConfig::from_uri(
5181 "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
5182 ).unwrap();
5183 assert_eq!(config.base_url, "https://api.example.com/v1");
5184 assert_eq!(config.http_method, Some("PUT".to_string()));
5185 assert!(!config.throw_exception_on_failure);
5186 assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
5187 }
5188
5189 #[test]
5190 fn test_http_endpoint_config_auth_and_headers_options() {
5191 let config = HttpEndpointConfig::from_uri(
5192 "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie",
5193 )
5194 .unwrap();
5195
5196 assert!(matches!(
5197 config.auth,
5198 HttpAuth::Basic { username, password } if username == "u" && password == "p"
5199 ));
5200 assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
5201 assert!(config.bridge_endpoint);
5202 assert!(config.connection_close);
5203 assert_eq!(
5204 config.skip_request_headers,
5205 vec!["authorization".to_string(), "x-secret".to_string()]
5206 );
5207 assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
5208 }
5209
5210 #[test]
5211 fn test_http_endpoint_config_bearer_auth() {
5212 let config = HttpEndpointConfig::from_uri(
5213 "http://localhost/api?authMethod=Bearer&authBearerToken=t",
5214 )
5215 .unwrap();
5216 assert!(matches!(
5217 config.auth,
5218 HttpAuth::Bearer { token } if token == "t"
5219 ));
5220 }
5221
5222 #[test]
5223 fn rejects_cookie_handling_inmemory() {
5224 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=InMemory");
5225 match result {
5226 Err(CamelError::InvalidUri(msg)) => {
5227 assert!(
5228 msg.contains("cookieHandling is not supported"),
5229 "expected rejection message, got: {msg}"
5230 );
5231 }
5232 other => panic!("expected InvalidUri error, got: {other:?}"),
5233 }
5234 }
5235
5236 #[test]
5237 fn rejects_cookie_handling_disabled() {
5238 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=Disabled");
5239 match result {
5240 Err(CamelError::InvalidUri(msg)) => {
5241 assert!(
5242 msg.contains("cookieHandling is not supported"),
5243 "expected rejection message, got: {msg}"
5244 );
5245 }
5246 other => panic!("expected InvalidUri error, got: {other:?}"),
5247 }
5248 }
5249
5250 #[test]
5251 fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
5252 let config = HttpConfig::default()
5253 .with_response_timeout_ms(999)
5254 .with_allow_internal(true)
5255 .with_blocked_hosts(vec!["evil.com".to_string()])
5256 .with_max_body_size(12345);
5257 let endpoint =
5258 HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
5259 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
5260 assert!(endpoint.allow_internal);
5261 assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
5262 assert_eq!(endpoint.max_body_size, 12345);
5263 }
5264
5265 #[test]
5266 fn test_from_uri_with_defaults_uri_overrides_config() {
5267 let config = HttpConfig::default()
5268 .with_response_timeout_ms(999)
5269 .with_allow_internal(true)
5270 .with_blocked_hosts(vec!["evil.com".to_string()])
5271 .with_max_body_size(12345);
5272 let endpoint = HttpEndpointConfig::from_uri_with_defaults(
5273 "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
5274 &config,
5275 )
5276 .unwrap();
5277 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
5278 assert!(!endpoint.allow_internal);
5279 assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
5280 assert_eq!(endpoint.max_body_size, 99);
5281 }
5282
5283 #[test]
5284 fn test_http_config_ok_status_range() {
5285 let config =
5286 HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
5287 assert_eq!(config.ok_status_code_range, (200, 204));
5288 }
5289
5290 #[test]
5291 fn test_http_config_wrong_scheme() {
5292 let result = HttpEndpointConfig::from_uri("file:/tmp");
5293 assert!(result.is_err());
5294 }
5295
5296 #[test]
5297 fn test_http_component_scheme() {
5298 let component = HttpComponent::new();
5299 assert_eq!(component.scheme(), "http");
5300 }
5301
5302 #[test]
5309 fn tls_strict_defaults_false_on_deserialize() {
5310 let tls: TlsConfig = serde_json::from_value(serde_json::json!({
5311 "enabled": true
5312 }))
5313 .unwrap();
5314 assert!(!tls.strict, "absent strict must default to false");
5315 }
5316
5317 fn strict_config(ca_path: Option<&str>, strict: bool) -> HttpConfig {
5318 HttpConfig {
5319 tls: Some(TlsConfig {
5320 enabled: true,
5321 strict,
5322 ca_cert_path: ca_path.map(|p| p.to_string()),
5323 ..TlsConfig::default()
5324 }),
5325 ..HttpConfig::default()
5326 }
5327 }
5328
5329 #[test]
5330 fn strict_tls_missing_ca_fails_endpoint_creation() {
5331 let component =
5332 HttpComponent::with_config(strict_config(Some("/nonexistent/ca.pem"), true));
5333 let err = component
5334 .create_endpoint("http://localhost/api", &NoOpComponentContext)
5335 .err()
5336 .expect("strict + missing CA must fail endpoint creation");
5337 assert!(
5338 err.to_string().contains("tls.strict"),
5339 "must name the strict knob: {err}"
5340 );
5341 assert!(
5342 err.to_string().contains("unreadable"),
5343 "must name the failure class: {err}"
5344 );
5345 }
5346
5347 #[test]
5348 fn strict_tls_unparseable_ca_fails_endpoint_creation() {
5349 let path = camel_component_api::test_support::tls::write_pem_tmp(
5350 "strict-bad-ca.pem",
5351 "not a certificate",
5352 );
5353 let component =
5354 HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
5355 let err = component
5356 .create_endpoint("http://localhost/api", &NoOpComponentContext)
5357 .err()
5358 .expect("strict + unparseable CA must fail endpoint creation");
5359 assert!(
5360 err.to_string()
5361 .contains("no parseable PEM CERTIFICATE section"),
5362 "must name the failure class: {err}"
5363 );
5364 }
5365
5366 #[test]
5367 fn strict_tls_der_file_rejected_not_certified() {
5368 let path = camel_component_api::test_support::tls::write_pem_tmp(
5373 "strict-der-ca.pem",
5374 "00garbage-bytes",
5375 );
5376 let component =
5377 HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
5378 let err = component
5379 .create_endpoint("http://localhost/api", &NoOpComponentContext)
5380 .err()
5381 .expect("strict + DER file must fail endpoint creation");
5382 assert!(
5383 err.to_string().contains("convert to PEM"),
5384 "must tell the operator to convert: {err}"
5385 );
5386 }
5387
5388 #[test]
5389 fn strict_tls_half_mtls_pair_rejected() {
5390 let cfg = strict_mtls_config(Some("/any/cert.pem"), None);
5393 let component = HttpComponent::with_config(cfg);
5394 let err = component
5395 .create_endpoint("http://localhost/api", &NoOpComponentContext)
5396 .err()
5397 .expect("strict + half mTLS pair must fail endpoint creation");
5398 assert!(
5399 err.to_string().contains("BOTH"),
5400 "must name the pair requirement: {err}"
5401 );
5402 }
5403
5404 #[test]
5405 fn strict_tls_valid_material_allows_endpoint_creation() {
5406 let (ca, _cert, _key) = camel_component_api::test_support::tls::gen_server_cert();
5407 let path = camel_component_api::test_support::tls::write_pem_tmp("strict-ok-ca.pem", &ca);
5408 let component =
5409 HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
5410 assert!(
5411 component
5412 .create_endpoint("http://localhost/api", &NoOpComponentContext)
5413 .is_ok(),
5414 "valid CA under strict must create the endpoint"
5415 );
5416 }
5417
5418 #[test]
5419 fn permissive_missing_ca_keeps_back_compat() {
5420 let component =
5423 HttpComponent::with_config(strict_config(Some("/nonexistent/ca.pem"), false));
5424 assert!(
5425 component
5426 .create_endpoint("http://localhost/api", &NoOpComponentContext)
5427 .is_ok(),
5428 "permissive mode must keep the back-compat fallback"
5429 );
5430 }
5431
5432 fn strict_mtls_config(cert_path: Option<&str>, key_path: Option<&str>) -> HttpConfig {
5433 HttpConfig {
5434 tls: Some(TlsConfig {
5435 enabled: true,
5436 strict: true,
5437 client_cert_path: cert_path.map(|p| p.to_string()),
5438 client_key_path: key_path.map(|p| p.to_string()),
5439 ..TlsConfig::default()
5440 }),
5441 ..HttpConfig::default()
5442 }
5443 }
5444
5445 #[test]
5446 fn strict_tls_missing_mtls_cert_fails_endpoint_creation() {
5447 let (_ca, _cert, key) = camel_component_api::test_support::tls::gen_server_cert();
5450 let key_path =
5451 camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-key.pem", &key);
5452 let component = HttpComponent::with_config(strict_mtls_config(
5453 Some("/nonexistent/cert.pem"),
5454 Some(key_path.to_str().unwrap()),
5455 ));
5456 let err = component
5457 .create_endpoint("http://localhost/api", &NoOpComponentContext)
5458 .err()
5459 .expect("strict + unreadable mTLS pair must fail endpoint creation");
5460 assert!(
5461 err.to_string().contains("tls.strict"),
5462 "must name the strict knob: {err}"
5463 );
5464 assert!(
5465 err.to_string().contains("unreadable"),
5466 "must name the failure class: {err}"
5467 );
5468 }
5469
5470 #[test]
5471 fn strict_tls_valid_mtls_pair_allows_endpoint_creation() {
5472 let (_ca, cert, key) = camel_component_api::test_support::tls::gen_server_cert();
5473 let cert_path =
5474 camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-cert.pem", &cert);
5475 let key_path =
5476 camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-key2.pem", &key);
5477 let component = HttpComponent::with_config(strict_mtls_config(
5478 Some(cert_path.to_str().unwrap()),
5479 Some(key_path.to_str().unwrap()),
5480 ));
5481 assert!(
5482 component
5483 .create_endpoint("http://localhost/api", &NoOpComponentContext)
5484 .is_ok(),
5485 "valid mTLS pair under strict must create the endpoint"
5486 );
5487 }
5488
5489 #[test]
5490 fn test_https_component_scheme() {
5491 let component = HttpsComponent::new();
5492 assert_eq!(component.scheme(), "https");
5493 }
5494
5495 #[test]
5496 fn test_http_endpoint_creates_consumer() {
5497 let component = HttpComponent::new();
5498 let ctx = NoOpComponentContext;
5499 let endpoint = component
5500 .create_endpoint("http://0.0.0.0:19100/test", &ctx)
5501 .unwrap();
5502 assert!(endpoint.create_consumer(rt()).is_ok());
5503 }
5504
5505 #[test]
5506 fn test_https_endpoint_creates_consumer_errors_without_tls() {
5507 let component = HttpsComponent::new();
5508 let ctx = NoOpComponentContext;
5509 let endpoint = component
5510 .create_endpoint("https://0.0.0.0:8443/test", &ctx)
5511 .unwrap();
5512 assert!(endpoint.create_consumer(rt()).is_err());
5514 }
5515
5516 #[test]
5517 fn test_http_endpoint_creates_producer() {
5518 let ctx = test_producer_ctx();
5519 let component = HttpComponent::new();
5520 let endpoint_ctx = NoOpComponentContext;
5521 let endpoint = component
5522 .create_endpoint("http://localhost/api", &endpoint_ctx)
5523 .unwrap();
5524 assert!(endpoint.create_producer(rt(), &ctx).is_ok());
5525 }
5526
5527 #[tokio::test]
5532 async fn test_producer_with_token_provider() {
5533 use camel_auth::oauth2::TokenProvider;
5534 use tower::ServiceExt;
5535
5536 let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
5537 Arc::new(std::sync::Mutex::new(None));
5538 let captured_clone = Arc::clone(&captured_auth);
5539
5540 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5541 let port = listener.local_addr().unwrap().port();
5542
5543 let _handle = tokio::spawn(async move {
5544 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5545 if let Ok((mut stream, _)) = listener.accept().await {
5546 let mut buf = vec![0u8; 8192];
5547 let n = stream.read(&mut buf).await.unwrap_or(0);
5548 let request = String::from_utf8_lossy(&buf[..n]).to_string();
5549 let auth = request
5550 .lines()
5551 .find(|l| l.to_lowercase().starts_with("authorization:"))
5552 .map(|l| {
5553 l.split(':')
5554 .nth(1)
5555 .map(|s| s.trim().to_string())
5556 .unwrap_or_default()
5557 });
5558 *captured_clone.lock().unwrap() = auth;
5559 let body = r#"{"echo":"ok"}"#;
5560 let resp = format!(
5561 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
5562 body.len(),
5563 body
5564 );
5565 let _ = stream.write_all(resp.as_bytes()).await;
5566 }
5567 });
5568
5569 #[derive(Debug)]
5570 struct StaticProvider;
5571 #[async_trait::async_trait]
5572 impl TokenProvider for StaticProvider {
5573 async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
5574 Ok("injected-token".into())
5575 }
5576 }
5577
5578 let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
5579 let ctx = test_producer_ctx();
5580 let component = HttpComponent::new();
5581 let endpoint_ctx = NoOpComponentContext;
5582 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
5583 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5584
5585 let exchange = Exchange::new(Message::new("hello"));
5586
5587 let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
5588 let mut layered = layer.layer(producer);
5589 let result = layered.ready().await.unwrap().call(exchange).await;
5590 assert!(result.is_ok(), "producer call failed: {:?}", result);
5591
5592 tokio::time::sleep(Duration::from_millis(100)).await;
5593 let auth = captured_auth.lock().unwrap().take();
5594 assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
5595 }
5596
5597 async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
5598 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5599 let addr = listener.local_addr().unwrap();
5600 let url = format!("http://127.0.0.1:{}", addr.port());
5601
5602 let handle = tokio::spawn(async move {
5603 loop {
5604 if let Ok((mut stream, _)) = listener.accept().await {
5605 tokio::spawn(async move {
5606 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5607 let mut buf = vec![0u8; 4096];
5608 let n = stream.read(&mut buf).await.unwrap_or(0);
5609 let request = String::from_utf8_lossy(&buf[..n]).to_string();
5610
5611 let method = request.split_whitespace().next().unwrap_or("GET");
5612
5613 let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
5614 let response = format!(
5615 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
5616 body.len(),
5617 body
5618 );
5619 let _ = stream.write_all(response.as_bytes()).await;
5620 });
5621 }
5622 }
5623 });
5624
5625 (url, handle)
5626 }
5627
5628 async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
5629 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5630 let addr = listener.local_addr().unwrap();
5631 let url = format!("http://127.0.0.1:{}", addr.port());
5632
5633 let handle = tokio::spawn(async move {
5634 loop {
5635 if let Ok((mut stream, _)) = listener.accept().await {
5636 let status = status;
5637 tokio::spawn(async move {
5638 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5639 let mut buf = vec![0u8; 4096];
5640 let _ = stream.read(&mut buf).await;
5641
5642 let status_text = match status {
5643 404 => "Not Found",
5644 500 => "Internal Server Error",
5645 _ => "Error",
5646 };
5647 let body = "error body";
5648 let response = format!(
5649 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
5650 status,
5651 status_text,
5652 body.len(),
5653 body
5654 );
5655 let _ = stream.write_all(response.as_bytes()).await;
5656 });
5657 }
5658 }
5659 });
5660
5661 (url, handle)
5662 }
5663
5664 async fn start_request_capturing_server() -> (
5665 String,
5666 Arc<std::sync::Mutex<Option<String>>>,
5667 tokio::task::JoinHandle<()>,
5668 ) {
5669 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5670 let port = listener.local_addr().unwrap().port();
5671 let url = format!("http://127.0.0.1:{port}");
5672 let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
5673 let captured_clone = Arc::clone(&captured);
5674 let handle = tokio::spawn(async move {
5675 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5676 if let Ok((mut stream, _)) = listener.accept().await {
5677 let mut buf = vec![0u8; 16384];
5678 let n = stream.read(&mut buf).await.unwrap_or(0);
5679 let request = String::from_utf8_lossy(&buf[..n]).to_string();
5680 if request.contains("\r\n\r\n") {
5681 *captured_clone.lock().unwrap() = Some(request);
5682 }
5683 let body = r#"{"echo":"ok"}"#;
5684 let resp = format!(
5685 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
5686 body.len(),
5687 body
5688 );
5689 let _ = stream.write_all(resp.as_bytes()).await;
5690 }
5691 });
5692 (url, captured, handle)
5693 }
5694
5695 #[tokio::test]
5696 async fn test_http_producer_get_request() {
5697 use tower::ServiceExt;
5698
5699 let (url, _handle) = start_test_server().await;
5700 let ctx = test_producer_ctx();
5701
5702 let component = HttpComponent::new();
5703 let endpoint_ctx = NoOpComponentContext;
5704 let endpoint = component
5705 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5706 .unwrap();
5707 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5708
5709 let exchange = Exchange::new(Message::default());
5710 let result = producer.oneshot(exchange).await.unwrap();
5711
5712 let status = result
5713 .input
5714 .header("CamelHttpResponseCode")
5715 .and_then(|v| v.as_u64())
5716 .unwrap();
5717 assert_eq!(status, 200);
5718
5719 assert!(!result.input.body.is_empty());
5720 }
5721
5722 #[tokio::test]
5723 async fn producer_excludes_host_and_framing() {
5724 use tower::ServiceExt;
5725
5726 let (url, captured, _handle) = start_request_capturing_server().await;
5727 let ctx = test_producer_ctx();
5728 let component = HttpComponent::new();
5729 let endpoint_ctx = NoOpComponentContext;
5730 let endpoint = component
5731 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5732 .unwrap();
5733 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5734
5735 let mut exchange = Exchange::new(Message::default());
5736 exchange.input.set_header("Host", "localhost");
5737 exchange.input.set_header("Content-Length", "42");
5738 exchange.input.set_header("Connection", "keep-alive");
5739 exchange.input.set_header("Upgrade", "h2c");
5740
5741 let result = producer.oneshot(exchange).await;
5742 assert!(result.is_ok(), "producer call failed: {:?}", result);
5743
5744 tokio::time::sleep(Duration::from_millis(100)).await;
5745 let request = captured
5746 .lock()
5747 .unwrap()
5748 .take()
5749 .expect("no outbound request captured");
5750 let lower = request.to_ascii_lowercase();
5751 assert!(
5752 !lower.contains("\r\nhost: localhost"),
5753 "forwarded Host: localhost must be stripped\n{request}"
5754 );
5755 assert!(
5756 !lower.contains("content-length: 42"),
5757 "exchange Content-Length must not be copied\n{request}"
5758 );
5759 assert!(
5760 !lower.lines().any(|l| l.starts_with("connection:")),
5761 "Connection header must not be forwarded\n{request}"
5762 );
5763 assert!(
5764 !lower.lines().any(|l| l.starts_with("upgrade:")),
5765 "Upgrade header must not be forwarded\n{request}"
5766 );
5767 let host_header = lower
5768 .lines()
5769 .find(|l| l.starts_with("host:"))
5770 .map(|l| l.split_once(':').map(|(_, v)| v).unwrap_or("").trim())
5771 .expect("outbound Host header must be set by reqwest");
5772 assert!(
5773 host_header.starts_with("127.0.0.1:"),
5774 "outbound Host '{host_header}' must match the capture-server address"
5775 );
5776 }
5777
5778 #[tokio::test]
5779 async fn producer_forwards_request_only_headers() {
5780 use tower::ServiceExt;
5781
5782 let (url, captured, _handle) = start_request_capturing_server().await;
5783 let ctx = test_producer_ctx();
5784 let component = HttpComponent::new();
5785 let endpoint_ctx = NoOpComponentContext;
5786 let endpoint = component
5787 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5788 .unwrap();
5789 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5790
5791 let mut exchange = Exchange::new(Message::default());
5792 exchange.input.set_header("Accept", "application/json");
5793 exchange.input.set_header("User-Agent", "myclient/1.0");
5794
5795 let result = producer.oneshot(exchange).await;
5796 assert!(result.is_ok(), "producer call failed: {:?}", result);
5797
5798 tokio::time::sleep(Duration::from_millis(100)).await;
5799 let request = captured
5800 .lock()
5801 .unwrap()
5802 .take()
5803 .expect("no outbound request captured");
5804 let lower = request.to_ascii_lowercase();
5805 assert!(
5806 lower.contains("accept: application/json"),
5807 "request-only Accept header must be forwarded\n{request}"
5808 );
5809 assert!(
5810 lower.contains("user-agent: myclient/1.0"),
5811 "request-only User-Agent header must be forwarded\n{request}"
5812 );
5813 }
5814
5815 fn endpoint_with_config_overrides(
5824 base_url: &str,
5825 user_agent: Option<String>,
5826 auth: HttpAuth,
5827 ) -> HttpEndpoint {
5828 let uri = format!("{base_url}/api/test?allowInternal=true");
5829 let mut config =
5830 HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses");
5831 config.user_agent = user_agent;
5832 config.auth = auth;
5833 HttpEndpoint {
5834 uri: uri.clone(),
5835 config,
5836 server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
5837 client: plain_http_test_client(),
5838 pinned_cache: Arc::new(PinnedClientCache::new(
5839 PINNED_CLIENT_TTL,
5840 PINNED_CLIENT_MAX_ENTRIES,
5841 )),
5842 http_config: HttpConfig::default(),
5843 }
5844 }
5845
5846 #[tracing_test::traced_test]
5851 #[tokio::test]
5852 async fn producer_invalid_configured_headers_surfaced() {
5853 use tower::ServiceExt;
5854
5855 let (bad_url, bad_captured, _bad_handle) = start_request_capturing_server().await;
5856 let (ok_url, ok_captured, _ok_handle) = start_request_capturing_server().await;
5857 let ctx = test_producer_ctx();
5858
5859 let bad_producer = endpoint_with_config_overrides(
5860 &bad_url,
5861 Some("bad\r\nua".to_string()),
5862 HttpAuth::Bearer {
5863 token: "tok\r\nen".to_string(),
5864 },
5865 )
5866 .create_producer(rt(), &ctx)
5867 .unwrap();
5868 let ok_producer = endpoint_with_config_overrides(
5869 &ok_url,
5870 Some("httpsweep-ok/1".to_string()),
5871 HttpAuth::Bearer {
5872 token: "valid-token".to_string(),
5873 },
5874 )
5875 .create_producer(rt(), &ctx)
5876 .unwrap();
5877
5878 let bad_exchange = Exchange::new(Message::default());
5879 let ok_exchange = Exchange::new(Message::default());
5880 let bad_cid = bad_exchange.correlation_id().to_string();
5881 let ok_cid = ok_exchange.correlation_id().to_string();
5882
5883 let bad_result = bad_producer.oneshot(bad_exchange).await;
5884 assert!(
5885 bad_result.is_ok(),
5886 "invalid-config producer call failed: {bad_result:?}"
5887 );
5888 let ok_result = ok_producer.oneshot(ok_exchange).await;
5889 assert!(
5890 ok_result.is_ok(),
5891 "valid-config producer call failed: {ok_result:?}"
5892 );
5893
5894 tokio::time::sleep(Duration::from_millis(100)).await;
5895 let bad_request = bad_captured
5896 .lock()
5897 .unwrap()
5898 .take()
5899 .expect("no outbound request captured");
5900 let ok_request = ok_captured
5901 .lock()
5902 .unwrap()
5903 .take()
5904 .expect("no outbound request captured");
5905
5906 let bad_lower = bad_request.to_ascii_lowercase();
5909 assert!(
5910 !bad_lower.lines().any(|l| l.starts_with("authorization:")),
5911 "invalid Bearer token must not reach the wire\n{bad_request}"
5912 );
5913 assert!(
5914 !bad_request.contains("bad\r\nua"),
5915 "invalid configured user-agent must not reach the wire\n{bad_request}"
5916 );
5917
5918 logs_assert(|lines: &[&str]| {
5919 let drops: Vec<&&str> = lines
5920 .iter()
5921 .filter(|l| {
5922 l.contains("outbound header dropped")
5923 && l.contains(&format!("correlation_id={bad_cid}"))
5924 })
5925 .collect();
5926 if drops.len() != 2 {
5927 return Err(format!(
5928 "expected exactly 2 drop records for {bad_cid}, found {}",
5929 drops.len()
5930 ));
5931 }
5932 let has_ua = drops.iter().any(|l| l.contains("header=user-agent"));
5933 let has_auth = drops.iter().any(|l| l.contains("header=authorization"));
5934 let reason_ok = drops
5935 .iter()
5936 .all(|l| l.contains("outbound header dropped: invalid header value"));
5937 match (has_ua, has_auth, reason_ok) {
5938 (true, true, true) => Ok(()),
5939 _ => Err(format!(
5940 "drop records mismatched: user-agent={has_ua} \
5941 authorization={has_auth} reason-ok={reason_ok}"
5942 )),
5943 }
5944 });
5945 logs_assert(|lines: &[&str]| {
5946 if lines
5947 .iter()
5948 .any(|l| l.contains("bad\r\nua") || l.contains("tok\r\nen"))
5949 {
5950 Err("sentinel CRLF values leaked into logs".to_string())
5951 } else {
5952 Ok(())
5953 }
5954 });
5955
5956 let ok_lower = ok_request.to_ascii_lowercase();
5959 assert!(
5960 ok_lower.contains("user-agent: httpsweep-ok/1"),
5961 "valid configured user-agent must reach the wire\n{ok_request}"
5962 );
5963 assert!(
5964 ok_lower.contains("authorization: bearer valid-token"),
5965 "valid Bearer token must reach the wire\n{ok_request}"
5966 );
5967 logs_assert(|lines: &[&str]| {
5968 let hits = lines
5969 .iter()
5970 .filter(|l| {
5971 l.contains("outbound header dropped")
5972 && l.contains(&format!("correlation_id={ok_cid}"))
5973 })
5974 .count();
5975 match hits {
5976 0 => Ok(()),
5977 n => Err(format!("expected no drop records for {ok_cid}, found {n}")),
5978 }
5979 });
5980 }
5981
5982 #[tokio::test]
5983 async fn producer_honours_skip_request_headers() {
5984 use tower::ServiceExt;
5985
5986 let (url, captured, _handle) = start_request_capturing_server().await;
5987 let ctx = test_producer_ctx();
5988 let component = HttpComponent::new();
5989 let endpoint_ctx = NoOpComponentContext;
5990 let endpoint = component
5991 .create_endpoint(
5992 &format!("{url}/api/test?allowInternal=true&skipRequestHeaders=Authorization"),
5993 &endpoint_ctx,
5994 )
5995 .unwrap();
5996 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5997
5998 let mut exchange = Exchange::new(Message::default());
5999 exchange.input.set_header("Authorization", "Bearer x");
6000
6001 let result = producer.oneshot(exchange).await;
6002 assert!(result.is_ok(), "producer call failed: {:?}", result);
6003
6004 tokio::time::sleep(Duration::from_millis(100)).await;
6005 let request = captured
6006 .lock()
6007 .unwrap()
6008 .take()
6009 .expect("no outbound request captured");
6010 assert!(
6011 !request.to_ascii_lowercase().contains("authorization"),
6012 "Authorization must be stripped by skipRequestHeaders\n{request}"
6013 );
6014 }
6015
6016 #[tokio::test]
6017 async fn producer_stringifies_scalar_header_values_on_wire() {
6018 use tower::ServiceExt;
6019
6020 let (url, captured, _handle) = start_request_capturing_server().await;
6021 let ctx = test_producer_ctx();
6022 let component = HttpComponent::new();
6023 let endpoint_ctx = NoOpComponentContext;
6024 let endpoint = component
6025 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
6026 .unwrap();
6027 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6028
6029 let mut exchange = Exchange::new(Message::default());
6030 exchange.input.set_header("X-Retries", serde_json::json!(3));
6031 exchange
6032 .input
6033 .set_header("X-Enabled", serde_json::json!(true));
6034 exchange
6035 .input
6036 .set_header("X-Obj", serde_json::json!({"a": 1}));
6037
6038 let result = producer.oneshot(exchange).await;
6039 assert!(result.is_ok(), "producer call failed: {:?}", result);
6040
6041 tokio::time::sleep(Duration::from_millis(100)).await;
6042 let request = captured
6043 .lock()
6044 .unwrap()
6045 .take()
6046 .expect("no outbound request captured");
6047 let lower = request.to_ascii_lowercase();
6048 assert!(
6049 lower.contains("x-retries: 3"),
6050 "numeric header must reach the wire stringified\n{request}"
6051 );
6052 assert!(
6053 lower.contains("x-enabled: true"),
6054 "bool header must reach the wire stringified\n{request}"
6055 );
6056 assert!(
6057 !lower.contains("x-obj:"),
6058 "object header has no single-value form and must not reach the wire\n{request}"
6059 );
6060 }
6061
6062 #[tokio::test]
6063 async fn test_http_producer_post_with_body() {
6064 use tower::ServiceExt;
6065
6066 let (url, _handle) = start_test_server().await;
6067 let ctx = test_producer_ctx();
6068
6069 let component = HttpComponent::new();
6070 let endpoint_ctx = NoOpComponentContext;
6071 let endpoint = component
6072 .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
6073 .unwrap();
6074 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6075
6076 let exchange = Exchange::new(Message::new("request body"));
6077 let result = producer.oneshot(exchange).await.unwrap();
6078
6079 let status = result
6080 .input
6081 .header("CamelHttpResponseCode")
6082 .and_then(|v| v.as_u64())
6083 .unwrap();
6084 assert_eq!(status, 200);
6085 }
6086
6087 #[tokio::test]
6088 async fn test_http_producer_method_from_header() {
6089 use tower::ServiceExt;
6090
6091 let (url, _handle) = start_test_server().await;
6092 let ctx = test_producer_ctx();
6093
6094 let component = HttpComponent::new();
6095 let endpoint_ctx = NoOpComponentContext;
6096 let endpoint = component
6097 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
6098 .unwrap();
6099 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6100
6101 let mut exchange = Exchange::new(Message::default());
6102 exchange.input.set_header(
6103 "CamelHttpMethod",
6104 serde_json::Value::String("DELETE".to_string()),
6105 );
6106
6107 let result = producer.oneshot(exchange).await.unwrap();
6108 let status = result
6109 .input
6110 .header("CamelHttpResponseCode")
6111 .and_then(|v| v.as_u64())
6112 .unwrap();
6113 assert_eq!(status, 200);
6114 }
6115
6116 #[tokio::test]
6117 async fn test_http_producer_forced_method() {
6118 use tower::ServiceExt;
6119
6120 let (url, _handle) = start_test_server().await;
6121 let ctx = test_producer_ctx();
6122
6123 let component = HttpComponent::new();
6124 let endpoint_ctx = NoOpComponentContext;
6125 let endpoint = component
6126 .create_endpoint(
6127 &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
6128 &endpoint_ctx,
6129 )
6130 .unwrap();
6131 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6132
6133 let exchange = Exchange::new(Message::default());
6134 let result = producer.oneshot(exchange).await.unwrap();
6135
6136 let status = result
6137 .input
6138 .header("CamelHttpResponseCode")
6139 .and_then(|v| v.as_u64())
6140 .unwrap();
6141 assert_eq!(status, 200);
6142 }
6143
6144 #[tokio::test]
6145 async fn test_http_producer_throw_exception_on_failure() {
6146 use tower::ServiceExt;
6147
6148 let (url, _handle) = start_status_server(404).await;
6149 let ctx = test_producer_ctx();
6150
6151 let component = HttpComponent::new();
6152 let endpoint_ctx = NoOpComponentContext;
6153 let endpoint = component
6154 .create_endpoint(
6155 &format!("{url}/not-found?allowInternal=true"),
6156 &endpoint_ctx,
6157 )
6158 .unwrap();
6159 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6160
6161 let exchange = Exchange::new(Message::default());
6162 let result = producer.oneshot(exchange).await;
6163 assert!(result.is_err());
6164
6165 match result.unwrap_err() {
6166 CamelError::HttpOperationFailed { status_code, .. } => {
6167 assert_eq!(status_code, 404);
6168 }
6169 e => panic!("Expected HttpOperationFailed, got: {e}"),
6170 }
6171 }
6172
6173 #[tokio::test]
6174 async fn test_http_producer_no_throw_on_failure() {
6175 use tower::ServiceExt;
6176
6177 let (url, _handle) = start_status_server(500).await;
6178 let ctx = test_producer_ctx();
6179
6180 let component = HttpComponent::new();
6181 let endpoint_ctx = NoOpComponentContext;
6182 let endpoint = component
6183 .create_endpoint(
6184 &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
6185 &endpoint_ctx,
6186 )
6187 .unwrap();
6188 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6189
6190 let exchange = Exchange::new(Message::default());
6191 let result = producer.oneshot(exchange).await.unwrap();
6192
6193 let status = result
6194 .input
6195 .header("CamelHttpResponseCode")
6196 .and_then(|v| v.as_u64())
6197 .unwrap();
6198 assert_eq!(status, 500);
6199 }
6200
6201 #[tokio::test]
6202 async fn test_http_producer_uri_override() {
6203 use tower::ServiceExt;
6204
6205 let (url, _handle) = start_test_server().await;
6206 let ctx = test_producer_ctx();
6207
6208 let component = HttpComponent::new();
6209 let endpoint_ctx = NoOpComponentContext;
6210 let endpoint = component
6211 .create_endpoint(
6212 "http://localhost:1/does-not-exist?allowInternal=true",
6213 &endpoint_ctx,
6214 )
6215 .unwrap();
6216 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6217
6218 let mut exchange = Exchange::new(Message::default());
6219 exchange.input.set_header(
6220 "CamelHttpUri",
6221 serde_json::Value::String(format!("{url}/api")),
6222 );
6223
6224 let result = producer.oneshot(exchange).await.unwrap();
6225 let status = result
6226 .input
6227 .header("CamelHttpResponseCode")
6228 .and_then(|v| v.as_u64())
6229 .unwrap();
6230 assert_eq!(status, 200);
6231 }
6232
6233 #[tokio::test]
6234 async fn test_http_producer_response_headers_mapped() {
6235 use tower::ServiceExt;
6236
6237 let (url, _handle) = start_test_server().await;
6238 let ctx = test_producer_ctx();
6239
6240 let component = HttpComponent::new();
6241 let endpoint_ctx = NoOpComponentContext;
6242 let endpoint = component
6243 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
6244 .unwrap();
6245 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6246
6247 let exchange = Exchange::new(Message::default());
6248 let result = producer.oneshot(exchange).await.unwrap();
6249
6250 assert!(
6251 result.input.header("Content-Type").is_some(),
6252 "Response should have Content-Type header"
6253 );
6254 assert!(result.input.header("CamelHttpResponseText").is_some());
6255 }
6256
6257 async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
6262 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6263 let addr = listener.local_addr().unwrap();
6264 let url = format!("http://127.0.0.1:{}", addr.port());
6265
6266 let handle = tokio::spawn(async move {
6267 use tokio::io::{AsyncReadExt, AsyncWriteExt};
6268 loop {
6269 if let Ok((mut stream, _)) = listener.accept().await {
6270 tokio::spawn(async move {
6271 let mut buf = vec![0u8; 4096];
6272 let n = stream.read(&mut buf).await.unwrap_or(0);
6273 let request = String::from_utf8_lossy(&buf[..n]).to_string();
6274
6275 if request.contains("GET /final") {
6277 let body = r#"{"status":"final"}"#;
6278 let response = format!(
6279 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
6280 body.len(),
6281 body
6282 );
6283 let _ = stream.write_all(response.as_bytes()).await;
6284 } else {
6285 let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
6290 let _ = stream.write_all(response.as_bytes()).await;
6291 }
6292 });
6293 }
6294 }
6295 });
6296
6297 (url, handle)
6298 }
6299
6300 struct CapturedRequest {
6301 method: String,
6302 path: String,
6303 body: Vec<u8>,
6304 content_length: Option<String>,
6305 transfer_encoding: Option<String>,
6306 }
6307
6308 async fn capture_request(stream: &mut tokio::net::TcpStream) -> Option<CapturedRequest> {
6314 use tokio::io::AsyncReadExt;
6315
6316 let mut buf: Vec<u8> = Vec::new();
6318 let mut chunk = [0u8; 4096];
6319 let head_end: usize;
6320 loop {
6321 let n = stream.read(&mut chunk).await.unwrap_or(0);
6322 if n == 0 {
6323 return None;
6324 }
6325 buf.extend_from_slice(&chunk[..n]);
6326 if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
6327 head_end = pos + 4;
6328 break;
6329 }
6330 }
6331
6332 let head = String::from_utf8_lossy(&buf[..head_end]);
6334 let mut lines = head.split("\r\n");
6335 let request_line = lines.next().unwrap_or("");
6336 let mut parts = request_line.split_whitespace();
6337 let method = parts.next().unwrap_or("").to_string();
6338 let path = parts.next().unwrap_or("").to_string();
6339
6340 let mut content_length: Option<String> = None;
6341 let mut transfer_encoding: Option<String> = None;
6342 for line in lines {
6343 if let Some((name, value)) = line.split_once(':') {
6344 let name = name.trim().to_ascii_lowercase();
6345 let value = value.trim().to_string();
6346 if name == "content-length" {
6347 content_length = Some(value);
6348 } else if name == "transfer-encoding" {
6349 transfer_encoding = Some(value);
6350 }
6351 }
6352 }
6353
6354 let body_len: usize = content_length
6356 .as_deref()
6357 .and_then(|v| v.parse::<usize>().ok())
6358 .unwrap_or(0);
6359
6360 let mut body: Vec<u8> = buf[head_end..].to_vec();
6361 while body.len() < body_len {
6362 let n = stream.read(&mut chunk).await.unwrap_or(0);
6363 if n == 0 {
6364 break;
6365 }
6366 body.extend_from_slice(&chunk[..n]);
6367 }
6368 body.truncate(body_len);
6369
6370 Some(CapturedRequest {
6371 method,
6372 path,
6373 body,
6374 content_length,
6375 transfer_encoding,
6376 })
6377 }
6378
6379 async fn start_capture_server() -> (
6384 String,
6385 tokio::task::JoinHandle<()>,
6386 Arc<Mutex<Vec<CapturedRequest>>>,
6387 ) {
6388 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6389 let addr = listener.local_addr().unwrap();
6390 let url = format!("http://127.0.0.1:{}", addr.port());
6391
6392 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
6393 let captured_for_return = Arc::clone(&captured);
6394
6395 let handle = tokio::spawn(async move {
6396 use tokio::io::AsyncWriteExt;
6397 loop {
6398 if let Ok(Ok((mut stream, _))) =
6406 tokio::time::timeout(Duration::from_secs(10), listener.accept()).await
6407 {
6408 let captured = Arc::clone(&captured);
6409 tokio::spawn(async move {
6410 let Some(req) = tokio::time::timeout(
6414 Duration::from_secs(10),
6415 capture_request(&mut stream),
6416 )
6417 .await
6418 .ok()
6419 .flatten() else {
6420 return;
6421 };
6422 captured.lock().unwrap().push(req);
6423
6424 let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
6427 let _ = stream.write_all(response.as_bytes()).await;
6428 });
6429 }
6430 }
6431 });
6432
6433 (url, handle, captured_for_return)
6434 }
6435
6436 async fn start_redirect_capture_server() -> (
6442 String,
6443 tokio::task::JoinHandle<()>,
6444 Arc<Mutex<Vec<CapturedRequest>>>,
6445 ) {
6446 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6447 let addr = listener.local_addr().unwrap();
6448 let url = format!("http://127.0.0.1:{}", addr.port());
6449
6450 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
6451 let captured_for_return = Arc::clone(&captured);
6452
6453 let handle = tokio::spawn(async move {
6454 use tokio::io::AsyncWriteExt;
6455 loop {
6456 if let Ok(Ok((mut stream, _))) =
6464 tokio::time::timeout(Duration::from_secs(10), listener.accept()).await
6465 {
6466 let captured = Arc::clone(&captured);
6467 tokio::spawn(async move {
6468 let Some(req) = tokio::time::timeout(
6472 Duration::from_secs(10),
6473 capture_request(&mut stream),
6474 )
6475 .await
6476 .ok()
6477 .flatten() else {
6478 return;
6479 };
6480 let path = req.path.clone();
6481 captured.lock().unwrap().push(req);
6482
6483 let (status_line, location) = match path.as_str() {
6484 "/hop307" => ("HTTP/1.1 307 Temporary Redirect", Some("/final")),
6485 "/hop308" => ("HTTP/1.1 308 Permanent Redirect", Some("/final")),
6486 "/final" => ("HTTP/1.1 200 OK", None),
6487 _ => ("HTTP/1.1 404 Not Found", None),
6488 };
6489
6490 let response = match location {
6491 Some(loc) => format!(
6495 "{status_line}\r\nLocation: {loc}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
6496 ),
6497 None => format!("{status_line}\r\nContent-Length: 0\r\n\r\n"),
6498 };
6499 let _ = stream.write_all(response.as_bytes()).await;
6500 });
6501 }
6502 }
6503 });
6504
6505 (url, handle, captured_for_return)
6506 }
6507
6508 #[tokio::test]
6509 async fn test_get_with_body_sends_no_body_and_no_framing_headers() {
6510 use tower::ServiceExt;
6511
6512 let (url, _handle, captured) = start_capture_server().await;
6513 let ctx = test_producer_ctx();
6514
6515 let component = HttpComponent::with_config(HttpConfig::default());
6516 let endpoint_ctx = NoOpComponentContext;
6517 let endpoint = component
6518 .create_endpoint(
6519 &format!("{url}?httpMethod=GET&allowInternal=true"),
6520 &endpoint_ctx,
6521 )
6522 .unwrap();
6523 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6524
6525 let mut exchange = Exchange::new(Message::default());
6526 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6527
6528 let result = producer.oneshot(exchange).await.unwrap();
6529
6530 let status = result
6531 .input
6532 .header("CamelHttpResponseCode")
6533 .and_then(|v| v.as_u64())
6534 .unwrap();
6535 assert_eq!(status, 200);
6536
6537 let captured = captured.lock().unwrap();
6538 assert_eq!(captured.len(), 1, "expected exactly one captured request");
6539 let req = &captured[0];
6540 assert_eq!(req.method, "GET");
6541 assert_eq!(req.path, "/");
6544 assert!(req.body.is_empty(), "GET must not carry a body");
6545 assert!(
6546 req.content_length.is_none(),
6547 "suppressed request must not carry Content-Length"
6548 );
6549 assert!(
6550 req.transfer_encoding.is_none(),
6551 "suppressed request must not carry Transfer-Encoding"
6552 );
6553
6554 assert!(
6556 result.input.body.is_empty(),
6557 "exchange body must be consumed"
6558 );
6559 }
6560
6561 #[tokio::test]
6562 async fn test_head_with_body_suppressed_via_header() {
6563 use tower::ServiceExt;
6564
6565 let (url, _handle, captured) = start_capture_server().await;
6566 let ctx = test_producer_ctx();
6567
6568 let component = HttpComponent::with_config(HttpConfig::default());
6569 let endpoint_ctx = NoOpComponentContext;
6570 let endpoint = component
6571 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6572 .unwrap();
6573 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6574
6575 let mut exchange = Exchange::new(Message::default());
6576 exchange.input.set_header(
6577 "CamelHttpMethod",
6578 serde_json::Value::String("HEAD".to_string()),
6579 );
6580 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6581
6582 let result = producer.oneshot(exchange).await.unwrap();
6583 let status = result
6584 .input
6585 .header("CamelHttpResponseCode")
6586 .and_then(|v| v.as_u64())
6587 .unwrap();
6588 assert_eq!(status, 200);
6589
6590 let captured = captured.lock().unwrap();
6591 assert_eq!(captured.len(), 1);
6592 let req = &captured[0];
6593 assert_eq!(req.method, "HEAD");
6594 assert!(req.body.is_empty(), "HEAD must not carry a body");
6595 }
6596
6597 #[tokio::test]
6598 async fn test_delete_options_trace_with_body_suppressed() {
6599 use tower::ServiceExt;
6600
6601 let (url, _handle, captured) = start_capture_server().await;
6602 let ctx = test_producer_ctx();
6603 let component = HttpComponent::with_config(HttpConfig::default());
6604 let endpoint_ctx = NoOpComponentContext;
6605
6606 for method in ["DELETE", "OPTIONS", "TRACE"] {
6607 let endpoint = component
6608 .create_endpoint(
6609 &format!("{url}?httpMethod={method}&allowInternal=true"),
6610 &endpoint_ctx,
6611 )
6612 .unwrap();
6613 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6614
6615 let mut exchange = Exchange::new(Message::default());
6616 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6617
6618 let result = producer.oneshot(exchange).await.unwrap();
6619 let status = result
6620 .input
6621 .header("CamelHttpResponseCode")
6622 .and_then(|v| v.as_u64())
6623 .unwrap();
6624 assert_eq!(status, 200, "method {method} should succeed");
6625 }
6626
6627 let captured = captured.lock().unwrap();
6628 assert_eq!(captured.len(), 3, "expected three captured requests");
6629 for method in ["DELETE", "OPTIONS", "TRACE"] {
6630 let req = captured
6631 .iter()
6632 .find(|r| r.method == method)
6633 .unwrap_or_else(|| panic!("missing captured request for {method}"));
6634 assert!(req.body.is_empty(), "{} must not carry a body", req.method);
6635 }
6636 }
6637
6638 #[tokio::test]
6639 async fn test_post_put_patch_with_body_still_sent() {
6640 use tower::ServiceExt;
6641
6642 let (url, _handle, captured) = start_capture_server().await;
6643 let ctx = test_producer_ctx();
6644 let component = HttpComponent::with_config(HttpConfig::default());
6645 let endpoint_ctx = NoOpComponentContext;
6646
6647 for method in ["POST", "PUT", "PATCH"] {
6648 let endpoint = component
6649 .create_endpoint(
6650 &format!("{url}?httpMethod={method}&allowInternal=true"),
6651 &endpoint_ctx,
6652 )
6653 .unwrap();
6654 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6655
6656 let payload = format!("body-for-{method}");
6657 let mut exchange = Exchange::new(Message::default());
6658 exchange.input.body = Body::Bytes(bytes::Bytes::from(payload.as_bytes().to_vec()));
6659
6660 let result = producer.oneshot(exchange).await.unwrap();
6661 let status = result
6662 .input
6663 .header("CamelHttpResponseCode")
6664 .and_then(|v| v.as_u64())
6665 .unwrap();
6666 assert_eq!(status, 200, "method {method} should succeed");
6667 }
6668
6669 let captured = captured.lock().unwrap();
6670 assert_eq!(captured.len(), 3, "expected three captured requests");
6671 for method in ["POST", "PUT", "PATCH"] {
6672 let req = captured
6673 .iter()
6674 .find(|r| r.method == method)
6675 .unwrap_or_else(|| panic!("missing captured request for {method}"));
6676 let expected = format!("body-for-{method}");
6677 assert!(!req.body.is_empty(), "{method} must still carry its body");
6678 assert_eq!(req.body, expected.as_bytes(), "{method} body mismatch");
6679 }
6680 }
6681
6682 #[tokio::test]
6686 async fn test_stream_body_under_get_not_attached() {
6687 use tower::ServiceExt;
6688
6689 let (url, _handle, captured) = start_capture_server().await;
6690 let ctx = test_producer_ctx();
6691
6692 let component = HttpComponent::with_config(HttpConfig::default());
6693 let endpoint_ctx = NoOpComponentContext;
6694 let endpoint = component
6695 .create_endpoint(
6696 &format!("{url}?httpMethod=GET&allowInternal=true"),
6697 &endpoint_ctx,
6698 )
6699 .unwrap();
6700 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6701
6702 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
6703 vec![Ok(bytes::Bytes::from_static(b"stream-body"))];
6704 let stream = Box::pin(futures::stream::iter(chunks));
6705 let mut exchange = Exchange::new(Message::default());
6706 exchange.input.body = Body::Stream(StreamBody {
6707 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
6708 metadata: StreamMetadata::default(),
6709 });
6710
6711 let result = producer.oneshot(exchange).await.unwrap();
6712
6713 let status = result
6714 .input
6715 .header("CamelHttpResponseCode")
6716 .and_then(|v| v.as_u64())
6717 .unwrap();
6718 assert_eq!(status, 200);
6719
6720 let captured = captured.lock().unwrap();
6721 assert_eq!(captured.len(), 1, "expected exactly one captured request");
6722 assert!(
6723 captured[0].body.is_empty(),
6724 "GET must not carry a stream body"
6725 );
6726 assert!(
6727 captured[0].transfer_encoding.is_none(),
6728 "suppressed request must not carry Transfer-Encoding"
6729 );
6730 assert!(
6731 captured[0].content_length.is_none(),
6732 "suppressed request must not carry Content-Length"
6733 );
6734 assert!(
6735 result.input.body.is_empty(),
6736 "exchange body must be consumed to Empty, not left as a stream"
6737 );
6738 }
6739
6740 #[tokio::test]
6744 async fn test_redirect_hops_never_replay_suppressed_body() {
6745 use tower::ServiceExt;
6746
6747 let (url, _handle, captured) = start_redirect_capture_server().await;
6748 let ctx = test_producer_ctx();
6749
6750 let component =
6751 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6752 let endpoint_ctx = NoOpComponentContext;
6753
6754 for path in ["/hop307", "/hop308"] {
6755 let endpoint = component
6756 .create_endpoint(
6757 &format!("{url}{path}?httpMethod=GET&allowInternal=true"),
6758 &endpoint_ctx,
6759 )
6760 .unwrap();
6761 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6762
6763 let mut exchange = Exchange::new(Message::default());
6764 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6765
6766 let result = producer.oneshot(exchange).await.unwrap();
6767 let status = result
6768 .input
6769 .header("CamelHttpResponseCode")
6770 .and_then(|v| v.as_u64())
6771 .unwrap();
6772 assert_eq!(
6773 status, 200,
6774 "redirect chain for {path} should end at /final"
6775 );
6776 }
6777
6778 let captured = captured.lock().unwrap();
6780 assert_eq!(captured.len(), 4, "expected 2 chains × 2 hops");
6781 for req in captured.iter() {
6782 assert!(
6783 req.body.is_empty(),
6784 "hop {} {} must not carry a body",
6785 req.method,
6786 req.path
6787 );
6788 }
6789 }
6790
6791 #[tracing_test::traced_test]
6800 #[tokio::test]
6801 async fn test_suppressed_body_logs_exactly_one_warn() {
6802 use tower::ServiceExt;
6803
6804 let (url, _handle, _captured) = start_capture_server().await;
6805 let ctx = test_producer_ctx();
6806
6807 let component = HttpComponent::with_config(HttpConfig::default());
6808 let endpoint_ctx = NoOpComponentContext;
6809 let endpoint = component
6810 .create_endpoint(
6811 &format!("{url}?httpMethod=GET&allowInternal=true"),
6812 &endpoint_ctx,
6813 )
6814 .unwrap();
6815 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6816
6817 let mut exchange = Exchange::new(Message::default());
6818 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6819 let correlation_id = exchange.correlation_id().to_string();
6820
6821 let result = producer.oneshot(exchange).await.unwrap();
6822 let status = result
6823 .input
6824 .header("CamelHttpResponseCode")
6825 .and_then(|v| v.as_u64())
6826 .unwrap();
6827 assert_eq!(status, 200);
6828
6829 logs_assert(|lines: &[&str]| {
6830 let hits = lines
6831 .iter()
6832 .filter(|l| {
6833 l.contains("dropping request body")
6834 && l.contains("method=GET")
6835 && l.contains(&format!("correlation_id={correlation_id}"))
6836 })
6837 .count();
6838 match hits {
6839 1 => Ok(()),
6840 n => Err(format!("expected exactly one body-drop warn, found {n}")),
6841 }
6842 });
6843 }
6844
6845 #[tracing_test::traced_test]
6846 #[tokio::test]
6847 async fn test_empty_body_get_emits_no_warn() {
6848 use tower::ServiceExt;
6849
6850 let (url, _handle, _captured) = start_capture_server().await;
6851 let ctx = test_producer_ctx();
6852
6853 let component = HttpComponent::with_config(HttpConfig::default());
6854 let endpoint_ctx = NoOpComponentContext;
6855 let endpoint = component
6856 .create_endpoint(
6857 &format!("{url}?httpMethod=GET&allowInternal=true"),
6858 &endpoint_ctx,
6859 )
6860 .unwrap();
6861 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6862
6863 let exchange = Exchange::new(Message::default());
6864 let result = producer.oneshot(exchange).await.unwrap();
6865 let status = result
6866 .input
6867 .header("CamelHttpResponseCode")
6868 .and_then(|v| v.as_u64())
6869 .unwrap();
6870 assert_eq!(status, 200);
6871
6872 logs_assert(|lines: &[&str]| {
6873 let hits = lines
6874 .iter()
6875 .filter(|l| l.contains("dropping request body"))
6876 .count();
6877 match hits {
6878 0 => Ok(()),
6879 n => Err(format!("expected no body-drop warn, found {n}")),
6880 }
6881 });
6882 }
6883
6884 #[tokio::test]
6885 async fn test_follow_redirects_false_does_not_follow() {
6886 use tower::ServiceExt;
6887
6888 let (url, _handle) = start_redirect_server().await;
6889 let ctx = test_producer_ctx();
6890
6891 let component =
6892 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
6893 let endpoint_ctx = NoOpComponentContext;
6894 let endpoint = component
6895 .create_endpoint(
6896 &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
6897 &endpoint_ctx,
6898 )
6899 .unwrap();
6900 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6901
6902 let exchange = Exchange::new(Message::default());
6903 let result = producer.oneshot(exchange).await.unwrap();
6904
6905 let status = result
6907 .input
6908 .header("CamelHttpResponseCode")
6909 .and_then(|v| v.as_u64())
6910 .unwrap();
6911 assert_eq!(
6912 status, 302,
6913 "Should NOT follow redirect when followRedirects=false"
6914 );
6915 }
6916
6917 #[tokio::test]
6918 async fn test_follow_redirects_true_follows_redirect() {
6919 use tower::ServiceExt;
6920
6921 let (url, _handle) = start_redirect_server().await;
6922 let ctx = test_producer_ctx();
6923
6924 let component =
6925 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6926 let endpoint_ctx = NoOpComponentContext;
6927 let endpoint = component
6928 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6929 .unwrap();
6930 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6931
6932 let exchange = Exchange::new(Message::default());
6933 let result = producer.oneshot(exchange).await.unwrap();
6934
6935 let status = result
6937 .input
6938 .header("CamelHttpResponseCode")
6939 .and_then(|v| v.as_u64())
6940 .unwrap();
6941 assert_eq!(
6942 status, 200,
6943 "Should follow redirect when followRedirects=true"
6944 );
6945 }
6946
6947 #[tokio::test]
6950 async fn test_redirect_to_private_ip_is_ssrf_blocked() {
6951 use tower::ServiceExt;
6952
6953 let (url, _handle) = start_redirect_server().await;
6955 let ctx = test_producer_ctx();
6956
6957 let component =
6958 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6959 let endpoint_ctx = NoOpComponentContext;
6960 let endpoint = component
6961 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6962 .unwrap();
6963 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6964
6965 let exchange = Exchange::new(Message::default());
6966 let result = producer.oneshot(exchange).await;
6967
6968 assert!(
6970 result.is_ok(),
6971 "Redirect should succeed with allowInternal=true, got: {:?}",
6972 result
6973 );
6974 let exchange = result.unwrap();
6975 let status = exchange
6976 .input
6977 .header("CamelHttpResponseCode")
6978 .and_then(|v| v.as_u64())
6979 .unwrap();
6980 assert_eq!(status, 200, "Should follow redirect to /final");
6981 }
6982
6983 #[tokio::test]
6985 async fn test_redirect_to_private_ip_allowed_when_configured() {
6986 use tower::ServiceExt;
6987
6988 let (url, _handle) = start_redirect_server().await;
6990 let ctx = test_producer_ctx();
6991
6992 let component =
6993 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6994 let endpoint_ctx = NoOpComponentContext;
6995 let endpoint = component
6996 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6997 .unwrap();
6998 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6999
7000 let exchange = Exchange::new(Message::default());
7001 let result = producer.oneshot(exchange).await.unwrap();
7002
7003 let status = result
7004 .input
7005 .header("CamelHttpResponseCode")
7006 .and_then(|v| v.as_u64())
7007 .unwrap();
7008 assert_eq!(
7009 status, 200,
7010 "Should follow redirect to private IP when allowInternal=true"
7011 );
7012 }
7013
7014 #[tokio::test]
7017 async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
7018 use tower::ServiceExt;
7019
7020 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7022 let addr = listener.local_addr().unwrap();
7023 let url = format!("http://127.0.0.1:{}", addr.port());
7024
7025 let handle = tokio::spawn(async move {
7026 use tokio::io::{AsyncReadExt, AsyncWriteExt};
7027 loop {
7028 if let Ok((mut stream, _)) = listener.accept().await {
7029 tokio::spawn(async move {
7030 let mut buf = vec![0u8; 4096];
7031 let _ = stream.read(&mut buf).await;
7032 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";
7034 let _ = stream.write_all(response.as_bytes()).await;
7035 });
7036 }
7037 }
7038 });
7039
7040 let ctx = test_producer_ctx();
7041 let component =
7042 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
7043 let endpoint_ctx = NoOpComponentContext;
7044 let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
7046 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7047
7048 let exchange = Exchange::new(Message::default());
7049 let result = producer.oneshot(exchange).await;
7050
7051 assert!(
7053 result.is_err(),
7054 "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
7055 );
7056 let err = result.unwrap_err().to_string();
7057 assert!(
7058 err.contains("blocked IP")
7059 || err.contains("private IP")
7060 || err.contains("SSRF")
7061 || err.contains("not allowed"),
7062 "Error should mention SSRF/IP blocking, got: {err}"
7063 );
7064
7065 handle.abort();
7066 }
7067
7068 #[tokio::test]
7070 async fn test_too_many_redirects_returns_error() {
7071 use tower::ServiceExt;
7072
7073 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7075 let addr = listener.local_addr().unwrap();
7076 let url = format!("http://127.0.0.1:{}", addr.port());
7077
7078 let handle = tokio::spawn(async move {
7079 use tokio::io::{AsyncReadExt, AsyncWriteExt};
7080 loop {
7081 if let Ok((mut stream, _)) = listener.accept().await {
7082 tokio::spawn(async move {
7083 let mut buf = vec![0u8; 4096];
7084 let _ = stream.read(&mut buf).await;
7085 let response = "HTTP/1.1 302 Found\r\nLocation: /loop\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
7090 let _ = stream.write_all(response.as_bytes()).await;
7091 });
7092 }
7093 }
7094 });
7095
7096 let ctx = test_producer_ctx();
7097 let component =
7098 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
7099 let endpoint_ctx = NoOpComponentContext;
7100 let endpoint = component
7101 .create_endpoint(
7102 &format!("{url}?allowInternal=true&maxRedirects=2"),
7103 &endpoint_ctx,
7104 )
7105 .unwrap();
7106 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7107
7108 let exchange = Exchange::new(Message::default());
7109 let result = producer.oneshot(exchange).await;
7110
7111 match result {
7119 Err(e) => {
7120 let msg = e.to_string();
7122 assert!(
7123 msg.contains("HTTP operation failed") || msg.contains("302"),
7124 "expected redirect-after-exhaustion error, got: {msg}"
7125 );
7126 }
7127 Ok(ex) => {
7128 let response_code = ex
7129 .input
7130 .header("CamelHttpResponseCode")
7131 .and_then(|v| v.as_u64());
7132 assert_eq!(
7133 response_code,
7134 Some(302),
7135 "expected 302 after exhausting redirects"
7136 );
7137 }
7138 }
7139
7140 handle.abort();
7141 }
7142
7143 #[tokio::test]
7144 async fn test_query_params_forwarded_to_http_request() {
7145 use tower::ServiceExt;
7146
7147 let (url, _handle) = start_test_server().await;
7148 let ctx = test_producer_ctx();
7149
7150 let component = HttpComponent::new();
7151 let endpoint_ctx = NoOpComponentContext;
7152 let endpoint = component
7154 .create_endpoint(
7155 &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
7156 &endpoint_ctx,
7157 )
7158 .unwrap();
7159 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7160
7161 let exchange = Exchange::new(Message::default());
7162 let result = producer.oneshot(exchange).await.unwrap();
7163
7164 let status = result
7167 .input
7168 .header("CamelHttpResponseCode")
7169 .and_then(|v| v.as_u64())
7170 .unwrap();
7171 assert_eq!(status, 200);
7172 }
7173
7174 #[test]
7175 fn test_non_camel_query_params_are_forwarded() {
7176 let config = HttpEndpointConfig::from_uri(
7179 "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
7180 )
7181 .unwrap();
7182
7183 assert_eq!(
7186 config.raw_query.as_deref(),
7187 Some("apiKey=secret123&httpMethod=GET&token=abc456")
7188 );
7189 assert!(config.query_params.is_empty());
7190 }
7191
7192 #[test]
7193 fn test_authored_query_bytes_survive_resolve_url() {
7194 let config =
7195 HttpEndpointConfig::from_uri("http://example.com/api?q=hello%20world&tag=a+b").unwrap();
7196 let exchange = Exchange::new(Message::default());
7197
7198 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
7199
7200 assert!(url.contains("q=hello%20world"), "url was: {url}");
7203 assert!(url.contains("tag=a+b"), "url was: {url}");
7204 }
7205
7206 async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
7211 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7212 let addr = listener.local_addr().unwrap();
7213 let url = format!("http://127.0.0.1:{}", addr.port());
7214
7215 let handle = tokio::spawn(async move {
7216 loop {
7217 if let Ok((mut stream, _)) = listener.accept().await {
7218 let delay = delay_ms;
7219 tokio::spawn(async move {
7220 use tokio::io::{AsyncReadExt, AsyncWriteExt};
7221 let mut buf = vec![0u8; 4096];
7222 let _ = stream.read(&mut buf).await;
7223 let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
7225 let _ = stream.write_all(headers.as_bytes()).await;
7226 tokio::time::sleep(Duration::from_millis(delay)).await;
7228 let body = r#"{"status":"slow"}"#;
7229 let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
7230 let _ = stream.write_all(chunk.as_bytes()).await;
7231 });
7232 }
7233 }
7234 });
7235
7236 (url, handle)
7237 }
7238
7239 #[tokio::test]
7240 async fn test_http_producer_timeout() {
7241 use tower::ServiceExt;
7242
7243 let (url, _handle) = start_slow_server(500).await;
7245 let ctx = test_producer_ctx();
7246
7247 let component = HttpComponent::with_config(
7248 HttpConfig::default()
7249 .with_read_timeout_ms(100)
7250 .with_response_timeout_ms(30_000), );
7252 let endpoint_ctx = NoOpComponentContext;
7253 let endpoint = component
7254 .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
7255 .unwrap();
7256 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7257
7258 let exchange = Exchange::new(Message::default());
7259 let result = producer.oneshot(exchange).await;
7260
7261 assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
7262 let err = result.unwrap_err().to_string();
7263 assert!(
7264 err.contains("Read timeout") || err.contains("timeout"),
7265 "Error should mention timeout, got: {}",
7266 err
7267 );
7268 }
7269
7270 #[tokio::test]
7271 async fn test_http_producer_no_timeout_when_fast() {
7272 use tower::ServiceExt;
7273
7274 let (url, _handle) = start_test_server().await;
7275 let ctx = test_producer_ctx();
7276
7277 let component =
7278 HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
7279 let endpoint_ctx = NoOpComponentContext;
7280 let endpoint = component
7281 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
7282 .unwrap();
7283 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7284
7285 let exchange = Exchange::new(Message::default());
7286 let result = producer.oneshot(exchange).await.unwrap();
7287
7288 let status = result
7289 .input
7290 .header("CamelHttpResponseCode")
7291 .and_then(|v| v.as_u64())
7292 .unwrap();
7293 assert_eq!(status, 200);
7294 }
7295
7296 #[tokio::test]
7301 async fn test_http_producer_blocks_metadata_endpoint() {
7302 use tower::ServiceExt;
7303
7304 let ctx = test_producer_ctx();
7305 let component = HttpComponent::new();
7306 let endpoint_ctx = NoOpComponentContext;
7307 let endpoint = component
7308 .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
7309 .unwrap();
7310 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7311
7312 let mut exchange = Exchange::new(Message::default());
7313 exchange.input.set_header(
7314 "CamelHttpUri",
7315 serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
7316 );
7317
7318 let result = producer.oneshot(exchange).await;
7319 assert!(result.is_err(), "Should block AWS metadata endpoint");
7320
7321 let err = result.unwrap_err();
7322 assert!(
7323 err.to_string().contains("Private IP"),
7324 "Error should mention private IP blocking, got: {}",
7325 err
7326 );
7327 }
7328
7329 #[test]
7330 fn test_ssrf_config_defaults() {
7331 let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
7332 assert!(
7333 !config.allow_internal,
7334 "Private IPs should be blocked by default"
7335 );
7336 assert!(
7337 config.blocked_hosts.is_empty(),
7338 "Blocked hosts should be empty by default"
7339 );
7340 }
7341
7342 #[test]
7343 fn test_ssrf_config_allow_internal() {
7344 let config =
7345 HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
7346 assert!(
7347 config.allow_internal,
7348 "Private IPs should be allowed when explicitly set"
7349 );
7350 }
7351
7352 #[test]
7353 fn test_uri_option_allow_cleartext_parses() {
7354 let config =
7355 HttpEndpointConfig::from_uri("http://example.com/?allowCleartext=true").unwrap();
7356 assert!(
7357 config.allow_cleartext,
7358 "allowCleartext=true must parse into the endpoint config"
7359 );
7360
7361 let plain = HttpEndpointConfig::from_uri("http://example.com/").unwrap();
7362 assert!(
7363 !plain.allow_cleartext,
7364 "cleartext consent must default to false"
7365 );
7366
7367 let err =
7368 HttpEndpointConfig::from_uri("http://example.com/?allowCleartext=banana").unwrap_err();
7369 assert!(
7370 matches!(&err, CamelError::InvalidUri(msg) if msg.contains("allowCleartext")),
7371 "bad allowCleartext value must yield InvalidUri naming the option, got: {err:?}"
7372 );
7373 }
7374
7375 #[test]
7379 fn test_camel_http_uri_override_public_cleartext_follows_endpoint_flags() {
7380 let endpoint =
7381 HttpEndpointConfig::from_uri("http://localhost/?allowCleartext=false").unwrap();
7382 let err = crate::ssrf::validate_url_for_ssrf("http://93.184.216.34/exfil", &endpoint)
7383 .expect_err("public cleartext override must be rejected without consent");
7384 assert!(
7385 err.to_string().contains("allowCleartext"),
7386 "error must name the remedy, got: {err}"
7387 );
7388
7389 let endpoint =
7390 HttpEndpointConfig::from_uri("http://localhost/?allowCleartext=true").unwrap();
7391 assert!(
7392 crate::ssrf::validate_url_for_ssrf("http://93.184.216.34/exfil", &endpoint).is_ok(),
7393 "endpoint consent must admit a public cleartext override"
7394 );
7395 }
7396
7397 #[test]
7398 fn test_ssrf_config_blocked_hosts() {
7399 let config = HttpEndpointConfig::from_uri(
7400 "http://example.com/api?blockedHosts=evil.com,malware.net",
7401 )
7402 .unwrap();
7403 assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
7404 }
7405
7406 #[tokio::test]
7407 async fn test_http_producer_blocks_localhost() {
7408 use tower::ServiceExt;
7409
7410 let ctx = test_producer_ctx();
7411 let component = HttpComponent::new();
7412 let endpoint_ctx = NoOpComponentContext;
7413 let endpoint = component
7414 .create_endpoint("http://example.com/api", &endpoint_ctx)
7415 .unwrap();
7416 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7417
7418 let mut exchange = Exchange::new(Message::default());
7419 exchange.input.set_header(
7420 "CamelHttpUri",
7421 serde_json::Value::String("http://localhost:8080/internal".to_string()),
7422 );
7423
7424 let result = producer.oneshot(exchange).await;
7425 assert!(result.is_err(), "Should block localhost");
7426 }
7427
7428 #[tokio::test]
7429 async fn test_http_producer_blocks_loopback_ip() {
7430 use tower::ServiceExt;
7431
7432 let ctx = test_producer_ctx();
7433 let component = HttpComponent::new();
7434 let endpoint_ctx = NoOpComponentContext;
7435 let endpoint = component
7436 .create_endpoint("http://example.com/api", &endpoint_ctx)
7437 .unwrap();
7438 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7439
7440 let mut exchange = Exchange::new(Message::default());
7441 exchange.input.set_header(
7442 "CamelHttpUri",
7443 serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
7444 );
7445
7446 let result = producer.oneshot(exchange).await;
7447 assert!(result.is_err(), "Should block loopback IP");
7448 }
7449
7450 #[tokio::test]
7451 async fn test_http_producer_allows_private_ip_when_enabled() {
7452 use tower::ServiceExt;
7453
7454 let ctx = test_producer_ctx();
7455 let component = HttpComponent::new();
7456 let endpoint_ctx = NoOpComponentContext;
7457 let endpoint = component
7460 .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
7461 .unwrap();
7462 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7463
7464 let exchange = Exchange::new(Message::default());
7465
7466 let result = producer.oneshot(exchange).await;
7469 if let Err(ref e) = result {
7471 let err_str = e.to_string();
7472 assert!(
7473 !err_str.contains("Private IP") && !err_str.contains("not allowed"),
7474 "Should not be SSRF error, got: {}",
7475 err_str
7476 );
7477 }
7478 }
7479
7480 #[test]
7485 fn test_http_server_config_parse() {
7486 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
7487 assert_eq!(cfg.host, "0.0.0.0");
7488 assert_eq!(cfg.port, 8080);
7489 assert_eq!(cfg.path, "/orders");
7490 assert_eq!(cfg.max_inflight_requests, 1024);
7491 }
7492
7493 #[test]
7494 fn test_http_server_config_scheme() {
7495 assert_eq!(HttpServerConfig::scheme(), "http");
7497 }
7498
7499 #[test]
7500 fn test_http_server_config_from_components() {
7501 let components = camel_component_api::UriComponents {
7503 scheme: "https".to_string(),
7504 path: "//0.0.0.0:8443/api".to_string(),
7505 params: std::collections::HashMap::from([
7506 ("maxRequestBody".to_string(), "5242880".to_string()),
7507 ("maxInflightRequests".to_string(), "7".to_string()),
7508 ]),
7509 raw_query: None,
7510 };
7511 let cfg = HttpServerConfig::from_components(components).unwrap();
7512 assert_eq!(cfg.host, "0.0.0.0");
7513 assert_eq!(cfg.port, 8443);
7514 assert_eq!(cfg.path, "/api");
7515 assert_eq!(cfg.max_request_body, 5242880);
7516 assert_eq!(cfg.max_inflight_requests, 7);
7517 }
7518
7519 #[test]
7520 fn test_http_server_config_default_path() {
7521 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
7522 assert_eq!(cfg.path, "/");
7523 }
7524
7525 #[test]
7526 fn test_http_server_config_wrong_scheme() {
7527 assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
7528 }
7529
7530 #[test]
7531 fn test_http_server_config_invalid_port() {
7532 assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
7533 }
7534
7535 #[test]
7536 fn test_http_server_config_default_port_by_scheme() {
7537 let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
7539 assert_eq!(cfg_http.port, 80);
7540
7541 let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
7543 assert_eq!(cfg_https.port, 443);
7544 }
7545
7546 #[test]
7547 fn test_request_envelope_and_reply_are_send() {
7548 fn assert_send<T: Send>() {}
7549 assert_send::<RequestEnvelope>();
7550 assert_send::<HttpReply>();
7551 }
7552
7553 #[test]
7558 fn test_server_registry_global_is_singleton() {
7559 let r1 = ServerRegistry::global();
7560 let r2 = ServerRegistry::global();
7561 assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
7562 }
7563
7564 #[allow(clippy::await_holding_lock)]
7565 #[tokio::test]
7566 async fn test_concurrent_get_or_spawn_returns_same_registry() {
7567 let _guard = lock_registry_test_mutex();
7568 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7569 let port = listener.local_addr().unwrap().port();
7570 drop(listener);
7571
7572 let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
7573 Arc::new(std::sync::Mutex::new(Vec::new()));
7574
7575 let mut handles = Vec::new();
7576 for _ in 0..4 {
7577 let results = results.clone();
7578 handles.push(tokio::spawn(async move {
7579 let registry = ServerRegistry::global()
7580 .get_or_spawn(
7581 "127.0.0.1",
7582 port,
7583 2 * 1024 * 1024,
7584 10 * 1024 * 1024,
7585 1024,
7586 test_rt(),
7587 "test-route".into(),
7588 None,
7589 )
7590 .await
7591 .unwrap();
7592 results.lock().unwrap().push(registry);
7593 }));
7594 }
7595
7596 for h in handles {
7597 h.await.unwrap();
7598 }
7599
7600 let registries = results.lock().unwrap();
7601 assert_eq!(registries.len(), 4);
7602 for i in 1..registries.len() {
7603 assert!(
7604 Arc::ptr_eq(®istries[0].inner, ®istries[i].inner),
7605 "all concurrent callers should get same route registry"
7606 );
7607 }
7608 }
7609
7610 #[test]
7611 fn test_server_registry_distinguishes_host_and_port() {
7612 let _guard = lock_registry_test_mutex();
7613 let rt = tokio::runtime::Runtime::new().expect("runtime");
7614 rt.block_on(async {
7615 let registry = ServerRegistry::global();
7616 let d1 = registry
7620 .get_or_spawn(
7621 "127.0.0.1",
7622 0,
7623 1024 * 1024,
7624 10 * 1024 * 1024,
7625 1024,
7626 test_rt(),
7627 "test-route-1".into(),
7628 None,
7629 )
7630 .await;
7631 let d2 = registry
7632 .get_or_spawn(
7633 "0.0.0.0",
7634 0,
7635 1024 * 1024,
7636 10 * 1024 * 1024,
7637 1024,
7638 test_rt(),
7639 "test-route-2".into(),
7640 None,
7641 )
7642 .await;
7643 assert!(d1.is_ok());
7644 assert!(d2.is_ok());
7645 assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
7646 });
7647 }
7648
7649 #[allow(clippy::await_holding_lock)]
7650 #[tokio::test]
7651 async fn test_shared_server_max_request_body_policy_is_deterministic() {
7652 let _guard = lock_registry_test_mutex();
7653 let registry = ServerRegistry::global();
7654 let d1 = registry
7656 .get_or_spawn(
7657 "127.0.0.1",
7658 9991,
7659 1024 * 1024,
7660 10 * 1024 * 1024,
7661 1024,
7662 test_rt(),
7663 "test-route".into(),
7664 None,
7665 )
7666 .await;
7667 assert!(d1.is_ok());
7668
7669 let d2 = registry
7672 .get_or_spawn(
7673 "127.0.0.1",
7674 9991,
7675 2 * 1024 * 1024,
7676 10 * 1024 * 1024,
7677 1024,
7678 test_rt(),
7679 "test-route-2".into(),
7680 None,
7681 )
7682 .await;
7683 assert!(d2.is_err());
7684 let err = d2.unwrap_err();
7685 assert!(
7686 err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
7687 "Expected incompatible maxRequestBody error, got: {}",
7688 err
7689 );
7690 }
7691
7692 #[test]
7693 fn test_server_registry_reset_clears_entries() {
7694 let _guard = lock_registry_test_mutex();
7695 let rt = tokio::runtime::Runtime::new().expect("runtime");
7696 rt.block_on(async {
7697 let d1 = ServerRegistry::global()
7699 .get_or_spawn(
7700 "127.0.0.1",
7701 9992,
7702 1024 * 1024,
7703 10 * 1024 * 1024,
7704 1024,
7705 test_rt(),
7706 "test-route".into(),
7707 None,
7708 )
7709 .await;
7710 assert!(d1.is_ok());
7711
7712 let guard = ServerRegistry::global().inner.lock().expect("lock");
7714 assert!(guard.entries.contains_key(&("127.0.0.1".to_string(), 9992)));
7715 drop(guard);
7716
7717 ServerRegistry::reset();
7719
7720 let guard = ServerRegistry::global().inner.lock().expect("lock");
7722 assert!(
7723 guard.entries.is_empty(),
7724 "registry should be empty after reset, has {} entries",
7725 guard.entries.len()
7726 );
7727 });
7728 }
7729
7730 #[allow(clippy::await_holding_lock)]
7731 #[tokio::test]
7732 async fn registry_rejects_tls_on_plain_port() {
7733 let _guard = lock_registry_test_mutex();
7738 ServerRegistry::reset();
7739 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
7740
7741 let _r1 = ServerRegistry::global()
7743 .get_or_spawn(
7744 "127.0.0.1",
7745 0,
7746 1024,
7747 1024,
7748 16,
7749 Arc::clone(&rt),
7750 "route-1".into(),
7751 None, )
7753 .await;
7754
7755 let result = ServerRegistry::global()
7757 .get_or_spawn(
7758 "127.0.0.1",
7759 0,
7760 1024,
7761 1024,
7762 16,
7763 Arc::clone(&rt),
7764 "route-2".into(),
7765 Some(crate::config::ServerTlsConfig {
7766 cert_path: "/x.pem".into(),
7767 key_path: "/y.pem".into(),
7768 }),
7769 )
7770 .await;
7771 assert!(result.is_err(), "must reject TLS on plain port");
7772 }
7773
7774 #[allow(clippy::await_holding_lock)]
7780 #[tokio::test]
7781 async fn test_unregister_last_http_route_keeps_server_alive() {
7782 let _guard = lock_registry_test_mutex();
7783 ServerRegistry::reset();
7784 let registry = ServerRegistry::global();
7785
7786 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7787 let port = listener.local_addr().unwrap().port();
7788 drop(listener); let rt = test_rt();
7790
7791 let _r1 = registry
7794 .get_or_spawn(
7795 "127.0.0.1",
7796 port,
7797 1024 * 1024,
7798 10 * 1024 * 1024,
7799 16,
7800 rt.clone(),
7801 "test-route-1".into(),
7802 None,
7803 )
7804 .await
7805 .unwrap();
7806 let _r2 = registry
7807 .get_or_spawn(
7808 "127.0.0.1",
7809 port,
7810 1024 * 1024,
7811 10 * 1024 * 1024,
7812 16,
7813 rt,
7814 "test-route-2".into(),
7815 None,
7816 )
7817 .await
7818 .unwrap();
7819
7820 let key = ("127.0.0.1".to_string(), port);
7821 let cell = {
7822 let guard = registry.inner.lock().expect("lock");
7823 guard.entries.get(&key).expect("entry should exist").clone()
7824 };
7825
7826 registry.unregister("127.0.0.1", port).await;
7828 {
7829 let handle = cell
7830 .get()
7831 .expect("handle should still exist after first unregister");
7832 assert!(
7833 !handle.monitor_task.is_finished(),
7834 "monitor task should still be alive after first unregister"
7835 );
7836 }
7837
7838 registry.unregister("127.0.0.1", port).await;
7840 tokio::time::sleep(Duration::from_millis(20)).await;
7841 {
7842 let handle = cell
7843 .get()
7844 .expect("handle should still exist after last unregister");
7845 assert!(
7846 !handle.monitor_task.is_finished(),
7847 "monitor task should still be alive — server is process-lifetime"
7848 );
7849 }
7850
7851 {
7853 let guard = registry.inner.lock().expect("lock");
7854 assert!(
7855 guard.entries.contains_key(&key),
7856 "entry should remain in registry — server kept alive for restart"
7857 );
7858 }
7859 }
7860
7861 async fn clone_fixture_listener() -> (
7870 tokio::net::TcpListener,
7871 std::net::TcpListener,
7872 std::net::SocketAddr,
7873 ) {
7874 let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind std listener");
7875 let probe = l.try_clone().expect("clone probe");
7876 l.set_nonblocking(true).expect("set_nonblocking");
7877 let listener = tokio::net::TcpListener::from_std(l).expect("from_std");
7878 let addr = listener.local_addr().expect("local_addr");
7879 (listener, probe, addr)
7880 }
7881
7882 fn staged_limits() -> (usize, usize, usize) {
7884 (1024 * 1024, 10 * 1024 * 1024, 1024)
7885 }
7886
7887 #[allow(clippy::await_holding_lock)]
7888 #[tokio::test]
7889 async fn staged_listener_first_spawn_serves_without_second_bind() {
7890 let _guard = lock_registry_test_mutex();
7891 ServerRegistry::reset();
7892 let registry = ServerRegistry::global();
7893 let (listener, _probe, addr) = clone_fixture_listener().await;
7894 let port = addr.port();
7895 registry
7896 .stage_listener(listener)
7897 .await
7898 .expect("stage listener");
7899
7900 let (max_req, max_res, max_inflight) = staged_limits();
7901 let routes = registry
7902 .get_or_spawn(
7903 "127.0.0.1",
7904 port,
7905 max_req,
7906 max_res,
7907 max_inflight,
7908 test_rt(),
7909 "staged-first-spawn".into(),
7910 None,
7911 )
7912 .await
7913 .expect("spawn from staged listener must succeed");
7914
7915 assert_eq!(
7916 registry.bound_addr("127.0.0.1", port),
7917 Some(addr),
7918 "served socket must be the staged listener's addr"
7919 );
7920 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__staged_probe__"))
7923 .await
7924 .expect("http request against staged listener must connect");
7925 assert!(
7926 resp.status().as_u16() >= 200,
7927 "any status proves the staged socket serves"
7928 );
7929 drop(routes);
7930 }
7931
7932 #[allow(clippy::await_holding_lock)]
7933 #[tokio::test]
7934 async fn staged_entry_reused_by_second_caller() {
7935 let _guard = lock_registry_test_mutex();
7936 ServerRegistry::reset();
7937 let registry = ServerRegistry::global();
7938 let (listener, _probe, addr) = clone_fixture_listener().await;
7939 let port = addr.port();
7940 registry
7941 .stage_listener(listener)
7942 .await
7943 .expect("stage listener");
7944
7945 let (max_req, max_res, max_inflight) = staged_limits();
7946 let first = registry
7947 .get_or_spawn(
7948 "127.0.0.1",
7949 port,
7950 max_req,
7951 max_res,
7952 max_inflight,
7953 test_rt(),
7954 "staged-reuse-1".into(),
7955 None,
7956 )
7957 .await
7958 .expect("first spawn from staged listener");
7959 let second = registry
7960 .get_or_spawn(
7961 "127.0.0.1",
7962 port,
7963 max_req,
7964 max_res,
7965 max_inflight,
7966 test_rt(),
7967 "staged-reuse-2".into(),
7968 None,
7969 )
7970 .await
7971 .expect("second caller must reuse the entry");
7972 assert_eq!(
7973 registry.bound_addr("127.0.0.1", port),
7974 Some(addr),
7975 "entry reused — bound addr unchanged, no second bind"
7976 );
7977 drop(first);
7978 drop(second);
7979 }
7980
7981 #[allow(clippy::await_holding_lock)]
7982 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7983 async fn staged_race_two_callers_single_resolver() {
7984 let _guard = lock_registry_test_mutex();
7985 ServerRegistry::reset();
7986 let registry = ServerRegistry::global();
7987 let (listener, _probe, addr) = clone_fixture_listener().await;
7988 let port = addr.port();
7989 registry
7990 .stage_listener(listener)
7991 .await
7992 .expect("stage listener");
7993
7994 let (max_req, max_res, max_inflight) = staged_limits();
7999 let (first, second) = tokio::join!(
8000 registry.get_or_spawn(
8001 "127.0.0.1",
8002 port,
8003 max_req,
8004 max_res,
8005 max_inflight,
8006 test_rt(),
8007 "staged-race-1".into(),
8008 None,
8009 ),
8010 registry.get_or_spawn(
8011 "127.0.0.1",
8012 port,
8013 max_req,
8014 max_res,
8015 max_inflight,
8016 test_rt(),
8017 "staged-race-2".into(),
8018 None,
8019 ),
8020 );
8021 let first = first.expect("first racing caller must succeed");
8022 let second = second.expect("second racing caller must succeed");
8023 assert_eq!(
8024 registry.bound_addr("127.0.0.1", port),
8025 Some(addr),
8026 "single entry must be served from the staged socket — no EADDRINUSE path"
8027 );
8028 drop(first);
8029 drop(second);
8030 }
8031
8032 #[allow(clippy::await_holding_lock)]
8033 #[tokio::test]
8034 async fn unstaged_spawn_binds_legacy() {
8035 let _guard = lock_registry_test_mutex();
8036 ServerRegistry::reset();
8037 let registry = ServerRegistry::global();
8038 let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe bind");
8040 let port = probe.local_addr().expect("local addr").port();
8041 drop(probe);
8042
8043 let (max_req, max_res, max_inflight) = staged_limits();
8044 registry
8045 .get_or_spawn(
8046 "127.0.0.1",
8047 port,
8048 max_req,
8049 max_res,
8050 max_inflight,
8051 test_rt(),
8052 "legacy-bind".into(),
8053 None,
8054 )
8055 .await
8056 .expect("legacy bind spawn");
8057 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__legacy_probe__"))
8058 .await
8059 .expect("connect to freshly bound port must succeed");
8060 assert!(resp.status().as_u16() >= 200);
8061 assert_eq!(
8062 registry.bound_addr("127.0.0.1", port),
8063 Some(std::net::SocketAddr::from(([127, 0, 0, 1], port))),
8064 "bound addr must be the legacy bound (host, port)"
8065 );
8066 }
8067
8068 #[allow(clippy::await_holding_lock)]
8069 #[tokio::test]
8070 async fn wrong_host_staged_port_fails_deterministically() {
8071 let _guard = lock_registry_test_mutex();
8072 ServerRegistry::reset();
8073 let registry = ServerRegistry::global();
8074 let (listener, _probe, addr) = clone_fixture_listener().await;
8075 let port = addr.port();
8076 registry
8077 .stage_listener(listener)
8078 .await
8079 .expect("stage listener under 127.0.0.1");
8080
8081 let (max_req, max_res, max_inflight) = staged_limits();
8082 let err = registry
8083 .get_or_spawn(
8084 "localhost",
8085 port,
8086 max_req,
8087 max_res,
8088 max_inflight,
8089 test_rt(),
8090 "conflict-probe".into(),
8091 None,
8092 )
8093 .await
8094 .expect_err("wrong host on staged port must fail deterministically");
8095 assert!(
8096 err.to_string().contains("staged listener conflict on port"),
8097 "unexpected error: {err}"
8098 );
8099
8100 registry
8102 .get_or_spawn(
8103 "127.0.0.1",
8104 port,
8105 max_req,
8106 max_res,
8107 max_inflight,
8108 test_rt(),
8109 "conflict-after".into(),
8110 None,
8111 )
8112 .await
8113 .expect("correct host must serve the staged listener");
8114 assert_eq!(
8115 registry.bound_addr("127.0.0.1", port),
8116 Some(addr),
8117 "staged slot must be untouched by the conflicting call"
8118 );
8119 }
8120
8121 #[allow(clippy::await_holding_lock)]
8122 #[tokio::test]
8123 async fn duplicate_stage_same_key_rejected() {
8124 let _guard = lock_registry_test_mutex();
8125 ServerRegistry::reset();
8126 let registry = ServerRegistry::global();
8127 let (listener, probe, addr) = clone_fixture_listener().await;
8128 registry
8129 .stage_listener(listener)
8130 .await
8131 .expect("stage listener A");
8132
8133 let dup = probe.try_clone().expect("clone2");
8135 dup.set_nonblocking(true).expect("set_nonblocking2");
8136 let b = tokio::net::TcpListener::from_std(dup).expect("from_std2");
8137
8138 let err = registry
8139 .stage_listener(b)
8140 .await
8141 .expect_err("duplicate stage must be rejected");
8142 assert!(
8143 err.to_string().contains("listener already staged"),
8144 "unexpected error: {err}"
8145 );
8146
8147 let (max_req, max_res, max_inflight) = staged_limits();
8148 registry
8149 .get_or_spawn(
8150 "127.0.0.1",
8151 addr.port(),
8152 max_req,
8153 max_res,
8154 max_inflight,
8155 test_rt(),
8156 "dup-stage-after".into(),
8157 None,
8158 )
8159 .await
8160 .expect("spawn from first staged listener");
8161 assert_eq!(
8162 registry.bound_addr("127.0.0.1", addr.port()),
8163 Some(addr),
8164 "first staged listener retained"
8165 );
8166 }
8167
8168 #[allow(clippy::await_holding_lock)]
8169 #[tokio::test]
8170 async fn distinct_keys_stage_independently() {
8171 let _guard = lock_registry_test_mutex();
8172 ServerRegistry::reset();
8173 let registry = ServerRegistry::global();
8174 let (l1, _p1, addr1) = clone_fixture_listener().await;
8175 let (l2, _p2, addr2) = clone_fixture_listener().await;
8176 registry.stage_listener(l1).await.expect("stage P1");
8177 registry.stage_listener(l2).await.expect("stage P2");
8178
8179 let (max_req, max_res, max_inflight) = staged_limits();
8180 registry
8181 .get_or_spawn(
8182 "127.0.0.1",
8183 addr1.port(),
8184 max_req,
8185 max_res,
8186 max_inflight,
8187 test_rt(),
8188 "distinct-1".into(),
8189 None,
8190 )
8191 .await
8192 .expect("spawn P1");
8193 registry
8194 .get_or_spawn(
8195 "127.0.0.1",
8196 addr2.port(),
8197 max_req,
8198 max_res,
8199 max_inflight,
8200 test_rt(),
8201 "distinct-2".into(),
8202 None,
8203 )
8204 .await
8205 .expect("spawn P2");
8206 assert_eq!(
8207 registry.bound_addr("127.0.0.1", addr1.port()),
8208 Some(addr1),
8209 "P1 bound addr must be its own listener"
8210 );
8211 assert_eq!(
8212 registry.bound_addr("127.0.0.1", addr2.port()),
8213 Some(addr2),
8214 "P2 bound addr must be its own listener"
8215 );
8216 let r1 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr1.port()))
8217 .await
8218 .expect("connect P1");
8219 assert!(r1.status().as_u16() >= 200);
8220 let r2 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr2.port()))
8221 .await
8222 .expect("connect P2");
8223 assert!(r2.status().as_u16() >= 200);
8224 }
8225
8226 #[allow(clippy::await_holding_lock)]
8227 #[tokio::test]
8228 async fn tls_prebound_listener_served() {
8229 use camel_component_api::test_support::tls;
8230
8231 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
8234
8235 let _guard = lock_registry_test_mutex();
8236 ServerRegistry::reset();
8237 let registry = ServerRegistry::global();
8238 let (listener, _probe, addr) = clone_fixture_listener().await;
8239 let port = addr.port();
8240
8241 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
8242 let cert_path = tls::write_pem_tmp("http-staged-tls-cert.pem", &cert_pem);
8243 let key_path = tls::write_pem_tmp("http-staged-tls-key.pem", &key_pem);
8244 let ca_path = tls::write_pem_tmp("http-staged-tls-ca.pem", &ca_pem);
8245
8246 let (max_req, max_res, max_inflight) = staged_limits();
8247 let routes = registry
8248 .get_or_spawn_with_listener(
8249 listener,
8250 max_req,
8251 max_res,
8252 max_inflight,
8253 test_rt(),
8254 "staged-tls".into(),
8255 Some(crate::config::ServerTlsConfig {
8256 cert_path: cert_path.to_string_lossy().into_owned(),
8257 key_path: key_path.to_string_lossy().into_owned(),
8258 }),
8259 )
8260 .await
8261 .expect("spawn TLS server from pre-bound listener");
8262
8263 let ca_bytes = std::fs::read(&ca_path).expect("read ca pem");
8266 let client = reqwest::Client::builder()
8267 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).expect("parse ca pem"))
8268 .build()
8269 .expect("build tls client");
8270
8271 let resp = client
8272 .get(format!("https://127.0.0.1:{port}/__staged_tls__"))
8273 .send()
8274 .await
8275 .expect("TLS handshake + request must succeed");
8276 assert_eq!(resp.status().as_u16(), 404, "unknown path 404s through TLS");
8277 assert_eq!(
8278 registry.bound_addr("127.0.0.1", port),
8279 Some(addr),
8280 "bound addr equals the pre-bound listener addr"
8281 );
8282 drop(routes);
8283 }
8284
8285 #[allow(clippy::await_holding_lock)]
8286 #[tokio::test]
8287 async fn with_listener_direct_spawn_keyed_by_actual_addr() {
8288 let _guard = lock_registry_test_mutex();
8289 ServerRegistry::reset();
8290 let registry = ServerRegistry::global();
8291 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
8292 .await
8293 .expect("bind un-staged listener");
8294 let addr = listener.local_addr().expect("local addr");
8295 let port = addr.port();
8296
8297 let (max_req, max_res, max_inflight) = staged_limits();
8298 registry
8299 .get_or_spawn_with_listener(
8300 listener,
8301 max_req,
8302 max_res,
8303 max_inflight,
8304 test_rt(),
8305 "with-listener".into(),
8306 None,
8307 )
8308 .await
8309 .expect("direct spawn from un-staged listener");
8310 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__with_listener__"))
8311 .await
8312 .expect("connect on actual port");
8313 assert!(resp.status().as_u16() >= 200);
8314 assert_eq!(
8315 registry.bound_addr("127.0.0.1", port),
8316 Some(addr),
8317 "registry key is the listener's actual port"
8318 );
8319
8320 registry
8321 .get_or_spawn(
8322 "127.0.0.1",
8323 port,
8324 max_req,
8325 max_res,
8326 max_inflight,
8327 test_rt(),
8328 "with-listener-reuse".into(),
8329 None,
8330 )
8331 .await
8332 .expect("legacy caller must reuse the entry");
8333 assert_eq!(
8334 registry.bound_addr("127.0.0.1", port),
8335 Some(addr),
8336 "entry reused — no second bind"
8337 );
8338 }
8339
8340 #[tokio::test]
8345 async fn test_dispatch_handler_returns_404_for_unknown_path() {
8346 let registry = HttpRouteRegistry::new();
8347 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8349 let port = listener.local_addr().unwrap().port();
8350 tokio::spawn(run_axum_server(
8351 listener,
8352 registry,
8353 2 * 1024 * 1024,
8354 10 * 1024 * 1024,
8355 Arc::new(tokio::sync::Semaphore::new(1024)),
8356 test_rt(),
8357 "test-route".into(),
8358 ));
8359
8360 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
8362
8363 let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
8364 .await
8365 .unwrap();
8366 assert_eq!(resp.status().as_u16(), 404);
8367 }
8368
8369 #[tokio::test]
8374 async fn test_http_consumer_start_registers_path() {
8375 use camel_component_api::ConsumerContext;
8376
8377 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8379 let port = listener.local_addr().unwrap().port();
8380 drop(listener); let consumer_cfg = HttpServerConfig {
8383 scheme: "http".to_string(),
8384 host: "127.0.0.1".to_string(),
8385 port,
8386 path: "/ping".to_string(),
8387 max_request_body: 2 * 1024 * 1024,
8388 max_response_body: 10 * 1024 * 1024,
8389 max_inflight_requests: 1024,
8390 method: None,
8391 tls_config: None,
8392 };
8393 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8394
8395 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8396 let token = tokio_util::sync::CancellationToken::new();
8397 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8398
8399 tokio::spawn(async move {
8400 consumer.start(ctx).await.unwrap();
8401 });
8402
8403 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8404
8405 let client = plain_http_test_client();
8406 let resp_future = client
8407 .post(format!("http://127.0.0.1:{port}/ping"))
8408 .body("hello world")
8409 .send();
8410
8411 let (http_result, _) = tokio::join!(resp_future, async {
8412 if let Some(mut envelope) = rx.recv().await {
8413 envelope.exchange.input.set_header(
8415 "CamelHttpResponseCode",
8416 serde_json::Value::Number(201.into()),
8417 );
8418 if let Some(reply_tx) = envelope.reply_tx {
8419 let _ = reply_tx.send(Ok(envelope.exchange));
8420 }
8421 }
8422 });
8423
8424 let resp = http_result.unwrap();
8425 assert_eq!(resp.status().as_u16(), 201);
8426
8427 token.cancel();
8428 }
8429
8430 #[tokio::test]
8435 async fn http_consumer_raw_dispatch_carries_in_flight_claim() {
8436 use camel_component_api::ConsumerContext;
8437 use camel_component_api::StartupSignal;
8438
8439 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8440 let port = listener.local_addr().unwrap().port();
8441 drop(listener);
8442
8443 let consumer_cfg = HttpServerConfig {
8444 scheme: "http".to_string(),
8445 host: "127.0.0.1".to_string(),
8446 port,
8447 path: "/claim".to_string(),
8448 max_request_body: 2 * 1024 * 1024,
8449 max_response_body: 10 * 1024 * 1024,
8450 max_inflight_requests: 1024,
8451 method: None,
8452 tls_config: None,
8453 };
8454 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8455
8456 let counter = std::sync::Arc::new(camel_api::InFlightGauge::new());
8457 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8458 let token = tokio_util::sync::CancellationToken::new();
8459 let (signal, startup_rx) = StartupSignal::pair();
8460 let ctx = ConsumerContext::new(tx, token.clone(), "http-claim-route".to_string())
8461 .with_startup(signal)
8462 .with_in_flight_counter(std::sync::Arc::clone(&counter));
8463
8464 tokio::spawn(async move {
8465 consumer.start(ctx).await.unwrap();
8466 });
8467
8468 tokio::time::timeout(std::time::Duration::from_secs(5), startup_rx.await_ready())
8471 .await
8472 .expect("startup must resolve within 5s")
8473 .expect("startup must be Ok");
8474
8475 let client = plain_http_test_client();
8476 let (http_result, claim) = tokio::join!(
8477 client
8478 .post(format!("http://127.0.0.1:{port}/claim"))
8479 .body("hello")
8480 .send(),
8481 async {
8482 let mut envelope = rx.recv().await.expect("envelope must arrive");
8483 let claim = envelope
8484 .in_flight_claim
8485 .take()
8486 .expect("raw dispatch must carry an acceptance-minted claim");
8487 assert_eq!(
8488 counter.total(),
8489 1,
8490 "exact total: the acceptance mint is the only live claim"
8491 );
8492 let reply_tx = envelope.reply_tx.take().expect("reply channel must be set");
8496 let reply_exchange = Exchange::new(camel_component_api::Message::new(
8497 camel_component_api::Body::Text("done".to_string()),
8498 ));
8499 reply_tx
8500 .send(Ok(reply_exchange))
8501 .expect("reply must be taken");
8502 claim
8503 },
8504 );
8505
8506 let resp = http_result.expect("http roundtrip must complete");
8507 assert_eq!(resp.status().as_u16(), 200);
8508
8509 drop(claim);
8510 assert_eq!(
8511 counter.total(),
8512 0,
8513 "release exactly once when the holder drops"
8514 );
8515 token.cancel();
8516 }
8517
8518 #[test]
8522 fn test_envelope_channel_capacity_follows_max_inflight() {
8523 assert_eq!(envelope_channel_capacity(0), 1);
8524 assert_eq!(envelope_channel_capacity(1), 1);
8525 assert_eq!(envelope_channel_capacity(7), 7);
8526 assert_eq!(envelope_channel_capacity(64), 64);
8527 assert_eq!(envelope_channel_capacity(1024), 1024);
8528 }
8529
8530 #[tokio::test]
8534 async fn test_http_consumer_start_with_zero_max_inflight_rejects_503() {
8535 use camel_component_api::ConsumerContext;
8536
8537 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8538 let port = listener.local_addr().unwrap().port();
8539 drop(listener);
8540
8541 let consumer_cfg = HttpServerConfig {
8542 scheme: "http".to_string(),
8543 host: "127.0.0.1".to_string(),
8544 port,
8545 path: "/ping".to_string(),
8546 max_request_body: 2 * 1024 * 1024,
8547 max_response_body: 10 * 1024 * 1024,
8548 max_inflight_requests: 0,
8549 method: None,
8550 tls_config: None,
8551 };
8552 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8553
8554 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8555 let token = tokio_util::sync::CancellationToken::new();
8556 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8557
8558 let start_handle = tokio::spawn(async move {
8559 consumer.start(ctx).await.unwrap();
8560 });
8561
8562 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8563
8564 let client = plain_http_test_client();
8565 let resp = client
8566 .post(format!("http://127.0.0.1:{port}/ping"))
8567 .body("hello world")
8568 .send()
8569 .await
8570 .unwrap();
8571 assert_eq!(resp.status().as_u16(), 503);
8572
8573 token.cancel();
8574 let _ = start_handle.await;
8575 }
8576
8577 #[test]
8589 fn test_max_inflight_requests_limit_boundary() {
8590 let l = tokio::sync::Semaphore::MAX_PERMITS;
8591 assert_eq!(max_inflight_requests_limit(l - 1).unwrap(), l - 1);
8592 assert_eq!(max_inflight_requests_limit(l).unwrap(), l);
8593 assert_eq!(max_inflight_requests_limit(0).unwrap(), 0);
8594 match max_inflight_requests_limit(l + 1) {
8595 Err(CamelError::Config(msg)) => {
8596 assert!(msg.contains("maxInflightRequests"), "message was: {msg}");
8597 assert!(msg.contains(&format!("{}", l + 1)), "message was: {msg}");
8598 assert!(msg.contains(&format!("{l}")), "message was: {msg}");
8599 }
8600 other => panic!("expected CamelError::Config, got: {other:?}"),
8601 }
8602 let _ = tokio::sync::Semaphore::new(l);
8603 }
8604
8605 #[test]
8610 fn test_parse_http_uri_max_inflight_at_limit_accepted() {
8611 let l = tokio::sync::Semaphore::MAX_PERMITS;
8612 let uri = format!("http://localhost:8080/api?maxInflightRequests={l}");
8613 let cfg = HttpServerConfig::from_uri(&uri).unwrap();
8614 assert_eq!(cfg.max_inflight_requests, l);
8615 }
8616
8617 #[test]
8621 fn test_parse_http_uri_max_inflight_above_limit_rejected() {
8622 let l = tokio::sync::Semaphore::MAX_PERMITS;
8623 let uri = format!("http://localhost:8080/api?maxInflightRequests={}", l + 1);
8624 match HttpServerConfig::from_uri(&uri) {
8625 Err(CamelError::Config(msg)) => {
8626 assert!(msg.contains("maxInflightRequests"), "message was: {msg}");
8627 assert!(msg.contains(&format!("{}", l + 1)), "message was: {msg}");
8628 assert!(msg.contains(&format!("{l}")), "message was: {msg}");
8629 }
8630 other => panic!("expected CamelError::Config, got: {other:?}"),
8631 }
8632 }
8633
8634 #[test]
8639 fn test_create_consumer_rejects_oversized_max_inflight() {
8640 let l = tokio::sync::Semaphore::MAX_PERMITS;
8641 let endpoint = HttpEndpoint {
8642 uri: "http://0.0.0.0:8080/api".to_string(),
8643 config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
8644 server_config: HttpServerConfig {
8645 scheme: "http".to_string(),
8646 host: "0.0.0.0".to_string(),
8647 port: 8080,
8648 path: "/api".to_string(),
8649 max_request_body: 2 * 1024 * 1024,
8650 max_response_body: 10 * 1024 * 1024,
8651 max_inflight_requests: l + 1,
8652 method: None,
8653 tls_config: None,
8654 },
8655 client: reqwest::Client::new(),
8656 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
8657 PINNED_CLIENT_TTL,
8658 PINNED_CLIENT_MAX_ENTRIES,
8659 )),
8660 http_config: HttpConfig::default(),
8661 };
8662 match endpoint.create_consumer(rt()) {
8663 Err(CamelError::Config(msg)) => {
8664 assert!(msg.contains("maxInflightRequests"), "message was: {msg}");
8665 assert!(msg.contains(&format!("{}", l + 1)), "message was: {msg}");
8666 assert!(msg.contains(&format!("{l}")), "message was: {msg}");
8667 }
8668 Err(other) => panic!("expected CamelError::Config, got: {other:?}"),
8669 Ok(_) => panic!("expected error, got Ok"),
8670 }
8671 }
8672
8673 #[tokio::test]
8679 async fn test_http_consumer_start_rejects_oversized_before_spawn() {
8680 use camel_component_api::ConsumerContext;
8681
8682 let l = tokio::sync::Semaphore::MAX_PERMITS;
8683 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8684 let port = listener.local_addr().unwrap().port();
8685 drop(listener);
8686
8687 let consumer_cfg = HttpServerConfig {
8688 scheme: "http".to_string(),
8689 host: "127.0.0.1".to_string(),
8690 port,
8691 path: "/ping".to_string(),
8692 max_request_body: 2 * 1024 * 1024,
8693 max_response_body: 10 * 1024 * 1024,
8694 max_inflight_requests: l + 1,
8695 method: None,
8696 tls_config: None,
8697 };
8698 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8699
8700 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8701 let token = tokio_util::sync::CancellationToken::new();
8702 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8703
8704 match consumer.start(ctx).await {
8705 Err(CamelError::Config(msg)) => {
8706 assert!(msg.contains("maxInflightRequests"), "message was: {msg}");
8707 assert!(msg.contains(&format!("{}", l + 1)), "message was: {msg}");
8708 assert!(msg.contains(&format!("{l}")), "message was: {msg}");
8709 }
8710 other => panic!("expected CamelError::Config, got: {other:?}"),
8711 }
8712 token.cancel();
8713 }
8714
8715 #[tokio::test]
8719 async fn test_spawn_entry_rejects_oversized_before_semaphore() {
8720 let l = tokio::sync::Semaphore::MAX_PERMITS;
8721 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8722 let addr = listener.local_addr().unwrap();
8723 let key: ServerKey = (addr.ip().to_string(), addr.port());
8724
8725 let result = spawn_entry(
8726 key,
8727 ListenerSource::Staged(listener),
8728 1,
8729 1,
8730 l + 1,
8731 test_rt(),
8732 "route-test".into(),
8733 None,
8734 )
8735 .await;
8736
8737 match result {
8738 Err(CamelError::Config(msg)) => {
8739 assert!(msg.contains("maxInflightRequests"), "message was: {msg}");
8740 assert!(msg.contains(&format!("{}", l + 1)), "message was: {msg}");
8741 assert!(msg.contains(&format!("{l}")), "message was: {msg}");
8742 }
8743 Err(other) => panic!("expected CamelError::Config, got: {other:?}"),
8744 Ok(_) => panic!("expected CamelError::Config, got Ok"),
8745 }
8746 }
8747
8748 #[test]
8751 fn test_http_consumer_startup_mode_is_explicit() {
8752 use camel_component_api::ConsumerStartupMode;
8753 let consumer_cfg = HttpServerConfig {
8754 scheme: "http".to_string(),
8755 host: "127.0.0.1".to_string(),
8756 port: 0,
8757 path: "/x".to_string(),
8758 max_request_body: 2 * 1024 * 1024,
8759 max_response_body: 10 * 1024 * 1024,
8760 max_inflight_requests: 1024,
8761 method: None,
8762 tls_config: None,
8763 };
8764 let consumer = HttpConsumer::new(consumer_cfg, test_rt());
8765 assert_eq!(
8766 consumer.startup_mode(),
8767 ConsumerStartupMode::Explicit,
8768 "HttpConsumer must opt into Explicit startup"
8769 );
8770 }
8771
8772 #[allow(clippy::await_holding_lock)]
8778 #[tokio::test]
8779 async fn test_http_consumer_emits_mark_ready_after_bind() {
8780 use camel_component_api::{ConsumerContext, StartupSignal};
8781
8782 let _guard = lock_registry_test_mutex();
8783
8784 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8785 let port = listener.local_addr().unwrap().port();
8786 drop(listener);
8787
8788 let consumer_cfg = HttpServerConfig {
8789 scheme: "http".to_string(),
8790 host: "127.0.0.1".to_string(),
8791 port,
8792 path: "/ready-probe".to_string(),
8793 max_request_body: 2 * 1024 * 1024,
8794 max_response_body: 10 * 1024 * 1024,
8795 max_inflight_requests: 1024,
8796 method: None,
8797 tls_config: None,
8798 };
8799 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8800
8801 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8802 let token = tokio_util::sync::CancellationToken::new();
8803 let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
8804
8805 let (signal, startup_rx) = StartupSignal::pair();
8807 let ctx = ctx.with_startup(signal);
8808
8809 tokio::spawn(async move {
8812 let _ = consumer.start(ctx).await;
8813 });
8814
8815 let result =
8820 tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
8821 .await
8822 .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
8823 assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
8824
8825 token.cancel();
8827 }
8828
8829 #[derive(Default, Clone)]
8836 struct ErrorRecordingRuntime {
8837 errors: std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>>,
8838 }
8839
8840 impl camel_api::MetricsCollector for ErrorRecordingRuntime {
8841 fn record_exchange_duration(&self, _route_id: &str, _duration: std::time::Duration) {}
8842 fn increment_errors(&self, route_id: &str, error_type: &str) {
8843 self.errors
8844 .lock()
8845 .expect("error recorder lock")
8846 .push((route_id.to_string(), error_type.to_string()));
8847 }
8848 fn increment_exchanges(&self, _route_id: &str) {}
8849 fn set_queue_depth(&self, _queue: &str, _depth: usize) {}
8850 fn record_circuit_breaker_change(&self, _route_id: &str, _from: &str, _to: &str) {}
8851 }
8852
8853 impl camel_component_api::HealthCheckRegistry for ErrorRecordingRuntime {
8854 fn force_unhealthy_for_route(&self, _route_id: &str, _name: &str, _reason: &str) {}
8855 }
8856
8857 impl camel_component_api::RuntimeObservability for ErrorRecordingRuntime {
8858 fn metrics(&self) -> std::sync::Arc<dyn camel_api::MetricsCollector> {
8859 std::sync::Arc::new(self.clone())
8860 }
8861 fn health(&self) -> std::sync::Arc<dyn camel_component_api::HealthCheckRegistry> {
8862 std::sync::Arc::new(self.clone())
8863 }
8864 }
8865
8866 #[allow(clippy::await_holding_lock)]
8881 #[tokio::test]
8882 async fn shared_server_death_fails_every_hosted_consumer() {
8883 use camel_component_api::{ConsumerContext, StartupSignal};
8884
8885 let _guard = lock_registry_test_mutex();
8886 ServerRegistry::reset();
8887
8888 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8890 let port = listener.local_addr().unwrap().port();
8891 drop(listener);
8892
8893 let rt = ErrorRecordingRuntime::default();
8894
8895 let make_consumer = |path: &str| {
8896 HttpConsumer::new(
8897 HttpServerConfig {
8898 scheme: "http".to_string(),
8899 host: "127.0.0.1".to_string(),
8900 port,
8901 path: path.to_string(),
8902 max_request_body: 2 * 1024 * 1024,
8903 max_response_body: 10 * 1024 * 1024,
8904 max_inflight_requests: 16,
8905 method: None,
8906 tls_config: None,
8907 },
8908 std::sync::Arc::new(rt.clone()),
8909 )
8910 };
8911
8912 let spawn_consumer = |path: &str, route_id: &str| {
8913 let mut consumer = make_consumer(path);
8914 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8915 let token = tokio_util::sync::CancellationToken::new();
8916 let ctx = ConsumerContext::new(tx, token, route_id.to_string());
8917 let (signal, startup_rx) = StartupSignal::pair();
8918 let ctx = ctx.with_startup(signal);
8919 let task = tokio::spawn(async move { consumer.start(ctx).await });
8920 (task, startup_rx)
8921 };
8922
8923 let (task_a, ready_a) = spawn_consumer("/zombie-a", "zombie-route-a");
8925 let (task_b, ready_b) = spawn_consumer("/zombie-b", "zombie-route-b");
8926
8927 for (name, ready) in [("a", ready_a), ("b", ready_b)] {
8929 let result =
8930 tokio::time::timeout(std::time::Duration::from_secs(2), ready.await_ready())
8931 .await
8932 .unwrap_or_else(|_| panic!("consumer {name} never became ready"));
8933 assert!(
8934 result.is_ok(),
8935 "consumer {name} readiness must resolve Ok (bind + registration complete)"
8936 );
8937 }
8938
8939 {
8942 let registry = ServerRegistry::global();
8943 let guard = registry.inner.lock().expect("ServerRegistry lock");
8944 let cell = guard
8945 .entries
8946 .get(&("127.0.0.1".to_string(), port))
8947 .expect("shared server entry must exist");
8948 let handle = cell.get().expect("server handle must be initialized");
8949 handle.server_abort.abort();
8950 }
8951
8952 let outcome_a = tokio::time::timeout(std::time::Duration::from_secs(2), task_a)
8955 .await
8956 .expect("ZOMBIE: consumer-a still running after shared server death (rc-szmob)");
8957 let outcome_b = tokio::time::timeout(std::time::Duration::from_secs(2), task_b)
8958 .await
8959 .expect("ZOMBIE: consumer-b still running after shared server death (rc-szmob)");
8960
8961 let err_a = outcome_a
8962 .expect("consumer-a task must join")
8963 .expect_err("consumer-a start() must return Err when the shared server dies");
8964 let err_b = outcome_b
8965 .expect("consumer-b task must join")
8966 .expect_err("consumer-b start() must return Err when the shared server dies");
8967
8968 for (name, err) in [("a", &err_a), ("b", &err_b)] {
8971 assert!(
8972 err.to_string().contains("127.0.0.1")
8973 && err.to_string().contains(&port.to_string()),
8974 "consumer-{name} error must name the dead shared server, got: {err}"
8975 );
8976 }
8977
8978 let recorded = rt.errors.lock().expect("error recorder lock").clone();
8981 assert!(
8982 recorded
8983 .iter()
8984 .any(|(route, label)| label == "e:http:server-task-exited"
8985 && route == "zombie-route-a"),
8986 "expected e:http:server-task-exited for the spawning route, got: {recorded:?}"
8987 );
8988 }
8989
8990 #[tokio::test]
8991 async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
8992 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8993
8994 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8995 let port = listener.local_addr().unwrap().port();
8996 drop(listener);
8997
8998 let consumer_cfg = HttpServerConfig {
8999 scheme: "http".to_string(),
9000 host: "127.0.0.1".to_string(),
9001 port,
9002 path: "/saturation".to_string(),
9003 max_request_body: 2 * 1024 * 1024,
9004 max_response_body: 10 * 1024 * 1024,
9005 max_inflight_requests: 1,
9006 method: None,
9007 tls_config: None,
9008 };
9009 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
9010
9011 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9012 let token = tokio_util::sync::CancellationToken::new();
9013 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9014 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9015 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9016
9017 let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
9018 let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
9019
9020 tokio::spawn(async move {
9021 let mut first_seen_tx = Some(first_seen_tx);
9022 let mut unblock_first_rx = Some(unblock_first_rx);
9023
9024 loop {
9025 match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
9026 Ok(Some(envelope)) => {
9027 if let Some(tx) = first_seen_tx.take() {
9028 let _ = tx.send(());
9029 if let Some(rx_unblock) = unblock_first_rx.take() {
9030 let _ = tokio::time::timeout(Duration::from_secs(2), rx_unblock)
9034 .await
9035 .expect("first exchange unblock within 2s");
9036 }
9037 }
9038
9039 if let Some(reply_tx) = envelope.reply_tx {
9040 let _ = reply_tx.send(Ok(envelope.exchange));
9041 }
9042 }
9043 Ok(None) => break, Err(_) => break, }
9048 }
9049 });
9050
9051 let client = plain_http_test_client();
9052 let first_req = {
9053 let client = client.clone();
9054 async move {
9055 client
9056 .get(format!("http://127.0.0.1:{port}/saturation"))
9057 .send()
9058 .await
9059 .unwrap()
9060 }
9061 };
9062
9063 let first_handle = tokio::spawn(first_req);
9064 first_seen_rx.await.unwrap();
9065
9066 let second_resp = client
9067 .get(format!("http://127.0.0.1:{port}/saturation"))
9068 .send()
9069 .await
9070 .unwrap();
9071
9072 assert_eq!(second_resp.status().as_u16(), 503);
9073
9074 let _ = unblock_first_tx.send(());
9075 let first_resp = first_handle.await.unwrap();
9076 assert_eq!(first_resp.status().as_u16(), 200);
9077
9078 token.cancel();
9079 }
9080
9081 #[tokio::test]
9085 async fn test_http_consumer_chunked_body_is_capped() {
9086 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9087
9088 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9089 let port = listener.local_addr().unwrap().port();
9090 drop(listener);
9091
9092 let consumer_cfg = HttpServerConfig {
9093 scheme: "http".to_string(),
9094 host: "127.0.0.1".to_string(),
9095 port,
9096 path: "/chunked-cap".to_string(),
9097 max_request_body: 1024, max_response_body: 10 * 1024 * 1024,
9099 max_inflight_requests: 16,
9100 method: None,
9101 tls_config: None,
9102 };
9103 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
9104
9105 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9106 let token = tokio_util::sync::CancellationToken::new();
9107 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9108 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9109 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9110
9111 let chunks: Vec<Result<bytes::Bytes, std::io::Error>> = (0..8)
9113 .map(|_| Ok(bytes::Bytes::from(vec![b'A'; 512])))
9114 .collect();
9115 let stream_body = reqwest::Body::wrap_stream(futures::stream::iter(chunks));
9116
9117 let client = plain_http_test_client();
9118 let send_fut = client
9119 .post(format!("http://127.0.0.1:{port}/chunked-cap"))
9120 .body(stream_body)
9121 .send();
9122
9123 let (http_result, _) = tokio::join!(send_fut, async {
9124 if let Some(mut envelope) = rx.recv().await {
9125 let materialized = envelope
9127 .exchange
9128 .input
9129 .body
9130 .clone()
9131 .into_bytes(64 * 1024)
9132 .await;
9133 assert!(
9134 materialized.is_err(),
9135 "materializing a 4 KiB chunked body under a 1 KiB cap must fail"
9136 );
9137 let err = materialized.unwrap_err().to_string();
9138 assert!(
9139 err.contains("limit") || err.contains("exceeds"),
9140 "error should mention the limit: {err}"
9141 );
9142 if let Some(reply_tx) = envelope.reply_tx {
9143 envelope.exchange.input.body =
9144 camel_component_api::Body::Text("handled".to_string());
9145 let _ = reply_tx.send(Ok(envelope.exchange));
9146 }
9147 }
9148 });
9149
9150 let resp = http_result.unwrap();
9151 assert_eq!(resp.status().as_u16(), 200);
9152
9153 token.cancel();
9154 }
9155
9156 #[tokio::test]
9157 #[allow(clippy::await_holding_lock)]
9158 async fn test_http_consumer_enforces_max_response_body_for_bytes() {
9159 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9160
9161 let _guard = lock_registry_test_mutex();
9162
9163 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9164 let port = listener.local_addr().unwrap().port();
9165 drop(listener);
9166
9167 let consumer_cfg = HttpServerConfig {
9168 scheme: "http".to_string(),
9169 host: "127.0.0.1".to_string(),
9170 port,
9171 path: "/limit-bytes".to_string(),
9172 max_request_body: 2 * 1024 * 1024,
9173 max_response_body: 16,
9174 max_inflight_requests: 1024,
9175 method: None,
9176 tls_config: None,
9177 };
9178 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
9179
9180 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9181 let token = tokio_util::sync::CancellationToken::new();
9182 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9183 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9184 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9185
9186 let client = plain_http_test_client();
9187 let send_fut = client
9188 .get(format!("http://127.0.0.1:{port}/limit-bytes"))
9189 .send();
9190
9191 let (http_result, _) = tokio::join!(send_fut, async {
9192 if let Some(mut envelope) = rx.recv().await {
9193 envelope.exchange.input.body =
9194 camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
9195 if let Some(reply_tx) = envelope.reply_tx {
9196 let _ = reply_tx.send(Ok(envelope.exchange));
9197 }
9198 }
9199 });
9200
9201 let resp = http_result.unwrap();
9202 assert_eq!(resp.status().as_u16(), 500);
9203 let body = resp.text().await.unwrap();
9204 assert_eq!(body, "Response body exceeds configured limit");
9205 token.cancel();
9206 }
9207
9208 #[tokio::test]
9209 #[allow(clippy::await_holding_lock)]
9210 async fn test_http_consumer_enforces_max_response_body_for_json() {
9211 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9212
9213 let _guard = lock_registry_test_mutex();
9214
9215 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9216 let port = listener.local_addr().unwrap().port();
9217 drop(listener);
9218
9219 let consumer_cfg = HttpServerConfig {
9220 scheme: "http".to_string(),
9221 host: "127.0.0.1".to_string(),
9222 port,
9223 path: "/limit-json".to_string(),
9224 max_request_body: 2 * 1024 * 1024,
9225 max_response_body: 16,
9226 max_inflight_requests: 1024,
9227 method: None,
9228 tls_config: None,
9229 };
9230 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
9231
9232 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9233 let token = tokio_util::sync::CancellationToken::new();
9234 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9235 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9236 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9237
9238 let client = plain_http_test_client();
9239 let send_fut = client
9240 .get(format!("http://127.0.0.1:{port}/limit-json"))
9241 .send();
9242
9243 let (http_result, _) = tokio::join!(send_fut, async {
9244 if let Some(mut envelope) = rx.recv().await {
9245 envelope.exchange.input.body = camel_component_api::Body::Json(
9246 serde_json::json!({"message":"this response is bigger than sixteen"}),
9247 );
9248 if let Some(reply_tx) = envelope.reply_tx {
9249 let _ = reply_tx.send(Ok(envelope.exchange));
9250 }
9251 }
9252 });
9253
9254 let resp = http_result.unwrap();
9255 assert_eq!(resp.status().as_u16(), 500);
9256 let body = resp.text().await.unwrap();
9257 assert_eq!(body, "Response body exceeds configured limit");
9258 token.cancel();
9259 }
9260
9261 #[tokio::test]
9262 #[allow(clippy::await_holding_lock)]
9263 async fn test_http_consumer_enforces_max_response_body_for_xml() {
9264 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9265
9266 let _guard = lock_registry_test_mutex();
9267
9268 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9269 let port = listener.local_addr().unwrap().port();
9270 drop(listener);
9271
9272 let consumer_cfg = HttpServerConfig {
9273 scheme: "http".to_string(),
9274 host: "127.0.0.1".to_string(),
9275 port,
9276 path: "/limit-xml".to_string(),
9277 max_request_body: 2 * 1024 * 1024,
9278 max_response_body: 16,
9279 max_inflight_requests: 1024,
9280 method: None,
9281 tls_config: None,
9282 };
9283 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
9284
9285 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9286 let token = tokio_util::sync::CancellationToken::new();
9287 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9288 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9289 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9290
9291 let client = plain_http_test_client();
9292 let send_fut = client
9293 .get(format!("http://127.0.0.1:{port}/limit-xml"))
9294 .send();
9295
9296 let (http_result, _) = tokio::join!(send_fut, async {
9297 if let Some(mut envelope) = rx.recv().await {
9298 envelope.exchange.input.body = camel_component_api::Body::Xml(
9299 "<root><value>way-too-large</value></root>".into(),
9300 );
9301 if let Some(reply_tx) = envelope.reply_tx {
9302 let _ = reply_tx.send(Ok(envelope.exchange));
9303 }
9304 }
9305 });
9306
9307 let resp = http_result.unwrap();
9308 assert_eq!(resp.status().as_u16(), 500);
9309 let body = resp.text().await.unwrap();
9310 assert_eq!(body, "Response body exceeds configured limit");
9311 token.cancel();
9312 }
9313
9314 #[tokio::test]
9315 #[allow(clippy::await_holding_lock)]
9316 async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
9317 use camel_component_api::{
9318 CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
9319 };
9320 use futures::stream;
9321
9322 let _guard = lock_registry_test_mutex();
9323
9324 let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
9325 let port = listener.local_addr().unwrap().port();
9326 drop(listener);
9327
9328 let consumer_cfg = HttpServerConfig {
9329 scheme: "http".to_string(),
9330 host: "0.0.0.0".to_string(),
9331 port,
9332 path: "/limit-stream".to_string(),
9333 max_request_body: 2 * 1024 * 1024,
9334 max_response_body: 16,
9335 max_inflight_requests: 1024,
9336 method: None,
9337 tls_config: None,
9338 };
9339 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
9340
9341 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9342 let token = tokio_util::sync::CancellationToken::new();
9343 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9344 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9345 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9346
9347 let client = plain_http_test_client();
9348 let send_fut = client
9349 .get(format!("http://127.0.0.1:{port}/limit-stream"))
9350 .send();
9351
9352 let (http_result, _) = tokio::join!(send_fut, async {
9353 if let Some(mut envelope) = rx.recv().await {
9354 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
9355 vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
9356 let stream = Box::pin(stream::iter(chunks));
9357 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
9358 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
9359 metadata: StreamMetadata {
9360 size_hint: Some(32),
9361 content_type: Some("application/octet-stream".into()),
9362 origin: None,
9363 },
9364 });
9365 if let Some(reply_tx) = envelope.reply_tx {
9366 let _ = reply_tx.send(Ok(envelope.exchange));
9367 }
9368 }
9369 });
9370
9371 let resp = http_result.unwrap();
9372 assert_eq!(resp.status().as_u16(), 200);
9373 let body = resp.bytes().await.unwrap();
9374 assert_eq!(body.len(), 32);
9375 token.cancel();
9376 }
9377
9378 #[tokio::test]
9383 #[allow(clippy::await_holding_lock)]
9384 async fn test_integration_single_consumer_round_trip() {
9385 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9386
9387 let _guard = lock_registry_test_mutex();
9391
9392 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9394 let port = listener.local_addr().unwrap().port();
9395 drop(listener); let component = HttpComponent::new();
9398 let endpoint_ctx = NoOpComponentContext;
9399 let endpoint = component
9400 .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
9401 .unwrap();
9402 let mut consumer = endpoint.create_consumer(rt()).unwrap();
9403
9404 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9405 let token = tokio_util::sync::CancellationToken::new();
9406 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9407
9408 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9409 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9410
9411 let client = plain_http_test_client();
9412 let send_fut = client
9413 .post(format!("http://127.0.0.1:{port}/echo"))
9414 .header("Content-Type", "text/plain")
9415 .body("ping")
9416 .send();
9417
9418 let (http_result, _) = tokio::join!(send_fut, async {
9419 if let Some(mut envelope) = rx.recv().await {
9420 assert_eq!(
9421 envelope.exchange.input.header("CamelHttpMethod"),
9422 Some(&serde_json::Value::String("POST".into()))
9423 );
9424 assert_eq!(
9425 envelope.exchange.input.header("CamelHttpPath"),
9426 Some(&serde_json::Value::String("/echo".into()))
9427 );
9428 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
9429 if let Some(reply_tx) = envelope.reply_tx {
9430 let _ = reply_tx.send(Ok(envelope.exchange));
9431 }
9432 }
9433 });
9434
9435 let resp = http_result.unwrap();
9436 assert_eq!(resp.status().as_u16(), 200);
9437 let body = resp.text().await.unwrap();
9438 assert_eq!(body, "pong");
9439
9440 token.cancel();
9441 }
9442
9443 #[tokio::test]
9444 #[allow(clippy::await_holding_lock)]
9445 async fn test_integration_two_consumers_shared_port() {
9446 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9447
9448 let _guard = lock_registry_test_mutex();
9449
9450 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9452 let port = listener.local_addr().unwrap().port();
9453 drop(listener);
9454
9455 let component = HttpComponent::new();
9456 let endpoint_ctx = NoOpComponentContext;
9457
9458 let endpoint_a = component
9460 .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
9461 .unwrap();
9462 let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
9463
9464 let endpoint_b = component
9466 .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
9467 .unwrap();
9468 let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
9469
9470 let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9471 let token_a = tokio_util::sync::CancellationToken::new();
9472 let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
9473
9474 let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9475 let token_b = tokio_util::sync::CancellationToken::new();
9476 let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
9477
9478 tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
9479 tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
9480 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9481
9482 let client = plain_http_test_client();
9483
9484 let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
9486 let (resp_hello, _) = tokio::join!(fut_hello, async {
9487 if let Some(mut envelope) = rx_a.recv().await {
9488 envelope.exchange.input.body =
9489 camel_component_api::Body::Text("hello-response".to_string());
9490 if let Some(reply_tx) = envelope.reply_tx {
9491 let _ = reply_tx.send(Ok(envelope.exchange));
9492 }
9493 }
9494 });
9495
9496 let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
9498 let (resp_world, _) = tokio::join!(fut_world, async {
9499 if let Some(mut envelope) = rx_b.recv().await {
9500 envelope.exchange.input.body =
9501 camel_component_api::Body::Text("world-response".to_string());
9502 if let Some(reply_tx) = envelope.reply_tx {
9503 let _ = reply_tx.send(Ok(envelope.exchange));
9504 }
9505 }
9506 });
9507
9508 let body_a = resp_hello.unwrap().text().await.unwrap();
9509 let body_b = resp_world.unwrap().text().await.unwrap();
9510
9511 assert_eq!(body_a, "hello-response");
9512 assert_eq!(body_b, "world-response");
9513
9514 token_a.cancel();
9515 token_b.cancel();
9516 }
9517
9518 #[tokio::test]
9519 #[allow(clippy::await_holding_lock)]
9520 async fn test_integration_unregistered_path_returns_404() {
9521 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9522
9523 let _guard = lock_registry_test_mutex();
9524
9525 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9527 let port = listener.local_addr().unwrap().port();
9528 drop(listener);
9529
9530 let component = HttpComponent::new();
9531 let endpoint_ctx = NoOpComponentContext;
9532 let endpoint = component
9533 .create_endpoint(
9534 &format!("http://127.0.0.1:{port}/registered"),
9535 &endpoint_ctx,
9536 )
9537 .unwrap();
9538 let mut consumer = endpoint.create_consumer(rt()).unwrap();
9539
9540 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9541 let token = tokio_util::sync::CancellationToken::new();
9542 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9543
9544 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9545
9546 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
9548 loop {
9549 if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
9550 .await
9551 .is_ok()
9552 {
9553 break;
9554 }
9555 if std::time::Instant::now() >= deadline {
9556 panic!("HTTP server did not start within 5s on port {port}");
9557 }
9558 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
9559 }
9560
9561 let client = plain_http_test_client();
9562 let resp = client
9563 .get(format!("http://127.0.0.1:{port}/not-there"))
9564 .send()
9565 .await
9566 .unwrap();
9567 assert_eq!(resp.status().as_u16(), 404);
9568
9569 token.cancel();
9570 }
9571
9572 #[test]
9573 fn test_http_consumer_declares_concurrent() {
9574 use camel_component_api::ConcurrencyModel;
9575
9576 let config = HttpServerConfig {
9577 scheme: "http".to_string(),
9578 host: "127.0.0.1".to_string(),
9579 port: 19999,
9580 path: "/test".to_string(),
9581 max_request_body: 2 * 1024 * 1024,
9582 max_response_body: 10 * 1024 * 1024,
9583 max_inflight_requests: 1024,
9584 method: None,
9585 tls_config: None,
9586 };
9587 let consumer = HttpConsumer::new(config, test_rt());
9588 assert_eq!(
9589 consumer.concurrency_model(),
9590 ConcurrencyModel::Concurrent { max: None }
9591 );
9592 }
9593
9594 #[test]
9595 fn server_config_parses_tls_cert_and_key() {
9596 let cfg = HttpServerConfig::from_uri(
9597 "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
9598 )
9599 .unwrap();
9600 assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
9601 assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
9602 }
9603
9604 #[test]
9605 fn server_config_no_tls_when_params_absent() {
9606 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
9607 assert!(cfg.tls_config.is_none());
9608 }
9609
9610 #[tokio::test]
9615 async fn test_http_reply_body_stream_variant_exists() {
9616 use bytes::Bytes;
9617 use camel_component_api::CamelError;
9618 use futures::stream;
9619
9620 let chunks: Vec<Result<Bytes, CamelError>> =
9621 vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
9622 let stream = Box::pin(stream::iter(chunks));
9623 let reply_body = HttpReplyBody::Stream(stream);
9624 match reply_body {
9626 HttpReplyBody::Stream(_) => {}
9627 HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
9628 }
9629 }
9630
9631 #[cfg(feature = "otel")]
9636 mod otel_tests {
9637 use super::*;
9638 use camel_component_api::Message;
9639 use tower::ServiceExt;
9640
9641 #[tokio::test]
9642 async fn test_producer_injects_traceparent_header() {
9643 let (url, _handle) = start_test_server_with_header_capture().await;
9644 let ctx = test_producer_ctx();
9645
9646 let component = HttpComponent::new();
9647 let endpoint_ctx = NoOpComponentContext;
9648 let endpoint = component
9649 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
9650 .unwrap();
9651 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
9652
9653 let mut exchange = Exchange::new(Message::default());
9655 let mut headers = std::collections::HashMap::new();
9656 headers.insert(
9657 "traceparent".to_string(),
9658 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
9659 );
9660 camel_otel::extract_into_exchange(&mut exchange, &headers);
9661
9662 let result = producer.oneshot(exchange).await.unwrap();
9663
9664 let status = result
9666 .input
9667 .header("CamelHttpResponseCode")
9668 .and_then(|v| v.as_u64())
9669 .unwrap();
9670 assert_eq!(status, 200);
9671
9672 let traceparent = result.input.header("X-Received-Traceparent");
9674 assert!(
9675 traceparent.is_some(),
9676 "traceparent header should have been sent"
9677 );
9678
9679 let traceparent_str = traceparent.unwrap().as_str().unwrap();
9680 let parts: Vec<&str> = traceparent_str.split('-').collect();
9682 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
9683 assert_eq!(parts[0], "00", "version should be 00");
9684 assert_eq!(
9685 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
9686 "trace-id should match"
9687 );
9688 assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
9689 assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
9690 }
9691
9692 #[tokio::test]
9693 async fn test_consumer_extracts_traceparent_header() {
9694 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9695
9696 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9698 let port = listener.local_addr().unwrap().port();
9699 drop(listener);
9700
9701 let component = HttpComponent::new();
9702 let endpoint_ctx = NoOpComponentContext;
9703 let endpoint = component
9704 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
9705 .unwrap();
9706 let mut consumer = endpoint.create_consumer(rt()).unwrap();
9707
9708 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9709 let token = tokio_util::sync::CancellationToken::new();
9710 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9711
9712 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9713 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9714
9715 let client = plain_http_test_client();
9717 let send_fut = client
9718 .post(format!("http://127.0.0.1:{port}/trace"))
9719 .header(
9720 "traceparent",
9721 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
9722 )
9723 .body("test")
9724 .send();
9725
9726 let (http_result, _) = tokio::join!(send_fut, async {
9727 if let Some(envelope) = rx.recv().await {
9728 let mut injected_headers = std::collections::HashMap::new();
9731 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
9732
9733 assert!(
9734 injected_headers.contains_key("traceparent"),
9735 "Exchange should have traceparent after extraction"
9736 );
9737
9738 let traceparent = injected_headers.get("traceparent").unwrap();
9739 let parts: Vec<&str> = traceparent.split('-').collect();
9740 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
9741 assert_eq!(
9742 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
9743 "Trace ID should match the original traceparent header"
9744 );
9745
9746 if let Some(reply_tx) = envelope.reply_tx {
9747 let _ = reply_tx.send(Ok(envelope.exchange));
9748 }
9749 }
9750 });
9751
9752 let resp = http_result.unwrap();
9753 assert_eq!(resp.status().as_u16(), 200);
9754
9755 token.cancel();
9756 }
9757
9758 #[tokio::test]
9759 async fn test_consumer_extracts_mixed_case_traceparent_header() {
9760 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9761
9762 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9764 let port = listener.local_addr().unwrap().port();
9765 drop(listener);
9766
9767 let component = HttpComponent::new();
9768 let endpoint_ctx = NoOpComponentContext;
9769 let endpoint = component
9770 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
9771 .unwrap();
9772 let mut consumer = endpoint.create_consumer(rt()).unwrap();
9773
9774 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9775 let token = tokio_util::sync::CancellationToken::new();
9776 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9777
9778 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9779 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9780
9781 let client = plain_http_test_client();
9783 let send_fut = client
9784 .post(format!("http://127.0.0.1:{port}/trace"))
9785 .header(
9786 "TraceParent",
9787 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
9788 )
9789 .body("test")
9790 .send();
9791
9792 let (http_result, _) = tokio::join!(send_fut, async {
9793 if let Some(envelope) = rx.recv().await {
9794 let mut injected_headers = HashMap::new();
9797 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
9798
9799 assert!(
9800 injected_headers.contains_key("traceparent"),
9801 "Exchange should have traceparent after extraction from mixed-case header"
9802 );
9803
9804 let traceparent = injected_headers.get("traceparent").unwrap();
9805 let parts: Vec<&str> = traceparent.split('-').collect();
9806 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
9807 assert_eq!(
9808 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
9809 "Trace ID should match the original mixed-case TraceParent header"
9810 );
9811
9812 if let Some(reply_tx) = envelope.reply_tx {
9813 let _ = reply_tx.send(Ok(envelope.exchange));
9814 }
9815 }
9816 });
9817
9818 let resp = http_result.unwrap();
9819 assert_eq!(resp.status().as_u16(), 200);
9820
9821 token.cancel();
9822 }
9823
9824 #[tokio::test]
9825 async fn test_producer_no_trace_context_no_crash() {
9826 let (url, _handle) = start_test_server().await;
9827 let ctx = test_producer_ctx();
9828
9829 let component = HttpComponent::new();
9830 let endpoint_ctx = NoOpComponentContext;
9831 let endpoint = component
9832 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
9833 .unwrap();
9834 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
9835
9836 let exchange = Exchange::new(Message::default());
9838
9839 let result = producer.oneshot(exchange).await.unwrap();
9841
9842 let status = result
9844 .input
9845 .header("CamelHttpResponseCode")
9846 .and_then(|v| v.as_u64())
9847 .unwrap();
9848 assert_eq!(status, 200);
9849 }
9850
9851 async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
9853 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9854 let addr = listener.local_addr().unwrap();
9855 let url = format!("http://127.0.0.1:{}", addr.port());
9856
9857 let handle = tokio::spawn(async move {
9858 loop {
9859 if let Ok((mut stream, _)) = listener.accept().await {
9860 tokio::spawn(async move {
9861 use tokio::io::{AsyncReadExt, AsyncWriteExt};
9862 let mut buf = vec![0u8; 8192];
9863 let n = stream.read(&mut buf).await.unwrap_or(0);
9864 let request = String::from_utf8_lossy(&buf[..n]).to_string();
9865
9866 let traceparent = request
9868 .lines()
9869 .find(|line| line.to_lowercase().starts_with("traceparent:"))
9870 .map(|line| {
9871 line.split(':')
9872 .nth(1)
9873 .map(|s| s.trim().to_string())
9874 .unwrap_or_default()
9875 })
9876 .unwrap_or_default();
9877
9878 let body =
9879 format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
9880 let response = format!(
9881 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
9882 body.len(),
9883 traceparent,
9884 body
9885 );
9886 let _ = stream.write_all(response.as_bytes()).await;
9887 });
9888 }
9889 }
9890 });
9891
9892 (url, handle)
9893 }
9894 }
9895
9896 #[tokio::test]
9905 async fn test_request_body_arrives_as_stream() {
9906 use camel_component_api::Body;
9907 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9908
9909 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9910 let port = listener.local_addr().unwrap().port();
9911 drop(listener);
9912
9913 let component = HttpComponent::new();
9914 let endpoint_ctx = NoOpComponentContext;
9915 let endpoint = component
9916 .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
9917 .unwrap();
9918 let mut consumer = endpoint.create_consumer(rt()).unwrap();
9919
9920 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9921 let token = tokio_util::sync::CancellationToken::new();
9922 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9923
9924 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9925 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9926
9927 let client = plain_http_test_client();
9928 let send_fut = client
9929 .post(format!("http://127.0.0.1:{port}/upload"))
9930 .body("hello streaming world")
9931 .send();
9932
9933 let (http_result, _) = tokio::join!(send_fut, async {
9934 if let Some(mut envelope) = rx.recv().await {
9935 assert!(
9937 matches!(envelope.exchange.input.body, Body::Stream(_)),
9938 "expected Body::Stream, got discriminant {:?}",
9939 std::mem::discriminant(&envelope.exchange.input.body)
9940 );
9941 let bytes = envelope
9943 .exchange
9944 .input
9945 .body
9946 .into_bytes(1024 * 1024)
9947 .await
9948 .unwrap();
9949 assert_eq!(&bytes[..], b"hello streaming world");
9950
9951 envelope.exchange.input.body = camel_component_api::Body::Empty;
9952 if let Some(reply_tx) = envelope.reply_tx {
9953 let _ = reply_tx.send(Ok(envelope.exchange));
9954 }
9955 }
9956 });
9957
9958 let resp = http_result.unwrap();
9959 assert_eq!(resp.status().as_u16(), 200);
9960
9961 token.cancel();
9962 }
9963
9964 #[tokio::test]
9969 async fn test_streaming_response_chunked() {
9970 use bytes::Bytes;
9971 use camel_component_api::Body;
9972 use camel_component_api::CamelError;
9973 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9974 use camel_component_api::{StreamBody, StreamMetadata};
9975 use futures::stream;
9976 use std::sync::Arc;
9977 use tokio::sync::Mutex;
9978
9979 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9980 let port = listener.local_addr().unwrap().port();
9981 drop(listener);
9982
9983 let component = HttpComponent::new();
9984 let endpoint_ctx = NoOpComponentContext;
9985 let endpoint = component
9986 .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
9987 .unwrap();
9988 let mut consumer = endpoint.create_consumer(rt()).unwrap();
9989
9990 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9991 let token = tokio_util::sync::CancellationToken::new();
9992 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9993
9994 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9995 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9996
9997 let client = plain_http_test_client();
9998 let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
9999
10000 let (http_result, _) = tokio::join!(send_fut, async {
10001 if let Some(mut envelope) = rx.recv().await {
10002 let chunks: Vec<Result<Bytes, CamelError>> =
10004 vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
10005 let stream = Box::pin(stream::iter(chunks));
10006 envelope.exchange.input.body = Body::Stream(StreamBody {
10007 stream: Arc::new(Mutex::new(Some(stream))),
10008 metadata: StreamMetadata::default(),
10009 });
10010 if let Some(reply_tx) = envelope.reply_tx {
10011 let _ = reply_tx.send(Ok(envelope.exchange));
10012 }
10013 }
10014 });
10015
10016 let resp = http_result.unwrap();
10017 assert_eq!(resp.status().as_u16(), 200);
10018 let body = resp.text().await.unwrap();
10019 assert_eq!(body, "chunk1chunk2");
10020
10021 token.cancel();
10022 }
10023
10024 #[tokio::test]
10029 async fn test_413_when_content_length_exceeds_limit() {
10030 use camel_component_api::ConsumerContext;
10031
10032 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10033 let port = listener.local_addr().unwrap().port();
10034 drop(listener);
10035
10036 let component = HttpComponent::new();
10038 let endpoint_ctx = NoOpComponentContext;
10039 let endpoint = component
10040 .create_endpoint(
10041 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
10042 &endpoint_ctx,
10043 )
10044 .unwrap();
10045 let mut consumer = endpoint.create_consumer(rt()).unwrap();
10046
10047 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
10048 let token = tokio_util::sync::CancellationToken::new();
10049 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
10050
10051 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10052 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
10053
10054 let client = plain_http_test_client();
10055 let resp = client
10056 .post(format!("http://127.0.0.1:{port}/upload"))
10057 .header("Content-Length", "1000") .body("x".repeat(1000))
10059 .send()
10060 .await
10061 .unwrap();
10062
10063 assert_eq!(resp.status().as_u16(), 413);
10064
10065 token.cancel();
10066 }
10067
10068 #[tokio::test]
10072 async fn test_chunked_upload_without_content_length_bypasses_limit() {
10073 use bytes::Bytes;
10074 use camel_component_api::Body;
10075 use camel_component_api::ConsumerContext;
10076 use futures::stream;
10077
10078 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10079 let port = listener.local_addr().unwrap().port();
10080 drop(listener);
10081
10082 let component = HttpComponent::new();
10084 let endpoint_ctx = NoOpComponentContext;
10085 let endpoint = component
10086 .create_endpoint(
10087 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
10088 &endpoint_ctx,
10089 )
10090 .unwrap();
10091 let mut consumer = endpoint.create_consumer(rt()).unwrap();
10092
10093 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
10094 let token = tokio_util::sync::CancellationToken::new();
10095 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
10096
10097 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10098 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
10099
10100 let client = plain_http_test_client();
10101
10102 let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
10106 Ok(Bytes::from("y".repeat(50))),
10107 Ok(Bytes::from("y".repeat(50))),
10108 ];
10109 let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
10110 let send_fut = client
10111 .post(format!("http://127.0.0.1:{port}/upload"))
10112 .body(stream_body)
10113 .send();
10114
10115 let consumer_fut = async {
10116 match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
10118 Ok(Some(mut envelope)) => {
10119 assert!(
10120 matches!(envelope.exchange.input.body, Body::Stream(_)),
10121 "expected Body::Stream"
10122 );
10123 envelope.exchange.input.body = camel_component_api::Body::Empty;
10124 if let Some(reply_tx) = envelope.reply_tx {
10125 let _ = reply_tx.send(Ok(envelope.exchange));
10126 }
10127 }
10128 Ok(None) => panic!("consumer channel closed unexpectedly"),
10129 Err(_) => {
10130 }
10133 }
10134 };
10135
10136 let (http_result, _) = tokio::join!(send_fut, consumer_fut);
10137
10138 let resp = http_result.unwrap();
10139 assert_ne!(
10146 resp.status().as_u16(),
10147 413,
10148 "chunked upload has no Content-Length to pre-check"
10149 );
10150 assert_eq!(resp.status().as_u16(), 200);
10151
10152 token.cancel();
10153 }
10154
10155 #[test]
10156 fn test_is_private_ip_ranges() {
10157 use camel_api::is_ssrf_blocked_ip;
10158 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(
10177 &"2001:4860:4860::8888".parse().unwrap()
10178 )); }
10180
10181 #[test]
10182 fn test_title_case_header() {
10183 assert_eq!(title_case_header("content-type"), "Content-Type");
10184 assert_eq!(title_case_header("authorization"), "Authorization");
10185 assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
10186 assert_eq!(title_case_header("host"), "Host");
10187 assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
10188 assert_eq!(title_case_header("single"), "Single");
10189 assert_eq!(title_case_header(""), "");
10190 }
10191
10192 #[test]
10193 fn test_resolve_url_combines_path_and_query_sources() {
10194 let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
10195 let mut exchange = Exchange::new(Message::default());
10196 exchange.input.set_header(
10197 "CamelHttpPath",
10198 serde_json::Value::String("next".to_string()),
10199 );
10200 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10201 assert!(url.starts_with("http://example.com/base/next?"));
10202 assert!(url.contains("foo=bar"));
10203
10204 exchange.input.set_header(
10205 "CamelHttpUri",
10206 serde_json::Value::String("http://other.test/root".to_string()),
10207 );
10208 exchange.input.set_header(
10209 "CamelHttpQuery",
10210 serde_json::Value::String("a=1&b=2".to_string()),
10211 );
10212
10213 let override_url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10214 assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
10215 }
10216
10217 fn exchange_with_path_and_query(path: &str, query: &str) -> Exchange {
10218 let mut exchange = Exchange::new(Message::default());
10219 exchange
10220 .input
10221 .set_header("CamelHttpPath", serde_json::Value::String(path.to_string()));
10222 exchange.input.set_header(
10223 "CamelHttpQuery",
10224 serde_json::Value::String(query.to_string()),
10225 );
10226 exchange
10227 }
10228
10229 #[test]
10230 fn resolve_url_bridge_endpoint_true_ignores_exchange_path() {
10231 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
10232 cfg.bridge_endpoint = true;
10233 cfg.query_params
10234 .push(("token".to_string(), "secret".to_string()));
10235 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
10236 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10237 assert_eq!(url, "http://x?token=secret");
10241 assert!(!url.contains("/foo"));
10242 assert!(!url.contains("dropme"));
10243 }
10244
10245 #[test]
10246 fn resolve_url_bridge_endpoint_false_merges_path() {
10247 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
10248 cfg.bridge_endpoint = false;
10249 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
10250 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10251 assert!(url.contains("/foo"), "url should contain /foo: {url}");
10252 assert!(
10253 url.contains("dropme=1"),
10254 "url should contain dropme=1: {url}"
10255 );
10256 }
10257
10258 #[test]
10259 fn resolve_url_bridge_endpoint_true_keeps_base_when_no_query_params() {
10260 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
10261 cfg.bridge_endpoint = true;
10262 let mut exchange = Exchange::new(Message::default());
10263 exchange.input.set_header(
10264 "CamelHttpPath",
10265 serde_json::Value::String("/foo".to_string()),
10266 );
10267 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10268 assert_eq!(url, "http://x");
10269 assert!(!url.contains("/foo"));
10270 }
10271
10272 #[test]
10273 fn resolve_url_bridge_endpoint_true_ignores_camel_http_uri() {
10274 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
10275 cfg.bridge_endpoint = true;
10276 let mut exchange = Exchange::new(Message::default());
10278 exchange.input.set_header(
10279 "CamelHttpUri",
10280 serde_json::Value::String("http://dest/explicit".to_string()),
10281 );
10282 exchange.input.set_header(
10283 "CamelHttpPath",
10284 serde_json::Value::String("/foo".to_string()),
10285 );
10286 exchange.input.set_header(
10287 "CamelHttpQuery",
10288 serde_json::Value::String("x=1".to_string()),
10289 );
10290 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10291 assert_eq!(url, "http://x");
10295 }
10296
10297 #[test]
10298 fn bridge_programmatic_params_use_percent20() {
10299 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
10300 cfg.bridge_endpoint = true;
10301 cfg.query_params = vec![("b".to_string(), "x y".to_string())];
10302 let exchange = Exchange::new(Message::default());
10303
10304 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10305
10306 assert_eq!(url, "http://x?b=x%20y");
10310 assert!(!url.contains('+'));
10311 }
10312
10313 #[test]
10314 fn bridge_arm_carries_authored_raw_query() {
10315 let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
10316 let exchange = exchange_with_path_and_query("/ignored", "dropme=1");
10319
10320 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10321
10322 assert_eq!(url, "http://h/p?a=1");
10325 assert!(!url.contains("dropme"), "exchange query leaked: {url}");
10326 assert!(!url.contains("/ignored"), "exchange path leaked: {url}");
10327 }
10328
10329 #[test]
10336 fn resolve_url_bridge_preserves_dot_segments() {
10337 let mut cfg = HttpEndpointConfig::from_uri("http://h/a/../b").unwrap();
10338 cfg.bridge_endpoint = true;
10339 cfg.query_params.push(("k".to_string(), "1".to_string()));
10340 let exchange = Exchange::new(Message::default());
10341
10342 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10343
10344 assert_eq!(url, "http://h/a/../b?k=1");
10347 }
10348
10349 #[test]
10350 fn resolve_url_bridge_preserves_default_port() {
10351 let mut cfg = HttpEndpointConfig::from_uri("http://h:80/p").unwrap();
10352 cfg.bridge_endpoint = true;
10353 cfg.query_params.push(("k".to_string(), "1".to_string()));
10354 let exchange = Exchange::new(Message::default());
10355
10356 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10357
10358 assert_eq!(url, "http://h:80/p?k=1");
10361 }
10362
10363 #[test]
10364 fn resolve_url_bridge_preserves_scheme_and_host_case() {
10365 let mut cfg = HttpEndpointConfig::from_uri("http://ExAMPLE.COM/p").unwrap();
10366 cfg.bridge_endpoint = true;
10367 cfg.query_params.push(("k".to_string(), "1".to_string()));
10368 cfg.base_url = "HTTP://ExAMPLE.COM/p".to_string();
10372 let exchange = Exchange::new(Message::default());
10373
10374 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10375
10376 assert_eq!(url, "HTTP://ExAMPLE.COM/p?k=1");
10379 }
10380
10381 #[test]
10382 fn resolve_url_bridge_no_query_emits_base_verbatim() {
10383 let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10384 cfg.bridge_endpoint = true;
10385 let exchange = Exchange::new(Message::default());
10386
10387 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10388
10389 assert_eq!(url, "http://h/p");
10392 }
10393
10394 #[test]
10395 fn resolve_url_bridge_and_non_bridge_byte_identical() {
10396 let mut bridged = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
10399 bridged.bridge_endpoint = true;
10400 bridged
10401 .query_params
10402 .push(("k".to_string(), "1".to_string()));
10403 let bridge_url =
10404 HttpProducer::resolve_url(&Exchange::new(Message::default()), &bridged).unwrap();
10405
10406 let plain = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
10409 let mut exchange = Exchange::new(Message::default());
10410 exchange.input.set_header(
10411 "CamelHttpQuery",
10412 serde_json::Value::String("k=1".to_string()),
10413 );
10414 let plain_url = HttpProducer::resolve_url(&exchange, &plain).unwrap();
10415
10416 assert_eq!(bridge_url, plain_url);
10417 assert_eq!(bridge_url, "http://H:80/a/../b?k=1");
10418 }
10419
10420 #[test]
10421 fn resolve_url_bridge_preserves_ipv6_authority_verbatim() {
10422 let mut cfg = HttpEndpointConfig::from_uri("http://[::1]:8080/p").unwrap();
10423 cfg.bridge_endpoint = true;
10424 cfg.query_params.push(("k".to_string(), "1".to_string()));
10425 let exchange = Exchange::new(Message::default());
10426
10427 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10428
10429 assert_eq!(url, "http://[::1]:8080/p?k=1");
10430 }
10431
10432 #[test]
10433 fn resolve_url_bridge_empty_base_path_keeps_no_synthetic_slash() {
10434 let cfg = HttpEndpointConfig::from_uri("http://h?x=1&bridgeEndpoint=true").unwrap();
10435 let exchange = Exchange::new(Message::default());
10436
10437 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10438
10439 assert_eq!(url, "http://h?x=1");
10442 }
10443
10444 #[test]
10449 fn resolve_url_preserves_authored_query_order_and_bytes() {
10450 let config =
10451 HttpEndpointConfig::from_uri("http://h/p?a=1&b=x,y&c=t:1&connectTimeout=5000").unwrap();
10452 let exchange = Exchange::new(Message::default());
10453
10454 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10455
10456 assert_eq!(url, "http://h/p?a=1&b=x,y&c=t:1");
10459 }
10460
10461 #[test]
10462 fn resolve_url_consumes_encoded_option_key() {
10463 let config = HttpEndpointConfig::from_uri("http://h/p?connect%54imeout=5000&a=1").unwrap();
10464 let exchange = Exchange::new(Message::default());
10465
10466 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10467
10468 assert_eq!(url, "http://h/p?a=1");
10470 }
10471
10472 #[test]
10473 fn resolve_url_all_options_consumed_drops_query() {
10474 let config = HttpEndpointConfig::from_uri("http://h/p?connectTimeout=5000").unwrap();
10475 let exchange = Exchange::new(Message::default());
10476
10477 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10478
10479 assert_eq!(url, "http://h/p");
10482 assert!(!url.contains('?'));
10483 }
10484
10485 #[test]
10486 fn resolve_url_preserves_empty_query_marker() {
10487 let config = HttpEndpointConfig::from_uri("http://h/p?").unwrap();
10488 let exchange = Exchange::new(Message::default());
10489
10490 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10491
10492 assert_eq!(url, "http://h/p?");
10495 }
10496
10497 #[test]
10498 fn resolve_url_raw_wrapper_not_re_encoded() {
10499 let config = HttpEndpointConfig::from_uri("http://h/p?token=RAW(abc)").unwrap();
10500 let exchange = Exchange::new(Message::default());
10501
10502 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10503
10504 assert_eq!(url, "http://h/p?token=RAW(abc)");
10506 assert!(!url.contains("%28"), "RAW( wrapper re-encoded: {url}");
10507 }
10508
10509 #[test]
10510 fn resolve_url_camel_http_query_composes_verbatim_span() {
10511 let config = HttpEndpointConfig::from_uri("http://h/p?x=1").unwrap();
10512 let mut exchange = Exchange::new(Message::default());
10513 exchange.input.set_header(
10514 "CamelHttpQuery",
10515 serde_json::Value::String("userFilter=a%2Cb".to_string()),
10516 );
10517
10518 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10519
10520 assert_eq!(url, "http://h/p?x=1&userFilter=a%2Cb");
10525 assert!(!url.contains("%252C"), "header bytes re-encoded: {url}");
10526 }
10527
10528 #[test]
10533 fn header_composes_with_endpoint_query() {
10534 let config =
10535 HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret&lang=en").unwrap();
10536 let mut exchange = Exchange::new(Message::default());
10537 exchange.input.set_header(
10538 "CamelHttpQuery",
10539 serde_json::Value::String("lang=es&page=2".to_string()),
10540 );
10541
10542 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10543
10544 assert_eq!(url, "http://upstream/api?apiKey=secret&lang=en&page=2");
10547 }
10548
10549 #[test]
10550 fn header_alone_still_rides() {
10551 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
10552 let mut exchange = Exchange::new(Message::default());
10553 exchange.input.set_header(
10554 "CamelHttpQuery",
10555 serde_json::Value::String("page=2".to_string()),
10556 );
10557
10558 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10559
10560 assert_eq!(url, "http://upstream/api?page=2");
10562 }
10563
10564 #[test]
10565 fn empty_reflected_query_leaves_endpoint_query_intact() {
10566 let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
10567 let mut exchange = Exchange::new(Message::default());
10568 exchange
10571 .input
10572 .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
10573
10574 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10575
10576 assert_eq!(url, "http://upstream/api?apiKey=secret");
10578 assert!(!url.ends_with('?'), "dangling '?' marker: {url}");
10579 }
10580
10581 #[test]
10582 fn forbidden_byte_in_header_query_errors() {
10583 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
10584 let mut exchange = Exchange::new(Message::default());
10585 exchange.input.set_header(
10586 "CamelHttpQuery",
10587 serde_json::Value::String("q=ab<cd".to_string()),
10588 );
10589
10590 let err = HttpProducer::resolve_url(&exchange, &config)
10591 .unwrap_err()
10592 .to_string();
10593
10594 assert!(err.contains("0x3C"), "error must name the byte: {err}");
10597 }
10598
10599 #[test]
10600 fn override_uri_with_query_plus_header_query() {
10601 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
10602 let mut exchange = Exchange::new(Message::default());
10603 exchange.input.set_header(
10604 "CamelHttpUri",
10605 serde_json::Value::String("http://host/api?a=1".to_string()),
10606 );
10607 exchange.input.set_header(
10608 "CamelHttpQuery",
10609 serde_json::Value::String("a=2&b=3".to_string()),
10610 );
10611
10612 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10613
10614 assert_eq!(url, "http://host/api?a=1&b=3");
10617 }
10618
10619 #[test]
10620 fn path_applies_before_query_composition() {
10621 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
10622 let mut exchange = Exchange::new(Message::default());
10623 exchange.input.set_header(
10624 "CamelHttpUri",
10625 serde_json::Value::String("http://host/api?a=1".to_string()),
10626 );
10627 exchange.input.set_header(
10628 "CamelHttpPath",
10629 serde_json::Value::String("/extra".to_string()),
10630 );
10631 exchange.input.set_header(
10632 "CamelHttpQuery",
10633 serde_json::Value::String("b=2".to_string()),
10634 );
10635
10636 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10637
10638 assert_eq!(url, "http://host/api/extra?a=1&b=2");
10641 }
10642
10643 #[test]
10644 fn plain_proxy_reflection_composes() {
10645 let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
10646 let exchange = exchange_with_path_and_query("/in/extra", "page=2");
10648
10649 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10650
10651 assert_eq!(url, "http://upstream/api/in/extra?apiKey=secret&page=2");
10654 }
10655
10656 #[test]
10657 fn bridge_endpoint_ignores_url_headers() {
10658 let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
10659 let mut exchange = Exchange::new(Message::default());
10660 exchange.input.set_header(
10661 "CamelHttpUri",
10662 serde_json::Value::String("http://evil.test/x".to_string()),
10663 );
10664 exchange.input.set_header(
10665 "CamelHttpPath",
10666 serde_json::Value::String("/foo".to_string()),
10667 );
10668 exchange.input.set_header(
10669 "CamelHttpQuery",
10670 serde_json::Value::String("z=9".to_string()),
10671 );
10672
10673 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10674
10675 assert_eq!(url, "http://h/p?a=1");
10678 assert!(!url.contains("evil"), "override leaked: {url}");
10679 assert!(!url.contains("z=9"), "header query leaked: {url}");
10680 assert!(!url.contains("/foo"), "header path leaked: {url}");
10681 }
10682
10683 #[test]
10684 fn resolve_url_programmatic_params_use_percent20_deterministic() {
10685 let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10686 config.query_params = vec![
10687 ("b".to_string(), "x y".to_string()),
10688 ("a".to_string(), "1".to_string()),
10689 ];
10690 let exchange = Exchange::new(Message::default());
10691
10692 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10693
10694 assert_eq!(url, "http://h/p?b=x%20y&a=1");
10697 assert!(!url.contains('+'));
10698 }
10699
10700 #[test]
10701 fn resolve_url_authored_and_programmatic_merge() {
10702 let mut config = HttpEndpointConfig::from_uri("http://h/p?a=1&c=t:1").unwrap();
10703 config.query_params = vec![
10704 ("b".to_string(), "2".to_string()),
10705 ("a".to_string(), "9".to_string()),
10706 ];
10707 let exchange = Exchange::new(Message::default());
10708
10709 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
10710
10711 assert_eq!(url, "http://h/p?a=1&c=t:1&b=2");
10714 }
10715
10716 #[test]
10717 fn from_uri_no_longer_fills_query_params_from_uri() {
10718 let config = HttpEndpointConfig::from_uri("http://h/p?a=1&connectTimeout=5000").unwrap();
10719
10720 assert!(
10722 config.query_params.is_empty(),
10723 "query_params is programmatic-only: {:?}",
10724 config.query_params
10725 );
10726 assert_eq!(config.raw_query.as_deref(), Some("a=1&connectTimeout=5000"));
10727 }
10728
10729 #[test]
10730 fn resolve_url_forbidden_raw_byte_errors() {
10731 let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10732 config.raw_query = Some("a=x y".to_string());
10733 let exchange = Exchange::new(Message::default());
10734
10735 let err = HttpProducer::resolve_url(&exchange, &config)
10736 .expect_err("literal space in raw query must error");
10737
10738 assert!(
10740 err.to_string().contains("0x20"),
10741 "error must name the forbidden byte: {err}"
10742 );
10743 }
10744
10745 #[test]
10749 fn resolve_url_override_query_forbidden_byte_errors() {
10750 let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10751 let mut exchange = Exchange::new(Message::default());
10752 exchange.input.set_header(
10753 "CamelHttpUri",
10754 serde_json::Value::String("http://h2/p?a=x y".to_string()),
10755 );
10756
10757 let err = HttpProducer::resolve_url(&exchange, &config)
10758 .expect_err("literal space in the override URI's query must error");
10759
10760 assert!(
10761 err.to_string().contains("0x20"),
10762 "error must name the forbidden byte from the override query: {err}"
10763 );
10764 }
10765
10766 #[test]
10771 fn merge_header_query_decoded_key_collision_drops_header_pair() {
10772 let merged = merge_header_query(Some("a=1"), "%61=2")
10773 .expect("decoded-key collision must not be a parse error");
10774 assert_eq!(
10775 merged.as_deref(),
10776 Some("a=1"),
10777 "the higher-precedence span wins and the colliding header pair is dropped"
10778 );
10779 }
10780
10781 #[test]
10784 fn merge_header_query_duplicate_keys_within_header_ride_verbatim() {
10785 let merged = merge_header_query(None, "k=1&k=2")
10786 .expect("duplicate header keys must not be a parse error");
10787 assert_eq!(
10788 merged.as_deref(),
10789 Some("k=1&k=2"),
10790 "intra-header duplicate keys ride verbatim"
10791 );
10792 }
10793
10794 #[test]
10801 fn endpoint_config_debug_masks_base_url_userinfo() {
10802 let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
10803 config.base_url = "http://user:pass@h.example/p".to_string();
10804 let rendered = format!("{config:?}");
10805 assert!(
10806 rendered.contains("***@h.example"),
10807 "userinfo must render masked: {rendered}"
10808 );
10809 assert!(
10810 !rendered.contains("user:pass"),
10811 "no credentials in Debug output: {rendered}"
10812 );
10813
10814 let plain = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
10815 let rendered_plain = format!("{plain:?}");
10816 assert!(
10817 rendered_plain.contains("http://h.example/p"),
10818 "a base without userinfo renders unchanged: {rendered_plain}"
10819 );
10820 }
10821
10822 #[test]
10827 fn endpoint_config_debug_base_url_converges_on_canonical_redact() {
10828 let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
10829
10830 config.base_url = "http://h.example/p?token=secret#access_token=x".to_string();
10831 let rendered = format!("{config:?}");
10832 assert!(
10833 rendered.contains("base_url: \"http://h.example/p?[redacted]#[redacted]\""),
10834 "query and fragment must render as composed sentinels: {rendered}"
10835 );
10836 assert!(
10837 !rendered.contains("token=secret") && !rendered.contains("access_token"),
10838 "query/fragment credential bytes must not render: {rendered}"
10839 );
10840
10841 config.base_url = "http://h.example//u2:p2@evil/".to_string();
10842 let rendered = format!("{config:?}");
10843 assert!(
10844 rendered.contains("base_url: \"http://h.example//***@evil/\""),
10845 "later //window userinfo must mask (canonical window rule): {rendered}"
10846 );
10847 assert!(
10848 !rendered.contains("u2:p2"),
10849 "later-window credentials must not render: {rendered}"
10850 );
10851
10852 config.base_url = "http://user:pass@h.example/p?token=x".to_string();
10855 let canonical = camel_api::redact::redact_url(&config.base_url);
10856 assert_eq!(canonical, "http://***@h.example/p?[redacted]");
10857 let rendered = format!("{config:?}");
10858 assert!(
10859 rendered.contains(&format!("base_url: \"{canonical}\"")),
10860 "Debug base_url must equal canonical redact_url output: {rendered}"
10861 );
10862 }
10863
10864 #[test]
10870 fn resolve_url_authored_apostrophe_rejected_percent_escape_rides() {
10871 let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10872
10873 config.raw_query = Some("q=it's".to_string());
10874 let exchange = Exchange::new(Message::default());
10875 let err = HttpProducer::resolve_url(&exchange, &config)
10876 .expect_err("authored apostrophe must be rejected, not silently %27-normalized");
10877 assert!(
10878 err.to_string().contains("0x27"),
10879 "error must name the apostrophe byte: {err}"
10880 );
10881
10882 config.raw_query = Some("q=it%27s".to_string());
10883 let url = HttpProducer::resolve_url(&exchange, &config)
10884 .expect("authored %27 escape is wire-legal");
10885 assert!(
10886 url.contains("q=it%27s"),
10887 "the authored escape must ride byte-for-byte: {url}"
10888 );
10889
10890 for &byte in b"\"`<>" {
10894 config.raw_query = Some(format!("k={}x", byte as char));
10895 let err = HttpProducer::resolve_url(&exchange, &config)
10896 .expect_err("WHATWG special-query byte must be rejected");
10897 assert!(
10898 err.to_string().contains(&format!("0x{byte:02X}")),
10899 "error must name byte 0x{byte:02X}: {err}"
10900 );
10901 }
10902 }
10903
10904 #[test]
10905 fn armed_fence_rejects_unknown_host_redacted() {
10906 let cfg = HttpEndpointConfig::from_uri(
10907 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10908 )
10909 .unwrap();
10910 let mut exchange = Exchange::new(Message::default());
10911 exchange.input.set_header(
10912 "CamelHttpUri",
10913 serde_json::Value::String(
10914 "http://user:pass@evil.example.com/x?token=s3cret".to_string(),
10915 ),
10916 );
10917
10918 let err = HttpProducer::resolve_url(&exchange, &cfg)
10919 .expect_err("override host outside the fence must fail resolution");
10920
10921 let message = err.to_string();
10922 assert!(!message.contains("pass"), "userinfo leaked: {message}");
10923 assert!(!message.contains("s3cret"), "query leaked: {message}");
10924 }
10925
10926 #[test]
10927 fn armed_fence_rejects_unparseable_override_redacted() {
10928 let cfg = HttpEndpointConfig::from_uri(
10929 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10930 )
10931 .unwrap();
10932 let mut exchange = Exchange::new(Message::default());
10933 exchange.input.set_header(
10934 "CamelHttpUri",
10935 serde_json::Value::String("http://u:fencesecret@evil.example.com:99999/x".to_string()),
10936 );
10937
10938 let err = HttpProducer::resolve_url(&exchange, &cfg)
10939 .expect_err("unparseable override outside the fence must fail resolution");
10940
10941 let message = err.to_string();
10942 assert!(
10943 message.contains("allowedUriHosts fence"),
10944 "fence must be named: {message}"
10945 );
10946 assert!(
10947 message.contains("[redacted]"),
10948 "suppression sentinel missing: {message}"
10949 );
10950 assert!(
10951 !message.contains("evil.example.com"),
10952 "host leaked: fail-closed arm must render only the sentinel: {message}"
10953 );
10954 assert!(
10955 !message.contains("fencesecret"),
10956 "password leaked: {message}"
10957 );
10958 assert!(!message.contains("u:"), "userinfo leaked: {message}");
10959 }
10960
10961 #[test]
10962 fn armed_fence_rejects_password_only_userinfo_redacted() {
10963 let cfg = HttpEndpointConfig::from_uri(
10964 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10965 )
10966 .unwrap();
10967 let mut exchange = Exchange::new(Message::default());
10968 exchange.input.set_header(
10969 "CamelHttpUri",
10970 serde_json::Value::String(
10971 "http://:passwordonly@evil.example.com/x?token=querysecret".to_string(),
10972 ),
10973 );
10974
10975 let err = HttpProducer::resolve_url(&exchange, &cfg)
10976 .expect_err("password-only override outside the fence must fail resolution");
10977
10978 let message = err.to_string();
10979 assert!(
10980 !message.contains("passwordonly"),
10981 "password-only userinfo leaked: {message}"
10982 );
10983 assert!(!message.contains("querysecret"), "query leaked: {message}");
10984 assert!(
10985 message.contains("http://***@evil.example.com/x?[redacted]"),
10986 "masked shape missing: {message}"
10987 );
10988 }
10989
10990 #[test]
10991 fn armed_fence_allows_listed_host() {
10992 let cfg = HttpEndpointConfig::from_uri(
10993 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10994 )
10995 .unwrap();
10996 let mut exchange = Exchange::new(Message::default());
10997 exchange.input.set_header(
10998 "CamelHttpUri",
10999 serde_json::Value::String("http://cdn.example.com/x".to_string()),
11000 );
11001
11002 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
11003 assert_eq!(url, "http://cdn.example.com/x");
11004 }
11005
11006 #[test]
11007 fn host_only_entry_permits_any_port() {
11008 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=cdn.example.com").unwrap();
11009 let mut exchange = Exchange::new(Message::default());
11010 exchange.input.set_header(
11011 "CamelHttpUri",
11012 serde_json::Value::String("http://cdn.example.com:9443/x".to_string()),
11013 );
11014
11015 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
11016 assert_eq!(url, "http://cdn.example.com:9443/x");
11017 }
11018
11019 #[test]
11020 fn unarmed_endpoint_unchanged() {
11021 let cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
11022 let mut exchange = Exchange::new(Message::default());
11023 exchange.input.set_header(
11024 "CamelHttpUri",
11025 serde_json::Value::String("http://any.example.com/path".to_string()),
11026 );
11027
11028 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
11029 assert_eq!(url, "http://any.example.com/path");
11030 }
11031
11032 #[test]
11033 fn empty_allowlist_fails_endpoint_creation() {
11034 assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=,,").is_err());
11035 }
11036
11037 #[test]
11038 fn malformed_entry_fails_endpoint_creation() {
11039 assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=not a host!").is_err());
11040 }
11041
11042 #[test]
11043 fn fence_entry_with_path_fails_creation() {
11044 assert!(
11047 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=api.internal:8443/v2").is_err()
11048 );
11049 }
11050
11051 #[test]
11052 fn fence_entry_with_userinfo_fails_creation() {
11053 assert!(
11054 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=user@cdn.example.com").is_err()
11055 );
11056 }
11057
11058 #[test]
11059 fn ipv6_fence_entry_allows_bracketed_host() {
11060 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=[::1]:8443").unwrap();
11061 for uri in ["http://[::1]:8443/x", "http://[0:0:0:0:0:0:0:1]:8443/x"] {
11064 let mut exchange = Exchange::new(Message::default());
11065 exchange
11066 .input
11067 .set_header("CamelHttpUri", serde_json::Value::String(uri.to_string()));
11068 let url = HttpProducer::resolve_url(&exchange, &cfg)
11069 .unwrap_or_else(|e| panic!("override {uri} must be honored: {e}"));
11070 assert_eq!(url, uri, "bracketed IPv6 override not honored");
11071 }
11072 }
11073
11074 #[test]
11075 fn dns_case_insensitive_fence_match() {
11076 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=CDN.Example.COM").unwrap();
11079 let mut exchange = Exchange::new(Message::default());
11080 exchange.input.set_header(
11081 "CamelHttpUri",
11082 serde_json::Value::String("http://cdn.example.com/x".to_string()),
11083 );
11084 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
11085 assert_eq!(url, "http://cdn.example.com/x");
11086 }
11087
11088 #[test]
11089 fn fence_allowed_override_query_merges_with_header() {
11090 let cfg =
11093 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example&k=v").unwrap();
11094 let mut exchange = Exchange::new(Message::default());
11095 exchange.input.set_header(
11096 "CamelHttpUri",
11097 serde_json::Value::String("http://host.example/api?a=1".to_string()),
11098 );
11099 exchange.input.set_header(
11100 "CamelHttpQuery",
11101 serde_json::Value::String("b=2".to_string()),
11102 );
11103
11104 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
11105 assert_eq!(url, "http://host.example/api?a=1&b=2");
11106 }
11107
11108 #[test]
11109 fn empty_header_with_armed_fence_leaves_no_query() {
11110 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example").unwrap();
11111 let mut exchange = Exchange::new(Message::default());
11112 exchange.input.set_header(
11113 "CamelHttpUri",
11114 serde_json::Value::String("http://host.example/api".to_string()),
11115 );
11116 exchange
11117 .input
11118 .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
11119
11120 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
11121 assert_eq!(url, "http://host.example/api");
11122 assert!(!url.contains('?'), "query marker leaked: {url}");
11123 }
11124
11125 #[test]
11126 fn fence_option_is_consumed() {
11127 let cfg =
11131 HttpEndpointConfig::from_uri("http://h/p?x=1&allowedUriHosts=cdn.example.com").unwrap();
11132 let exchange = Exchange::new(Message::default());
11133
11134 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
11135 assert!(!url.contains("allowedUriHosts"), "option leaked: {url}");
11136 assert!(url.contains("x=1"), "authored query lost: {url}");
11137 }
11138
11139 #[tokio::test]
11140 async fn resolve_url_malformed_base_url_errors_no_panic() {
11141 use tower::ServiceExt;
11142
11143 let (url, _handle) = start_test_server().await;
11144 let mut config = HttpEndpointConfig::from_uri("http://[::1:bad").unwrap();
11145 config.allow_internal = true; let producer = HttpProducer {
11147 config: Arc::new(config),
11148 client: build_client(&HttpConfig::default(), None).expect("client must build"), pinned_cache: Arc::new(PinnedClientCache::new(
11150 PINNED_CLIENT_TTL,
11151 PINNED_CLIENT_MAX_ENTRIES,
11152 )),
11153 http_config: Arc::new(HttpConfig::default()),
11154 runtime: rt(),
11155 };
11156
11157 let first = producer
11160 .clone()
11161 .oneshot(Exchange::new(Message::default()))
11162 .await;
11163 let err = first.expect_err("malformed base URL must error, not panic");
11164 assert!(
11165 err.to_string().to_lowercase().contains("url"),
11166 "error must name the malformed URL: {err}"
11167 );
11168
11169 let mut exchange = Exchange::new(Message::default());
11172 exchange.input.set_header(
11173 "CamelHttpUri",
11174 serde_json::Value::String(format!("{url}/api")),
11175 );
11176 let response = producer
11177 .oneshot(exchange)
11178 .await
11179 .expect("valid request through same producer must succeed");
11180 let status = response
11181 .input
11182 .header("CamelHttpResponseCode")
11183 .and_then(|v| v.as_u64())
11184 .unwrap();
11185 assert_eq!(status, 200);
11186 }
11187
11188 #[test]
11189 fn resolve_url_bridge_malformed_base_errors_no_panic() {
11190 let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
11191 cfg.bridge_endpoint = true;
11192 cfg.query_params.push(("k".to_string(), "1".to_string()));
11193 cfg.base_url = "http://[::1:bad".to_string();
11198 let exchange = Exchange::new(Message::default());
11199
11200 let err = HttpProducer::resolve_url(&exchange, &cfg)
11201 .expect_err("malformed bridge base URL must error");
11202 assert!(
11203 err.to_string().contains("invalid base URL"),
11204 "error must name the invalid base URL: {err}"
11205 );
11206 }
11207
11208 #[test]
11209 fn test_http_producer_helpers_status_and_size_boundaries() {
11210 assert!(HttpProducer::is_ok_status(200, (200, 299)));
11211 assert!(HttpProducer::is_ok_status(299, (200, 299)));
11212 assert!(!HttpProducer::is_ok_status(199, (200, 299)));
11213 assert!(!HttpProducer::is_ok_status(300, (200, 299)));
11214
11215 assert!(!exceeds_max_response_body(10, 10));
11216 assert!(exceeds_max_response_body(11, 10));
11217 }
11218
11219 #[allow(clippy::await_holding_lock)]
11224 async fn setup_consumer_on_free_port(
11225 path: &str,
11226 ) -> (
11227 u16,
11228 tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
11229 tokio_util::sync::CancellationToken,
11230 ) {
11231 use camel_component_api::ConsumerContext;
11232
11233 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11238 let port = listener.local_addr().unwrap().port();
11239
11240 let _registry_guard = lock_registry_test_mutex();
11249
11250 ServerRegistry::global()
11251 .stage_listener(listener)
11252 .await
11253 .expect("stage consumer test listener");
11254
11255 let consumer_cfg = HttpServerConfig {
11256 scheme: "http".to_string(),
11257 host: "127.0.0.1".to_string(),
11258 port,
11259 path: path.to_string(),
11260 max_request_body: 2 * 1024 * 1024,
11261 max_response_body: 10 * 1024 * 1024,
11262 max_inflight_requests: 1024,
11263 method: None,
11264 tls_config: None,
11265 };
11266 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
11267
11268 let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
11269 let token = tokio_util::sync::CancellationToken::new();
11270 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
11271
11272 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
11273
11274 wait_for_registry_ready("127.0.0.1", port).await;
11280 for _ in 0..8 {
11281 tokio::task::yield_now().await;
11282 }
11283
11284 (port, rx, token)
11285 }
11286
11287 async fn wait_for_registry_ready(host: &str, port: u16) {
11292 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
11293 let mut backoff = std::time::Duration::from_millis(1);
11294 while ServerRegistry::global().bound_addr(host, port).is_none() {
11295 assert!(
11296 tokio::time::Instant::now() < deadline,
11297 "consumer server did not become ready on port {port} — registry entry absent (concurrent reset or starvation)"
11298 );
11299 tokio::time::sleep(backoff).await;
11300 backoff = (backoff * 2).min(std::time::Duration::from_millis(64));
11301 }
11302 }
11303
11304 #[tokio::test(start_paused = true)]
11305 #[should_panic(expected = "registry entry absent (concurrent reset or starvation)")]
11306 async fn readiness_deadline_fires_loud_with_hint() {
11307 let held = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11321 let port = held.local_addr().unwrap().port();
11322 wait_for_registry_ready("httpflake-unreachable-host", port).await;
11323 }
11324
11325 #[tokio::test]
11330 async fn readiness_survives_concurrent_registry_reset() {
11331 let contended = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
11332 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
11333
11334 let contended_hammer = std::sync::Arc::clone(&contended);
11341 let stop_hammer = std::sync::Arc::clone(&stop);
11342 let handle = std::thread::spawn(move || {
11343 while !stop_hammer.load(std::sync::atomic::Ordering::Relaxed) {
11344 let _guard = match REGISTRY_TEST_MUTEX.try_lock() {
11345 Err(std::sync::TryLockError::WouldBlock) => {
11346 contended_hammer.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
11347 lock_registry_test_mutex()
11348 }
11349 Err(std::sync::TryLockError::Poisoned(_)) => lock_registry_test_mutex(),
11350 Ok(guard) => guard,
11351 };
11352 ServerRegistry::reset();
11353 }
11354 });
11355
11356 struct StopHammerOnDrop {
11359 handle: Option<std::thread::JoinHandle<()>>,
11360 stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
11361 }
11362 impl Drop for StopHammerOnDrop {
11363 fn drop(&mut self) {
11364 self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
11365 if let Some(handle) = self.handle.take() {
11366 let _ = handle.join();
11367 }
11368 }
11369 }
11370 let _hammer_guard = StopHammerOnDrop {
11371 handle: Some(handle),
11372 stop,
11373 };
11374
11375 let mut setups = 0;
11378 loop {
11379 setups += 1;
11380 let (_port, rx, token) = setup_consumer_on_free_port("/reset-hammer").await;
11381 drop(rx);
11382 token.cancel();
11383 if (setups >= 25 && contended.load(std::sync::atomic::Ordering::SeqCst) >= 1)
11384 || setups >= 50
11385 {
11386 break;
11387 }
11388 }
11389
11390 let contended_hits = contended.load(std::sync::atomic::Ordering::SeqCst);
11391 assert!(
11392 contended_hits >= 1,
11393 "expected at least one contended registry reset across {setups} setups, got {contended_hits}"
11394 );
11395 }
11396
11397 #[tokio::test]
11398 async fn test_content_type_inferred_for_json_body() {
11399 let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
11400
11401 let client = plain_http_test_client();
11402 let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
11403
11404 let (http_result, _) = tokio::join!(send_fut, async {
11405 if let Some(mut envelope) = rx.recv().await {
11406 envelope.exchange.input.body =
11407 camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
11408 if let Some(reply_tx) = envelope.reply_tx {
11409 let _ = reply_tx.send(Ok(envelope.exchange));
11410 }
11411 }
11412 });
11413
11414 let resp = http_result.unwrap();
11415 assert_eq!(resp.status().as_u16(), 200);
11416 let ct = resp
11417 .headers()
11418 .get("content-type")
11419 .expect("Content-Type header should be present");
11420 assert_eq!(ct, "application/json");
11421 let body = resp.text().await.unwrap();
11422 assert_eq!(body, r#"{"message":"hello"}"#);
11423
11424 token.cancel();
11425 }
11426
11427 #[tokio::test]
11428 async fn test_content_type_inferred_for_text_body() {
11429 let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
11430
11431 let client = build_client(&HttpConfig::default(), None).expect("client must build"); let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
11437
11438 let (http_result, _) = tokio::join!(send_fut, async {
11439 if let Some(mut envelope) = rx.recv().await {
11440 envelope.exchange.input.body =
11441 camel_component_api::Body::Text("plain text response".to_string());
11442 if let Some(reply_tx) = envelope.reply_tx {
11443 let _ = reply_tx.send(Ok(envelope.exchange));
11444 }
11445 }
11446 });
11447
11448 let resp = http_result.unwrap();
11449 assert_eq!(resp.status().as_u16(), 200);
11450 let ct = resp
11451 .headers()
11452 .get("content-type")
11453 .expect("Content-Type header should be present");
11454 assert_eq!(ct, "text/plain; charset=utf-8");
11455 let body = resp.text().await.unwrap();
11456 assert_eq!(body, "plain text response");
11457
11458 token.cancel();
11459 }
11460
11461 #[tokio::test]
11462 async fn test_content_type_inferred_for_xml_body() {
11463 let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
11464
11465 let client = plain_http_test_client();
11466 let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
11467
11468 let (http_result, _) = tokio::join!(send_fut, async {
11469 if let Some(mut envelope) = rx.recv().await {
11470 envelope.exchange.input.body =
11471 camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
11472 if let Some(reply_tx) = envelope.reply_tx {
11473 let _ = reply_tx.send(Ok(envelope.exchange));
11474 }
11475 }
11476 });
11477
11478 let resp = http_result.unwrap();
11479 assert_eq!(resp.status().as_u16(), 200);
11480 let ct = resp
11481 .headers()
11482 .get("content-type")
11483 .expect("Content-Type header should be present");
11484 assert_eq!(ct, "application/xml");
11485 let body = resp.text().await.unwrap();
11486 assert_eq!(body, "<root><item>value</item></root>");
11487
11488 token.cancel();
11489 }
11490
11491 #[tokio::test]
11492 async fn test_no_content_type_for_empty_body() {
11493 let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
11494
11495 let client = plain_http_test_client();
11496 let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
11497
11498 let (http_result, _) = tokio::join!(send_fut, async {
11499 if let Some(mut envelope) = rx.recv().await {
11500 envelope.exchange.input.body = camel_component_api::Body::Empty;
11501 if let Some(reply_tx) = envelope.reply_tx {
11502 let _ = reply_tx.send(Ok(envelope.exchange));
11503 }
11504 }
11505 });
11506
11507 let resp = http_result.unwrap();
11508 assert_eq!(resp.status().as_u16(), 200);
11509 assert!(
11510 resp.headers().get("content-type").is_none(),
11511 "Empty body should not set Content-Type"
11512 );
11513
11514 token.cancel();
11515 }
11516
11517 #[tokio::test]
11518 async fn test_no_content_type_for_raw_bytes_body() {
11519 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
11520
11521 let client = plain_http_test_client();
11522 let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
11523
11524 let (http_result, _) = tokio::join!(send_fut, async {
11525 if let Some(mut envelope) = rx.recv().await {
11526 envelope.exchange.input.body =
11527 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
11528 if let Some(reply_tx) = envelope.reply_tx {
11529 let _ = reply_tx.send(Ok(envelope.exchange));
11530 }
11531 }
11532 });
11533
11534 let resp = http_result.unwrap();
11535 assert_eq!(resp.status().as_u16(), 200);
11536 assert!(
11537 resp.headers().get("content-type").is_none(),
11538 "Raw Bytes body should not set Content-Type"
11539 );
11540
11541 token.cancel();
11542 }
11543
11544 #[tokio::test]
11545 async fn test_content_type_from_stream_metadata() {
11546 use camel_component_api::{StreamBody, StreamMetadata};
11547 use futures::stream;
11548
11549 let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
11550
11551 let client = plain_http_test_client();
11552 let send_fut = client
11553 .get(format!("http://127.0.0.1:{port}/stream-ct"))
11554 .send();
11555
11556 let (http_result, _) = tokio::join!(send_fut, async {
11557 if let Some(mut envelope) = rx.recv().await {
11558 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
11559 vec![Ok(bytes::Bytes::from("audio data"))];
11560 let stream = Box::pin(stream::iter(chunks));
11561 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
11562 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
11563 metadata: StreamMetadata {
11564 size_hint: None,
11565 content_type: Some("audio/mpeg".to_string()),
11566 origin: None,
11567 },
11568 });
11569 if let Some(reply_tx) = envelope.reply_tx {
11570 let _ = reply_tx.send(Ok(envelope.exchange));
11571 }
11572 }
11573 });
11574
11575 let resp = http_result.unwrap();
11576 assert_eq!(resp.status().as_u16(), 200);
11577 let ct = resp
11578 .headers()
11579 .get("content-type")
11580 .expect("Content-Type header should be present");
11581 assert_eq!(ct, "audio/mpeg");
11582 let body = resp.text().await.unwrap();
11583 assert_eq!(body, "audio data");
11584
11585 token.cancel();
11586 }
11587
11588 #[tokio::test]
11589 async fn test_user_content_type_overrides_inferred() {
11590 let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
11591
11592 let client = plain_http_test_client();
11593 let send_fut = client
11594 .get(format!("http://127.0.0.1:{port}/override-ct"))
11595 .send();
11596
11597 let (http_result, _) = tokio::join!(send_fut, async {
11598 if let Some(mut envelope) = rx.recv().await {
11599 envelope.exchange.input.body =
11600 camel_component_api::Body::Json(serde_json::json!({"ok": true}));
11601 envelope.exchange.input.set_header(
11602 "Content-Type",
11603 serde_json::Value::String("text/html".to_string()),
11604 );
11605 if let Some(reply_tx) = envelope.reply_tx {
11606 let _ = reply_tx.send(Ok(envelope.exchange));
11607 }
11608 }
11609 });
11610
11611 let resp = http_result.unwrap();
11612 assert_eq!(resp.status().as_u16(), 200);
11613 let ct = resp
11614 .headers()
11615 .get("content-type")
11616 .expect("Content-Type header should be present");
11617 assert_eq!(
11618 ct, "text/html",
11619 "User-set Content-Type should take precedence over inferred type"
11620 );
11621
11622 token.cancel();
11623 }
11624
11625 #[tokio::test]
11626 async fn test_user_content_type_with_bytes_body() {
11627 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
11628
11629 let client = plain_http_test_client();
11630 let send_fut = client
11631 .get(format!("http://127.0.0.1:{port}/bytes-ct"))
11632 .send();
11633
11634 let (http_result, _) = tokio::join!(send_fut, async {
11635 if let Some(mut envelope) = rx.recv().await {
11636 envelope.exchange.input.body =
11637 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
11638 envelope.exchange.input.set_header(
11639 "Content-Type",
11640 serde_json::Value::String("application/json".to_string()),
11641 );
11642 if let Some(reply_tx) = envelope.reply_tx {
11643 let _ = reply_tx.send(Ok(envelope.exchange));
11644 }
11645 }
11646 });
11647
11648 let resp = http_result.unwrap();
11649 assert_eq!(resp.status().as_u16(), 200);
11650 let ct = resp
11651 .headers()
11652 .get("content-type")
11653 .expect("Content-Type header should be present for Bytes body with user header");
11654 assert_eq!(
11655 ct, "application/json",
11656 "User Content-Type should be sent for Bytes body"
11657 );
11658
11659 token.cancel();
11660 }
11661
11662 #[tokio::test]
11667 async fn monitor_task_silent_on_clean_exit() {
11668 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
11669 let server_exited = tokio_util::sync::CancellationToken::new();
11670 monitor_axum_task(
11672 handle,
11673 "127.0.0.1:0".to_string(),
11674 noop_rt(),
11675 "test-monitor".into(),
11676 server_exited.clone(),
11677 )
11678 .await;
11679 assert!(
11683 !server_exited.is_cancelled(),
11684 "clean server exit must not cancel server_exited"
11685 );
11686 }
11687
11688 #[tokio::test]
11689 async fn monitor_task_handles_panicked_task() {
11690 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
11691 panic!("simulated server crash");
11692 });
11693 let server_exited = tokio_util::sync::CancellationToken::new();
11694 monitor_axum_task(
11696 handle,
11697 "127.0.0.1:9999".to_string(),
11698 noop_rt(),
11699 "test-monitor".into(),
11700 server_exited.clone(),
11701 )
11702 .await;
11703 assert!(
11706 server_exited.is_cancelled(),
11707 "crashed server must cancel server_exited"
11708 );
11709 }
11710
11711 #[test]
11716 fn http_auth_basic_debug_redacts_password() {
11717 let auth = HttpAuth::Basic {
11718 username: "admin".to_string(),
11719 password: "hunter2".to_string(),
11720 };
11721 let debug = format!("{:?}", auth);
11722 assert!(
11723 !debug.contains("hunter2"),
11724 "password must be redacted: {debug}"
11725 );
11726 assert!(debug.contains("admin"), "username should appear: {debug}");
11727 }
11728
11729 #[test]
11730 fn http_auth_bearer_debug_redacts_token() {
11731 let auth = HttpAuth::Bearer {
11732 token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
11733 };
11734 let debug = format!("{:?}", auth);
11735 assert!(
11736 !debug.contains("eyJhbGci"),
11737 "token must be redacted: {debug}"
11738 );
11739 }
11740
11741 #[test]
11742 fn http_auth_none_debug_shows_variant() {
11743 let debug = format!("{:?}", HttpAuth::None);
11744 assert!(
11745 debug.contains("None"),
11746 "None variant should appear: {debug}"
11747 );
11748 }
11749
11750 #[test]
11751 fn http_endpoint_config_debug_redacts_auth_credentials() {
11752 let config = HttpEndpointConfig::from_uri(
11753 "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
11754 )
11755 .unwrap();
11756 let debug = format!("{:?}", config);
11757 assert!(
11758 !debug.contains("secret123"),
11759 "password must be redacted in HttpEndpointConfig debug: {debug}"
11760 );
11761 }
11762
11763 #[test]
11764 fn debug_lists_all_public_fields() {
11765 let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
11766 let debug = format!("{:?}", config);
11767 for field in [
11768 "base_url",
11769 "http_method",
11770 "throw_exception_on_failure",
11771 "ok_status_code_range",
11772 "response_timeout",
11773 "query_params",
11774 "raw_query",
11775 "allow_internal",
11776 "allow_cleartext",
11777 "blocked_hosts",
11778 "max_body_size",
11779 "read_timeout_ms",
11780 "max_response_bytes",
11781 "auth",
11782 "token_provider",
11783 "user_agent",
11784 "bridge_endpoint",
11785 "connection_close",
11786 "skip_request_headers",
11787 "skip_response_headers",
11788 "follow_redirects",
11789 "max_redirects",
11790 ] {
11791 assert!(
11792 debug.contains(field),
11793 "Debug output missing field '{field}': {debug}"
11794 );
11795 }
11796 }
11797
11798 use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
11803 use tower_http::services::ServeDir;
11804
11805 fn make_test_registry() -> HttpRouteRegistry {
11806 HttpRouteRegistry::new()
11807 }
11808
11809 fn make_test_state(registry: HttpRouteRegistry) -> AppState {
11810 AppState {
11811 registry,
11812 max_request_body: 2 * 1024 * 1024,
11813 max_response_body: 10 * 1024 * 1024,
11814 inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
11815 }
11816 }
11817
11818 #[allow(clippy::await_holding_lock)]
11819 #[tokio::test]
11820 async fn test_static_file_serving_serves_file_contents() {
11821 let _guard = lock_registry_test_mutex();
11822 ServerRegistry::reset();
11823
11824 let temp_dir =
11826 std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
11827 std::fs::create_dir_all(&temp_dir).unwrap();
11828 std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
11829 std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
11830
11831 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11832
11833 let registry = make_test_registry();
11834 let serve_dir = ServeDir::new(&canonical_dir)
11835 .precompressed_gzip()
11836 .precompressed_br()
11837 .append_index_html_on_directories(true);
11838
11839 let mount = StaticMount {
11840 mount_path: "/".to_string(),
11841 mode: MountMode::Static,
11842 dir: canonical_dir.clone(),
11843 cache_control: "public, max-age=3600".to_string(),
11844 error_pages: std::collections::HashMap::new(),
11845 serve_dir,
11846 };
11847 registry.register_static_mount(mount).await.unwrap();
11848
11849 let state = make_test_state(registry);
11850
11851 let req = Request::builder()
11853 .uri("/hello.txt")
11854 .body(AxumBody::empty())
11855 .unwrap();
11856 let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
11857 assert_eq!(resp.status(), StatusCode::OK);
11858 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11859 .await
11860 .unwrap();
11861 assert_eq!(&body[..], b"Hello, static world!");
11862
11863 let req = Request::builder()
11865 .uri("/style.css")
11866 .body(AxumBody::empty())
11867 .unwrap();
11868 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
11869 assert_eq!(resp.status(), StatusCode::OK);
11870 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11871 .await
11872 .unwrap();
11873 assert_eq!(&body[..], b"body { color: red; }");
11874
11875 let req = Request::builder()
11877 .uri("/missing.txt")
11878 .body(AxumBody::empty())
11879 .unwrap();
11880 let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
11881 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11882
11883 std::fs::remove_dir_all(&temp_dir).ok();
11885 }
11886
11887 #[allow(clippy::await_holding_lock)]
11888 #[tokio::test]
11889 async fn test_spa_fallback_serves_index_for_unknown_paths() {
11890 let _guard = lock_registry_test_mutex();
11891 ServerRegistry::reset();
11892
11893 let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
11894 std::fs::create_dir_all(&temp_dir).unwrap();
11895 std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
11896 std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
11897
11898 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11899
11900 let registry = make_test_registry();
11901 let serve_dir = ServeDir::new(&canonical_dir)
11902 .precompressed_gzip()
11903 .precompressed_br()
11904 .append_index_html_on_directories(true);
11905
11906 let mount = StaticMount {
11907 mount_path: "/".to_string(),
11908 mode: MountMode::Spa,
11909 dir: canonical_dir.clone(),
11910 cache_control: "public, max-age=0".to_string(),
11911 error_pages: std::collections::HashMap::new(),
11912 serve_dir,
11913 };
11914 registry.register_static_mount(mount).await.unwrap();
11916
11917 let state = make_test_state(registry);
11918
11919 let req = Request::builder()
11921 .method("GET")
11922 .uri("/dashboard")
11923 .header("Accept", "text/html")
11924 .body(AxumBody::empty())
11925 .unwrap();
11926 let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
11927 assert_eq!(resp.status(), StatusCode::OK);
11928 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11929 .await
11930 .unwrap();
11931 assert_eq!(&body[..], b"<h1>SPA App</h1>");
11932
11933 let req = Request::builder()
11935 .method("GET")
11936 .uri("/app.js")
11937 .body(AxumBody::empty())
11938 .unwrap();
11939 let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
11940 assert_eq!(resp.status(), StatusCode::OK);
11941 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11942 .await
11943 .unwrap();
11944 assert_eq!(&body[..], b"console.log('app')");
11945
11946 let req = Request::builder()
11948 .method("GET")
11949 .uri("/api/data")
11950 .header("Accept", "application/json")
11951 .body(AxumBody::empty())
11952 .unwrap();
11953 let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
11954 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11955
11956 let req = Request::builder()
11958 .method("GET")
11959 .uri("/style.css")
11960 .header("Accept", "text/html")
11961 .body(AxumBody::empty())
11962 .unwrap();
11963 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
11964 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11965
11966 std::fs::remove_dir_all(&temp_dir).ok();
11968 }
11969
11970 #[allow(clippy::await_holding_lock)]
11977 async fn run_conditional_get_returns_304(mode: MountMode) {
11978 let _guard = lock_registry_test_mutex();
11979 ServerRegistry::reset();
11980
11981 let temp_dir = std::env::temp_dir().join(format!(
11982 "http_cond_get_{}_{}",
11983 if mode == MountMode::Spa {
11984 "spa"
11985 } else {
11986 "static"
11987 },
11988 std::process::id()
11989 ));
11990 std::fs::create_dir_all(&temp_dir).unwrap();
11991 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
11992
11993 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11994
11995 let registry = make_test_registry();
11996 let serve_dir = ServeDir::new(&canonical_dir)
11997 .precompressed_gzip()
11998 .precompressed_br()
11999 .append_index_html_on_directories(true);
12000
12001 let mount = StaticMount {
12002 mount_path: "/".to_string(),
12003 mode,
12004 dir: canonical_dir.clone(),
12005 cache_control: "public, max-age=3600".to_string(),
12006 error_pages: std::collections::HashMap::new(),
12007 serve_dir,
12008 };
12009 registry.register_static_mount(mount).await.unwrap();
12010
12011 let state = make_test_state(registry);
12012
12013 let req = Request::builder()
12015 .method("GET")
12016 .uri("/index.html")
12017 .body(AxumBody::empty())
12018 .unwrap();
12019 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
12020 assert_eq!(
12021 resp.status(),
12022 StatusCode::OK,
12023 "first GET should return 200, got {}",
12024 resp.status()
12025 );
12026 assert!(
12028 resp.headers().contains_key(http::header::CACHE_CONTROL),
12029 "200 response missing Cache-Control"
12030 );
12031 let etag = resp
12032 .headers()
12033 .get(http::header::ETAG)
12034 .expect("ServeDir must emit ETag on 200 for If-None-Match coverage")
12035 .clone();
12036 let last_modified = resp
12037 .headers()
12038 .get(http::header::LAST_MODIFIED)
12039 .expect("ServeDir must emit Last-Modified on 200 for If-Modified-Since coverage")
12040 .clone();
12041 let _ = axum::body::to_bytes(resp.into_body(), usize::MAX)
12043 .await
12044 .unwrap();
12045
12046 let req = Request::builder()
12050 .method("GET")
12051 .uri("/index.html")
12052 .header(http::header::IF_NONE_MATCH, etag.clone())
12053 .body(AxumBody::empty())
12054 .unwrap();
12055 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
12056 assert_eq!(
12057 resp.status(),
12058 StatusCode::NOT_MODIFIED,
12059 "If-None-Match with matching ETag should return 304, got {}",
12060 resp.status()
12061 );
12062 assert!(
12064 resp.headers().contains_key(http::header::CACHE_CONTROL),
12065 "304 (If-None-Match) missing Cache-Control"
12066 );
12067 assert_eq!(
12070 resp.headers().get(http::header::ETAG),
12071 Some(&etag),
12072 "304 (If-None-Match) must echo the ETag validator"
12073 );
12074 assert_eq!(
12075 resp.headers().get(http::header::LAST_MODIFIED),
12076 Some(&last_modified),
12077 "304 (If-None-Match) must carry Last-Modified"
12078 );
12079
12080 let req = Request::builder()
12082 .method("GET")
12083 .uri("/index.html")
12084 .header(http::header::IF_MODIFIED_SINCE, last_modified.clone())
12085 .body(AxumBody::empty())
12086 .unwrap();
12087 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
12088 assert_eq!(
12089 resp.status(),
12090 StatusCode::NOT_MODIFIED,
12091 "If-Modified-Since with matching timestamp should return 304, got {}",
12092 resp.status()
12093 );
12094 assert!(
12095 resp.headers().contains_key(http::header::CACHE_CONTROL),
12096 "304 (If-Modified-Since) missing Cache-Control"
12097 );
12098 assert_eq!(
12099 resp.headers().get(http::header::ETAG),
12100 Some(&etag),
12101 "304 (If-Modified-Since) must carry the ETag validator"
12102 );
12103 assert_eq!(
12104 resp.headers().get(http::header::LAST_MODIFIED),
12105 Some(&last_modified),
12106 "304 (If-Modified-Since) must echo Last-Modified"
12107 );
12108
12109 let req = Request::builder()
12115 .method("GET")
12116 .uri("/index.html")
12117 .header(
12118 http::header::IF_MODIFIED_SINCE,
12119 "Wed, 21 Oct 2000 07:28:00 GMT",
12120 )
12121 .body(AxumBody::empty())
12122 .unwrap();
12123 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
12124 assert_eq!(
12125 resp.status(),
12126 StatusCode::OK,
12127 "past If-Modified-Since should return 200 (file modified after it), got {}",
12128 resp.status()
12129 );
12130
12131 std::fs::remove_dir_all(&temp_dir).ok();
12133 }
12134
12135 #[tokio::test]
12136 async fn test_conditional_get_returns_304_static_mode() {
12137 run_conditional_get_returns_304(MountMode::Static).await;
12138 }
12139
12140 #[tokio::test]
12141 async fn test_conditional_get_returns_304_spa_mode() {
12142 run_conditional_get_returns_304(MountMode::Spa).await;
12143 }
12144
12145 #[allow(clippy::await_holding_lock)]
12146 #[tokio::test]
12147 async fn test_error_page_mapping_serves_custom_404() {
12148 let _guard = lock_registry_test_mutex();
12149 ServerRegistry::reset();
12150
12151 let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
12152 let errors_dir = temp_dir.join("errors");
12153 std::fs::create_dir_all(&errors_dir).unwrap();
12154 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
12155 std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
12156
12157 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
12158 let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
12159
12160 let registry = make_test_registry();
12161 let serve_dir = ServeDir::new(&canonical_dir)
12162 .precompressed_gzip()
12163 .precompressed_br()
12164 .append_index_html_on_directories(true);
12165
12166 let mut error_pages = std::collections::HashMap::new();
12167 error_pages.insert(404, canonical_404);
12168
12169 let mount = StaticMount {
12170 mount_path: "/".to_string(),
12171 mode: MountMode::Static,
12172 dir: canonical_dir.clone(),
12173 cache_control: "public, max-age=0".to_string(),
12174 error_pages,
12175 serve_dir,
12176 };
12177 registry.register_static_mount(mount).await.unwrap();
12178
12179 let state = make_test_state(registry);
12180
12181 let req = Request::builder()
12183 .method("GET")
12184 .uri("/missing.html")
12185 .body(AxumBody::empty())
12186 .unwrap();
12187 let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
12188 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
12189 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
12190 .await
12191 .unwrap();
12192 assert_eq!(&body[..], b"<h1>Custom 404</h1>");
12193
12194 let req = Request::builder()
12196 .method("GET")
12197 .uri("/index.html")
12198 .body(AxumBody::empty())
12199 .unwrap();
12200 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
12201 assert_eq!(resp.status(), StatusCode::OK);
12202 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
12203 .await
12204 .unwrap();
12205 assert_eq!(&body[..], b"<h1>Home</h1>");
12206
12207 std::fs::remove_dir_all(&temp_dir).ok();
12209 }
12210
12211 #[tokio::test]
12212 async fn http_consumer_returns_body_and_code_on_stop() {
12213 use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
12214 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
12215 use tower::ServiceExt;
12216
12217 let set_body_step = CompiledStep::Process {
12219 kind_hint: camel_api::SpanKindHint::Internal,
12220 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
12221 ex.input.body = Body::Text("nope".into());
12222 Box::pin(async move { Ok(ex) })
12223 }),
12224 body_contract: None,
12225 lifecycle: None,
12226 label: None,
12227 to_uri: None,
12228 };
12229 let set_status_step = CompiledStep::Process {
12230 kind_hint: camel_api::SpanKindHint::Internal,
12231 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
12232 ex.input.set_header(
12233 "CamelHttpResponseCode",
12234 serde_json::Value::Number(409.into()),
12235 );
12236 Box::pin(async move { Ok(ex) })
12237 }),
12238 body_contract: None,
12239 lifecycle: None,
12240 label: None,
12241 to_uri: None,
12242 };
12243 let pipeline = compose_pipeline_with_handler(
12244 vec![set_body_step, set_status_step, CompiledStep::Stop],
12245 None,
12246 PipelineRuntimeCtx::compile_time(),
12247 );
12248
12249 let ex = Exchange::new(Message::default());
12250 let result = pipeline.oneshot(ex).await;
12251 assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
12252 let returned = result.unwrap();
12253 assert_eq!(returned.input.body.as_text(), Some("nope"));
12254 assert_eq!(
12255 returned
12256 .input
12257 .header("CamelHttpResponseCode")
12258 .and_then(|v| v.as_u64()),
12259 Some(409)
12260 );
12261 }
12262
12263 #[tokio::test]
12264 async fn http_consumer_returns_200_when_body_empty_on_stop() {
12265 use camel_api::{Exchange, Message};
12273 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
12274 use tower::ServiceExt;
12275
12276 let pipeline = compose_pipeline_with_handler(
12277 vec![CompiledStep::Stop],
12278 None,
12279 PipelineRuntimeCtx::compile_time(),
12280 );
12281 let ex = Exchange::new(Message::default());
12282 let result = pipeline.oneshot(ex).await;
12283 assert!(result.is_ok(), "Stop with empty body arrives as Ok");
12284 }
12287
12288 async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
12296 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12297 let port = listener.local_addr().unwrap().port();
12298 let registry = HttpRouteRegistry::new();
12299 tokio::spawn(run_axum_server(
12300 listener,
12301 registry.clone(),
12302 2 * 1024 * 1024,
12303 10 * 1024 * 1024,
12304 Arc::new(tokio::sync::Semaphore::new(1024)),
12305 test_rt(),
12306 "test-route".into(),
12307 ));
12308 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
12310 (port, registry)
12311 }
12312
12313 fn spawn_responder(
12318 mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
12319 status: u16,
12320 body: String,
12321 ) -> tokio::task::JoinHandle<()> {
12322 tokio::spawn(async move {
12323 match tokio::time::timeout(Duration::from_secs(10), rx.recv()).await {
12327 Ok(Some(envelope)) => {
12328 let _ = envelope.reply_tx.send(HttpReply {
12329 status,
12330 headers: vec![],
12331 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
12332 });
12333 }
12334 Ok(None) => {}
12335 Err(_) => panic!("spawn_responder: no request arrived within 10s"),
12336 }
12337 })
12338 }
12339
12340 #[tokio::test]
12341 async fn method_aware_dispatch_same_path_different_verbs() {
12342 let (port, registry) = spawn_test_server().await;
12343
12344 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12349 registry
12350 .register_rest_endpoint(
12351 "GET".into(),
12352 vec![PathSegment::Literal("users".into())],
12353 get_tx,
12354 )
12355 .await;
12356
12357 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12358 registry
12359 .register_rest_endpoint(
12360 "POST".into(),
12361 vec![PathSegment::Literal("users".into())],
12362 post_tx,
12363 )
12364 .await;
12365
12366 let get_handle = spawn_responder(get_rx, 200, "list".into());
12367 let post_handle = spawn_responder(post_rx, 201, "create".into());
12368
12369 let client = plain_http_test_client();
12370
12371 let resp = client
12373 .get(format!("http://127.0.0.1:{port}/users"))
12374 .send()
12375 .await
12376 .unwrap();
12377 assert_eq!(resp.status().as_u16(), 200);
12378 let body = resp.text().await.unwrap();
12379 assert_eq!(body, "list");
12380
12381 let resp = client
12383 .post(format!("http://127.0.0.1:{port}/users"))
12384 .send()
12385 .await
12386 .unwrap();
12387 assert_eq!(resp.status().as_u16(), 201);
12388 let body = resp.text().await.unwrap();
12389 assert_eq!(body, "create");
12390
12391 let (get_joined, post_joined) = tokio::join!(get_handle, post_handle);
12392 get_joined.expect("get responder task must not stall");
12393 post_joined.expect("post responder task must not stall");
12394 }
12395
12396 #[tokio::test]
12397 async fn method_aware_dispatch_templated_path_extracts_params() {
12398 let (port, registry) = spawn_test_server().await;
12399
12400 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12404 registry
12405 .register_rest_endpoint(
12406 "GET".into(),
12407 vec![
12408 PathSegment::Literal("users".into()),
12409 PathSegment::Param("id".into()),
12410 ],
12411 tx,
12412 )
12413 .await;
12414
12415 let handle = tokio::spawn(async move {
12418 match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
12419 Ok(Some(envelope)) => {
12420 let id = envelope.path_params.get("id").cloned().unwrap_or_default();
12421 let _ = envelope.reply_tx.send(HttpReply {
12422 status: 200,
12423 headers: vec![],
12424 body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
12425 });
12426 }
12427 Ok(None) => {}
12429 Err(_) => {}
12431 }
12432 });
12433
12434 let client = plain_http_test_client();
12435 let resp = client
12436 .get(format!("http://127.0.0.1:{port}/users/42"))
12437 .send()
12438 .await
12439 .unwrap();
12440 assert_eq!(resp.status().as_u16(), 200);
12441 let body = resp.text().await.unwrap();
12442 assert_eq!(body, "id=42");
12443
12444 let _ = handle.await;
12445 }
12446
12447 #[tokio::test]
12448 async fn method_aware_dispatch_unmatched_method_falls_through() {
12449 let (port, _registry) = spawn_test_server().await;
12454
12455 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12457 _registry
12458 .register_rest_endpoint(
12459 "GET".into(),
12460 vec![PathSegment::Literal("users".into())],
12461 get_tx,
12462 )
12463 .await;
12464
12465 let drain = tokio::spawn(async move {
12468 let mut get_rx = get_rx;
12469 loop {
12470 match tokio::time::timeout(Duration::from_secs(2), get_rx.recv()).await {
12471 Ok(Some(_)) => continue,
12472 Ok(None) => break, Err(_) => break, }
12475 }
12476 });
12477
12478 let client = plain_http_test_client();
12479 let resp = client
12480 .delete(format!("http://127.0.0.1:{port}/users"))
12481 .send()
12482 .await
12483 .unwrap();
12484 assert_eq!(resp.status().as_u16(), 404);
12485
12486 drop(drain);
12487 }
12488
12489 #[tokio::test]
12490 async fn regression_legacy_exact_api_route_still_works() {
12491 let (port, registry) = spawn_test_server().await;
12496
12497 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12498 registry.register_api_route("/legacy/path".into(), tx).await;
12499
12500 let handle = tokio::spawn(async move {
12501 match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
12502 Ok(Some(envelope)) => {
12503 let _ = envelope.reply_tx.send(HttpReply {
12504 status: 200,
12505 headers: vec![],
12506 body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
12507 });
12508 }
12509 Ok(None) => {}
12511 Err(_) => {}
12513 }
12514 });
12515
12516 let client = plain_http_test_client();
12517 let resp = client
12518 .get(format!("http://127.0.0.1:{port}/legacy/path"))
12519 .send()
12520 .await
12521 .unwrap();
12522 assert_eq!(resp.status().as_u16(), 200);
12523 let body = resp.text().await.unwrap();
12524 assert_eq!(body, "legacy ok");
12525
12526 let _ = handle.await;
12527 }
12528
12529 #[allow(clippy::await_holding_lock)]
12530 #[tokio::test]
12531 async fn regression_static_mount_still_works() {
12532 let _guard = lock_registry_test_mutex();
12536 ServerRegistry::reset();
12537
12538 let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
12539 std::fs::create_dir_all(&temp_dir).unwrap();
12540 std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
12541 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
12542
12543 let registry = make_test_registry();
12544 let serve_dir = ServeDir::new(&canonical_dir)
12545 .precompressed_gzip()
12546 .precompressed_br()
12547 .append_index_html_on_directories(true);
12548 let mount = StaticMount {
12549 mount_path: "/".to_string(),
12550 mode: MountMode::Static,
12551 dir: canonical_dir.clone(),
12552 cache_control: "public, max-age=3600".to_string(),
12553 error_pages: std::collections::HashMap::new(),
12554 serve_dir,
12555 };
12556 registry.register_static_mount(mount).await.unwrap();
12557
12558 let state = make_test_state(registry);
12559 let req = Request::builder()
12560 .uri("/regress.txt")
12561 .body(AxumBody::empty())
12562 .unwrap();
12563 let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
12564 assert_eq!(resp.status(), StatusCode::OK);
12565 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
12566 .await
12567 .unwrap();
12568 assert_eq!(&body[..], b"static works");
12569
12570 std::fs::remove_dir_all(&temp_dir).ok();
12571 }
12572
12573 #[tokio::test]
12582 async fn deregister_one_method_keeps_sibling_verbs() {
12583 let (port, registry) = spawn_test_server().await;
12587
12588 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12589 registry
12590 .register_rest_endpoint(
12591 "GET".into(),
12592 vec![PathSegment::Literal("users".into())],
12593 get_tx,
12594 )
12595 .await;
12596
12597 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12598 registry
12599 .register_rest_endpoint(
12600 "POST".into(),
12601 vec![PathSegment::Literal("users".into())],
12602 post_tx,
12603 )
12604 .await;
12605
12606 let drain = tokio::spawn(async move {
12608 let mut get_rx = get_rx;
12609 loop {
12610 match tokio::time::timeout(Duration::from_secs(2), get_rx.recv()).await {
12611 Ok(Some(_)) => continue,
12612 Ok(None) => break, Err(_) => break, }
12615 }
12616 });
12617
12618 registry.unregister_rest_endpoint("GET", "/users").await;
12620 drop(drain);
12621
12622 let post_handle = spawn_responder(post_rx, 201, "create".into());
12623
12624 let client = plain_http_test_client();
12625 let resp = client
12627 .post(format!("http://127.0.0.1:{port}/users"))
12628 .send()
12629 .await
12630 .unwrap();
12631 assert_eq!(resp.status().as_u16(), 201);
12632 assert_eq!(resp.text().await.unwrap(), "create");
12633
12634 post_handle
12635 .await
12636 .expect("post responder task must not stall");
12637 }
12638
12639 #[tokio::test]
12640 async fn dispatch_exact_legacy_beats_rest_template() {
12641 let (port, registry) = spawn_test_server().await;
12646
12647 let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12649 registry
12650 .register_api_route("/api/users".into(), exact_tx)
12651 .await;
12652 let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
12653
12654 let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12656 registry
12657 .register_rest_endpoint(
12658 "GET".into(),
12659 vec![
12660 PathSegment::Literal("api".into()),
12661 PathSegment::Param("resource".into()),
12662 ],
12663 tpl_tx,
12664 )
12665 .await;
12666 let _tpl_drain = tokio::spawn(async move {
12672 let mut tpl_rx = tpl_rx;
12673 match tokio::time::timeout(Duration::from_secs(2), tpl_rx.recv()).await {
12674 Ok(Some(env)) => {
12675 let _ = env.reply_tx.send(HttpReply {
12676 status: 200,
12677 headers: vec![],
12678 body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
12679 });
12680 }
12681 Ok(None) => {}
12683 Err(_) => {}
12685 }
12686 });
12687
12688 let client = plain_http_test_client();
12689 let resp = client
12690 .get(format!("http://127.0.0.1:{port}/api/users"))
12691 .send()
12692 .await
12693 .unwrap();
12694 assert_eq!(resp.status().as_u16(), 200);
12695 assert_eq!(resp.text().await.unwrap(), "exact");
12697
12698 exact_handle
12699 .await
12700 .expect("exact responder task must not stall");
12701 }
12702
12703 #[tokio::test]
12704 async fn ambiguous_rest_templates_return_500_not_silent_404() {
12705 let (port, registry) = spawn_test_server().await;
12710
12711 let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12712 registry
12713 .register_rest_endpoint(
12714 "GET".into(),
12715 vec![
12716 PathSegment::Literal("users".into()),
12717 PathSegment::Param("id".into()),
12718 ],
12719 a_tx,
12720 )
12721 .await;
12722
12723 let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12724 registry
12725 .register_rest_endpoint(
12726 "GET".into(),
12727 vec![
12728 PathSegment::Literal("users".into()),
12729 PathSegment::Param("name".into()),
12730 ],
12731 b_tx,
12732 )
12733 .await;
12734
12735 let client = plain_http_test_client();
12736 let resp = client
12737 .get(format!("http://127.0.0.1:{port}/users/42"))
12738 .send()
12739 .await
12740 .unwrap();
12741 assert_eq!(resp.status().as_u16(), 500);
12743 }
12744
12745 #[test]
12746 fn from_uri_round_trips_templated_path_with_http_method() {
12747 use crate::UriConfig;
12753 let cfg =
12754 HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
12755 assert_eq!(cfg.host, "0.0.0.0");
12756 assert_eq!(cfg.port, 8080);
12757 assert_eq!(cfg.path, "/users/{id}");
12758 assert_eq!(cfg.method.as_deref(), Some("GET"));
12759
12760 let cfg_lc =
12762 HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
12763 assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
12764 assert_eq!(cfg_lc.path, "/orders");
12765 }
12766
12767 #[test]
12772 fn type_conversion_failed_maps_to_400() {
12773 let reply = pipeline_error_to_reply(
12774 CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
12775 "/api/users",
12776 );
12777 assert_eq!(reply.status, 400);
12778 let json_ct = reply
12780 .headers
12781 .iter()
12782 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12783 .count();
12784 assert_eq!(json_ct, 1);
12785 let body = match &reply.body {
12787 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12788 _ => panic!("expected bytes body"),
12789 };
12790 let parsed: serde_json::Value =
12791 serde_json::from_str(&body).expect("body must be valid JSON");
12792 assert_eq!(parsed["error"], "bad_request");
12793 assert_eq!(parsed["message"], "invalid JSON at line 1");
12794 }
12795
12796 #[test]
12797 fn other_error_still_maps_to_500() {
12798 let reply =
12799 pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
12800 assert_eq!(reply.status, 500);
12801 }
12802
12803 #[test]
12804 fn unauthenticated_maps_to_401() {
12805 let reply = pipeline_error_to_reply(
12806 CamelError::Unauthenticated("no token".to_string()),
12807 "/api/users",
12808 );
12809 assert_eq!(reply.status, 401);
12810 }
12811
12812 #[test]
12813 fn unauthorized_maps_to_403() {
12814 let reply = pipeline_error_to_reply(
12815 CamelError::Unauthorized("forbidden".to_string()),
12816 "/api/users",
12817 );
12818 assert_eq!(reply.status, 403);
12819 }
12820
12821 #[test]
12822 fn validation_error_maps_to_400() {
12823 let reply = pipeline_error_to_reply(
12824 CamelError::ValidationError("body does not match schema".to_string()),
12825 "/api/users",
12826 );
12827 assert_eq!(reply.status, 400);
12828 let json_ct = reply
12829 .headers
12830 .iter()
12831 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12832 .count();
12833 assert_eq!(json_ct, 1);
12834 let body = match &reply.body {
12835 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12836 _ => panic!("expected bytes body"),
12837 };
12838 let parsed: serde_json::Value =
12839 serde_json::from_str(&body).expect("body must be valid JSON");
12840 assert_eq!(parsed["error"], "validation_error");
12841 assert_eq!(parsed["message"], "body does not match schema");
12842 }
12843
12844 #[test]
12849 fn finalizer_maps_unsupported_media_type() {
12850 let reply = pipeline_error_to_reply(
12851 CamelError::UnsupportedMediaType {
12852 consumed: "text/plain".to_string(),
12853 declared: "application/json".to_string(),
12854 },
12855 "/x",
12856 );
12857 assert_eq!(reply.status, 415);
12858 let json_ct = reply
12859 .headers
12860 .iter()
12861 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12862 .count();
12863 assert_eq!(json_ct, 1);
12864 let body = match &reply.body {
12865 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12866 _ => panic!("expected bytes body"),
12867 };
12868 let parsed: serde_json::Value =
12869 serde_json::from_str(&body).expect("body must be valid JSON");
12870 assert_eq!(parsed["error"], "unsupported_media_type");
12871 assert_eq!(
12872 parsed["message"],
12873 "consumed text/plain, declared application/json"
12874 );
12875 }
12876
12877 #[test]
12878 fn finalizer_maps_not_acceptable() {
12879 let reply = pipeline_error_to_reply(
12880 CamelError::NotAcceptable {
12881 accept: "application/xml".to_string(),
12882 produced: "application/json".to_string(),
12883 },
12884 "/x",
12885 );
12886 assert_eq!(reply.status, 406);
12887 let json_ct = reply
12888 .headers
12889 .iter()
12890 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12891 .count();
12892 assert_eq!(json_ct, 1);
12893 let body = match &reply.body {
12894 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12895 _ => panic!("expected bytes body"),
12896 };
12897 let parsed: serde_json::Value =
12898 serde_json::from_str(&body).expect("body must be valid JSON");
12899 assert_eq!(parsed["error"], "not_acceptable");
12900 assert_eq!(
12901 parsed["message"],
12902 "accept application/xml, produced application/json"
12903 );
12904 }
12905
12906 #[test]
12907 fn json_error_reply_preserves_empty_message() {
12908 let reply = json_error_reply(400, "bad_request", "".to_string());
12909 assert_eq!(reply.status, 400);
12910 let json_ct = reply
12911 .headers
12912 .iter()
12913 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
12914 .count();
12915 assert_eq!(json_ct, 1);
12916 let body = match &reply.body {
12917 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
12918 _ => panic!("expected bytes body"),
12919 };
12920 let parsed: serde_json::Value =
12921 serde_json::from_str(&body).expect("body must be valid JSON");
12922 assert_eq!(parsed["error"], "bad_request");
12923 assert_eq!(parsed["message"], "");
12924 }
12925
12926 #[test]
12927 fn https_consumer_without_tls_cert_errors() {
12928 let endpoint = HttpEndpoint {
12929 uri: "https://0.0.0.0:8443/api".to_string(),
12930 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
12931 server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
12932 client: plain_http_test_client(),
12933 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
12934 PINNED_CLIENT_TTL,
12935 PINNED_CLIENT_MAX_ENTRIES,
12936 )),
12937 http_config: HttpConfig::default(),
12938 };
12939 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
12940 let result = endpoint.create_consumer(rt);
12941 assert!(result.is_err(), "expected error for https without tls cert");
12942 if let Err(e) = result {
12943 let msg = e.to_string();
12944 assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
12945 }
12946 }
12947
12948 #[test]
12949 fn http_consumer_with_tls_config_errors() {
12950 let endpoint = HttpEndpoint {
12951 uri: "http://0.0.0.0:8080/api".to_string(),
12952 config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
12953 server_config: HttpServerConfig::from_uri(
12954 "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
12955 )
12956 .unwrap(),
12957 client: plain_http_test_client(),
12958 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
12959 PINNED_CLIENT_TTL,
12960 PINNED_CLIENT_MAX_ENTRIES,
12961 )),
12962 http_config: HttpConfig::default(),
12963 };
12964 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
12965 let result = endpoint.create_consumer(rt);
12966 assert!(result.is_err(), "expected error for http with tls config");
12967 if let Err(e) = result {
12968 let msg = e.to_string();
12969 assert!(msg.contains("https"), "error must mention https: {msg}");
12970 }
12971 }
12972
12973 #[test]
12974 fn https_consumer_with_partial_tls_cert_only_errors() {
12975 let server_config =
12978 HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
12979 assert!(
12980 server_config.tls_config.is_none(),
12981 "partial tlsCert must not create ServerTlsConfig"
12982 );
12983 let endpoint = HttpEndpoint {
12984 uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
12985 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
12986 .unwrap(),
12987 server_config,
12988 client: plain_http_test_client(),
12989 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
12990 PINNED_CLIENT_TTL,
12991 PINNED_CLIENT_MAX_ENTRIES,
12992 )),
12993 http_config: HttpConfig::default(),
12994 };
12995 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
12996 let result = endpoint.create_consumer(rt);
12997 assert!(
12998 result.is_err(),
12999 "must error: https:// requires both tlsCert and tlsKey"
13000 );
13001 }
13002
13003 #[test]
13004 fn load_tls_config_parses_valid_pem() {
13005 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
13007 use camel_component_api::test_support::tls;
13008 let (_, cert_pem, key_pem) = tls::gen_server_cert();
13009 let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
13010 let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
13011
13012 let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
13013 assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
13014 }
13015
13016 #[tokio::test(flavor = "multi_thread")]
13017 #[allow(clippy::await_holding_lock)]
13018 async fn consumer_tls_handshake_roundtrip() {
13019 use camel_component_api::test_support::tls;
13020 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
13021
13022 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
13024
13025 let _guard = lock_registry_test_mutex();
13027
13028 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
13030 let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
13031 let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
13032 let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
13033
13034 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13036 let port = probe.local_addr().unwrap().port();
13037 drop(probe);
13038
13039 ServerRegistry::reset();
13040
13041 let component = HttpComponent::new();
13043 let endpoint_ctx = NoOpComponentContext;
13044 let uri = format!(
13045 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
13046 cert_path.to_string_lossy(),
13047 key_path.to_string_lossy(),
13048 );
13049 let endpoint = component
13050 .create_endpoint(&uri, &endpoint_ctx)
13051 .expect("create TLS endpoint");
13052 let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
13053
13054 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
13056 let token = tokio_util::sync::CancellationToken::new();
13057 let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
13058 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
13059
13060 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
13062
13063 let ca_bytes = std::fs::read(&ca_path).unwrap();
13065 let client = reqwest::Client::builder()
13066 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
13067 .build()
13068 .unwrap();
13069
13070 let send_fut = client
13071 .post(format!("https://localhost:{port}/test"))
13072 .body("ping")
13073 .send();
13074
13075 let (http_result, _) = tokio::join!(send_fut, async {
13077 if let Some(mut envelope) = rx.recv().await {
13078 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
13079 if let Some(reply_tx) = envelope.reply_tx {
13080 let _ = reply_tx.send(Ok(envelope.exchange));
13081 }
13082 }
13083 });
13084
13085 let resp = http_result.expect("TLS handshake + request must succeed");
13086
13087 assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
13088 let body = resp.text().await.unwrap();
13089 assert_eq!(body, "pong");
13090
13091 token.cancel();
13092 }
13093
13094 #[tokio::test(flavor = "multi_thread")]
13095 #[allow(clippy::await_holding_lock)]
13096 async fn consumer_tls_rejects_client_without_ca() {
13097 use camel_component_api::test_support::tls;
13098 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
13099
13100 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
13101
13102 let _guard = lock_registry_test_mutex();
13104
13105 let (_, cert_pem, key_pem) = tls::gen_server_cert();
13106 let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
13107 let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
13108
13109 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13110 let port = probe.local_addr().unwrap().port();
13111 drop(probe);
13112
13113 ServerRegistry::reset();
13114
13115 let component = HttpComponent::new();
13117 let endpoint_ctx = NoOpComponentContext;
13118 let uri = format!(
13119 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
13120 cert_path.to_string_lossy(),
13121 key_path.to_string_lossy(),
13122 );
13123 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
13124 let mut consumer = endpoint.create_consumer(rt()).unwrap();
13125 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
13126 let token = tokio_util::sync::CancellationToken::new();
13127 let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
13128 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
13129
13130 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
13131
13132 let client = plain_http_test_client();
13134
13135 let result = client
13136 .get(format!("https://localhost:{port}/test"))
13137 .send()
13138 .await;
13139
13140 assert!(
13141 result.is_err(),
13142 "must reject without CA — proves real verification"
13143 );
13144
13145 token.cancel();
13146 }
13147
13148 #[test]
13149 fn server_config_partial_tls_cert_without_key() {
13150 let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
13152 assert!(cfg.tls_config.is_none());
13154 }
13155
13156 #[test]
13157 fn endpoint_uri_options_count_parity() {
13158 assert_eq!(
13160 HttpEndpointConfig::uri_options().len(),
13161 23,
13162 "HttpEndpointUriConfig #[uri_param] count drifted from parser"
13163 );
13164 }
13165
13166 fn make_headers(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
13167 pairs
13168 .iter()
13169 .map(|(k, v)| {
13170 (
13171 (*k).to_string(),
13172 serde_json::Value::String((*v).to_string()),
13173 )
13174 })
13175 .collect()
13176 }
13177
13178 #[test]
13179 fn response_emits_cache_control_via_pragma_warning() {
13180 let headers = make_headers(&[
13181 ("Cache-Control", "public, max-age=3600"),
13182 ("Via", "1.1 myproxy"),
13183 ("Pragma", "no-cache"),
13184 ("Warning", "199 misc"),
13185 ]);
13186 let selected = select_response_headers(&headers, None, None);
13187 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
13188 for expected in ["Cache-Control", "Via", "Pragma", "Warning"] {
13189 assert!(
13190 names.contains(&expected),
13191 "{expected} should pass through to the response"
13192 );
13193 }
13194 }
13195
13196 #[test]
13197 fn response_excludes_request_only_and_server_owned() {
13198 let headers = make_headers(&[
13199 ("User-Agent", "x"),
13200 ("Accept", "*/*"),
13201 ("Date", "Thu, 01 Jan 2026 00:00:00 GMT"),
13202 ]);
13203 let selected = select_response_headers(&headers, None, None);
13204 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
13205 for excluded in ["User-Agent", "Accept", "Date"] {
13206 assert!(
13207 !names.contains(&excluded),
13208 "{excluded} should NOT appear in the response"
13209 );
13210 }
13211 }
13212
13213 #[test]
13214 fn response_re_derives_content_type() {
13215 let headers = make_headers(&[("Content-Type", "text/plain")]);
13216 let selected = select_response_headers(&headers, Some("application/json".into()), None);
13217 let ct_entries: Vec<&str> = selected
13218 .iter()
13219 .filter(|(k, _)| k == "Content-Type")
13220 .map(|(_, v)| v.as_str())
13221 .collect();
13222 assert_eq!(
13223 ct_entries,
13224 ["application/json"],
13225 "exactly one Content-Type entry, re-derived from user_content_type"
13226 );
13227 }
13228
13229 #[test]
13230 fn response_excludes_camel_headers() {
13231 let headers = make_headers(&[("CamelHttpPath", "/foo"), ("Cache-Control", "public")]);
13232 let selected = select_response_headers(&headers, None, None);
13233 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
13234 assert!(
13235 !names.contains(&"CamelHttpPath"),
13236 "Camel-namespace headers must be excluded"
13237 );
13238 assert!(
13239 names.contains(&"Cache-Control"),
13240 "Cache-Control must pass through"
13241 );
13242 }
13243
13244 #[test]
13245 fn response_stringifies_scalar_header_values() {
13246 let mut headers = make_headers(&[("X-Label", "keep")]);
13247 headers.insert("X-Retries".to_string(), serde_json::json!(3));
13248 headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
13249 headers.insert("X-Enabled".to_string(), serde_json::json!(true));
13250 let selected = select_response_headers(&headers, None, None);
13251 let get = |name: &str| -> Option<&str> {
13252 selected
13253 .iter()
13254 .find(|(k, _)| k == name)
13255 .map(|(_, v)| v.as_str())
13256 };
13257 assert_eq!(
13258 get("X-Retries"),
13259 Some("3"),
13260 "integer header must be stringified"
13261 );
13262 assert_eq!(
13263 get("X-Ratio"),
13264 Some("3.5"),
13265 "float header must be stringified"
13266 );
13267 assert_eq!(
13268 get("X-Enabled"),
13269 Some("true"),
13270 "bool header must be stringified"
13271 );
13272 assert_eq!(
13273 get("X-Label"),
13274 Some("keep"),
13275 "string header must pass through"
13276 );
13277 }
13278
13279 #[test]
13280 fn response_drops_null_and_structured_header_values() {
13281 let mut headers = make_headers(&[("X-Keep", "yes")]);
13282 headers.insert("X-Null".to_string(), serde_json::Value::Null);
13283 headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
13284 headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
13285 let selected = select_response_headers(&headers, None, None);
13286 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
13287 for dropped in ["X-Null", "X-Obj", "X-Arr"] {
13288 assert!(
13289 !names.contains(&dropped),
13290 "{dropped} must not be emitted: no single-value form"
13291 );
13292 }
13293 assert!(names.contains(&"X-Keep"), "scalar headers must survive");
13294 }
13295
13296 #[test]
13297 fn response_stringifies_scalars_despite_excluded_names() {
13298 let mut headers = HashMap::new();
13302 headers.insert("Content-Length".to_string(), serde_json::json!(999));
13303 headers.insert("Date".to_string(), serde_json::json!(12345));
13304 let selected = select_response_headers(&headers, None, None);
13305 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
13306 assert!(
13307 !names.contains(&"Content-Length"),
13308 "content-length is re-derived by the server"
13309 );
13310 assert!(!names.contains(&"Date"), "date is server-owned");
13311 }
13312
13313 #[test]
13314 fn outbound_stringifies_scalar_header_values() {
13315 let mut headers = make_headers(&[("X-Label", "keep")]);
13316 headers.insert("X-Retries".to_string(), serde_json::json!(3));
13317 headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
13318 headers.insert("X-Enabled".to_string(), serde_json::json!(true));
13319 let outbound = select_outbound_headers(&headers, &[], &[]);
13320 let get = |name: &str| -> Option<String> {
13322 outbound
13323 .accepted
13324 .iter()
13325 .find(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
13326 .map(|(_, v)| v.to_str().unwrap().to_string())
13327 };
13328 assert_eq!(
13329 get("X-Retries").as_deref(),
13330 Some("3"),
13331 "integer header must be stringified"
13332 );
13333 assert_eq!(
13334 get("X-Ratio").as_deref(),
13335 Some("3.5"),
13336 "float header must be stringified"
13337 );
13338 assert_eq!(
13339 get("X-Enabled").as_deref(),
13340 Some("true"),
13341 "bool header must be stringified"
13342 );
13343 assert_eq!(
13344 get("X-Label").as_deref(),
13345 Some("keep"),
13346 "string header must pass through"
13347 );
13348 assert!(outbound.drops.is_empty(), "scalar headers must not drop");
13349 }
13350
13351 #[test]
13352 fn outbound_drops_null_and_structured_header_values() {
13353 let mut headers = make_headers(&[("X-Keep", "yes")]);
13354 headers.insert("X-Null".to_string(), serde_json::Value::Null);
13355 headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
13356 headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
13357 let outbound = select_outbound_headers(&headers, &[], &[]);
13358 let has = |name: &str| {
13359 outbound
13360 .accepted
13361 .iter()
13362 .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
13363 };
13364 assert!(has("X-Keep"), "scalar headers must survive");
13365 for (name, kind) in [("X-Null", "null"), ("X-Obj", "object"), ("X-Arr", "array")] {
13366 let dropped = outbound
13367 .drops
13368 .iter()
13369 .find(|d| d.name == name)
13370 .unwrap_or_else(|| panic!("{name} must have a drop record: {:?}", outbound.drops));
13371 assert_eq!(
13372 dropped.reason, "no scalar string form",
13373 "{name} drop reason must name the value kind absence"
13374 );
13375 assert_eq!(dropped.value_kind, Some(kind), "{name} kind recorded");
13376 }
13377 }
13378
13379 #[test]
13380 fn outbound_stringifies_scalars_despite_excluded_names() {
13381 let mut headers = HashMap::new();
13385 headers.insert("Transfer-Encoding".to_string(), serde_json::json!(7));
13386 headers.insert("Host".to_string(), serde_json::json!(12345));
13387 headers.insert("X-Ok".to_string(), serde_json::json!(7));
13388 let outbound = select_outbound_headers(&headers, &[], &[]);
13389 let has = |name: &str| {
13390 outbound
13391 .accepted
13392 .iter()
13393 .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
13394 };
13395 assert!(
13396 !has("Transfer-Encoding"),
13397 "hop-by-hop header must stay excluded"
13398 );
13399 assert!(!has("Host"), "host is destination-derived");
13400 assert!(has("X-Ok"), "non-excluded scalar must be stringified");
13401 assert!(
13402 outbound
13403 .drops
13404 .iter()
13405 .any(|d| d.name == "Transfer-Encoding" && d.reason == "outbound emission policy"),
13406 "policy drop must be recorded before coercion"
13407 );
13408 }
13409
13410 #[test]
13411 fn outbound_drops_invalid_names_values_and_skip_config() {
13412 let mut headers = make_headers(&[("X-Good", "fine")]);
13413 headers.insert("X Bad Name".to_string(), serde_json::json!("v"));
13414 headers.insert(
13415 "X-Control-Value".to_string(),
13416 serde_json::json!("line1\nline2"),
13417 );
13418 headers.insert("X-Secret".to_string(), serde_json::json!("s3cr3t"));
13419 headers.insert("CamelHttpQuery".to_string(), serde_json::json!("q=1"));
13420 let skip = vec!["x-secret".to_string()];
13421 let outbound = select_outbound_headers(&headers, &skip, &[]);
13422 let has = |name: &str| {
13423 outbound
13424 .accepted
13425 .iter()
13426 .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
13427 };
13428 assert!(has("X-Good"), "valid header must survive");
13429 assert!(!has("X Bad Name"), "invalid header name must drop");
13430 assert!(!has("X-Control-Value"), "control-char value must drop");
13431 assert!(!has("X-Secret"), "skipped header must drop");
13432 assert!(!has("CamelHttpQuery"), "Camel-namespace header must drop");
13433 let reason = |n: &str| {
13434 outbound
13435 .drops
13436 .iter()
13437 .find(|d| d.name == n)
13438 .map(|d| d.reason)
13439 };
13440 assert_eq!(reason("X Bad Name"), Some("invalid header name"));
13441 assert_eq!(reason("X-Control-Value"), Some("invalid header value"));
13442 assert_eq!(reason("X-Secret"), Some("skip_request_headers"));
13443 assert_eq!(reason("CamelHttpQuery"), Some("Camel namespace"));
13444 }
13445
13446 #[test]
13447 fn constructed_header_invalid_value_returns_drop_record() {
13448 let result = constructed_header("user-agent", "bad\r\ns3nt1nel");
13449 let Err(record) = result else {
13450 panic!("invalid value must produce a drop record");
13451 };
13452 assert_eq!(record.reason, "invalid header value");
13453 assert_eq!(record.name, "user-agent");
13454 assert!(record.value_kind.is_none());
13455 let debug = format!("{record:?}");
13456 assert!(
13457 !debug.contains("bad\r\n") && !debug.contains("s3nt1nel"),
13458 "drop record debug must not leak the value"
13459 );
13460 }
13461
13462 #[test]
13463 fn constructed_header_invalid_name_returns_drop_record() {
13464 let result = constructed_header("bad name", "ok");
13465 let Err(record) = result else {
13466 panic!("invalid name must produce a drop record");
13467 };
13468 assert_eq!(record.reason, "invalid header name");
13469 assert_eq!(record.name, "bad name");
13470 let debug = format!("{record:?}");
13471 assert!(
13472 !debug.contains("ok"),
13473 "drop record debug must not leak the value"
13474 );
13475 }
13476
13477 #[test]
13478 fn constructed_header_valid_pair_roundtrip() {
13479 let result = constructed_header("authorization", "Bearer abc123");
13480 let Ok((name, val)) = result else {
13481 panic!("valid pair must construct");
13482 };
13483 assert_eq!(name.as_str(), "authorization");
13484 let Ok(roundtrip) = val.to_str() else {
13485 panic!("valid value must roundtrip to str");
13486 };
13487 assert_eq!(roundtrip, "Bearer abc123");
13488 }
13489
13490 async fn start_host_capturing_destination() -> (
13502 String,
13503 Arc<std::sync::Mutex<Option<(String, String)>>>,
13504 tokio::task::JoinHandle<()>,
13505 ) {
13506 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13507 let port = listener.local_addr().unwrap().port();
13508 let url = format!("http://127.0.0.1:{port}");
13509 let captured: Arc<std::sync::Mutex<Option<(String, String)>>> =
13510 Arc::new(std::sync::Mutex::new(None));
13511 let captured_clone = Arc::clone(&captured);
13512 let handle = tokio::spawn(async move {
13513 use tokio::io::{AsyncReadExt, AsyncWriteExt};
13514 if let Ok((mut stream, _)) = listener.accept().await {
13515 let mut buf = vec![0u8; 16384];
13516 let n = stream.read(&mut buf).await.unwrap_or(0);
13517 let request = String::from_utf8_lossy(&buf[..n]).to_string();
13518 if request.contains("\r\n\r\n") {
13519 let request_line = request.lines().next().unwrap_or("").to_string();
13520 let host_value = request
13521 .lines()
13522 .find(|l| l.to_lowercase().starts_with("host:"))
13523 .and_then(|l| l.split_once(':'))
13524 .map(|(_, v)| v.trim().to_string())
13525 .unwrap_or_default();
13526 *captured_clone.lock().unwrap() = Some((host_value, request_line));
13527 }
13528 let body = r#"{"echo":"ok"}"#;
13529 let resp = format!(
13530 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
13531 body.len(),
13532 body
13533 );
13534 let _ = stream.write_all(resp.as_bytes()).await;
13535 }
13536 });
13537 (url, captured, handle)
13538 }
13539
13540 #[tokio::test]
13545 async fn bridge_proxy_outbound_host_matches_destination() {
13546 use tower::ServiceExt;
13547
13548 let (url, captured, _handle) = start_host_capturing_destination().await;
13549 let expected_host = url.strip_prefix("http://").unwrap();
13552
13553 let ctx = test_producer_ctx();
13554 let component = HttpComponent::new();
13555 let endpoint_ctx = NoOpComponentContext;
13556 let endpoint = component
13557 .create_endpoint(
13558 &format!("{url}?bridgeEndpoint=true&allowInternal=true"),
13559 &endpoint_ctx,
13560 )
13561 .unwrap();
13562 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
13563
13564 let mut exchange = Exchange::new(Message::default());
13567 exchange.input.set_header("Host", "localhost");
13568 exchange.input.set_header("CamelHttpPath", "/foo");
13569
13570 let result = producer.oneshot(exchange).await;
13571 assert!(result.is_ok(), "producer call failed: {:?}", result);
13572
13573 tokio::time::sleep(Duration::from_millis(100)).await;
13574 let (host_value, request_line) = captured
13575 .lock()
13576 .unwrap()
13577 .take()
13578 .expect("destination capture mutex empty — producer did not reach the destination");
13579
13580 assert_ne!(
13581 host_value, "localhost",
13582 "bridge producer must not forward the exchange Host: localhost"
13583 );
13584 assert_eq!(
13585 host_value, expected_host,
13586 "Host must be derived from the destination authority (no scheme)"
13587 );
13588 assert!(
13589 !request_line.contains("/foo"),
13590 "bridge_endpoint must drop CamelHttpPath; request line was: {request_line}"
13591 );
13592 }
13593
13594 #[tokio::test]
13599 async fn bridge_proxy_route_set_response_header_survives() {
13600 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
13601
13602 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13603 let port = listener.local_addr().unwrap().port();
13604 drop(listener);
13605
13606 let component = HttpComponent::new();
13607 let endpoint_ctx = NoOpComponentContext;
13608 let endpoint = component
13609 .create_endpoint(&format!("http://127.0.0.1:{port}/cache"), &endpoint_ctx)
13610 .unwrap();
13611 let mut consumer = endpoint.create_consumer(rt()).unwrap();
13612
13613 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
13614 let token = tokio_util::sync::CancellationToken::new();
13615 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
13616
13617 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
13618 tokio::time::sleep(Duration::from_millis(50)).await;
13619
13620 let client = plain_http_test_client();
13621 let send_fut = client.get(format!("http://127.0.0.1:{port}/cache")).send();
13622
13623 let (http_result, _) = tokio::join!(send_fut, async {
13627 if let Some(mut envelope) = rx.recv().await {
13628 envelope
13629 .exchange
13630 .input
13631 .set_header("Cache-Control", "public, max-age=3600");
13632 if let Some(reply_tx) = envelope.reply_tx {
13633 let _ = reply_tx.send(Ok(envelope.exchange));
13634 }
13635 }
13636 });
13637
13638 let resp = http_result.unwrap();
13639 assert_eq!(resp.status().as_u16(), 200);
13640
13641 let cache_control = resp.headers().get("cache-control");
13642 assert!(
13643 cache_control.is_some(),
13644 "Cache-Control header must survive to the wire response"
13645 );
13646 assert_eq!(
13647 cache_control.unwrap().to_str().unwrap(),
13648 "public, max-age=3600"
13649 );
13650
13651 token.cancel();
13652 }
13653
13654 use camel_api::security_policy::CredentialSource;
13673 use camel_auth::credential_source::extract_token_from_exchange;
13674 use camel_auth::native_auth::NativeCredentialStore;
13675 use camel_auth::{StaticTokenAuthenticator, TokenAuthenticator};
13676
13677 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 {
13685 let mut msg = Message::default();
13686 msg.set_header(
13687 "CamelHttpMethod",
13688 serde_json::Value::String(envelope.method.clone()),
13689 );
13690 msg.set_header(
13691 "CamelHttpPath",
13692 serde_json::Value::String(envelope.path.clone()),
13693 );
13694 msg.set_header(
13695 "CamelHttpQuery",
13696 serde_json::Value::String(envelope.query.clone()),
13697 );
13698 for (k, v) in &envelope.headers {
13699 if let Ok(val_str) = v.to_str() {
13700 msg.set_header(
13701 title_case_header(k.as_str()),
13702 serde_json::Value::String(val_str.to_string()),
13703 );
13704 }
13705 }
13706 Exchange::new(msg)
13707 }
13708
13709 async fn spawn_failing_auth_route(
13716 registry: &HttpRouteRegistry,
13717 path: &str,
13718 sources: Vec<CredentialSource>,
13719 ) {
13720 let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(
13721 NativeCredentialStore::try_new(vec![]).unwrap(),
13722 ));
13723 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
13724 registry.register_api_route(path.to_string(), tx).await;
13725 let path_owned = path.to_string();
13726 tokio::spawn(async move {
13727 loop {
13728 match tokio::time::timeout(Duration::from_secs(10), rx.recv()).await {
13732 Ok(Some(envelope)) => {
13733 let exchange = envelope_to_exchange(&envelope);
13734 let reply_tx = envelope.reply_tx;
13735 let reply = match extract_token_from_exchange(&exchange, &sources)
13739 .map(|extracted| extracted.token)
13740 {
13741 Some(token) => {
13742 match tokio::time::timeout(
13743 Duration::from_secs(10),
13744 authenticator.authenticate_bearer(&token),
13745 )
13746 .await
13747 {
13748 Ok(Ok(_)) => HttpReply {
13749 status: 200,
13750 headers: vec![],
13751 body: HttpReplyBody::Bytes(bytes::Bytes::from("ok")),
13752 },
13753 Ok(Err(e)) => pipeline_error_to_reply(e, &path_owned),
13754 Err(_) => pipeline_error_to_reply(
13755 CamelError::ProcessorError(
13756 "authenticator stalled beyond 10s".into(),
13757 ),
13758 &path_owned,
13759 ),
13760 }
13761 }
13762 None => pipeline_error_to_reply(
13763 CamelError::Unauthenticated("no credential in any source".into()),
13764 &path_owned,
13765 ),
13766 };
13767 let _ = reply_tx.send(reply);
13768 }
13769 Ok(None) => break,
13770 Err(_) => {
13771 panic!("spawn_failing_auth_route: no request arrived within 10s")
13772 }
13773 }
13774 }
13775 });
13776 }
13777
13778 fn captured_logs_contain(needle: &str) -> bool {
13782 let buf = tracing_test::internal::global_buf().lock().unwrap();
13783 String::from_utf8_lossy(&buf).contains(needle)
13784 }
13785
13786 #[tracing_test::traced_test]
13787 #[tokio::test]
13788 async fn error_context_redacts_query_sentinel() {
13789 let (port, registry) = spawn_test_server().await;
13790 spawn_failing_auth_route(
13791 ®istry,
13792 "/secure-query",
13793 vec![CredentialSource::QueryParam {
13794 param: "token".to_string(),
13795 }],
13796 )
13797 .await;
13798
13799 let client = plain_http_test_client();
13800 let resp = client
13801 .get(format!(
13803 "http://127.0.0.1:{port}/secure-query?token={SENTINEL_QRY_42}"
13804 ))
13805 .send()
13806 .await
13807 .unwrap();
13808
13809 assert_eq!(resp.status().as_u16(), 401);
13810 let body = resp.text().await.unwrap();
13811 assert_eq!(body, "Unauthorized");
13812 assert!(
13813 !body.contains(SENTINEL_QRY_42),
13814 "reply body must not contain the query credential"
13815 );
13816 assert!(
13817 !captured_logs_contain(SENTINEL_QRY_42),
13818 "no tracing record during request handling may render the query credential"
13819 );
13820 assert!(
13824 captured_logs_contain("Authentication failed"),
13825 "positive control: the failed-auth warn! must be captured by the test subscriber"
13826 );
13827 }
13828
13829 #[tracing_test::traced_test]
13830 #[tokio::test]
13831 async fn error_context_redacts_cookie_sentinel() {
13832 let (port, registry) = spawn_test_server().await;
13833 spawn_failing_auth_route(
13834 ®istry,
13835 "/secure-cookie",
13836 vec![CredentialSource::Cookie {
13837 name: "session".to_string(),
13838 }],
13839 )
13840 .await;
13841
13842 let client = plain_http_test_client();
13843 let resp = client
13844 .get(format!("http://127.0.0.1:{port}/secure-cookie"))
13845 .header("Cookie", format!("session={SENTINEL_CKY_7}"))
13846 .send()
13847 .await
13848 .unwrap();
13849
13850 assert_eq!(resp.status().as_u16(), 401);
13851 let body = resp.text().await.unwrap();
13852 assert_eq!(body, "Unauthorized");
13853 assert!(
13854 !body.contains(SENTINEL_CKY_7),
13855 "reply body must not contain the cookie credential"
13856 );
13857 assert!(
13858 !captured_logs_contain(SENTINEL_CKY_7),
13859 "no tracing record during request handling may render the cookie credential"
13860 );
13861 }
13862
13863 #[tracing_test::traced_test]
13864 #[tokio::test]
13865 async fn error_reply_no_credential_value() {
13866 let (port, registry) = spawn_test_server().await;
13867 spawn_failing_auth_route(
13868 ®istry,
13869 "/secure-bad",
13870 vec![CredentialSource::Cookie {
13871 name: "session".to_string(),
13872 }],
13873 )
13874 .await;
13875
13876 let client = plain_http_test_client();
13877 let resp = client
13878 .get(format!("http://127.0.0.1:{port}/secure-bad"))
13879 .header("Cookie", format!("session={SENTINEL_BAD_1}"))
13880 .send()
13881 .await
13882 .unwrap();
13883
13884 assert_eq!(resp.status().as_u16(), 401);
13885 let body = resp.text().await.unwrap();
13886 assert_eq!(body, "Unauthorized");
13887 assert!(
13888 !body.contains(SENTINEL_BAD_1),
13889 "reply body must not contain the credential value"
13890 );
13891 assert!(
13892 !captured_logs_contain(SENTINEL_BAD_1),
13893 "error logs must not render the credential value"
13894 );
13895 }
13896
13897 async fn spawn_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
13911 use tokio::io::AsyncWriteExt;
13912
13913 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
13914 .await
13915 .expect("bind ephemeral 127.0.0.1 listener");
13916 let port = listener.local_addr().expect("local addr").port();
13917 let base_url = format!("http://localhost:{port}");
13918 let handle = tokio::spawn(async move {
13919 while let Ok((mut conn, _)) = listener.accept().await {
13920 let _ = conn
13921 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
13922 .await;
13923 let _ = conn.shutdown().await;
13924 }
13925 });
13926 (base_url, handle)
13927 }
13928
13929 async fn spawn_tls_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
13936 use tokio::io::AsyncWriteExt;
13937
13938 let (_ca_pem, cert_pem, key_pem) =
13939 camel_component_api::test_support::tls::gen_server_cert();
13940 let certs: Vec<_> = rustls_pemfile::certs(&mut cert_pem.as_bytes())
13941 .collect::<Result<_, _>>()
13942 .expect("parse server cert pem");
13943 let key = rustls_pemfile::private_key(&mut key_pem.as_bytes())
13944 .expect("parse server key pem")
13945 .expect("server key present");
13946 let provider =
13949 std::sync::Arc::new(tokio_rustls::rustls::crypto::aws_lc_rs::default_provider());
13950 let tls_cfg = tokio_rustls::rustls::ServerConfig::builder_with_provider(provider)
13951 .with_safe_default_protocol_versions()
13952 .expect("safe default protocol versions")
13953 .with_no_client_auth()
13954 .with_single_cert(certs, key)
13955 .expect("build rustls server config");
13956 let acceptor = tokio_rustls::TlsAcceptor::from(std::sync::Arc::new(tls_cfg));
13957
13958 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
13959 .await
13960 .expect("bind ephemeral 127.0.0.1 listener");
13961 let port = listener.local_addr().expect("local addr").port();
13962 let base_url = format!("https://localhost:{port}");
13963 let handle = tokio::spawn(async move {
13964 while let Ok((conn, _)) = listener.accept().await {
13965 let acceptor = acceptor.clone();
13966 tokio::spawn(async move {
13967 if let Ok(mut tls) = acceptor.accept(conn).await {
13968 let _ = tls
13969 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
13970 .await;
13971 let _ = tls.shutdown().await;
13972 }
13973 });
13974 }
13975 });
13976 (base_url, handle)
13977 }
13978
13979 fn responder_port(base_url: &str) -> u16 {
13983 url::Url::parse(base_url)
13984 .expect("responder base URL parses")
13985 .port()
13986 .expect("responder base URL carries an explicit port")
13987 }
13988
13989 fn endpoint_with_shared_cache(
13994 base_url: &str,
13995 pinned_cache: &Arc<PinnedClientCache>,
13996 ) -> HttpEndpoint {
13997 let uri = format!("{base_url}?allowInternal=true");
13998 HttpEndpoint {
13999 uri: uri.clone(),
14000 config: HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses"),
14001 server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
14002 client: plain_http_test_client(),
14003 pinned_cache: Arc::clone(pinned_cache),
14004 http_config: HttpConfig::default(),
14005 }
14006 }
14007
14008 #[tokio::test]
14009 async fn producers_share_endpoint_cache() {
14010 use tower::ServiceExt;
14011
14012 let (base_url, _handle) = spawn_multi_accept_200().await;
14013 let pinned_cache = Arc::new(PinnedClientCache::new(
14014 PINNED_CLIENT_TTL,
14015 PINNED_CLIENT_MAX_ENTRIES,
14016 ));
14017
14018 let ctx = test_producer_ctx();
14019 let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
14020 let producer_a = endpoint.create_producer(rt(), &ctx);
14021 let producer_b = endpoint.create_producer(rt(), &ctx);
14022
14023 for producer in [producer_a, producer_b] {
14026 let producer = producer.expect("create producer");
14027 let exchange = Exchange::new(Message::default());
14028 let reply = producer.oneshot(exchange).await;
14029 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
14030 }
14031
14032 assert_eq!(
14033 pinned_cache.build_count(),
14034 1,
14035 "both producers must hit the same shared cache entry; a second \
14036 build means sharing is broken"
14037 );
14038 }
14039
14040 #[tokio::test]
14041 async fn producer_repeated_hostname_requests_build_one_client() {
14042 use tower::ServiceExt;
14043
14044 let (base_url, _handle) = spawn_multi_accept_200().await;
14045 let pinned_cache = Arc::new(PinnedClientCache::new(
14046 PINNED_CLIENT_TTL,
14047 PINNED_CLIENT_MAX_ENTRIES,
14048 ));
14049 let ctx = test_producer_ctx();
14050 let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
14051 let producer = endpoint
14052 .create_producer(rt(), &ctx)
14053 .expect("create producer");
14054
14055 for i in 0..2 {
14058 let exchange = Exchange::new(Message::default());
14059 let reply = producer.clone().oneshot(exchange).await;
14060 assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
14061 }
14062
14063 assert_eq!(
14064 pinned_cache.build_count(),
14065 1,
14066 "repeated hostname requests must reuse the one pinned client; \
14067 0 builds means the producer bypassed the cache, more than 1 \
14068 means the entry was dropped"
14069 );
14070 }
14071
14072 #[tokio::test]
14073 async fn ip_literal_request_never_enters_cache() {
14074 use tower::ServiceExt;
14075
14076 let (base_url, _handle) = spawn_multi_accept_200().await;
14077 let pinned_cache = Arc::new(PinnedClientCache::new(
14078 PINNED_CLIENT_TTL,
14079 PINNED_CLIENT_MAX_ENTRIES,
14080 ));
14081
14082 let ctx = test_producer_ctx();
14083 let destination = format!("http://127.0.0.1:{}/ping", responder_port(&base_url));
14084 let endpoint = endpoint_with_shared_cache(&destination, &pinned_cache);
14085 let producer = endpoint
14086 .create_producer(rt(), &ctx)
14087 .expect("create producer");
14088
14089 let exchange = Exchange::new(Message::default());
14090 let reply = producer.oneshot(exchange).await;
14091 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
14092
14093 assert_eq!(
14094 pinned_cache.build_count(),
14095 0,
14096 "an IP-literal URL must use the shared unpinned client and \
14097 never enter the pinned cache"
14098 );
14099 }
14100
14101 #[tokio::test]
14102 async fn test_component_endpoints_share_pinned_cache() {
14103 use tower::ServiceExt;
14104
14105 let component = HttpComponent::new();
14106 let (base_url, _handle) = spawn_multi_accept_200().await;
14107 let baseline = component.pinned_cache.build_count();
14108
14109 let ctx = test_producer_ctx();
14110 let endpoint_ctx = NoOpComponentContext;
14111 for uri in [
14112 format!("{base_url}/a?allowInternal=true&k=a"),
14113 format!("{base_url}/b?allowInternal=true&k=b"),
14114 ] {
14115 let endpoint = component
14116 .create_endpoint(&uri, &endpoint_ctx)
14117 .expect("create endpoint");
14118 let producer = endpoint
14119 .create_producer(rt(), &ctx)
14120 .expect("create producer");
14121 let exchange = Exchange::new(Message::default());
14122 let reply = producer.oneshot(exchange).await;
14123 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
14124 }
14125
14126 assert_eq!(
14127 component.pinned_cache.build_count() - baseline,
14128 1,
14129 "endpoints created by one component must share its pinned cache; \
14130 0 builds means the endpoints bypassed it, more than 1 means \
14131 per-endpoint caches came back"
14132 );
14133 }
14134
14135 #[tokio::test]
14136 async fn test_dynamic_resolution_sequence_hits_shared_cache() {
14137 use tower::ServiceExt;
14138
14139 let component = HttpComponent::new();
14140 let (base_url, _handle) = spawn_multi_accept_200().await;
14141 let baseline = component.pinned_cache.build_count();
14142
14143 let ctx = test_producer_ctx();
14144 let endpoint_ctx = NoOpComponentContext;
14145 for i in 0..3 {
14146 let endpoint = component
14147 .create_endpoint(
14148 &format!("{base_url}/r{i}?allowInternal=true&k={i}"),
14149 &endpoint_ctx,
14150 )
14151 .expect("create endpoint");
14152 let producer = endpoint
14153 .create_producer(rt(), &ctx)
14154 .expect("create producer");
14155 let exchange = Exchange::new(Message::default());
14156 let reply = producer.oneshot(exchange).await;
14157 assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
14158 }
14159
14160 assert_eq!(
14161 component.pinned_cache.build_count() - baseline,
14162 1,
14163 "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
14164 must reuse the component's one pinned cache entry; 0 builds \
14165 means the endpoints bypassed it, more than 1 means \
14166 per-endpoint caches came back"
14167 );
14168 }
14169
14170 #[tokio::test]
14178 async fn test_https_component_endpoints_share_pinned_cache_behaviorally() {
14179 use tower::ServiceExt;
14180
14181 let http_config = HttpConfig {
14182 tls: Some(crate::config::TlsConfig {
14183 enabled: true,
14184 insecure: true,
14185 ..Default::default()
14186 }),
14187 ..Default::default()
14188 };
14189 let component = HttpsComponent::with_config(http_config);
14190 let (base_url, _handle) = spawn_tls_multi_accept_200().await;
14191 let baseline = component.pinned_cache.build_count();
14192
14193 let ctx = test_producer_ctx();
14194 let endpoint_ctx = NoOpComponentContext;
14195 for uri in [
14196 format!("{base_url}/a?allowInternal=true&k=a"),
14197 format!("{base_url}/b?allowInternal=true&k=b"),
14198 ] {
14199 let endpoint = component
14200 .create_endpoint(&uri, &endpoint_ctx)
14201 .expect("create https endpoint");
14202 let producer = endpoint
14203 .create_producer(rt(), &ctx)
14204 .expect("create producer");
14205 let exchange = Exchange::new(Message::default());
14206 let reply = producer.oneshot(exchange).await;
14207 assert!(reply.is_ok(), "https request failed: {reply:?}");
14208 }
14209
14210 assert_eq!(
14211 component.pinned_cache.build_count() - baseline,
14212 1,
14213 "endpoints of one HttpsComponent must share its pinned cache over \
14214 real https requests; 0 builds means the endpoints bypassed it \
14215 (per-endpoint cache regression), more than 1 means \
14216 per-endpoint caches came back"
14217 );
14218 }
14219
14220 #[test]
14221 fn test_https_component_owns_distinct_cache() {
14222 let http = HttpComponent::new();
14223 let https = HttpsComponent::new();
14224
14225 assert!(
14226 !Arc::ptr_eq(&http.pinned_cache, &https.pinned_cache),
14227 "http and https components must each own their own pinned cache"
14228 );
14229
14230 let endpoint_ctx = NoOpComponentContext;
14231 let _ = http
14232 .create_endpoint("http://localhost:1/?allowInternal=true", &endpoint_ctx)
14233 .expect("http endpoint");
14234 let _ = https
14235 .create_endpoint("https://localhost:1/?allowInternal=true", &endpoint_ctx)
14236 .expect("https endpoint");
14237
14238 assert_eq!(
14239 http.pinned_cache.build_count(),
14240 0,
14241 "endpoint creation must not build a pinned client"
14242 );
14243 assert_eq!(
14244 https.pinned_cache.build_count(),
14245 0,
14246 "endpoint creation must not build a pinned client"
14247 );
14248 }
14249
14250 #[test]
14251 fn test_component_constructor_builds_one_unpinned_client() {
14252 let baseline = build_client_call_count();
14253
14254 let _http = HttpComponent::new();
14255 assert_eq!(
14256 build_client_call_count() - baseline,
14257 1,
14258 "HttpComponent::new() must build exactly one shared unpinned client"
14259 );
14260
14261 let _https = HttpsComponent::new();
14262 assert_eq!(
14263 build_client_call_count() - baseline,
14264 2,
14265 "HttpsComponent::new() must build exactly one more shared unpinned client"
14266 );
14267 }
14268
14269 #[test]
14270 fn test_component_endpoints_share_unpinned_client() {
14271 let component = HttpComponent::new();
14272 let baseline = build_client_call_count();
14273
14274 let endpoint_ctx = NoOpComponentContext;
14275 for uri in [
14276 "http://localhost:1/a?allowInternal=true",
14277 "http://localhost:1/b?allowInternal=true",
14278 ] {
14279 let _endpoint = component
14280 .create_endpoint(uri, &endpoint_ctx)
14281 .expect("create endpoint");
14282 }
14283
14284 assert_eq!(
14285 build_client_call_count() - baseline,
14286 0,
14287 "create_endpoint must clone the component's shared unpinned client, \
14288 never build a fresh one"
14289 );
14290 }
14291
14292 #[test]
14293 fn test_dynamic_resolution_adds_no_unpinned_client_builds() {
14294 let component = HttpComponent::new();
14295 let baseline = build_client_call_count();
14296
14297 let ctx = test_producer_ctx();
14298 let endpoint_ctx = NoOpComponentContext;
14299 for i in 0..3 {
14300 let endpoint = component
14301 .create_endpoint(
14302 &format!("http://localhost:1/r{i}?allowInternal=true&k={i}"),
14303 &endpoint_ctx,
14304 )
14305 .expect("create endpoint");
14306 let _producer = endpoint
14307 .create_producer(rt(), &ctx)
14308 .expect("create producer");
14309 }
14310
14311 assert_eq!(
14312 build_client_call_count() - baseline,
14313 0,
14314 "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
14315 must reuse the component's shared unpinned client and build \
14316 no additional clients"
14317 );
14318 }
14319
14320 #[test]
14321 fn test_webpki_fallback_config_builds_client() {
14322 let client = reqwest::Client::builder()
14327 .tls_backend_preconfigured(webpki_root_client_config())
14328 .build()
14329 .expect("webpki-rooted client must build"); let rendered = format!("{client:?}");
14333 assert!(
14334 !rendered.is_empty(),
14335 "client must render a debug representation"
14336 );
14337 }
14338
14339 fn fallback_test_ca() -> (tempfile::TempDir, String) {
14342 let ca_key = rcgen::KeyPair::generate().expect("ca keygen"); let mut ca_params = rcgen::CertificateParams::default();
14344 ca_params
14345 .distinguished_name
14346 .push(rcgen::DnType::CommonName, "Fallback test CA");
14347 ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
14348 let ca_cert = ca_params.self_signed(&ca_key).expect("ca self-sign"); let dir = tempfile::tempdir().expect("tempdir"); let ca_path = dir.path().join("ca.pem");
14351 std::fs::write(&ca_path, ca_cert.pem()).expect("write ca pem"); (dir, ca_path.display().to_string())
14353 }
14354
14355 struct StrictMaterial {
14365 dir: tempfile::TempDir,
14366 ca: TestCa,
14367 ca_path: std::path::PathBuf,
14368 cert_path: std::path::PathBuf,
14369 key_path: std::path::PathBuf,
14370 }
14371
14372 fn strict_material_fixtures() -> StrictMaterial {
14373 let ca = gen_test_ca();
14374 let dir = tempfile::tempdir().expect("tempdir"); let ca_path = dir.path().join("ca.pem");
14376 std::fs::write(&ca_path, ca.ca_pem.as_bytes()).expect("write ca pem"); let (client_cert_pem, client_key_pem) = gen_client_identity(&ca);
14378 let cert_path = dir.path().join("client-cert.pem");
14379 std::fs::write(&cert_path, client_cert_pem.as_bytes()).expect("write client cert"); let key_path = dir.path().join("client-key.pem");
14381 std::fs::write(&key_path, client_key_pem.as_bytes()).expect("write client key"); StrictMaterial {
14383 dir,
14384 ca,
14385 ca_path,
14386 cert_path,
14387 key_path,
14388 }
14389 }
14390
14391 fn assert_strict_fallback_error(err: CamelError, fragments: &[&str]) {
14394 match err {
14395 CamelError::EndpointCreationFailed(msg) => {
14396 for fragment in fragments {
14397 assert!(msg.contains(fragment), "message: {msg}");
14398 }
14399 }
14400 other => panic!("expected EndpointCreationFailed, got {other:?}"),
14401 }
14402 }
14403
14404 #[test]
14416 fn test_fallback_root_store_union_custom_and_mozilla() {
14417 let (_dir, ca_path) = fallback_test_ca();
14418 let store = fallback_root_store(Some(&ca_path), true).expect("union root store must build"); assert_eq!(
14420 store.roots.len(),
14421 webpki_roots::TLS_SERVER_ROOTS.len() + 1,
14422 "custom CA must UNION with the Mozilla anchors, not replace them"
14423 );
14424 let ca_pem = std::fs::read_to_string(&ca_path).expect("read ca pem back"); let der = rustls_pemfile::certs(&mut std::io::Cursor::new(ca_pem.as_bytes()))
14426 .next()
14427 .expect("exactly one CERTIFICATE section in test CA") .expect("parse test CA pem"); let mut scratch = rustls::RootCertStore::empty();
14432 let (added, _ignored) = scratch.add_parsable_certificates(vec![der]);
14433 assert_eq!(added, 1, "scratch root store must accept the test CA");
14434 let expected_anchor = scratch.roots[0].clone();
14435 assert_eq!(
14436 store.roots.first(),
14437 Some(&expected_anchor),
14438 "custom anchor must sit at index 0 — parity with the primary path's \
14439 extra-roots-before-platform-roots order"
14440 );
14441 assert_eq!(
14442 store.roots[1..],
14443 *webpki_roots::TLS_SERVER_ROOTS,
14444 "everything after the custom anchor must be exactly the bundled Mozilla anchors"
14445 );
14446 let first_custom = store
14447 .roots
14448 .iter()
14449 .position(|anchor| anchor == &expected_anchor)
14450 .expect("custom anchor present in store"); let first_mozilla = store
14452 .roots
14453 .iter()
14454 .position(|anchor| webpki_roots::TLS_SERVER_ROOTS.contains(anchor))
14455 .expect("Mozilla anchors present in store"); assert!(
14457 first_custom < first_mozilla,
14458 "first custom anchor (index {first_custom}) must precede the first \
14459 Mozilla anchor (index {first_mozilla})"
14460 );
14461 }
14462
14463 #[test]
14464 fn test_fallback_root_store_no_ca_is_mozilla_only() {
14465 let store = fallback_root_store(None, true).expect("mozilla-only store must build"); assert_eq!(
14467 store.roots.len(),
14468 webpki_roots::TLS_SERVER_ROOTS.len(),
14469 "no configured CA must leave the bundled Mozilla anchors untouched"
14470 );
14471 assert_eq!(
14472 store.roots,
14473 *webpki_roots::TLS_SERVER_ROOTS,
14474 "with no configured CA the whole store IS the bundled Mozilla anchors, \
14475 byte-for-byte"
14476 );
14477 }
14478
14479 #[test]
14480 fn test_fallback_root_store_strict_unreadable_ca_fails_closed() {
14481 let err = match fallback_root_store(Some("/nonexistent/ca-bundle.pem"), true) {
14482 Err(e) => e,
14483 Ok(_) => panic!("strict mode must fail closed on an unreadable CA file"),
14484 };
14485 assert_strict_fallback_error(err, &["tls.strict/webpki-fallback", "unreadable"]);
14486 }
14487
14488 #[test]
14489 fn test_fallback_root_store_strict_zero_pem_sections_fails_closed() {
14490 let dir = tempfile::tempdir().expect("tempdir"); let ca_path = dir.path().join("key-only.pem");
14492 std::fs::write(
14493 &ca_path,
14494 "-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----\n",
14495 )
14496 .expect("write key-only pem"); let path = ca_path.display().to_string();
14498 let err = match fallback_root_store(Some(&path), true) {
14499 Err(e) => e,
14500 Ok(_) => panic!("strict mode must fail closed on a CA with no CERTIFICATE section"),
14501 };
14502 assert_strict_fallback_error(err, &["no parseable PEM CERTIFICATE"]);
14503 }
14504
14505 #[test]
14506 fn test_fallback_root_store_strict_zero_roots_accepted_fails_closed() {
14507 let dir = tempfile::tempdir().expect("tempdir"); let ca_path = dir.path().join("garbage-cert.pem");
14509 std::fs::write(
14510 &ca_path,
14511 "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n",
14512 )
14513 .expect("write garbage cert pem"); let path = ca_path.display().to_string();
14515 let err = match fallback_root_store(Some(&path), true) {
14516 Err(e) => e,
14517 Ok(_) => panic!("strict mode must fail closed when the root store rejects the CA"),
14518 };
14519 assert_strict_fallback_error(err, &["rejected by the TLS root store"]);
14520 }
14521
14522 #[test]
14523 fn test_fallback_root_store_nonstrict_bad_ca_degrades_to_mozilla() {
14524 let dir = tempfile::tempdir().expect("tempdir"); let ca_path = dir.path().join("garbage.pem");
14526 std::fs::write(&ca_path, "this is not a certificate\n").expect("write garbage pem"); let path = ca_path.display().to_string();
14528 let store = fallback_root_store(Some(&path), false)
14529 .expect("non-strict garbage CA must degrade to Mozilla-only"); assert_eq!(
14531 store.roots.len(),
14532 webpki_roots::TLS_SERVER_ROOTS.len(),
14533 "non-strict bad CA must degrade to the bundled Mozilla anchors"
14534 );
14535 }
14536
14537 #[test]
14538 fn test_fallback_client_config_disabled_tls_returns_none() {
14539 let tls = TlsConfig {
14540 enabled: false,
14541 ..Default::default()
14542 };
14543 assert!(
14544 fallback_client_config(&tls)
14545 .expect("disabled TLS is infallible") .is_none()
14547 );
14548 }
14549
14550 #[test]
14551 fn test_fallback_client_config_no_material_verifying_returns_none() {
14552 let tls = TlsConfig {
14553 enabled: true,
14554 ..Default::default()
14555 };
14556 assert!(
14557 fallback_client_config(&tls)
14558 .expect("plain verifying TLS is infallible") .is_none()
14560 );
14561 }
14562
14563 #[test]
14564 fn test_fallback_client_config_strict_identity_garbage_key_fails_closed() {
14565 let dir = tempfile::tempdir().expect("tempdir"); let client_key = rcgen::KeyPair::generate().expect("client keygen"); let client_params = rcgen::CertificateParams::default();
14568 let client_cert = client_params
14569 .self_signed(&client_key)
14570 .expect("client cert self-sign"); let cert_path = dir.path().join("client.pem");
14572 std::fs::write(&cert_path, client_cert.pem()).expect("write client pem"); let key_path = dir.path().join("key.pem");
14574 std::fs::write(&key_path, "garbage not a key at all\n").expect("write garbage key"); let tls = TlsConfig {
14576 enabled: true,
14577 strict: true,
14578 client_cert_path: Some(cert_path.display().to_string()),
14579 client_key_path: Some(key_path.display().to_string()),
14580 ..Default::default()
14581 };
14582 let err = match fallback_client_config(&tls) {
14583 Err(e) => e,
14584 Ok(_) => panic!("strict mode must fail closed on a garbage client key"),
14585 };
14586 assert_strict_fallback_error(err, &["no parseable private key"]);
14587 }
14588
14589 #[test]
14590 fn test_fallback_client_config_strict_rustls_rejected_key_fails_closed() {
14591 let dir = tempfile::tempdir().expect("tempdir"); let client_key = rcgen::KeyPair::generate().expect("client keygen"); let client_params = rcgen::CertificateParams::default();
14594 let client_cert = client_params
14595 .self_signed(&client_key)
14596 .expect("client cert self-sign"); let cert_path = dir.path().join("client.pem");
14598 std::fs::write(&cert_path, client_cert.pem()).expect("write client pem"); let key_path = dir.path().join("key.pem");
14600 std::fs::write(
14601 &key_path,
14602 "-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----\n",
14603 )
14604 .expect("write pkcs8-framed garbage key"); let tls = TlsConfig {
14606 enabled: true,
14607 strict: true,
14608 client_cert_path: Some(cert_path.display().to_string()),
14609 client_key_path: Some(key_path.display().to_string()),
14610 ..Default::default()
14611 };
14612 let err = match fallback_client_config(&tls) {
14613 Err(e) => e,
14614 Ok(_) => panic!("strict mode must fail closed when rustls rejects the identity"),
14615 };
14616 assert_strict_fallback_error(err, &["rejected by the TLS backend"]);
14617 }
14618
14619 #[test]
14620 fn test_fallback_client_config_half_mtls_pair_strict_fails_nonstrict_no_auth() {
14621 let dir = tempfile::tempdir().expect("tempdir"); let cert_path = dir.path().join("client.pem");
14623 std::fs::write(&cert_path, "cert-only, key missing\n").expect("write placeholder pem"); let tls_strict = TlsConfig {
14625 enabled: true,
14626 strict: true,
14627 client_cert_path: Some(cert_path.display().to_string()),
14628 ..Default::default()
14629 };
14630 let err = match fallback_client_config(&tls_strict) {
14631 Err(e) => e,
14632 Ok(_) => panic!("strict mode must fail closed on a half-configured mTLS pair"),
14633 };
14634 assert_strict_fallback_error(err, &["BOTH client_cert_path"]);
14635 let tls_permissive = TlsConfig {
14636 enabled: true,
14637 client_cert_path: Some(cert_path.display().to_string()),
14638 ..Default::default()
14639 };
14640 assert!(
14641 fallback_client_config(&tls_permissive)
14642 .expect("non-strict half pair degrades to no-auth") .is_some()
14644 );
14645 }
14646
14647 #[test]
14648 fn test_no_verify_verifier_accepts_any_certificate() {
14649 use rustls::client::danger::ServerCertVerifier;
14650
14651 let verifier = NoVerifyServerCertVerifier;
14652 let garbage = rustls::pki_types::CertificateDer::from(vec![0xDE, 0xAD, 0xBE, 0xEF]);
14653 let server_name =
14654 rustls::pki_types::ServerName::try_from("localhost").expect("localhost dns name"); assert!(
14656 verifier
14657 .verify_server_cert(
14658 &garbage,
14659 &[],
14660 &server_name,
14661 &[],
14662 rustls::pki_types::UnixTime::now()
14663 )
14664 .is_ok(),
14665 "no-verify verifier must accept any certificate without validation"
14666 );
14667 }
14668
14669 #[test]
14670 fn test_build_client_falls_back_on_empty_platform_ca_store() {
14671 let _ca_guard = lock_ca_store_test_mutex();
14675 let empty_ca_dir = tempfile::tempdir().expect("tempdir"); let empty_ca_file = empty_ca_dir.path().join("empty-ca-bundle.pem");
14687 std::fs::write(&empty_ca_file, b"").expect("write empty CA file"); let (prev_file, prev_dir) = unsafe {
14693 let prev = (
14694 std::env::var_os("SSL_CERT_FILE"),
14695 std::env::var_os("SSL_CERT_DIR"),
14696 );
14697 std::env::set_var("SSL_CERT_FILE", &empty_ca_file);
14698 std::env::set_var("SSL_CERT_DIR", empty_ca_dir.path());
14699 prev
14700 };
14701
14702 let result = std::panic::catch_unwind(|| {
14703 let fallbacks_before = build_client_fallback_count();
14704 let _client = build_client(&HttpConfig::default(), None).expect("client must build"); build_client_fallback_count() - fallbacks_before
14706 });
14707
14708 unsafe {
14711 match prev_file {
14712 Some(v) => std::env::set_var("SSL_CERT_FILE", v),
14713 None => std::env::remove_var("SSL_CERT_FILE"),
14714 }
14715 match prev_dir {
14716 Some(v) => std::env::set_var("SSL_CERT_DIR", v),
14717 None => std::env::remove_var("SSL_CERT_DIR"),
14718 }
14719 }
14720
14721 let fallbacks_taken = result.expect("build_client must not panic on an empty CA store"); assert_eq!(
14723 fallbacks_taken, 1,
14724 "an empty platform CA store must route exactly one build through \
14725 the webpki fallback (primary build failed, fallback succeeded)"
14726 );
14727 }
14728
14729 const HERMETIC_TEST_ROOT_PEM: &str = "-----BEGIN CERTIFICATE-----\n\
14735 MIIDVzCCAj+gAwIBAgIUQ+hB0JPFtHpUKaNwcTB8ijGqEW0wDQYJKoZIhvcNAQEL\n\
14736 BQAwOjEdMBsGA1UEAwwUY2FtZWwtaHR0cCB0ZXN0IHJvb3QxGTAXBgNVBAoMEGNh\n\
14737 bWVsLWh0dHAgdGVzdHMwIBcNMjYwOTIxMTEyMzUwWhgPMjEyNjA4MjgxMTIzNTBa\n\
14738 MDoxHTAbBgNVBAMMFGNhbWVsLWh0dHAgdGVzdCByb290MRkwFwYDVQQKDBBjYW1l\n\
14739 bC1odHRwIHRlc3RzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAolnF\n\
14740 SiT/bK8pMl9n50HTQj4oxPZGXK34Q/OSq8TMUEcmhUFzBjzwZKoFqvDIt/e0mSi/\n\
14741 h8WQlnrHAvYOnsRlpDsfs4yder+lpEYE84OooJjQvj/kREj7ncK6WQKDY1NTGikx\n\
14742 lF8kIaCrNHNIlUgoSWMR4E6UshLFwSu5lKtcWCeN6FNzGVQ9jxYJSFsmVk+wFyHI\n\
14743 edvpPbnFkD3M1/GKNVuxCCR50sO+cQeB7w9FCyFdHvoTWWuwPV4Qq2LBM2n4eedG\n\
14744 pCXkqmuARtFuOKjpYIRryGybic9u9ZW59FbTbgHSj6rcOXNtBUVV/KnWRDRWo7ZC\n\
14745 cR5SmO2YwtfpISjbmwIDAQABo1MwUTAdBgNVHQ4EFgQUkhwU0Q5JYNLrs7yOqbXE\n\
14746 szzn2/cwHwYDVR0jBBgwFoAUkhwU0Q5JYNLrs7yOqbXEszzn2/cwDwYDVR0TAQH/\n\
14747 BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAXLqN7poLyxlpxN9vY1jISs403P2I\n\
14748 edwgX3eWyJgXQfc/+dhnig8Pi7SkJKNM9PM27qain3+e2/HeetNYGBD8xnzQo5ga\n\
14749 1ScPvm7u2DWIWc5y0bpm5JuCPqXoDl/7kBcaelA2lZRrPJzQuu6uPnH1ubjUPFJj\n\
14750 9SxWhR+QgJW3cFc17C179WSVSJSPLozxX1ODABSyxNXnz14Rq+MOJi2tEsDggyMm\n\
14751 txh6GwgVbiSey9c9bvu3B28iPfNpULuxBzxSLHcRrSSoII+46foY0G63V/jx7huY\n\
14752 Fc2DPwOcO2Ni8fLssMaAnEdWPZaIN/DdjmUFw9XgHE9Eld6aKEXBcRFPzA==\n\
14753 -----END CERTIFICATE-----";
14754
14755 #[test]
14756 fn test_build_client_primary_path_when_platform_store_non_empty() {
14757 let _ca_guard = lock_ca_store_test_mutex();
14765 let ca_dir = tempfile::tempdir().expect("tempdir"); let ca_file = ca_dir.path().join("hermetic-root.pem");
14767 std::fs::write(&ca_file, HERMETIC_TEST_ROOT_PEM).expect("write fixture root"); let (prev_file, prev_dir) = unsafe {
14772 let prev = (
14773 std::env::var_os("SSL_CERT_FILE"),
14774 std::env::var_os("SSL_CERT_DIR"),
14775 );
14776 std::env::set_var("SSL_CERT_FILE", &ca_file);
14777 std::env::set_var("SSL_CERT_DIR", ca_dir.path());
14778 prev
14779 };
14780
14781 let fallbacks_before = build_client_fallback_count();
14782 let _client = build_client(&HttpConfig::default(), None).expect("client must build"); let fallbacks_taken = build_client_fallback_count() - fallbacks_before;
14784
14785 unsafe {
14787 match prev_file {
14788 Some(v) => std::env::set_var("SSL_CERT_FILE", v),
14789 None => std::env::remove_var("SSL_CERT_FILE"),
14790 }
14791 match prev_dir {
14792 Some(v) => std::env::set_var("SSL_CERT_DIR", v),
14793 None => std::env::remove_var("SSL_CERT_DIR"),
14794 }
14795 }
14796
14797 assert_eq!(
14798 fallbacks_taken, 0,
14799 "with at least one platform root loadable, build_client must \
14800 use the platform-verifier primary path, never the webpki \
14801 fallback"
14802 );
14803 }
14804
14805 #[test]
14806 fn test_build_client_forced_fallback_strict_store_rejected_ca_returns_typed_error() {
14807 let dir = tempfile::tempdir().expect("tempdir"); let ca_path = dir.path().join("garbage-cert.pem");
14813 std::fs::write(
14814 &ca_path,
14815 "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n",
14816 )
14817 .expect("write garbage cert pem"); let config = HttpConfig {
14819 tls: Some(TlsConfig {
14820 enabled: true,
14821 strict: true,
14822 ca_cert_path: Some(ca_path.display().to_string()),
14823 ..Default::default()
14824 }),
14825 ..Default::default()
14826 };
14827
14828 let (err, fallbacks_taken) = forced_fallback_env(|| {
14829 let fallbacks_before = build_client_fallback_count();
14830 let err = match build_client(&config, None) {
14831 Err(e) => e,
14832 Ok(_) => panic!("strict mode must fail closed when the root store rejects the CA"),
14833 };
14834 (err, build_client_fallback_count() - fallbacks_before)
14835 });
14836
14837 assert_strict_fallback_error(
14838 err,
14839 &[
14840 "tls.strict/webpki-fallback",
14841 "rejected by the TLS root store",
14842 ],
14843 );
14844 assert_eq!(
14845 fallbacks_taken, 1,
14846 "the typed error must come from exactly one webpki fallback entry"
14847 );
14848 }
14849
14850 #[test]
14851 fn test_build_client_forced_fallback_strict_material_rejection_classes() {
14852 let dir = tempfile::tempdir().expect("tempdir"); let plain_text_ca = dir.path().join("plain-text.pem");
14858 std::fs::write(&plain_text_ca, "this is not a certificate\n").expect("write plain text ca"); let client_key = rcgen::KeyPair::generate().expect("client keygen"); let client_cert = rcgen::CertificateParams::default()
14863 .self_signed(&client_key)
14864 .expect("client self-sign"); let cert_path = dir.path().join("client-cert.pem");
14866 std::fs::write(&cert_path, client_cert.pem()).expect("write client cert"); let valid_key_path = dir.path().join("client-key.pem");
14868 std::fs::write(&valid_key_path, client_key.serialize_pem()).expect("write client key"); let garbage_key_path = dir.path().join("garbage-key.pem");
14870 std::fs::write(&garbage_key_path, "not a key\n").expect("write garbage key"); let rejected_key_path = dir.path().join("rejected-key.pem");
14874 std::fs::write(
14875 &rejected_key_path,
14876 "-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----\n",
14877 )
14878 .expect("write rejected key"); struct RejectionCase {
14881 name: &'static str,
14882 tls: TlsConfig,
14883 fragment: &'static str,
14884 }
14885 let cases = [
14886 RejectionCase {
14887 name: "CA with zero PEM CERTIFICATE sections",
14888 tls: TlsConfig {
14889 enabled: true,
14890 strict: true,
14891 ca_cert_path: Some(plain_text_ca.display().to_string()),
14892 ..Default::default()
14893 },
14894 fragment: "no parseable PEM CERTIFICATE section",
14895 },
14896 RejectionCase {
14897 name: "client key with no parseable section",
14898 tls: TlsConfig {
14899 enabled: true,
14900 strict: true,
14901 client_cert_path: Some(cert_path.display().to_string()),
14902 client_key_path: Some(garbage_key_path.display().to_string()),
14903 ..Default::default()
14904 },
14905 fragment: "no parseable private key section",
14906 },
14907 RejectionCase {
14908 name: "client key rejected by the TLS backend",
14909 tls: TlsConfig {
14910 enabled: true,
14911 strict: true,
14912 client_cert_path: Some(cert_path.display().to_string()),
14913 client_key_path: Some(rejected_key_path.display().to_string()),
14914 ..Default::default()
14915 },
14916 fragment: "rejected by the TLS backend",
14917 },
14918 RejectionCase {
14919 name: "unreadable client cert",
14920 tls: TlsConfig {
14921 enabled: true,
14922 strict: true,
14923 client_cert_path: Some("/nonexistent/client-cert.pem".to_string()),
14924 client_key_path: Some(valid_key_path.display().to_string()),
14925 ..Default::default()
14926 },
14927 fragment: "unreadable",
14928 },
14929 ];
14930
14931 for case in cases {
14932 let config = HttpConfig {
14933 tls: Some(case.tls),
14934 ..Default::default()
14935 };
14936 let err = forced_fallback_env(|| match build_client(&config, None) {
14937 Err(e) => e,
14938 Ok(_) => panic!("strict case '{}' must fail closed", case.name),
14939 });
14940 assert_strict_fallback_error(err, &["tls.strict/webpki-fallback", case.fragment]);
14941 }
14942 }
14943
14944 #[test]
14945 fn test_build_client_forced_fallback_nonstrict_bad_ca_still_builds() {
14946 let dir = tempfile::tempdir().expect("tempdir"); let ca_path = dir.path().join("garbage.pem");
14948 std::fs::write(&ca_path, "this is not a certificate\n").expect("write garbage pem"); let config = HttpConfig {
14950 tls: Some(TlsConfig {
14951 enabled: true,
14952 strict: false,
14953 ca_cert_path: Some(ca_path.display().to_string()),
14954 ..Default::default()
14955 }),
14956 ..Default::default()
14957 };
14958
14959 let (_client, fallbacks_taken) = forced_fallback_env(|| {
14960 let fallbacks_before = build_client_fallback_count();
14961 let client = build_client(&config, None)
14962 .expect("non-strict bad CA must degrade per-item and still build"); (client, build_client_fallback_count() - fallbacks_before)
14964 });
14965
14966 assert_eq!(
14967 fallbacks_taken, 1,
14968 "permissive item downgrade still routes exactly one build \
14969 through the webpki fallback"
14970 );
14971 }
14972
14973 #[test]
14974 fn test_http_component_with_config_folds_fallback_error_without_panic() {
14975 let dir = tempfile::tempdir().expect("tempdir"); let ca_path = dir.path().join("garbage-cert.pem");
14977 std::fs::write(
14978 &ca_path,
14979 "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n",
14980 )
14981 .expect("write garbage cert pem"); let config = HttpConfig {
14983 tls: Some(TlsConfig {
14984 enabled: true,
14985 strict: true,
14986 ca_cert_path: Some(ca_path.display().to_string()),
14987 ..Default::default()
14988 }),
14989 ..Default::default()
14990 };
14991
14992 let endpoint_err = forced_fallback_env(|| {
14993 let component = HttpComponent::with_config(config);
14997 component
14998 .create_endpoint("http://h/p", &NoOpComponentContext)
14999 .err()
15000 });
15001
15002 let err = endpoint_err.expect("folded fallback error must fail endpoint creation"); assert!(
15004 err.to_string().contains("rejected by the TLS root store"),
15005 "folded error must carry the fallback diagnosis: {err}"
15006 );
15007 }
15008
15009 #[test]
15010 fn test_http_component_new_no_panic_on_empty_ca_store() {
15011 let endpoint = forced_fallback_env(|| {
15012 let component = HttpComponent::new();
15013 component
15014 .create_endpoint("http://h/p", &NoOpComponentContext)
15015 .ok()
15016 });
15017 let _endpoint = endpoint.expect("material-free webpki fallback must serve endpoints"); }
15019
15020 #[tracing_test::traced_test]
15048 #[tokio::test]
15049 async fn tls_handshake_strict_custom_ca_carried() {
15050 let ca = gen_test_ca();
15051 let dir = tempfile::tempdir().expect("tempdir"); let ca_path = dir.path().join("ca.pem");
15053 std::fs::write(&ca_path, ca.ca_pem.as_bytes()).expect("write ca pem"); let (server_cert, server_key) =
15055 gen_leaf_signed_by_ca(&ca, "127.0.0.1".parse().expect("loopback ip")); let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15057
15058 let config = HttpConfig {
15059 tls: Some(TlsConfig {
15060 enabled: true,
15061 strict: true,
15062 ca_cert_path: Some(ca_path.display().to_string()),
15063 ..Default::default()
15064 }),
15065 ..Default::default()
15066 };
15067 let client = build_under_seam(&config);
15068
15069 let resp = tokio::time::timeout(
15070 Duration::from_secs(15),
15071 client.get(format!("https://{addr}/")).send(),
15072 )
15073 .await
15074 .expect("request must complete within the harness timeout") .expect("strict webpki fallback must carry the configured CA into the handshake"); assert_eq!(resp.status().as_u16(), 200);
15077 let body = resp.text().await.expect("response body"); assert_eq!(body, "ok");
15079
15080 assert!(
15085 logs_contain("carries configured TLS material"),
15086 "strict webpki fallback with configured material must log the carry"
15087 );
15088
15089 server.abort();
15090 }
15091
15092 #[tracing_test::traced_test]
15093 #[tokio::test]
15094 async fn tls_handshake_strict_material_free_fails_same_server() {
15095 let ca = gen_test_ca();
15096 let (server_cert, server_key) =
15097 gen_leaf_signed_by_ca(&ca, "127.0.0.1".parse().expect("loopback ip")); let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15099
15100 let config = HttpConfig {
15101 tls: Some(TlsConfig {
15102 enabled: true,
15103 strict: true,
15104 ..Default::default()
15105 }),
15106 ..Default::default()
15107 };
15108 let client = forced_fallback_env(|| {
15109 build_client(&config, None).expect("client must build") });
15111
15112 let send_result = tokio::time::timeout(
15113 Duration::from_secs(15),
15114 client.get(format!("https://{addr}/")).send(),
15115 )
15116 .await
15117 .expect("request must complete within the harness timeout"); assert!(
15119 send_result.is_err(),
15120 "material-free strict fallback trusts only Mozilla roots, which do not \
15121 trust the rcgen CA — the handshake must fail"
15122 );
15123
15124 assert!(
15130 logs_contain("HTTP client build failed on platform TLS roots"),
15131 "positive control: this test's own fallback warn must be captured"
15132 );
15133 assert!(
15134 !logs_contain("client certificate NOT used"),
15135 "no identity was configured — the identity degrade warn must not fire"
15136 );
15137 assert!(
15138 !logs_contain("falling back to bundled Mozilla roots"),
15139 "no CA was configured — the CA degrade warn must not fire"
15140 );
15141
15142 server.abort();
15143 }
15144
15145 #[tracing_test::traced_test]
15146 #[tokio::test]
15147 async fn tls_handshake_strict_mtls_identity_carried() {
15148 let ca = gen_test_ca();
15149 let dir = tempfile::tempdir().expect("tempdir"); let ca_path = dir.path().join("ca.pem");
15151 std::fs::write(&ca_path, ca.ca_pem.as_bytes()).expect("write ca pem"); let (client_cert_pem, client_key_pem) = gen_client_identity(&ca);
15153 let client_cert_path = dir.path().join("client-cert.pem");
15154 std::fs::write(&client_cert_path, client_cert_pem.as_bytes()).expect("write client cert"); let client_key_path = dir.path().join("client-key.pem");
15156 std::fs::write(&client_key_path, client_key_pem.as_bytes()).expect("write client key"); let (server_cert, server_key) =
15158 gen_leaf_signed_by_ca(&ca, "127.0.0.1".parse().expect("loopback ip")); let (addr, server) = spawn_tls_server(&server_cert, &server_key, Some(&ca.ca_pem)).await;
15162
15163 let config = HttpConfig {
15164 tls: Some(TlsConfig {
15165 enabled: true,
15166 strict: true,
15167 ca_cert_path: Some(ca_path.display().to_string()),
15168 client_cert_path: Some(client_cert_path.display().to_string()),
15169 client_key_path: Some(client_key_path.display().to_string()),
15170 ..Default::default()
15171 }),
15172 ..Default::default()
15173 };
15174 let client = build_under_seam(&config);
15175
15176 let resp = tokio::time::timeout(
15177 Duration::from_secs(15),
15178 client.get(format!("https://{addr}/")).send(),
15179 )
15180 .await
15181 .expect("request must complete within the harness timeout") .expect("strict webpki fallback must carry the mTLS identity into the handshake"); assert_eq!(resp.status().as_u16(), 200);
15184 let body = resp.text().await.expect("response body"); assert_eq!(body, "ok");
15186
15187 assert!(
15191 logs_contain("carries configured TLS material"),
15192 "strict webpki fallback with configured material must log the carry"
15193 );
15194
15195 server.abort();
15196 }
15197
15198 #[tokio::test]
15199 async fn tls_handshake_strict_mtls_less_client_rejected() {
15200 let ca = gen_test_ca();
15201 let dir = tempfile::tempdir().expect("tempdir"); let ca_path = dir.path().join("ca.pem");
15203 std::fs::write(&ca_path, ca.ca_pem.as_bytes()).expect("write ca pem"); let (server_cert, server_key) =
15205 gen_leaf_signed_by_ca(&ca, "127.0.0.1".parse().expect("loopback ip")); let (addr, server) = spawn_tls_server(&server_cert, &server_key, Some(&ca.ca_pem)).await;
15207
15208 let config = HttpConfig {
15211 tls: Some(TlsConfig {
15212 enabled: true,
15213 strict: true,
15214 ca_cert_path: Some(ca_path.display().to_string()),
15215 ..Default::default()
15216 }),
15217 ..Default::default()
15218 };
15219 let client = build_under_seam(&config);
15220
15221 let send_result = tokio::time::timeout(
15222 Duration::from_secs(15),
15223 client.get(format!("https://{addr}/")).send(),
15224 )
15225 .await
15226 .expect("request must complete within the harness timeout"); assert!(
15228 send_result.is_err(),
15229 "the server requires client certificates — an identity-less client \
15230 must be rejected at the handshake"
15231 );
15232
15233 server.abort();
15234 }
15235
15236 #[tracing_test::traced_test]
15237 #[tokio::test]
15238 async fn tls_handshake_nonstrict_valid_ca_carried() {
15239 let ca = gen_test_ca();
15240 let dir = tempfile::tempdir().expect("tempdir"); let ca_path = dir.path().join("ca.pem");
15242 std::fs::write(&ca_path, ca.ca_pem.as_bytes()).expect("write ca pem"); let (server_cert, server_key) =
15244 gen_leaf_signed_by_ca(&ca, "127.0.0.1".parse().expect("loopback ip")); let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15246
15247 let config = HttpConfig {
15248 tls: Some(TlsConfig {
15249 enabled: true,
15250 strict: false,
15251 ca_cert_path: Some(ca_path.display().to_string()),
15252 ..Default::default()
15253 }),
15254 ..Default::default()
15255 };
15256 let client = build_under_seam(&config);
15257
15258 let resp = tokio::time::timeout(
15259 Duration::from_secs(15),
15260 client.get(format!("https://{addr}/")).send(),
15261 )
15262 .await
15263 .expect("request must complete within the harness timeout") .expect("non-strict webpki fallback must carry the valid configured CA into the handshake"); assert_eq!(resp.status().as_u16(), 200);
15266 let body = resp.text().await.expect("response body"); assert_eq!(body, "ok");
15268
15269 assert!(
15279 !logs_contain("client certificate NOT used"),
15280 "no identity was configured — the identity degrade warn must not fire"
15281 );
15282 assert!(
15283 !logs_contain("falling back to bundled Mozilla roots"),
15284 "the CA loaded cleanly — the CA degrade warn must not fire"
15285 );
15286
15287 server.abort();
15288 }
15289
15290 #[tracing_test::traced_test]
15304 #[tokio::test]
15305 async fn tls_handshake_mtls_identity_only_genuine_fallback_anchor() {
15306 let ca = gen_test_ca();
15307 let dir = tempfile::tempdir().expect("tempdir"); let (client_cert_pem, client_key_pem) = gen_client_identity(&ca);
15309 let client_cert_path = dir.path().join("client-cert.pem");
15310 std::fs::write(&client_cert_path, client_cert_pem.as_bytes()).expect("write client cert"); let client_key_path = dir.path().join("client-key.pem");
15312 std::fs::write(&client_key_path, client_key_pem.as_bytes()).expect("write client key"); let config = HttpConfig {
15315 tls: Some(TlsConfig {
15316 enabled: true,
15317 strict: true,
15318 client_cert_path: Some(client_cert_path.display().to_string()),
15319 client_key_path: Some(client_key_path.display().to_string()),
15320 ..Default::default()
15321 }),
15322 ..Default::default()
15323 };
15324 let (client, fallbacks_taken) = {
15325 let fallbacks_before = build_client_fallback_count();
15326 let client = forced_fallback_env(|| {
15327 build_client(&config, None).expect("client must build") });
15329 (client, build_client_fallback_count() - fallbacks_before)
15330 };
15331 assert_eq!(
15332 fallbacks_taken, 1,
15333 "the empty-store env window must genuinely fail the platform-verifier primary"
15334 );
15335
15336 drop(client);
15343
15344 assert!(
15349 logs_contain("HTTP client build failed on platform TLS roots"),
15350 "the genuine env-window trigger must emit the real platform warn"
15351 );
15352 }
15353
15354 #[tokio::test]
15371 async fn tls_parity_insecure_self_signed_succeeds() {
15372 let (server_cert, server_key) = gen_self_signed_server_cert();
15373 let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15374
15375 let config = HttpConfig {
15376 tls: Some(TlsConfig {
15377 enabled: true,
15378 strict: false,
15379 insecure: true,
15380 ..Default::default()
15381 }),
15382 ..Default::default()
15383 };
15384 let client = build_under_seam(&config);
15385
15386 let resp = tokio::time::timeout(
15387 Duration::from_secs(15),
15388 client.get(format!("https://{addr}/")).send(),
15389 )
15390 .await
15391 .expect("request must complete within the harness timeout") .expect("verify-off (insecure) fallback must handshake with the self-signed server"); assert_eq!(resp.status().as_u16(), 200);
15394 let body = resp.text().await.expect("response body"); assert_eq!(body, "ok");
15396
15397 server.abort();
15398 }
15399
15400 #[tokio::test]
15401 async fn tls_parity_verify_peer_false_self_signed_succeeds() {
15402 let (server_cert, server_key) = gen_self_signed_server_cert();
15403 let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15404
15405 let config = HttpConfig {
15406 tls: Some(TlsConfig {
15407 enabled: true,
15408 strict: false,
15409 verify_peer: false,
15410 ..Default::default()
15411 }),
15412 ..Default::default()
15413 };
15414 let client = build_under_seam(&config);
15415
15416 let resp = tokio::time::timeout(
15417 Duration::from_secs(15),
15418 client.get(format!("https://{addr}/")).send(),
15419 )
15420 .await
15421 .expect("request must complete within the harness timeout") .expect("verify_peer=false fallback must handshake with the self-signed server"); assert_eq!(resp.status().as_u16(), 200);
15424 let body = resp.text().await.expect("response body"); assert_eq!(body, "ok");
15426
15427 server.abort();
15428 }
15429
15430 #[tokio::test]
15431 async fn tls_parity_insecure_strict_bad_material_still_fails_closed() {
15432 let dir = tempfile::tempdir().expect("tempdir"); let ca_path = dir.path().join("rejected-ca.pem");
15437 std::fs::write(
15438 &ca_path,
15439 b"-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n",
15440 )
15441 .expect("write rejected ca fixture"); let config = HttpConfig {
15444 tls: Some(TlsConfig {
15445 enabled: true,
15446 strict: true,
15447 insecure: true,
15448 ca_cert_path: Some(ca_path.display().to_string()),
15449 ..Default::default()
15450 }),
15451 ..Default::default()
15452 };
15453 let err = match force_webpki_fallback(|| build_client(&config, None)) {
15457 Err(e) => e,
15458 Ok(_) => panic!(
15459 "strict mode must fail closed on store-rejected CA material \
15460 even with insecure=true"
15461 ),
15462 };
15463 assert_strict_fallback_error(err, &["tls.strict/webpki-fallback"]);
15464 }
15465
15466 #[test]
15467 fn strict_rebuild_failure_returns_typed_error() {
15468 let StrictMaterial {
15469 dir: _dir,
15470 ca_path,
15471 cert_path,
15472 key_path,
15473 ..
15474 } = strict_material_fixtures();
15475
15476 let config = HttpConfig {
15480 tls: Some(TlsConfig {
15481 enabled: true,
15482 strict: true,
15483 ca_cert_path: Some(ca_path.display().to_string()),
15484 client_cert_path: Some(cert_path.display().to_string()),
15485 client_key_path: Some(key_path.display().to_string()),
15486 ..Default::default()
15487 }),
15488 ..Default::default()
15489 };
15490 let fallbacks_before = build_client_fallback_count();
15491 let err = match force_webpki_fallback_rebuild_failure(|| build_client(&config, None)) {
15492 Err(e) => e,
15493 Ok(_) => panic!(
15494 "strict mode must fail closed when the webpki fallback client \
15495 rebuild fails — never the material-free emergency client"
15496 ),
15497 };
15498 assert_eq!(
15499 build_client_fallback_count() - fallbacks_before,
15500 1,
15501 "the rebuild-failure call must route through the webpki fallback exactly once"
15502 );
15503 assert_strict_fallback_error(
15504 err,
15505 &[
15506 "tls.strict/webpki-fallback:",
15507 "webpki fallback client rebuild failed",
15508 "forced webpki fallback rebuild failure",
15509 ],
15510 );
15511 }
15512
15513 #[test]
15514 fn non_strict_rebuild_failure_returns_emergency_client() {
15515 let StrictMaterial {
15516 dir: _dir,
15517 ca_path,
15518 cert_path,
15519 key_path,
15520 ..
15521 } = strict_material_fixtures();
15522
15523 let config = HttpConfig {
15524 tls: Some(TlsConfig {
15525 enabled: true,
15526 strict: false,
15527 ca_cert_path: Some(ca_path.display().to_string()),
15528 client_cert_path: Some(cert_path.display().to_string()),
15529 client_key_path: Some(key_path.display().to_string()),
15530 ..Default::default()
15531 }),
15532 ..Default::default()
15533 };
15534 let fallbacks_before = build_client_fallback_count();
15535 let _client = force_webpki_fallback_rebuild_failure(|| build_client(&config, None))
15536 .expect("non-strict rebuild failure must degrade to the emergency client"); assert_eq!(
15538 build_client_fallback_count() - fallbacks_before,
15539 1,
15540 "the rebuild-failure call must route through the webpki fallback exactly once"
15541 );
15542 }
15543
15544 #[test]
15545 fn strict_rebuild_failure_surfaces_at_endpoint_creation() {
15546 let StrictMaterial {
15547 dir: _dir,
15548 ca_path,
15549 cert_path,
15550 key_path,
15551 ..
15552 } = strict_material_fixtures();
15553
15554 let config = HttpConfig {
15558 tls: Some(TlsConfig {
15559 enabled: true,
15560 strict: true,
15561 ca_cert_path: Some(ca_path.display().to_string()),
15562 client_cert_path: Some(cert_path.display().to_string()),
15563 client_key_path: Some(key_path.display().to_string()),
15564 ..Default::default()
15565 }),
15566 ..Default::default()
15567 };
15568
15569 let endpoint_err = force_webpki_fallback_rebuild_failure(|| {
15570 let component = HttpComponent::with_config(config);
15574 component
15575 .create_endpoint("http://h/p", &NoOpComponentContext)
15576 .err()
15577 });
15578
15579 let err = endpoint_err.expect("folded rebuild failure must fail endpoint creation"); assert_strict_fallback_error(
15581 err,
15582 &[
15583 "tls.strict/webpki-fallback:",
15584 "webpki fallback client rebuild failed",
15585 ],
15586 );
15587 }
15588
15589 #[tracing_test::traced_test]
15590 #[tokio::test]
15591 async fn non_strict_rebuild_failure_drops_custom_trust() {
15592 let StrictMaterial {
15593 dir: _dir,
15594 ca,
15595 ca_path,
15596 ..
15597 } = strict_material_fixtures();
15598
15599 let (server_cert, server_key) =
15603 gen_leaf_signed_by_ca(&ca, "127.0.0.1".parse().expect("loopback ip")); let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15605
15606 let config = HttpConfig {
15607 tls: Some(TlsConfig {
15608 enabled: true,
15609 strict: false,
15610 ca_cert_path: Some(ca_path.display().to_string()),
15611 ..Default::default()
15612 }),
15613 ..Default::default()
15614 };
15615 let control = force_webpki_fallback(|| {
15624 build_client(&config, None).expect("control client must build") });
15626 let client = force_webpki_fallback_rebuild_failure(|| {
15630 build_client(&config, None).expect("emergency client must build") });
15632
15633 let control_resp = tokio::time::timeout(
15638 Duration::from_secs(15),
15639 control.get(format!("https://{addr}/")).send(),
15640 )
15641 .await
15642 .expect("request must complete within the harness timeout") .expect("the control client carries the CA — the CA-signed handshake must succeed"); assert_eq!(control_resp.status().as_u16(), 200);
15645
15646 let send_result = tokio::time::timeout(
15647 Duration::from_secs(15),
15648 client.get(format!("https://{addr}/")).send(),
15649 )
15650 .await
15651 .expect("request must complete within the harness timeout"); assert!(
15653 send_result.is_err(),
15654 "the emergency client carries Mozilla roots only — the configured \
15655 CA trust is provably dropped, so the CA-signed handshake must fail"
15656 );
15657
15658 assert!(
15662 logs_contain("webpki fallback client build failed — TLS stack broken process-wide"),
15663 "the rebuild-failure terminal must log the system-broken diagnosis"
15664 );
15665
15666 server.abort();
15667 }
15668
15669 #[tracing_test::traced_test]
15670 #[tokio::test]
15671 async fn tls_parity_nonstrict_itemwise_valid_ca_bad_identity() {
15672 let ca = gen_test_ca();
15673 let dir = tempfile::tempdir().expect("tempdir"); let ca_path = dir.path().join("ca.pem");
15675 std::fs::write(&ca_path, ca.ca_pem.as_bytes()).expect("write ca pem"); let (client_cert_pem, _client_key_pem) = gen_client_identity(&ca);
15677 let client_cert_path = dir.path().join("client-cert.pem");
15678 std::fs::write(&client_cert_path, client_cert_pem.as_bytes()).expect("write client cert"); let (server_cert, server_key) =
15680 gen_leaf_signed_by_ca(&ca, "127.0.0.1".parse().expect("loopback ip")); let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15684
15685 let config = HttpConfig {
15686 tls: Some(TlsConfig {
15687 enabled: true,
15688 strict: false,
15689 ca_cert_path: Some(ca_path.display().to_string()),
15690 client_cert_path: Some(client_cert_path.display().to_string()),
15691 client_key_path: Some("/nonexistent/key.pem".to_string()),
15694 ..Default::default()
15695 }),
15696 ..Default::default()
15697 };
15698 let client = build_under_seam(&config);
15699
15700 let resp = tokio::time::timeout(
15701 Duration::from_secs(15),
15702 client.get(format!("https://{addr}/")).send(),
15703 )
15704 .await
15705 .expect("request must complete within the harness timeout") .expect("the valid CA item must be carried despite the unreadable key"); assert_eq!(resp.status().as_u16(), 200);
15708 let body = resp.text().await.expect("response body"); assert_eq!(body, "ok");
15710
15711 assert!(
15716 logs_contain("client certificate NOT used"),
15717 "the unreadable key must degrade the identity item with the mTLS warn"
15718 );
15719 assert!(
15720 !logs_contain("falling back to bundled Mozilla roots"),
15721 "the CA loaded cleanly — the CA degrade warn must not fire"
15722 );
15723
15724 server.abort();
15725 }
15726
15727 #[tokio::test]
15728 async fn tls_parity_verifying_client_rejects_self_signed_without_insecure() {
15729 let (server_cert, server_key) = gen_self_signed_server_cert();
15730 let (addr, server) = spawn_tls_server(&server_cert, &server_key, None).await;
15731
15732 let config = HttpConfig {
15738 tls: Some(TlsConfig {
15739 enabled: true,
15740 strict: true,
15741 ..Default::default()
15742 }),
15743 ..Default::default()
15744 };
15745 let client = build_under_seam(&config);
15746
15747 let send_result = tokio::time::timeout(
15748 Duration::from_secs(15),
15749 client.get(format!("https://{addr}/")).send(),
15750 )
15751 .await
15752 .expect("request must complete within the harness timeout"); assert!(
15754 send_result.is_err(),
15755 "a verifying client must reject the self-signed server at the handshake"
15756 );
15757
15758 server.abort();
15759 }
15760}