1use std::fmt;
2use std::ops::Deref;
3use std::path::PathBuf;
4use thiserror::Error;
5use tokio::task::JoinHandle;
6use tokio_util::sync::CancellationToken;
7
8use crate::spec::{BridgeSpec, CXF_BRIDGE, JMS_BRIDGE, XML_BRIDGE};
9
10#[derive(Clone)]
17pub struct Redacted<T>(T);
18
19impl<T> Redacted<T> {
20 pub fn new(value: T) -> Self {
21 Self(value)
22 }
23
24 pub fn into_inner(self) -> T {
25 self.0
26 }
27}
28
29impl<T> fmt::Debug for Redacted<T> {
30 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31 write!(f, "[REDACTED]")
32 }
33}
34
35impl<T> fmt::Display for Redacted<T> {
36 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37 write!(f, "[REDACTED]")
38 }
39}
40
41impl<T> Deref for Redacted<T> {
42 type Target = T;
43
44 fn deref(&self) -> &Self::Target {
45 &self.0
46 }
47}
48
49#[derive(Debug, Error)]
50#[non_exhaustive]
51pub enum BridgeError {
52 #[error("IO error: {0}")]
53 Io(#[from] std::io::Error),
54 #[error("Bridge timed out: {0}")]
55 Timeout(String),
56 #[error("Bridge stdout closed before ready message")]
57 StdoutClosed,
58 #[error("Bridge ready message malformed: {0}")]
59 BadReadyMessage(String),
60 #[error("Download failed: {0}")]
61 Download(String),
62 #[error("Checksum mismatch: expected {expected}, got {actual}")]
63 ChecksumMismatch { expected: String, actual: String },
64 #[error("URL not allowed: {0}")]
65 UrlNotAllowed(String),
66 #[error("Transport error: {0}")]
67 Transport(String),
68 #[error("Config error: {0}")]
69 Config(String),
70 #[error("Bridge permanent failure: {0}")]
74 PermanentFailure(String),
75 #[error(
76 "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"
77 )]
78 CacheUnwritable {
79 path: std::path::PathBuf,
80 source: std::io::Error,
81 },
82}
83
84#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
85#[serde(rename_all = "lowercase")]
86pub enum BrokerType {
87 #[serde(alias = "active_mq")]
88 ActiveMq,
89 Artemis,
90 Generic,
91}
92
93impl BrokerType {
94 pub fn as_env_str(&self) -> &'static str {
95 match self {
96 BrokerType::ActiveMq => "activemq",
97 BrokerType::Artemis => "artemis",
98 BrokerType::Generic => "generic",
99 }
100 }
101}
102
103impl std::str::FromStr for BrokerType {
104 type Err = BridgeError;
105
106 fn from_str(s: &str) -> Result<Self, Self::Err> {
107 match s.to_lowercase().as_str() {
108 "activemq" => Ok(BrokerType::ActiveMq),
109 "artemis" => Ok(BrokerType::Artemis),
110 "generic" => Ok(BrokerType::Generic),
111 other => Err(BridgeError::Config(format!("unknown broker type: {other}"))), }
113 }
114}
115
116#[derive(Debug, Default)]
120pub struct CxfTlsEnvVars {
121 pub keystore_path: Option<String>,
122 pub keystore_password: Option<Redacted<String>>,
123 pub keystore_type: Option<String>,
124 pub truststore_path: Option<String>,
125 pub truststore_password: Option<Redacted<String>>,
126 pub truststore_type: Option<String>,
127}
128
129impl CxfTlsEnvVars {
130 pub fn to_env_vars(&self, prefix: &str) -> Vec<(String, String)> {
133 let mut vars = Vec::new();
134 if let Some(ref v) = self.keystore_path {
135 vars.push((format!("{prefix}TLS_KEYSTORE_PATH"), v.clone()));
136 }
137 if let Some(ref v) = self.keystore_password {
138 vars.push((format!("{prefix}TLS_KEYSTORE_PASSWORD"), (**v).clone()));
139 }
140 if let Some(ref v) = self.keystore_type {
141 vars.push((format!("{prefix}TLS_KEYSTORE_TYPE"), v.clone()));
142 }
143 if let Some(ref v) = self.truststore_path {
144 vars.push((format!("{prefix}TLS_TRUSTSTORE_PATH"), v.clone()));
145 }
146 if let Some(ref v) = self.truststore_password {
147 vars.push((format!("{prefix}TLS_TRUSTSTORE_PASSWORD"), (**v).clone()));
148 }
149 if let Some(ref v) = self.truststore_type {
150 vars.push((format!("{prefix}TLS_TRUSTSTORE_TYPE"), v.clone()));
151 }
152 vars
153 }
154}
155
156#[derive(Debug)]
159pub struct CxfProfileEnvVars {
160 pub name: String,
161 pub wsdl_path: String,
162 pub service_name: String,
163 pub port_name: String,
164 pub address: Option<String>,
165 pub keystore_path: Option<String>,
166 pub keystore_password: Option<Redacted<String>>,
167 pub truststore_path: Option<String>,
168 pub truststore_password: Option<Redacted<String>>,
169 pub sig_username: Option<String>,
170 pub sig_password: Option<Redacted<String>>,
171 pub enc_username: Option<String>,
172 pub security_actions_out: Option<String>,
173 pub security_actions_in: Option<String>,
174 pub signature_algorithm: Option<String>,
175 pub signature_digest_algorithm: Option<String>,
176 pub signature_c14n_algorithm: Option<String>,
177 pub signature_parts: Option<String>,
178 pub fault_status: Option<u16>,
181 pub tls: Option<CxfTlsEnvVars>,
183}
184
185impl CxfProfileEnvVars {
186 pub fn to_env_vars(&self) -> Vec<(String, String)> {
187 let prefix = format!("CXF_PROFILE_{}_", self.name.to_uppercase());
188 let mut vars = vec![
189 (format!("{}WSDL_PATH", prefix), self.wsdl_path.clone()),
190 (format!("{}SERVICE_NAME", prefix), self.service_name.clone()),
191 (format!("{}PORT_NAME", prefix), self.port_name.clone()),
192 ];
193
194 if let Some(ref v) = self.address {
195 vars.push((format!("{}ADDRESS", prefix), v.clone()));
196 }
197 if let Some(ref v) = self.keystore_path {
198 vars.push((format!("{}KEYSTORE_PATH", prefix), v.clone()));
199 }
200 if let Some(ref v) = self.keystore_password {
201 vars.push((format!("{}KEYSTORE_PASSWORD", prefix), (**v).clone()));
202 }
203 if let Some(ref v) = self.truststore_path {
204 vars.push((format!("{}TRUSTSTORE_PATH", prefix), v.clone()));
205 }
206 if let Some(ref v) = self.truststore_password {
207 vars.push((format!("{}TRUSTSTORE_PASSWORD", prefix), (**v).clone()));
208 }
209 if let Some(ref v) = self.sig_username {
210 vars.push((format!("{}SIG_USERNAME", prefix), v.clone()));
211 }
212 if let Some(ref v) = self.sig_password {
213 vars.push((format!("{}SIG_PASSWORD", prefix), (**v).clone()));
214 }
215 if let Some(ref v) = self.enc_username {
216 vars.push((format!("{}ENC_USERNAME", prefix), v.clone()));
217 }
218 if let Some(ref v) = self.security_actions_out {
219 vars.push((format!("{}SECURITY_ACTIONS_OUT", prefix), v.clone()));
220 }
221 if let Some(ref v) = self.security_actions_in {
222 vars.push((format!("{}SECURITY_ACTIONS_IN", prefix), v.clone()));
223 }
224 if let Some(ref v) = self.signature_algorithm {
225 vars.push((format!("{}SIGNATURE_ALGORITHM", prefix), v.clone()));
226 }
227 if let Some(ref v) = self.signature_digest_algorithm {
228 vars.push((format!("{}SIGNATURE_DIGEST_ALGORITHM", prefix), v.clone()));
229 }
230 if let Some(ref v) = self.signature_c14n_algorithm {
231 vars.push((format!("{}SIGNATURE_C14N_ALGORITHM", prefix), v.clone()));
232 }
233 if let Some(ref v) = self.signature_parts {
234 vars.push((format!("{}SIGNATURE_PARTS", prefix), v.clone()));
235 }
236 if let Some(v) = self.fault_status {
237 vars.push((format!("{}FAULT_STATUS", prefix), v.to_string()));
238 }
239 if let Some(ref tls) = self.tls {
240 vars.extend(tls.to_env_vars(&prefix));
241 }
242
243 vars
244 }
245}
246
247pub struct BridgeProcessConfig {
252 pub spec: &'static BridgeSpec,
253 pub binary_path: PathBuf,
254 pub broker_url: String,
255 pub broker_type: BrokerType,
256 pub username: Option<String>,
257 pub password: Option<Redacted<String>>,
258 pub start_timeout_ms: u64,
259 pub env_vars: Vec<(String, String)>,
260 pub expect_soap_ready: bool,
266}
267
268impl fmt::Debug for BridgeProcessConfig {
269 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270 f.debug_struct("BridgeProcessConfig")
271 .field("spec", &self.spec)
272 .field("binary_path", &self.binary_path)
273 .field("broker_url", &"[REDACTED]")
274 .field("broker_type", &self.broker_type)
275 .field("username", &self.username.as_ref().map(|_| "[REDACTED]"))
276 .field("password", &self.password)
277 .field("start_timeout_ms", &self.start_timeout_ms)
278 .field(
279 "env_vars",
280 &format!("[REDACTED; {} entries]", self.env_vars.len()),
281 )
282 .field("expect_soap_ready", &self.expect_soap_ready)
283 .finish()
284 }
285}
286
287impl BridgeProcessConfig {
288 #[allow(clippy::too_many_arguments)]
296 pub fn jms(
297 binary_path: PathBuf,
298 broker_url: String,
299 broker_type: BrokerType,
300 username: Option<String>,
301 password: Option<Redacted<String>>,
302 start_timeout_ms: u64,
303 journal_dir: Option<String>,
304 broker_key: Option<String>,
305 ) -> Self {
306 let mut env_vars = vec![
307 ("BRIDGE_BROKER_URL".to_string(), broker_url.clone()),
308 (
309 "BRIDGE_BROKER_TYPE".to_string(),
310 broker_type.as_env_str().to_string(),
311 ),
312 ];
313 if let Some(u) = &username {
314 env_vars.push(("BRIDGE_USERNAME".to_string(), u.clone()));
315 }
316 if let Some(p) = &password {
317 env_vars.push(("BRIDGE_PASSWORD".to_string(), (**p).clone()));
318 }
319 if let Some(dir) = journal_dir {
320 env_vars.push(("JMS_ATTEMPT_JOURNAL_DIR".to_string(), dir));
321 }
322 if let Some(key) = broker_key {
323 env_vars.push(("JMS_BRIDGE_BROKER_KEY".to_string(), key));
324 }
325 Self {
326 spec: &JMS_BRIDGE,
327 binary_path,
328 broker_url,
329 broker_type,
330 username,
331 password,
332 start_timeout_ms,
333 env_vars,
334 expect_soap_ready: false,
335 }
336 }
337
338 pub fn xml(binary_path: PathBuf, start_timeout_ms: u64) -> Self {
340 Self {
341 spec: &XML_BRIDGE,
342 binary_path,
343 broker_url: String::new(),
344 broker_type: BrokerType::Generic,
345 username: None,
346 password: None,
347 start_timeout_ms,
348 env_vars: vec![],
349 expect_soap_ready: false,
350 }
351 }
352
353 pub fn cxf_profiles(
356 binary_path: PathBuf,
357 profiles: &[CxfProfileEnvVars],
358 start_timeout_ms: u64,
359 ) -> Self {
360 let profile_names: Vec<String> = profiles.iter().map(|p| p.name.clone()).collect();
361 let mut env_vars = vec![("CXF_PROFILES".to_string(), profile_names.join(","))];
362
363 for profile in profiles {
364 env_vars.extend(profile.to_env_vars());
365 }
366
367 Self {
368 spec: &CXF_BRIDGE,
369 binary_path,
370 broker_url: String::new(),
371 broker_type: BrokerType::Generic,
372 username: None,
373 password: None,
374 start_timeout_ms,
375 env_vars,
376 expect_soap_ready: false,
377 }
378 }
379
380 pub fn validate(&self) -> Result<(), String> {
381 if self.start_timeout_ms == 0 {
382 return Err("start_timeout_ms must be > 0".to_string());
383 }
384 Ok(())
385 }
386}
387
388async fn drain_stdout<R: tokio::io::AsyncBufRead + Unpin>(mut reader: R, token: CancellationToken) {
393 use tokio::io::AsyncBufReadExt;
394 use tokio::time::{Duration, Instant};
395
396 const MAX_LINE_BYTES: usize = 64 * 1024;
397 const LOG_INTERVAL: Duration = Duration::from_secs(1);
398 const LOG_BUDGET: u32 = 100;
399
400 let mut line_buf: Vec<u8> = Vec::new();
401 let mut oversized = false;
402 let mut interval_start = Instant::now();
403 let mut logged: u32 = 0;
404 let mut dropped: u32 = 0;
405
406 loop {
407 tokio::select! {
408 biased;
409 _ = token.cancelled() => break,
410 res = reader.fill_buf() => {
411 let chunk_len = match res {
412 Ok(chunk) => {
413 if chunk.is_empty() {
414 break; }
416 for &b in chunk {
417 if b == b'\n' {
418 if oversized {
419 if logged < LOG_BUDGET {
421 tracing::warn!(
422 "bridge stdout: oversized line (>{MAX_LINE_BYTES} bytes), truncated"
423 );
424 logged += 1;
425 } else {
426 dropped += 1;
427 }
428 } else if logged < LOG_BUDGET {
429 tracing::debug!(
431 target: "camel_bridge::child",
432 "{}",
433 String::from_utf8_lossy(&line_buf)
434 );
435 logged += 1;
436 } else {
437 dropped += 1;
438 }
439 line_buf.clear();
440 oversized = false;
441 } else if !oversized {
442 if line_buf.len() < MAX_LINE_BYTES {
443 line_buf.push(b);
444 } else {
445 oversized = true;
446 }
447 }
448 }
449 chunk.len()
450 }
451 Err(_) => break,
452 };
453 reader.consume(chunk_len);
454 if interval_start.elapsed() >= LOG_INTERVAL {
455 if dropped > 0 {
456 tracing::debug!(
458 "bridge stdout: dropped {dropped} lines in last interval"
459 );
460 }
461 interval_start = Instant::now();
462 logged = 0;
463 dropped = 0;
464 }
465 }
466 }
467 }
468}
469
470fn announced_port(value: &serde_json::Value, line: &str) -> Result<u16, BridgeError> {
476 match value.get("port").and_then(|p| p.as_u64()) {
477 Some(p) if (1..=u64::from(u16::MAX)).contains(&p) => Ok(p as u16),
478 _ => {
479 tracing::error!("bridge ready message malformed: {line}");
481 Err(BridgeError::BadReadyMessage(line.to_string()))
482 }
483 }
484}
485
486#[derive(Debug, PartialEq)]
487enum ReadyLine {
488 Ready(u16),
489 SoapReady(u16),
490 PermanentFailure(String),
491 MalformedReady,
492}
493
494fn parse_ready_line(line: &str) -> Option<ReadyLine> {
496 let value: serde_json::Value = serde_json::from_str(line).ok()?;
497 match value.get("status").and_then(|s| s.as_str())? {
498 status @ ("ready" | "soap-ready") => Some(match announced_port(&value, line) {
499 Ok(port) if status == "ready" => ReadyLine::Ready(port),
500 Ok(port) => ReadyLine::SoapReady(port),
501 Err(_) => ReadyLine::MalformedReady,
502 }),
503 "permanent-failure" => Some(ReadyLine::PermanentFailure(
504 value
505 .get("reason")
506 .and_then(|r| r.as_str())
507 .unwrap_or("bridge reported a permanent startup failure")
508 .to_string(),
509 )),
510 _ => None,
511 }
512}
513
514async fn read_ready_ports<R: tokio::io::AsyncBufRead + Unpin>(
517 reader: &mut R,
518 expect_soap_ready: bool,
519) -> Result<(u16, Option<u16>), BridgeError> {
520 use tokio::io::AsyncBufReadExt;
521 const READY_MAX_LINE: usize = 64 * 1024;
522 let mut buf = Vec::new();
523 let mut oversized = false;
524 let mut grpc_port = None;
525 let mut soap_port = None;
526 loop {
527 let chunk = reader.fill_buf().await?;
528 if chunk.is_empty() {
529 return Err(BridgeError::StdoutClosed);
530 }
531 let newline = chunk.iter().position(|&b| b == b'\n');
532 let take = newline.map_or(chunk.len(), |i| i + 1);
533 if !oversized && buf.len() + take <= READY_MAX_LINE {
534 buf.extend_from_slice(&chunk[..take]);
535 } else {
536 oversized = true;
537 }
538 reader.consume(take);
539 if newline.is_some() {
540 if !oversized {
541 let line = String::from_utf8_lossy(&buf);
542 match parse_ready_line(line.trim_end_matches('\n')) {
543 Some(ReadyLine::Ready(port)) => grpc_port = Some(port),
544 Some(ReadyLine::SoapReady(port)) => soap_port = Some(port),
545 Some(ReadyLine::PermanentFailure(reason)) => {
546 return Err(BridgeError::PermanentFailure(reason));
547 }
548 Some(ReadyLine::MalformedReady) => {
549 return Err(BridgeError::BadReadyMessage(line.into_owned()));
550 }
551 None => {}
552 }
553 if let Some(port) = grpc_port
554 && (!expect_soap_ready || soap_port.is_some())
555 {
556 return Ok((port, soap_port));
557 }
558 }
559 buf.clear();
560 oversized = false;
561 }
562 }
563}
564
565#[cfg(test)]
566async fn read_ready_port<R: tokio::io::AsyncBufRead + Unpin>(
567 reader: &mut R,
568) -> Result<u16, BridgeError> {
569 read_ready_ports(reader, false).await.map(|(port, _)| port)
570}
571
572async fn eof_exit_error(child: &mut tokio::process::Child) -> BridgeError {
574 use tokio::time::{Duration, timeout};
575 match timeout(Duration::from_secs(2), child.wait()).await {
576 Ok(Ok(status)) if status.code() == Some(78) => BridgeError::PermanentFailure(
577 "bridge exited with code 78 before announcing readiness".to_string(),
578 ),
579 Ok(_) => BridgeError::StdoutClosed,
580 Err(_) => {
581 let _ = child.start_kill();
582 let _ = timeout(Duration::from_secs(2), child.wait()).await;
583 BridgeError::StdoutClosed
584 }
585 }
586}
587
588pub struct BridgeProcess {
589 child: tokio::process::Child,
590 grpc_port: u16,
591 soap_port: Option<u16>,
595 tls: crate::tls::BridgeTlsMaterial,
596 token: CancellationToken,
597 handle: Option<JoinHandle<()>>,
598}
599
600impl BridgeProcess {
601 pub fn grpc_port(&self) -> u16 {
602 self.grpc_port
603 }
604
605 pub fn soap_port(&self) -> Option<u16> {
607 self.soap_port
608 }
609
610 pub fn child_pid(&self) -> Option<u32> {
612 self.child.id()
613 }
614
615 pub async fn connect(&self) -> Result<tonic::transport::Channel, BridgeError> {
617 crate::channel::connect_channel(self.grpc_port, &self.tls).await
618 }
619
620 pub async fn start_and_connect(
622 config: &BridgeProcessConfig,
623 ) -> Result<(Self, tonic::transport::Channel), BridgeError> {
624 let process = Self::start(config).await?;
625 let channel = process.connect().await?;
626 Ok((process, channel))
627 }
628
629 pub async fn start(config: &BridgeProcessConfig) -> Result<Self, BridgeError> {
641 use tokio::process::Command;
642 use tokio::time::{Duration, timeout};
643
644 config.validate().map_err(BridgeError::Config)?;
645
646 let tls = crate::tls::BridgeTlsMaterial::generate()?;
647
648 let free_port: u16 = 0;
654
655 let stderr_stdio: std::process::Stdio =
657 if let Ok(log_dir) = std::env::var("CAMEL_BRIDGE_LOG_STDERR") {
658 let log_filename = config
659 .spec
660 .log_file_template
661 .replace("{pid}", &std::process::id().to_string());
662 let log_path = if log_dir.is_empty() {
663 format!("/tmp/{log_filename}")
664 } else {
665 format!("{log_dir}/{log_filename}")
666 };
667 match std::fs::File::create(&log_path) {
668 Ok(f) => {
669 eprintln!("[camel-bridge] stderr → {}", log_path);
670 f.into()
671 }
672 Err(e) => {
673 eprintln!(
674 "[camel-bridge] failed to create log file {}: {}",
675 log_path, e
676 );
677 std::process::Stdio::inherit()
678 }
679 }
680 } else {
681 std::process::Stdio::inherit()
682 };
683
684 let mut command = Command::new(&config.binary_path);
685 command
689 .kill_on_drop(true)
692 .env("QUARKUS_HTTP_SSL_PORT", free_port.to_string())
693 .env(
694 "QUARKUS_TLS_BRIDGE_KEY_STORE_PEM_0_CERT",
695 &tls.server_pem_path,
696 )
697 .env(
698 "QUARKUS_TLS_BRIDGE_KEY_STORE_PEM_0_KEY",
699 &tls.server_key_path,
700 )
701 .env("QUARKUS_TLS_BRIDGE_TRUST_STORE_PEM_CERTS", &tls.ca_pem_path)
702 .stdout(std::process::Stdio::piped())
703 .stderr(stderr_stdio);
704
705 for (key, value) in &config.env_vars {
707 command.env(key, value);
708 }
709
710 let mut child = command.spawn()?;
711
712 let stdout = child.stdout.take().ok_or(BridgeError::StdoutClosed)?;
713 let mut reader = tokio::io::BufReader::new(stdout);
714
715 let ready = timeout(
724 Duration::from_millis(config.start_timeout_ms),
725 read_ready_ports(&mut reader, config.expect_soap_ready),
726 )
727 .await;
728 let (port, soap_port) = match ready {
729 Ok(Ok(ports)) => ports,
730 Ok(Err(BridgeError::StdoutClosed)) => {
731 let error = eof_exit_error(&mut child).await;
733 tracing::error!("bridge stdout closed before ready message");
735 return Err(error);
736 }
737 Ok(Err(error)) => return Err(error),
738 Err(_) => {
739 let msg = format!(
740 "{} failed to start: health check timeout after {}ms",
741 config.spec.name, config.start_timeout_ms
742 );
743 tracing::error!("{msg}");
745 return Err(BridgeError::Timeout(msg));
746 }
747 };
748
749 let token = CancellationToken::new();
754 let child_token = token.clone();
755 let handle = tokio::spawn(async move {
756 drain_stdout(reader, child_token).await;
757 });
758
759 Ok(BridgeProcess {
760 child,
761 grpc_port: port,
762 soap_port,
763 tls,
764 token,
765 handle: Some(handle),
766 })
767 }
768
769 pub async fn stop(mut self) -> Result<(), BridgeError> {
771 use tokio::time::{Duration, sleep};
772
773 self.token.cancel();
774
775 if let Some(handle) = self.handle.take() {
776 let join_result = tokio::time::timeout(Duration::from_secs(5), handle).await;
777 if join_result.is_err() {
778 tracing::warn!("bridge stdout drain task did not exit after cancellation");
779 }
780 }
781
782 #[cfg(unix)]
784 {
785 let pid = self.child.id().unwrap_or(0);
786 if pid > 0 {
787 unsafe {
789 libc::kill(pid as i32, libc::SIGTERM);
790 }
791 }
792 }
793
794 #[cfg(not(unix))]
796 let _ = self.child.start_kill();
797
798 tokio::select! {
800 result = self.child.wait() => {
801 result?;
802 }
803 _ = sleep(Duration::from_secs(5)) => {
804 let _ = self.child.start_kill();
805 self.child.wait().await?;
806 }
807 }
808 Ok(())
809 }
810}
811
812impl Drop for BridgeProcess {
813 fn drop(&mut self) {
814 self.token.cancel();
815 let _ = self.child.start_kill();
817 }
818}
819
820#[cfg(test)]
821mod tests {
822 use super::*;
823
824 #[test]
825 fn broker_type_from_str_activemq() {
826 assert_eq!(
827 "activemq".parse::<BrokerType>().unwrap(),
828 BrokerType::ActiveMq
829 );
830 assert_eq!(
831 "ACTIVEMQ".parse::<BrokerType>().unwrap(),
832 BrokerType::ActiveMq
833 );
834 }
835
836 #[test]
837 fn broker_type_from_str_artemis() {
838 assert_eq!(
839 "artemis".parse::<BrokerType>().unwrap(),
840 BrokerType::Artemis
841 );
842 }
843
844 #[test]
845 fn broker_type_from_str_generic() {
846 assert_eq!(
847 "generic".parse::<BrokerType>().unwrap(),
848 BrokerType::Generic
849 );
850 }
851
852 #[test]
853 fn broker_type_from_str_unknown_returns_err() {
854 assert!("ibmmq".parse::<BrokerType>().is_err());
855 assert!("UnknownBroker".parse::<BrokerType>().is_err());
856 }
857
858 #[test]
859 fn broker_type_env_str() {
860 assert_eq!(BrokerType::ActiveMq.as_env_str(), "activemq");
861 assert_eq!(BrokerType::Artemis.as_env_str(), "artemis");
862 assert_eq!(BrokerType::Generic.as_env_str(), "generic");
863 }
864
865 #[test]
866 fn jms_constructor_uses_jms_spec() {
867 let cfg = BridgeProcessConfig::jms(
868 PathBuf::from("/tmp/jms-bridge"),
869 "tcp://localhost:61616".to_string(),
870 BrokerType::ActiveMq,
871 Some("user".to_string()),
872 Some(Redacted::new("pass".to_string())),
873 1000,
874 None,
875 None,
876 );
877 assert_eq!(cfg.spec.name, "jms-bridge");
878 }
879
880 #[test]
881 fn xml_constructor_uses_xml_spec() {
882 let cfg = BridgeProcessConfig::xml(PathBuf::from("/tmp/xml-bridge"), 1000);
883 assert_eq!(cfg.spec.name, "xml-bridge");
884 }
885
886 #[test]
887 fn journal_env_passed_on_launch_and_restart() {
888 for _ in 0..2 {
889 let config = BridgeProcessConfig::jms(
890 PathBuf::from("/tmp/jms-bridge"),
891 "tcp://localhost:61616".into(),
892 BrokerType::ActiveMq,
893 None,
894 None,
895 1000,
896 Some("/var/lib/state/rust-camel/jms-bridge".into()),
897 Some("broker-key".into()),
898 );
899 assert!(config.env_vars.contains(&(
900 "JMS_ATTEMPT_JOURNAL_DIR".into(),
901 "/var/lib/state/rust-camel/jms-bridge".into()
902 )));
903 assert!(
904 config
905 .env_vars
906 .contains(&("JMS_BRIDGE_BROKER_KEY".into(), "broker-key".into()))
907 );
908 }
909 let unset = BridgeProcessConfig::jms(
910 PathBuf::from("/tmp/jms-bridge"),
911 "tcp://localhost:61616".into(),
912 BrokerType::ActiveMq,
913 None,
914 None,
915 1000,
916 None,
917 None,
918 );
919 assert!(
920 !unset
921 .env_vars
922 .iter()
923 .any(|(key, _)| key == "JMS_ATTEMPT_JOURNAL_DIR" || key == "JMS_BRIDGE_BROKER_KEY")
924 );
925 }
926
927 #[test]
928 fn parses_permanent_failure_status_line() {
929 assert_eq!(
930 parse_ready_line(
931 r#"{"status":"permanent-failure","reason":"JMS_ATTEMPT_JOURNAL_DIR is not owner-private"}"#
932 ),
933 Some(ReadyLine::PermanentFailure(
934 "JMS_ATTEMPT_JOURNAL_DIR is not owner-private".into()
935 ))
936 );
937 assert_eq!(
938 parse_ready_line(r#"{"status":"ready","port":8080}"#),
939 Some(ReadyLine::Ready(8080))
940 );
941 assert_eq!(parse_ready_line("just a log line"), None);
942 for status in ["ready", "soap-ready"] {
943 for port in ["0", "65536", "-1", "1.5", "\"123\"", "null"] {
944 assert_eq!(
945 parse_ready_line(&format!(r#"{{"status":"{status}","port":{port}}}"#)),
946 Some(ReadyLine::MalformedReady)
947 );
948 }
949 }
950 }
951
952 #[cfg(unix)]
953 fn wait_pid_gone(pid: u32) -> bool {
954 std::process::Command::new("sh").arg("-c").arg(format!(
955 "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"
956 )).status().map(|s| s.success()).unwrap_or(false)
957 }
958
959 #[cfg(unix)]
960 #[tokio::test]
961 async fn eof_with_exit_78_maps_to_permanent() {
962 let (_dir, script) = fake_bridge_script("exec 1>&-; sleep 0.3; exit 78");
964 let result = BridgeProcess::start(&fake_bridge_config(&script, false, 150)).await;
965 assert!(
966 matches!(result, Err(BridgeError::PermanentFailure(reason)) if reason.contains("78"))
967 );
968 }
969
970 #[cfg(unix)]
971 #[tokio::test]
972 async fn eof_with_other_exit_stays_transient() {
973 let (_dir, script) = fake_bridge_script("exit 1");
974 let result = BridgeProcess::start(&fake_bridge_config(&script, false, 5000)).await;
975 assert!(matches!(result, Err(BridgeError::StdoutClosed)));
976 }
977
978 #[tokio::test]
979 async fn oversized_ready_line_is_discarded_before_next_announcement() {
980 let mut output = vec![b' '; 64 * 1024];
981 output.extend_from_slice(b"{\"status\":\"ready\",\"port\":9999}\n");
982 output.extend_from_slice(b"{\"status\":\"ready\",\"port\":12345}\n");
983 let mut reader = tokio::io::BufReader::new(output.as_slice());
984 assert_eq!(
985 read_ready_port(&mut reader).await.expect("ready port"),
986 12345
987 );
988 }
989
990 #[cfg(unix)]
991 #[tokio::test]
992 async fn eof_closed_stdout_child_is_bounded_other_and_killed() {
993 let dir = tempfile::tempdir().expect("tempdir");
994 let pidfile = dir.path().join("child.pid");
995 let (_script_dir, script) = fake_bridge_script(&format!(
996 "echo $$ > '{}'; exec 1>&-; exec sleep 30",
997 pidfile.display()
998 ));
999 let result = tokio::time::timeout(
1000 std::time::Duration::from_secs(6),
1001 BridgeProcess::start(&fake_bridge_config(&script, false, 5000)),
1002 )
1003 .await
1004 .expect("bounded EOF classification");
1005 assert!(matches!(result, Err(BridgeError::StdoutClosed)));
1006 let pid = std::fs::read_to_string(pidfile)
1007 .expect("pidfile")
1008 .trim()
1009 .parse()
1010 .expect("pid");
1011 assert!(wait_pid_gone(pid), "EOF child must be killed");
1012 }
1013
1014 #[cfg(unix)]
1015 #[tokio::test]
1016 async fn permanent_line_returns_prompt_and_kills_child() {
1017 let dir = tempfile::tempdir().expect("tempdir");
1018 let pidfile = dir.path().join("child.pid");
1019 let (_script_dir, script) = fake_bridge_script(&format!(
1020 "echo $$ > '{}'; echo '{{\"status\":\"permanent-failure\",\"reason\":\"JMS_ATTEMPT_JOURNAL_DIR is not owner-private\"}}'; exec sleep 30",
1021 pidfile.display()
1022 ));
1023 let result = tokio::time::timeout(
1024 std::time::Duration::from_secs(1),
1025 BridgeProcess::start(&fake_bridge_config(&script, true, 300)),
1026 )
1027 .await
1028 .expect("prompt permanent failure");
1029 assert!(
1030 matches!(result, Err(BridgeError::PermanentFailure(reason)) if reason.contains("JMS_ATTEMPT_JOURNAL_DIR"))
1031 );
1032 let pid = std::fs::read_to_string(pidfile)
1033 .expect("pidfile")
1034 .trim()
1035 .parse()
1036 .expect("pid");
1037 assert!(wait_pid_gone(pid), "permanent failure child must be killed");
1038 }
1039
1040 #[cfg(unix)]
1041 #[tokio::test]
1042 async fn startup_cancellation_kills_child() {
1043 let dir = tempfile::tempdir().expect("tempdir");
1044 let pidfile = dir.path().join("child.pid");
1045 let (_script_dir, script) =
1046 fake_bridge_script(&format!("echo $$ > '{}'; exec sleep 30", pidfile.display()));
1047 let config = fake_bridge_config(&script, true, 5000);
1048 let task = tokio::spawn(async move { BridgeProcess::start(&config).await });
1049 let pid = tokio::time::timeout(std::time::Duration::from_secs(2), async {
1050 loop {
1051 if let Ok(text) = std::fs::read_to_string(&pidfile)
1052 && let Ok(pid) = text.trim().parse::<u32>()
1053 {
1054 break pid;
1055 }
1056 tokio::task::yield_now().await;
1057 }
1058 })
1059 .await
1060 .expect("child starts");
1061 task.abort();
1062 assert!(matches!(task.await, Err(error) if error.is_cancelled()));
1063 assert!(wait_pid_gone(pid), "cancelled startup child must be killed");
1064 }
1065
1066 #[test]
1067 fn default_expect_soap_ready_is_false() {
1068 let xml = BridgeProcessConfig::xml(PathBuf::from("/tmp/xml-bridge"), 1000);
1069 assert!(
1070 !xml.expect_soap_ready,
1071 "xml bridge must not expect soap-ready"
1072 );
1073 let jms = BridgeProcessConfig::jms(
1074 PathBuf::from("/tmp/jms-bridge"),
1075 "tcp://localhost:61616".to_string(),
1076 BrokerType::ActiveMq,
1077 None,
1078 None,
1079 1000,
1080 None,
1081 None,
1082 );
1083 assert!(
1084 !jms.expect_soap_ready,
1085 "jms bridge must not expect soap-ready"
1086 );
1087 let cxf = BridgeProcessConfig::cxf_profiles(PathBuf::from("/tmp/cxf-bridge"), &[], 1000);
1088 assert!(
1089 !cxf.expect_soap_ready,
1090 "a CXF pool without a SOAP bind must not expect soap-ready"
1091 );
1092 }
1093
1094 #[test]
1095 fn cxf_profiles_generates_cxf_profiles_env_var() {
1096 let profiles = vec![
1097 CxfProfileEnvVars {
1098 name: "baleares".to_string(),
1099 wsdl_path: "/a.wsdl".to_string(),
1100 service_name: "Svc".to_string(),
1101 port_name: "Port".to_string(),
1102 address: None,
1103 keystore_path: None,
1104 keystore_password: None,
1105 truststore_path: None,
1106 truststore_password: None,
1107 sig_username: None,
1108 sig_password: None,
1109 enc_username: None,
1110 security_actions_out: None,
1111 security_actions_in: None,
1112 signature_algorithm: None,
1113 signature_digest_algorithm: None,
1114 signature_c14n_algorithm: None,
1115 signature_parts: None,
1116 fault_status: None,
1117 tls: None,
1118 },
1119 CxfProfileEnvVars {
1120 name: "extremadura".to_string(),
1121 wsdl_path: "/b.wsdl".to_string(),
1122 service_name: "Svc2".to_string(),
1123 port_name: "Port2".to_string(),
1124 address: Some("http://host:9090/ws".to_string()),
1125 keystore_path: Some("/b.jks".to_string()),
1126 keystore_password: Some(Redacted::new("pass".to_string())),
1127 truststore_path: None,
1128 truststore_password: None,
1129 sig_username: Some("cert".to_string()),
1130 sig_password: Some(Redacted::new("sig_pass".to_string())),
1131 enc_username: None,
1132 security_actions_out: Some("Timestamp Signature".to_string()),
1133 security_actions_in: Some("Timestamp Signature".to_string()),
1134 signature_algorithm: None,
1135 signature_digest_algorithm: None,
1136 signature_c14n_algorithm: None,
1137 signature_parts: None,
1138 fault_status: None,
1139 tls: None,
1140 },
1141 ];
1142
1143 let cfg =
1144 BridgeProcessConfig::cxf_profiles(PathBuf::from("/tmp/cxf-bridge"), &profiles, 15_000);
1145
1146 assert_eq!(cfg.spec.name, "cxf-bridge");
1147 assert!(cfg.broker_url.is_empty());
1148 assert_eq!(cfg.broker_type, BrokerType::Generic);
1149 assert!(cfg.username.is_none());
1150 assert!(cfg.password.is_none());
1151
1152 let profiles_var = cfg
1154 .env_vars
1155 .iter()
1156 .find(|(k, _)| k == "CXF_PROFILES")
1157 .expect("CXF_PROFILES env var must exist");
1158 assert_eq!(profiles_var.1, "baleares,extremadura");
1159
1160 assert!(
1162 cfg.env_vars
1163 .iter()
1164 .any(|(k, v)| k == "CXF_PROFILE_BALEARES_WSDL_PATH" && v == "/a.wsdl")
1165 );
1166 assert!(
1167 cfg.env_vars
1168 .iter()
1169 .any(|(k, v)| k == "CXF_PROFILE_BALEARES_SERVICE_NAME" && v == "Svc")
1170 );
1171 assert!(
1172 cfg.env_vars
1173 .iter()
1174 .any(|(k, v)| k == "CXF_PROFILE_BALEARES_PORT_NAME" && v == "Port")
1175 );
1176 assert!(
1177 !cfg.env_vars
1178 .iter()
1179 .any(|(k, _)| k == "CXF_PROFILE_BALEARES_ADDRESS")
1180 );
1181
1182 assert!(
1184 cfg.env_vars
1185 .iter()
1186 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_WSDL_PATH" && v == "/b.wsdl")
1187 );
1188 assert!(
1189 cfg.env_vars
1190 .iter()
1191 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_ADDRESS" && v == "http://host:9090/ws")
1192 );
1193 assert!(
1194 cfg.env_vars
1195 .iter()
1196 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_KEYSTORE_PATH" && v == "/b.jks")
1197 );
1198 assert!(
1199 cfg.env_vars
1200 .iter()
1201 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_KEYSTORE_PASSWORD" && v == "pass")
1202 );
1203 assert!(
1204 cfg.env_vars
1205 .iter()
1206 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_SIG_USERNAME" && v == "cert")
1207 );
1208 assert!(
1209 cfg.env_vars
1210 .iter()
1211 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_SIG_PASSWORD" && v == "sig_pass")
1212 );
1213 assert!(
1214 cfg.env_vars
1215 .iter()
1216 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_SECURITY_ACTIONS_OUT"
1217 && v == "Timestamp Signature")
1218 );
1219 }
1220
1221 #[test]
1222 fn cxf_profiles_single_profile_no_security() {
1223 let profiles = vec![CxfProfileEnvVars {
1224 name: "test".to_string(),
1225 wsdl_path: "service.wsdl".to_string(),
1226 service_name: "{http://example.com}Service".to_string(),
1227 port_name: "{http://example.com}Port".to_string(),
1228 address: None,
1229 keystore_path: None,
1230 keystore_password: None,
1231 truststore_path: None,
1232 truststore_password: None,
1233 sig_username: None,
1234 sig_password: None,
1235 enc_username: None,
1236 security_actions_out: None,
1237 security_actions_in: None,
1238 signature_algorithm: None,
1239 signature_digest_algorithm: None,
1240 signature_c14n_algorithm: None,
1241 signature_parts: None,
1242 fault_status: None,
1243 tls: None,
1244 }];
1245
1246 let cfg =
1247 BridgeProcessConfig::cxf_profiles(PathBuf::from("/tmp/cxf-bridge"), &profiles, 15_000);
1248
1249 assert_eq!(cfg.spec.name, "cxf-bridge");
1250 assert_eq!(cfg.env_vars.len(), 4);
1252 assert_eq!(cfg.env_vars[0].0, "CXF_PROFILES");
1253 assert_eq!(cfg.env_vars[0].1, "test");
1254 assert_eq!(cfg.env_vars[1].0, "CXF_PROFILE_TEST_WSDL_PATH");
1255 assert_eq!(cfg.env_vars[1].1, "service.wsdl");
1256 assert_eq!(cfg.env_vars[2].0, "CXF_PROFILE_TEST_SERVICE_NAME");
1257 assert_eq!(cfg.env_vars[2].1, "{http://example.com}Service");
1258 assert_eq!(cfg.env_vars[3].0, "CXF_PROFILE_TEST_PORT_NAME");
1259 assert_eq!(cfg.env_vars[3].1, "{http://example.com}Port");
1260 }
1261
1262 #[test]
1263 fn profile_env_vars_to_env_vars_includes_all_fields() {
1264 let vars = CxfProfileEnvVars {
1265 name: "full".to_string(),
1266 wsdl_path: "/wsdl".to_string(),
1267 service_name: "Svc".to_string(),
1268 port_name: "Port".to_string(),
1269 address: Some("http://host:8080".to_string()),
1270 keystore_path: Some("/ks.jks".to_string()),
1271 keystore_password: Some(Redacted::new("ks_pass".to_string())),
1272 truststore_path: Some("/ts.jks".to_string()),
1273 truststore_password: Some(Redacted::new("ts_pass".to_string())),
1274 sig_username: Some("user".to_string()),
1275 sig_password: Some(Redacted::new("sig_pass".to_string())),
1276 enc_username: None,
1277 security_actions_out: Some("Timestamp Signature".to_string()),
1278 security_actions_in: Some("Timestamp".to_string()),
1279 signature_algorithm: None,
1280 signature_digest_algorithm: None,
1281 signature_c14n_algorithm: None,
1282 signature_parts: None,
1283 fault_status: Some(403),
1284 tls: None,
1285 };
1286
1287 let env = vars.to_env_vars();
1288 assert_eq!(env.len(), 13);
1290
1291 let keys: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
1292 assert!(keys.contains(&"CXF_PROFILE_FULL_WSDL_PATH"));
1293 assert!(keys.contains(&"CXF_PROFILE_FULL_SERVICE_NAME"));
1294 assert!(keys.contains(&"CXF_PROFILE_FULL_PORT_NAME"));
1295 assert!(keys.contains(&"CXF_PROFILE_FULL_ADDRESS"));
1296 assert!(keys.contains(&"CXF_PROFILE_FULL_KEYSTORE_PATH"));
1297 assert!(keys.contains(&"CXF_PROFILE_FULL_KEYSTORE_PASSWORD"));
1298 assert!(keys.contains(&"CXF_PROFILE_FULL_TRUSTSTORE_PATH"));
1299 assert!(keys.contains(&"CXF_PROFILE_FULL_TRUSTSTORE_PASSWORD"));
1300 assert!(keys.contains(&"CXF_PROFILE_FULL_SIG_USERNAME"));
1301 assert!(keys.contains(&"CXF_PROFILE_FULL_SIG_PASSWORD"));
1302 assert!(keys.contains(&"CXF_PROFILE_FULL_SECURITY_ACTIONS_OUT"));
1303 assert!(keys.contains(&"CXF_PROFILE_FULL_SECURITY_ACTIONS_IN"));
1304 let fault_status = env
1305 .iter()
1306 .find(|(k, _)| k == "CXF_PROFILE_FULL_FAULT_STATUS")
1307 .expect("fault_status env var emitted");
1308 assert_eq!(fault_status.1, "403");
1309 }
1310
1311 #[test]
1312 fn test_start_timeout_zero_rejected() {
1313 let config = BridgeProcessConfig::jms(
1314 PathBuf::from("/usr/bin/echo"),
1315 "tcp://localhost:61616".to_string(),
1316 BrokerType::ActiveMq,
1317 None,
1318 None,
1319 0,
1320 None,
1321 None,
1322 );
1323 let result = config.validate();
1324 assert!(result.is_err());
1325 }
1326
1327 #[test]
1328 fn test_bridge_rejects_zero_start_timeout() {
1329 let config = BridgeProcessConfig::jms(
1330 PathBuf::from("/usr/bin/echo"),
1331 "tcp://localhost:61616".to_string(),
1332 BrokerType::ActiveMq,
1333 None,
1334 None,
1335 0,
1336 None,
1337 None,
1338 );
1339 assert!(config.validate().is_err());
1340 }
1341
1342 #[tokio::test]
1343 async fn test_bridge_stop_completes() {
1344 use tokio::process::Command;
1345 use tokio::time::{Duration, timeout};
1346
1347 let child = Command::new("sh")
1348 .arg("-c")
1349 .arg("trap '' TERM; while true; do echo tick; sleep 1; done")
1350 .stdout(std::process::Stdio::null())
1351 .spawn()
1352 .expect("must spawn test child process");
1353
1354 let bridge = BridgeProcess {
1355 child,
1356 grpc_port: 0,
1357 soap_port: None,
1358 tls: crate::tls::BridgeTlsMaterial::generate().expect("test tls"),
1359 token: CancellationToken::new(),
1360 handle: None,
1361 };
1362
1363 let result = timeout(Duration::from_secs(10), bridge.stop()).await;
1364 assert!(result.is_ok(), "stop() must complete within 10s");
1365 }
1366
1367 #[test]
1370 fn redacted_debug_displays_redacted() {
1371 let r = Redacted::new("secret_password".to_string());
1372 assert_eq!(format!("{r:?}"), "[REDACTED]");
1373 }
1374
1375 #[test]
1376 fn redacted_display_displays_redacted() {
1377 let r = Redacted::new("secret_password".to_string());
1378 assert_eq!(format!("{r}"), "[REDACTED]");
1379 }
1380
1381 #[test]
1382 fn redacted_deref_gives_inner_value() {
1383 let r = Redacted::new("secret".to_string());
1384 assert_eq!(&*r, "secret");
1385 }
1386
1387 #[test]
1388 fn redacted_into_inner_returns_value() {
1389 let r = Redacted::new("secret".to_string());
1390 assert_eq!(r.into_inner(), "secret");
1391 }
1392
1393 #[test]
1394 fn redacted_clone_works() {
1395 let r = Redacted::new("secret".to_string());
1396 let c = r.clone();
1397 assert_eq!(&*c, "secret");
1398 assert_eq!(format!("{c:?}"), "[REDACTED]");
1399 }
1400
1401 #[test]
1402 fn bridge_process_config_debug_redacts_password() {
1403 let cfg = BridgeProcessConfig::jms(
1404 PathBuf::from("/tmp/jms-bridge"),
1405 "tcp://localhost:61616".to_string(),
1406 BrokerType::ActiveMq,
1407 Some("user".to_string()),
1408 Some(Redacted::new("super_secret".to_string())),
1409 1000,
1410 None,
1411 None,
1412 );
1413 let password_debug = format!("{:?}", cfg.password); assert!(
1418 !password_debug.contains("super_secret"),
1419 "Password field must not leak in Debug: {password_debug}"
1420 );
1421 assert_eq!(
1422 password_debug, "Some([REDACTED])",
1423 "Password field must show [REDACTED]: {password_debug}"
1424 );
1425 }
1426
1427 #[test]
1428 fn bridge_process_config_debug_redacts_broker_url_and_env_vars() {
1429 let cfg = BridgeProcessConfig {
1430 spec: &JMS_BRIDGE,
1431 binary_path: PathBuf::from("/tmp/jms-bridge"),
1432 broker_url: "amqp://u:SENTINEL-BRIDGE-PASS@host:5672".to_string(),
1433 broker_type: BrokerType::ActiveMq,
1434 username: Some("user".to_string()),
1435 password: Some(Redacted::new("pass".to_string())),
1436 start_timeout_ms: 1000,
1437 env_vars: vec![(
1438 "KEYSTORE_PASSWORD".to_string(),
1439 "SENTINEL-ENV-PASS".to_string(),
1440 )],
1441 expect_soap_ready: false,
1442 };
1443 let debug_output = format!("{cfg:?}");
1444 assert!(
1445 !debug_output.contains("SENTINEL-BRIDGE-PASS"),
1446 "broker_url must be redacted in Debug: {debug_output}"
1447 );
1448 assert!(
1449 !debug_output.contains("SENTINEL-ENV-PASS"),
1450 "env_vars values must be redacted in Debug: {debug_output}"
1451 );
1452 assert!(
1453 debug_output.contains("[REDACTED; 1 entries]"),
1454 "env_vars must show redacted count: {debug_output}"
1455 );
1456 }
1457
1458 #[test]
1459 fn bridge_process_config_debug_redacts_all_sensitive_fields() {
1460 let cfg = BridgeProcessConfig {
1461 spec: &JMS_BRIDGE,
1462 binary_path: PathBuf::from("/tmp/jms-bridge"),
1463 broker_url: "amqp://SENTINEL-BROKER-URL@host:5672".to_string(),
1464 broker_type: BrokerType::ActiveMq,
1465 username: Some("SENTINEL-USERNAME".to_string()),
1466 password: Some(Redacted::new("SENTINEL-PASSWORD".to_string())),
1467 start_timeout_ms: 1000,
1468 env_vars: vec![(
1469 "KEYSTORE_PASSWORD".to_string(),
1470 "SENTINEL-ENV-PASS".to_string(),
1471 )],
1472 expect_soap_ready: false,
1473 };
1474 let debug_output = format!("{cfg:?}");
1475 assert!(
1476 !debug_output.contains("SENTINEL-BROKER-URL")
1477 && !debug_output.contains("SENTINEL-USERNAME")
1478 && !debug_output.contains("SENTINEL-PASSWORD")
1479 && !debug_output.contains("SENTINEL-ENV-PASS"),
1480 "No sensitive field may leak in Debug: {debug_output}"
1481 );
1482 assert!(
1483 debug_output.contains("username: Some(\"[REDACTED]\")"),
1484 "username must be masked in Debug: {debug_output}"
1485 );
1486 }
1487
1488 #[test]
1489 fn cxf_profile_debug_redacts_passwords() {
1490 let profile = CxfProfileEnvVars {
1491 name: "test".to_string(),
1492 wsdl_path: "/a.wsdl".to_string(),
1493 service_name: "Svc".to_string(),
1494 port_name: "Port".to_string(),
1495 address: None,
1496 keystore_path: None,
1497 keystore_password: Some(Redacted::new("ks_secret_val".to_string())),
1498 truststore_path: None,
1499 truststore_password: Some(Redacted::new("ts_secret_val".to_string())),
1500 sig_username: None,
1501 sig_password: Some(Redacted::new("sig_secret_val".to_string())),
1502 enc_username: None,
1503 security_actions_out: None,
1504 security_actions_in: None,
1505 signature_algorithm: None,
1506 signature_digest_algorithm: None,
1507 signature_c14n_algorithm: None,
1508 signature_parts: None,
1509 fault_status: None,
1510 tls: None,
1511 };
1512 let debug_output = format!("{profile:?}");
1513 assert!(
1514 !debug_output.contains("ks_secret_val")
1515 && !debug_output.contains("ts_secret_val")
1516 && !debug_output.contains("sig_secret_val"),
1517 "Debug must not contain passwords: {debug_output}"
1518 );
1519 }
1520
1521 fn fake_bridge_script(body: &str) -> (tempfile::TempDir, PathBuf) {
1527 let dir = tempfile::tempdir().expect("fake bridge tempdir");
1528 let path = dir.path().join("fake-bridge.sh");
1529 std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).expect("write fake bridge script");
1530 #[cfg(unix)]
1531 {
1532 use std::os::unix::fs::PermissionsExt;
1533 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
1534 .expect("chmod fake bridge script");
1535 }
1536 (dir, path)
1537 }
1538
1539 fn fake_bridge_config(
1540 script: &std::path::Path,
1541 expect_soap_ready: bool,
1542 start_timeout_ms: u64,
1543 ) -> BridgeProcessConfig {
1544 let mut config =
1545 BridgeProcessConfig::cxf_profiles(script.to_path_buf(), &[], start_timeout_ms);
1546 config.expect_soap_ready = expect_soap_ready;
1547 config
1548 }
1549
1550 #[tokio::test]
1553 async fn process_dual_ready_reordered_is_accepted() {
1554 use std::time::{Duration, Instant};
1555
1556 for (label, body, expect_soap_ready, want_soap) in [
1557 (
1558 "soap-ready before ready (expect=true)",
1559 r#"echo '{"status":"soap-ready","port":23456}'; echo '{"status":"ready","port":12345}'"#,
1560 true,
1561 Some(23456u16),
1562 ),
1563 (
1564 "ready before soap-ready (expect=true)",
1565 r#"echo '{"status":"ready","port":12345}'; echo '{"status":"soap-ready","port":23456}'"#,
1566 true,
1567 Some(23456u16),
1568 ),
1569 (
1570 "soap-ready before ready (expect=false still stores the valid soap port)",
1571 r#"echo '{"status":"soap-ready","port":23456}'; echo '{"status":"ready","port":12345}'"#,
1572 false,
1573 Some(23456u16),
1574 ),
1575 ] {
1576 let (_dir, script) = fake_bridge_script(body);
1577 let config = fake_bridge_config(&script, expect_soap_ready, 10_000);
1578 let started = Instant::now();
1579 let process = BridgeProcess::start(&config).await.unwrap_or_else(|e| {
1580 panic!("{label}: dual ready lines in any order must start: {e}")
1581 });
1582 let elapsed = started.elapsed();
1583 assert!(
1584 elapsed < Duration::from_millis(10_000),
1585 "{label}: start must complete inside one config budget, took {elapsed:?}"
1586 );
1587 assert_eq!(process.grpc_port(), 12345, "{label}: gRPC port stored");
1588 assert_eq!(process.soap_port(), want_soap, "{label}: soap port stored");
1589 process
1590 .stop()
1591 .await
1592 .unwrap_or_else(|e| panic!("{label}: fake bridge must stop cleanly: {e}"));
1593 }
1594 }
1595
1596 #[tokio::test]
1606 async fn partial_ready_is_bounded_and_completes_in_one_timeout() {
1607 use std::time::{Duration, Instant};
1608
1609 for (label, body_template, budget_ms, expect_ok, max_elapsed_ms) in [
1610 (
1611 "partial soap-ready then ready completes within ONE budget",
1612 r#"echo '{"status":"soap-ready","port":23456}'; sleep 1; echo '{"status":"ready","port":12345}'"#,
1613 6_000u64,
1614 true,
1615 6_000u64,
1616 ),
1617 (
1618 "counterexample: TOTAL budget exceeded fires StartTimeout — no per-line reset",
1619 r#"sleep 2.5; echo '{"status":"soap-ready","port":23456}'; touch '{marker}'; sleep 2.5; echo '{"status":"ready","port":12345}'"#,
1620 4_000u64,
1621 false,
1622 5_000u64,
1623 ),
1624 ] {
1625 let marker_dir = tempfile::tempdir().expect("marker tempdir");
1629 let marker = marker_dir.path().join("soap-ready-emitted");
1630 let body = body_template.replace("{marker}", &marker.display().to_string());
1631 let (_dir, script) = fake_bridge_script(&body);
1632 let config = fake_bridge_config(&script, true, budget_ms);
1633 let started = Instant::now();
1634 let result = BridgeProcess::start(&config).await;
1635 let elapsed = started.elapsed();
1636 assert!(
1637 elapsed < Duration::from_millis(max_elapsed_ms),
1638 "{label}: must resolve in {max_elapsed_ms}ms (one enclosing budget), took {elapsed:?}"
1639 );
1640 if expect_ok {
1641 let process =
1642 result.unwrap_or_else(|e| panic!("{label}: start must complete: {e}"));
1643 assert_eq!(process.grpc_port(), 12345, "{label}: gRPC port");
1644 assert_eq!(process.soap_port(), Some(23456), "{label}: soap port");
1645 process
1646 .stop()
1647 .await
1648 .unwrap_or_else(|e| panic!("{label}: fake bridge must stop cleanly: {e}"));
1649 } else {
1650 match result {
1651 Err(BridgeError::Timeout(_)) => {}
1652 Err(e) => panic!("{label}: expected StartTimeout, got {e}"),
1653 Ok(_) => panic!(
1654 "{label}: start must hit the single enclosing budget instead of \
1655 waiting out per-line resets"
1656 ),
1657 }
1658 assert!(
1659 marker.exists(),
1660 "{label}: the partial soap-ready line must have been emitted before \
1661 the deadline — the timeout comes from the ENCLOSING budget, not \
1662 from a missing partial line"
1663 );
1664 }
1665 }
1666 }
1667
1668 #[tokio::test]
1673 async fn malformed_soap_ready_fails_startup() {
1674 let malformed = [
1675 (
1676 "non-numeric port",
1677 r#"{"status":"soap-ready","port":"abc"}"#,
1678 ),
1679 ("missing port", r#"{"status":"soap-ready"}"#),
1680 (
1681 "port above u16 range",
1682 r#"{"status":"soap-ready","port":70000}"#,
1683 ),
1684 ("port zero", r#"{"status":"soap-ready","port":0}"#),
1685 ];
1686 for (kind, soap_line) in malformed {
1687 for (order, body) in [
1688 (
1689 "soap-ready first",
1690 format!("echo '{soap_line}'; echo '{{\"status\":\"ready\",\"port\":12345}}'"),
1691 ),
1692 (
1693 "ready first",
1694 format!("echo '{{\"status\":\"ready\",\"port\":12345}}'; echo '{soap_line}'"),
1695 ),
1696 ] {
1697 let label = format!("{kind}, {order}");
1698 let (_dir, script) = fake_bridge_script(&body);
1699 let config = fake_bridge_config(&script, true, 5_000);
1700 match BridgeProcess::start(&config).await {
1701 Err(BridgeError::BadReadyMessage(_)) => {}
1702 Err(e) => panic!("{label}: expected BadReadyMessage, got {e}"),
1703 Ok(_) => panic!("{label}: malformed soap-ready must fail startup"),
1704 }
1705 }
1706 }
1707 }
1708
1709 #[tokio::test]
1714 async fn no_soap_wait_when_unbound() {
1715 use std::time::{Duration, Instant};
1716
1717 let (_dir, script) =
1720 fake_bridge_script(r#"echo '{"status":"ready","port":12345}'; exec tail -f /dev/null"#);
1721 let config = fake_bridge_config(&script, false, 5_000);
1722 let started = Instant::now();
1723 let process = BridgeProcess::start(&config)
1724 .await
1725 .expect("start must return on the gRPC ready line alone");
1726 let elapsed = started.elapsed();
1727 assert!(
1728 elapsed < Duration::from_millis(2_500),
1729 "expect_soap_ready=false must not wait for a soap-ready line, took {elapsed:?}"
1730 );
1731 assert_eq!(process.grpc_port(), 12345);
1732 assert_eq!(
1733 process.soap_port(),
1734 None,
1735 "no soap-ready line → soap_port stays None"
1736 );
1737 process.stop().await.expect("fake bridge must stop cleanly");
1738 }
1739
1740 async fn spawn_child_with_stdout(script: &str) -> tokio::process::Child {
1750 use tokio::process::Command;
1751
1752 Command::new("sh")
1753 .arg("-c")
1754 .arg(script)
1755 .stdin(std::process::Stdio::null())
1756 .stdout(std::process::Stdio::piped())
1757 .stderr(std::process::Stdio::null())
1758 .spawn()
1759 .expect("must spawn test child")
1760 }
1761
1762 #[tokio::test]
1763 async fn drain_chatty_child_no_deadlock() {
1764 let mut child =
1766 spawn_child_with_stdout("for i in $(seq 1 500); do echo \"line $i\"; done").await;
1767
1768 let stdout = child.stdout.take().expect("stdout piped");
1769 let reader = tokio::io::BufReader::new(stdout);
1770 let token = CancellationToken::new();
1771
1772 let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1774
1775 let status = child.wait().await.expect("wait for child");
1777 assert!(status.success(), "child should exit successfully");
1778
1779 let result = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
1781 assert!(result.is_ok(), "drain task should complete within 2s");
1782 }
1783
1784 #[tokio::test]
1785 async fn drain_oversized_line_bounded() {
1786 let oversized_bytes = 200 * 1024; let mut child = spawn_child_with_stdout(&format!(
1796 "head -c {oversized_bytes} /dev/zero | tr '\\0' 'A'; echo"
1797 ))
1798 .await;
1799
1800 let stdout = child.stdout.take().expect("stdout piped");
1801 let reader = tokio::io::BufReader::new(stdout);
1802 let token = CancellationToken::new();
1803
1804 let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1805
1806 let status = child.wait().await.expect("wait for child");
1807 assert!(status.success(), "child should exit successfully");
1808
1809 let result = tokio::time::timeout(std::time::Duration::from_millis(500), handle).await;
1813 assert!(result.is_ok(), "drain task should complete within 500ms");
1814 }
1815
1816 #[tokio::test]
1817 async fn drain_cancellation_exits_promptly() {
1818 let mut child = spawn_child_with_stdout("while true; do echo tick; sleep 1; done").await;
1820
1821 let stdout = child.stdout.take().expect("stdout piped");
1822 let reader = tokio::io::BufReader::new(stdout);
1823 let token = CancellationToken::new();
1824
1825 let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1827
1828 token.cancel();
1831 let result = tokio::time::timeout(std::time::Duration::from_millis(500), handle).await;
1832 assert!(
1833 result.is_ok(),
1834 "drain task should exit promptly after cancellation"
1835 );
1836
1837 let _ = child.kill().await;
1839 }
1840
1841 #[tokio::test]
1849 async fn drain_handoff_preserves_trailing_bytes() {
1850 let mut child = spawn_child_with_stdout(
1852 r#"echo '{"status":"ready","port":12345}'; echo "trailing-1"; echo "trailing-2"; echo "trailing-3""#,
1853 )
1854 .await;
1855
1856 let stdout = child.stdout.take().expect("stdout piped");
1857 let mut reader = tokio::io::BufReader::new(stdout);
1858
1859 assert_eq!(
1861 read_ready_port(&mut reader).await.expect("ready port"),
1862 12345
1863 );
1864
1865 let token = CancellationToken::new();
1867 let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1868
1869 let status = tokio::time::timeout(std::time::Duration::from_secs(5), child.wait())
1872 .await
1873 .expect("child exits within 5s")
1874 .expect("wait for child");
1875 assert!(status.success(), "child should exit successfully");
1876
1877 let result = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
1879 assert!(
1880 result.is_ok(),
1881 "drain task should complete — trailing bytes preserved through handoff"
1882 );
1883 }
1884}