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)]
50pub enum BridgeError {
51 #[error("IO error: {0}")]
52 Io(#[from] std::io::Error),
53 #[error("Bridge timed out: {0}")]
54 Timeout(String),
55 #[error("Bridge stdout closed before ready message")]
56 StdoutClosed,
57 #[error("Bridge ready message malformed: {0}")]
58 BadReadyMessage(String),
59 #[error("Download failed: {0}")]
60 Download(String),
61 #[error("Checksum mismatch: expected {expected}, got {actual}")]
62 ChecksumMismatch { expected: String, actual: String },
63 #[error("URL not allowed: {0}")]
64 UrlNotAllowed(String),
65 #[error("Transport error: {0}")]
66 Transport(String),
67 #[error("Config error: {0}")]
68 Config(String),
69}
70
71#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
72#[serde(rename_all = "lowercase")]
73pub enum BrokerType {
74 #[serde(alias = "active_mq")]
75 ActiveMq,
76 Artemis,
77 Generic,
78}
79
80impl BrokerType {
81 pub fn as_env_str(&self) -> &'static str {
82 match self {
83 BrokerType::ActiveMq => "activemq",
84 BrokerType::Artemis => "artemis",
85 BrokerType::Generic => "generic",
86 }
87 }
88}
89
90impl std::str::FromStr for BrokerType {
91 type Err = BridgeError;
92
93 fn from_str(s: &str) -> Result<Self, Self::Err> {
94 match s.to_lowercase().as_str() {
95 "activemq" => Ok(BrokerType::ActiveMq),
96 "artemis" => Ok(BrokerType::Artemis),
97 "generic" => Ok(BrokerType::Generic),
98 other => Err(BridgeError::Config(format!("unknown broker type: {other}"))), }
100 }
101}
102
103#[derive(Debug)]
106pub struct CxfProfileEnvVars {
107 pub name: String,
108 pub wsdl_path: String,
109 pub service_name: String,
110 pub port_name: String,
111 pub address: Option<String>,
112 pub keystore_path: Option<String>,
113 pub keystore_password: Option<Redacted<String>>,
114 pub truststore_path: Option<String>,
115 pub truststore_password: Option<Redacted<String>>,
116 pub sig_username: Option<String>,
117 pub sig_password: Option<Redacted<String>>,
118 pub enc_username: Option<String>,
119 pub security_actions_out: Option<String>,
120 pub security_actions_in: Option<String>,
121 pub signature_algorithm: Option<String>,
122 pub signature_digest_algorithm: Option<String>,
123 pub signature_c14n_algorithm: Option<String>,
124 pub signature_parts: Option<String>,
125}
126
127impl CxfProfileEnvVars {
128 pub fn to_env_vars(&self) -> Vec<(String, String)> {
129 let prefix = format!("CXF_PROFILE_{}_", self.name.to_uppercase());
130 let mut vars = vec![
131 (format!("{}WSDL_PATH", prefix), self.wsdl_path.clone()),
132 (format!("{}SERVICE_NAME", prefix), self.service_name.clone()),
133 (format!("{}PORT_NAME", prefix), self.port_name.clone()),
134 ];
135
136 if let Some(ref v) = self.address {
137 vars.push((format!("{}ADDRESS", prefix), v.clone()));
138 }
139 if let Some(ref v) = self.keystore_path {
140 vars.push((format!("{}KEYSTORE_PATH", prefix), v.clone()));
141 }
142 if let Some(ref v) = self.keystore_password {
143 vars.push((format!("{}KEYSTORE_PASSWORD", prefix), (**v).clone()));
144 }
145 if let Some(ref v) = self.truststore_path {
146 vars.push((format!("{}TRUSTSTORE_PATH", prefix), v.clone()));
147 }
148 if let Some(ref v) = self.truststore_password {
149 vars.push((format!("{}TRUSTSTORE_PASSWORD", prefix), (**v).clone()));
150 }
151 if let Some(ref v) = self.sig_username {
152 vars.push((format!("{}SIG_USERNAME", prefix), v.clone()));
153 }
154 if let Some(ref v) = self.sig_password {
155 vars.push((format!("{}SIG_PASSWORD", prefix), (**v).clone()));
156 }
157 if let Some(ref v) = self.enc_username {
158 vars.push((format!("{}ENC_USERNAME", prefix), v.clone()));
159 }
160 if let Some(ref v) = self.security_actions_out {
161 vars.push((format!("{}SECURITY_ACTIONS_OUT", prefix), v.clone()));
162 }
163 if let Some(ref v) = self.security_actions_in {
164 vars.push((format!("{}SECURITY_ACTIONS_IN", prefix), v.clone()));
165 }
166 if let Some(ref v) = self.signature_algorithm {
167 vars.push((format!("{}SIGNATURE_ALGORITHM", prefix), v.clone()));
168 }
169 if let Some(ref v) = self.signature_digest_algorithm {
170 vars.push((format!("{}SIGNATURE_DIGEST_ALGORITHM", prefix), v.clone()));
171 }
172 if let Some(ref v) = self.signature_c14n_algorithm {
173 vars.push((format!("{}SIGNATURE_C14N_ALGORITHM", prefix), v.clone()));
174 }
175 if let Some(ref v) = self.signature_parts {
176 vars.push((format!("{}SIGNATURE_PARTS", prefix), v.clone()));
177 }
178
179 vars
180 }
181}
182
183pub struct BridgeProcessConfig {
188 pub spec: &'static BridgeSpec,
189 pub binary_path: PathBuf,
190 pub broker_url: String,
191 pub broker_type: BrokerType,
192 pub username: Option<String>,
193 pub password: Option<Redacted<String>>,
194 pub start_timeout_ms: u64,
195 pub env_vars: Vec<(String, String)>,
196}
197
198impl fmt::Debug for BridgeProcessConfig {
199 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
200 f.debug_struct("BridgeProcessConfig")
201 .field("spec", &self.spec)
202 .field("binary_path", &self.binary_path)
203 .field("broker_url", &"[REDACTED]")
204 .field("broker_type", &self.broker_type)
205 .field("username", &self.username.as_ref().map(|_| "[REDACTED]"))
206 .field("password", &self.password)
207 .field("start_timeout_ms", &self.start_timeout_ms)
208 .field(
209 "env_vars",
210 &format!("[REDACTED; {} entries]", self.env_vars.len()),
211 )
212 .finish()
213 }
214}
215
216impl BridgeProcessConfig {
217 pub fn jms(
219 binary_path: PathBuf,
220 broker_url: String,
221 broker_type: BrokerType,
222 username: Option<String>,
223 password: Option<Redacted<String>>,
224 start_timeout_ms: u64,
225 ) -> Self {
226 let mut env_vars = vec![
227 ("BRIDGE_BROKER_URL".to_string(), broker_url.clone()),
228 (
229 "BRIDGE_BROKER_TYPE".to_string(),
230 broker_type.as_env_str().to_string(),
231 ),
232 ];
233 if let Some(u) = &username {
234 env_vars.push(("BRIDGE_USERNAME".to_string(), u.clone()));
235 }
236 if let Some(p) = &password {
237 env_vars.push(("BRIDGE_PASSWORD".to_string(), (**p).clone()));
238 }
239 Self {
240 spec: &JMS_BRIDGE,
241 binary_path,
242 broker_url,
243 broker_type,
244 username,
245 password,
246 start_timeout_ms,
247 env_vars,
248 }
249 }
250
251 pub fn xml(binary_path: PathBuf, start_timeout_ms: u64) -> Self {
253 Self {
254 spec: &XML_BRIDGE,
255 binary_path,
256 broker_url: String::new(),
257 broker_type: BrokerType::Generic,
258 username: None,
259 password: None,
260 start_timeout_ms,
261 env_vars: vec![],
262 }
263 }
264
265 pub fn cxf_profiles(
268 binary_path: PathBuf,
269 profiles: &[CxfProfileEnvVars],
270 start_timeout_ms: u64,
271 ) -> Self {
272 let profile_names: Vec<String> = profiles.iter().map(|p| p.name.clone()).collect();
273 let mut env_vars = vec![("CXF_PROFILES".to_string(), profile_names.join(","))];
274
275 for profile in profiles {
276 env_vars.extend(profile.to_env_vars());
277 }
278
279 Self {
280 spec: &CXF_BRIDGE,
281 binary_path,
282 broker_url: String::new(),
283 broker_type: BrokerType::Generic,
284 username: None,
285 password: None,
286 start_timeout_ms,
287 env_vars,
288 }
289 }
290
291 pub fn validate(&self) -> Result<(), String> {
292 if self.start_timeout_ms == 0 {
293 return Err("start_timeout_ms must be > 0".to_string());
294 }
295 Ok(())
296 }
297}
298
299async fn drain_stdout<R: tokio::io::AsyncBufRead + Unpin>(mut reader: R, token: CancellationToken) {
304 use tokio::io::AsyncBufReadExt;
305 use tokio::time::{Duration, Instant};
306
307 const MAX_LINE_BYTES: usize = 64 * 1024;
308 const LOG_INTERVAL: Duration = Duration::from_secs(1);
309 const LOG_BUDGET: u32 = 100;
310
311 let mut line_buf: Vec<u8> = Vec::new();
312 let mut oversized = false;
313 let mut interval_start = Instant::now();
314 let mut logged: u32 = 0;
315 let mut dropped: u32 = 0;
316
317 loop {
318 tokio::select! {
319 biased;
320 _ = token.cancelled() => break,
321 res = reader.fill_buf() => {
322 let chunk_len = match res {
323 Ok(chunk) => {
324 if chunk.is_empty() {
325 break; }
327 for &b in chunk {
328 if b == b'\n' {
329 if oversized {
330 if logged < LOG_BUDGET {
332 tracing::warn!(
333 "bridge stdout: oversized line (>{MAX_LINE_BYTES} bytes), truncated"
334 );
335 logged += 1;
336 } else {
337 dropped += 1;
338 }
339 } else if logged < LOG_BUDGET {
340 tracing::debug!(
342 target: "camel_bridge::child",
343 "{}",
344 String::from_utf8_lossy(&line_buf)
345 );
346 logged += 1;
347 } else {
348 dropped += 1;
349 }
350 line_buf.clear();
351 oversized = false;
352 } else if !oversized {
353 if line_buf.len() < MAX_LINE_BYTES {
354 line_buf.push(b);
355 } else {
356 oversized = true;
357 }
358 }
359 }
360 chunk.len()
361 }
362 Err(_) => break,
363 };
364 reader.consume(chunk_len);
365 if interval_start.elapsed() >= LOG_INTERVAL {
366 if dropped > 0 {
367 tracing::debug!(
369 "bridge stdout: dropped {dropped} lines in last interval"
370 );
371 }
372 interval_start = Instant::now();
373 logged = 0;
374 dropped = 0;
375 }
376 }
377 }
378 }
379}
380
381pub struct BridgeProcess {
382 child: tokio::process::Child,
383 grpc_port: u16,
384 tls: crate::tls::BridgeTlsMaterial,
385 token: CancellationToken,
386 handle: Option<JoinHandle<()>>,
387}
388
389impl BridgeProcess {
390 pub fn grpc_port(&self) -> u16 {
391 self.grpc_port
392 }
393
394 pub async fn connect(&self) -> Result<tonic::transport::Channel, BridgeError> {
396 crate::channel::connect_channel(self.grpc_port, &self.tls).await
397 }
398
399 pub async fn start_and_connect(
401 config: &BridgeProcessConfig,
402 ) -> Result<(Self, tonic::transport::Channel), BridgeError> {
403 let process = Self::start(config).await?;
404 let channel = process.connect().await?;
405 Ok((process, channel))
406 }
407
408 pub async fn start(config: &BridgeProcessConfig) -> Result<Self, BridgeError> {
420 use tokio::io::AsyncBufReadExt;
421 use tokio::process::Command;
422 use tokio::time::{Duration, timeout};
423
424 config.validate().map_err(BridgeError::Config)?;
425
426 let tls = crate::tls::BridgeTlsMaterial::generate()?;
427
428 let free_port: u16 = 0;
434
435 let stderr_stdio: std::process::Stdio =
437 if let Ok(log_dir) = std::env::var("CAMEL_BRIDGE_LOG_STDERR") {
438 let log_filename = config
439 .spec
440 .log_file_template
441 .replace("{pid}", &std::process::id().to_string());
442 let log_path = if log_dir.is_empty() {
443 format!("/tmp/{log_filename}")
444 } else {
445 format!("{log_dir}/{log_filename}")
446 };
447 match std::fs::File::create(&log_path) {
448 Ok(f) => {
449 eprintln!("[camel-bridge] stderr → {}", log_path);
450 f.into()
451 }
452 Err(e) => {
453 eprintln!(
454 "[camel-bridge] failed to create log file {}: {}",
455 log_path, e
456 );
457 std::process::Stdio::inherit()
458 }
459 }
460 } else {
461 std::process::Stdio::inherit()
462 };
463
464 let mut command = Command::new(&config.binary_path);
465 command
466 .env("QUARKUS_HTTP_SSL_PORT", free_port.to_string())
467 .env(
468 "QUARKUS_TLS_BRIDGE_KEY_STORE_PEM_0_CERT",
469 &tls.server_pem_path,
470 )
471 .env(
472 "QUARKUS_TLS_BRIDGE_KEY_STORE_PEM_0_KEY",
473 &tls.server_key_path,
474 )
475 .env("QUARKUS_TLS_BRIDGE_TRUST_STORE_PEM_CERTS", &tls.ca_pem_path)
476 .stdout(std::process::Stdio::piped())
477 .stderr(stderr_stdio);
478
479 for (key, value) in &config.env_vars {
481 command.env(key, value);
482 }
483
484 let mut child = command.spawn()?;
485
486 let stdout = child.stdout.take().ok_or(BridgeError::StdoutClosed)?;
487 let mut reader = tokio::io::BufReader::new(stdout);
488
489 let port = timeout(Duration::from_millis(config.start_timeout_ms), async {
493 let mut buf_acc: Vec<u8> = Vec::new();
494 const READY_MAX_LINE: usize = 64 * 1024;
495 while let Ok(chunk) = reader.fill_buf().await {
496 if chunk.is_empty() {
497 break; }
499 let newline_pos = chunk.iter().position(|&b| b == b'\n');
500 let take = match newline_pos {
501 Some(i) => i + 1,
502 None => chunk.len(),
503 };
504 if buf_acc.len() + take <= READY_MAX_LINE {
505 buf_acc.extend_from_slice(&chunk[..take]);
506 }
507 reader.consume(take);
508 if newline_pos.is_some() {
509 let line = String::from_utf8_lossy(&buf_acc);
510 let line_trimmed = line.trim_end_matches('\n');
511 if let Ok(v) = serde_json::from_str::<serde_json::Value>(line_trimmed)
512 && v.get("status").and_then(|s| s.as_str()) == Some("ready")
513 {
514 if let Some(p) = v.get("port").and_then(|p| p.as_u64()) {
515 return Ok(p as u16);
516 }
517 tracing::error!("bridge ready message malformed: {line_trimmed}");
519 return Err(BridgeError::BadReadyMessage(line_trimmed.to_string()));
520 }
521 buf_acc.clear();
522 }
523 }
524 tracing::error!("bridge stdout closed before ready message");
526 Err(BridgeError::StdoutClosed)
527 })
528 .await
529 .map_err(|_| {
530 let msg = format!(
531 "{} failed to start: health check timeout after {}ms",
532 config.spec.name, config.start_timeout_ms
533 );
534 tracing::error!("{msg}");
536 BridgeError::Timeout(msg)
537 })??;
538
539 let token = CancellationToken::new();
544 let child_token = token.clone();
545 let handle = tokio::spawn(async move {
546 drain_stdout(reader, child_token).await;
547 });
548
549 Ok(BridgeProcess {
550 child,
551 grpc_port: port,
552 tls,
553 token,
554 handle: Some(handle),
555 })
556 }
557
558 pub async fn stop(mut self) -> Result<(), BridgeError> {
560 use tokio::time::{Duration, sleep};
561
562 self.token.cancel();
563
564 if let Some(handle) = self.handle.take() {
565 let join_result = tokio::time::timeout(Duration::from_secs(5), handle).await;
566 if join_result.is_err() {
567 tracing::warn!("bridge stdout drain task did not exit after cancellation");
568 }
569 }
570
571 #[cfg(unix)]
573 {
574 let pid = self.child.id().unwrap_or(0);
575 if pid > 0 {
576 unsafe {
578 libc::kill(pid as i32, libc::SIGTERM);
579 }
580 }
581 }
582
583 #[cfg(not(unix))]
585 let _ = self.child.start_kill();
586
587 tokio::select! {
589 result = self.child.wait() => {
590 result?;
591 }
592 _ = sleep(Duration::from_secs(5)) => {
593 let _ = self.child.start_kill();
594 self.child.wait().await?;
595 }
596 }
597 Ok(())
598 }
599}
600
601impl Drop for BridgeProcess {
602 fn drop(&mut self) {
603 self.token.cancel();
604 let _ = self.child.start_kill();
606 }
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612
613 #[test]
614 fn broker_type_from_str_activemq() {
615 assert_eq!(
616 "activemq".parse::<BrokerType>().unwrap(),
617 BrokerType::ActiveMq
618 );
619 assert_eq!(
620 "ACTIVEMQ".parse::<BrokerType>().unwrap(),
621 BrokerType::ActiveMq
622 );
623 }
624
625 #[test]
626 fn broker_type_from_str_artemis() {
627 assert_eq!(
628 "artemis".parse::<BrokerType>().unwrap(),
629 BrokerType::Artemis
630 );
631 }
632
633 #[test]
634 fn broker_type_from_str_generic() {
635 assert_eq!(
636 "generic".parse::<BrokerType>().unwrap(),
637 BrokerType::Generic
638 );
639 }
640
641 #[test]
642 fn broker_type_from_str_unknown_returns_err() {
643 assert!("ibmmq".parse::<BrokerType>().is_err());
644 assert!("UnknownBroker".parse::<BrokerType>().is_err());
645 }
646
647 #[test]
648 fn broker_type_env_str() {
649 assert_eq!(BrokerType::ActiveMq.as_env_str(), "activemq");
650 assert_eq!(BrokerType::Artemis.as_env_str(), "artemis");
651 assert_eq!(BrokerType::Generic.as_env_str(), "generic");
652 }
653
654 #[test]
655 fn jms_constructor_uses_jms_spec() {
656 let cfg = BridgeProcessConfig::jms(
657 PathBuf::from("/tmp/jms-bridge"),
658 "tcp://localhost:61616".to_string(),
659 BrokerType::ActiveMq,
660 Some("user".to_string()),
661 Some(Redacted::new("pass".to_string())),
662 1000,
663 );
664 assert_eq!(cfg.spec.name, "jms-bridge");
665 }
666
667 #[test]
668 fn xml_constructor_uses_xml_spec() {
669 let cfg = BridgeProcessConfig::xml(PathBuf::from("/tmp/xml-bridge"), 1000);
670 assert_eq!(cfg.spec.name, "xml-bridge");
671 }
672
673 #[test]
674 fn cxf_profiles_generates_cxf_profiles_env_var() {
675 let profiles = vec![
676 CxfProfileEnvVars {
677 name: "baleares".to_string(),
678 wsdl_path: "/a.wsdl".to_string(),
679 service_name: "Svc".to_string(),
680 port_name: "Port".to_string(),
681 address: None,
682 keystore_path: None,
683 keystore_password: None,
684 truststore_path: None,
685 truststore_password: None,
686 sig_username: None,
687 sig_password: None,
688 enc_username: None,
689 security_actions_out: None,
690 security_actions_in: None,
691 signature_algorithm: None,
692 signature_digest_algorithm: None,
693 signature_c14n_algorithm: None,
694 signature_parts: None,
695 },
696 CxfProfileEnvVars {
697 name: "extremadura".to_string(),
698 wsdl_path: "/b.wsdl".to_string(),
699 service_name: "Svc2".to_string(),
700 port_name: "Port2".to_string(),
701 address: Some("http://host:9090/ws".to_string()),
702 keystore_path: Some("/b.jks".to_string()),
703 keystore_password: Some(Redacted::new("pass".to_string())),
704 truststore_path: None,
705 truststore_password: None,
706 sig_username: Some("cert".to_string()),
707 sig_password: Some(Redacted::new("sig_pass".to_string())),
708 enc_username: None,
709 security_actions_out: Some("Timestamp Signature".to_string()),
710 security_actions_in: Some("Timestamp Signature".to_string()),
711 signature_algorithm: None,
712 signature_digest_algorithm: None,
713 signature_c14n_algorithm: None,
714 signature_parts: None,
715 },
716 ];
717
718 let cfg =
719 BridgeProcessConfig::cxf_profiles(PathBuf::from("/tmp/cxf-bridge"), &profiles, 15_000);
720
721 assert_eq!(cfg.spec.name, "cxf-bridge");
722 assert!(cfg.broker_url.is_empty());
723 assert_eq!(cfg.broker_type, BrokerType::Generic);
724 assert!(cfg.username.is_none());
725 assert!(cfg.password.is_none());
726
727 let profiles_var = cfg
729 .env_vars
730 .iter()
731 .find(|(k, _)| k == "CXF_PROFILES")
732 .expect("CXF_PROFILES env var must exist");
733 assert_eq!(profiles_var.1, "baleares,extremadura");
734
735 assert!(
737 cfg.env_vars
738 .iter()
739 .any(|(k, v)| k == "CXF_PROFILE_BALEARES_WSDL_PATH" && v == "/a.wsdl")
740 );
741 assert!(
742 cfg.env_vars
743 .iter()
744 .any(|(k, v)| k == "CXF_PROFILE_BALEARES_SERVICE_NAME" && v == "Svc")
745 );
746 assert!(
747 cfg.env_vars
748 .iter()
749 .any(|(k, v)| k == "CXF_PROFILE_BALEARES_PORT_NAME" && v == "Port")
750 );
751 assert!(
752 !cfg.env_vars
753 .iter()
754 .any(|(k, _)| k == "CXF_PROFILE_BALEARES_ADDRESS")
755 );
756
757 assert!(
759 cfg.env_vars
760 .iter()
761 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_WSDL_PATH" && v == "/b.wsdl")
762 );
763 assert!(
764 cfg.env_vars
765 .iter()
766 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_ADDRESS" && v == "http://host:9090/ws")
767 );
768 assert!(
769 cfg.env_vars
770 .iter()
771 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_KEYSTORE_PATH" && v == "/b.jks")
772 );
773 assert!(
774 cfg.env_vars
775 .iter()
776 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_KEYSTORE_PASSWORD" && v == "pass")
777 );
778 assert!(
779 cfg.env_vars
780 .iter()
781 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_SIG_USERNAME" && v == "cert")
782 );
783 assert!(
784 cfg.env_vars
785 .iter()
786 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_SIG_PASSWORD" && v == "sig_pass")
787 );
788 assert!(
789 cfg.env_vars
790 .iter()
791 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_SECURITY_ACTIONS_OUT"
792 && v == "Timestamp Signature")
793 );
794 }
795
796 #[test]
797 fn cxf_profiles_single_profile_no_security() {
798 let profiles = vec![CxfProfileEnvVars {
799 name: "test".to_string(),
800 wsdl_path: "service.wsdl".to_string(),
801 service_name: "{http://example.com}Service".to_string(),
802 port_name: "{http://example.com}Port".to_string(),
803 address: None,
804 keystore_path: None,
805 keystore_password: None,
806 truststore_path: None,
807 truststore_password: None,
808 sig_username: None,
809 sig_password: None,
810 enc_username: None,
811 security_actions_out: None,
812 security_actions_in: None,
813 signature_algorithm: None,
814 signature_digest_algorithm: None,
815 signature_c14n_algorithm: None,
816 signature_parts: None,
817 }];
818
819 let cfg =
820 BridgeProcessConfig::cxf_profiles(PathBuf::from("/tmp/cxf-bridge"), &profiles, 15_000);
821
822 assert_eq!(cfg.spec.name, "cxf-bridge");
823 assert_eq!(cfg.env_vars.len(), 4);
825 assert_eq!(cfg.env_vars[0].0, "CXF_PROFILES");
826 assert_eq!(cfg.env_vars[0].1, "test");
827 assert_eq!(cfg.env_vars[1].0, "CXF_PROFILE_TEST_WSDL_PATH");
828 assert_eq!(cfg.env_vars[1].1, "service.wsdl");
829 assert_eq!(cfg.env_vars[2].0, "CXF_PROFILE_TEST_SERVICE_NAME");
830 assert_eq!(cfg.env_vars[2].1, "{http://example.com}Service");
831 assert_eq!(cfg.env_vars[3].0, "CXF_PROFILE_TEST_PORT_NAME");
832 assert_eq!(cfg.env_vars[3].1, "{http://example.com}Port");
833 }
834
835 #[test]
836 fn profile_env_vars_to_env_vars_includes_all_fields() {
837 let vars = CxfProfileEnvVars {
838 name: "full".to_string(),
839 wsdl_path: "/wsdl".to_string(),
840 service_name: "Svc".to_string(),
841 port_name: "Port".to_string(),
842 address: Some("http://host:8080".to_string()),
843 keystore_path: Some("/ks.jks".to_string()),
844 keystore_password: Some(Redacted::new("ks_pass".to_string())),
845 truststore_path: Some("/ts.jks".to_string()),
846 truststore_password: Some(Redacted::new("ts_pass".to_string())),
847 sig_username: Some("user".to_string()),
848 sig_password: Some(Redacted::new("sig_pass".to_string())),
849 enc_username: None,
850 security_actions_out: Some("Timestamp Signature".to_string()),
851 security_actions_in: Some("Timestamp".to_string()),
852 signature_algorithm: None,
853 signature_digest_algorithm: None,
854 signature_c14n_algorithm: None,
855 signature_parts: None,
856 };
857
858 let env = vars.to_env_vars();
859 assert_eq!(env.len(), 12);
861
862 let keys: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
863 assert!(keys.contains(&"CXF_PROFILE_FULL_WSDL_PATH"));
864 assert!(keys.contains(&"CXF_PROFILE_FULL_SERVICE_NAME"));
865 assert!(keys.contains(&"CXF_PROFILE_FULL_PORT_NAME"));
866 assert!(keys.contains(&"CXF_PROFILE_FULL_ADDRESS"));
867 assert!(keys.contains(&"CXF_PROFILE_FULL_KEYSTORE_PATH"));
868 assert!(keys.contains(&"CXF_PROFILE_FULL_KEYSTORE_PASSWORD"));
869 assert!(keys.contains(&"CXF_PROFILE_FULL_TRUSTSTORE_PATH"));
870 assert!(keys.contains(&"CXF_PROFILE_FULL_TRUSTSTORE_PASSWORD"));
871 assert!(keys.contains(&"CXF_PROFILE_FULL_SIG_USERNAME"));
872 assert!(keys.contains(&"CXF_PROFILE_FULL_SIG_PASSWORD"));
873 assert!(keys.contains(&"CXF_PROFILE_FULL_SECURITY_ACTIONS_OUT"));
874 assert!(keys.contains(&"CXF_PROFILE_FULL_SECURITY_ACTIONS_IN"));
875 }
876
877 #[test]
878 fn test_start_timeout_zero_rejected() {
879 let config = BridgeProcessConfig::jms(
880 PathBuf::from("/usr/bin/echo"),
881 "tcp://localhost:61616".to_string(),
882 BrokerType::ActiveMq,
883 None,
884 None,
885 0,
886 );
887 let result = config.validate();
888 assert!(result.is_err());
889 }
890
891 #[test]
892 fn test_bridge_rejects_zero_start_timeout() {
893 let config = BridgeProcessConfig::jms(
894 PathBuf::from("/usr/bin/echo"),
895 "tcp://localhost:61616".to_string(),
896 BrokerType::ActiveMq,
897 None,
898 None,
899 0,
900 );
901 assert!(config.validate().is_err());
902 }
903
904 #[tokio::test]
905 async fn test_bridge_stop_completes() {
906 use tokio::process::Command;
907 use tokio::time::{Duration, timeout};
908
909 let child = Command::new("sh")
910 .arg("-c")
911 .arg("trap '' TERM; while true; do echo tick; sleep 1; done")
912 .stdout(std::process::Stdio::null())
913 .spawn()
914 .expect("must spawn test child process");
915
916 let bridge = BridgeProcess {
917 child,
918 grpc_port: 0,
919 tls: crate::tls::BridgeTlsMaterial::generate().expect("test tls"),
920 token: CancellationToken::new(),
921 handle: None,
922 };
923
924 let result = timeout(Duration::from_secs(10), bridge.stop()).await;
925 assert!(result.is_ok(), "stop() must complete within 10s");
926 }
927
928 #[test]
931 fn redacted_debug_displays_redacted() {
932 let r = Redacted::new("secret_password".to_string());
933 assert_eq!(format!("{r:?}"), "[REDACTED]");
934 }
935
936 #[test]
937 fn redacted_display_displays_redacted() {
938 let r = Redacted::new("secret_password".to_string());
939 assert_eq!(format!("{r}"), "[REDACTED]");
940 }
941
942 #[test]
943 fn redacted_deref_gives_inner_value() {
944 let r = Redacted::new("secret".to_string());
945 assert_eq!(&*r, "secret");
946 }
947
948 #[test]
949 fn redacted_into_inner_returns_value() {
950 let r = Redacted::new("secret".to_string());
951 assert_eq!(r.into_inner(), "secret");
952 }
953
954 #[test]
955 fn redacted_clone_works() {
956 let r = Redacted::new("secret".to_string());
957 let c = r.clone();
958 assert_eq!(&*c, "secret");
959 assert_eq!(format!("{c:?}"), "[REDACTED]");
960 }
961
962 #[test]
963 fn bridge_process_config_debug_redacts_password() {
964 let cfg = BridgeProcessConfig::jms(
965 PathBuf::from("/tmp/jms-bridge"),
966 "tcp://localhost:61616".to_string(),
967 BrokerType::ActiveMq,
968 Some("user".to_string()),
969 Some(Redacted::new("super_secret".to_string())),
970 1000,
971 );
972 let password_debug = format!("{:?}", cfg.password); assert!(
977 !password_debug.contains("super_secret"),
978 "Password field must not leak in Debug: {password_debug}"
979 );
980 assert_eq!(
981 password_debug, "Some([REDACTED])",
982 "Password field must show [REDACTED]: {password_debug}"
983 );
984 }
985
986 #[test]
987 fn bridge_process_config_debug_redacts_broker_url_and_env_vars() {
988 let cfg = BridgeProcessConfig {
989 spec: &JMS_BRIDGE,
990 binary_path: PathBuf::from("/tmp/jms-bridge"),
991 broker_url: "amqp://u:SENTINEL-BRIDGE-PASS@host:5672".to_string(),
992 broker_type: BrokerType::ActiveMq,
993 username: Some("user".to_string()),
994 password: Some(Redacted::new("pass".to_string())),
995 start_timeout_ms: 1000,
996 env_vars: vec![(
997 "KEYSTORE_PASSWORD".to_string(),
998 "SENTINEL-ENV-PASS".to_string(),
999 )],
1000 };
1001 let debug_output = format!("{cfg:?}");
1002 assert!(
1003 !debug_output.contains("SENTINEL-BRIDGE-PASS"),
1004 "broker_url must be redacted in Debug: {debug_output}"
1005 );
1006 assert!(
1007 !debug_output.contains("SENTINEL-ENV-PASS"),
1008 "env_vars values must be redacted in Debug: {debug_output}"
1009 );
1010 assert!(
1011 debug_output.contains("[REDACTED; 1 entries]"),
1012 "env_vars must show redacted count: {debug_output}"
1013 );
1014 }
1015
1016 #[test]
1017 fn bridge_process_config_debug_redacts_all_sensitive_fields() {
1018 let cfg = BridgeProcessConfig {
1019 spec: &JMS_BRIDGE,
1020 binary_path: PathBuf::from("/tmp/jms-bridge"),
1021 broker_url: "amqp://SENTINEL-BROKER-URL@host:5672".to_string(),
1022 broker_type: BrokerType::ActiveMq,
1023 username: Some("SENTINEL-USERNAME".to_string()),
1024 password: Some(Redacted::new("SENTINEL-PASSWORD".to_string())),
1025 start_timeout_ms: 1000,
1026 env_vars: vec![(
1027 "KEYSTORE_PASSWORD".to_string(),
1028 "SENTINEL-ENV-PASS".to_string(),
1029 )],
1030 };
1031 let debug_output = format!("{cfg:?}");
1032 assert!(
1033 !debug_output.contains("SENTINEL-BROKER-URL")
1034 && !debug_output.contains("SENTINEL-USERNAME")
1035 && !debug_output.contains("SENTINEL-PASSWORD")
1036 && !debug_output.contains("SENTINEL-ENV-PASS"),
1037 "No sensitive field may leak in Debug: {debug_output}"
1038 );
1039 assert!(
1040 debug_output.contains("username: Some(\"[REDACTED]\")"),
1041 "username must be masked in Debug: {debug_output}"
1042 );
1043 }
1044
1045 #[test]
1046 fn cxf_profile_debug_redacts_passwords() {
1047 let profile = CxfProfileEnvVars {
1048 name: "test".to_string(),
1049 wsdl_path: "/a.wsdl".to_string(),
1050 service_name: "Svc".to_string(),
1051 port_name: "Port".to_string(),
1052 address: None,
1053 keystore_path: None,
1054 keystore_password: Some(Redacted::new("ks_secret_val".to_string())),
1055 truststore_path: None,
1056 truststore_password: Some(Redacted::new("ts_secret_val".to_string())),
1057 sig_username: None,
1058 sig_password: Some(Redacted::new("sig_secret_val".to_string())),
1059 enc_username: None,
1060 security_actions_out: None,
1061 security_actions_in: None,
1062 signature_algorithm: None,
1063 signature_digest_algorithm: None,
1064 signature_c14n_algorithm: None,
1065 signature_parts: None,
1066 };
1067 let debug_output = format!("{profile:?}");
1068 assert!(
1069 !debug_output.contains("ks_secret_val")
1070 && !debug_output.contains("ts_secret_val")
1071 && !debug_output.contains("sig_secret_val"),
1072 "Debug must not contain passwords: {debug_output}"
1073 );
1074 }
1075
1076 async fn spawn_child_with_stdout(script: &str) -> tokio::process::Child {
1086 use tokio::process::Command;
1087
1088 Command::new("sh")
1089 .arg("-c")
1090 .arg(script)
1091 .stdin(std::process::Stdio::null())
1092 .stdout(std::process::Stdio::piped())
1093 .stderr(std::process::Stdio::null())
1094 .spawn()
1095 .expect("must spawn test child")
1096 }
1097
1098 #[tokio::test]
1099 async fn drain_chatty_child_no_deadlock() {
1100 let mut child =
1102 spawn_child_with_stdout("for i in $(seq 1 500); do echo \"line $i\"; done").await;
1103
1104 let stdout = child.stdout.take().expect("stdout piped");
1105 let reader = tokio::io::BufReader::new(stdout);
1106 let token = CancellationToken::new();
1107
1108 let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1110
1111 let status = child.wait().await.expect("wait for child");
1113 assert!(status.success(), "child should exit successfully");
1114
1115 let result = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
1117 assert!(result.is_ok(), "drain task should complete within 2s");
1118 }
1119
1120 #[tokio::test]
1121 async fn drain_oversized_line_bounded() {
1122 let oversized_bytes = 200 * 1024; let mut child = spawn_child_with_stdout(&format!(
1132 "head -c {oversized_bytes} /dev/zero | tr '\\0' 'A'; echo"
1133 ))
1134 .await;
1135
1136 let stdout = child.stdout.take().expect("stdout piped");
1137 let reader = tokio::io::BufReader::new(stdout);
1138 let token = CancellationToken::new();
1139
1140 let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1141
1142 let status = child.wait().await.expect("wait for child");
1143 assert!(status.success(), "child should exit successfully");
1144
1145 let result = tokio::time::timeout(std::time::Duration::from_millis(500), handle).await;
1149 assert!(result.is_ok(), "drain task should complete within 500ms");
1150 }
1151
1152 #[tokio::test]
1153 async fn drain_cancellation_exits_promptly() {
1154 let mut child = spawn_child_with_stdout("while true; do echo tick; sleep 1; done").await;
1156
1157 let stdout = child.stdout.take().expect("stdout piped");
1158 let reader = tokio::io::BufReader::new(stdout);
1159 let token = CancellationToken::new();
1160
1161 let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1163
1164 token.cancel();
1167 let result = tokio::time::timeout(std::time::Duration::from_millis(500), handle).await;
1168 assert!(
1169 result.is_ok(),
1170 "drain task should exit promptly after cancellation"
1171 );
1172
1173 let _ = child.kill().await;
1175 }
1176
1177 #[tokio::test]
1185 async fn drain_handoff_preserves_trailing_bytes() {
1186 use tokio::io::AsyncBufReadExt;
1187
1188 let mut child = spawn_child_with_stdout(
1190 r#"echo '{"status":"ready","port":12345}'; echo "trailing-1"; echo "trailing-2"; echo "trailing-3""#,
1191 )
1192 .await;
1193
1194 let stdout = child.stdout.take().expect("stdout piped");
1195 let mut reader = tokio::io::BufReader::new(stdout);
1196
1197 let mut buf_acc: Vec<u8> = Vec::new();
1199 const READY_MAX_LINE: usize = 64 * 1024;
1200 let mut port_found = None;
1201 while let Ok(chunk) = reader.fill_buf().await {
1202 if chunk.is_empty() {
1203 break; }
1205 let newline_pos = chunk.iter().position(|&b| b == b'\n');
1206 let take = match newline_pos {
1207 Some(i) => i + 1,
1208 None => chunk.len(),
1209 };
1210 if buf_acc.len() + take <= READY_MAX_LINE {
1211 buf_acc.extend_from_slice(&chunk[..take]);
1212 }
1213 reader.consume(take);
1214 if newline_pos.is_some() {
1215 let line = String::from_utf8_lossy(&buf_acc);
1216 let line_trimmed = line.trim_end_matches('\n');
1217 if let Ok(v) = serde_json::from_str::<serde_json::Value>(line_trimmed)
1218 && v.get("status").and_then(|s| s.as_str()) == Some("ready")
1219 && let Some(p) = v.get("port").and_then(|p| p.as_u64())
1220 {
1221 port_found = Some(p as u16);
1222 break;
1223 }
1224 buf_acc.clear();
1225 }
1226 }
1227 assert_eq!(port_found, Some(12345), "ready message must be parsed");
1228
1229 let token = CancellationToken::new();
1231 let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1232
1233 let status = tokio::time::timeout(std::time::Duration::from_secs(5), child.wait())
1236 .await
1237 .expect("child exits within 5s")
1238 .expect("wait for child");
1239 assert!(status.success(), "child should exit successfully");
1240
1241 let result = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
1243 assert!(
1244 result.is_ok(),
1245 "drain task should complete — trailing bytes preserved through handoff"
1246 );
1247 }
1248}