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}
76
77#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
78#[serde(rename_all = "lowercase")]
79pub enum BrokerType {
80 #[serde(alias = "active_mq")]
81 ActiveMq,
82 Artemis,
83 Generic,
84}
85
86impl BrokerType {
87 pub fn as_env_str(&self) -> &'static str {
88 match self {
89 BrokerType::ActiveMq => "activemq",
90 BrokerType::Artemis => "artemis",
91 BrokerType::Generic => "generic",
92 }
93 }
94}
95
96impl std::str::FromStr for BrokerType {
97 type Err = BridgeError;
98
99 fn from_str(s: &str) -> Result<Self, Self::Err> {
100 match s.to_lowercase().as_str() {
101 "activemq" => Ok(BrokerType::ActiveMq),
102 "artemis" => Ok(BrokerType::Artemis),
103 "generic" => Ok(BrokerType::Generic),
104 other => Err(BridgeError::Config(format!("unknown broker type: {other}"))), }
106 }
107}
108
109#[derive(Debug)]
112pub struct CxfProfileEnvVars {
113 pub name: String,
114 pub wsdl_path: String,
115 pub service_name: String,
116 pub port_name: String,
117 pub address: Option<String>,
118 pub keystore_path: Option<String>,
119 pub keystore_password: Option<Redacted<String>>,
120 pub truststore_path: Option<String>,
121 pub truststore_password: Option<Redacted<String>>,
122 pub sig_username: Option<String>,
123 pub sig_password: Option<Redacted<String>>,
124 pub enc_username: Option<String>,
125 pub security_actions_out: Option<String>,
126 pub security_actions_in: Option<String>,
127 pub signature_algorithm: Option<String>,
128 pub signature_digest_algorithm: Option<String>,
129 pub signature_c14n_algorithm: Option<String>,
130 pub signature_parts: Option<String>,
131}
132
133impl CxfProfileEnvVars {
134 pub fn to_env_vars(&self) -> Vec<(String, String)> {
135 let prefix = format!("CXF_PROFILE_{}_", self.name.to_uppercase());
136 let mut vars = vec![
137 (format!("{}WSDL_PATH", prefix), self.wsdl_path.clone()),
138 (format!("{}SERVICE_NAME", prefix), self.service_name.clone()),
139 (format!("{}PORT_NAME", prefix), self.port_name.clone()),
140 ];
141
142 if let Some(ref v) = self.address {
143 vars.push((format!("{}ADDRESS", prefix), v.clone()));
144 }
145 if let Some(ref v) = self.keystore_path {
146 vars.push((format!("{}KEYSTORE_PATH", prefix), v.clone()));
147 }
148 if let Some(ref v) = self.keystore_password {
149 vars.push((format!("{}KEYSTORE_PASSWORD", prefix), (**v).clone()));
150 }
151 if let Some(ref v) = self.truststore_path {
152 vars.push((format!("{}TRUSTSTORE_PATH", prefix), v.clone()));
153 }
154 if let Some(ref v) = self.truststore_password {
155 vars.push((format!("{}TRUSTSTORE_PASSWORD", prefix), (**v).clone()));
156 }
157 if let Some(ref v) = self.sig_username {
158 vars.push((format!("{}SIG_USERNAME", prefix), v.clone()));
159 }
160 if let Some(ref v) = self.sig_password {
161 vars.push((format!("{}SIG_PASSWORD", prefix), (**v).clone()));
162 }
163 if let Some(ref v) = self.enc_username {
164 vars.push((format!("{}ENC_USERNAME", prefix), v.clone()));
165 }
166 if let Some(ref v) = self.security_actions_out {
167 vars.push((format!("{}SECURITY_ACTIONS_OUT", prefix), v.clone()));
168 }
169 if let Some(ref v) = self.security_actions_in {
170 vars.push((format!("{}SECURITY_ACTIONS_IN", prefix), v.clone()));
171 }
172 if let Some(ref v) = self.signature_algorithm {
173 vars.push((format!("{}SIGNATURE_ALGORITHM", prefix), v.clone()));
174 }
175 if let Some(ref v) = self.signature_digest_algorithm {
176 vars.push((format!("{}SIGNATURE_DIGEST_ALGORITHM", prefix), v.clone()));
177 }
178 if let Some(ref v) = self.signature_c14n_algorithm {
179 vars.push((format!("{}SIGNATURE_C14N_ALGORITHM", prefix), v.clone()));
180 }
181 if let Some(ref v) = self.signature_parts {
182 vars.push((format!("{}SIGNATURE_PARTS", prefix), v.clone()));
183 }
184
185 vars
186 }
187}
188
189pub struct BridgeProcessConfig {
194 pub spec: &'static BridgeSpec,
195 pub binary_path: PathBuf,
196 pub broker_url: String,
197 pub broker_type: BrokerType,
198 pub username: Option<String>,
199 pub password: Option<Redacted<String>>,
200 pub start_timeout_ms: u64,
201 pub env_vars: Vec<(String, String)>,
202}
203
204impl fmt::Debug for BridgeProcessConfig {
205 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206 f.debug_struct("BridgeProcessConfig")
207 .field("spec", &self.spec)
208 .field("binary_path", &self.binary_path)
209 .field("broker_url", &"[REDACTED]")
210 .field("broker_type", &self.broker_type)
211 .field("username", &self.username.as_ref().map(|_| "[REDACTED]"))
212 .field("password", &self.password)
213 .field("start_timeout_ms", &self.start_timeout_ms)
214 .field(
215 "env_vars",
216 &format!("[REDACTED; {} entries]", self.env_vars.len()),
217 )
218 .finish()
219 }
220}
221
222impl BridgeProcessConfig {
223 #[allow(clippy::too_many_arguments)]
231 pub fn jms(
232 binary_path: PathBuf,
233 broker_url: String,
234 broker_type: BrokerType,
235 username: Option<String>,
236 password: Option<Redacted<String>>,
237 start_timeout_ms: u64,
238 journal_dir: Option<String>,
239 broker_key: Option<String>,
240 ) -> Self {
241 let mut env_vars = vec![
242 ("BRIDGE_BROKER_URL".to_string(), broker_url.clone()),
243 (
244 "BRIDGE_BROKER_TYPE".to_string(),
245 broker_type.as_env_str().to_string(),
246 ),
247 ];
248 if let Some(u) = &username {
249 env_vars.push(("BRIDGE_USERNAME".to_string(), u.clone()));
250 }
251 if let Some(p) = &password {
252 env_vars.push(("BRIDGE_PASSWORD".to_string(), (**p).clone()));
253 }
254 if let Some(dir) = journal_dir {
255 env_vars.push(("JMS_ATTEMPT_JOURNAL_DIR".to_string(), dir));
256 }
257 if let Some(key) = broker_key {
258 env_vars.push(("JMS_BRIDGE_BROKER_KEY".to_string(), key));
259 }
260 Self {
261 spec: &JMS_BRIDGE,
262 binary_path,
263 broker_url,
264 broker_type,
265 username,
266 password,
267 start_timeout_ms,
268 env_vars,
269 }
270 }
271
272 pub fn xml(binary_path: PathBuf, start_timeout_ms: u64) -> Self {
274 Self {
275 spec: &XML_BRIDGE,
276 binary_path,
277 broker_url: String::new(),
278 broker_type: BrokerType::Generic,
279 username: None,
280 password: None,
281 start_timeout_ms,
282 env_vars: vec![],
283 }
284 }
285
286 pub fn cxf_profiles(
289 binary_path: PathBuf,
290 profiles: &[CxfProfileEnvVars],
291 start_timeout_ms: u64,
292 ) -> Self {
293 let profile_names: Vec<String> = profiles.iter().map(|p| p.name.clone()).collect();
294 let mut env_vars = vec![("CXF_PROFILES".to_string(), profile_names.join(","))];
295
296 for profile in profiles {
297 env_vars.extend(profile.to_env_vars());
298 }
299
300 Self {
301 spec: &CXF_BRIDGE,
302 binary_path,
303 broker_url: String::new(),
304 broker_type: BrokerType::Generic,
305 username: None,
306 password: None,
307 start_timeout_ms,
308 env_vars,
309 }
310 }
311
312 pub fn validate(&self) -> Result<(), String> {
313 if self.start_timeout_ms == 0 {
314 return Err("start_timeout_ms must be > 0".to_string());
315 }
316 Ok(())
317 }
318}
319
320async fn drain_stdout<R: tokio::io::AsyncBufRead + Unpin>(mut reader: R, token: CancellationToken) {
325 use tokio::io::AsyncBufReadExt;
326 use tokio::time::{Duration, Instant};
327
328 const MAX_LINE_BYTES: usize = 64 * 1024;
329 const LOG_INTERVAL: Duration = Duration::from_secs(1);
330 const LOG_BUDGET: u32 = 100;
331
332 let mut line_buf: Vec<u8> = Vec::new();
333 let mut oversized = false;
334 let mut interval_start = Instant::now();
335 let mut logged: u32 = 0;
336 let mut dropped: u32 = 0;
337
338 loop {
339 tokio::select! {
340 biased;
341 _ = token.cancelled() => break,
342 res = reader.fill_buf() => {
343 let chunk_len = match res {
344 Ok(chunk) => {
345 if chunk.is_empty() {
346 break; }
348 for &b in chunk {
349 if b == b'\n' {
350 if oversized {
351 if logged < LOG_BUDGET {
353 tracing::warn!(
354 "bridge stdout: oversized line (>{MAX_LINE_BYTES} bytes), truncated"
355 );
356 logged += 1;
357 } else {
358 dropped += 1;
359 }
360 } else if logged < LOG_BUDGET {
361 tracing::debug!(
363 target: "camel_bridge::child",
364 "{}",
365 String::from_utf8_lossy(&line_buf)
366 );
367 logged += 1;
368 } else {
369 dropped += 1;
370 }
371 line_buf.clear();
372 oversized = false;
373 } else if !oversized {
374 if line_buf.len() < MAX_LINE_BYTES {
375 line_buf.push(b);
376 } else {
377 oversized = true;
378 }
379 }
380 }
381 chunk.len()
382 }
383 Err(_) => break,
384 };
385 reader.consume(chunk_len);
386 if interval_start.elapsed() >= LOG_INTERVAL {
387 if dropped > 0 {
388 tracing::debug!(
390 "bridge stdout: dropped {dropped} lines in last interval"
391 );
392 }
393 interval_start = Instant::now();
394 logged = 0;
395 dropped = 0;
396 }
397 }
398 }
399 }
400}
401
402#[derive(Debug, PartialEq, Eq)]
408enum ReadyLine {
409 Ready(u16),
411 PermanentFailure(String),
413 MalformedReady,
415}
416
417fn parse_ready_line(line: &str) -> Option<ReadyLine> {
420 let value: serde_json::Value = serde_json::from_str(line).ok()?;
421 match value.get("status").and_then(|s| s.as_str())? {
422 "ready" => match value.get("port").and_then(|p| p.as_u64()) {
423 Some(port) => Some(ReadyLine::Ready(port as u16)),
424 None => Some(ReadyLine::MalformedReady),
425 },
426 "permanent-failure" => Some(ReadyLine::PermanentFailure(
427 value
428 .get("reason")
429 .and_then(|r| r.as_str())
430 .unwrap_or("bridge reported a permanent startup failure")
431 .to_string(),
432 )),
433 _ => None,
434 }
435}
436
437async fn read_ready_port<R: tokio::io::AsyncBufRead + Unpin>(
442 reader: &mut R,
443) -> Result<u16, BridgeError> {
444 use tokio::io::AsyncBufReadExt;
445
446 let mut buf_acc: Vec<u8> = Vec::new();
447 const READY_MAX_LINE: usize = 64 * 1024;
448 while let Ok(chunk) = reader.fill_buf().await {
449 if chunk.is_empty() {
450 break; }
452 let newline_pos = chunk.iter().position(|&b| b == b'\n');
453 let take = match newline_pos {
454 Some(i) => i + 1,
455 None => chunk.len(),
456 };
457 if buf_acc.len() + take <= READY_MAX_LINE {
458 buf_acc.extend_from_slice(&chunk[..take]);
459 }
460 reader.consume(take);
461 if newline_pos.is_some() {
462 let line = String::from_utf8_lossy(&buf_acc);
463 let line_trimmed = line.trim_end_matches('\n');
464 match parse_ready_line(line_trimmed) {
465 Some(ReadyLine::Ready(port)) => return Ok(port),
466 Some(ReadyLine::PermanentFailure(reason)) => {
467 return Err(BridgeError::PermanentFailure(reason));
468 }
469 Some(ReadyLine::MalformedReady) => {
470 tracing::error!("bridge ready message malformed: {line_trimmed}");
472 return Err(BridgeError::BadReadyMessage(line_trimmed.to_string()));
473 }
474 None => {}
475 }
476 buf_acc.clear();
477 }
478 }
479 tracing::error!("bridge stdout closed before ready message");
481 Err(BridgeError::StdoutClosed)
482}
483
484async fn eof_exit_error(child: &mut tokio::process::Child) -> BridgeError {
492 use tokio::time::{Duration, timeout};
493
494 match timeout(Duration::from_secs(2), child.wait()).await {
495 Ok(Ok(status)) if status.code() == Some(78) => BridgeError::PermanentFailure(
496 "bridge exited with code 78 before announcing readiness".to_string(),
497 ),
498 Ok(_) => BridgeError::StdoutClosed,
499 Err(_) => {
500 let _ = child.start_kill();
503 let _ = timeout(Duration::from_secs(2), child.wait()).await;
504 BridgeError::StdoutClosed
505 }
506 }
507}
508
509pub struct BridgeProcess {
510 child: tokio::process::Child,
511 grpc_port: u16,
512 tls: crate::tls::BridgeTlsMaterial,
513 token: CancellationToken,
514 handle: Option<JoinHandle<()>>,
515}
516
517impl BridgeProcess {
518 pub fn grpc_port(&self) -> u16 {
519 self.grpc_port
520 }
521
522 pub async fn connect(&self) -> Result<tonic::transport::Channel, BridgeError> {
524 crate::channel::connect_channel(self.grpc_port, &self.tls).await
525 }
526
527 pub async fn start_and_connect(
529 config: &BridgeProcessConfig,
530 ) -> Result<(Self, tonic::transport::Channel), BridgeError> {
531 let process = Self::start(config).await?;
532 let channel = process.connect().await?;
533 Ok((process, channel))
534 }
535
536 pub async fn start(config: &BridgeProcessConfig) -> Result<Self, BridgeError> {
548 use tokio::process::Command;
549 use tokio::time::{Duration, timeout};
550
551 config.validate().map_err(BridgeError::Config)?;
552
553 let tls = crate::tls::BridgeTlsMaterial::generate()?;
554
555 let free_port: u16 = 0;
561
562 let stderr_stdio: std::process::Stdio =
564 if let Ok(log_dir) = std::env::var("CAMEL_BRIDGE_LOG_STDERR") {
565 let log_filename = config
566 .spec
567 .log_file_template
568 .replace("{pid}", &std::process::id().to_string());
569 let log_path = if log_dir.is_empty() {
570 format!("/tmp/{log_filename}")
571 } else {
572 format!("{log_dir}/{log_filename}")
573 };
574 match std::fs::File::create(&log_path) {
575 Ok(f) => {
576 eprintln!("[camel-bridge] stderr → {}", log_path);
577 f.into()
578 }
579 Err(e) => {
580 eprintln!(
581 "[camel-bridge] failed to create log file {}: {}",
582 log_path, e
583 );
584 std::process::Stdio::inherit()
585 }
586 }
587 } else {
588 std::process::Stdio::inherit()
589 };
590
591 let mut command = Command::new(&config.binary_path);
592 command
593 .kill_on_drop(true)
596 .env("QUARKUS_HTTP_SSL_PORT", free_port.to_string())
597 .env(
598 "QUARKUS_TLS_BRIDGE_KEY_STORE_PEM_0_CERT",
599 &tls.server_pem_path,
600 )
601 .env(
602 "QUARKUS_TLS_BRIDGE_KEY_STORE_PEM_0_KEY",
603 &tls.server_key_path,
604 )
605 .env("QUARKUS_TLS_BRIDGE_TRUST_STORE_PEM_CERTS", &tls.ca_pem_path)
606 .stdout(std::process::Stdio::piped())
607 .stderr(stderr_stdio);
608
609 for (key, value) in &config.env_vars {
611 command.env(key, value);
612 }
613
614 let mut child = command.spawn()?;
615
616 let stdout = child.stdout.take().ok_or(BridgeError::StdoutClosed)?;
617 let mut reader = tokio::io::BufReader::new(stdout);
618
619 let ready = timeout(
623 Duration::from_millis(config.start_timeout_ms),
624 read_ready_port(&mut reader),
625 )
626 .await;
627
628 let port = match ready {
629 Ok(Ok(port)) => port,
630 Ok(Err(BridgeError::StdoutClosed)) => {
631 return Err(eof_exit_error(&mut child).await);
636 }
637 Ok(Err(error)) => {
638 return Err(error);
643 }
644 Err(_) => {
645 let msg = format!(
646 "{} failed to start: health check timeout after {}ms",
647 config.spec.name, config.start_timeout_ms
648 );
649 tracing::error!("{msg}");
651 return Err(BridgeError::Timeout(msg));
653 }
654 };
655
656 let token = CancellationToken::new();
661 let child_token = token.clone();
662 let handle = tokio::spawn(async move {
663 drain_stdout(reader, child_token).await;
664 });
665
666 Ok(BridgeProcess {
667 child,
668 grpc_port: port,
669 tls,
670 token,
671 handle: Some(handle),
672 })
673 }
674
675 pub async fn stop(mut self) -> Result<(), BridgeError> {
677 use tokio::time::{Duration, sleep};
678
679 self.token.cancel();
680
681 if let Some(handle) = self.handle.take() {
682 let join_result = tokio::time::timeout(Duration::from_secs(5), handle).await;
683 if join_result.is_err() {
684 tracing::warn!("bridge stdout drain task did not exit after cancellation");
685 }
686 }
687
688 #[cfg(unix)]
690 {
691 let pid = self.child.id().unwrap_or(0);
692 if pid > 0 {
693 unsafe {
695 libc::kill(pid as i32, libc::SIGTERM);
696 }
697 }
698 }
699
700 #[cfg(not(unix))]
702 let _ = self.child.start_kill();
703
704 tokio::select! {
706 result = self.child.wait() => {
707 result?;
708 }
709 _ = sleep(Duration::from_secs(5)) => {
710 let _ = self.child.start_kill();
711 self.child.wait().await?;
712 }
713 }
714 Ok(())
715 }
716}
717
718impl Drop for BridgeProcess {
719 fn drop(&mut self) {
720 self.token.cancel();
721 let _ = self.child.start_kill();
723 }
724}
725
726#[cfg(test)]
727mod tests {
728 use super::*;
729
730 #[test]
731 fn broker_type_from_str_activemq() {
732 assert_eq!(
733 "activemq".parse::<BrokerType>().unwrap(),
734 BrokerType::ActiveMq
735 );
736 assert_eq!(
737 "ACTIVEMQ".parse::<BrokerType>().unwrap(),
738 BrokerType::ActiveMq
739 );
740 }
741
742 #[test]
743 fn broker_type_from_str_artemis() {
744 assert_eq!(
745 "artemis".parse::<BrokerType>().unwrap(),
746 BrokerType::Artemis
747 );
748 }
749
750 #[test]
751 fn broker_type_from_str_generic() {
752 assert_eq!(
753 "generic".parse::<BrokerType>().unwrap(),
754 BrokerType::Generic
755 );
756 }
757
758 #[test]
759 fn broker_type_from_str_unknown_returns_err() {
760 assert!("ibmmq".parse::<BrokerType>().is_err());
761 assert!("UnknownBroker".parse::<BrokerType>().is_err());
762 }
763
764 #[test]
765 fn broker_type_env_str() {
766 assert_eq!(BrokerType::ActiveMq.as_env_str(), "activemq");
767 assert_eq!(BrokerType::Artemis.as_env_str(), "artemis");
768 assert_eq!(BrokerType::Generic.as_env_str(), "generic");
769 }
770
771 #[test]
772 fn jms_constructor_uses_jms_spec() {
773 let cfg = BridgeProcessConfig::jms(
774 PathBuf::from("/tmp/jms-bridge"),
775 "tcp://localhost:61616".to_string(),
776 BrokerType::ActiveMq,
777 Some("user".to_string()),
778 Some(Redacted::new("pass".to_string())),
779 1000,
780 Some("/var/lib/state/rust-camel/jms-bridge".to_string()),
781 Some("0123456789abcdef0123456789abcdef".to_string()),
782 );
783 assert_eq!(cfg.spec.name, "jms-bridge");
784 }
785
786 #[test]
790 fn journal_env_passed_on_launch_and_restart() {
791 let dir = "/var/lib/state/rust-camel/jms-bridge".to_string();
792 let key = "0123456789abcdef0123456789abcdef".to_string();
793
794 let initial = BridgeProcessConfig::jms(
795 PathBuf::from("/tmp/jms-bridge"),
796 "tcp://localhost:61616".to_string(),
797 BrokerType::ActiveMq,
798 None,
799 None,
800 1000,
801 Some(dir.clone()),
802 Some(key.clone()),
803 );
804 let restart = BridgeProcessConfig::jms(
805 PathBuf::from("/tmp/jms-bridge"),
806 "tcp://localhost:61616".to_string(),
807 BrokerType::ActiveMq,
808 None,
809 None,
810 1000,
811 Some(dir.clone()),
812 Some(key.clone()),
813 );
814
815 for cfg in [&initial, &restart] {
816 assert!(
817 cfg.env_vars
818 .iter()
819 .any(|(k, v)| k == "JMS_ATTEMPT_JOURNAL_DIR" && v == &dir),
820 "JMS_ATTEMPT_JOURNAL_DIR must be present"
821 );
822 assert!(
823 cfg.env_vars
824 .iter()
825 .any(|(k, v)| k == "JMS_BRIDGE_BROKER_KEY" && v == &key),
826 "JMS_BRIDGE_BROKER_KEY must be present"
827 );
828 }
829
830 let unset = BridgeProcessConfig::jms(
832 PathBuf::from("/tmp/jms-bridge"),
833 "tcp://localhost:61616".to_string(),
834 BrokerType::ActiveMq,
835 None,
836 None,
837 1000,
838 None,
839 None,
840 );
841 assert!(
842 !unset
843 .env_vars
844 .iter()
845 .any(|(k, _)| k == "JMS_ATTEMPT_JOURNAL_DIR" || k == "JMS_BRIDGE_BROKER_KEY"),
846 "unset journal env must not be injected"
847 );
848 }
849
850 #[test]
851 fn xml_constructor_uses_xml_spec() {
852 let cfg = BridgeProcessConfig::xml(PathBuf::from("/tmp/xml-bridge"), 1000);
853 assert_eq!(cfg.spec.name, "xml-bridge");
854 }
855
856 #[test]
857 fn cxf_profiles_generates_cxf_profiles_env_var() {
858 let profiles = vec![
859 CxfProfileEnvVars {
860 name: "baleares".to_string(),
861 wsdl_path: "/a.wsdl".to_string(),
862 service_name: "Svc".to_string(),
863 port_name: "Port".to_string(),
864 address: None,
865 keystore_path: None,
866 keystore_password: None,
867 truststore_path: None,
868 truststore_password: None,
869 sig_username: None,
870 sig_password: None,
871 enc_username: None,
872 security_actions_out: None,
873 security_actions_in: None,
874 signature_algorithm: None,
875 signature_digest_algorithm: None,
876 signature_c14n_algorithm: None,
877 signature_parts: None,
878 },
879 CxfProfileEnvVars {
880 name: "extremadura".to_string(),
881 wsdl_path: "/b.wsdl".to_string(),
882 service_name: "Svc2".to_string(),
883 port_name: "Port2".to_string(),
884 address: Some("http://host:9090/ws".to_string()),
885 keystore_path: Some("/b.jks".to_string()),
886 keystore_password: Some(Redacted::new("pass".to_string())),
887 truststore_path: None,
888 truststore_password: None,
889 sig_username: Some("cert".to_string()),
890 sig_password: Some(Redacted::new("sig_pass".to_string())),
891 enc_username: None,
892 security_actions_out: Some("Timestamp Signature".to_string()),
893 security_actions_in: Some("Timestamp Signature".to_string()),
894 signature_algorithm: None,
895 signature_digest_algorithm: None,
896 signature_c14n_algorithm: None,
897 signature_parts: None,
898 },
899 ];
900
901 let cfg =
902 BridgeProcessConfig::cxf_profiles(PathBuf::from("/tmp/cxf-bridge"), &profiles, 15_000);
903
904 assert_eq!(cfg.spec.name, "cxf-bridge");
905 assert!(cfg.broker_url.is_empty());
906 assert_eq!(cfg.broker_type, BrokerType::Generic);
907 assert!(cfg.username.is_none());
908 assert!(cfg.password.is_none());
909
910 let profiles_var = cfg
912 .env_vars
913 .iter()
914 .find(|(k, _)| k == "CXF_PROFILES")
915 .expect("CXF_PROFILES env var must exist");
916 assert_eq!(profiles_var.1, "baleares,extremadura");
917
918 assert!(
920 cfg.env_vars
921 .iter()
922 .any(|(k, v)| k == "CXF_PROFILE_BALEARES_WSDL_PATH" && v == "/a.wsdl")
923 );
924 assert!(
925 cfg.env_vars
926 .iter()
927 .any(|(k, v)| k == "CXF_PROFILE_BALEARES_SERVICE_NAME" && v == "Svc")
928 );
929 assert!(
930 cfg.env_vars
931 .iter()
932 .any(|(k, v)| k == "CXF_PROFILE_BALEARES_PORT_NAME" && v == "Port")
933 );
934 assert!(
935 !cfg.env_vars
936 .iter()
937 .any(|(k, _)| k == "CXF_PROFILE_BALEARES_ADDRESS")
938 );
939
940 assert!(
942 cfg.env_vars
943 .iter()
944 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_WSDL_PATH" && v == "/b.wsdl")
945 );
946 assert!(
947 cfg.env_vars
948 .iter()
949 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_ADDRESS" && v == "http://host:9090/ws")
950 );
951 assert!(
952 cfg.env_vars
953 .iter()
954 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_KEYSTORE_PATH" && v == "/b.jks")
955 );
956 assert!(
957 cfg.env_vars
958 .iter()
959 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_KEYSTORE_PASSWORD" && v == "pass")
960 );
961 assert!(
962 cfg.env_vars
963 .iter()
964 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_SIG_USERNAME" && v == "cert")
965 );
966 assert!(
967 cfg.env_vars
968 .iter()
969 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_SIG_PASSWORD" && v == "sig_pass")
970 );
971 assert!(
972 cfg.env_vars
973 .iter()
974 .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_SECURITY_ACTIONS_OUT"
975 && v == "Timestamp Signature")
976 );
977 }
978
979 #[test]
980 fn cxf_profiles_single_profile_no_security() {
981 let profiles = vec![CxfProfileEnvVars {
982 name: "test".to_string(),
983 wsdl_path: "service.wsdl".to_string(),
984 service_name: "{http://example.com}Service".to_string(),
985 port_name: "{http://example.com}Port".to_string(),
986 address: None,
987 keystore_path: None,
988 keystore_password: None,
989 truststore_path: None,
990 truststore_password: None,
991 sig_username: None,
992 sig_password: None,
993 enc_username: None,
994 security_actions_out: None,
995 security_actions_in: None,
996 signature_algorithm: None,
997 signature_digest_algorithm: None,
998 signature_c14n_algorithm: None,
999 signature_parts: None,
1000 }];
1001
1002 let cfg =
1003 BridgeProcessConfig::cxf_profiles(PathBuf::from("/tmp/cxf-bridge"), &profiles, 15_000);
1004
1005 assert_eq!(cfg.spec.name, "cxf-bridge");
1006 assert_eq!(cfg.env_vars.len(), 4);
1008 assert_eq!(cfg.env_vars[0].0, "CXF_PROFILES");
1009 assert_eq!(cfg.env_vars[0].1, "test");
1010 assert_eq!(cfg.env_vars[1].0, "CXF_PROFILE_TEST_WSDL_PATH");
1011 assert_eq!(cfg.env_vars[1].1, "service.wsdl");
1012 assert_eq!(cfg.env_vars[2].0, "CXF_PROFILE_TEST_SERVICE_NAME");
1013 assert_eq!(cfg.env_vars[2].1, "{http://example.com}Service");
1014 assert_eq!(cfg.env_vars[3].0, "CXF_PROFILE_TEST_PORT_NAME");
1015 assert_eq!(cfg.env_vars[3].1, "{http://example.com}Port");
1016 }
1017
1018 #[test]
1019 fn profile_env_vars_to_env_vars_includes_all_fields() {
1020 let vars = CxfProfileEnvVars {
1021 name: "full".to_string(),
1022 wsdl_path: "/wsdl".to_string(),
1023 service_name: "Svc".to_string(),
1024 port_name: "Port".to_string(),
1025 address: Some("http://host:8080".to_string()),
1026 keystore_path: Some("/ks.jks".to_string()),
1027 keystore_password: Some(Redacted::new("ks_pass".to_string())),
1028 truststore_path: Some("/ts.jks".to_string()),
1029 truststore_password: Some(Redacted::new("ts_pass".to_string())),
1030 sig_username: Some("user".to_string()),
1031 sig_password: Some(Redacted::new("sig_pass".to_string())),
1032 enc_username: None,
1033 security_actions_out: Some("Timestamp Signature".to_string()),
1034 security_actions_in: Some("Timestamp".to_string()),
1035 signature_algorithm: None,
1036 signature_digest_algorithm: None,
1037 signature_c14n_algorithm: None,
1038 signature_parts: None,
1039 };
1040
1041 let env = vars.to_env_vars();
1042 assert_eq!(env.len(), 12);
1044
1045 let keys: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
1046 assert!(keys.contains(&"CXF_PROFILE_FULL_WSDL_PATH"));
1047 assert!(keys.contains(&"CXF_PROFILE_FULL_SERVICE_NAME"));
1048 assert!(keys.contains(&"CXF_PROFILE_FULL_PORT_NAME"));
1049 assert!(keys.contains(&"CXF_PROFILE_FULL_ADDRESS"));
1050 assert!(keys.contains(&"CXF_PROFILE_FULL_KEYSTORE_PATH"));
1051 assert!(keys.contains(&"CXF_PROFILE_FULL_KEYSTORE_PASSWORD"));
1052 assert!(keys.contains(&"CXF_PROFILE_FULL_TRUSTSTORE_PATH"));
1053 assert!(keys.contains(&"CXF_PROFILE_FULL_TRUSTSTORE_PASSWORD"));
1054 assert!(keys.contains(&"CXF_PROFILE_FULL_SIG_USERNAME"));
1055 assert!(keys.contains(&"CXF_PROFILE_FULL_SIG_PASSWORD"));
1056 assert!(keys.contains(&"CXF_PROFILE_FULL_SECURITY_ACTIONS_OUT"));
1057 assert!(keys.contains(&"CXF_PROFILE_FULL_SECURITY_ACTIONS_IN"));
1058 }
1059
1060 #[test]
1061 fn test_start_timeout_zero_rejected() {
1062 let config = BridgeProcessConfig::jms(
1063 PathBuf::from("/usr/bin/echo"),
1064 "tcp://localhost:61616".to_string(),
1065 BrokerType::ActiveMq,
1066 None,
1067 None,
1068 0,
1069 None,
1070 None,
1071 );
1072 let result = config.validate();
1073 assert!(result.is_err());
1074 }
1075
1076 #[test]
1077 fn test_bridge_rejects_zero_start_timeout() {
1078 let config = BridgeProcessConfig::jms(
1079 PathBuf::from("/usr/bin/echo"),
1080 "tcp://localhost:61616".to_string(),
1081 BrokerType::ActiveMq,
1082 None,
1083 None,
1084 0,
1085 None,
1086 None,
1087 );
1088 assert!(config.validate().is_err());
1089 }
1090
1091 #[tokio::test]
1092 async fn test_bridge_stop_completes() {
1093 use tokio::process::Command;
1094 use tokio::time::{Duration, timeout};
1095
1096 let child = Command::new("sh")
1097 .arg("-c")
1098 .arg("trap '' TERM; while true; do echo tick; sleep 1; done")
1099 .stdout(std::process::Stdio::null())
1100 .spawn()
1101 .expect("must spawn test child process");
1102
1103 let bridge = BridgeProcess {
1104 child,
1105 grpc_port: 0,
1106 tls: crate::tls::BridgeTlsMaterial::generate().expect("test tls"),
1107 token: CancellationToken::new(),
1108 handle: None,
1109 };
1110
1111 let result = timeout(Duration::from_secs(10), bridge.stop()).await;
1112 assert!(result.is_ok(), "stop() must complete within 10s");
1113 }
1114
1115 #[test]
1118 fn redacted_debug_displays_redacted() {
1119 let r = Redacted::new("secret_password".to_string());
1120 assert_eq!(format!("{r:?}"), "[REDACTED]");
1121 }
1122
1123 #[test]
1124 fn redacted_display_displays_redacted() {
1125 let r = Redacted::new("secret_password".to_string());
1126 assert_eq!(format!("{r}"), "[REDACTED]");
1127 }
1128
1129 #[test]
1130 fn redacted_deref_gives_inner_value() {
1131 let r = Redacted::new("secret".to_string());
1132 assert_eq!(&*r, "secret");
1133 }
1134
1135 #[test]
1136 fn redacted_into_inner_returns_value() {
1137 let r = Redacted::new("secret".to_string());
1138 assert_eq!(r.into_inner(), "secret");
1139 }
1140
1141 #[test]
1142 fn redacted_clone_works() {
1143 let r = Redacted::new("secret".to_string());
1144 let c = r.clone();
1145 assert_eq!(&*c, "secret");
1146 assert_eq!(format!("{c:?}"), "[REDACTED]");
1147 }
1148
1149 #[test]
1150 fn bridge_process_config_debug_redacts_password() {
1151 let cfg = BridgeProcessConfig::jms(
1152 PathBuf::from("/tmp/jms-bridge"),
1153 "tcp://localhost:61616".to_string(),
1154 BrokerType::ActiveMq,
1155 Some("user".to_string()),
1156 Some(Redacted::new("super_secret".to_string())),
1157 1000,
1158 None,
1159 None,
1160 );
1161 let password_debug = format!("{:?}", cfg.password); assert!(
1166 !password_debug.contains("super_secret"),
1167 "Password field must not leak in Debug: {password_debug}"
1168 );
1169 assert_eq!(
1170 password_debug, "Some([REDACTED])",
1171 "Password field must show [REDACTED]: {password_debug}"
1172 );
1173 }
1174
1175 #[test]
1176 fn bridge_process_config_debug_redacts_broker_url_and_env_vars() {
1177 let cfg = BridgeProcessConfig {
1178 spec: &JMS_BRIDGE,
1179 binary_path: PathBuf::from("/tmp/jms-bridge"),
1180 broker_url: "amqp://u:SENTINEL-BRIDGE-PASS@host:5672".to_string(),
1181 broker_type: BrokerType::ActiveMq,
1182 username: Some("user".to_string()),
1183 password: Some(Redacted::new("pass".to_string())),
1184 start_timeout_ms: 1000,
1185 env_vars: vec![(
1186 "KEYSTORE_PASSWORD".to_string(),
1187 "SENTINEL-ENV-PASS".to_string(),
1188 )],
1189 };
1190 let debug_output = format!("{cfg:?}");
1191 assert!(
1192 !debug_output.contains("SENTINEL-BRIDGE-PASS"),
1193 "broker_url must be redacted in Debug: {debug_output}"
1194 );
1195 assert!(
1196 !debug_output.contains("SENTINEL-ENV-PASS"),
1197 "env_vars values must be redacted in Debug: {debug_output}"
1198 );
1199 assert!(
1200 debug_output.contains("[REDACTED; 1 entries]"),
1201 "env_vars must show redacted count: {debug_output}"
1202 );
1203 }
1204
1205 #[test]
1206 fn bridge_process_config_debug_redacts_all_sensitive_fields() {
1207 let cfg = BridgeProcessConfig {
1208 spec: &JMS_BRIDGE,
1209 binary_path: PathBuf::from("/tmp/jms-bridge"),
1210 broker_url: "amqp://SENTINEL-BROKER-URL@host:5672".to_string(),
1211 broker_type: BrokerType::ActiveMq,
1212 username: Some("SENTINEL-USERNAME".to_string()),
1213 password: Some(Redacted::new("SENTINEL-PASSWORD".to_string())),
1214 start_timeout_ms: 1000,
1215 env_vars: vec![(
1216 "KEYSTORE_PASSWORD".to_string(),
1217 "SENTINEL-ENV-PASS".to_string(),
1218 )],
1219 };
1220 let debug_output = format!("{cfg:?}");
1221 assert!(
1222 !debug_output.contains("SENTINEL-BROKER-URL")
1223 && !debug_output.contains("SENTINEL-USERNAME")
1224 && !debug_output.contains("SENTINEL-PASSWORD")
1225 && !debug_output.contains("SENTINEL-ENV-PASS"),
1226 "No sensitive field may leak in Debug: {debug_output}"
1227 );
1228 assert!(
1229 debug_output.contains("username: Some(\"[REDACTED]\")"),
1230 "username must be masked in Debug: {debug_output}"
1231 );
1232 }
1233
1234 #[test]
1235 fn cxf_profile_debug_redacts_passwords() {
1236 let profile = CxfProfileEnvVars {
1237 name: "test".to_string(),
1238 wsdl_path: "/a.wsdl".to_string(),
1239 service_name: "Svc".to_string(),
1240 port_name: "Port".to_string(),
1241 address: None,
1242 keystore_path: None,
1243 keystore_password: Some(Redacted::new("ks_secret_val".to_string())),
1244 truststore_path: None,
1245 truststore_password: Some(Redacted::new("ts_secret_val".to_string())),
1246 sig_username: None,
1247 sig_password: Some(Redacted::new("sig_secret_val".to_string())),
1248 enc_username: None,
1249 security_actions_out: None,
1250 security_actions_in: None,
1251 signature_algorithm: None,
1252 signature_digest_algorithm: None,
1253 signature_c14n_algorithm: None,
1254 signature_parts: None,
1255 };
1256 let debug_output = format!("{profile:?}");
1257 assert!(
1258 !debug_output.contains("ks_secret_val")
1259 && !debug_output.contains("ts_secret_val")
1260 && !debug_output.contains("sig_secret_val"),
1261 "Debug must not contain passwords: {debug_output}"
1262 );
1263 }
1264
1265 async fn spawn_child_with_stdout(script: &str) -> tokio::process::Child {
1275 use tokio::process::Command;
1276
1277 Command::new("sh")
1278 .arg("-c")
1279 .arg(script)
1280 .stdin(std::process::Stdio::null())
1281 .stdout(std::process::Stdio::piped())
1282 .stderr(std::process::Stdio::null())
1283 .spawn()
1284 .expect("must spawn test child")
1285 }
1286
1287 #[tokio::test]
1288 async fn drain_chatty_child_no_deadlock() {
1289 let mut child =
1291 spawn_child_with_stdout("for i in $(seq 1 500); do echo \"line $i\"; done").await;
1292
1293 let stdout = child.stdout.take().expect("stdout piped");
1294 let reader = tokio::io::BufReader::new(stdout);
1295 let token = CancellationToken::new();
1296
1297 let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1299
1300 let status = child.wait().await.expect("wait for child");
1302 assert!(status.success(), "child should exit successfully");
1303
1304 let result = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
1306 assert!(result.is_ok(), "drain task should complete within 2s");
1307 }
1308
1309 #[tokio::test]
1310 async fn drain_oversized_line_bounded() {
1311 let oversized_bytes = 200 * 1024; let mut child = spawn_child_with_stdout(&format!(
1321 "head -c {oversized_bytes} /dev/zero | tr '\\0' 'A'; echo"
1322 ))
1323 .await;
1324
1325 let stdout = child.stdout.take().expect("stdout piped");
1326 let reader = tokio::io::BufReader::new(stdout);
1327 let token = CancellationToken::new();
1328
1329 let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1330
1331 let status = child.wait().await.expect("wait for child");
1332 assert!(status.success(), "child should exit successfully");
1333
1334 let result = tokio::time::timeout(std::time::Duration::from_millis(500), handle).await;
1338 assert!(result.is_ok(), "drain task should complete within 500ms");
1339 }
1340
1341 #[tokio::test]
1342 async fn drain_cancellation_exits_promptly() {
1343 let mut child = spawn_child_with_stdout("while true; do echo tick; sleep 1; done").await;
1345
1346 let stdout = child.stdout.take().expect("stdout piped");
1347 let reader = tokio::io::BufReader::new(stdout);
1348 let token = CancellationToken::new();
1349
1350 let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1352
1353 token.cancel();
1356 let result = tokio::time::timeout(std::time::Duration::from_millis(500), handle).await;
1357 assert!(
1358 result.is_ok(),
1359 "drain task should exit promptly after cancellation"
1360 );
1361
1362 let _ = child.kill().await;
1364 }
1365
1366 #[tokio::test]
1374 async fn drain_handoff_preserves_trailing_bytes() {
1375 use tokio::io::AsyncBufReadExt;
1376
1377 let mut child = spawn_child_with_stdout(
1379 r#"echo '{"status":"ready","port":12345}'; echo "trailing-1"; echo "trailing-2"; echo "trailing-3""#,
1380 )
1381 .await;
1382
1383 let stdout = child.stdout.take().expect("stdout piped");
1384 let mut reader = tokio::io::BufReader::new(stdout);
1385
1386 let mut buf_acc: Vec<u8> = Vec::new();
1388 const READY_MAX_LINE: usize = 64 * 1024;
1389 let mut port_found = None;
1390 while let Ok(chunk) = reader.fill_buf().await {
1391 if chunk.is_empty() {
1392 break; }
1394 let newline_pos = chunk.iter().position(|&b| b == b'\n');
1395 let take = match newline_pos {
1396 Some(i) => i + 1,
1397 None => chunk.len(),
1398 };
1399 if buf_acc.len() + take <= READY_MAX_LINE {
1400 buf_acc.extend_from_slice(&chunk[..take]);
1401 }
1402 reader.consume(take);
1403 if newline_pos.is_some() {
1404 let line = String::from_utf8_lossy(&buf_acc);
1405 let line_trimmed = line.trim_end_matches('\n');
1406 if let Ok(v) = serde_json::from_str::<serde_json::Value>(line_trimmed)
1407 && v.get("status").and_then(|s| s.as_str()) == Some("ready")
1408 && let Some(p) = v.get("port").and_then(|p| p.as_u64())
1409 {
1410 port_found = Some(p as u16);
1411 break;
1412 }
1413 buf_acc.clear();
1414 }
1415 }
1416 assert_eq!(port_found, Some(12345), "ready message must be parsed");
1417
1418 let token = CancellationToken::new();
1420 let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1421
1422 let status = tokio::time::timeout(std::time::Duration::from_secs(5), child.wait())
1425 .await
1426 .expect("child exits within 5s")
1427 .expect("wait for child");
1428 assert!(status.success(), "child should exit successfully");
1429
1430 let result = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
1432 assert!(
1433 result.is_ok(),
1434 "drain task should complete — trailing bytes preserved through handoff"
1435 );
1436 }
1437
1438 #[test]
1442 fn parses_permanent_failure_status_line() {
1443 assert_eq!(
1444 parse_ready_line(
1445 r#"{"status":"permanent-failure","reason":"JMS_ATTEMPT_JOURNAL_DIR is not owner-private"}"#
1446 ),
1447 Some(ReadyLine::PermanentFailure(
1448 "JMS_ATTEMPT_JOURNAL_DIR is not owner-private".to_string()
1449 )),
1450 "a permanent-failure line must classify as PermanentFailure"
1451 );
1452 assert!(matches!(
1454 parse_ready_line(r#"{"status":"ready","port":8080}"#),
1455 Some(ReadyLine::Ready(8080))
1456 ));
1457 assert_eq!(parse_ready_line("just a log line"), None);
1459 }
1460
1461 #[cfg(unix)]
1463 fn write_stub(dir: &std::path::Path, name: &str, script: &str) -> PathBuf {
1464 use std::os::unix::fs::PermissionsExt;
1465
1466 let path = dir.join(name);
1467 std::fs::write(&path, script).expect("write stub bridge");
1468 let mut perms = std::fs::metadata(&path)
1469 .expect("stat stub bridge")
1470 .permissions();
1471 perms.set_mode(0o755);
1472 std::fs::set_permissions(&path, perms).expect("chmod stub bridge");
1473 path
1474 }
1475
1476 #[cfg(unix)]
1478 fn stub_config(binary: PathBuf, start_timeout_ms: u64) -> BridgeProcessConfig {
1479 BridgeProcessConfig {
1480 spec: &JMS_BRIDGE,
1481 binary_path: binary,
1482 broker_url: "tcp://localhost:61616".to_string(),
1483 broker_type: BrokerType::ActiveMq,
1484 username: None,
1485 password: None,
1486 start_timeout_ms,
1487 env_vars: vec![],
1488 }
1489 }
1490
1491 #[cfg(unix)]
1498 fn wait_pid_gone(pid: u32) -> bool {
1499 std::process::Command::new("sh")
1500 .arg("-c")
1501 .arg(format!(
1502 "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"
1503 ))
1504 .status()
1505 .map(|s| s.success())
1506 .unwrap_or(false)
1507 }
1508
1509 #[cfg(unix)]
1511 fn read_pid(path: &std::path::Path) -> u32 {
1512 std::fs::read_to_string(path)
1513 .ok()
1514 .and_then(|s| s.trim().parse().ok())
1515 .unwrap_or(0)
1516 }
1517
1518 #[cfg(unix)]
1521 #[tokio::test]
1522 async fn eof_with_exit_78_maps_to_permanent() {
1523 let dir = tempfile::tempdir().expect("tempdir");
1524 let stub = write_stub(dir.path(), "stub-bridge.sh", "#!/bin/sh\nexit 78\n");
1525
1526 let result = BridgeProcess::start(&stub_config(stub, 5_000)).await;
1527 match result {
1528 Err(BridgeError::PermanentFailure(reason)) => {
1529 assert!(
1530 reason.contains("78"),
1531 "reason must name the reserved exit code: {reason}"
1532 );
1533 }
1534 Err(other) => panic!("expected PermanentFailure from exit 78, got: {other:?}"),
1535 Ok(_) => panic!("exit 78 before ready must never start a bridge"),
1536 }
1537 }
1538
1539 #[cfg(unix)]
1542 #[tokio::test]
1543 async fn eof_closed_stdout_child_is_bounded_other_and_killed() {
1544 let dir = tempfile::tempdir().expect("tempdir");
1545 let pidfile = dir.path().join("child.pid");
1546 let stub = write_stub(
1547 dir.path(),
1548 "stub-bridge.sh",
1549 &format!(
1550 "#!/bin/sh\necho $$ > '{}'\nexec 1>&-\nexec sleep 30\n",
1551 pidfile.display()
1552 ),
1553 );
1554
1555 let result = BridgeProcess::start(&stub_config(stub, 5_000)).await;
1556 assert!(
1557 matches!(result, Err(BridgeError::StdoutClosed)),
1558 "a non-exiting child must surface the bounded EOF error"
1559 );
1560
1561 let pid = read_pid(&pidfile);
1562 assert!(pid > 0, "stub must have written its pid");
1563 assert!(wait_pid_gone(pid), "child {pid} must be terminated");
1564 }
1565
1566 #[cfg(unix)]
1569 #[tokio::test]
1570 async fn permanent_line_returns_prompt_and_kills_child() {
1571 let dir = tempfile::tempdir().expect("tempdir");
1572 let pidfile = dir.path().join("child.pid");
1573 let stub = write_stub(
1574 dir.path(),
1575 "stub-bridge.sh",
1576 &format!(
1577 "#!/bin/sh\necho $$ > '{}'\nprintf '%s\\n' '{{\"status\":\"permanent-failure\",\"reason\":\"JMS_ATTEMPT_JOURNAL_DIR is not owner-private\"}}'\nexec sleep 30\n",
1578 pidfile.display()
1579 ),
1580 );
1581
1582 let result = BridgeProcess::start(&stub_config(stub, 300)).await;
1584 match result {
1585 Err(BridgeError::PermanentFailure(reason)) => {
1586 assert!(reason.contains("JMS_ATTEMPT_JOURNAL_DIR"), "{reason}");
1587 }
1588 Err(other) => panic!("expected PermanentFailure, got: {other:?}"),
1589 Ok(_) => panic!("a permanent-failure line must never start a bridge"),
1590 }
1591
1592 let pid = read_pid(&pidfile);
1593 assert!(pid > 0, "stub must have written its pid");
1594 assert!(wait_pid_gone(pid), "child {pid} must be terminated");
1595 }
1596}