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)
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    /// Picks a free OS port and passes it to the bridge via `QUARKUS_HTTP_SSL_PORT`
412    /// so Quarkus binds exactly to that port and PortAnnouncer can echo it back.
413    /// Build-time TLS props are in application.yml; only runtime cert paths
414    /// and the SSL port are passed via env vars.
415    pub async fn start(config: &BridgeProcessConfig) -> Result<Self, BridgeError> {
416        use tokio::io::AsyncBufReadExt;
417        use tokio::process::Command;
418        use tokio::time::{Duration, timeout};
419
420        config.validate().map_err(BridgeError::Config)?;
421
422        let tls = crate::tls::BridgeTlsMaterial::generate()?;
423
424        // Bind :0 to let the OS pick a free port, then release so the bridge can use it.
425        let free_port = {
426            let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
427            listener.local_addr()?.port()
428        };
429
430        // If CAMEL_BRIDGE_LOG_STDERR is set, redirect stderr to a file for debugging.
431        let stderr_stdio: std::process::Stdio =
432            if let Ok(log_dir) = std::env::var("CAMEL_BRIDGE_LOG_STDERR") {
433                let log_filename = config
434                    .spec
435                    .log_file_template
436                    .replace("{pid}", &std::process::id().to_string());
437                let log_path = if log_dir.is_empty() {
438                    format!("/tmp/{log_filename}")
439                } else {
440                    format!("{log_dir}/{log_filename}")
441                };
442                match std::fs::File::create(&log_path) {
443                    Ok(f) => {
444                        eprintln!("[camel-bridge] stderr → {}", log_path);
445                        f.into()
446                    }
447                    Err(e) => {
448                        eprintln!(
449                            "[camel-bridge] failed to create log file {}: {}",
450                            log_path, e
451                        );
452                        std::process::Stdio::inherit()
453                    }
454                }
455            } else {
456                std::process::Stdio::inherit()
457            };
458
459        let mut command = Command::new(&config.binary_path);
460        command
461            .env("QUARKUS_HTTP_SSL_PORT", free_port.to_string())
462            .env(
463                "QUARKUS_TLS_BRIDGE_KEY_STORE_PEM_0_CERT",
464                &tls.server_pem_path,
465            )
466            .env(
467                "QUARKUS_TLS_BRIDGE_KEY_STORE_PEM_0_KEY",
468                &tls.server_key_path,
469            )
470            .env("QUARKUS_TLS_BRIDGE_TRUST_STORE_PEM_CERTS", &tls.ca_pem_path)
471            .stdout(std::process::Stdio::piped())
472            .stderr(stderr_stdio);
473
474        // Inject bridge-specific env vars (e.g. JMS broker URL/credentials via ::jms()).
475        for (key, value) in &config.env_vars {
476            command.env(key, value);
477        }
478
479        let mut child = command.spawn()?;
480
481        let stdout = child.stdout.take().ok_or(BridgeError::StdoutClosed)?;
482        let mut reader = tokio::io::BufReader::new(stdout);
483
484        // --- Ready-detection phase (bounded by outer timeout + bounded line) ---
485        // R4-L7: use fill_buf()/consume() instead of Lines::next_line() to avoid
486        // unbounded buffer growth on lines without newlines.
487        let port = timeout(Duration::from_millis(config.start_timeout_ms), async {
488            let mut buf_acc: Vec<u8> = Vec::new();
489            const READY_MAX_LINE: usize = 64 * 1024;
490            while let Ok(chunk) = reader.fill_buf().await {
491                if chunk.is_empty() {
492                    break; // EOF
493                }
494                let newline_pos = chunk.iter().position(|&b| b == b'\n');
495                let take = match newline_pos {
496                    Some(i) => i + 1,
497                    None => chunk.len(),
498                };
499                if buf_acc.len() + take <= READY_MAX_LINE {
500                    buf_acc.extend_from_slice(&chunk[..take]);
501                }
502                reader.consume(take);
503                if newline_pos.is_some() {
504                    let line = String::from_utf8_lossy(&buf_acc);
505                    let line_trimmed = line.trim_end_matches('\n');
506                    if let Ok(v) = serde_json::from_str::<serde_json::Value>(line_trimmed)
507                        && v.get("status").and_then(|s| s.as_str()) == Some("ready")
508                    {
509                        if let Some(p) = v.get("port").and_then(|p| p.as_u64()) {
510                            return Ok(p as u16);
511                        }
512                        // log-policy: system-broken
513                        tracing::error!("bridge ready message malformed: {line_trimmed}");
514                        return Err(BridgeError::BadReadyMessage(line_trimmed.to_string()));
515                    }
516                    buf_acc.clear();
517                }
518            }
519            // log-policy: system-broken
520            tracing::error!("bridge stdout closed before ready message");
521            Err(BridgeError::StdoutClosed)
522        })
523        .await
524        .map_err(|_| {
525            let msg = format!(
526                "{} failed to start: health check timeout after {}ms",
527                config.spec.name, config.start_timeout_ms
528            );
529            // log-policy: system-broken
530            tracing::error!("{msg}");
531            BridgeError::Timeout(msg)
532        })??;
533
534        // --- Post-ready bounded drain (R4-L7) ---
535        // Never stop reading: the OS pipe must stay drained (stopping = pipe fill
536        // = child blocked = worse regression). Bound single-line size to 64 KiB;
537        // rate-limit logging to 100 lines/second with drop summary.
538        let token = CancellationToken::new();
539        let child_token = token.clone();
540        let handle = tokio::spawn(async move {
541            drain_stdout(reader, child_token).await;
542        });
543
544        Ok(BridgeProcess {
545            child,
546            grpc_port: port,
547            tls,
548            token,
549            handle: Some(handle),
550        })
551    }
552
553    /// Gracefully stop: SIGTERM + wait for exit.
554    pub async fn stop(mut self) -> Result<(), BridgeError> {
555        use tokio::time::{Duration, sleep};
556
557        self.token.cancel();
558
559        if let Some(handle) = self.handle.take() {
560            let join_result = tokio::time::timeout(Duration::from_secs(5), handle).await;
561            if join_result.is_err() {
562                tracing::warn!("bridge stdout drain task did not exit after cancellation");
563            }
564        }
565
566        // Send SIGTERM first (graceful shutdown)
567        #[cfg(unix)]
568        {
569            let pid = self.child.id().unwrap_or(0);
570            if pid > 0 {
571                // SAFETY: libc::kill is called with the child process PID obtained from tokio.
572                unsafe {
573                    libc::kill(pid as i32, libc::SIGTERM);
574                }
575            }
576        }
577
578        // On non-Unix (Windows), fall through to kill immediately
579        #[cfg(not(unix))]
580        let _ = self.child.start_kill();
581
582        // Wait up to 5 seconds for graceful exit, then SIGKILL
583        tokio::select! {
584            result = self.child.wait() => {
585                result?;
586            }
587            _ = sleep(Duration::from_secs(5)) => {
588                let _ = self.child.start_kill();
589                self.child.wait().await?;
590            }
591        }
592        Ok(())
593    }
594}
595
596impl Drop for BridgeProcess {
597    fn drop(&mut self) {
598        self.token.cancel();
599        // Best-effort only. Does NOT wait — cannot block in Drop.
600        let _ = self.child.start_kill();
601    }
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607
608    #[test]
609    fn broker_type_from_str_activemq() {
610        assert_eq!(
611            "activemq".parse::<BrokerType>().unwrap(),
612            BrokerType::ActiveMq
613        );
614        assert_eq!(
615            "ACTIVEMQ".parse::<BrokerType>().unwrap(),
616            BrokerType::ActiveMq
617        );
618    }
619
620    #[test]
621    fn broker_type_from_str_artemis() {
622        assert_eq!(
623            "artemis".parse::<BrokerType>().unwrap(),
624            BrokerType::Artemis
625        );
626    }
627
628    #[test]
629    fn broker_type_from_str_generic() {
630        assert_eq!(
631            "generic".parse::<BrokerType>().unwrap(),
632            BrokerType::Generic
633        );
634    }
635
636    #[test]
637    fn broker_type_from_str_unknown_returns_err() {
638        assert!("ibmmq".parse::<BrokerType>().is_err());
639        assert!("UnknownBroker".parse::<BrokerType>().is_err());
640    }
641
642    #[test]
643    fn broker_type_env_str() {
644        assert_eq!(BrokerType::ActiveMq.as_env_str(), "activemq");
645        assert_eq!(BrokerType::Artemis.as_env_str(), "artemis");
646        assert_eq!(BrokerType::Generic.as_env_str(), "generic");
647    }
648
649    #[test]
650    fn jms_constructor_uses_jms_spec() {
651        let cfg = BridgeProcessConfig::jms(
652            PathBuf::from("/tmp/jms-bridge"),
653            "tcp://localhost:61616".to_string(),
654            BrokerType::ActiveMq,
655            Some("user".to_string()),
656            Some(Redacted::new("pass".to_string())),
657            1000,
658        );
659        assert_eq!(cfg.spec.name, "jms-bridge");
660    }
661
662    #[test]
663    fn xml_constructor_uses_xml_spec() {
664        let cfg = BridgeProcessConfig::xml(PathBuf::from("/tmp/xml-bridge"), 1000);
665        assert_eq!(cfg.spec.name, "xml-bridge");
666    }
667
668    #[test]
669    fn cxf_profiles_generates_cxf_profiles_env_var() {
670        let profiles = vec![
671            CxfProfileEnvVars {
672                name: "baleares".to_string(),
673                wsdl_path: "/a.wsdl".to_string(),
674                service_name: "Svc".to_string(),
675                port_name: "Port".to_string(),
676                address: None,
677                keystore_path: None,
678                keystore_password: None,
679                truststore_path: None,
680                truststore_password: None,
681                sig_username: None,
682                sig_password: None,
683                enc_username: None,
684                security_actions_out: None,
685                security_actions_in: None,
686                signature_algorithm: None,
687                signature_digest_algorithm: None,
688                signature_c14n_algorithm: None,
689                signature_parts: None,
690            },
691            CxfProfileEnvVars {
692                name: "extremadura".to_string(),
693                wsdl_path: "/b.wsdl".to_string(),
694                service_name: "Svc2".to_string(),
695                port_name: "Port2".to_string(),
696                address: Some("http://host:9090/ws".to_string()),
697                keystore_path: Some("/b.jks".to_string()),
698                keystore_password: Some(Redacted::new("pass".to_string())),
699                truststore_path: None,
700                truststore_password: None,
701                sig_username: Some("cert".to_string()),
702                sig_password: Some(Redacted::new("sig_pass".to_string())),
703                enc_username: None,
704                security_actions_out: Some("Timestamp Signature".to_string()),
705                security_actions_in: Some("Timestamp Signature".to_string()),
706                signature_algorithm: None,
707                signature_digest_algorithm: None,
708                signature_c14n_algorithm: None,
709                signature_parts: None,
710            },
711        ];
712
713        let cfg =
714            BridgeProcessConfig::cxf_profiles(PathBuf::from("/tmp/cxf-bridge"), &profiles, 15_000);
715
716        assert_eq!(cfg.spec.name, "cxf-bridge");
717        assert!(cfg.broker_url.is_empty());
718        assert_eq!(cfg.broker_type, BrokerType::Generic);
719        assert!(cfg.username.is_none());
720        assert!(cfg.password.is_none());
721
722        // Find CXF_PROFILES env var
723        let profiles_var = cfg
724            .env_vars
725            .iter()
726            .find(|(k, _)| k == "CXF_PROFILES")
727            .expect("CXF_PROFILES env var must exist");
728        assert_eq!(profiles_var.1, "baleares,extremadura");
729
730        // Check baleares profile vars (no security)
731        assert!(
732            cfg.env_vars
733                .iter()
734                .any(|(k, v)| k == "CXF_PROFILE_BALEARES_WSDL_PATH" && v == "/a.wsdl")
735        );
736        assert!(
737            cfg.env_vars
738                .iter()
739                .any(|(k, v)| k == "CXF_PROFILE_BALEARES_SERVICE_NAME" && v == "Svc")
740        );
741        assert!(
742            cfg.env_vars
743                .iter()
744                .any(|(k, v)| k == "CXF_PROFILE_BALEARES_PORT_NAME" && v == "Port")
745        );
746        assert!(
747            !cfg.env_vars
748                .iter()
749                .any(|(k, _)| k == "CXF_PROFILE_BALEARES_ADDRESS")
750        );
751
752        // Check extremadura profile vars (with security)
753        assert!(
754            cfg.env_vars
755                .iter()
756                .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_WSDL_PATH" && v == "/b.wsdl")
757        );
758        assert!(
759            cfg.env_vars
760                .iter()
761                .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_ADDRESS" && v == "http://host:9090/ws")
762        );
763        assert!(
764            cfg.env_vars
765                .iter()
766                .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_KEYSTORE_PATH" && v == "/b.jks")
767        );
768        assert!(
769            cfg.env_vars
770                .iter()
771                .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_KEYSTORE_PASSWORD" && v == "pass")
772        );
773        assert!(
774            cfg.env_vars
775                .iter()
776                .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_SIG_USERNAME" && v == "cert")
777        );
778        assert!(
779            cfg.env_vars
780                .iter()
781                .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_SIG_PASSWORD" && v == "sig_pass")
782        );
783        assert!(
784            cfg.env_vars
785                .iter()
786                .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_SECURITY_ACTIONS_OUT"
787                    && v == "Timestamp Signature")
788        );
789    }
790
791    #[test]
792    fn cxf_profiles_single_profile_no_security() {
793        let profiles = vec![CxfProfileEnvVars {
794            name: "test".to_string(),
795            wsdl_path: "service.wsdl".to_string(),
796            service_name: "{http://example.com}Service".to_string(),
797            port_name: "{http://example.com}Port".to_string(),
798            address: None,
799            keystore_path: None,
800            keystore_password: None,
801            truststore_path: None,
802            truststore_password: None,
803            sig_username: None,
804            sig_password: None,
805            enc_username: None,
806            security_actions_out: None,
807            security_actions_in: None,
808            signature_algorithm: None,
809            signature_digest_algorithm: None,
810            signature_c14n_algorithm: None,
811            signature_parts: None,
812        }];
813
814        let cfg =
815            BridgeProcessConfig::cxf_profiles(PathBuf::from("/tmp/cxf-bridge"), &profiles, 15_000);
816
817        assert_eq!(cfg.spec.name, "cxf-bridge");
818        // CXF_PROFILES + 3 required vars (WSDL_PATH, SERVICE_NAME, PORT_NAME)
819        assert_eq!(cfg.env_vars.len(), 4);
820        assert_eq!(cfg.env_vars[0].0, "CXF_PROFILES");
821        assert_eq!(cfg.env_vars[0].1, "test");
822        assert_eq!(cfg.env_vars[1].0, "CXF_PROFILE_TEST_WSDL_PATH");
823        assert_eq!(cfg.env_vars[1].1, "service.wsdl");
824        assert_eq!(cfg.env_vars[2].0, "CXF_PROFILE_TEST_SERVICE_NAME");
825        assert_eq!(cfg.env_vars[2].1, "{http://example.com}Service");
826        assert_eq!(cfg.env_vars[3].0, "CXF_PROFILE_TEST_PORT_NAME");
827        assert_eq!(cfg.env_vars[3].1, "{http://example.com}Port");
828    }
829
830    #[test]
831    fn profile_env_vars_to_env_vars_includes_all_fields() {
832        let vars = CxfProfileEnvVars {
833            name: "full".to_string(),
834            wsdl_path: "/wsdl".to_string(),
835            service_name: "Svc".to_string(),
836            port_name: "Port".to_string(),
837            address: Some("http://host:8080".to_string()),
838            keystore_path: Some("/ks.jks".to_string()),
839            keystore_password: Some(Redacted::new("ks_pass".to_string())),
840            truststore_path: Some("/ts.jks".to_string()),
841            truststore_password: Some(Redacted::new("ts_pass".to_string())),
842            sig_username: Some("user".to_string()),
843            sig_password: Some(Redacted::new("sig_pass".to_string())),
844            enc_username: None,
845            security_actions_out: Some("Timestamp Signature".to_string()),
846            security_actions_in: Some("Timestamp".to_string()),
847            signature_algorithm: None,
848            signature_digest_algorithm: None,
849            signature_c14n_algorithm: None,
850            signature_parts: None,
851        };
852
853        let env = vars.to_env_vars();
854        // 3 required + 1 address + 8 security = 12
855        assert_eq!(env.len(), 12);
856
857        let keys: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
858        assert!(keys.contains(&"CXF_PROFILE_FULL_WSDL_PATH"));
859        assert!(keys.contains(&"CXF_PROFILE_FULL_SERVICE_NAME"));
860        assert!(keys.contains(&"CXF_PROFILE_FULL_PORT_NAME"));
861        assert!(keys.contains(&"CXF_PROFILE_FULL_ADDRESS"));
862        assert!(keys.contains(&"CXF_PROFILE_FULL_KEYSTORE_PATH"));
863        assert!(keys.contains(&"CXF_PROFILE_FULL_KEYSTORE_PASSWORD"));
864        assert!(keys.contains(&"CXF_PROFILE_FULL_TRUSTSTORE_PATH"));
865        assert!(keys.contains(&"CXF_PROFILE_FULL_TRUSTSTORE_PASSWORD"));
866        assert!(keys.contains(&"CXF_PROFILE_FULL_SIG_USERNAME"));
867        assert!(keys.contains(&"CXF_PROFILE_FULL_SIG_PASSWORD"));
868        assert!(keys.contains(&"CXF_PROFILE_FULL_SECURITY_ACTIONS_OUT"));
869        assert!(keys.contains(&"CXF_PROFILE_FULL_SECURITY_ACTIONS_IN"));
870    }
871
872    #[test]
873    fn test_start_timeout_zero_rejected() {
874        let config = BridgeProcessConfig::jms(
875            PathBuf::from("/usr/bin/echo"),
876            "tcp://localhost:61616".to_string(),
877            BrokerType::ActiveMq,
878            None,
879            None,
880            0,
881        );
882        let result = config.validate();
883        assert!(result.is_err());
884    }
885
886    #[test]
887    fn test_bridge_rejects_zero_start_timeout() {
888        let config = BridgeProcessConfig::jms(
889            PathBuf::from("/usr/bin/echo"),
890            "tcp://localhost:61616".to_string(),
891            BrokerType::ActiveMq,
892            None,
893            None,
894            0,
895        );
896        assert!(config.validate().is_err());
897    }
898
899    #[tokio::test]
900    async fn test_bridge_stop_completes() {
901        use tokio::process::Command;
902        use tokio::time::{Duration, timeout};
903
904        let child = Command::new("sh")
905            .arg("-c")
906            .arg("trap '' TERM; while true; do echo tick; sleep 1; done")
907            .stdout(std::process::Stdio::null())
908            .spawn()
909            .expect("must spawn test child process");
910
911        let bridge = BridgeProcess {
912            child,
913            grpc_port: 0,
914            tls: crate::tls::BridgeTlsMaterial::generate().expect("test tls"),
915            token: CancellationToken::new(),
916            handle: None,
917        };
918
919        let result = timeout(Duration::from_secs(10), bridge.stop()).await;
920        assert!(result.is_ok(), "stop() must complete within 10s");
921    }
922
923    // --- BRG-004: Redacted<T> tests ---
924
925    #[test]
926    fn redacted_debug_displays_redacted() {
927        let r = Redacted::new("secret_password".to_string());
928        assert_eq!(format!("{r:?}"), "[REDACTED]");
929    }
930
931    #[test]
932    fn redacted_display_displays_redacted() {
933        let r = Redacted::new("secret_password".to_string());
934        assert_eq!(format!("{r}"), "[REDACTED]");
935    }
936
937    #[test]
938    fn redacted_deref_gives_inner_value() {
939        let r = Redacted::new("secret".to_string());
940        assert_eq!(&*r, "secret");
941    }
942
943    #[test]
944    fn redacted_into_inner_returns_value() {
945        let r = Redacted::new("secret".to_string());
946        assert_eq!(r.into_inner(), "secret");
947    }
948
949    #[test]
950    fn redacted_clone_works() {
951        let r = Redacted::new("secret".to_string());
952        let c = r.clone();
953        assert_eq!(&*c, "secret");
954        assert_eq!(format!("{c:?}"), "[REDACTED]");
955    }
956
957    #[test]
958    fn bridge_process_config_debug_redacts_password() {
959        let cfg = BridgeProcessConfig::jms(
960            PathBuf::from("/tmp/jms-bridge"),
961            "tcp://localhost:61616".to_string(),
962            BrokerType::ActiveMq,
963            Some("user".to_string()),
964            Some(Redacted::new("super_secret".to_string())),
965            1000,
966        );
967        // The Redacted<T> password field must show [REDACTED] in debug output.
968        // env_vars is a separate Vec<(String, String)> used for process injection
969        // and legitimately contains the raw value — that is not a Redacted leak.
970        let password_debug = format!("{:?}", cfg.password); // allow-secret
971        assert!(
972            !password_debug.contains("super_secret"),
973            "Password field must not leak in Debug: {password_debug}"
974        );
975        assert_eq!(
976            password_debug, "Some([REDACTED])",
977            "Password field must show [REDACTED]: {password_debug}"
978        );
979    }
980
981    #[test]
982    fn bridge_process_config_debug_redacts_broker_url_and_env_vars() {
983        let cfg = BridgeProcessConfig {
984            spec: &JMS_BRIDGE,
985            binary_path: PathBuf::from("/tmp/jms-bridge"),
986            broker_url: "amqp://u:SENTINEL-BRIDGE-PASS@host:5672".to_string(),
987            broker_type: BrokerType::ActiveMq,
988            username: Some("user".to_string()),
989            password: Some(Redacted::new("pass".to_string())),
990            start_timeout_ms: 1000,
991            env_vars: vec![(
992                "KEYSTORE_PASSWORD".to_string(),
993                "SENTINEL-ENV-PASS".to_string(),
994            )],
995        };
996        let debug_output = format!("{cfg:?}");
997        assert!(
998            !debug_output.contains("SENTINEL-BRIDGE-PASS"),
999            "broker_url must be redacted in Debug: {debug_output}"
1000        );
1001        assert!(
1002            !debug_output.contains("SENTINEL-ENV-PASS"),
1003            "env_vars values must be redacted in Debug: {debug_output}"
1004        );
1005        assert!(
1006            debug_output.contains("[REDACTED; 1 entries]"),
1007            "env_vars must show redacted count: {debug_output}"
1008        );
1009    }
1010
1011    #[test]
1012    fn cxf_profile_debug_redacts_passwords() {
1013        let profile = CxfProfileEnvVars {
1014            name: "test".to_string(),
1015            wsdl_path: "/a.wsdl".to_string(),
1016            service_name: "Svc".to_string(),
1017            port_name: "Port".to_string(),
1018            address: None,
1019            keystore_path: None,
1020            keystore_password: Some(Redacted::new("ks_secret_val".to_string())),
1021            truststore_path: None,
1022            truststore_password: Some(Redacted::new("ts_secret_val".to_string())),
1023            sig_username: None,
1024            sig_password: Some(Redacted::new("sig_secret_val".to_string())),
1025            enc_username: None,
1026            security_actions_out: None,
1027            security_actions_in: None,
1028            signature_algorithm: None,
1029            signature_digest_algorithm: None,
1030            signature_c14n_algorithm: None,
1031            signature_parts: None,
1032        };
1033        let debug_output = format!("{profile:?}");
1034        assert!(
1035            !debug_output.contains("ks_secret_val")
1036                && !debug_output.contains("ts_secret_val")
1037                && !debug_output.contains("sig_secret_val"),
1038            "Debug must not contain passwords: {debug_output}"
1039        );
1040    }
1041
1042    // --- R4-L7: Bounded stdout drain tests ---
1043
1044    /// Helper: spawn a child that writes to stdout, return (child, stdout BufReader).
1045    async fn spawn_child_with_stdout(script: &str) -> tokio::process::Child {
1046        use tokio::process::Command;
1047
1048        Command::new("sh")
1049            .arg("-c")
1050            .arg(script)
1051            .stdout(std::process::Stdio::piped())
1052            .spawn()
1053            .expect("must spawn test child")
1054    }
1055
1056    #[tokio::test]
1057    async fn drain_chatty_child_no_deadlock() {
1058        use tokio::io::AsyncBufReadExt;
1059
1060        // Child writes many short lines quickly, then exits
1061        let mut child =
1062            spawn_child_with_stdout("for i in $(seq 1 500); do echo \"line $i\"; done").await;
1063
1064        let stdout = child.stdout.take().expect("stdout piped");
1065        let reader = tokio::io::BufReader::new(stdout);
1066        let token = CancellationToken::new();
1067
1068        // Call the production drain function directly (I-1: no duplicated logic)
1069        let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1070
1071        // Wait for child to exit
1072        let status = child.wait().await.expect("wait for child");
1073        assert!(status.success(), "child should exit successfully");
1074
1075        // Drain task should complete promptly after child exits (EOF)
1076        let result = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
1077        assert!(result.is_ok(), "drain task should complete within 2s");
1078    }
1079
1080    #[tokio::test]
1081    async fn drain_oversized_line_bounded() {
1082        use tokio::io::AsyncBufReadExt;
1083
1084        // Child writes a line >64 KiB without newline, then newline.
1085        // Use printf + head to generate 200 KiB of 'A' without python dependency.
1086        // Bound verification: the drain loop caps line_buf at MAX_LINE_BYTES (64 KiB)
1087        // via the `oversized` flag — bytes beyond the cap are discarded, not accumulated.
1088        // Enforced by code inspection (src/process.rs drain_stdout: `line_buf.len() < MAX_LINE_BYTES`
1089        // guard + `oversized = true` branch that skips push). This test verifies the function
1090        // completes within a tight time bound (500ms), which would fail if the loop stalled
1091        // or allocated unboundedly.
1092        let oversized_bytes = 200 * 1024; // 200 KiB
1093        let mut child = spawn_child_with_stdout(&format!(
1094            "head -c {oversized_bytes} /dev/zero | tr '\\0' 'A'; echo"
1095        ))
1096        .await;
1097
1098        let stdout = child.stdout.take().expect("stdout piped");
1099        let reader = tokio::io::BufReader::new(stdout);
1100        let token = CancellationToken::new();
1101
1102        let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1103
1104        let status = child.wait().await.expect("wait for child");
1105        assert!(status.success(), "child should exit successfully");
1106
1107        // Tighter bound: 500ms (was 2s). If the cap were broken, the loop would
1108        // still complete but would have allocated >64 KiB per line — caught by
1109        // code review + the oversized warning log assertion in integration tests.
1110        let result = tokio::time::timeout(std::time::Duration::from_millis(500), handle).await;
1111        assert!(result.is_ok(), "drain task should complete within 500ms");
1112    }
1113
1114    #[tokio::test]
1115    async fn drain_cancellation_exits_promptly() {
1116        use tokio::io::AsyncBufReadExt;
1117
1118        // Child writes slowly (sleep between lines)
1119        let mut child = spawn_child_with_stdout("while true; do echo tick; sleep 1; done").await;
1120
1121        let stdout = child.stdout.take().expect("stdout piped");
1122        let reader = tokio::io::BufReader::new(stdout);
1123        let token = CancellationToken::new();
1124
1125        // Call the production drain function directly (I-1: no duplicated logic)
1126        let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1127
1128        // Give drain task time to start
1129        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1130
1131        // Cancel and verify prompt exit
1132        token.cancel();
1133        let result = tokio::time::timeout(std::time::Duration::from_millis(500), handle).await;
1134        assert!(
1135            result.is_ok(),
1136            "drain task should exit promptly after cancellation"
1137        );
1138
1139        // Clean up child
1140        let _ = child.kill().await;
1141    }
1142
1143    /// I-2: Verify the single-BufReader-for-both-phases invariant.
1144    /// The ready-detection phase and drain phase must share the SAME BufReader;
1145    /// otherwise trailing bytes after the ready message would be lost (a second
1146    /// reader would start at offset 0, missing data already consumed by phase 1).
1147    /// This test exercises the handoff by mimicking the ready-detection loop,
1148    /// then calling drain_stdout with the same reader to verify trailing lines
1149    /// are processed (not lost).
1150    #[tokio::test]
1151    async fn drain_handoff_preserves_trailing_bytes() {
1152        use tokio::io::AsyncBufReadExt;
1153
1154        // Child emits ready JSON + trailing log lines
1155        let mut child = spawn_child_with_stdout(
1156            r#"echo '{"status":"ready","port":12345}'; echo "trailing-1"; echo "trailing-2"; echo "trailing-3""#,
1157        )
1158        .await;
1159
1160        let stdout = child.stdout.take().expect("stdout piped");
1161        let mut reader = tokio::io::BufReader::new(stdout);
1162
1163        // --- Phase 1: ready-detection (mimics start() logic) ---
1164        let mut buf_acc: Vec<u8> = Vec::new();
1165        const READY_MAX_LINE: usize = 64 * 1024;
1166        let mut port_found = None;
1167        while let Ok(chunk) = reader.fill_buf().await {
1168            if chunk.is_empty() {
1169                break; // EOF
1170            }
1171            let newline_pos = chunk.iter().position(|&b| b == b'\n');
1172            let take = match newline_pos {
1173                Some(i) => i + 1,
1174                None => chunk.len(),
1175            };
1176            if buf_acc.len() + take <= READY_MAX_LINE {
1177                buf_acc.extend_from_slice(&chunk[..take]);
1178            }
1179            reader.consume(take);
1180            if newline_pos.is_some() {
1181                let line = String::from_utf8_lossy(&buf_acc);
1182                let line_trimmed = line.trim_end_matches('\n');
1183                if let Ok(v) = serde_json::from_str::<serde_json::Value>(line_trimmed)
1184                    && v.get("status").and_then(|s| s.as_str()) == Some("ready")
1185                {
1186                    if let Some(p) = v.get("port").and_then(|p| p.as_u64()) {
1187                        port_found = Some(p as u16);
1188                        break;
1189                    }
1190                }
1191                buf_acc.clear();
1192            }
1193        }
1194        assert_eq!(port_found, Some(12345), "ready message must be parsed");
1195
1196        // --- Phase 2: drain with the SAME reader (I-2: handoff invariant) ---
1197        let token = CancellationToken::new();
1198        let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1199
1200        // Wait for child to exit
1201        let status = child.wait().await.expect("wait for child");
1202        assert!(status.success(), "child should exit successfully");
1203
1204        // Drain task should complete (trailing lines processed, not lost)
1205        let result = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
1206        assert!(
1207            result.is_ok(),
1208            "drain task should complete — trailing bytes preserved through handoff"
1209        );
1210    }
1211}