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)
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> {
416 use tokio::io::AsyncBufReadExt;
417 use tokio::process::Command;
418 use tokio::time::{Duration, timeout};
419
420 config.validate().map_err(BridgeError::Config)?;
421
422 let tls = crate::tls::BridgeTlsMaterial::generate()?;
423
424 let free_port = {
426 let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
427 listener.local_addr()?.port()
428 };
429
430 let stderr_stdio: std::process::Stdio =
432 if let Ok(log_dir) = std::env::var("CAMEL_BRIDGE_LOG_STDERR") {
433 let log_filename = config
434 .spec
435 .log_file_template
436 .replace("{pid}", &std::process::id().to_string());
437 let log_path = if log_dir.is_empty() {
438 format!("/tmp/{log_filename}")
439 } else {
440 format!("{log_dir}/{log_filename}")
441 };
442 match std::fs::File::create(&log_path) {
443 Ok(f) => {
444 eprintln!("[camel-bridge] stderr → {}", log_path);
445 f.into()
446 }
447 Err(e) => {
448 eprintln!(
449 "[camel-bridge] failed to create log file {}: {}",
450 log_path, e
451 );
452 std::process::Stdio::inherit()
453 }
454 }
455 } else {
456 std::process::Stdio::inherit()
457 };
458
459 let mut command = Command::new(&config.binary_path);
460 command
461 .env("QUARKUS_HTTP_SSL_PORT", free_port.to_string())
462 .env(
463 "QUARKUS_TLS_BRIDGE_KEY_STORE_PEM_0_CERT",
464 &tls.server_pem_path,
465 )
466 .env(
467 "QUARKUS_TLS_BRIDGE_KEY_STORE_PEM_0_KEY",
468 &tls.server_key_path,
469 )
470 .env("QUARKUS_TLS_BRIDGE_TRUST_STORE_PEM_CERTS", &tls.ca_pem_path)
471 .stdout(std::process::Stdio::piped())
472 .stderr(stderr_stdio);
473
474 for (key, value) in &config.env_vars {
476 command.env(key, value);
477 }
478
479 let mut child = command.spawn()?;
480
481 let stdout = child.stdout.take().ok_or(BridgeError::StdoutClosed)?;
482 let mut reader = tokio::io::BufReader::new(stdout);
483
484 let port = timeout(Duration::from_millis(config.start_timeout_ms), async {
488 let mut buf_acc: Vec<u8> = Vec::new();
489 const READY_MAX_LINE: usize = 64 * 1024;
490 while let Ok(chunk) = reader.fill_buf().await {
491 if chunk.is_empty() {
492 break; }
494 let newline_pos = chunk.iter().position(|&b| b == b'\n');
495 let take = match newline_pos {
496 Some(i) => i + 1,
497 None => chunk.len(),
498 };
499 if buf_acc.len() + take <= READY_MAX_LINE {
500 buf_acc.extend_from_slice(&chunk[..take]);
501 }
502 reader.consume(take);
503 if newline_pos.is_some() {
504 let line = String::from_utf8_lossy(&buf_acc);
505 let line_trimmed = line.trim_end_matches('\n');
506 if let Ok(v) = serde_json::from_str::<serde_json::Value>(line_trimmed)
507 && v.get("status").and_then(|s| s.as_str()) == Some("ready")
508 {
509 if let Some(p) = v.get("port").and_then(|p| p.as_u64()) {
510 return Ok(p as u16);
511 }
512 tracing::error!("bridge ready message malformed: {line_trimmed}");
514 return Err(BridgeError::BadReadyMessage(line_trimmed.to_string()));
515 }
516 buf_acc.clear();
517 }
518 }
519 tracing::error!("bridge stdout closed before ready message");
521 Err(BridgeError::StdoutClosed)
522 })
523 .await
524 .map_err(|_| {
525 let msg = format!(
526 "{} failed to start: health check timeout after {}ms",
527 config.spec.name, config.start_timeout_ms
528 );
529 tracing::error!("{msg}");
531 BridgeError::Timeout(msg)
532 })??;
533
534 let token = CancellationToken::new();
539 let child_token = token.clone();
540 let handle = tokio::spawn(async move {
541 drain_stdout(reader, child_token).await;
542 });
543
544 Ok(BridgeProcess {
545 child,
546 grpc_port: port,
547 tls,
548 token,
549 handle: Some(handle),
550 })
551 }
552
553 pub async fn stop(mut self) -> Result<(), BridgeError> {
555 use tokio::time::{Duration, sleep};
556
557 self.token.cancel();
558
559 if let Some(handle) = self.handle.take() {
560 let join_result = tokio::time::timeout(Duration::from_secs(5), handle).await;
561 if join_result.is_err() {
562 tracing::warn!("bridge stdout drain task did not exit after cancellation");
563 }
564 }
565
566 #[cfg(unix)]
568 {
569 let pid = self.child.id().unwrap_or(0);
570 if pid > 0 {
571 unsafe {
573 libc::kill(pid as i32, libc::SIGTERM);
574 }
575 }
576 }
577
578 #[cfg(not(unix))]
580 let _ = self.child.start_kill();
581
582 tokio::select! {
584 result = self.child.wait() => {
585 result?;
586 }
587 _ = sleep(Duration::from_secs(5)) => {
588 let _ = self.child.start_kill();
589 self.child.wait().await?;
590 }
591 }
592 Ok(())
593 }
594}
595
596impl Drop for BridgeProcess {
597 fn drop(&mut self) {
598 self.token.cancel();
599 let _ = self.child.start_kill();
601 }
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607
608 #[test]
609 fn broker_type_from_str_activemq() {
610 assert_eq!(
611 "activemq".parse::<BrokerType>().unwrap(),
612 BrokerType::ActiveMq
613 );
614 assert_eq!(
615 "ACTIVEMQ".parse::<BrokerType>().unwrap(),
616 BrokerType::ActiveMq
617 );
618 }
619
620 #[test]
621 fn broker_type_from_str_artemis() {
622 assert_eq!(
623 "artemis".parse::<BrokerType>().unwrap(),
624 BrokerType::Artemis
625 );
626 }
627
628 #[test]
629 fn broker_type_from_str_generic() {
630 assert_eq!(
631 "generic".parse::<BrokerType>().unwrap(),
632 BrokerType::Generic
633 );
634 }
635
636 #[test]
637 fn broker_type_from_str_unknown_returns_err() {
638 assert!("ibmmq".parse::<BrokerType>().is_err());
639 assert!("UnknownBroker".parse::<BrokerType>().is_err());
640 }
641
642 #[test]
643 fn broker_type_env_str() {
644 assert_eq!(BrokerType::ActiveMq.as_env_str(), "activemq");
645 assert_eq!(BrokerType::Artemis.as_env_str(), "artemis");
646 assert_eq!(BrokerType::Generic.as_env_str(), "generic");
647 }
648
649 #[test]
650 fn jms_constructor_uses_jms_spec() {
651 let cfg = BridgeProcessConfig::jms(
652 PathBuf::from("/tmp/jms-bridge"),
653 "tcp://localhost:61616".to_string(),
654 BrokerType::ActiveMq,
655 Some("user".to_string()),
656 Some(Redacted::new("pass".to_string())),
657 1000,
658 );
659 assert_eq!(cfg.spec.name, "jms-bridge");
660 }
661
662 #[test]
663 fn xml_constructor_uses_xml_spec() {
664 let cfg = BridgeProcessConfig::xml(PathBuf::from("/tmp/xml-bridge"), 1000);
665 assert_eq!(cfg.spec.name, "xml-bridge");
666 }
667
668 #[test]
669 fn cxf_profiles_generates_cxf_profiles_env_var() {
670 let profiles = vec![
671 CxfProfileEnvVars {
672 name: "baleares".to_string(),
673 wsdl_path: "/a.wsdl".to_string(),
674 service_name: "Svc".to_string(),
675 port_name: "Port".to_string(),
676 address: None,
677 keystore_path: None,
678 keystore_password: None,
679 truststore_path: None,
680 truststore_password: None,
681 sig_username: None,
682 sig_password: None,
683 enc_username: None,
684 security_actions_out: None,
685 security_actions_in: None,
686 signature_algorithm: None,
687 signature_digest_algorithm: None,
688 signature_c14n_algorithm: None,
689 signature_parts: None,
690 },
691 CxfProfileEnvVars {
692 name: "extremadura".to_string(),
693 wsdl_path: "/b.wsdl".to_string(),
694 service_name: "Svc2".to_string(),
695 port_name: "Port2".to_string(),
696 address: Some("http://host:9090/ws".to_string()),
697 keystore_path: Some("/b.jks".to_string()),
698 keystore_password: Some(Redacted::new("pass".to_string())),
699 truststore_path: None,
700 truststore_password: None,
701 sig_username: Some("cert".to_string()),
702 sig_password: Some(Redacted::new("sig_pass".to_string())),
703 enc_username: None,
704 security_actions_out: Some("Timestamp Signature".to_string()),
705 security_actions_in: Some("Timestamp Signature".to_string()),
706 signature_algorithm: None,
707 signature_digest_algorithm: None,
708 signature_c14n_algorithm: None,
709 signature_parts: None,
710 },
711 ];
712
713 let cfg =
714 BridgeProcessConfig::cxf_profiles(PathBuf::from("/tmp/cxf-bridge"), &profiles, 15_000);
715
716 assert_eq!(cfg.spec.name, "cxf-bridge");
717 assert!(cfg.broker_url.is_empty());
718 assert_eq!(cfg.broker_type, BrokerType::Generic);
719 assert!(cfg.username.is_none());
720 assert!(cfg.password.is_none());
721
722 let profiles_var = cfg
724 .env_vars
725 .iter()
726 .find(|(k, _)| k == "CXF_PROFILES")
727 .expect("CXF_PROFILES env var must exist");
728 assert_eq!(profiles_var.1, "baleares,extremadura");
729
730 assert!(
732 cfg.env_vars
733 .iter()
734 .any(|(k, v)| k == "CXF_PROFILE_BALEARES_WSDL_PATH" && v == "/a.wsdl")
735 );
736 assert!(
737 cfg.env_vars
738 .iter()
739 .any(|(k, v)| k == "CXF_PROFILE_BALEARES_SERVICE_NAME" && v == "Svc")
740 );
741 assert!(
742 cfg.env_vars
743 .iter()
744 .any(|(k, v)| k == "CXF_PROFILE_BALEARES_PORT_NAME" && v == "Port")
745 );
746 assert!(
747 !cfg.env_vars
748 .iter()
749 .any(|(k, _)| k == "CXF_PROFILE_BALEARES_ADDRESS")
750 );
751
752 assert!(
754 cfg.env_vars
755 .iter()
756 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_WSDL_PATH" && v == "/b.wsdl")
757 );
758 assert!(
759 cfg.env_vars
760 .iter()
761 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_ADDRESS" && v == "http://host:9090/ws")
762 );
763 assert!(
764 cfg.env_vars
765 .iter()
766 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_KEYSTORE_PATH" && v == "/b.jks")
767 );
768 assert!(
769 cfg.env_vars
770 .iter()
771 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_KEYSTORE_PASSWORD" && v == "pass")
772 );
773 assert!(
774 cfg.env_vars
775 .iter()
776 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_SIG_USERNAME" && v == "cert")
777 );
778 assert!(
779 cfg.env_vars
780 .iter()
781 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_SIG_PASSWORD" && v == "sig_pass")
782 );
783 assert!(
784 cfg.env_vars
785 .iter()
786 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_SECURITY_ACTIONS_OUT"
787 && v == "Timestamp Signature")
788 );
789 }
790
791 #[test]
792 fn cxf_profiles_single_profile_no_security() {
793 let profiles = vec![CxfProfileEnvVars {
794 name: "test".to_string(),
795 wsdl_path: "service.wsdl".to_string(),
796 service_name: "{http://example.com}Service".to_string(),
797 port_name: "{http://example.com}Port".to_string(),
798 address: None,
799 keystore_path: None,
800 keystore_password: None,
801 truststore_path: None,
802 truststore_password: None,
803 sig_username: None,
804 sig_password: None,
805 enc_username: None,
806 security_actions_out: None,
807 security_actions_in: None,
808 signature_algorithm: None,
809 signature_digest_algorithm: None,
810 signature_c14n_algorithm: None,
811 signature_parts: None,
812 }];
813
814 let cfg =
815 BridgeProcessConfig::cxf_profiles(PathBuf::from("/tmp/cxf-bridge"), &profiles, 15_000);
816
817 assert_eq!(cfg.spec.name, "cxf-bridge");
818 assert_eq!(cfg.env_vars.len(), 4);
820 assert_eq!(cfg.env_vars[0].0, "CXF_PROFILES");
821 assert_eq!(cfg.env_vars[0].1, "test");
822 assert_eq!(cfg.env_vars[1].0, "CXF_PROFILE_TEST_WSDL_PATH");
823 assert_eq!(cfg.env_vars[1].1, "service.wsdl");
824 assert_eq!(cfg.env_vars[2].0, "CXF_PROFILE_TEST_SERVICE_NAME");
825 assert_eq!(cfg.env_vars[2].1, "{http://example.com}Service");
826 assert_eq!(cfg.env_vars[3].0, "CXF_PROFILE_TEST_PORT_NAME");
827 assert_eq!(cfg.env_vars[3].1, "{http://example.com}Port");
828 }
829
830 #[test]
831 fn profile_env_vars_to_env_vars_includes_all_fields() {
832 let vars = CxfProfileEnvVars {
833 name: "full".to_string(),
834 wsdl_path: "/wsdl".to_string(),
835 service_name: "Svc".to_string(),
836 port_name: "Port".to_string(),
837 address: Some("http://host:8080".to_string()),
838 keystore_path: Some("/ks.jks".to_string()),
839 keystore_password: Some(Redacted::new("ks_pass".to_string())),
840 truststore_path: Some("/ts.jks".to_string()),
841 truststore_password: Some(Redacted::new("ts_pass".to_string())),
842 sig_username: Some("user".to_string()),
843 sig_password: Some(Redacted::new("sig_pass".to_string())),
844 enc_username: None,
845 security_actions_out: Some("Timestamp Signature".to_string()),
846 security_actions_in: Some("Timestamp".to_string()),
847 signature_algorithm: None,
848 signature_digest_algorithm: None,
849 signature_c14n_algorithm: None,
850 signature_parts: None,
851 };
852
853 let env = vars.to_env_vars();
854 assert_eq!(env.len(), 12);
856
857 let keys: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
858 assert!(keys.contains(&"CXF_PROFILE_FULL_WSDL_PATH"));
859 assert!(keys.contains(&"CXF_PROFILE_FULL_SERVICE_NAME"));
860 assert!(keys.contains(&"CXF_PROFILE_FULL_PORT_NAME"));
861 assert!(keys.contains(&"CXF_PROFILE_FULL_ADDRESS"));
862 assert!(keys.contains(&"CXF_PROFILE_FULL_KEYSTORE_PATH"));
863 assert!(keys.contains(&"CXF_PROFILE_FULL_KEYSTORE_PASSWORD"));
864 assert!(keys.contains(&"CXF_PROFILE_FULL_TRUSTSTORE_PATH"));
865 assert!(keys.contains(&"CXF_PROFILE_FULL_TRUSTSTORE_PASSWORD"));
866 assert!(keys.contains(&"CXF_PROFILE_FULL_SIG_USERNAME"));
867 assert!(keys.contains(&"CXF_PROFILE_FULL_SIG_PASSWORD"));
868 assert!(keys.contains(&"CXF_PROFILE_FULL_SECURITY_ACTIONS_OUT"));
869 assert!(keys.contains(&"CXF_PROFILE_FULL_SECURITY_ACTIONS_IN"));
870 }
871
872 #[test]
873 fn test_start_timeout_zero_rejected() {
874 let config = BridgeProcessConfig::jms(
875 PathBuf::from("/usr/bin/echo"),
876 "tcp://localhost:61616".to_string(),
877 BrokerType::ActiveMq,
878 None,
879 None,
880 0,
881 );
882 let result = config.validate();
883 assert!(result.is_err());
884 }
885
886 #[test]
887 fn test_bridge_rejects_zero_start_timeout() {
888 let config = BridgeProcessConfig::jms(
889 PathBuf::from("/usr/bin/echo"),
890 "tcp://localhost:61616".to_string(),
891 BrokerType::ActiveMq,
892 None,
893 None,
894 0,
895 );
896 assert!(config.validate().is_err());
897 }
898
899 #[tokio::test]
900 async fn test_bridge_stop_completes() {
901 use tokio::process::Command;
902 use tokio::time::{Duration, timeout};
903
904 let child = Command::new("sh")
905 .arg("-c")
906 .arg("trap '' TERM; while true; do echo tick; sleep 1; done")
907 .stdout(std::process::Stdio::null())
908 .spawn()
909 .expect("must spawn test child process");
910
911 let bridge = BridgeProcess {
912 child,
913 grpc_port: 0,
914 tls: crate::tls::BridgeTlsMaterial::generate().expect("test tls"),
915 token: CancellationToken::new(),
916 handle: None,
917 };
918
919 let result = timeout(Duration::from_secs(10), bridge.stop()).await;
920 assert!(result.is_ok(), "stop() must complete within 10s");
921 }
922
923 #[test]
926 fn redacted_debug_displays_redacted() {
927 let r = Redacted::new("secret_password".to_string());
928 assert_eq!(format!("{r:?}"), "[REDACTED]");
929 }
930
931 #[test]
932 fn redacted_display_displays_redacted() {
933 let r = Redacted::new("secret_password".to_string());
934 assert_eq!(format!("{r}"), "[REDACTED]");
935 }
936
937 #[test]
938 fn redacted_deref_gives_inner_value() {
939 let r = Redacted::new("secret".to_string());
940 assert_eq!(&*r, "secret");
941 }
942
943 #[test]
944 fn redacted_into_inner_returns_value() {
945 let r = Redacted::new("secret".to_string());
946 assert_eq!(r.into_inner(), "secret");
947 }
948
949 #[test]
950 fn redacted_clone_works() {
951 let r = Redacted::new("secret".to_string());
952 let c = r.clone();
953 assert_eq!(&*c, "secret");
954 assert_eq!(format!("{c:?}"), "[REDACTED]");
955 }
956
957 #[test]
958 fn bridge_process_config_debug_redacts_password() {
959 let cfg = BridgeProcessConfig::jms(
960 PathBuf::from("/tmp/jms-bridge"),
961 "tcp://localhost:61616".to_string(),
962 BrokerType::ActiveMq,
963 Some("user".to_string()),
964 Some(Redacted::new("super_secret".to_string())),
965 1000,
966 );
967 let password_debug = format!("{:?}", cfg.password); assert!(
972 !password_debug.contains("super_secret"),
973 "Password field must not leak in Debug: {password_debug}"
974 );
975 assert_eq!(
976 password_debug, "Some([REDACTED])",
977 "Password field must show [REDACTED]: {password_debug}"
978 );
979 }
980
981 #[test]
982 fn bridge_process_config_debug_redacts_broker_url_and_env_vars() {
983 let cfg = BridgeProcessConfig {
984 spec: &JMS_BRIDGE,
985 binary_path: PathBuf::from("/tmp/jms-bridge"),
986 broker_url: "amqp://u:SENTINEL-BRIDGE-PASS@host:5672".to_string(),
987 broker_type: BrokerType::ActiveMq,
988 username: Some("user".to_string()),
989 password: Some(Redacted::new("pass".to_string())),
990 start_timeout_ms: 1000,
991 env_vars: vec![(
992 "KEYSTORE_PASSWORD".to_string(),
993 "SENTINEL-ENV-PASS".to_string(),
994 )],
995 };
996 let debug_output = format!("{cfg:?}");
997 assert!(
998 !debug_output.contains("SENTINEL-BRIDGE-PASS"),
999 "broker_url must be redacted in Debug: {debug_output}"
1000 );
1001 assert!(
1002 !debug_output.contains("SENTINEL-ENV-PASS"),
1003 "env_vars values must be redacted in Debug: {debug_output}"
1004 );
1005 assert!(
1006 debug_output.contains("[REDACTED; 1 entries]"),
1007 "env_vars must show redacted count: {debug_output}"
1008 );
1009 }
1010
1011 #[test]
1012 fn cxf_profile_debug_redacts_passwords() {
1013 let profile = CxfProfileEnvVars {
1014 name: "test".to_string(),
1015 wsdl_path: "/a.wsdl".to_string(),
1016 service_name: "Svc".to_string(),
1017 port_name: "Port".to_string(),
1018 address: None,
1019 keystore_path: None,
1020 keystore_password: Some(Redacted::new("ks_secret_val".to_string())),
1021 truststore_path: None,
1022 truststore_password: Some(Redacted::new("ts_secret_val".to_string())),
1023 sig_username: None,
1024 sig_password: Some(Redacted::new("sig_secret_val".to_string())),
1025 enc_username: None,
1026 security_actions_out: None,
1027 security_actions_in: None,
1028 signature_algorithm: None,
1029 signature_digest_algorithm: None,
1030 signature_c14n_algorithm: None,
1031 signature_parts: None,
1032 };
1033 let debug_output = format!("{profile:?}");
1034 assert!(
1035 !debug_output.contains("ks_secret_val")
1036 && !debug_output.contains("ts_secret_val")
1037 && !debug_output.contains("sig_secret_val"),
1038 "Debug must not contain passwords: {debug_output}"
1039 );
1040 }
1041
1042 async fn spawn_child_with_stdout(script: &str) -> tokio::process::Child {
1046 use tokio::process::Command;
1047
1048 Command::new("sh")
1049 .arg("-c")
1050 .arg(script)
1051 .stdout(std::process::Stdio::piped())
1052 .spawn()
1053 .expect("must spawn test child")
1054 }
1055
1056 #[tokio::test]
1057 async fn drain_chatty_child_no_deadlock() {
1058 use tokio::io::AsyncBufReadExt;
1059
1060 let mut child =
1062 spawn_child_with_stdout("for i in $(seq 1 500); do echo \"line $i\"; done").await;
1063
1064 let stdout = child.stdout.take().expect("stdout piped");
1065 let reader = tokio::io::BufReader::new(stdout);
1066 let token = CancellationToken::new();
1067
1068 let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1070
1071 let status = child.wait().await.expect("wait for child");
1073 assert!(status.success(), "child should exit successfully");
1074
1075 let result = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
1077 assert!(result.is_ok(), "drain task should complete within 2s");
1078 }
1079
1080 #[tokio::test]
1081 async fn drain_oversized_line_bounded() {
1082 use tokio::io::AsyncBufReadExt;
1083
1084 let oversized_bytes = 200 * 1024; let mut child = spawn_child_with_stdout(&format!(
1094 "head -c {oversized_bytes} /dev/zero | tr '\\0' 'A'; echo"
1095 ))
1096 .await;
1097
1098 let stdout = child.stdout.take().expect("stdout piped");
1099 let reader = tokio::io::BufReader::new(stdout);
1100 let token = CancellationToken::new();
1101
1102 let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1103
1104 let status = child.wait().await.expect("wait for child");
1105 assert!(status.success(), "child should exit successfully");
1106
1107 let result = tokio::time::timeout(std::time::Duration::from_millis(500), handle).await;
1111 assert!(result.is_ok(), "drain task should complete within 500ms");
1112 }
1113
1114 #[tokio::test]
1115 async fn drain_cancellation_exits_promptly() {
1116 use tokio::io::AsyncBufReadExt;
1117
1118 let mut child = spawn_child_with_stdout("while true; do echo tick; sleep 1; done").await;
1120
1121 let stdout = child.stdout.take().expect("stdout piped");
1122 let reader = tokio::io::BufReader::new(stdout);
1123 let token = CancellationToken::new();
1124
1125 let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1127
1128 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1130
1131 token.cancel();
1133 let result = tokio::time::timeout(std::time::Duration::from_millis(500), handle).await;
1134 assert!(
1135 result.is_ok(),
1136 "drain task should exit promptly after cancellation"
1137 );
1138
1139 let _ = child.kill().await;
1141 }
1142
1143 #[tokio::test]
1151 async fn drain_handoff_preserves_trailing_bytes() {
1152 use tokio::io::AsyncBufReadExt;
1153
1154 let mut child = spawn_child_with_stdout(
1156 r#"echo '{"status":"ready","port":12345}'; echo "trailing-1"; echo "trailing-2"; echo "trailing-3""#,
1157 )
1158 .await;
1159
1160 let stdout = child.stdout.take().expect("stdout piped");
1161 let mut reader = tokio::io::BufReader::new(stdout);
1162
1163 let mut buf_acc: Vec<u8> = Vec::new();
1165 const READY_MAX_LINE: usize = 64 * 1024;
1166 let mut port_found = None;
1167 while let Ok(chunk) = reader.fill_buf().await {
1168 if chunk.is_empty() {
1169 break; }
1171 let newline_pos = chunk.iter().position(|&b| b == b'\n');
1172 let take = match newline_pos {
1173 Some(i) => i + 1,
1174 None => chunk.len(),
1175 };
1176 if buf_acc.len() + take <= READY_MAX_LINE {
1177 buf_acc.extend_from_slice(&chunk[..take]);
1178 }
1179 reader.consume(take);
1180 if newline_pos.is_some() {
1181 let line = String::from_utf8_lossy(&buf_acc);
1182 let line_trimmed = line.trim_end_matches('\n');
1183 if let Ok(v) = serde_json::from_str::<serde_json::Value>(line_trimmed)
1184 && v.get("status").and_then(|s| s.as_str()) == Some("ready")
1185 {
1186 if let Some(p) = v.get("port").and_then(|p| p.as_u64()) {
1187 port_found = Some(p as u16);
1188 break;
1189 }
1190 }
1191 buf_acc.clear();
1192 }
1193 }
1194 assert_eq!(port_found, Some(12345), "ready message must be parsed");
1195
1196 let token = CancellationToken::new();
1198 let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1199
1200 let status = child.wait().await.expect("wait for child");
1202 assert!(status.success(), "child should exit successfully");
1203
1204 let result = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
1206 assert!(
1207 result.is_ok(),
1208 "drain task should complete — trailing bytes preserved through handoff"
1209 );
1210 }
1211}