1use std::fmt;
12use std::ops::Deref;
13use std::path::PathBuf;
14use std::sync::OnceLock;
15use thiserror::Error;
16use tokio::task::JoinHandle;
17use tokio_util::sync::CancellationToken;
18
19#[cfg(unix)]
20use crate::GroupGuard;
21use crate::spec::{BridgeSpec, CXF_BRIDGE, JMS_BRIDGE, XML_BRIDGE};
22
23#[derive(Clone)]
30pub struct Redacted<T>(T);
31
32impl<T> Redacted<T> {
33 pub fn new(value: T) -> Self {
34 Self(value)
35 }
36
37 pub fn into_inner(self) -> T {
38 self.0
39 }
40}
41
42impl<T> fmt::Debug for Redacted<T> {
43 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44 write!(f, "[REDACTED]")
45 }
46}
47
48impl<T> fmt::Display for Redacted<T> {
49 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50 write!(f, "[REDACTED]")
51 }
52}
53
54impl<T> Deref for Redacted<T> {
55 type Target = T;
56
57 fn deref(&self) -> &Self::Target {
58 &self.0
59 }
60}
61
62#[derive(Debug, Error)]
63#[non_exhaustive]
64pub enum BridgeError {
65 #[error("IO error: {0}")]
66 Io(#[from] std::io::Error),
67 #[error("Bridge timed out: {0}")]
68 Timeout(String),
69 #[error("Bridge stdout closed before ready message")]
70 StdoutClosed,
71 #[error("Bridge ready message malformed: {0}")]
72 BadReadyMessage(String),
73 #[error("Download failed: {0}")]
74 Download(String),
75 #[error("Checksum mismatch: expected {expected}, got {actual}")]
76 ChecksumMismatch { expected: String, actual: String },
77 #[error("URL not allowed: {0}")]
78 UrlNotAllowed(String),
79 #[error("Transport error: {0}")]
80 Transport(String),
81 #[error("Config error: {0}")]
82 Config(String),
83 #[error("Bridge supervision error: {0}")]
84 Supervision(String),
85 #[error("Bridge permanent failure: {0}")]
89 PermanentFailure(String),
90 #[error(
91 "bridge cache directory {path} is not writable: {source}; the directory may be owned by another user (e.g. left by a Docker run as root) or on a read-only filesystem — repair its ownership/permissions, or bypass the cache via CAMEL_<BRIDGE>_BINARY_PATH or the component's bridge_cache_dir"
92 )]
93 CacheUnwritable {
94 path: std::path::PathBuf,
95 source: std::io::Error,
96 },
97}
98
99pub const BRIDGE_BROKER_TLS_CA_CERT_PATH: &str = "BRIDGE_BROKER_TLS_CA_CERT_PATH";
101pub const BRIDGE_BROKER_TLS_CLIENT_CERT_PATH: &str = "BRIDGE_BROKER_TLS_CLIENT_CERT_PATH";
102pub const BRIDGE_BROKER_TLS_CLIENT_KEY_PATH: &str = "BRIDGE_BROKER_TLS_CLIENT_KEY_PATH";
103pub const BRIDGE_BROKER_TLS_SERVER_NAME: &str = "BRIDGE_BROKER_TLS_SERVER_NAME";
104
105pub const BRIDGE_BROKER_TLS_ENV_KEYS: [&str; 4] = [
109 BRIDGE_BROKER_TLS_CA_CERT_PATH,
110 BRIDGE_BROKER_TLS_CLIENT_CERT_PATH,
111 BRIDGE_BROKER_TLS_CLIENT_KEY_PATH,
112 BRIDGE_BROKER_TLS_SERVER_NAME,
113];
114
115#[derive(Debug, Clone, Default, PartialEq, Eq)]
118pub struct JmsTlsEnv {
119 pub ca_cert_path: Option<String>,
120 pub client_cert_path: Option<String>,
121 pub client_key_path: Option<String>,
122 pub server_name: Option<String>,
123}
124
125#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
126#[serde(rename_all = "lowercase")]
127pub enum BrokerType {
128 #[serde(alias = "active_mq")]
129 ActiveMq,
130 Artemis,
131 Generic,
132}
133
134impl BrokerType {
135 pub fn as_env_str(&self) -> &'static str {
136 match self {
137 BrokerType::ActiveMq => "activemq",
138 BrokerType::Artemis => "artemis",
139 BrokerType::Generic => "generic",
140 }
141 }
142}
143
144impl std::str::FromStr for BrokerType {
145 type Err = BridgeError;
146
147 fn from_str(s: &str) -> Result<Self, Self::Err> {
148 match s.to_lowercase().as_str() {
149 "activemq" => Ok(BrokerType::ActiveMq),
150 "artemis" => Ok(BrokerType::Artemis),
151 "generic" => Ok(BrokerType::Generic),
152 other => Err(BridgeError::Config(format!("unknown broker type: {other}"))), }
154 }
155}
156
157#[derive(Debug, Default)]
161pub struct CxfTlsEnvVars {
162 pub keystore_path: Option<String>,
163 pub keystore_password: Option<Redacted<String>>,
164 pub keystore_type: Option<String>,
165 pub truststore_path: Option<String>,
166 pub truststore_password: Option<Redacted<String>>,
167 pub truststore_type: Option<String>,
168}
169
170impl CxfTlsEnvVars {
171 pub fn to_env_vars(&self, prefix: &str) -> Vec<(String, String)> {
174 let mut vars = Vec::new();
175 if let Some(ref v) = self.keystore_path {
176 vars.push((format!("{prefix}TLS_KEYSTORE_PATH"), v.clone()));
177 }
178 if let Some(ref v) = self.keystore_password {
179 vars.push((format!("{prefix}TLS_KEYSTORE_PASSWORD"), (**v).clone()));
180 }
181 if let Some(ref v) = self.keystore_type {
182 vars.push((format!("{prefix}TLS_KEYSTORE_TYPE"), v.clone()));
183 }
184 if let Some(ref v) = self.truststore_path {
185 vars.push((format!("{prefix}TLS_TRUSTSTORE_PATH"), v.clone()));
186 }
187 if let Some(ref v) = self.truststore_password {
188 vars.push((format!("{prefix}TLS_TRUSTSTORE_PASSWORD"), (**v).clone()));
189 }
190 if let Some(ref v) = self.truststore_type {
191 vars.push((format!("{prefix}TLS_TRUSTSTORE_TYPE"), v.clone()));
192 }
193 vars
194 }
195}
196
197#[derive(Debug)]
200pub struct CxfProfileEnvVars {
201 pub name: String,
202 pub wsdl_path: String,
203 pub service_name: String,
204 pub port_name: String,
205 pub address: Option<String>,
206 pub keystore_path: Option<String>,
207 pub keystore_password: Option<Redacted<String>>,
208 pub truststore_path: Option<String>,
209 pub truststore_password: Option<Redacted<String>>,
210 pub sig_username: Option<String>,
211 pub sig_password: Option<Redacted<String>>,
212 pub enc_username: Option<String>,
213 pub security_actions_out: Option<String>,
214 pub security_actions_in: Option<String>,
215 pub signature_algorithm: Option<String>,
216 pub signature_digest_algorithm: Option<String>,
217 pub signature_c14n_algorithm: Option<String>,
218 pub signature_parts: Option<String>,
219 pub allowed_signature_algorithms: Option<String>,
222 pub allowed_digest_algorithms: Option<String>,
225 pub allow_expired_pinned_certificate: bool,
228 pub timestamp_ttl_seconds: Option<u64>,
231 pub timestamp_future_tolerance_seconds: Option<u64>,
234 pub fault_status: Option<u16>,
237 pub exposed_soap_headers: Vec<String>,
241 pub tls: Option<CxfTlsEnvVars>,
243}
244
245impl CxfProfileEnvVars {
246 pub fn to_env_vars(&self) -> Vec<(String, String)> {
247 let prefix = format!("CXF_PROFILE_{}_", self.name.to_uppercase());
248 let mut vars = vec![
249 (format!("{}WSDL_PATH", prefix), self.wsdl_path.clone()),
250 (format!("{}SERVICE_NAME", prefix), self.service_name.clone()),
251 (format!("{}PORT_NAME", prefix), self.port_name.clone()),
252 ];
253
254 if let Some(ref v) = self.address {
255 vars.push((format!("{}ADDRESS", prefix), v.clone()));
256 }
257 if let Some(ref v) = self.keystore_path {
258 vars.push((format!("{}KEYSTORE_PATH", prefix), v.clone()));
259 }
260 if let Some(ref v) = self.keystore_password {
261 vars.push((format!("{}KEYSTORE_PASSWORD", prefix), (**v).clone()));
262 }
263 if let Some(ref v) = self.truststore_path {
264 vars.push((format!("{}TRUSTSTORE_PATH", prefix), v.clone()));
265 }
266 if let Some(ref v) = self.truststore_password {
267 vars.push((format!("{}TRUSTSTORE_PASSWORD", prefix), (**v).clone()));
268 }
269 if let Some(ref v) = self.sig_username {
270 vars.push((format!("{}SIG_USERNAME", prefix), v.clone()));
271 }
272 if let Some(ref v) = self.sig_password {
273 vars.push((format!("{}SIG_PASSWORD", prefix), (**v).clone()));
274 }
275 if let Some(ref v) = self.enc_username {
276 vars.push((format!("{}ENC_USERNAME", prefix), v.clone()));
277 }
278 if let Some(ref v) = self.security_actions_out {
279 vars.push((format!("{}SECURITY_ACTIONS_OUT", prefix), v.clone()));
280 }
281 if let Some(ref v) = self.security_actions_in {
282 vars.push((format!("{}SECURITY_ACTIONS_IN", prefix), v.clone()));
283 }
284 if let Some(ref v) = self.signature_algorithm {
285 vars.push((format!("{}SIGNATURE_ALGORITHM", prefix), v.clone()));
286 }
287 if let Some(ref v) = self.signature_digest_algorithm {
288 vars.push((format!("{}SIGNATURE_DIGEST_ALGORITHM", prefix), v.clone()));
289 }
290 if let Some(ref v) = self.signature_c14n_algorithm {
291 vars.push((format!("{}SIGNATURE_C14N_ALGORITHM", prefix), v.clone()));
292 }
293 if let Some(ref v) = self.signature_parts {
294 vars.push((format!("{}SIGNATURE_PARTS", prefix), v.clone()));
295 }
296 if let Some(ref v) = self.allowed_signature_algorithms {
297 vars.push((format!("{}ALLOWED_SIGNATURE_ALGORITHMS", prefix), v.clone()));
298 }
299 if let Some(ref v) = self.allowed_digest_algorithms {
300 vars.push((format!("{}ALLOWED_DIGEST_ALGORITHMS", prefix), v.clone()));
301 }
302 if self.allow_expired_pinned_certificate {
303 vars.push((
304 format!("{}ALLOW_EXPIRED_PINNED_CERTIFICATE", prefix),
305 "true".to_string(),
306 ));
307 }
308 if let Some(v) = self.timestamp_ttl_seconds {
309 vars.push((format!("{}TIMESTAMP_TTL_SECONDS", prefix), v.to_string()));
310 }
311 if let Some(v) = self.timestamp_future_tolerance_seconds {
312 vars.push((
313 format!("{}TIMESTAMP_FUTURE_TOLERANCE_SECONDS", prefix),
314 v.to_string(),
315 ));
316 }
317 if let Some(v) = self.fault_status {
318 vars.push((format!("{}FAULT_STATUS", prefix), v.to_string()));
319 }
320 if !self.exposed_soap_headers.is_empty() {
321 vars.push((
322 format!("{}EXPOSED_SOAP_HEADERS", prefix),
323 self.exposed_soap_headers.join(","),
324 ));
325 }
326 if let Some(ref tls) = self.tls {
327 vars.extend(tls.to_env_vars(&prefix));
328 }
329
330 vars
331 }
332}
333
334pub struct BridgeProcessConfig {
339 pub spec: &'static BridgeSpec,
340 pub binary_path: PathBuf,
341 pub broker_url: String,
342 pub broker_type: BrokerType,
343 pub username: Option<String>,
344 pub password: Option<Redacted<String>>,
345 pub start_timeout_ms: u64,
346 pub env_vars: Vec<(String, String)>,
347 pub env_remove_keys: Vec<String>,
351 pub expect_soap_ready: bool,
357}
358
359impl fmt::Debug for BridgeProcessConfig {
360 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
361 f.debug_struct("BridgeProcessConfig")
362 .field("spec", &self.spec)
363 .field("binary_path", &self.binary_path)
364 .field("broker_url", &"[REDACTED]")
365 .field("broker_type", &self.broker_type)
366 .field("username", &self.username.as_ref().map(|_| "[REDACTED]"))
367 .field("password", &self.password)
368 .field("start_timeout_ms", &self.start_timeout_ms)
369 .field(
370 "env_vars",
371 &format!("[REDACTED; {} entries]", self.env_vars.len()),
372 )
373 .field("env_remove_keys", &self.env_remove_keys)
374 .field("expect_soap_ready", &self.expect_soap_ready)
375 .finish()
376 }
377}
378
379impl BridgeProcessConfig {
380 #[allow(clippy::too_many_arguments)]
388 pub fn jms(
389 binary_path: PathBuf,
390 broker_url: String,
391 broker_type: BrokerType,
392 username: Option<String>,
393 password: Option<Redacted<String>>,
394 start_timeout_ms: u64,
395 pool_idle_timeout_ms: u64,
396 pool_expiration_check_ms: u64,
397 journal_dir: Option<String>,
398 broker_key: Option<String>,
399 tls: Option<JmsTlsEnv>,
400 ) -> Self {
401 let mut env_vars = vec![
402 ("BRIDGE_BROKER_URL".to_string(), broker_url.clone()),
403 (
404 "BRIDGE_BROKER_TYPE".to_string(),
405 broker_type.as_env_str().to_string(),
406 ),
407 (
408 "JMS_POOL_IDLE_TIMEOUT_MS".to_string(),
409 pool_idle_timeout_ms.to_string(),
410 ),
411 (
412 "JMS_POOL_EXPIRATION_CHECK_MS".to_string(),
413 pool_expiration_check_ms.to_string(),
414 ),
415 ];
416 if let Some(u) = &username {
417 env_vars.push(("BRIDGE_USERNAME".to_string(), u.clone()));
418 }
419 if let Some(p) = &password {
420 env_vars.push(("BRIDGE_PASSWORD".to_string(), (**p).clone()));
421 }
422 if let Some(dir) = journal_dir {
423 env_vars.push(("JMS_ATTEMPT_JOURNAL_DIR".to_string(), dir));
424 }
425 if let Some(key) = broker_key {
426 env_vars.push(("JMS_BRIDGE_BROKER_KEY".to_string(), key));
427 }
428 let env_remove_keys = BRIDGE_BROKER_TLS_ENV_KEYS
429 .iter()
430 .map(|key| (*key).to_string())
431 .collect();
432 if let Some(tls) = tls {
433 if let Some(value) = tls.ca_cert_path {
434 env_vars.push((BRIDGE_BROKER_TLS_CA_CERT_PATH.to_string(), value));
435 }
436 if let Some(value) = tls.client_cert_path {
437 env_vars.push((BRIDGE_BROKER_TLS_CLIENT_CERT_PATH.to_string(), value));
438 }
439 if let Some(value) = tls.client_key_path {
440 env_vars.push((BRIDGE_BROKER_TLS_CLIENT_KEY_PATH.to_string(), value));
441 }
442 if let Some(value) = tls.server_name {
443 env_vars.push((BRIDGE_BROKER_TLS_SERVER_NAME.to_string(), value));
444 }
445 }
446 Self {
447 spec: &JMS_BRIDGE,
448 binary_path,
449 broker_url,
450 broker_type,
451 username,
452 password,
453 start_timeout_ms,
454 env_vars,
455 env_remove_keys,
456 expect_soap_ready: false,
457 }
458 }
459
460 pub fn xml(binary_path: PathBuf, start_timeout_ms: u64) -> Self {
462 Self {
463 spec: &XML_BRIDGE,
464 binary_path,
465 broker_url: String::new(),
466 broker_type: BrokerType::Generic,
467 username: None,
468 password: None,
469 start_timeout_ms,
470 env_vars: vec![],
471 env_remove_keys: vec![],
472 expect_soap_ready: false,
473 }
474 }
475
476 pub fn cxf_profiles(
479 binary_path: PathBuf,
480 profiles: &[CxfProfileEnvVars],
481 start_timeout_ms: u64,
482 ) -> Self {
483 let profile_names: Vec<String> = profiles.iter().map(|p| p.name.clone()).collect();
484 let mut env_vars = vec![("CXF_PROFILES".to_string(), profile_names.join(","))];
485
486 for profile in profiles {
487 env_vars.extend(profile.to_env_vars());
488 }
489
490 Self {
491 spec: &CXF_BRIDGE,
492 binary_path,
493 broker_url: String::new(),
494 broker_type: BrokerType::Generic,
495 username: None,
496 password: None,
497 start_timeout_ms,
498 env_vars,
499 env_remove_keys: vec![],
500 expect_soap_ready: false,
501 }
502 }
503
504 pub fn validate(&self) -> Result<(), String> {
505 if self.start_timeout_ms == 0 {
506 return Err("start_timeout_ms must be > 0".to_string());
507 }
508 Ok(())
509 }
510}
511
512static DRAIN_RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
521
522fn drain_runtime() -> &'static tokio::runtime::Runtime {
523 DRAIN_RUNTIME.get_or_init(|| {
524 tokio::runtime::Builder::new_multi_thread()
525 .worker_threads(2)
526 .enable_io()
527 .enable_time()
528 .thread_name("camel-bridge-drain")
529 .build()
530 .expect("build camel-bridge drain runtime") })
534}
535
536async fn drain_child_output<R: tokio::io::AsyncBufRead + Unpin>(
542 mut reader: R,
543 token: CancellationToken,
544 label: &str,
545) {
546 use tokio::io::AsyncBufReadExt;
547 use tokio::time::{Duration, Instant};
548
549 const MAX_LINE_BYTES: usize = 64 * 1024;
550 const LOG_INTERVAL: Duration = Duration::from_secs(1);
551 const LOG_BUDGET: u32 = 100;
552
553 let mut line_buf: Vec<u8> = Vec::new();
554 let mut oversized = false;
555 let mut interval_start = Instant::now();
556 let mut logged: u32 = 0;
557 let mut dropped: u32 = 0;
558
559 loop {
560 tokio::select! {
561 biased;
562 _ = token.cancelled() => break,
563 res = reader.fill_buf() => {
564 let chunk_len = match res {
565 Ok(chunk) => {
566 if chunk.is_empty() {
567 break; }
569 for &b in chunk {
570 if b == b'\n' {
571 if oversized {
572 if logged < LOG_BUDGET {
574 tracing::warn!(
575 "bridge {label}: oversized line (>{MAX_LINE_BYTES} bytes), truncated"
576 );
577 logged += 1;
578 } else {
579 dropped += 1;
580 }
581 } else if logged < LOG_BUDGET {
582 tracing::debug!(
584 target: "camel_bridge::child",
585 "{}",
586 String::from_utf8_lossy(&line_buf)
587 );
588 logged += 1;
589 } else {
590 dropped += 1;
591 }
592 line_buf.clear();
593 oversized = false;
594 } else if !oversized {
595 if line_buf.len() < MAX_LINE_BYTES {
596 line_buf.push(b);
597 } else {
598 oversized = true;
599 }
600 }
601 }
602 chunk.len()
603 }
604 Err(_) => break,
605 };
606 reader.consume(chunk_len);
607 if interval_start.elapsed() >= LOG_INTERVAL {
608 if dropped > 0 {
609 tracing::debug!(
611 "bridge {label}: dropped {dropped} lines in last interval"
612 );
613 }
614 interval_start = Instant::now();
615 logged = 0;
616 dropped = 0;
617 }
618 }
619 }
620 }
621}
622
623fn announced_port(value: &serde_json::Value, line: &str) -> Result<u16, BridgeError> {
629 match value.get("port").and_then(|p| p.as_u64()) {
630 Some(p) if (1..=u64::from(u16::MAX)).contains(&p) => Ok(p as u16),
631 _ => {
632 tracing::error!("bridge ready message malformed: {line}");
634 Err(BridgeError::BadReadyMessage(line.to_string()))
635 }
636 }
637}
638
639#[derive(Debug, PartialEq)]
640enum ReadyLine {
641 Ready(u16),
642 SoapReady(u16),
643 PermanentFailure(String),
644 MalformedReady,
645}
646
647fn parse_ready_line(line: &str) -> Option<ReadyLine> {
649 let value: serde_json::Value = serde_json::from_str(line).ok()?;
650 match value.get("status").and_then(|s| s.as_str())? {
651 status @ ("ready" | "soap-ready") => Some(match announced_port(&value, line) {
652 Ok(port) if status == "ready" => ReadyLine::Ready(port),
653 Ok(port) => ReadyLine::SoapReady(port),
654 Err(_) => ReadyLine::MalformedReady,
655 }),
656 "permanent-failure" => Some(ReadyLine::PermanentFailure(
657 value
658 .get("reason")
659 .and_then(|r| r.as_str())
660 .unwrap_or("bridge reported a permanent startup failure")
661 .to_string(),
662 )),
663 _ => None,
664 }
665}
666
667async fn read_ready_ports<R: tokio::io::AsyncBufRead + Unpin>(
670 reader: &mut R,
671 expect_soap_ready: bool,
672) -> Result<(u16, Option<u16>), BridgeError> {
673 use tokio::io::AsyncBufReadExt;
674 const READY_MAX_LINE: usize = 64 * 1024;
675 let mut buf = Vec::new();
676 let mut oversized = false;
677 let mut grpc_port = None;
678 let mut soap_port = None;
679 loop {
680 let chunk = reader.fill_buf().await?;
681 if chunk.is_empty() {
682 return Err(BridgeError::StdoutClosed);
683 }
684 let newline = chunk.iter().position(|&b| b == b'\n');
685 let take = newline.map_or(chunk.len(), |i| i + 1);
686 if !oversized && buf.len() + take <= READY_MAX_LINE {
687 buf.extend_from_slice(&chunk[..take]);
688 } else {
689 oversized = true;
690 }
691 reader.consume(take);
692 if newline.is_some() {
693 if !oversized {
694 let line = String::from_utf8_lossy(&buf);
695 match parse_ready_line(line.trim_end_matches('\n')) {
696 Some(ReadyLine::Ready(port)) => grpc_port = Some(port),
697 Some(ReadyLine::SoapReady(port)) => soap_port = Some(port),
698 Some(ReadyLine::PermanentFailure(reason)) => {
699 return Err(BridgeError::PermanentFailure(reason));
700 }
701 Some(ReadyLine::MalformedReady) => {
702 return Err(BridgeError::BadReadyMessage(line.into_owned()));
703 }
704 None => {}
705 }
706 if let Some(port) = grpc_port
707 && (!expect_soap_ready || soap_port.is_some())
708 {
709 return Ok((port, soap_port));
710 }
711 }
712 buf.clear();
713 oversized = false;
714 }
715 }
716}
717
718#[cfg(test)]
719async fn read_ready_port<R: tokio::io::AsyncBufRead + Unpin>(
720 reader: &mut R,
721) -> Result<u16, BridgeError> {
722 read_ready_ports(reader, false).await.map(|(port, _)| port)
723}
724
725#[cfg(not(unix))]
727async fn eof_exit_error(child: &mut tokio::process::Child) -> BridgeError {
728 use tokio::time::{Duration, timeout};
729 match timeout(Duration::from_secs(2), child.wait()).await {
730 Ok(Ok(status)) if status.code() == Some(78) => BridgeError::PermanentFailure(
731 "bridge exited with code 78 before announcing readiness".to_string(),
732 ),
733 Ok(_) => BridgeError::StdoutClosed,
734 Err(_) => {
735 let _ = child.start_kill();
736 let _ = timeout(Duration::from_secs(2), child.wait()).await;
737 BridgeError::StdoutClosed
738 }
739 }
740}
741
742#[cfg(unix)]
746async fn eof_exit_error(guard: &GroupGuard) -> BridgeError {
747 use tokio::time::Duration;
748 match guard.shutdown_classified(Duration::from_secs(2)).await {
749 Ok(Some(status)) if status.code() == Some(78) => BridgeError::PermanentFailure(
750 "bridge exited with code 78 before announcing readiness".to_string(),
751 ),
752 Ok(_) | Err(_) => BridgeError::StdoutClosed,
753 }
754}
755
756pub struct BridgeProcess {
757 #[cfg(unix)]
759 guard: GroupGuard,
760 #[cfg(not(unix))]
762 child: tokio::process::Child,
763 grpc_port: u16,
764 soap_port: Option<u16>,
768 tls: crate::tls::BridgeTlsMaterial,
769 token: CancellationToken,
771 handle: Option<JoinHandle<()>>,
772 stderr_token: CancellationToken,
774 stderr_handle: Option<JoinHandle<()>>,
775}
776
777impl BridgeProcess {
778 pub fn grpc_port(&self) -> u16 {
779 self.grpc_port
780 }
781
782 pub fn soap_port(&self) -> Option<u16> {
784 self.soap_port
785 }
786
787 pub fn child_pid(&self) -> Option<u32> {
789 #[cfg(unix)]
790 {
791 self.guard.bridge_pid()
792 }
793 #[cfg(not(unix))]
794 {
795 self.child.id()
796 }
797 }
798
799 pub async fn connect(&self) -> Result<tonic::transport::Channel, BridgeError> {
801 crate::channel::connect_channel(self.grpc_port, &self.tls).await
802 }
803
804 pub async fn start_and_connect(
806 config: &BridgeProcessConfig,
807 ) -> Result<(Self, tonic::transport::Channel), BridgeError> {
808 let process = Self::start(config).await?;
809 let channel = process.connect().await?;
810 Ok((process, channel))
811 }
812
813 pub async fn start(config: &BridgeProcessConfig) -> Result<Self, BridgeError> {
825 use tokio::process::Command;
826 use tokio::time::{Duration, timeout};
827
828 config.validate().map_err(BridgeError::Config)?;
829
830 let tls = crate::tls::BridgeTlsMaterial::generate()?;
831
832 #[cfg(unix)]
836 let guard = GroupGuard::establish()?;
837
838 let free_port: u16 = 0;
844
845 let stderr_stdio: std::process::Stdio =
849 if let Ok(log_dir) = std::env::var("CAMEL_BRIDGE_LOG_STDERR") {
850 let log_filename = config
851 .spec
852 .log_file_template
853 .replace("{pid}", &std::process::id().to_string());
854 let log_path = if log_dir.is_empty() {
855 format!("/tmp/{log_filename}")
856 } else {
857 format!("{log_dir}/{log_filename}")
858 };
859 match std::fs::File::create(&log_path) {
860 Ok(f) => {
861 eprintln!("[camel-bridge] stderr → {}", log_path);
862 f.into()
863 }
864 Err(e) => {
865 eprintln!(
866 "[camel-bridge] failed to create log file {}: {}",
867 log_path, e
868 );
869 std::process::Stdio::piped()
870 }
871 }
872 } else {
873 std::process::Stdio::piped()
874 };
875
876 let mut command = Command::new(&config.binary_path);
877 command
878 .stdin(std::process::Stdio::null())
879 .env("QUARKUS_HTTP_SSL_PORT", free_port.to_string())
880 .env(
881 "QUARKUS_TLS_BRIDGE_KEY_STORE_PEM_0_CERT",
882 &tls.server_pem_path,
883 )
884 .env(
885 "QUARKUS_TLS_BRIDGE_KEY_STORE_PEM_0_KEY",
886 &tls.server_key_path,
887 )
888 .env("QUARKUS_TLS_BRIDGE_TRUST_STORE_PEM_CERTS", &tls.ca_pem_path)
889 .stdout(std::process::Stdio::piped())
890 .stderr(stderr_stdio);
891
892 #[cfg(not(unix))]
895 command.kill_on_drop(true);
896
897 for key in &config.env_remove_keys {
900 command.env_remove(key);
901 }
902
903 for (key, value) in &config.env_vars {
905 command.env(key, value);
906 }
907
908 #[cfg(unix)]
914 {
915 let _drain = drain_runtime().enter();
916 guard.spawn_bridge(command)?;
917 }
918 #[cfg(not(unix))]
919 let mut child = {
920 let _drain = drain_runtime().enter();
921 command.spawn()?
922 };
923
924 #[cfg(unix)]
925 let mut reader = {
926 let stdout = guard
927 .take_bridge_stdout()
928 .ok_or(BridgeError::StdoutClosed)?;
929 tokio::io::BufReader::new(stdout)
930 };
931 #[cfg(not(unix))]
932 let mut reader = {
933 let stdout = child.stdout.take().ok_or(BridgeError::StdoutClosed)?;
934 tokio::io::BufReader::new(stdout)
935 };
936
937 let stderr_token = CancellationToken::new();
942 #[cfg(unix)]
943 let stderr_pipe = guard.take_bridge_stderr();
944 #[cfg(not(unix))]
945 let stderr_pipe = child.stderr.take();
946 let stderr_handle = stderr_pipe.map(|stderr| {
947 let child_token = stderr_token.clone();
948 drain_runtime().spawn(async move {
949 drain_child_output(tokio::io::BufReader::new(stderr), child_token, "stderr").await;
950 })
951 });
952
953 let ready = timeout(
962 Duration::from_millis(config.start_timeout_ms),
963 read_ready_ports(&mut reader, config.expect_soap_ready),
964 )
965 .await;
966 let (port, soap_port) = match ready {
967 Ok(Ok(ports)) => ports,
968 Ok(Err(BridgeError::StdoutClosed)) => {
969 #[cfg(unix)]
971 let error = eof_exit_error(&guard).await;
972 #[cfg(not(unix))]
973 let error = eof_exit_error(&mut child).await;
974 tracing::error!("bridge stdout closed before ready message");
976 return Err(error);
977 }
978 Ok(Err(error)) => return Err(error),
979 Err(_) => {
980 let msg = format!(
981 "{} failed to start: health check timeout after {}ms",
982 config.spec.name, config.start_timeout_ms
983 );
984 tracing::error!("{msg}");
986 return Err(BridgeError::Timeout(msg));
987 }
988 };
989
990 let token = CancellationToken::new();
995 let child_token = token.clone();
996 let handle = drain_runtime().spawn(async move {
997 drain_child_output(reader, child_token, "stdout").await;
998 });
999
1000 Ok(BridgeProcess {
1001 #[cfg(unix)]
1002 guard,
1003 #[cfg(not(unix))]
1004 child,
1005 grpc_port: port,
1006 soap_port,
1007 tls,
1008 token,
1009 handle: Some(handle),
1010 stderr_token,
1011 stderr_handle,
1012 })
1013 }
1014
1015 pub async fn stop(mut self) -> Result<(), BridgeError> {
1017 use tokio::time::Duration;
1018
1019 self.token.cancel();
1020 if let Some(handle) = self.handle.take() {
1021 let join_result = tokio::time::timeout(Duration::from_secs(5), handle).await;
1022 if join_result.is_err() {
1023 tracing::warn!("bridge stdout drain task did not exit after cancellation");
1024 }
1025 }
1026
1027 self.stderr_token.cancel();
1028 if let Some(handle) = self.stderr_handle.take() {
1029 let join_result = tokio::time::timeout(Duration::from_secs(5), handle).await;
1030 if join_result.is_err() {
1031 tracing::warn!("bridge stderr drain task did not exit after cancellation");
1032 }
1033 }
1034
1035 #[cfg(unix)]
1039 self.guard.stop_group(Duration::from_secs(5)).await?;
1040
1041 #[cfg(not(unix))]
1043 {
1044 use tokio::time::sleep;
1045
1046 let _ = self.child.start_kill();
1047 tokio::select! {
1048 result = self.child.wait() => {
1049 result?;
1050 }
1051 _ = sleep(Duration::from_secs(5)) => {
1052 let _ = self.child.start_kill();
1053 self.child.wait().await?;
1054 }
1055 }
1056 }
1057 Ok(())
1058 }
1059}
1060
1061impl Drop for BridgeProcess {
1062 fn drop(&mut self) {
1063 self.token.cancel();
1064 self.stderr_token.cancel();
1065 #[cfg(not(unix))]
1069 let _ = self.child.start_kill();
1070 }
1071}
1072
1073#[cfg(test)]
1074mod tests {
1075 use super::*;
1076 use camel_component_api::test_support::EnvGuard;
1077
1078 #[test]
1079 fn broker_type_from_str_activemq() {
1080 assert_eq!(
1081 "activemq".parse::<BrokerType>().unwrap(),
1082 BrokerType::ActiveMq
1083 );
1084 assert_eq!(
1085 "ACTIVEMQ".parse::<BrokerType>().unwrap(),
1086 BrokerType::ActiveMq
1087 );
1088 }
1089
1090 #[test]
1091 fn broker_type_from_str_artemis() {
1092 assert_eq!(
1093 "artemis".parse::<BrokerType>().unwrap(),
1094 BrokerType::Artemis
1095 );
1096 }
1097
1098 #[test]
1099 fn broker_type_from_str_generic() {
1100 assert_eq!(
1101 "generic".parse::<BrokerType>().unwrap(),
1102 BrokerType::Generic
1103 );
1104 }
1105
1106 #[test]
1107 fn broker_type_from_str_unknown_returns_err() {
1108 assert!("ibmmq".parse::<BrokerType>().is_err());
1109 assert!("UnknownBroker".parse::<BrokerType>().is_err());
1110 }
1111
1112 #[test]
1113 fn broker_type_env_str() {
1114 assert_eq!(BrokerType::ActiveMq.as_env_str(), "activemq");
1115 assert_eq!(BrokerType::Artemis.as_env_str(), "artemis");
1116 assert_eq!(BrokerType::Generic.as_env_str(), "generic");
1117 }
1118
1119 #[test]
1120 fn jms_constructor_uses_jms_spec() {
1121 let cfg = BridgeProcessConfig::jms(
1122 PathBuf::from("/tmp/jms-bridge"),
1123 "tcp://localhost:61616".to_string(),
1124 BrokerType::ActiveMq,
1125 Some("user".to_string()),
1126 Some(Redacted::new("pass".to_string())),
1127 1000,
1128 30_000,
1129 30_000,
1130 None,
1131 None,
1132 None,
1133 );
1134 assert_eq!(cfg.spec.name, "jms-bridge");
1135 }
1136
1137 #[test]
1138 fn xml_constructor_uses_xml_spec() {
1139 let cfg = BridgeProcessConfig::xml(PathBuf::from("/tmp/xml-bridge"), 1000);
1140 assert_eq!(cfg.spec.name, "xml-bridge");
1141 }
1142
1143 #[test]
1144 fn journal_env_passed_on_launch_and_restart() {
1145 for _ in 0..2 {
1146 let config = BridgeProcessConfig::jms(
1147 PathBuf::from("/tmp/jms-bridge"),
1148 "tcp://localhost:61616".into(),
1149 BrokerType::ActiveMq,
1150 None,
1151 None,
1152 1000,
1153 30_000,
1154 30_000,
1155 Some("/var/lib/state/rust-camel/jms-bridge".into()),
1156 Some("broker-key".into()),
1157 None,
1158 );
1159 assert!(config.env_vars.contains(&(
1160 "JMS_ATTEMPT_JOURNAL_DIR".into(),
1161 "/var/lib/state/rust-camel/jms-bridge".into()
1162 )));
1163 assert!(
1164 config
1165 .env_vars
1166 .contains(&("JMS_BRIDGE_BROKER_KEY".into(), "broker-key".into()))
1167 );
1168 }
1169 let unset = BridgeProcessConfig::jms(
1170 PathBuf::from("/tmp/jms-bridge"),
1171 "tcp://localhost:61616".into(),
1172 BrokerType::ActiveMq,
1173 None,
1174 None,
1175 1000,
1176 30_000,
1177 30_000,
1178 None,
1179 None,
1180 None,
1181 );
1182 assert!(
1183 !unset
1184 .env_vars
1185 .iter()
1186 .any(|(key, _)| key == "JMS_ATTEMPT_JOURNAL_DIR" || key == "JMS_BRIDGE_BROKER_KEY")
1187 );
1188 }
1189
1190 #[test]
1191 fn jms_pool_knob_env_vars_present() {
1192 let config = BridgeProcessConfig::jms(
1193 PathBuf::from("/tmp/jms-bridge"),
1194 "tcp://localhost:61616".to_string(),
1195 BrokerType::ActiveMq,
1196 Some("user".to_string()),
1197 Some(Redacted::new("pass".to_string())),
1198 1000,
1199 60_000,
1200 5_000,
1201 None,
1202 None,
1203 None,
1204 );
1205 assert!(
1206 config
1207 .env_vars
1208 .contains(&("JMS_POOL_IDLE_TIMEOUT_MS".to_string(), "60000".to_string()))
1209 );
1210 assert!(config.env_vars.contains(&(
1211 "JMS_POOL_EXPIRATION_CHECK_MS".to_string(),
1212 "5000".to_string()
1213 )));
1214 }
1215
1216 #[test]
1217 fn tls_env_emitted_for_configured_broker() {
1218 let config = BridgeProcessConfig::jms(
1219 PathBuf::from("/tmp/jms-bridge"),
1220 "ssl://localhost:61617".to_string(),
1221 BrokerType::ActiveMq,
1222 None,
1223 None,
1224 1000,
1225 30_000,
1226 30_000,
1227 None,
1228 None,
1229 Some(JmsTlsEnv {
1230 ca_cert_path: Some("/certs/ca.pem".to_string()),
1231 client_cert_path: Some("/certs/client.pem".to_string()),
1232 client_key_path: Some("/certs/client-key.pem".to_string()),
1233 server_name: Some("mq.internal.example".to_string()),
1234 }),
1235 );
1236 assert!(config.env_vars.contains(&(
1237 BRIDGE_BROKER_TLS_CA_CERT_PATH.to_string(),
1238 "/certs/ca.pem".to_string()
1239 )));
1240 assert!(config.env_vars.contains(&(
1241 BRIDGE_BROKER_TLS_CLIENT_CERT_PATH.to_string(),
1242 "/certs/client.pem".to_string()
1243 )));
1244 assert!(config.env_vars.contains(&(
1245 BRIDGE_BROKER_TLS_CLIENT_KEY_PATH.to_string(),
1246 "/certs/client-key.pem".to_string()
1247 )));
1248 assert!(config.env_vars.contains(&(
1249 BRIDGE_BROKER_TLS_SERVER_NAME.to_string(),
1250 "mq.internal.example".to_string()
1251 )));
1252 }
1253
1254 #[test]
1255 fn tls_env_absent_keys_removed() {
1256 let config = BridgeProcessConfig::jms(
1257 PathBuf::from("/tmp/jms-bridge"),
1258 "tcp://localhost:61616".to_string(),
1259 BrokerType::ActiveMq,
1260 None,
1261 None,
1262 1000,
1263 30_000,
1264 30_000,
1265 None,
1266 None,
1267 None,
1268 );
1269 for key in BRIDGE_BROKER_TLS_ENV_KEYS {
1270 assert!(
1271 config.env_remove_keys.contains(&key.to_string()),
1272 "inherited {key} must be removed on the child Command"
1273 );
1274 assert!(
1275 !config.env_vars.iter().any(|(name, _)| name == key),
1276 "unconfigured {key} must not be injected"
1277 );
1278 }
1279 }
1280
1281 const TLS_SPAWN_ISOLATION_CHILD_ENV: &str = "CAMEL_TEST_TLS_ISOLATION_INNER";
1283 const TLS_SPAWN_ISOLATION_TEST: &str = "process::tests::tls_spawn_isolation_inner";
1285
1286 #[cfg(unix)]
1295 #[test]
1296 fn tls_spawn_removes_inherited_and_injects_configured() {
1297 let exe = match std::env::current_exe() {
1298 Ok(exe) => exe,
1299 Err(e) => panic!("current test executable path: {e}"),
1300 };
1301 let output = match std::process::Command::new(exe)
1302 .args([
1303 "--exact",
1304 TLS_SPAWN_ISOLATION_TEST,
1305 "--test-threads=1",
1306 "--nocapture",
1307 ])
1308 .env(TLS_SPAWN_ISOLATION_CHILD_ENV, "1")
1309 .output()
1310 {
1311 Ok(output) => output,
1312 Err(e) => panic!("spawn isolated TLS env test: {e}"),
1313 };
1314 assert!(
1315 output.status.success(),
1316 "isolated TLS env child failed: status={:?}\nstdout:\n{}\nstderr:\n{}",
1317 output.status,
1318 String::from_utf8_lossy(&output.stdout),
1319 String::from_utf8_lossy(&output.stderr),
1320 );
1321 }
1322
1323 #[cfg(unix)]
1326 #[tokio::test]
1327 async fn tls_spawn_isolation_inner() {
1328 if std::env::var(TLS_SPAWN_ISOLATION_CHILD_ENV).ok().as_deref() != Some("1") {
1329 return;
1330 }
1331
1332 let _env_guard = EnvGuard::set(BRIDGE_BROKER_TLS_CA_CERT_PATH, "stale-value");
1333
1334 let observed_dir = tempfile::tempdir().expect("observed env tempdir");
1335 let observed_a = observed_dir.path().join("a.env");
1336 let observed_b = observed_dir.path().join("b.env");
1337
1338 let body = |path: &std::path::Path| {
1339 format!(
1340 "env > '{}'; echo '{{\"status\":\"ready\",\"port\":12345}}'",
1341 path.display()
1342 )
1343 };
1344 let (_script_dir_a, script_a) = fake_bridge_script(&body(&observed_a));
1345 let (_script_dir_b, script_b) = fake_bridge_script(&body(&observed_b));
1346
1347 let mut config = fake_bridge_config(&script_a, false, 10_000);
1348 config.env_remove_keys = vec![BRIDGE_BROKER_TLS_CA_CERT_PATH.to_string()];
1349 config.env_vars = vec![];
1350 let process = BridgeProcess::start(&config)
1351 .await
1352 .unwrap_or_else(|e| panic!("remove-only spawn must start: {e}"));
1353 let child_env_a = std::fs::read_to_string(&observed_a).expect("child env snapshot a");
1354 assert!(
1355 !child_env_a
1356 .lines()
1357 .any(|line| line == format!("{BRIDGE_BROKER_TLS_CA_CERT_PATH}=stale-value")),
1358 "inherited stale TLS CA path must be removed from the child env"
1359 );
1360 assert!(
1361 !child_env_a.contains("stale-value"),
1362 "stale value must not leak through any inherited variable"
1363 );
1364 process
1365 .stop()
1366 .await
1367 .unwrap_or_else(|e| panic!("remove-only fake bridge must stop cleanly: {e}"));
1368
1369 let mut config = fake_bridge_config(&script_b, false, 10_000);
1370 config.env_remove_keys = vec![BRIDGE_BROKER_TLS_CA_CERT_PATH.to_string()];
1371 config.env_vars = vec![(
1372 BRIDGE_BROKER_TLS_CA_CERT_PATH.to_string(),
1373 "/good/path".to_string(),
1374 )];
1375 let process = BridgeProcess::start(&config)
1376 .await
1377 .unwrap_or_else(|e| panic!("remove-then-inject spawn must start: {e}"));
1378 let child_env_b = std::fs::read_to_string(&observed_b).expect("child env snapshot b");
1379 assert!(
1380 child_env_b
1381 .lines()
1382 .any(|line| line == format!("{BRIDGE_BROKER_TLS_CA_CERT_PATH}=/good/path")),
1383 "configured TLS CA path must win over the stale inherited value"
1384 );
1385 assert!(
1386 !child_env_b.contains("stale-value"),
1387 "remove-then-inject must not leave the inherited value behind"
1388 );
1389 process
1390 .stop()
1391 .await
1392 .unwrap_or_else(|e| panic!("remove-then-inject fake bridge must stop cleanly: {e}"));
1393 }
1394
1395 #[test]
1396 fn partial_tls_emits_only_present() {
1397 let config = BridgeProcessConfig::jms(
1398 PathBuf::from("/tmp/jms-bridge"),
1399 "ssl://localhost:61617".to_string(),
1400 BrokerType::ActiveMq,
1401 None,
1402 None,
1403 1000,
1404 30_000,
1405 30_000,
1406 None,
1407 None,
1408 Some(JmsTlsEnv {
1409 ca_cert_path: Some("/certs/ca.pem".to_string()),
1410 ..JmsTlsEnv::default()
1411 }),
1412 );
1413 assert!(config.env_vars.contains(&(
1414 BRIDGE_BROKER_TLS_CA_CERT_PATH.to_string(),
1415 "/certs/ca.pem".to_string()
1416 )));
1417 for key in [
1418 BRIDGE_BROKER_TLS_CLIENT_CERT_PATH,
1419 BRIDGE_BROKER_TLS_CLIENT_KEY_PATH,
1420 BRIDGE_BROKER_TLS_SERVER_NAME,
1421 ] {
1422 assert!(
1423 !config.env_vars.iter().any(|(name, _)| name == key),
1424 "absent {key} must not be injected"
1425 );
1426 assert!(
1427 config.env_remove_keys.contains(&key.to_string()),
1428 "{key} must still be removed"
1429 );
1430 }
1431 }
1432
1433 #[test]
1434 fn parses_permanent_failure_status_line() {
1435 assert_eq!(
1436 parse_ready_line(
1437 r#"{"status":"permanent-failure","reason":"JMS_ATTEMPT_JOURNAL_DIR is not owner-private"}"#
1438 ),
1439 Some(ReadyLine::PermanentFailure(
1440 "JMS_ATTEMPT_JOURNAL_DIR is not owner-private".into()
1441 ))
1442 );
1443 assert_eq!(
1444 parse_ready_line(r#"{"status":"ready","port":8080}"#),
1445 Some(ReadyLine::Ready(8080))
1446 );
1447 assert_eq!(parse_ready_line("just a log line"), None);
1448 for status in ["ready", "soap-ready"] {
1449 for port in ["0", "65536", "-1", "1.5", "\"123\"", "null"] {
1450 assert_eq!(
1451 parse_ready_line(&format!(r#"{{"status":"{status}","port":{port}}}"#)),
1452 Some(ReadyLine::MalformedReady)
1453 );
1454 }
1455 }
1456 }
1457
1458 #[cfg(unix)]
1459 fn wait_pid_gone(pid: u32) -> bool {
1460 std::process::Command::new("sh").arg("-c").arg(format!(
1461 "i=0; while [ $i -lt 100 ]; do kill -0 {pid} 2>/dev/null || exit 0; case \"$(ps -o stat= -p {pid} 2>/dev/null)\" in Z*) exit 0;; esac; i=$((i+1)); sleep 0.05; done; exit 1"
1462 )).status().map(|s| s.success()).unwrap_or(false)
1463 }
1464
1465 #[cfg(unix)]
1466 #[tokio::test]
1467 async fn eof_with_exit_78_maps_to_permanent() {
1468 let (_dir, script) = fake_bridge_script("exec 1>&-; sleep 0.3; exit 78");
1470 let result = BridgeProcess::start(&fake_bridge_config(&script, false, 150)).await;
1471 assert!(
1472 matches!(result, Err(BridgeError::PermanentFailure(reason)) if reason.contains("78"))
1473 );
1474 }
1475
1476 #[cfg(unix)]
1477 #[tokio::test]
1478 async fn eof_with_other_exit_stays_transient() {
1479 let (_dir, script) = fake_bridge_script("exit 1");
1480 let result = BridgeProcess::start(&fake_bridge_config(&script, false, 5000)).await;
1481 assert!(matches!(result, Err(BridgeError::StdoutClosed)));
1482 }
1483
1484 #[tokio::test]
1485 async fn oversized_ready_line_is_discarded_before_next_announcement() {
1486 let mut output = vec![b' '; 64 * 1024];
1487 output.extend_from_slice(b"{\"status\":\"ready\",\"port\":9999}\n");
1488 output.extend_from_slice(b"{\"status\":\"ready\",\"port\":12345}\n");
1489 let mut reader = tokio::io::BufReader::new(output.as_slice());
1490 assert_eq!(
1491 read_ready_port(&mut reader).await.expect("ready port"),
1492 12345
1493 );
1494 }
1495
1496 #[cfg(unix)]
1497 #[tokio::test]
1498 async fn eof_closed_stdout_child_is_bounded_other_and_killed() {
1499 let dir = tempfile::tempdir().expect("tempdir");
1500 let pidfile = dir.path().join("child.pid");
1501 let (_script_dir, script) = fake_bridge_script(&format!(
1502 "echo $$ > '{}'; exec 1>&-; exec sleep 30",
1503 pidfile.display()
1504 ));
1505 let result = tokio::time::timeout(
1506 std::time::Duration::from_secs(6),
1507 BridgeProcess::start(&fake_bridge_config(&script, false, 5000)),
1508 )
1509 .await
1510 .expect("bounded EOF classification");
1511 assert!(matches!(result, Err(BridgeError::StdoutClosed)));
1512 let pid = std::fs::read_to_string(pidfile)
1513 .expect("pidfile")
1514 .trim()
1515 .parse()
1516 .expect("pid");
1517 assert!(wait_pid_gone(pid), "EOF child must be killed");
1518 }
1519
1520 #[cfg(unix)]
1521 #[tokio::test]
1522 async fn permanent_line_returns_prompt_and_kills_child() {
1523 let dir = tempfile::tempdir().expect("tempdir");
1524 let pidfile = dir.path().join("child.pid");
1525 let (_script_dir, script) = fake_bridge_script(&format!(
1526 "echo $$ > '{}'; echo '{{\"status\":\"permanent-failure\",\"reason\":\"JMS_ATTEMPT_JOURNAL_DIR is not owner-private\"}}'; exec sleep 30",
1527 pidfile.display()
1528 ));
1529 let result = tokio::time::timeout(
1530 std::time::Duration::from_secs(1),
1531 BridgeProcess::start(&fake_bridge_config(&script, true, 300)),
1532 )
1533 .await
1534 .expect("prompt permanent failure");
1535 assert!(
1536 matches!(result, Err(BridgeError::PermanentFailure(reason)) if reason.contains("JMS_ATTEMPT_JOURNAL_DIR"))
1537 );
1538 let pid = std::fs::read_to_string(pidfile)
1539 .expect("pidfile")
1540 .trim()
1541 .parse()
1542 .expect("pid");
1543 assert!(wait_pid_gone(pid), "permanent failure child must be killed");
1544 }
1545
1546 #[cfg(unix)]
1547 #[tokio::test]
1548 async fn startup_cancellation_kills_child() {
1549 let dir = tempfile::tempdir().expect("tempdir");
1550 let pidfile = dir.path().join("child.pid");
1551 let (_script_dir, script) =
1552 fake_bridge_script(&format!("echo $$ > '{}'; exec sleep 30", pidfile.display()));
1553 let config = fake_bridge_config(&script, true, 5000);
1554 let task = tokio::spawn(async move { BridgeProcess::start(&config).await });
1555 let pid = tokio::time::timeout(std::time::Duration::from_secs(2), async {
1556 loop {
1557 if let Ok(text) = std::fs::read_to_string(&pidfile)
1558 && let Ok(pid) = text.trim().parse::<u32>()
1559 {
1560 break pid;
1561 }
1562 tokio::task::yield_now().await;
1563 }
1564 })
1565 .await
1566 .expect("child starts");
1567 task.abort();
1568 assert!(matches!(task.await, Err(error) if error.is_cancelled()));
1569 assert!(wait_pid_gone(pid), "cancelled startup child must be killed");
1570 }
1571
1572 #[test]
1573 fn default_expect_soap_ready_is_false() {
1574 let xml = BridgeProcessConfig::xml(PathBuf::from("/tmp/xml-bridge"), 1000);
1575 assert!(
1576 !xml.expect_soap_ready,
1577 "xml bridge must not expect soap-ready"
1578 );
1579 let jms = BridgeProcessConfig::jms(
1580 PathBuf::from("/tmp/jms-bridge"),
1581 "tcp://localhost:61616".to_string(),
1582 BrokerType::ActiveMq,
1583 None,
1584 None,
1585 1000,
1586 30_000,
1587 30_000,
1588 None,
1589 None,
1590 None,
1591 );
1592 assert!(
1593 !jms.expect_soap_ready,
1594 "jms bridge must not expect soap-ready"
1595 );
1596 let cxf = BridgeProcessConfig::cxf_profiles(PathBuf::from("/tmp/cxf-bridge"), &[], 1000);
1597 assert!(
1598 !cxf.expect_soap_ready,
1599 "a CXF pool without a SOAP bind must not expect soap-ready"
1600 );
1601 }
1602
1603 #[test]
1604 fn cxf_profiles_generates_cxf_profiles_env_var() {
1605 let profiles = vec![
1606 CxfProfileEnvVars {
1607 name: "baleares".to_string(),
1608 wsdl_path: "/a.wsdl".to_string(),
1609 service_name: "Svc".to_string(),
1610 port_name: "Port".to_string(),
1611 address: None,
1612 keystore_path: None,
1613 keystore_password: None,
1614 truststore_path: None,
1615 truststore_password: None,
1616 sig_username: None,
1617 sig_password: None,
1618 enc_username: None,
1619 security_actions_out: None,
1620 security_actions_in: None,
1621 signature_algorithm: None,
1622 signature_digest_algorithm: None,
1623 signature_c14n_algorithm: None,
1624 signature_parts: None,
1625 allowed_signature_algorithms: None,
1626 allowed_digest_algorithms: None,
1627 allow_expired_pinned_certificate: false,
1628 timestamp_ttl_seconds: None,
1629 timestamp_future_tolerance_seconds: None,
1630 fault_status: None,
1631 exposed_soap_headers: vec![],
1632 tls: None,
1633 },
1634 CxfProfileEnvVars {
1635 name: "extremadura".to_string(),
1636 wsdl_path: "/b.wsdl".to_string(),
1637 service_name: "Svc2".to_string(),
1638 port_name: "Port2".to_string(),
1639 address: Some("http://host:9090/ws".to_string()),
1640 keystore_path: Some("/b.jks".to_string()),
1641 keystore_password: Some(Redacted::new("pass".to_string())),
1642 truststore_path: None,
1643 truststore_password: None,
1644 sig_username: Some("cert".to_string()),
1645 sig_password: Some(Redacted::new("sig_pass".to_string())),
1646 enc_username: None,
1647 security_actions_out: Some("Timestamp Signature".to_string()),
1648 security_actions_in: Some("Timestamp Signature".to_string()),
1649 signature_algorithm: None,
1650 signature_digest_algorithm: None,
1651 signature_c14n_algorithm: None,
1652 signature_parts: None,
1653 allowed_signature_algorithms: None,
1654 allowed_digest_algorithms: None,
1655 allow_expired_pinned_certificate: false,
1656 timestamp_ttl_seconds: None,
1657 timestamp_future_tolerance_seconds: None,
1658 fault_status: None,
1659 exposed_soap_headers: vec![],
1660 tls: None,
1661 },
1662 ];
1663
1664 let cfg =
1665 BridgeProcessConfig::cxf_profiles(PathBuf::from("/tmp/cxf-bridge"), &profiles, 15_000);
1666
1667 assert_eq!(cfg.spec.name, "cxf-bridge");
1668 assert!(cfg.broker_url.is_empty());
1669 assert_eq!(cfg.broker_type, BrokerType::Generic);
1670 assert!(cfg.username.is_none());
1671 assert!(cfg.password.is_none());
1672
1673 let profiles_var = cfg
1675 .env_vars
1676 .iter()
1677 .find(|(k, _)| k == "CXF_PROFILES")
1678 .expect("CXF_PROFILES env var must exist");
1679 assert_eq!(profiles_var.1, "baleares,extremadura");
1680
1681 assert!(
1683 cfg.env_vars
1684 .iter()
1685 .any(|(k, v)| k == "CXF_PROFILE_BALEARES_WSDL_PATH" && v == "/a.wsdl")
1686 );
1687 assert!(
1688 cfg.env_vars
1689 .iter()
1690 .any(|(k, v)| k == "CXF_PROFILE_BALEARES_SERVICE_NAME" && v == "Svc")
1691 );
1692 assert!(
1693 cfg.env_vars
1694 .iter()
1695 .any(|(k, v)| k == "CXF_PROFILE_BALEARES_PORT_NAME" && v == "Port")
1696 );
1697 assert!(
1698 !cfg.env_vars
1699 .iter()
1700 .any(|(k, _)| k == "CXF_PROFILE_BALEARES_ADDRESS")
1701 );
1702
1703 assert!(
1705 cfg.env_vars
1706 .iter()
1707 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_WSDL_PATH" && v == "/b.wsdl")
1708 );
1709 assert!(
1710 cfg.env_vars
1711 .iter()
1712 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_ADDRESS" && v == "http://host:9090/ws")
1713 );
1714 assert!(
1715 cfg.env_vars
1716 .iter()
1717 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_KEYSTORE_PATH" && v == "/b.jks")
1718 );
1719 assert!(
1720 cfg.env_vars
1721 .iter()
1722 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_KEYSTORE_PASSWORD" && v == "pass")
1723 );
1724 assert!(
1725 cfg.env_vars
1726 .iter()
1727 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_SIG_USERNAME" && v == "cert")
1728 );
1729 assert!(
1730 cfg.env_vars
1731 .iter()
1732 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_SIG_PASSWORD" && v == "sig_pass")
1733 );
1734 assert!(
1735 cfg.env_vars
1736 .iter()
1737 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_SECURITY_ACTIONS_OUT"
1738 && v == "Timestamp Signature")
1739 );
1740 }
1741
1742 #[test]
1743 fn cxf_profiles_single_profile_no_security() {
1744 let profiles = vec![CxfProfileEnvVars {
1745 name: "test".to_string(),
1746 wsdl_path: "service.wsdl".to_string(),
1747 service_name: "{http://example.com}Service".to_string(),
1748 port_name: "{http://example.com}Port".to_string(),
1749 address: None,
1750 keystore_path: None,
1751 keystore_password: None,
1752 truststore_path: None,
1753 truststore_password: None,
1754 sig_username: None,
1755 sig_password: None,
1756 enc_username: None,
1757 security_actions_out: None,
1758 security_actions_in: None,
1759 signature_algorithm: None,
1760 signature_digest_algorithm: None,
1761 signature_c14n_algorithm: None,
1762 signature_parts: None,
1763 allowed_signature_algorithms: None,
1764 allowed_digest_algorithms: None,
1765 allow_expired_pinned_certificate: false,
1766 timestamp_ttl_seconds: None,
1767 timestamp_future_tolerance_seconds: None,
1768 fault_status: None,
1769 exposed_soap_headers: vec![],
1770 tls: None,
1771 }];
1772
1773 let cfg =
1774 BridgeProcessConfig::cxf_profiles(PathBuf::from("/tmp/cxf-bridge"), &profiles, 15_000);
1775
1776 assert_eq!(cfg.spec.name, "cxf-bridge");
1777 assert_eq!(cfg.env_vars.len(), 4);
1779 assert_eq!(cfg.env_vars[0].0, "CXF_PROFILES");
1780 assert_eq!(cfg.env_vars[0].1, "test");
1781 assert_eq!(cfg.env_vars[1].0, "CXF_PROFILE_TEST_WSDL_PATH");
1782 assert_eq!(cfg.env_vars[1].1, "service.wsdl");
1783 assert_eq!(cfg.env_vars[2].0, "CXF_PROFILE_TEST_SERVICE_NAME");
1784 assert_eq!(cfg.env_vars[2].1, "{http://example.com}Service");
1785 assert_eq!(cfg.env_vars[3].0, "CXF_PROFILE_TEST_PORT_NAME");
1786 assert_eq!(cfg.env_vars[3].1, "{http://example.com}Port");
1787 }
1788
1789 #[test]
1790 fn profile_env_vars_to_env_vars_includes_all_fields() {
1791 let vars = CxfProfileEnvVars {
1792 name: "full".to_string(),
1793 wsdl_path: "/wsdl".to_string(),
1794 service_name: "Svc".to_string(),
1795 port_name: "Port".to_string(),
1796 address: Some("http://host:8080".to_string()),
1797 keystore_path: Some("/ks.jks".to_string()),
1798 keystore_password: Some(Redacted::new("ks_pass".to_string())),
1799 truststore_path: Some("/ts.jks".to_string()),
1800 truststore_password: Some(Redacted::new("ts_pass".to_string())),
1801 sig_username: Some("user".to_string()),
1802 sig_password: Some(Redacted::new("sig_pass".to_string())),
1803 enc_username: None,
1804 security_actions_out: Some("Timestamp Signature".to_string()),
1805 security_actions_in: Some("Timestamp".to_string()),
1806 signature_algorithm: None,
1807 signature_digest_algorithm: None,
1808 signature_c14n_algorithm: None,
1809 signature_parts: None,
1810 allowed_signature_algorithms: Some(
1811 "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256".to_string(),
1812 ),
1813 allowed_digest_algorithms: Some("http://www.w3.org/2001/04/xmlenc#sha256".to_string()),
1814 allow_expired_pinned_certificate: true,
1815 timestamp_ttl_seconds: Some(900),
1816 timestamp_future_tolerance_seconds: Some(120),
1817 fault_status: Some(403),
1818 exposed_soap_headers: vec![],
1819 tls: None,
1820 };
1821
1822 let env = vars.to_env_vars();
1823 assert_eq!(env.len(), 18);
1826
1827 let keys: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
1828 assert!(keys.contains(&"CXF_PROFILE_FULL_WSDL_PATH"));
1829 assert!(keys.contains(&"CXF_PROFILE_FULL_SERVICE_NAME"));
1830 assert!(keys.contains(&"CXF_PROFILE_FULL_PORT_NAME"));
1831 assert!(keys.contains(&"CXF_PROFILE_FULL_ADDRESS"));
1832 assert!(keys.contains(&"CXF_PROFILE_FULL_KEYSTORE_PATH"));
1833 assert!(keys.contains(&"CXF_PROFILE_FULL_KEYSTORE_PASSWORD"));
1834 assert!(keys.contains(&"CXF_PROFILE_FULL_TRUSTSTORE_PATH"));
1835 assert!(keys.contains(&"CXF_PROFILE_FULL_TRUSTSTORE_PASSWORD"));
1836 assert!(keys.contains(&"CXF_PROFILE_FULL_SIG_USERNAME"));
1837 assert!(keys.contains(&"CXF_PROFILE_FULL_SIG_PASSWORD"));
1838 assert!(keys.contains(&"CXF_PROFILE_FULL_SECURITY_ACTIONS_OUT"));
1839 assert!(keys.contains(&"CXF_PROFILE_FULL_SECURITY_ACTIONS_IN"));
1840 assert!(keys.contains(&"CXF_PROFILE_FULL_ALLOWED_SIGNATURE_ALGORITHMS"));
1841 assert!(keys.contains(&"CXF_PROFILE_FULL_ALLOWED_DIGEST_ALGORITHMS"));
1842 let allow_expired = env
1843 .iter()
1844 .find(|(k, _)| k == "CXF_PROFILE_FULL_ALLOW_EXPIRED_PINNED_CERTIFICATE")
1845 .expect("expired-pinned-cert opt-in env var emitted");
1846 assert_eq!(allow_expired.1, "true");
1847 let fault_status = env
1848 .iter()
1849 .find(|(k, _)| k == "CXF_PROFILE_FULL_FAULT_STATUS")
1850 .expect("fault_status env var emitted");
1851 assert_eq!(fault_status.1, "403");
1852 let timestamp_ttl = env
1853 .iter()
1854 .find(|(k, _)| k == "CXF_PROFILE_FULL_TIMESTAMP_TTL_SECONDS")
1855 .expect("timestamp ttl env var emitted");
1856 assert_eq!(timestamp_ttl.1, "900");
1857 let timestamp_future_tolerance = env
1858 .iter()
1859 .find(|(k, _)| k == "CXF_PROFILE_FULL_TIMESTAMP_FUTURE_TOLERANCE_SECONDS")
1860 .expect("timestamp future tolerance env var emitted");
1861 assert_eq!(timestamp_future_tolerance.1, "120");
1862 }
1863
1864 #[test]
1865 fn profile_env_vars_omits_timestamp_window_when_unset() {
1866 let vars = CxfProfileEnvVars {
1867 name: "default".to_string(),
1868 wsdl_path: "/wsdl".to_string(),
1869 service_name: "Svc".to_string(),
1870 port_name: "Port".to_string(),
1871 address: None,
1872 keystore_path: None,
1873 keystore_password: None,
1874 truststore_path: None,
1875 truststore_password: None,
1876 sig_username: None,
1877 sig_password: None,
1878 enc_username: None,
1879 security_actions_out: None,
1880 security_actions_in: None,
1881 signature_algorithm: None,
1882 signature_digest_algorithm: None,
1883 signature_c14n_algorithm: None,
1884 signature_parts: None,
1885 allowed_signature_algorithms: None,
1886 allowed_digest_algorithms: None,
1887 allow_expired_pinned_certificate: false,
1888 timestamp_ttl_seconds: None,
1889 timestamp_future_tolerance_seconds: None,
1890 fault_status: None,
1891 exposed_soap_headers: vec![],
1892 tls: None,
1893 };
1894
1895 let env = vars.to_env_vars();
1896 assert!(
1897 !env.iter()
1898 .any(|(key, _)| key.ends_with("TIMESTAMP_TTL_SECONDS")),
1899 "unset ttl must not be emitted: {env:?}"
1900 );
1901 assert!(
1902 !env.iter()
1903 .any(|(key, _)| key.ends_with("TIMESTAMP_FUTURE_TOLERANCE_SECONDS")),
1904 "unset future tolerance must not be emitted: {env:?}"
1905 );
1906 }
1907
1908 #[test]
1909 fn profile_env_vars_emits_u64_max_timestamp_window_unchanged() {
1910 let vars = CxfProfileEnvVars {
1911 name: "max".to_string(),
1912 wsdl_path: "/wsdl".to_string(),
1913 service_name: "Svc".to_string(),
1914 port_name: "Port".to_string(),
1915 address: None,
1916 keystore_path: None,
1917 keystore_password: None,
1918 truststore_path: None,
1919 truststore_password: None,
1920 sig_username: None,
1921 sig_password: None,
1922 enc_username: None,
1923 security_actions_out: None,
1924 security_actions_in: None,
1925 signature_algorithm: None,
1926 signature_digest_algorithm: None,
1927 signature_c14n_algorithm: None,
1928 signature_parts: None,
1929 allowed_signature_algorithms: None,
1930 allowed_digest_algorithms: None,
1931 allow_expired_pinned_certificate: false,
1932 timestamp_ttl_seconds: Some(u64::MAX),
1933 timestamp_future_tolerance_seconds: Some(u64::MAX),
1934 fault_status: None,
1935 exposed_soap_headers: vec![],
1936 tls: None,
1937 };
1938
1939 let env = vars.to_env_vars();
1940 let ttl = env
1941 .iter()
1942 .find(|(k, _)| k == "CXF_PROFILE_MAX_TIMESTAMP_TTL_SECONDS")
1943 .expect("timestamp ttl env var emitted");
1944 assert_eq!(ttl.1, "18446744073709551615");
1945 let future = env
1946 .iter()
1947 .find(|(k, _)| k == "CXF_PROFILE_MAX_TIMESTAMP_FUTURE_TOLERANCE_SECONDS")
1948 .expect("timestamp future tolerance env var emitted");
1949 assert_eq!(future.1, "18446744073709551615");
1950 }
1951
1952 #[test]
1953 fn profile_env_vars_omits_allow_expired_pinned_certificate_when_false() {
1954 let vars = CxfProfileEnvVars {
1955 name: "default".to_string(),
1956 wsdl_path: "/wsdl".to_string(),
1957 service_name: "Svc".to_string(),
1958 port_name: "Port".to_string(),
1959 address: None,
1960 keystore_path: None,
1961 keystore_password: None,
1962 truststore_path: None,
1963 truststore_password: None,
1964 sig_username: None,
1965 sig_password: None,
1966 enc_username: None,
1967 security_actions_out: None,
1968 security_actions_in: None,
1969 signature_algorithm: None,
1970 signature_digest_algorithm: None,
1971 signature_c14n_algorithm: None,
1972 signature_parts: None,
1973 allowed_signature_algorithms: None,
1974 allowed_digest_algorithms: None,
1975 allow_expired_pinned_certificate: false,
1976 timestamp_ttl_seconds: None,
1977 timestamp_future_tolerance_seconds: None,
1978 fault_status: None,
1979 exposed_soap_headers: vec![],
1980 tls: None,
1981 };
1982
1983 let env = vars.to_env_vars();
1984 assert!(
1985 !env.iter()
1986 .any(|(key, _)| key.ends_with("ALLOW_EXPIRED_PINNED_CERTIFICATE")),
1987 "default-off must not emit the opt-in: {env:?}"
1988 );
1989 }
1990
1991 #[test]
1992 fn cxf_profile_env_var_emits_exposed_soap_headers() {
1993 const FIRST: &str = "{http://tempuri.org/commonSchema}requestHeaderType";
1994 const SECOND: &str = "{http://tempuri.org/resources}RequestHeaderElement";
1995
1996 let mut vars = CxfProfileEnvVars {
1997 name: "partner".to_string(),
1998 wsdl_path: "/wsdl".to_string(),
1999 service_name: "Svc".to_string(),
2000 port_name: "Port".to_string(),
2001 address: None,
2002 keystore_path: None,
2003 keystore_password: None,
2004 truststore_path: None,
2005 truststore_password: None,
2006 sig_username: None,
2007 sig_password: None,
2008 enc_username: None,
2009 security_actions_out: None,
2010 security_actions_in: None,
2011 signature_algorithm: None,
2012 signature_digest_algorithm: None,
2013 signature_c14n_algorithm: None,
2014 signature_parts: None,
2015 allowed_signature_algorithms: None,
2016 allowed_digest_algorithms: None,
2017 allow_expired_pinned_certificate: false,
2018 fault_status: None,
2019 timestamp_ttl_seconds: None,
2020 timestamp_future_tolerance_seconds: None,
2021 exposed_soap_headers: vec![FIRST.to_string(), SECOND.to_string()],
2022 tls: None,
2023 };
2024
2025 let env = vars.to_env_vars();
2026 let (_, value) = env
2027 .iter()
2028 .find(|(key, _)| key == "CXF_PROFILE_PARTNER_EXPOSED_SOAP_HEADERS")
2029 .expect("exposed header allow-list env var emitted");
2030 assert_eq!(value, &format!("{FIRST},{SECOND}"));
2031
2032 vars.exposed_soap_headers.clear();
2033 let env = vars.to_env_vars();
2034 assert!(
2035 !env.iter()
2036 .any(|(key, _)| key.ends_with("EXPOSED_SOAP_HEADERS")),
2037 "empty allow-list must not emit the env var: {env:?}"
2038 );
2039 }
2040
2041 #[test]
2042 fn test_start_timeout_zero_rejected() {
2043 let config = BridgeProcessConfig::jms(
2044 PathBuf::from("/usr/bin/echo"),
2045 "tcp://localhost:61616".to_string(),
2046 BrokerType::ActiveMq,
2047 None,
2048 None,
2049 0,
2050 30_000,
2051 30_000,
2052 None,
2053 None,
2054 None,
2055 );
2056 let result = config.validate();
2057 assert!(result.is_err());
2058 }
2059
2060 #[test]
2061 fn test_bridge_rejects_zero_start_timeout() {
2062 let config = BridgeProcessConfig::jms(
2063 PathBuf::from("/usr/bin/echo"),
2064 "tcp://localhost:61616".to_string(),
2065 BrokerType::ActiveMq,
2066 None,
2067 None,
2068 0,
2069 30_000,
2070 30_000,
2071 None,
2072 None,
2073 None,
2074 );
2075 assert!(config.validate().is_err());
2076 }
2077
2078 #[tokio::test]
2079 async fn test_bridge_stop_completes() {
2080 use tokio::time::{Duration, timeout};
2081
2082 let tls = crate::tls::BridgeTlsMaterial::generate().expect("test tls");
2083
2084 #[cfg(unix)]
2085 let bridge = {
2086 let guard = GroupGuard::establish().expect("establish guard");
2087 let mut command = tokio::process::Command::new("sh");
2088 command
2089 .arg("-c")
2090 .arg("trap '' TERM; while true; do echo tick; sleep 1; done")
2091 .stdin(std::process::Stdio::null())
2092 .stdout(std::process::Stdio::null())
2093 .stderr(std::process::Stdio::null());
2094 guard.spawn_bridge(command).expect("spawn test child");
2095 BridgeProcess {
2096 guard,
2097 grpc_port: 0,
2098 soap_port: None,
2099 tls,
2100 token: CancellationToken::new(),
2101 handle: None,
2102 stderr_token: CancellationToken::new(),
2103 stderr_handle: None,
2104 }
2105 };
2106
2107 #[cfg(not(unix))]
2108 let bridge = {
2109 let child = tokio::process::Command::new("sh")
2110 .arg("-c")
2111 .arg("trap '' TERM; while true; do echo tick; sleep 1; done")
2112 .stdout(std::process::Stdio::null())
2113 .spawn()
2114 .expect("must spawn test child process");
2115 BridgeProcess {
2116 child,
2117 grpc_port: 0,
2118 soap_port: None,
2119 tls,
2120 token: CancellationToken::new(),
2121 handle: None,
2122 stderr_token: CancellationToken::new(),
2123 stderr_handle: None,
2124 }
2125 };
2126
2127 let result = timeout(Duration::from_secs(10), bridge.stop()).await;
2128 assert!(result.is_ok(), "stop() must complete within 10s");
2129 }
2130
2131 #[test]
2134 fn redacted_debug_displays_redacted() {
2135 let r = Redacted::new("secret_password".to_string());
2136 assert_eq!(format!("{r:?}"), "[REDACTED]");
2137 }
2138
2139 #[test]
2140 fn redacted_display_displays_redacted() {
2141 let r = Redacted::new("secret_password".to_string());
2142 assert_eq!(format!("{r}"), "[REDACTED]");
2143 }
2144
2145 #[test]
2146 fn redacted_deref_gives_inner_value() {
2147 let r = Redacted::new("secret".to_string());
2148 assert_eq!(&*r, "secret");
2149 }
2150
2151 #[test]
2152 fn redacted_into_inner_returns_value() {
2153 let r = Redacted::new("secret".to_string());
2154 assert_eq!(r.into_inner(), "secret");
2155 }
2156
2157 #[test]
2158 fn redacted_clone_works() {
2159 let r = Redacted::new("secret".to_string());
2160 let c = r.clone();
2161 assert_eq!(&*c, "secret");
2162 assert_eq!(format!("{c:?}"), "[REDACTED]");
2163 }
2164
2165 #[test]
2166 fn bridge_process_config_debug_redacts_password() {
2167 let cfg = BridgeProcessConfig::jms(
2168 PathBuf::from("/tmp/jms-bridge"),
2169 "tcp://localhost:61616".to_string(),
2170 BrokerType::ActiveMq,
2171 Some("user".to_string()),
2172 Some(Redacted::new("super_secret".to_string())),
2173 1000,
2174 30_000,
2175 30_000,
2176 None,
2177 None,
2178 None,
2179 );
2180 let password_debug = format!("{:?}", cfg.password); assert!(
2185 !password_debug.contains("super_secret"),
2186 "Password field must not leak in Debug: {password_debug}"
2187 );
2188 assert_eq!(
2189 password_debug, "Some([REDACTED])",
2190 "Password field must show [REDACTED]: {password_debug}"
2191 );
2192 }
2193
2194 #[test]
2195 fn bridge_process_config_debug_redacts_broker_url_and_env_vars() {
2196 let cfg = BridgeProcessConfig {
2197 spec: &JMS_BRIDGE,
2198 binary_path: PathBuf::from("/tmp/jms-bridge"),
2199 broker_url: "amqp://u:SENTINEL-BRIDGE-PASS@host:5672".to_string(),
2200 broker_type: BrokerType::ActiveMq,
2201 username: Some("user".to_string()),
2202 password: Some(Redacted::new("pass".to_string())),
2203 start_timeout_ms: 1000,
2204 env_vars: vec![(
2205 "KEYSTORE_PASSWORD".to_string(),
2206 "SENTINEL-ENV-PASS".to_string(),
2207 )],
2208 env_remove_keys: vec![],
2209 expect_soap_ready: false,
2210 };
2211 let debug_output = format!("{cfg:?}");
2212 assert!(
2213 !debug_output.contains("SENTINEL-BRIDGE-PASS"),
2214 "broker_url must be redacted in Debug: {debug_output}"
2215 );
2216 assert!(
2217 !debug_output.contains("SENTINEL-ENV-PASS"),
2218 "env_vars values must be redacted in Debug: {debug_output}"
2219 );
2220 assert!(
2221 debug_output.contains("[REDACTED; 1 entries]"),
2222 "env_vars must show redacted count: {debug_output}"
2223 );
2224 }
2225
2226 #[test]
2227 fn bridge_process_config_debug_redacts_all_sensitive_fields() {
2228 let cfg = BridgeProcessConfig {
2229 spec: &JMS_BRIDGE,
2230 binary_path: PathBuf::from("/tmp/jms-bridge"),
2231 broker_url: "amqp://SENTINEL-BROKER-URL@host:5672".to_string(),
2232 broker_type: BrokerType::ActiveMq,
2233 username: Some("SENTINEL-USERNAME".to_string()),
2234 password: Some(Redacted::new("SENTINEL-PASSWORD".to_string())),
2235 start_timeout_ms: 1000,
2236 env_vars: vec![(
2237 "KEYSTORE_PASSWORD".to_string(),
2238 "SENTINEL-ENV-PASS".to_string(),
2239 )],
2240 env_remove_keys: vec![],
2241 expect_soap_ready: false,
2242 };
2243 let debug_output = format!("{cfg:?}");
2244 assert!(
2245 !debug_output.contains("SENTINEL-BROKER-URL")
2246 && !debug_output.contains("SENTINEL-USERNAME")
2247 && !debug_output.contains("SENTINEL-PASSWORD")
2248 && !debug_output.contains("SENTINEL-ENV-PASS"),
2249 "No sensitive field may leak in Debug: {debug_output}"
2250 );
2251 assert!(
2252 debug_output.contains("username: Some(\"[REDACTED]\")"),
2253 "username must be masked in Debug: {debug_output}"
2254 );
2255 }
2256
2257 #[test]
2258 fn cxf_profile_debug_redacts_passwords() {
2259 let profile = CxfProfileEnvVars {
2260 name: "test".to_string(),
2261 wsdl_path: "/a.wsdl".to_string(),
2262 service_name: "Svc".to_string(),
2263 port_name: "Port".to_string(),
2264 address: None,
2265 keystore_path: None,
2266 keystore_password: Some(Redacted::new("ks_secret_val".to_string())),
2267 truststore_path: None,
2268 truststore_password: Some(Redacted::new("ts_secret_val".to_string())),
2269 sig_username: None,
2270 sig_password: Some(Redacted::new("sig_secret_val".to_string())),
2271 enc_username: None,
2272 security_actions_out: None,
2273 security_actions_in: None,
2274 signature_algorithm: None,
2275 signature_digest_algorithm: None,
2276 signature_c14n_algorithm: None,
2277 signature_parts: None,
2278 allowed_signature_algorithms: None,
2279 allowed_digest_algorithms: None,
2280 allow_expired_pinned_certificate: false,
2281 timestamp_ttl_seconds: None,
2282 timestamp_future_tolerance_seconds: None,
2283 fault_status: None,
2284 exposed_soap_headers: vec![],
2285 tls: None,
2286 };
2287 let debug_output = format!("{profile:?}");
2288 assert!(
2289 !debug_output.contains("ks_secret_val")
2290 && !debug_output.contains("ts_secret_val")
2291 && !debug_output.contains("sig_secret_val"),
2292 "Debug must not contain passwords: {debug_output}"
2293 );
2294 }
2295
2296 fn fake_bridge_script(body: &str) -> (tempfile::TempDir, PathBuf) {
2302 let dir = tempfile::tempdir().expect("fake bridge tempdir");
2303 let path = dir.path().join("fake-bridge.sh");
2304 std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).expect("write fake bridge script");
2305 #[cfg(unix)]
2306 {
2307 use std::os::unix::fs::PermissionsExt;
2308 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
2309 .expect("chmod fake bridge script");
2310 }
2311 (dir, path)
2312 }
2313
2314 fn fake_bridge_config(
2315 script: &std::path::Path,
2316 expect_soap_ready: bool,
2317 start_timeout_ms: u64,
2318 ) -> BridgeProcessConfig {
2319 let mut config =
2320 BridgeProcessConfig::cxf_profiles(script.to_path_buf(), &[], start_timeout_ms);
2321 config.expect_soap_ready = expect_soap_ready;
2322 config
2323 }
2324
2325 #[tokio::test]
2328 async fn process_dual_ready_reordered_is_accepted() {
2329 use std::time::{Duration, Instant};
2330
2331 for (label, body, expect_soap_ready, want_soap) in [
2332 (
2333 "soap-ready before ready (expect=true)",
2334 r#"echo '{"status":"soap-ready","port":23456}'; echo '{"status":"ready","port":12345}'"#,
2335 true,
2336 Some(23456u16),
2337 ),
2338 (
2339 "ready before soap-ready (expect=true)",
2340 r#"echo '{"status":"ready","port":12345}'; echo '{"status":"soap-ready","port":23456}'"#,
2341 true,
2342 Some(23456u16),
2343 ),
2344 (
2345 "soap-ready before ready (expect=false still stores the valid soap port)",
2346 r#"echo '{"status":"soap-ready","port":23456}'; echo '{"status":"ready","port":12345}'"#,
2347 false,
2348 Some(23456u16),
2349 ),
2350 ] {
2351 let (_dir, script) = fake_bridge_script(body);
2352 let config = fake_bridge_config(&script, expect_soap_ready, 10_000);
2353 let started = Instant::now();
2354 let process = BridgeProcess::start(&config).await.unwrap_or_else(|e| {
2355 panic!("{label}: dual ready lines in any order must start: {e}")
2356 });
2357 let elapsed = started.elapsed();
2358 assert!(
2359 elapsed < Duration::from_millis(10_000),
2360 "{label}: start must complete inside one config budget, took {elapsed:?}"
2361 );
2362 assert_eq!(process.grpc_port(), 12345, "{label}: gRPC port stored");
2363 assert_eq!(process.soap_port(), want_soap, "{label}: soap port stored");
2364 process
2365 .stop()
2366 .await
2367 .unwrap_or_else(|e| panic!("{label}: fake bridge must stop cleanly: {e}"));
2368 }
2369 }
2370
2371 #[tokio::test]
2381 async fn partial_ready_is_bounded_and_completes_in_one_timeout() {
2382 use std::time::{Duration, Instant};
2383
2384 for (label, body_template, budget_ms, expect_ok, max_elapsed_ms) in [
2385 (
2386 "partial soap-ready then ready completes within ONE budget",
2387 r#"echo '{"status":"soap-ready","port":23456}'; sleep 1; echo '{"status":"ready","port":12345}'"#,
2388 6_000u64,
2389 true,
2390 6_000u64,
2391 ),
2392 (
2393 "counterexample: TOTAL budget exceeded fires StartTimeout — no per-line reset",
2394 r#"sleep 2.5; echo '{"status":"soap-ready","port":23456}'; touch '{marker}'; sleep 2.5; echo '{"status":"ready","port":12345}'"#,
2395 4_000u64,
2396 false,
2397 5_000u64,
2398 ),
2399 ] {
2400 let marker_dir = tempfile::tempdir().expect("marker tempdir");
2404 let marker = marker_dir.path().join("soap-ready-emitted");
2405 let body = body_template.replace("{marker}", &marker.display().to_string());
2406 let (_dir, script) = fake_bridge_script(&body);
2407 let config = fake_bridge_config(&script, true, budget_ms);
2408 let started = Instant::now();
2409 let result = BridgeProcess::start(&config).await;
2410 let elapsed = started.elapsed();
2411 assert!(
2412 elapsed < Duration::from_millis(max_elapsed_ms),
2413 "{label}: must resolve in {max_elapsed_ms}ms (one enclosing budget), took {elapsed:?}"
2414 );
2415 if expect_ok {
2416 let process =
2417 result.unwrap_or_else(|e| panic!("{label}: start must complete: {e}"));
2418 assert_eq!(process.grpc_port(), 12345, "{label}: gRPC port");
2419 assert_eq!(process.soap_port(), Some(23456), "{label}: soap port");
2420 process
2421 .stop()
2422 .await
2423 .unwrap_or_else(|e| panic!("{label}: fake bridge must stop cleanly: {e}"));
2424 } else {
2425 match result {
2426 Err(BridgeError::Timeout(_)) => {}
2427 Err(e) => panic!("{label}: expected StartTimeout, got {e}"),
2428 Ok(_) => panic!(
2429 "{label}: start must hit the single enclosing budget instead of \
2430 waiting out per-line resets"
2431 ),
2432 }
2433 assert!(
2434 marker.exists(),
2435 "{label}: the partial soap-ready line must have been emitted before \
2436 the deadline — the timeout comes from the ENCLOSING budget, not \
2437 from a missing partial line"
2438 );
2439 }
2440 }
2441 }
2442
2443 #[tokio::test]
2448 async fn malformed_soap_ready_fails_startup() {
2449 let malformed = [
2450 (
2451 "non-numeric port",
2452 r#"{"status":"soap-ready","port":"abc"}"#,
2453 ),
2454 ("missing port", r#"{"status":"soap-ready"}"#),
2455 (
2456 "port above u16 range",
2457 r#"{"status":"soap-ready","port":70000}"#,
2458 ),
2459 ("port zero", r#"{"status":"soap-ready","port":0}"#),
2460 ];
2461 for (kind, soap_line) in malformed {
2462 for (order, body) in [
2463 (
2464 "soap-ready first",
2465 format!("echo '{soap_line}'; echo '{{\"status\":\"ready\",\"port\":12345}}'"),
2466 ),
2467 (
2468 "ready first",
2469 format!("echo '{{\"status\":\"ready\",\"port\":12345}}'; echo '{soap_line}'"),
2470 ),
2471 ] {
2472 let label = format!("{kind}, {order}");
2473 let (_dir, script) = fake_bridge_script(&body);
2474 let config = fake_bridge_config(&script, true, 5_000);
2475 match BridgeProcess::start(&config).await {
2476 Err(BridgeError::BadReadyMessage(_)) => {}
2477 Err(e) => panic!("{label}: expected BadReadyMessage, got {e}"),
2478 Ok(_) => panic!("{label}: malformed soap-ready must fail startup"),
2479 }
2480 }
2481 }
2482 }
2483
2484 #[tokio::test]
2489 async fn no_soap_wait_when_unbound() {
2490 use std::time::{Duration, Instant};
2491
2492 let (_dir, script) =
2495 fake_bridge_script(r#"echo '{"status":"ready","port":12345}'; exec tail -f /dev/null"#);
2496 let config = fake_bridge_config(&script, false, 5_000);
2497 let started = Instant::now();
2498 let process = BridgeProcess::start(&config)
2499 .await
2500 .expect("start must return on the gRPC ready line alone");
2501 let elapsed = started.elapsed();
2502 assert!(
2503 elapsed < Duration::from_millis(2_500),
2504 "expect_soap_ready=false must not wait for a soap-ready line, took {elapsed:?}"
2505 );
2506 assert_eq!(process.grpc_port(), 12345);
2507 assert_eq!(
2508 process.soap_port(),
2509 None,
2510 "no soap-ready line → soap_port stays None"
2511 );
2512 process.stop().await.expect("fake bridge must stop cleanly");
2513 }
2514
2515 async fn spawn_child_with_stdout(script: &str) -> tokio::process::Child {
2525 use tokio::process::Command;
2526
2527 Command::new("sh")
2528 .arg("-c")
2529 .arg(script)
2530 .stdin(std::process::Stdio::null())
2531 .stdout(std::process::Stdio::piped())
2532 .stderr(std::process::Stdio::null())
2533 .spawn()
2534 .expect("must spawn test child")
2535 }
2536
2537 #[tokio::test]
2538 async fn drain_chatty_child_no_deadlock() {
2539 let mut child =
2541 spawn_child_with_stdout("for i in $(seq 1 500); do echo \"line $i\"; done").await;
2542
2543 let stdout = child.stdout.take().expect("stdout piped");
2544 let reader = tokio::io::BufReader::new(stdout);
2545 let token = CancellationToken::new();
2546
2547 let handle = tokio::spawn(drain_child_output(reader, token.clone(), "stdout"));
2549
2550 let status = child.wait().await.expect("wait for child"); assert!(status.success(), "child should exit successfully");
2553
2554 let result = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
2556 assert!(result.is_ok(), "drain task should complete within 2s");
2557 }
2558
2559 #[tokio::test]
2560 async fn drain_oversized_line_bounded() {
2561 use std::time::Duration;
2562
2563 let oversized_bytes = 200 * 1024; let mut child = spawn_child_with_stdout(&format!(
2573 "head -c {oversized_bytes} /dev/zero | tr '\\0' 'A'; echo"
2574 ))
2575 .await;
2576
2577 let stdout = child.stdout.take().expect("stdout piped");
2578 let reader = tokio::io::BufReader::new(stdout);
2579 let token = CancellationToken::new();
2580
2581 let handle = tokio::spawn(drain_child_output(reader, token.clone(), "stdout"));
2582
2583 let status = tokio::time::timeout(Duration::from_secs(30), child.wait())
2584 .await
2585 .expect("child exit within 30s: pipe-drain stall suspected")
2586 .expect("wait for child");
2587 assert!(status.success(), "child should exit successfully");
2588
2589 let result = tokio::time::timeout(std::time::Duration::from_millis(500), handle).await;
2593 assert!(result.is_ok(), "drain task should complete within 500ms");
2594 }
2595
2596 #[tokio::test]
2597 async fn drain_cancellation_exits_promptly() {
2598 let mut child = spawn_child_with_stdout("while true; do echo tick; sleep 1; done").await;
2600
2601 let stdout = child.stdout.take().expect("stdout piped");
2602 let reader = tokio::io::BufReader::new(stdout);
2603 let token = CancellationToken::new();
2604
2605 let handle = tokio::spawn(drain_child_output(reader, token.clone(), "stdout"));
2607
2608 token.cancel();
2611 let result = tokio::time::timeout(std::time::Duration::from_millis(500), handle).await;
2612 assert!(
2613 result.is_ok(),
2614 "drain task should exit promptly after cancellation"
2615 );
2616
2617 let _ = child.kill().await;
2619 }
2620
2621 #[tokio::test]
2629 async fn drain_handoff_preserves_trailing_bytes() {
2630 let mut child = spawn_child_with_stdout(
2632 r#"echo '{"status":"ready","port":12345}'; echo "trailing-1"; echo "trailing-2"; echo "trailing-3""#,
2633 )
2634 .await;
2635
2636 let stdout = child.stdout.take().expect("stdout piped");
2637 let mut reader = tokio::io::BufReader::new(stdout);
2638
2639 assert_eq!(
2641 read_ready_port(&mut reader).await.expect("ready port"),
2642 12345
2643 );
2644
2645 let token = CancellationToken::new();
2647 let handle = tokio::spawn(drain_child_output(reader, token.clone(), "stdout"));
2648
2649 let status = tokio::time::timeout(std::time::Duration::from_secs(5), child.wait())
2652 .await
2653 .expect("child exits within 5s")
2654 .expect("wait for child");
2655 assert!(status.success(), "child should exit successfully");
2656
2657 let result = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
2659 assert!(
2660 result.is_ok(),
2661 "drain task should complete — trailing bytes preserved through handoff"
2662 );
2663 }
2664}