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)]
50#[non_exhaustive]
51pub enum BridgeError {
52    #[error("IO error: {0}")]
53    Io(#[from] std::io::Error),
54    #[error("Bridge timed out: {0}")]
55    Timeout(String),
56    #[error("Bridge stdout closed before ready message")]
57    StdoutClosed,
58    #[error("Bridge ready message malformed: {0}")]
59    BadReadyMessage(String),
60    #[error("Download failed: {0}")]
61    Download(String),
62    #[error("Checksum mismatch: expected {expected}, got {actual}")]
63    ChecksumMismatch { expected: String, actual: String },
64    #[error("URL not allowed: {0}")]
65    UrlNotAllowed(String),
66    #[error("Transport error: {0}")]
67    Transport(String),
68    #[error("Config error: {0}")]
69    Config(String),
70    /// A startup permanent failure announced by the bridge (`{"status":
71    /// "permanent-failure","reason":"..."}`) or inferred from the reserved exit
72    /// code 78. Never retried (phase 4 parks the slot).
73    #[error("Bridge permanent failure: {0}")]
74    PermanentFailure(String),
75}
76
77#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
78#[serde(rename_all = "lowercase")]
79pub enum BrokerType {
80    #[serde(alias = "active_mq")]
81    ActiveMq,
82    Artemis,
83    Generic,
84}
85
86impl BrokerType {
87    pub fn as_env_str(&self) -> &'static str {
88        match self {
89            BrokerType::ActiveMq => "activemq",
90            BrokerType::Artemis => "artemis",
91            BrokerType::Generic => "generic",
92        }
93    }
94}
95
96impl std::str::FromStr for BrokerType {
97    type Err = BridgeError;
98
99    fn from_str(s: &str) -> Result<Self, Self::Err> {
100        match s.to_lowercase().as_str() {
101            "activemq" => Ok(BrokerType::ActiveMq),
102            "artemis" => Ok(BrokerType::Artemis),
103            "generic" => Ok(BrokerType::Generic),
104            other => Err(BridgeError::Config(format!("unknown broker type: {other}"))), // allow-secret
105        }
106    }
107}
108
109/// Environment variables for a single CXF profile, used by the bridge Java side.
110/// Password fields use [`Redacted`] to prevent accidental credential leakage in logs.
111#[derive(Debug)]
112pub struct CxfProfileEnvVars {
113    pub name: String,
114    pub wsdl_path: String,
115    pub service_name: String,
116    pub port_name: String,
117    pub address: Option<String>,
118    pub keystore_path: Option<String>,
119    pub keystore_password: Option<Redacted<String>>,
120    pub truststore_path: Option<String>,
121    pub truststore_password: Option<Redacted<String>>,
122    pub sig_username: Option<String>,
123    pub sig_password: Option<Redacted<String>>,
124    pub enc_username: Option<String>,
125    pub security_actions_out: Option<String>,
126    pub security_actions_in: Option<String>,
127    pub signature_algorithm: Option<String>,
128    pub signature_digest_algorithm: Option<String>,
129    pub signature_c14n_algorithm: Option<String>,
130    pub signature_parts: Option<String>,
131}
132
133impl CxfProfileEnvVars {
134    pub fn to_env_vars(&self) -> Vec<(String, String)> {
135        let prefix = format!("CXF_PROFILE_{}_", self.name.to_uppercase());
136        let mut vars = vec![
137            (format!("{}WSDL_PATH", prefix), self.wsdl_path.clone()),
138            (format!("{}SERVICE_NAME", prefix), self.service_name.clone()),
139            (format!("{}PORT_NAME", prefix), self.port_name.clone()),
140        ];
141
142        if let Some(ref v) = self.address {
143            vars.push((format!("{}ADDRESS", prefix), v.clone()));
144        }
145        if let Some(ref v) = self.keystore_path {
146            vars.push((format!("{}KEYSTORE_PATH", prefix), v.clone()));
147        }
148        if let Some(ref v) = self.keystore_password {
149            vars.push((format!("{}KEYSTORE_PASSWORD", prefix), (**v).clone()));
150        }
151        if let Some(ref v) = self.truststore_path {
152            vars.push((format!("{}TRUSTSTORE_PATH", prefix), v.clone()));
153        }
154        if let Some(ref v) = self.truststore_password {
155            vars.push((format!("{}TRUSTSTORE_PASSWORD", prefix), (**v).clone()));
156        }
157        if let Some(ref v) = self.sig_username {
158            vars.push((format!("{}SIG_USERNAME", prefix), v.clone()));
159        }
160        if let Some(ref v) = self.sig_password {
161            vars.push((format!("{}SIG_PASSWORD", prefix), (**v).clone()));
162        }
163        if let Some(ref v) = self.enc_username {
164            vars.push((format!("{}ENC_USERNAME", prefix), v.clone()));
165        }
166        if let Some(ref v) = self.security_actions_out {
167            vars.push((format!("{}SECURITY_ACTIONS_OUT", prefix), v.clone()));
168        }
169        if let Some(ref v) = self.security_actions_in {
170            vars.push((format!("{}SECURITY_ACTIONS_IN", prefix), v.clone()));
171        }
172        if let Some(ref v) = self.signature_algorithm {
173            vars.push((format!("{}SIGNATURE_ALGORITHM", prefix), v.clone()));
174        }
175        if let Some(ref v) = self.signature_digest_algorithm {
176            vars.push((format!("{}SIGNATURE_DIGEST_ALGORITHM", prefix), v.clone()));
177        }
178        if let Some(ref v) = self.signature_c14n_algorithm {
179            vars.push((format!("{}SIGNATURE_C14N_ALGORITHM", prefix), v.clone()));
180        }
181        if let Some(ref v) = self.signature_parts {
182            vars.push((format!("{}SIGNATURE_PARTS", prefix), v.clone()));
183        }
184
185        vars
186    }
187}
188
189/// Configuration for spawning a bridge subprocess.
190/// Password fields use [`Redacted`] to prevent accidental credential leakage in logs.
191///
192/// ADR-0051 credential boundary: manual-redaction
193pub struct BridgeProcessConfig {
194    pub spec: &'static BridgeSpec,
195    pub binary_path: PathBuf,
196    pub broker_url: String,
197    pub broker_type: BrokerType,
198    pub username: Option<String>,
199    pub password: Option<Redacted<String>>,
200    pub start_timeout_ms: u64,
201    pub env_vars: Vec<(String, String)>,
202}
203
204impl fmt::Debug for BridgeProcessConfig {
205    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206        f.debug_struct("BridgeProcessConfig")
207            .field("spec", &self.spec)
208            .field("binary_path", &self.binary_path)
209            .field("broker_url", &"[REDACTED]")
210            .field("broker_type", &self.broker_type)
211            .field("username", &self.username.as_ref().map(|_| "[REDACTED]"))
212            .field("password", &self.password)
213            .field("start_timeout_ms", &self.start_timeout_ms)
214            .field(
215                "env_vars",
216                &format!("[REDACTED; {} entries]", self.env_vars.len()),
217            )
218            .finish()
219    }
220}
221
222impl BridgeProcessConfig {
223    /// Constructor for the JMS bridge.
224    ///
225    /// `journal_dir` and `broker_key` are the Rust-resolved attempt-journal
226    /// root (`JMS_ATTEMPT_JOURNAL_DIR`) and the derived broker key
227    /// (`JMS_BRIDGE_BROKER_KEY`). Either may be `None`, which leaves the
228    /// variable unset so the bridge defers a queue subscribe to a
229    /// `FAILED_PRECONDITION`; producer-only and topic paths still work.
230    #[allow(clippy::too_many_arguments)]
231    pub fn jms(
232        binary_path: PathBuf,
233        broker_url: String,
234        broker_type: BrokerType,
235        username: Option<String>,
236        password: Option<Redacted<String>>,
237        start_timeout_ms: u64,
238        journal_dir: Option<String>,
239        broker_key: Option<String>,
240    ) -> Self {
241        let mut env_vars = vec![
242            ("BRIDGE_BROKER_URL".to_string(), broker_url.clone()),
243            (
244                "BRIDGE_BROKER_TYPE".to_string(),
245                broker_type.as_env_str().to_string(),
246            ),
247        ];
248        if let Some(u) = &username {
249            env_vars.push(("BRIDGE_USERNAME".to_string(), u.clone()));
250        }
251        if let Some(p) = &password {
252            env_vars.push(("BRIDGE_PASSWORD".to_string(), (**p).clone()));
253        }
254        if let Some(dir) = journal_dir {
255            env_vars.push(("JMS_ATTEMPT_JOURNAL_DIR".to_string(), dir));
256        }
257        if let Some(key) = broker_key {
258            env_vars.push(("JMS_BRIDGE_BROKER_KEY".to_string(), key));
259        }
260        Self {
261            spec: &JMS_BRIDGE,
262            binary_path,
263            broker_url,
264            broker_type,
265            username,
266            password,
267            start_timeout_ms,
268            env_vars,
269        }
270    }
271
272    /// Constructor for the XML bridge.
273    pub fn xml(binary_path: PathBuf, start_timeout_ms: u64) -> Self {
274        Self {
275            spec: &XML_BRIDGE,
276            binary_path,
277            broker_url: String::new(),
278            broker_type: BrokerType::Generic,
279            username: None,
280            password: None,
281            start_timeout_ms,
282            env_vars: vec![],
283        }
284    }
285
286    /// Constructor for the CXF bridge with multi-profile support.
287    /// Generates `CXF_PROFILES=list` env var plus per-profile env vars.
288    pub fn cxf_profiles(
289        binary_path: PathBuf,
290        profiles: &[CxfProfileEnvVars],
291        start_timeout_ms: u64,
292    ) -> Self {
293        let profile_names: Vec<String> = profiles.iter().map(|p| p.name.clone()).collect();
294        let mut env_vars = vec![("CXF_PROFILES".to_string(), profile_names.join(","))];
295
296        for profile in profiles {
297            env_vars.extend(profile.to_env_vars());
298        }
299
300        Self {
301            spec: &CXF_BRIDGE,
302            binary_path,
303            broker_url: String::new(),
304            broker_type: BrokerType::Generic,
305            username: None,
306            password: None,
307            start_timeout_ms,
308            env_vars,
309        }
310    }
311
312    pub fn validate(&self) -> Result<(), String> {
313        if self.start_timeout_ms == 0 {
314            return Err("start_timeout_ms must be > 0".to_string());
315        }
316        Ok(())
317    }
318}
319
320/// R4-L7: bounded stdout drain — shared between production start() and tests.
321/// Never stops reading: the OS pipe must stay drained (stopping = pipe fill = child
322/// blocked = worse regression). Bound single-line size to 64 KiB; rate-limit logging
323/// to 100 lines/second with drop summary.
324async fn drain_stdout<R: tokio::io::AsyncBufRead + Unpin>(mut reader: R, token: CancellationToken) {
325    use tokio::io::AsyncBufReadExt;
326    use tokio::time::{Duration, Instant};
327
328    const MAX_LINE_BYTES: usize = 64 * 1024;
329    const LOG_INTERVAL: Duration = Duration::from_secs(1);
330    const LOG_BUDGET: u32 = 100;
331
332    let mut line_buf: Vec<u8> = Vec::new();
333    let mut oversized = false;
334    let mut interval_start = Instant::now();
335    let mut logged: u32 = 0;
336    let mut dropped: u32 = 0;
337
338    loop {
339        tokio::select! {
340            biased;
341            _ = token.cancelled() => break,
342            res = reader.fill_buf() => {
343                let chunk_len = match res {
344                    Ok(chunk) => {
345                        if chunk.is_empty() {
346                            break; // EOF
347                        }
348                        for &b in chunk {
349                            if b == b'\n' {
350                                if oversized {
351                                    // log-policy: degraded
352                                    if logged < LOG_BUDGET {
353                                        tracing::warn!(
354                                            "bridge stdout: oversized line (>{MAX_LINE_BYTES} bytes), truncated"
355                                        );
356                                        logged += 1;
357                                    } else {
358                                        dropped += 1;
359                                    }
360                                } else if logged < LOG_BUDGET {
361                                    // log-policy: normal
362                                    tracing::debug!(
363                                        target: "camel_bridge::child",
364                                        "{}",
365                                        String::from_utf8_lossy(&line_buf)
366                                    );
367                                    logged += 1;
368                                } else {
369                                    dropped += 1;
370                                }
371                                line_buf.clear();
372                                oversized = false;
373                            } else if !oversized {
374                                if line_buf.len() < MAX_LINE_BYTES {
375                                    line_buf.push(b);
376                                } else {
377                                    oversized = true;
378                                }
379                            }
380                        }
381                        chunk.len()
382                    }
383                    Err(_) => break,
384                };
385                reader.consume(chunk_len);
386                if interval_start.elapsed() >= LOG_INTERVAL {
387                    if dropped > 0 {
388                        // log-policy: normal
389                        tracing::debug!(
390                            "bridge stdout: dropped {dropped} lines in last interval"
391                        );
392                    }
393                    interval_start = Instant::now();
394                    logged = 0;
395                    dropped = 0;
396                }
397            }
398        }
399    }
400}
401
402// ---------------------------------------------------------------------------
403// Ready-line classification (phase 4.1 permanent-failure signal)
404// ---------------------------------------------------------------------------
405
406/// One classified bridge startup stdout line.
407#[derive(Debug, PartialEq, Eq)]
408enum ReadyLine {
409    /// `{"status":"ready","port":N}` with a usable port.
410    Ready(u16),
411    /// `{"status":"permanent-failure","reason":"..."}`.
412    PermanentFailure(String),
413    /// `{"status":"ready"}` without a usable port.
414    MalformedReady,
415}
416
417/// Classifies one complete startup line. `None` is an unrelated bridge log
418/// line, which the handshake skips.
419fn parse_ready_line(line: &str) -> Option<ReadyLine> {
420    let value: serde_json::Value = serde_json::from_str(line).ok()?;
421    match value.get("status").and_then(|s| s.as_str())? {
422        "ready" => match value.get("port").and_then(|p| p.as_u64()) {
423            Some(port) => Some(ReadyLine::Ready(port as u16)),
424            None => Some(ReadyLine::MalformedReady),
425        },
426        "permanent-failure" => Some(ReadyLine::PermanentFailure(
427            value
428                .get("reason")
429                .and_then(|r| r.as_str())
430                .unwrap_or("bridge reported a permanent startup failure")
431                .to_string(),
432        )),
433        _ => None,
434    }
435}
436
437/// Reads bridge stdout until the ready line, a permanent-failure line, or EOF.
438///
439/// Bounded per line at 64 KiB. Returns [`BridgeError::StdoutClosed`] on EOF so
440/// the caller can apply the reserved-exit-code mapping.
441async fn read_ready_port<R: tokio::io::AsyncBufRead + Unpin>(
442    reader: &mut R,
443) -> Result<u16, BridgeError> {
444    use tokio::io::AsyncBufReadExt;
445
446    let mut buf_acc: Vec<u8> = Vec::new();
447    const READY_MAX_LINE: usize = 64 * 1024;
448    while let Ok(chunk) = reader.fill_buf().await {
449        if chunk.is_empty() {
450            break; // EOF
451        }
452        let newline_pos = chunk.iter().position(|&b| b == b'\n');
453        let take = match newline_pos {
454            Some(i) => i + 1,
455            None => chunk.len(),
456        };
457        if buf_acc.len() + take <= READY_MAX_LINE {
458            buf_acc.extend_from_slice(&chunk[..take]);
459        }
460        reader.consume(take);
461        if newline_pos.is_some() {
462            let line = String::from_utf8_lossy(&buf_acc);
463            let line_trimmed = line.trim_end_matches('\n');
464            match parse_ready_line(line_trimmed) {
465                Some(ReadyLine::Ready(port)) => return Ok(port),
466                Some(ReadyLine::PermanentFailure(reason)) => {
467                    return Err(BridgeError::PermanentFailure(reason));
468                }
469                Some(ReadyLine::MalformedReady) => {
470                    // log-policy: system-broken
471                    tracing::error!("bridge ready message malformed: {line_trimmed}");
472                    return Err(BridgeError::BadReadyMessage(line_trimmed.to_string()));
473                }
474                None => {}
475            }
476            buf_acc.clear();
477        }
478    }
479    // log-policy: system-broken
480    tracing::error!("bridge stdout closed before ready message");
481    Err(BridgeError::StdoutClosed)
482}
483
484/// Waits (bounded, 2 s) for a child that closed stdout before readiness. Exit
485/// code `78` is the reserved permanent-failure code; any other exit, or a child
486/// that does not exit, stays [`BridgeError::StdoutClosed`].
487///
488/// The 2 s exit classification is separate from cleanup: a child that does not
489/// exit is killed and reaped *after* classification, bounded at another 2 s, so
490/// a known signal is never delayed by teardown.
491async fn eof_exit_error(child: &mut tokio::process::Child) -> BridgeError {
492    use tokio::time::{Duration, timeout};
493
494    match timeout(Duration::from_secs(2), child.wait()).await {
495        Ok(Ok(status)) if status.code() == Some(78) => BridgeError::PermanentFailure(
496            "bridge exited with code 78 before announcing readiness".to_string(),
497        ),
498        Ok(_) => BridgeError::StdoutClosed,
499        Err(_) => {
500            // Closed stdout but still running: terminate and reap so no process
501            // leaks. This cleanup runs after classification and is bounded.
502            let _ = child.start_kill();
503            let _ = timeout(Duration::from_secs(2), child.wait()).await;
504            BridgeError::StdoutClosed
505        }
506    }
507}
508
509pub struct BridgeProcess {
510    child: tokio::process::Child,
511    grpc_port: u16,
512    tls: crate::tls::BridgeTlsMaterial,
513    token: CancellationToken,
514    handle: Option<JoinHandle<()>>,
515}
516
517impl BridgeProcess {
518    pub fn grpc_port(&self) -> u16 {
519        self.grpc_port
520    }
521
522    /// Connect a mTLS tonic channel to this bridge process.
523    pub async fn connect(&self) -> Result<tonic::transport::Channel, BridgeError> {
524        crate::channel::connect_channel(self.grpc_port, &self.tls).await
525    }
526
527    /// Start the bridge process and connect a mTLS channel in one step.
528    pub async fn start_and_connect(
529        config: &BridgeProcessConfig,
530    ) -> Result<(Self, tonic::transport::Channel), BridgeError> {
531        let process = Self::start(config).await?;
532        let channel = process.connect().await?;
533        Ok((process, channel))
534    }
535
536    /// Spawn the bridge process. Reads the SSL port from stdout JSON line:
537    ///   {"status":"ready","port":PORT}
538    ///
539    /// Port handoff (bd rc-s7dyw): passes `QUARKUS_HTTP_SSL_PORT=0` so the
540    /// Quarkus JVM binds an OS-assigned port itself; PortAnnouncer reports
541    /// the ACTUAL bound port (from the Quarkus runtime registry) in the ready
542    /// line, and this side connects to the announced port. The port is held
543    /// by the JVM from the first moment it exists, so the ADR-0070
544    /// bind-read-drop race cannot occur. Build-time TLS props are in
545    /// application.yml; only runtime cert paths and the SSL port mode are
546    /// passed via env vars.
547    pub async fn start(config: &BridgeProcessConfig) -> Result<Self, BridgeError> {
548        use tokio::process::Command;
549        use tokio::time::{Duration, timeout};
550
551        config.validate().map_err(BridgeError::Config)?;
552
553        let tls = crate::tls::BridgeTlsMaterial::generate()?;
554
555        // Port selection is JVM-authoritative (bd rc-s7dyw): 0 makes Quarkus
556        // bind an OS-assigned port and announce the actual bound port. The
557        // old bind-:0-read-drop here left the port unheld between the drop
558        // and the JVM bind (ADR-0070 port-toctou class); with the JVM owning
559        // the bind no such window exists.
560        let free_port: u16 = 0;
561
562        // If CAMEL_BRIDGE_LOG_STDERR is set, redirect stderr to a file for debugging.
563        let stderr_stdio: std::process::Stdio =
564            if let Ok(log_dir) = std::env::var("CAMEL_BRIDGE_LOG_STDERR") {
565                let log_filename = config
566                    .spec
567                    .log_file_template
568                    .replace("{pid}", &std::process::id().to_string());
569                let log_path = if log_dir.is_empty() {
570                    format!("/tmp/{log_filename}")
571                } else {
572                    format!("{log_dir}/{log_filename}")
573                };
574                match std::fs::File::create(&log_path) {
575                    Ok(f) => {
576                        eprintln!("[camel-bridge] stderr → {}", log_path);
577                        f.into()
578                    }
579                    Err(e) => {
580                        eprintln!(
581                            "[camel-bridge] failed to create log file {}: {}",
582                            log_path, e
583                        );
584                        std::process::Stdio::inherit()
585                    }
586                }
587            } else {
588                std::process::Stdio::inherit()
589            };
590
591        let mut command = Command::new(&config.binary_path);
592        command
593            // Cancellation-safe ownership: if this future is dropped (outer
594            // timeout or abort) the raw child is killed instead of orphaned.
595            .kill_on_drop(true)
596            .env("QUARKUS_HTTP_SSL_PORT", free_port.to_string())
597            .env(
598                "QUARKUS_TLS_BRIDGE_KEY_STORE_PEM_0_CERT",
599                &tls.server_pem_path,
600            )
601            .env(
602                "QUARKUS_TLS_BRIDGE_KEY_STORE_PEM_0_KEY",
603                &tls.server_key_path,
604            )
605            .env("QUARKUS_TLS_BRIDGE_TRUST_STORE_PEM_CERTS", &tls.ca_pem_path)
606            .stdout(std::process::Stdio::piped())
607            .stderr(stderr_stdio);
608
609        // Inject bridge-specific env vars (e.g. JMS broker URL/credentials via ::jms()).
610        for (key, value) in &config.env_vars {
611            command.env(key, value);
612        }
613
614        let mut child = command.spawn()?;
615
616        let stdout = child.stdout.take().ok_or(BridgeError::StdoutClosed)?;
617        let mut reader = tokio::io::BufReader::new(stdout);
618
619        // --- Ready-detection phase (bounded by outer timeout + bounded line) ---
620        // R4-L7: use fill_buf()/consume() instead of Lines::next_line() to avoid
621        // unbounded buffer growth on lines without newlines.
622        let ready = timeout(
623            Duration::from_millis(config.start_timeout_ms),
624            read_ready_port(&mut reader),
625        )
626        .await;
627
628        let port = match ready {
629            Ok(Ok(port)) => port,
630            Ok(Err(BridgeError::StdoutClosed)) => {
631                // EOF before ready: a reserved exit code 78 is a permanent
632                // startup failure; any other exit stays the transient EOF error.
633                // Classification is separate from cleanup: `eof_exit_error`
634                // terminates and reaps a still-running child after classifying.
635                return Err(eof_exit_error(&mut child).await);
636            }
637            Ok(Err(error)) => {
638                // Permanent-failure line or malformed ready line: return the
639                // observed signal immediately. The child is dropped at function
640                // exit, and `kill_on_drop` terminates it without blocking the
641                // handshake deadline.
642                return Err(error);
643            }
644            Err(_) => {
645                let msg = format!(
646                    "{} failed to start: health check timeout after {}ms",
647                    config.spec.name, config.start_timeout_ms
648                );
649                // log-policy: system-broken
650                tracing::error!("{msg}");
651                // The stuck child is dropped here; `kill_on_drop` reaps it.
652                return Err(BridgeError::Timeout(msg));
653            }
654        };
655
656        // --- Post-ready bounded drain (R4-L7) ---
657        // Never stop reading: the OS pipe must stay drained (stopping = pipe fill
658        // = child blocked = worse regression). Bound single-line size to 64 KiB;
659        // rate-limit logging to 100 lines/second with drop summary.
660        let token = CancellationToken::new();
661        let child_token = token.clone();
662        let handle = tokio::spawn(async move {
663            drain_stdout(reader, child_token).await;
664        });
665
666        Ok(BridgeProcess {
667            child,
668            grpc_port: port,
669            tls,
670            token,
671            handle: Some(handle),
672        })
673    }
674
675    /// Gracefully stop: SIGTERM + wait for exit.
676    pub async fn stop(mut self) -> Result<(), BridgeError> {
677        use tokio::time::{Duration, sleep};
678
679        self.token.cancel();
680
681        if let Some(handle) = self.handle.take() {
682            let join_result = tokio::time::timeout(Duration::from_secs(5), handle).await;
683            if join_result.is_err() {
684                tracing::warn!("bridge stdout drain task did not exit after cancellation");
685            }
686        }
687
688        // Send SIGTERM first (graceful shutdown)
689        #[cfg(unix)]
690        {
691            let pid = self.child.id().unwrap_or(0);
692            if pid > 0 {
693                // SAFETY: libc::kill is called with the child process PID obtained from tokio.
694                unsafe {
695                    libc::kill(pid as i32, libc::SIGTERM);
696                }
697            }
698        }
699
700        // On non-Unix (Windows), fall through to kill immediately
701        #[cfg(not(unix))]
702        let _ = self.child.start_kill();
703
704        // Wait up to 5 seconds for graceful exit, then SIGKILL
705        tokio::select! {
706            result = self.child.wait() => {
707                result?;
708            }
709            _ = sleep(Duration::from_secs(5)) => {
710                let _ = self.child.start_kill();
711                self.child.wait().await?;
712            }
713        }
714        Ok(())
715    }
716}
717
718impl Drop for BridgeProcess {
719    fn drop(&mut self) {
720        self.token.cancel();
721        // Best-effort only. Does NOT wait — cannot block in Drop.
722        let _ = self.child.start_kill();
723    }
724}
725
726#[cfg(test)]
727mod tests {
728    use super::*;
729
730    #[test]
731    fn broker_type_from_str_activemq() {
732        assert_eq!(
733            "activemq".parse::<BrokerType>().unwrap(),
734            BrokerType::ActiveMq
735        );
736        assert_eq!(
737            "ACTIVEMQ".parse::<BrokerType>().unwrap(),
738            BrokerType::ActiveMq
739        );
740    }
741
742    #[test]
743    fn broker_type_from_str_artemis() {
744        assert_eq!(
745            "artemis".parse::<BrokerType>().unwrap(),
746            BrokerType::Artemis
747        );
748    }
749
750    #[test]
751    fn broker_type_from_str_generic() {
752        assert_eq!(
753            "generic".parse::<BrokerType>().unwrap(),
754            BrokerType::Generic
755        );
756    }
757
758    #[test]
759    fn broker_type_from_str_unknown_returns_err() {
760        assert!("ibmmq".parse::<BrokerType>().is_err());
761        assert!("UnknownBroker".parse::<BrokerType>().is_err());
762    }
763
764    #[test]
765    fn broker_type_env_str() {
766        assert_eq!(BrokerType::ActiveMq.as_env_str(), "activemq");
767        assert_eq!(BrokerType::Artemis.as_env_str(), "artemis");
768        assert_eq!(BrokerType::Generic.as_env_str(), "generic");
769    }
770
771    #[test]
772    fn jms_constructor_uses_jms_spec() {
773        let cfg = BridgeProcessConfig::jms(
774            PathBuf::from("/tmp/jms-bridge"),
775            "tcp://localhost:61616".to_string(),
776            BrokerType::ActiveMq,
777            Some("user".to_string()),
778            Some(Redacted::new("pass".to_string())),
779            1000,
780            Some("/var/lib/state/rust-camel/jms-bridge".to_string()),
781            Some("0123456789abcdef0123456789abcdef".to_string()),
782        );
783        assert_eq!(cfg.spec.name, "jms-bridge");
784    }
785
786    /// Task 3.6: the Rust-resolved journal directory and broker key travel to
787    /// the bridge on the initial launch and every restart, because both go
788    /// through this one constructor.
789    #[test]
790    fn journal_env_passed_on_launch_and_restart() {
791        let dir = "/var/lib/state/rust-camel/jms-bridge".to_string();
792        let key = "0123456789abcdef0123456789abcdef".to_string();
793
794        let initial = BridgeProcessConfig::jms(
795            PathBuf::from("/tmp/jms-bridge"),
796            "tcp://localhost:61616".to_string(),
797            BrokerType::ActiveMq,
798            None,
799            None,
800            1000,
801            Some(dir.clone()),
802            Some(key.clone()),
803        );
804        let restart = BridgeProcessConfig::jms(
805            PathBuf::from("/tmp/jms-bridge"),
806            "tcp://localhost:61616".to_string(),
807            BrokerType::ActiveMq,
808            None,
809            None,
810            1000,
811            Some(dir.clone()),
812            Some(key.clone()),
813        );
814
815        for cfg in [&initial, &restart] {
816            assert!(
817                cfg.env_vars
818                    .iter()
819                    .any(|(k, v)| k == "JMS_ATTEMPT_JOURNAL_DIR" && v == &dir),
820                "JMS_ATTEMPT_JOURNAL_DIR must be present"
821            );
822            assert!(
823                cfg.env_vars
824                    .iter()
825                    .any(|(k, v)| k == "JMS_BRIDGE_BROKER_KEY" && v == &key),
826                "JMS_BRIDGE_BROKER_KEY must be present"
827            );
828        }
829
830        // Unset stays unset (producer-only path).
831        let unset = BridgeProcessConfig::jms(
832            PathBuf::from("/tmp/jms-bridge"),
833            "tcp://localhost:61616".to_string(),
834            BrokerType::ActiveMq,
835            None,
836            None,
837            1000,
838            None,
839            None,
840        );
841        assert!(
842            !unset
843                .env_vars
844                .iter()
845                .any(|(k, _)| k == "JMS_ATTEMPT_JOURNAL_DIR" || k == "JMS_BRIDGE_BROKER_KEY"),
846            "unset journal env must not be injected"
847        );
848    }
849
850    #[test]
851    fn xml_constructor_uses_xml_spec() {
852        let cfg = BridgeProcessConfig::xml(PathBuf::from("/tmp/xml-bridge"), 1000);
853        assert_eq!(cfg.spec.name, "xml-bridge");
854    }
855
856    #[test]
857    fn cxf_profiles_generates_cxf_profiles_env_var() {
858        let profiles = vec![
859            CxfProfileEnvVars {
860                name: "baleares".to_string(),
861                wsdl_path: "/a.wsdl".to_string(),
862                service_name: "Svc".to_string(),
863                port_name: "Port".to_string(),
864                address: None,
865                keystore_path: None,
866                keystore_password: None,
867                truststore_path: None,
868                truststore_password: None,
869                sig_username: None,
870                sig_password: None,
871                enc_username: None,
872                security_actions_out: None,
873                security_actions_in: None,
874                signature_algorithm: None,
875                signature_digest_algorithm: None,
876                signature_c14n_algorithm: None,
877                signature_parts: None,
878            },
879            CxfProfileEnvVars {
880                name: "extremadura".to_string(),
881                wsdl_path: "/b.wsdl".to_string(),
882                service_name: "Svc2".to_string(),
883                port_name: "Port2".to_string(),
884                address: Some("http://host:9090/ws".to_string()),
885                keystore_path: Some("/b.jks".to_string()),
886                keystore_password: Some(Redacted::new("pass".to_string())),
887                truststore_path: None,
888                truststore_password: None,
889                sig_username: Some("cert".to_string()),
890                sig_password: Some(Redacted::new("sig_pass".to_string())),
891                enc_username: None,
892                security_actions_out: Some("Timestamp Signature".to_string()),
893                security_actions_in: Some("Timestamp Signature".to_string()),
894                signature_algorithm: None,
895                signature_digest_algorithm: None,
896                signature_c14n_algorithm: None,
897                signature_parts: None,
898            },
899        ];
900
901        let cfg =
902            BridgeProcessConfig::cxf_profiles(PathBuf::from("/tmp/cxf-bridge"), &profiles, 15_000);
903
904        assert_eq!(cfg.spec.name, "cxf-bridge");
905        assert!(cfg.broker_url.is_empty());
906        assert_eq!(cfg.broker_type, BrokerType::Generic);
907        assert!(cfg.username.is_none());
908        assert!(cfg.password.is_none());
909
910        // Find CXF_PROFILES env var
911        let profiles_var = cfg
912            .env_vars
913            .iter()
914            .find(|(k, _)| k == "CXF_PROFILES")
915            .expect("CXF_PROFILES env var must exist");
916        assert_eq!(profiles_var.1, "baleares,extremadura");
917
918        // Check baleares profile vars (no security)
919        assert!(
920            cfg.env_vars
921                .iter()
922                .any(|(k, v)| k == "CXF_PROFILE_BALEARES_WSDL_PATH" && v == "/a.wsdl")
923        );
924        assert!(
925            cfg.env_vars
926                .iter()
927                .any(|(k, v)| k == "CXF_PROFILE_BALEARES_SERVICE_NAME" && v == "Svc")
928        );
929        assert!(
930            cfg.env_vars
931                .iter()
932                .any(|(k, v)| k == "CXF_PROFILE_BALEARES_PORT_NAME" && v == "Port")
933        );
934        assert!(
935            !cfg.env_vars
936                .iter()
937                .any(|(k, _)| k == "CXF_PROFILE_BALEARES_ADDRESS")
938        );
939
940        // Check extremadura profile vars (with security)
941        assert!(
942            cfg.env_vars
943                .iter()
944                .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_WSDL_PATH" && v == "/b.wsdl")
945        );
946        assert!(
947            cfg.env_vars
948                .iter()
949                .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_ADDRESS" && v == "http://host:9090/ws")
950        );
951        assert!(
952            cfg.env_vars
953                .iter()
954                .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_KEYSTORE_PATH" && v == "/b.jks")
955        );
956        assert!(
957            cfg.env_vars
958                .iter()
959                .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_KEYSTORE_PASSWORD" && v == "pass")
960        );
961        assert!(
962            cfg.env_vars
963                .iter()
964                .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_SIG_USERNAME" && v == "cert")
965        );
966        assert!(
967            cfg.env_vars
968                .iter()
969                .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_SIG_PASSWORD" && v == "sig_pass")
970        );
971        assert!(
972            cfg.env_vars
973                .iter()
974                .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_SECURITY_ACTIONS_OUT"
975                    && v == "Timestamp Signature")
976        );
977    }
978
979    #[test]
980    fn cxf_profiles_single_profile_no_security() {
981        let profiles = vec![CxfProfileEnvVars {
982            name: "test".to_string(),
983            wsdl_path: "service.wsdl".to_string(),
984            service_name: "{http://example.com}Service".to_string(),
985            port_name: "{http://example.com}Port".to_string(),
986            address: None,
987            keystore_path: None,
988            keystore_password: None,
989            truststore_path: None,
990            truststore_password: None,
991            sig_username: None,
992            sig_password: None,
993            enc_username: None,
994            security_actions_out: None,
995            security_actions_in: None,
996            signature_algorithm: None,
997            signature_digest_algorithm: None,
998            signature_c14n_algorithm: None,
999            signature_parts: None,
1000        }];
1001
1002        let cfg =
1003            BridgeProcessConfig::cxf_profiles(PathBuf::from("/tmp/cxf-bridge"), &profiles, 15_000);
1004
1005        assert_eq!(cfg.spec.name, "cxf-bridge");
1006        // CXF_PROFILES + 3 required vars (WSDL_PATH, SERVICE_NAME, PORT_NAME)
1007        assert_eq!(cfg.env_vars.len(), 4);
1008        assert_eq!(cfg.env_vars[0].0, "CXF_PROFILES");
1009        assert_eq!(cfg.env_vars[0].1, "test");
1010        assert_eq!(cfg.env_vars[1].0, "CXF_PROFILE_TEST_WSDL_PATH");
1011        assert_eq!(cfg.env_vars[1].1, "service.wsdl");
1012        assert_eq!(cfg.env_vars[2].0, "CXF_PROFILE_TEST_SERVICE_NAME");
1013        assert_eq!(cfg.env_vars[2].1, "{http://example.com}Service");
1014        assert_eq!(cfg.env_vars[3].0, "CXF_PROFILE_TEST_PORT_NAME");
1015        assert_eq!(cfg.env_vars[3].1, "{http://example.com}Port");
1016    }
1017
1018    #[test]
1019    fn profile_env_vars_to_env_vars_includes_all_fields() {
1020        let vars = CxfProfileEnvVars {
1021            name: "full".to_string(),
1022            wsdl_path: "/wsdl".to_string(),
1023            service_name: "Svc".to_string(),
1024            port_name: "Port".to_string(),
1025            address: Some("http://host:8080".to_string()),
1026            keystore_path: Some("/ks.jks".to_string()),
1027            keystore_password: Some(Redacted::new("ks_pass".to_string())),
1028            truststore_path: Some("/ts.jks".to_string()),
1029            truststore_password: Some(Redacted::new("ts_pass".to_string())),
1030            sig_username: Some("user".to_string()),
1031            sig_password: Some(Redacted::new("sig_pass".to_string())),
1032            enc_username: None,
1033            security_actions_out: Some("Timestamp Signature".to_string()),
1034            security_actions_in: Some("Timestamp".to_string()),
1035            signature_algorithm: None,
1036            signature_digest_algorithm: None,
1037            signature_c14n_algorithm: None,
1038            signature_parts: None,
1039        };
1040
1041        let env = vars.to_env_vars();
1042        // 3 required + 1 address + 8 security = 12
1043        assert_eq!(env.len(), 12);
1044
1045        let keys: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
1046        assert!(keys.contains(&"CXF_PROFILE_FULL_WSDL_PATH"));
1047        assert!(keys.contains(&"CXF_PROFILE_FULL_SERVICE_NAME"));
1048        assert!(keys.contains(&"CXF_PROFILE_FULL_PORT_NAME"));
1049        assert!(keys.contains(&"CXF_PROFILE_FULL_ADDRESS"));
1050        assert!(keys.contains(&"CXF_PROFILE_FULL_KEYSTORE_PATH"));
1051        assert!(keys.contains(&"CXF_PROFILE_FULL_KEYSTORE_PASSWORD"));
1052        assert!(keys.contains(&"CXF_PROFILE_FULL_TRUSTSTORE_PATH"));
1053        assert!(keys.contains(&"CXF_PROFILE_FULL_TRUSTSTORE_PASSWORD"));
1054        assert!(keys.contains(&"CXF_PROFILE_FULL_SIG_USERNAME"));
1055        assert!(keys.contains(&"CXF_PROFILE_FULL_SIG_PASSWORD"));
1056        assert!(keys.contains(&"CXF_PROFILE_FULL_SECURITY_ACTIONS_OUT"));
1057        assert!(keys.contains(&"CXF_PROFILE_FULL_SECURITY_ACTIONS_IN"));
1058    }
1059
1060    #[test]
1061    fn test_start_timeout_zero_rejected() {
1062        let config = BridgeProcessConfig::jms(
1063            PathBuf::from("/usr/bin/echo"),
1064            "tcp://localhost:61616".to_string(),
1065            BrokerType::ActiveMq,
1066            None,
1067            None,
1068            0,
1069            None,
1070            None,
1071        );
1072        let result = config.validate();
1073        assert!(result.is_err());
1074    }
1075
1076    #[test]
1077    fn test_bridge_rejects_zero_start_timeout() {
1078        let config = BridgeProcessConfig::jms(
1079            PathBuf::from("/usr/bin/echo"),
1080            "tcp://localhost:61616".to_string(),
1081            BrokerType::ActiveMq,
1082            None,
1083            None,
1084            0,
1085            None,
1086            None,
1087        );
1088        assert!(config.validate().is_err());
1089    }
1090
1091    #[tokio::test]
1092    async fn test_bridge_stop_completes() {
1093        use tokio::process::Command;
1094        use tokio::time::{Duration, timeout};
1095
1096        let child = Command::new("sh")
1097            .arg("-c")
1098            .arg("trap '' TERM; while true; do echo tick; sleep 1; done")
1099            .stdout(std::process::Stdio::null())
1100            .spawn()
1101            .expect("must spawn test child process");
1102
1103        let bridge = BridgeProcess {
1104            child,
1105            grpc_port: 0,
1106            tls: crate::tls::BridgeTlsMaterial::generate().expect("test tls"),
1107            token: CancellationToken::new(),
1108            handle: None,
1109        };
1110
1111        let result = timeout(Duration::from_secs(10), bridge.stop()).await;
1112        assert!(result.is_ok(), "stop() must complete within 10s");
1113    }
1114
1115    // --- BRG-004: Redacted<T> tests ---
1116
1117    #[test]
1118    fn redacted_debug_displays_redacted() {
1119        let r = Redacted::new("secret_password".to_string());
1120        assert_eq!(format!("{r:?}"), "[REDACTED]");
1121    }
1122
1123    #[test]
1124    fn redacted_display_displays_redacted() {
1125        let r = Redacted::new("secret_password".to_string());
1126        assert_eq!(format!("{r}"), "[REDACTED]");
1127    }
1128
1129    #[test]
1130    fn redacted_deref_gives_inner_value() {
1131        let r = Redacted::new("secret".to_string());
1132        assert_eq!(&*r, "secret");
1133    }
1134
1135    #[test]
1136    fn redacted_into_inner_returns_value() {
1137        let r = Redacted::new("secret".to_string());
1138        assert_eq!(r.into_inner(), "secret");
1139    }
1140
1141    #[test]
1142    fn redacted_clone_works() {
1143        let r = Redacted::new("secret".to_string());
1144        let c = r.clone();
1145        assert_eq!(&*c, "secret");
1146        assert_eq!(format!("{c:?}"), "[REDACTED]");
1147    }
1148
1149    #[test]
1150    fn bridge_process_config_debug_redacts_password() {
1151        let cfg = BridgeProcessConfig::jms(
1152            PathBuf::from("/tmp/jms-bridge"),
1153            "tcp://localhost:61616".to_string(),
1154            BrokerType::ActiveMq,
1155            Some("user".to_string()),
1156            Some(Redacted::new("super_secret".to_string())),
1157            1000,
1158            None,
1159            None,
1160        );
1161        // The Redacted<T> password field must show [REDACTED] in debug output.
1162        // env_vars is a separate Vec<(String, String)> used for process injection
1163        // and legitimately contains the raw value — that is not a Redacted leak.
1164        let password_debug = format!("{:?}", cfg.password); // allow-secret
1165        assert!(
1166            !password_debug.contains("super_secret"),
1167            "Password field must not leak in Debug: {password_debug}"
1168        );
1169        assert_eq!(
1170            password_debug, "Some([REDACTED])",
1171            "Password field must show [REDACTED]: {password_debug}"
1172        );
1173    }
1174
1175    #[test]
1176    fn bridge_process_config_debug_redacts_broker_url_and_env_vars() {
1177        let cfg = BridgeProcessConfig {
1178            spec: &JMS_BRIDGE,
1179            binary_path: PathBuf::from("/tmp/jms-bridge"),
1180            broker_url: "amqp://u:SENTINEL-BRIDGE-PASS@host:5672".to_string(),
1181            broker_type: BrokerType::ActiveMq,
1182            username: Some("user".to_string()),
1183            password: Some(Redacted::new("pass".to_string())),
1184            start_timeout_ms: 1000,
1185            env_vars: vec![(
1186                "KEYSTORE_PASSWORD".to_string(),
1187                "SENTINEL-ENV-PASS".to_string(),
1188            )],
1189        };
1190        let debug_output = format!("{cfg:?}");
1191        assert!(
1192            !debug_output.contains("SENTINEL-BRIDGE-PASS"),
1193            "broker_url must be redacted in Debug: {debug_output}"
1194        );
1195        assert!(
1196            !debug_output.contains("SENTINEL-ENV-PASS"),
1197            "env_vars values must be redacted in Debug: {debug_output}"
1198        );
1199        assert!(
1200            debug_output.contains("[REDACTED; 1 entries]"),
1201            "env_vars must show redacted count: {debug_output}"
1202        );
1203    }
1204
1205    #[test]
1206    fn bridge_process_config_debug_redacts_all_sensitive_fields() {
1207        let cfg = BridgeProcessConfig {
1208            spec: &JMS_BRIDGE,
1209            binary_path: PathBuf::from("/tmp/jms-bridge"),
1210            broker_url: "amqp://SENTINEL-BROKER-URL@host:5672".to_string(),
1211            broker_type: BrokerType::ActiveMq,
1212            username: Some("SENTINEL-USERNAME".to_string()),
1213            password: Some(Redacted::new("SENTINEL-PASSWORD".to_string())),
1214            start_timeout_ms: 1000,
1215            env_vars: vec![(
1216                "KEYSTORE_PASSWORD".to_string(),
1217                "SENTINEL-ENV-PASS".to_string(),
1218            )],
1219        };
1220        let debug_output = format!("{cfg:?}");
1221        assert!(
1222            !debug_output.contains("SENTINEL-BROKER-URL")
1223                && !debug_output.contains("SENTINEL-USERNAME")
1224                && !debug_output.contains("SENTINEL-PASSWORD")
1225                && !debug_output.contains("SENTINEL-ENV-PASS"),
1226            "No sensitive field may leak in Debug: {debug_output}"
1227        );
1228        assert!(
1229            debug_output.contains("username: Some(\"[REDACTED]\")"),
1230            "username must be masked in Debug: {debug_output}"
1231        );
1232    }
1233
1234    #[test]
1235    fn cxf_profile_debug_redacts_passwords() {
1236        let profile = CxfProfileEnvVars {
1237            name: "test".to_string(),
1238            wsdl_path: "/a.wsdl".to_string(),
1239            service_name: "Svc".to_string(),
1240            port_name: "Port".to_string(),
1241            address: None,
1242            keystore_path: None,
1243            keystore_password: Some(Redacted::new("ks_secret_val".to_string())),
1244            truststore_path: None,
1245            truststore_password: Some(Redacted::new("ts_secret_val".to_string())),
1246            sig_username: None,
1247            sig_password: Some(Redacted::new("sig_secret_val".to_string())),
1248            enc_username: None,
1249            security_actions_out: None,
1250            security_actions_in: None,
1251            signature_algorithm: None,
1252            signature_digest_algorithm: None,
1253            signature_c14n_algorithm: None,
1254            signature_parts: None,
1255        };
1256        let debug_output = format!("{profile:?}");
1257        assert!(
1258            !debug_output.contains("ks_secret_val")
1259                && !debug_output.contains("ts_secret_val")
1260                && !debug_output.contains("sig_secret_val"),
1261            "Debug must not contain passwords: {debug_output}"
1262        );
1263    }
1264
1265    // --- R4-L7: Bounded stdout drain tests ---
1266
1267    /// Helper: spawn a child that writes to stdout, return (child, stdout BufReader).
1268    ///
1269    /// stdin/stderr are nulled so no descendant inherits the test binary's
1270    /// harness pipes. Under cargo-nextest, a leaked grandchild (e.g. an
1271    /// orphaned `sleep` outliving a SIGKILLed `sh`) that still holds the
1272    /// inherited stdin/stderr keeps nextest's pipes open past leak-timeout
1273    /// and the test is flagged LEAK even though it passed.
1274    async fn spawn_child_with_stdout(script: &str) -> tokio::process::Child {
1275        use tokio::process::Command;
1276
1277        Command::new("sh")
1278            .arg("-c")
1279            .arg(script)
1280            .stdin(std::process::Stdio::null())
1281            .stdout(std::process::Stdio::piped())
1282            .stderr(std::process::Stdio::null())
1283            .spawn()
1284            .expect("must spawn test child")
1285    }
1286
1287    #[tokio::test]
1288    async fn drain_chatty_child_no_deadlock() {
1289        // Child writes many short lines quickly, then exits
1290        let mut child =
1291            spawn_child_with_stdout("for i in $(seq 1 500); do echo \"line $i\"; done").await;
1292
1293        let stdout = child.stdout.take().expect("stdout piped");
1294        let reader = tokio::io::BufReader::new(stdout);
1295        let token = CancellationToken::new();
1296
1297        // Call the production drain function directly (I-1: no duplicated logic)
1298        let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1299
1300        // Wait for child to exit
1301        let status = child.wait().await.expect("wait for child");
1302        assert!(status.success(), "child should exit successfully");
1303
1304        // Drain task should complete promptly after child exits (EOF)
1305        let result = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
1306        assert!(result.is_ok(), "drain task should complete within 2s");
1307    }
1308
1309    #[tokio::test]
1310    async fn drain_oversized_line_bounded() {
1311        // Child writes a line >64 KiB without newline, then newline.
1312        // Use printf + head to generate 200 KiB of 'A' without python dependency.
1313        // Bound verification: the drain loop caps line_buf at MAX_LINE_BYTES (64 KiB)
1314        // via the `oversized` flag — bytes beyond the cap are discarded, not accumulated.
1315        // Enforced by code inspection (src/process.rs drain_stdout: `line_buf.len() < MAX_LINE_BYTES`
1316        // guard + `oversized = true` branch that skips push). This test verifies the function
1317        // completes within a tight time bound (500ms), which would fail if the loop stalled
1318        // or allocated unboundedly.
1319        let oversized_bytes = 200 * 1024; // 200 KiB
1320        let mut child = spawn_child_with_stdout(&format!(
1321            "head -c {oversized_bytes} /dev/zero | tr '\\0' 'A'; echo"
1322        ))
1323        .await;
1324
1325        let stdout = child.stdout.take().expect("stdout piped");
1326        let reader = tokio::io::BufReader::new(stdout);
1327        let token = CancellationToken::new();
1328
1329        let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1330
1331        let status = child.wait().await.expect("wait for child");
1332        assert!(status.success(), "child should exit successfully");
1333
1334        // Tighter bound: 500ms (was 2s). If the cap were broken, the loop would
1335        // still complete but would have allocated >64 KiB per line — caught by
1336        // code review + the oversized warning log assertion in integration tests.
1337        let result = tokio::time::timeout(std::time::Duration::from_millis(500), handle).await;
1338        assert!(result.is_ok(), "drain task should complete within 500ms");
1339    }
1340
1341    #[tokio::test]
1342    async fn drain_cancellation_exits_promptly() {
1343        // Child writes slowly (sleep between lines)
1344        let mut child = spawn_child_with_stdout("while true; do echo tick; sleep 1; done").await;
1345
1346        let stdout = child.stdout.take().expect("stdout piped");
1347        let reader = tokio::io::BufReader::new(stdout);
1348        let token = CancellationToken::new();
1349
1350        // Call the production drain function directly (I-1: no duplicated logic)
1351        let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1352
1353        // drain_stdout polls `token.cancelled()` first in a biased select,
1354        // so cancelling before the task's first poll still exits promptly.
1355        token.cancel();
1356        let result = tokio::time::timeout(std::time::Duration::from_millis(500), handle).await;
1357        assert!(
1358            result.is_ok(),
1359            "drain task should exit promptly after cancellation"
1360        );
1361
1362        // Clean up child
1363        let _ = child.kill().await;
1364    }
1365
1366    /// I-2: Verify the single-BufReader-for-both-phases invariant.
1367    /// The ready-detection phase and drain phase must share the SAME BufReader;
1368    /// otherwise trailing bytes after the ready message would be lost (a second
1369    /// reader would start at offset 0, missing data already consumed by phase 1).
1370    /// This test exercises the handoff by mimicking the ready-detection loop,
1371    /// then calling drain_stdout with the same reader to verify trailing lines
1372    /// are processed (not lost).
1373    #[tokio::test]
1374    async fn drain_handoff_preserves_trailing_bytes() {
1375        use tokio::io::AsyncBufReadExt;
1376
1377        // Child emits ready JSON + trailing log lines
1378        let mut child = spawn_child_with_stdout(
1379            r#"echo '{"status":"ready","port":12345}'; echo "trailing-1"; echo "trailing-2"; echo "trailing-3""#,
1380        )
1381        .await;
1382
1383        let stdout = child.stdout.take().expect("stdout piped");
1384        let mut reader = tokio::io::BufReader::new(stdout);
1385
1386        // --- Phase 1: ready-detection (mimics start() logic) ---
1387        let mut buf_acc: Vec<u8> = Vec::new();
1388        const READY_MAX_LINE: usize = 64 * 1024;
1389        let mut port_found = None;
1390        while let Ok(chunk) = reader.fill_buf().await {
1391            if chunk.is_empty() {
1392                break; // EOF
1393            }
1394            let newline_pos = chunk.iter().position(|&b| b == b'\n');
1395            let take = match newline_pos {
1396                Some(i) => i + 1,
1397                None => chunk.len(),
1398            };
1399            if buf_acc.len() + take <= READY_MAX_LINE {
1400                buf_acc.extend_from_slice(&chunk[..take]);
1401            }
1402            reader.consume(take);
1403            if newline_pos.is_some() {
1404                let line = String::from_utf8_lossy(&buf_acc);
1405                let line_trimmed = line.trim_end_matches('\n');
1406                if let Ok(v) = serde_json::from_str::<serde_json::Value>(line_trimmed)
1407                    && v.get("status").and_then(|s| s.as_str()) == Some("ready")
1408                    && let Some(p) = v.get("port").and_then(|p| p.as_u64())
1409                {
1410                    port_found = Some(p as u16);
1411                    break;
1412                }
1413                buf_acc.clear();
1414            }
1415        }
1416        assert_eq!(port_found, Some(12345), "ready message must be parsed");
1417
1418        // --- Phase 2: drain with the SAME reader (I-2: handoff invariant) ---
1419        let token = CancellationToken::new();
1420        let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1421
1422        // Wait for child to exit; the echo script terminates promptly, the
1423        // bound only guards a hung child from hanging the test forever
1424        let status = tokio::time::timeout(std::time::Duration::from_secs(5), child.wait())
1425            .await
1426            .expect("child exits within 5s")
1427            .expect("wait for child");
1428        assert!(status.success(), "child should exit successfully");
1429
1430        // Drain task should complete (trailing lines processed, not lost)
1431        let result = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
1432        assert!(
1433            result.is_ok(),
1434            "drain task should complete — trailing bytes preserved through handoff"
1435        );
1436    }
1437
1438    // --- Phase 4.1: permanent-failure startup signal ---
1439
1440    /// Pure protocol classification; platform-independent.
1441    #[test]
1442    fn parses_permanent_failure_status_line() {
1443        assert_eq!(
1444            parse_ready_line(
1445                r#"{"status":"permanent-failure","reason":"JMS_ATTEMPT_JOURNAL_DIR is not owner-private"}"#
1446            ),
1447            Some(ReadyLine::PermanentFailure(
1448                "JMS_ATTEMPT_JOURNAL_DIR is not owner-private".to_string()
1449            )),
1450            "a permanent-failure line must classify as PermanentFailure"
1451        );
1452        // A ready line never classifies as permanent.
1453        assert!(matches!(
1454            parse_ready_line(r#"{"status":"ready","port":8080}"#),
1455            Some(ReadyLine::Ready(8080))
1456        ));
1457        // Unrelated lines are skipped.
1458        assert_eq!(parse_ready_line("just a log line"), None);
1459    }
1460
1461    /// Writes an executable stub bridge script into `dir`.
1462    #[cfg(unix)]
1463    fn write_stub(dir: &std::path::Path, name: &str, script: &str) -> PathBuf {
1464        use std::os::unix::fs::PermissionsExt;
1465
1466        let path = dir.join(name);
1467        std::fs::write(&path, script).expect("write stub bridge");
1468        let mut perms = std::fs::metadata(&path)
1469            .expect("stat stub bridge")
1470            .permissions();
1471        perms.set_mode(0o755);
1472        std::fs::set_permissions(&path, perms).expect("chmod stub bridge");
1473        path
1474    }
1475
1476    /// A minimal JMS `BridgeProcessConfig` whose binary is the given stub.
1477    #[cfg(unix)]
1478    fn stub_config(binary: PathBuf, start_timeout_ms: u64) -> BridgeProcessConfig {
1479        BridgeProcessConfig {
1480            spec: &JMS_BRIDGE,
1481            binary_path: binary,
1482            broker_url: "tcp://localhost:61616".to_string(),
1483            broker_type: BrokerType::ActiveMq,
1484            username: None,
1485            password: None,
1486            start_timeout_ms,
1487            env_vars: vec![],
1488        }
1489    }
1490
1491    /// Bounded (external, no Rust sleep) check that `pid` is gone.
1492    ///
1493    /// A zombie counts as gone: `kill -0` still succeeds on one, and these
1494    /// tests run on a current-thread runtime that this blocking call stalls,
1495    /// so tokio's orphan reaper cannot collect the `kill_on_drop` child while
1496    /// we wait. The child is dead (SIGKILLed); only the reap is pending.
1497    #[cfg(unix)]
1498    fn wait_pid_gone(pid: u32) -> bool {
1499        std::process::Command::new("sh")
1500            .arg("-c")
1501            .arg(format!(
1502                "i=0; while [ $i -lt 100 ]; do kill -0 {pid} 2>/dev/null || exit 0; case \"$(ps -o stat= -p {pid} 2>/dev/null)\" in Z*) exit 0;; esac; i=$((i+1)); sleep 0.05; done; exit 1"
1503            ))
1504            .status()
1505            .map(|s| s.success())
1506            .unwrap_or(false)
1507    }
1508
1509    /// Reads a PID file written by a stub.
1510    #[cfg(unix)]
1511    fn read_pid(path: &std::path::Path) -> u32 {
1512        std::fs::read_to_string(path)
1513            .ok()
1514            .and_then(|s| s.trim().parse().ok())
1515            .unwrap_or(0)
1516    }
1517
1518    /// Task 4.1: an EOF before ready with exit code 78 maps to a permanent
1519    /// failure, using a real bounded child.
1520    #[cfg(unix)]
1521    #[tokio::test]
1522    async fn eof_with_exit_78_maps_to_permanent() {
1523        let dir = tempfile::tempdir().expect("tempdir");
1524        let stub = write_stub(dir.path(), "stub-bridge.sh", "#!/bin/sh\nexit 78\n");
1525
1526        let result = BridgeProcess::start(&stub_config(stub, 5_000)).await;
1527        match result {
1528            Err(BridgeError::PermanentFailure(reason)) => {
1529                assert!(
1530                    reason.contains("78"),
1531                    "reason must name the reserved exit code: {reason}"
1532                );
1533            }
1534            Err(other) => panic!("expected PermanentFailure from exit 78, got: {other:?}"),
1535            Ok(_) => panic!("exit 78 before ready must never start a bridge"),
1536        }
1537    }
1538
1539    /// A child that closes stdout without exiting is a bounded transient error,
1540    /// and the still-running child is terminated on drop (no leak).
1541    #[cfg(unix)]
1542    #[tokio::test]
1543    async fn eof_closed_stdout_child_is_bounded_other_and_killed() {
1544        let dir = tempfile::tempdir().expect("tempdir");
1545        let pidfile = dir.path().join("child.pid");
1546        let stub = write_stub(
1547            dir.path(),
1548            "stub-bridge.sh",
1549            &format!(
1550                "#!/bin/sh\necho $$ > '{}'\nexec 1>&-\nexec sleep 30\n",
1551                pidfile.display()
1552            ),
1553        );
1554
1555        let result = BridgeProcess::start(&stub_config(stub, 5_000)).await;
1556        assert!(
1557            matches!(result, Err(BridgeError::StdoutClosed)),
1558            "a non-exiting child must surface the bounded EOF error"
1559        );
1560
1561        let pid = read_pid(&pidfile);
1562        assert!(pid > 0, "stub must have written its pid");
1563        assert!(wait_pid_gone(pid), "child {pid} must be terminated");
1564    }
1565
1566    /// A permanent-failure line returns promptly (not delayed by cleanup) and
1567    /// the still-running child is terminated on drop.
1568    #[cfg(unix)]
1569    #[tokio::test]
1570    async fn permanent_line_returns_prompt_and_kills_child() {
1571        let dir = tempfile::tempdir().expect("tempdir");
1572        let pidfile = dir.path().join("child.pid");
1573        let stub = write_stub(
1574            dir.path(),
1575            "stub-bridge.sh",
1576            &format!(
1577                "#!/bin/sh\necho $$ > '{}'\nprintf '%s\\n' '{{\"status\":\"permanent-failure\",\"reason\":\"JMS_ATTEMPT_JOURNAL_DIR is not owner-private\"}}'\nexec sleep 30\n",
1578                pidfile.display()
1579            ),
1580        );
1581
1582        // The startup budget is short so a delayed cleanup would blow it.
1583        let result = BridgeProcess::start(&stub_config(stub, 300)).await;
1584        match result {
1585            Err(BridgeError::PermanentFailure(reason)) => {
1586                assert!(reason.contains("JMS_ATTEMPT_JOURNAL_DIR"), "{reason}");
1587            }
1588            Err(other) => panic!("expected PermanentFailure, got: {other:?}"),
1589            Ok(_) => panic!("a permanent-failure line must never start a bridge"),
1590        }
1591
1592        let pid = read_pid(&pidfile);
1593        assert!(pid > 0, "stub must have written its pid");
1594        assert!(wait_pid_gone(pid), "child {pid} must be terminated");
1595    }
1596}