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