Skip to main content

camel_bridge/
process.rs

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// ---------------------------------------------------------------------------
11// Redacted<T> — wrapper that never leaks inner value via Debug/Display
12// ---------------------------------------------------------------------------
13
14/// A newtype that redacts its inner value in `Debug` and `Display` output.
15/// Used for password/credential fields to prevent accidental logging.
16#[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}"))), // allow-secret
99        }
100    }
101}
102
103/// Environment variables for a single CXF profile, used by the bridge Java side.
104/// Password fields use [`Redacted`] to prevent accidental credential leakage in logs.
105#[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
183/// Configuration for spawning a bridge subprocess.
184/// Password fields use [`Redacted`] to prevent accidental credential leakage in logs.
185///
186/// ADR-0051 credential boundary: manual-redaction
187pub 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    /// Constructor for the JMS bridge.
218    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    /// Constructor for the XML bridge.
252    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    /// Constructor for the CXF bridge with multi-profile support.
266    /// Generates `CXF_PROFILES=list` env var plus per-profile env vars.
267    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
299/// R4-L7: bounded stdout drain — shared between production start() and tests.
300/// Never stops reading: the OS pipe must stay drained (stopping = pipe fill = child
301/// blocked = worse regression). Bound single-line size to 64 KiB; rate-limit logging
302/// to 100 lines/second with drop summary.
303async 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; // EOF
326                        }
327                        for &b in chunk {
328                            if b == b'\n' {
329                                if oversized {
330                                    // log-policy: degraded
331                                    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                                    // log-policy: normal
341                                    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                        // log-policy: normal
368                        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    /// Connect a mTLS tonic channel to this bridge process.
395    pub async fn connect(&self) -> Result<tonic::transport::Channel, BridgeError> {
396        crate::channel::connect_channel(self.grpc_port, &self.tls).await
397    }
398
399    /// Start the bridge process and connect a mTLS channel in one step.
400    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    /// Spawn the bridge process. Reads the SSL port from stdout JSON line:
409    ///   {"status":"ready","port":PORT}
410    ///
411    /// Port handoff (bd rc-s7dyw): passes `QUARKUS_HTTP_SSL_PORT=0` so the
412    /// Quarkus JVM binds an OS-assigned port itself; PortAnnouncer reports
413    /// the ACTUAL bound port (from the Quarkus runtime registry) in the ready
414    /// line, and this side connects to the announced port. The port is held
415    /// by the JVM from the first moment it exists, so the ADR-0070
416    /// bind-read-drop race cannot occur. Build-time TLS props are in
417    /// application.yml; only runtime cert paths and the SSL port mode are
418    /// passed via env vars.
419    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        // Port selection is JVM-authoritative (bd rc-s7dyw): 0 makes Quarkus
429        // bind an OS-assigned port and announce the actual bound port. The
430        // old bind-:0-read-drop here left the port unheld between the drop
431        // and the JVM bind (ADR-0070 port-toctou class); with the JVM owning
432        // the bind no such window exists.
433        let free_port: u16 = 0;
434
435        // If CAMEL_BRIDGE_LOG_STDERR is set, redirect stderr to a file for debugging.
436        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        // Inject bridge-specific env vars (e.g. JMS broker URL/credentials via ::jms()).
480        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        // --- Ready-detection phase (bounded by outer timeout + bounded line) ---
490        // R4-L7: use fill_buf()/consume() instead of Lines::next_line() to avoid
491        // unbounded buffer growth on lines without newlines.
492        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; // EOF
498                }
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                        // log-policy: system-broken
518                        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            // log-policy: system-broken
525            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            // log-policy: system-broken
535            tracing::error!("{msg}");
536            BridgeError::Timeout(msg)
537        })??;
538
539        // --- Post-ready bounded drain (R4-L7) ---
540        // Never stop reading: the OS pipe must stay drained (stopping = pipe fill
541        // = child blocked = worse regression). Bound single-line size to 64 KiB;
542        // rate-limit logging to 100 lines/second with drop summary.
543        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    /// Gracefully stop: SIGTERM + wait for exit.
559    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        // Send SIGTERM first (graceful shutdown)
572        #[cfg(unix)]
573        {
574            let pid = self.child.id().unwrap_or(0);
575            if pid > 0 {
576                // SAFETY: libc::kill is called with the child process PID obtained from tokio.
577                unsafe {
578                    libc::kill(pid as i32, libc::SIGTERM);
579                }
580            }
581        }
582
583        // On non-Unix (Windows), fall through to kill immediately
584        #[cfg(not(unix))]
585        let _ = self.child.start_kill();
586
587        // Wait up to 5 seconds for graceful exit, then SIGKILL
588        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        // Best-effort only. Does NOT wait — cannot block in Drop.
605        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        // Find CXF_PROFILES env var
728        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        // Check baleares profile vars (no security)
736        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        // Check extremadura profile vars (with security)
758        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        // CXF_PROFILES + 3 required vars (WSDL_PATH, SERVICE_NAME, PORT_NAME)
824        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        // 3 required + 1 address + 8 security = 12
860        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    // --- BRG-004: Redacted<T> tests ---
929
930    #[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        // The Redacted<T> password field must show [REDACTED] in debug output.
973        // env_vars is a separate Vec<(String, String)> used for process injection
974        // and legitimately contains the raw value — that is not a Redacted leak.
975        let password_debug = format!("{:?}", cfg.password); // allow-secret
976        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    // --- R4-L7: Bounded stdout drain tests ---
1077
1078    /// Helper: spawn a child that writes to stdout, return (child, stdout BufReader).
1079    ///
1080    /// stdin/stderr are nulled so no descendant inherits the test binary's
1081    /// harness pipes. Under cargo-nextest, a leaked grandchild (e.g. an
1082    /// orphaned `sleep` outliving a SIGKILLed `sh`) that still holds the
1083    /// inherited stdin/stderr keeps nextest's pipes open past leak-timeout
1084    /// and the test is flagged LEAK even though it passed.
1085    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        // Child writes many short lines quickly, then exits
1101        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        // Call the production drain function directly (I-1: no duplicated logic)
1109        let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1110
1111        // Wait for child to exit
1112        let status = child.wait().await.expect("wait for child");
1113        assert!(status.success(), "child should exit successfully");
1114
1115        // Drain task should complete promptly after child exits (EOF)
1116        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        // Child writes a line >64 KiB without newline, then newline.
1123        // Use printf + head to generate 200 KiB of 'A' without python dependency.
1124        // Bound verification: the drain loop caps line_buf at MAX_LINE_BYTES (64 KiB)
1125        // via the `oversized` flag — bytes beyond the cap are discarded, not accumulated.
1126        // Enforced by code inspection (src/process.rs drain_stdout: `line_buf.len() < MAX_LINE_BYTES`
1127        // guard + `oversized = true` branch that skips push). This test verifies the function
1128        // completes within a tight time bound (500ms), which would fail if the loop stalled
1129        // or allocated unboundedly.
1130        let oversized_bytes = 200 * 1024; // 200 KiB
1131        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        // Tighter bound: 500ms (was 2s). If the cap were broken, the loop would
1146        // still complete but would have allocated >64 KiB per line — caught by
1147        // code review + the oversized warning log assertion in integration tests.
1148        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        // Child writes slowly (sleep between lines)
1155        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        // Call the production drain function directly (I-1: no duplicated logic)
1162        let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1163
1164        // drain_stdout polls `token.cancelled()` first in a biased select,
1165        // so cancelling before the task's first poll still exits promptly.
1166        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        // Clean up child
1174        let _ = child.kill().await;
1175    }
1176
1177    /// I-2: Verify the single-BufReader-for-both-phases invariant.
1178    /// The ready-detection phase and drain phase must share the SAME BufReader;
1179    /// otherwise trailing bytes after the ready message would be lost (a second
1180    /// reader would start at offset 0, missing data already consumed by phase 1).
1181    /// This test exercises the handoff by mimicking the ready-detection loop,
1182    /// then calling drain_stdout with the same reader to verify trailing lines
1183    /// are processed (not lost).
1184    #[tokio::test]
1185    async fn drain_handoff_preserves_trailing_bytes() {
1186        use tokio::io::AsyncBufReadExt;
1187
1188        // Child emits ready JSON + trailing log lines
1189        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        // --- Phase 1: ready-detection (mimics start() logic) ---
1198        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; // EOF
1204            }
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        // --- Phase 2: drain with the SAME reader (I-2: handoff invariant) ---
1230        let token = CancellationToken::new();
1231        let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1232
1233        // Wait for child to exit
1234        let status = child.wait().await.expect("wait for child");
1235        assert!(status.success(), "child should exit successfully");
1236
1237        // Drain task should complete (trailing lines processed, not lost)
1238        let result = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
1239        assert!(
1240            result.is_ok(),
1241            "drain task should complete — trailing bytes preserved through handoff"
1242        );
1243    }
1244}