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    #[error(
76        "bridge cache directory {path} is not writable: {source}; the directory may be owned by another user (e.g. left by a Docker run as root) or on a read-only filesystem — repair its ownership/permissions, or bypass the cache via CAMEL_<BRIDGE>_BINARY_PATH or the component's bridge_cache_dir"
77    )]
78    CacheUnwritable {
79        path: std::path::PathBuf,
80        source: std::io::Error,
81    },
82}
83
84#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
85#[serde(rename_all = "lowercase")]
86pub enum BrokerType {
87    #[serde(alias = "active_mq")]
88    ActiveMq,
89    Artemis,
90    Generic,
91}
92
93impl BrokerType {
94    pub fn as_env_str(&self) -> &'static str {
95        match self {
96            BrokerType::ActiveMq => "activemq",
97            BrokerType::Artemis => "artemis",
98            BrokerType::Generic => "generic",
99        }
100    }
101}
102
103impl std::str::FromStr for BrokerType {
104    type Err = BridgeError;
105
106    fn from_str(s: &str) -> Result<Self, Self::Err> {
107        match s.to_lowercase().as_str() {
108            "activemq" => Ok(BrokerType::ActiveMq),
109            "artemis" => Ok(BrokerType::Artemis),
110            "generic" => Ok(BrokerType::Generic),
111            other => Err(BridgeError::Config(format!("unknown broker type: {other}"))), // allow-secret
112        }
113    }
114}
115
116/// Environment variables for a profile's producer transport TLS material,
117/// emitted as `CXF_PROFILE_<NAME>_TLS_*`. Password fields use [`Redacted`] to
118/// prevent accidental credential leakage in logs (ADR-0051).
119#[derive(Debug, Default)]
120pub struct CxfTlsEnvVars {
121    pub keystore_path: Option<String>,
122    pub keystore_password: Option<Redacted<String>>,
123    pub keystore_type: Option<String>,
124    pub truststore_path: Option<String>,
125    pub truststore_password: Option<Redacted<String>>,
126    pub truststore_type: Option<String>,
127}
128
129impl CxfTlsEnvVars {
130    /// Emit the six `TLS_*` env vars under the given `CXF_PROFILE_<NAME>_`
131    /// prefix.
132    pub fn to_env_vars(&self, prefix: &str) -> Vec<(String, String)> {
133        let mut vars = Vec::new();
134        if let Some(ref v) = self.keystore_path {
135            vars.push((format!("{prefix}TLS_KEYSTORE_PATH"), v.clone()));
136        }
137        if let Some(ref v) = self.keystore_password {
138            vars.push((format!("{prefix}TLS_KEYSTORE_PASSWORD"), (**v).clone()));
139        }
140        if let Some(ref v) = self.keystore_type {
141            vars.push((format!("{prefix}TLS_KEYSTORE_TYPE"), v.clone()));
142        }
143        if let Some(ref v) = self.truststore_path {
144            vars.push((format!("{prefix}TLS_TRUSTSTORE_PATH"), v.clone()));
145        }
146        if let Some(ref v) = self.truststore_password {
147            vars.push((format!("{prefix}TLS_TRUSTSTORE_PASSWORD"), (**v).clone()));
148        }
149        if let Some(ref v) = self.truststore_type {
150            vars.push((format!("{prefix}TLS_TRUSTSTORE_TYPE"), v.clone()));
151        }
152        vars
153    }
154}
155
156/// Environment variables for a single CXF profile, used by the bridge Java side.
157/// Password fields use [`Redacted`] to prevent accidental credential leakage in logs.
158#[derive(Debug)]
159pub struct CxfProfileEnvVars {
160    pub name: String,
161    pub wsdl_path: String,
162    pub service_name: String,
163    pub port_name: String,
164    pub address: Option<String>,
165    pub keystore_path: Option<String>,
166    pub keystore_password: Option<Redacted<String>>,
167    pub truststore_path: Option<String>,
168    pub truststore_password: Option<Redacted<String>>,
169    pub sig_username: Option<String>,
170    pub sig_password: Option<Redacted<String>>,
171    pub enc_username: Option<String>,
172    pub security_actions_out: Option<String>,
173    pub security_actions_in: Option<String>,
174    pub signature_algorithm: Option<String>,
175    pub signature_digest_algorithm: Option<String>,
176    pub signature_c14n_algorithm: Option<String>,
177    pub signature_parts: Option<String>,
178    /// Optional per-profile HTTP status override for security (WSS) faults only,
179    /// emitted as `CXF_PROFILE_<NAME>_FAULT_STATUS`.
180    pub fault_status: Option<u16>,
181    /// Producer transport TLS material (`[profiles.security.tls]`).
182    pub tls: Option<CxfTlsEnvVars>,
183}
184
185impl CxfProfileEnvVars {
186    pub fn to_env_vars(&self) -> Vec<(String, String)> {
187        let prefix = format!("CXF_PROFILE_{}_", self.name.to_uppercase());
188        let mut vars = vec![
189            (format!("{}WSDL_PATH", prefix), self.wsdl_path.clone()),
190            (format!("{}SERVICE_NAME", prefix), self.service_name.clone()),
191            (format!("{}PORT_NAME", prefix), self.port_name.clone()),
192        ];
193
194        if let Some(ref v) = self.address {
195            vars.push((format!("{}ADDRESS", prefix), v.clone()));
196        }
197        if let Some(ref v) = self.keystore_path {
198            vars.push((format!("{}KEYSTORE_PATH", prefix), v.clone()));
199        }
200        if let Some(ref v) = self.keystore_password {
201            vars.push((format!("{}KEYSTORE_PASSWORD", prefix), (**v).clone()));
202        }
203        if let Some(ref v) = self.truststore_path {
204            vars.push((format!("{}TRUSTSTORE_PATH", prefix), v.clone()));
205        }
206        if let Some(ref v) = self.truststore_password {
207            vars.push((format!("{}TRUSTSTORE_PASSWORD", prefix), (**v).clone()));
208        }
209        if let Some(ref v) = self.sig_username {
210            vars.push((format!("{}SIG_USERNAME", prefix), v.clone()));
211        }
212        if let Some(ref v) = self.sig_password {
213            vars.push((format!("{}SIG_PASSWORD", prefix), (**v).clone()));
214        }
215        if let Some(ref v) = self.enc_username {
216            vars.push((format!("{}ENC_USERNAME", prefix), v.clone()));
217        }
218        if let Some(ref v) = self.security_actions_out {
219            vars.push((format!("{}SECURITY_ACTIONS_OUT", prefix), v.clone()));
220        }
221        if let Some(ref v) = self.security_actions_in {
222            vars.push((format!("{}SECURITY_ACTIONS_IN", prefix), v.clone()));
223        }
224        if let Some(ref v) = self.signature_algorithm {
225            vars.push((format!("{}SIGNATURE_ALGORITHM", prefix), v.clone()));
226        }
227        if let Some(ref v) = self.signature_digest_algorithm {
228            vars.push((format!("{}SIGNATURE_DIGEST_ALGORITHM", prefix), v.clone()));
229        }
230        if let Some(ref v) = self.signature_c14n_algorithm {
231            vars.push((format!("{}SIGNATURE_C14N_ALGORITHM", prefix), v.clone()));
232        }
233        if let Some(ref v) = self.signature_parts {
234            vars.push((format!("{}SIGNATURE_PARTS", prefix), v.clone()));
235        }
236        if let Some(v) = self.fault_status {
237            vars.push((format!("{}FAULT_STATUS", prefix), v.to_string()));
238        }
239        if let Some(ref tls) = self.tls {
240            vars.extend(tls.to_env_vars(&prefix));
241        }
242
243        vars
244    }
245}
246
247/// Configuration for spawning a bridge subprocess.
248/// Password fields use [`Redacted`] to prevent accidental credential leakage in logs.
249///
250/// ADR-0051 credential boundary: manual-redaction
251pub struct BridgeProcessConfig {
252    pub spec: &'static BridgeSpec,
253    pub binary_path: PathBuf,
254    pub broker_url: String,
255    pub broker_type: BrokerType,
256    pub username: Option<String>,
257    pub password: Option<Redacted<String>>,
258    pub start_timeout_ms: u64,
259    pub env_vars: Vec<(String, String)>,
260    /// When `true`, the supervisor awaits the bridge's `soap-ready` announcement
261    /// in addition to the gRPC `ready` line (either order) before `start()`
262    /// returns. Only a CXF bridge configured with a SOAP bind sets it; every
263    /// other bridge (and a CXF pool without a bind) keeps `false` and returns on
264    /// the gRPC `ready` line alone.
265    pub expect_soap_ready: bool,
266}
267
268impl fmt::Debug for BridgeProcessConfig {
269    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270        f.debug_struct("BridgeProcessConfig")
271            .field("spec", &self.spec)
272            .field("binary_path", &self.binary_path)
273            .field("broker_url", &"[REDACTED]")
274            .field("broker_type", &self.broker_type)
275            .field("username", &self.username.as_ref().map(|_| "[REDACTED]"))
276            .field("password", &self.password)
277            .field("start_timeout_ms", &self.start_timeout_ms)
278            .field(
279                "env_vars",
280                &format!("[REDACTED; {} entries]", self.env_vars.len()),
281            )
282            .field("expect_soap_ready", &self.expect_soap_ready)
283            .finish()
284    }
285}
286
287impl BridgeProcessConfig {
288    /// Constructor for the JMS bridge.
289    ///
290    /// `journal_dir` and `broker_key` are the Rust-resolved attempt-journal
291    /// root (`JMS_ATTEMPT_JOURNAL_DIR`) and the derived broker key
292    /// (`JMS_BRIDGE_BROKER_KEY`). Either may be `None`, which leaves the
293    /// variable unset so the bridge defers a queue subscribe to a
294    /// `FAILED_PRECONDITION`; producer-only and topic paths still work.
295    #[allow(clippy::too_many_arguments)]
296    pub fn jms(
297        binary_path: PathBuf,
298        broker_url: String,
299        broker_type: BrokerType,
300        username: Option<String>,
301        password: Option<Redacted<String>>,
302        start_timeout_ms: u64,
303        journal_dir: Option<String>,
304        broker_key: Option<String>,
305    ) -> Self {
306        let mut env_vars = vec![
307            ("BRIDGE_BROKER_URL".to_string(), broker_url.clone()),
308            (
309                "BRIDGE_BROKER_TYPE".to_string(),
310                broker_type.as_env_str().to_string(),
311            ),
312        ];
313        if let Some(u) = &username {
314            env_vars.push(("BRIDGE_USERNAME".to_string(), u.clone()));
315        }
316        if let Some(p) = &password {
317            env_vars.push(("BRIDGE_PASSWORD".to_string(), (**p).clone()));
318        }
319        if let Some(dir) = journal_dir {
320            env_vars.push(("JMS_ATTEMPT_JOURNAL_DIR".to_string(), dir));
321        }
322        if let Some(key) = broker_key {
323            env_vars.push(("JMS_BRIDGE_BROKER_KEY".to_string(), key));
324        }
325        Self {
326            spec: &JMS_BRIDGE,
327            binary_path,
328            broker_url,
329            broker_type,
330            username,
331            password,
332            start_timeout_ms,
333            env_vars,
334            expect_soap_ready: false,
335        }
336    }
337
338    /// Constructor for the XML bridge.
339    pub fn xml(binary_path: PathBuf, start_timeout_ms: u64) -> Self {
340        Self {
341            spec: &XML_BRIDGE,
342            binary_path,
343            broker_url: String::new(),
344            broker_type: BrokerType::Generic,
345            username: None,
346            password: None,
347            start_timeout_ms,
348            env_vars: vec![],
349            expect_soap_ready: false,
350        }
351    }
352
353    /// Constructor for the CXF bridge with multi-profile support.
354    /// Generates `CXF_PROFILES=list` env var plus per-profile env vars.
355    pub fn cxf_profiles(
356        binary_path: PathBuf,
357        profiles: &[CxfProfileEnvVars],
358        start_timeout_ms: u64,
359    ) -> Self {
360        let profile_names: Vec<String> = profiles.iter().map(|p| p.name.clone()).collect();
361        let mut env_vars = vec![("CXF_PROFILES".to_string(), profile_names.join(","))];
362
363        for profile in profiles {
364            env_vars.extend(profile.to_env_vars());
365        }
366
367        Self {
368            spec: &CXF_BRIDGE,
369            binary_path,
370            broker_url: String::new(),
371            broker_type: BrokerType::Generic,
372            username: None,
373            password: None,
374            start_timeout_ms,
375            env_vars,
376            expect_soap_ready: false,
377        }
378    }
379
380    pub fn validate(&self) -> Result<(), String> {
381        if self.start_timeout_ms == 0 {
382            return Err("start_timeout_ms must be > 0".to_string());
383        }
384        Ok(())
385    }
386}
387
388/// R4-L7: bounded stdout drain — shared between production start() and tests.
389/// Never stops reading: the OS pipe must stay drained (stopping = pipe fill = child
390/// blocked = worse regression). Bound single-line size to 64 KiB; rate-limit logging
391/// to 100 lines/second with drop summary.
392async fn drain_stdout<R: tokio::io::AsyncBufRead + Unpin>(mut reader: R, token: CancellationToken) {
393    use tokio::io::AsyncBufReadExt;
394    use tokio::time::{Duration, Instant};
395
396    const MAX_LINE_BYTES: usize = 64 * 1024;
397    const LOG_INTERVAL: Duration = Duration::from_secs(1);
398    const LOG_BUDGET: u32 = 100;
399
400    let mut line_buf: Vec<u8> = Vec::new();
401    let mut oversized = false;
402    let mut interval_start = Instant::now();
403    let mut logged: u32 = 0;
404    let mut dropped: u32 = 0;
405
406    loop {
407        tokio::select! {
408            biased;
409            _ = token.cancelled() => break,
410            res = reader.fill_buf() => {
411                let chunk_len = match res {
412                    Ok(chunk) => {
413                        if chunk.is_empty() {
414                            break; // EOF
415                        }
416                        for &b in chunk {
417                            if b == b'\n' {
418                                if oversized {
419                                    // log-policy: degraded
420                                    if logged < LOG_BUDGET {
421                                        tracing::warn!(
422                                            "bridge stdout: oversized line (>{MAX_LINE_BYTES} bytes), truncated"
423                                        );
424                                        logged += 1;
425                                    } else {
426                                        dropped += 1;
427                                    }
428                                } else if logged < LOG_BUDGET {
429                                    // log-policy: normal
430                                    tracing::debug!(
431                                        target: "camel_bridge::child",
432                                        "{}",
433                                        String::from_utf8_lossy(&line_buf)
434                                    );
435                                    logged += 1;
436                                } else {
437                                    dropped += 1;
438                                }
439                                line_buf.clear();
440                                oversized = false;
441                            } else if !oversized {
442                                if line_buf.len() < MAX_LINE_BYTES {
443                                    line_buf.push(b);
444                                } else {
445                                    oversized = true;
446                                }
447                            }
448                        }
449                        chunk.len()
450                    }
451                    Err(_) => break,
452                };
453                reader.consume(chunk_len);
454                if interval_start.elapsed() >= LOG_INTERVAL {
455                    if dropped > 0 {
456                        // log-policy: normal
457                        tracing::debug!(
458                            "bridge stdout: dropped {dropped} lines in last interval"
459                        );
460                    }
461                    interval_start = Instant::now();
462                    logged = 0;
463                    dropped = 0;
464                }
465            }
466        }
467    }
468}
469
470/// Validate the announced port of a recognized ready line (`ready` or
471/// `soap-ready`): integer 1..=65535. Untrusted-stdout policy — a missing,
472/// non-numeric, or out-of-range port fails startup with `BadReadyMessage`
473/// instead of silently cast-wrapping into a wrong `u16` (e.g. 70000 → 4464).
474/// The reason carries the raw line, already bounded by the 64 KiB line cap.
475fn announced_port(value: &serde_json::Value, line: &str) -> Result<u16, BridgeError> {
476    match value.get("port").and_then(|p| p.as_u64()) {
477        Some(p) if (1..=u64::from(u16::MAX)).contains(&p) => Ok(p as u16),
478        _ => {
479            // log-policy: system-broken
480            tracing::error!("bridge ready message malformed: {line}");
481            Err(BridgeError::BadReadyMessage(line.to_string()))
482        }
483    }
484}
485
486#[derive(Debug, PartialEq)]
487enum ReadyLine {
488    Ready(u16),
489    SoapReady(u16),
490    PermanentFailure(String),
491    MalformedReady,
492}
493
494/// Classifies every startup status through the same strict port validator.
495fn parse_ready_line(line: &str) -> Option<ReadyLine> {
496    let value: serde_json::Value = serde_json::from_str(line).ok()?;
497    match value.get("status").and_then(|s| s.as_str())? {
498        status @ ("ready" | "soap-ready") => Some(match announced_port(&value, line) {
499            Ok(port) if status == "ready" => ReadyLine::Ready(port),
500            Ok(port) => ReadyLine::SoapReady(port),
501            Err(_) => ReadyLine::MalformedReady,
502        }),
503        "permanent-failure" => Some(ReadyLine::PermanentFailure(
504            value
505                .get("reason")
506                .and_then(|r| r.as_str())
507                .unwrap_or("bridge reported a permanent startup failure")
508                .to_string(),
509        )),
510        _ => None,
511    }
512}
513
514/// Reads both readiness announcements in either order, with a 64 KiB line cap.
515/// EOF classification belongs to the caller, outside the startup timeout.
516async fn read_ready_ports<R: tokio::io::AsyncBufRead + Unpin>(
517    reader: &mut R,
518    expect_soap_ready: bool,
519) -> Result<(u16, Option<u16>), BridgeError> {
520    use tokio::io::AsyncBufReadExt;
521    const READY_MAX_LINE: usize = 64 * 1024;
522    let mut buf = Vec::new();
523    let mut oversized = false;
524    let mut grpc_port = None;
525    let mut soap_port = None;
526    loop {
527        let chunk = reader.fill_buf().await?;
528        if chunk.is_empty() {
529            return Err(BridgeError::StdoutClosed);
530        }
531        let newline = chunk.iter().position(|&b| b == b'\n');
532        let take = newline.map_or(chunk.len(), |i| i + 1);
533        if !oversized && buf.len() + take <= READY_MAX_LINE {
534            buf.extend_from_slice(&chunk[..take]);
535        } else {
536            oversized = true;
537        }
538        reader.consume(take);
539        if newline.is_some() {
540            if !oversized {
541                let line = String::from_utf8_lossy(&buf);
542                match parse_ready_line(line.trim_end_matches('\n')) {
543                    Some(ReadyLine::Ready(port)) => grpc_port = Some(port),
544                    Some(ReadyLine::SoapReady(port)) => soap_port = Some(port),
545                    Some(ReadyLine::PermanentFailure(reason)) => {
546                        return Err(BridgeError::PermanentFailure(reason));
547                    }
548                    Some(ReadyLine::MalformedReady) => {
549                        return Err(BridgeError::BadReadyMessage(line.into_owned()));
550                    }
551                    None => {}
552                }
553                if let Some(port) = grpc_port
554                    && (!expect_soap_ready || soap_port.is_some())
555                {
556                    return Ok((port, soap_port));
557                }
558            }
559            buf.clear();
560            oversized = false;
561        }
562    }
563}
564
565#[cfg(test)]
566async fn read_ready_port<R: tokio::io::AsyncBufRead + Unpin>(
567    reader: &mut R,
568) -> Result<u16, BridgeError> {
569    read_ready_ports(reader, false).await.map(|(port, _)| port)
570}
571
572/// Classify EOF outside the readiness deadline, then bound cleanup separately.
573async fn eof_exit_error(child: &mut tokio::process::Child) -> BridgeError {
574    use tokio::time::{Duration, timeout};
575    match timeout(Duration::from_secs(2), child.wait()).await {
576        Ok(Ok(status)) if status.code() == Some(78) => BridgeError::PermanentFailure(
577            "bridge exited with code 78 before announcing readiness".to_string(),
578        ),
579        Ok(_) => BridgeError::StdoutClosed,
580        Err(_) => {
581            let _ = child.start_kill();
582            let _ = timeout(Duration::from_secs(2), child.wait()).await;
583            BridgeError::StdoutClosed
584        }
585    }
586}
587
588pub struct BridgeProcess {
589    child: tokio::process::Child,
590    grpc_port: u16,
591    /// Announced SOAP listener port (`soap-ready` line). `None` when the bridge
592    /// runs without a SOAP bind. Fresh per [`BridgeProcess::start`] — a restart
593    /// builds a new process, so no stale port can survive into the next slot.
594    soap_port: Option<u16>,
595    tls: crate::tls::BridgeTlsMaterial,
596    token: CancellationToken,
597    handle: Option<JoinHandle<()>>,
598}
599
600impl BridgeProcess {
601    pub fn grpc_port(&self) -> u16 {
602        self.grpc_port
603    }
604
605    /// Announced SOAP listener port; `None` without a `soap-ready` line.
606    pub fn soap_port(&self) -> Option<u16> {
607        self.soap_port
608    }
609
610    /// PID of the owned bridge child (read-only lifecycle accessor).
611    pub fn child_pid(&self) -> Option<u32> {
612        self.child.id()
613    }
614
615    /// Connect a mTLS tonic channel to this bridge process.
616    pub async fn connect(&self) -> Result<tonic::transport::Channel, BridgeError> {
617        crate::channel::connect_channel(self.grpc_port, &self.tls).await
618    }
619
620    /// Start the bridge process and connect a mTLS channel in one step.
621    pub async fn start_and_connect(
622        config: &BridgeProcessConfig,
623    ) -> Result<(Self, tonic::transport::Channel), BridgeError> {
624        let process = Self::start(config).await?;
625        let channel = process.connect().await?;
626        Ok((process, channel))
627    }
628
629    /// Spawn the bridge process. Reads the SSL port from stdout JSON line:
630    ///   {"status":"ready","port":PORT}
631    ///
632    /// Port handoff (bd rc-s7dyw): passes `QUARKUS_HTTP_SSL_PORT=0` so the
633    /// Quarkus JVM binds an OS-assigned port itself; PortAnnouncer reports
634    /// the ACTUAL bound port (from the Quarkus runtime registry) in the ready
635    /// line, and this side connects to the announced port. The port is held
636    /// by the JVM from the first moment it exists, so the ADR-0070
637    /// bind-read-drop race cannot occur. Build-time TLS props are in
638    /// application.yml; only runtime cert paths and the SSL port mode are
639    /// passed via env vars.
640    pub async fn start(config: &BridgeProcessConfig) -> Result<Self, BridgeError> {
641        use tokio::process::Command;
642        use tokio::time::{Duration, timeout};
643
644        config.validate().map_err(BridgeError::Config)?;
645
646        let tls = crate::tls::BridgeTlsMaterial::generate()?;
647
648        // Port selection is JVM-authoritative (bd rc-s7dyw): 0 makes Quarkus
649        // bind an OS-assigned port and announce the actual bound port. The
650        // old bind-:0-read-drop here left the port unheld between the drop
651        // and the JVM bind (ADR-0070 port-toctou class); with the JVM owning
652        // the bind no such window exists.
653        let free_port: u16 = 0;
654
655        // If CAMEL_BRIDGE_LOG_STDERR is set, redirect stderr to a file for debugging.
656        let stderr_stdio: std::process::Stdio =
657            if let Ok(log_dir) = std::env::var("CAMEL_BRIDGE_LOG_STDERR") {
658                let log_filename = config
659                    .spec
660                    .log_file_template
661                    .replace("{pid}", &std::process::id().to_string());
662                let log_path = if log_dir.is_empty() {
663                    format!("/tmp/{log_filename}")
664                } else {
665                    format!("{log_dir}/{log_filename}")
666                };
667                match std::fs::File::create(&log_path) {
668                    Ok(f) => {
669                        eprintln!("[camel-bridge] stderr → {}", log_path);
670                        f.into()
671                    }
672                    Err(e) => {
673                        eprintln!(
674                            "[camel-bridge] failed to create log file {}: {}",
675                            log_path, e
676                        );
677                        std::process::Stdio::inherit()
678                    }
679                }
680            } else {
681                std::process::Stdio::inherit()
682            };
683
684        let mut command = Command::new(&config.binary_path);
685        // Owned cleanup: any early return from start() (timeout, malformed ready
686        // line, missing stdout) drops the child handle and kills the process —
687        // no leaked bridge keeps running with stale credentials.
688        command
689            // Cancellation-safe ownership: if this future is dropped (outer
690            // timeout or abort) the raw child is killed instead of orphaned.
691            .kill_on_drop(true)
692            .env("QUARKUS_HTTP_SSL_PORT", free_port.to_string())
693            .env(
694                "QUARKUS_TLS_BRIDGE_KEY_STORE_PEM_0_CERT",
695                &tls.server_pem_path,
696            )
697            .env(
698                "QUARKUS_TLS_BRIDGE_KEY_STORE_PEM_0_KEY",
699                &tls.server_key_path,
700            )
701            .env("QUARKUS_TLS_BRIDGE_TRUST_STORE_PEM_CERTS", &tls.ca_pem_path)
702            .stdout(std::process::Stdio::piped())
703            .stderr(stderr_stdio);
704
705        // Inject bridge-specific env vars (e.g. JMS broker URL/credentials via ::jms()).
706        for (key, value) in &config.env_vars {
707            command.env(key, value);
708        }
709
710        let mut child = command.spawn()?;
711
712        let stdout = child.stdout.take().ok_or(BridgeError::StdoutClosed)?;
713        let mut reader = tokio::io::BufReader::new(stdout);
714
715        // --- Ready-detection phase (bounded by ONE outer timeout + bounded line) ---
716        // Startup protocol (Task 2.8): parse the gRPC `ready` line AND the
717        // `soap-ready` line in ANY order. `config.start_timeout_ms` encloses the
718        // WHOLE wait — it is never reset per line or per chunk. With
719        // `expect_soap_ready = false` the loop returns on the gRPC `ready` line
720        // alone (a `soap-ready` seen before it is still stored when valid).
721        // R4-L7: use fill_buf()/consume() instead of Lines::next_line() to avoid
722        // unbounded buffer growth on lines without newlines.
723        let ready = timeout(
724            Duration::from_millis(config.start_timeout_ms),
725            read_ready_ports(&mut reader, config.expect_soap_ready),
726        )
727        .await;
728        let (port, soap_port) = match ready {
729            Ok(Ok(ports)) => ports,
730            Ok(Err(BridgeError::StdoutClosed)) => {
731                // EOF classification must not lose exit 78 to the readiness deadline.
732                let error = eof_exit_error(&mut child).await;
733                // log-policy: system-broken
734                tracing::error!("bridge stdout closed before ready message");
735                return Err(error);
736            }
737            Ok(Err(error)) => return Err(error),
738            Err(_) => {
739                let msg = format!(
740                    "{} failed to start: health check timeout after {}ms",
741                    config.spec.name, config.start_timeout_ms
742                );
743                // log-policy: system-broken
744                tracing::error!("{msg}");
745                return Err(BridgeError::Timeout(msg));
746            }
747        };
748
749        // --- Post-ready bounded drain (R4-L7) ---
750        // Never stop reading: the OS pipe must stay drained (stopping = pipe fill
751        // = child blocked = worse regression). Bound single-line size to 64 KiB;
752        // rate-limit logging to 100 lines/second with drop summary.
753        let token = CancellationToken::new();
754        let child_token = token.clone();
755        let handle = tokio::spawn(async move {
756            drain_stdout(reader, child_token).await;
757        });
758
759        Ok(BridgeProcess {
760            child,
761            grpc_port: port,
762            soap_port,
763            tls,
764            token,
765            handle: Some(handle),
766        })
767    }
768
769    /// Gracefully stop: SIGTERM + wait for exit.
770    pub async fn stop(mut self) -> Result<(), BridgeError> {
771        use tokio::time::{Duration, sleep};
772
773        self.token.cancel();
774
775        if let Some(handle) = self.handle.take() {
776            let join_result = tokio::time::timeout(Duration::from_secs(5), handle).await;
777            if join_result.is_err() {
778                tracing::warn!("bridge stdout drain task did not exit after cancellation");
779            }
780        }
781
782        // Send SIGTERM first (graceful shutdown)
783        #[cfg(unix)]
784        {
785            let pid = self.child.id().unwrap_or(0);
786            if pid > 0 {
787                // SAFETY: libc::kill is called with the child process PID obtained from tokio.
788                unsafe {
789                    libc::kill(pid as i32, libc::SIGTERM);
790                }
791            }
792        }
793
794        // On non-Unix (Windows), fall through to kill immediately
795        #[cfg(not(unix))]
796        let _ = self.child.start_kill();
797
798        // Wait up to 5 seconds for graceful exit, then SIGKILL
799        tokio::select! {
800            result = self.child.wait() => {
801                result?;
802            }
803            _ = sleep(Duration::from_secs(5)) => {
804                let _ = self.child.start_kill();
805                self.child.wait().await?;
806            }
807        }
808        Ok(())
809    }
810}
811
812impl Drop for BridgeProcess {
813    fn drop(&mut self) {
814        self.token.cancel();
815        // Best-effort only. Does NOT wait — cannot block in Drop.
816        let _ = self.child.start_kill();
817    }
818}
819
820#[cfg(test)]
821mod tests {
822    use super::*;
823
824    #[test]
825    fn broker_type_from_str_activemq() {
826        assert_eq!(
827            "activemq".parse::<BrokerType>().unwrap(),
828            BrokerType::ActiveMq
829        );
830        assert_eq!(
831            "ACTIVEMQ".parse::<BrokerType>().unwrap(),
832            BrokerType::ActiveMq
833        );
834    }
835
836    #[test]
837    fn broker_type_from_str_artemis() {
838        assert_eq!(
839            "artemis".parse::<BrokerType>().unwrap(),
840            BrokerType::Artemis
841        );
842    }
843
844    #[test]
845    fn broker_type_from_str_generic() {
846        assert_eq!(
847            "generic".parse::<BrokerType>().unwrap(),
848            BrokerType::Generic
849        );
850    }
851
852    #[test]
853    fn broker_type_from_str_unknown_returns_err() {
854        assert!("ibmmq".parse::<BrokerType>().is_err());
855        assert!("UnknownBroker".parse::<BrokerType>().is_err());
856    }
857
858    #[test]
859    fn broker_type_env_str() {
860        assert_eq!(BrokerType::ActiveMq.as_env_str(), "activemq");
861        assert_eq!(BrokerType::Artemis.as_env_str(), "artemis");
862        assert_eq!(BrokerType::Generic.as_env_str(), "generic");
863    }
864
865    #[test]
866    fn jms_constructor_uses_jms_spec() {
867        let cfg = BridgeProcessConfig::jms(
868            PathBuf::from("/tmp/jms-bridge"),
869            "tcp://localhost:61616".to_string(),
870            BrokerType::ActiveMq,
871            Some("user".to_string()),
872            Some(Redacted::new("pass".to_string())),
873            1000,
874            None,
875            None,
876        );
877        assert_eq!(cfg.spec.name, "jms-bridge");
878    }
879
880    #[test]
881    fn xml_constructor_uses_xml_spec() {
882        let cfg = BridgeProcessConfig::xml(PathBuf::from("/tmp/xml-bridge"), 1000);
883        assert_eq!(cfg.spec.name, "xml-bridge");
884    }
885
886    #[test]
887    fn journal_env_passed_on_launch_and_restart() {
888        for _ in 0..2 {
889            let config = BridgeProcessConfig::jms(
890                PathBuf::from("/tmp/jms-bridge"),
891                "tcp://localhost:61616".into(),
892                BrokerType::ActiveMq,
893                None,
894                None,
895                1000,
896                Some("/var/lib/state/rust-camel/jms-bridge".into()),
897                Some("broker-key".into()),
898            );
899            assert!(config.env_vars.contains(&(
900                "JMS_ATTEMPT_JOURNAL_DIR".into(),
901                "/var/lib/state/rust-camel/jms-bridge".into()
902            )));
903            assert!(
904                config
905                    .env_vars
906                    .contains(&("JMS_BRIDGE_BROKER_KEY".into(), "broker-key".into()))
907            );
908        }
909        let unset = BridgeProcessConfig::jms(
910            PathBuf::from("/tmp/jms-bridge"),
911            "tcp://localhost:61616".into(),
912            BrokerType::ActiveMq,
913            None,
914            None,
915            1000,
916            None,
917            None,
918        );
919        assert!(
920            !unset
921                .env_vars
922                .iter()
923                .any(|(key, _)| key == "JMS_ATTEMPT_JOURNAL_DIR" || key == "JMS_BRIDGE_BROKER_KEY")
924        );
925    }
926
927    #[test]
928    fn parses_permanent_failure_status_line() {
929        assert_eq!(
930            parse_ready_line(
931                r#"{"status":"permanent-failure","reason":"JMS_ATTEMPT_JOURNAL_DIR is not owner-private"}"#
932            ),
933            Some(ReadyLine::PermanentFailure(
934                "JMS_ATTEMPT_JOURNAL_DIR is not owner-private".into()
935            ))
936        );
937        assert_eq!(
938            parse_ready_line(r#"{"status":"ready","port":8080}"#),
939            Some(ReadyLine::Ready(8080))
940        );
941        assert_eq!(parse_ready_line("just a log line"), None);
942        for status in ["ready", "soap-ready"] {
943            for port in ["0", "65536", "-1", "1.5", "\"123\"", "null"] {
944                assert_eq!(
945                    parse_ready_line(&format!(r#"{{"status":"{status}","port":{port}}}"#)),
946                    Some(ReadyLine::MalformedReady)
947                );
948            }
949        }
950    }
951
952    #[cfg(unix)]
953    fn wait_pid_gone(pid: u32) -> bool {
954        std::process::Command::new("sh").arg("-c").arg(format!(
955            "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"
956        )).status().map(|s| s.success()).unwrap_or(false)
957    }
958
959    #[cfg(unix)]
960    #[tokio::test]
961    async fn eof_with_exit_78_maps_to_permanent() {
962        // Exit occurs after stdout closes and after the readiness budget ends.
963        let (_dir, script) = fake_bridge_script("exec 1>&-; sleep 0.3; exit 78");
964        let result = BridgeProcess::start(&fake_bridge_config(&script, false, 150)).await;
965        assert!(
966            matches!(result, Err(BridgeError::PermanentFailure(reason)) if reason.contains("78"))
967        );
968    }
969
970    #[cfg(unix)]
971    #[tokio::test]
972    async fn eof_with_other_exit_stays_transient() {
973        let (_dir, script) = fake_bridge_script("exit 1");
974        let result = BridgeProcess::start(&fake_bridge_config(&script, false, 5000)).await;
975        assert!(matches!(result, Err(BridgeError::StdoutClosed)));
976    }
977
978    #[tokio::test]
979    async fn oversized_ready_line_is_discarded_before_next_announcement() {
980        let mut output = vec![b' '; 64 * 1024];
981        output.extend_from_slice(b"{\"status\":\"ready\",\"port\":9999}\n");
982        output.extend_from_slice(b"{\"status\":\"ready\",\"port\":12345}\n");
983        let mut reader = tokio::io::BufReader::new(output.as_slice());
984        assert_eq!(
985            read_ready_port(&mut reader).await.expect("ready port"),
986            12345
987        );
988    }
989
990    #[cfg(unix)]
991    #[tokio::test]
992    async fn eof_closed_stdout_child_is_bounded_other_and_killed() {
993        let dir = tempfile::tempdir().expect("tempdir");
994        let pidfile = dir.path().join("child.pid");
995        let (_script_dir, script) = fake_bridge_script(&format!(
996            "echo $$ > '{}'; exec 1>&-; exec sleep 30",
997            pidfile.display()
998        ));
999        let result = tokio::time::timeout(
1000            std::time::Duration::from_secs(6),
1001            BridgeProcess::start(&fake_bridge_config(&script, false, 5000)),
1002        )
1003        .await
1004        .expect("bounded EOF classification");
1005        assert!(matches!(result, Err(BridgeError::StdoutClosed)));
1006        let pid = std::fs::read_to_string(pidfile)
1007            .expect("pidfile")
1008            .trim()
1009            .parse()
1010            .expect("pid");
1011        assert!(wait_pid_gone(pid), "EOF child must be killed");
1012    }
1013
1014    #[cfg(unix)]
1015    #[tokio::test]
1016    async fn permanent_line_returns_prompt_and_kills_child() {
1017        let dir = tempfile::tempdir().expect("tempdir");
1018        let pidfile = dir.path().join("child.pid");
1019        let (_script_dir, script) = fake_bridge_script(&format!(
1020            "echo $$ > '{}'; echo '{{\"status\":\"permanent-failure\",\"reason\":\"JMS_ATTEMPT_JOURNAL_DIR is not owner-private\"}}'; exec sleep 30",
1021            pidfile.display()
1022        ));
1023        let result = tokio::time::timeout(
1024            std::time::Duration::from_secs(1),
1025            BridgeProcess::start(&fake_bridge_config(&script, true, 300)),
1026        )
1027        .await
1028        .expect("prompt permanent failure");
1029        assert!(
1030            matches!(result, Err(BridgeError::PermanentFailure(reason)) if reason.contains("JMS_ATTEMPT_JOURNAL_DIR"))
1031        );
1032        let pid = std::fs::read_to_string(pidfile)
1033            .expect("pidfile")
1034            .trim()
1035            .parse()
1036            .expect("pid");
1037        assert!(wait_pid_gone(pid), "permanent failure child must be killed");
1038    }
1039
1040    #[cfg(unix)]
1041    #[tokio::test]
1042    async fn startup_cancellation_kills_child() {
1043        let dir = tempfile::tempdir().expect("tempdir");
1044        let pidfile = dir.path().join("child.pid");
1045        let (_script_dir, script) =
1046            fake_bridge_script(&format!("echo $$ > '{}'; exec sleep 30", pidfile.display()));
1047        let config = fake_bridge_config(&script, true, 5000);
1048        let task = tokio::spawn(async move { BridgeProcess::start(&config).await });
1049        let pid = tokio::time::timeout(std::time::Duration::from_secs(2), async {
1050            loop {
1051                if let Ok(text) = std::fs::read_to_string(&pidfile)
1052                    && let Ok(pid) = text.trim().parse::<u32>()
1053                {
1054                    break pid;
1055                }
1056                tokio::task::yield_now().await;
1057            }
1058        })
1059        .await
1060        .expect("child starts");
1061        task.abort();
1062        assert!(matches!(task.await, Err(error) if error.is_cancelled()));
1063        assert!(wait_pid_gone(pid), "cancelled startup child must be killed");
1064    }
1065
1066    #[test]
1067    fn default_expect_soap_ready_is_false() {
1068        let xml = BridgeProcessConfig::xml(PathBuf::from("/tmp/xml-bridge"), 1000);
1069        assert!(
1070            !xml.expect_soap_ready,
1071            "xml bridge must not expect soap-ready"
1072        );
1073        let jms = BridgeProcessConfig::jms(
1074            PathBuf::from("/tmp/jms-bridge"),
1075            "tcp://localhost:61616".to_string(),
1076            BrokerType::ActiveMq,
1077            None,
1078            None,
1079            1000,
1080            None,
1081            None,
1082        );
1083        assert!(
1084            !jms.expect_soap_ready,
1085            "jms bridge must not expect soap-ready"
1086        );
1087        let cxf = BridgeProcessConfig::cxf_profiles(PathBuf::from("/tmp/cxf-bridge"), &[], 1000);
1088        assert!(
1089            !cxf.expect_soap_ready,
1090            "a CXF pool without a SOAP bind must not expect soap-ready"
1091        );
1092    }
1093
1094    #[test]
1095    fn cxf_profiles_generates_cxf_profiles_env_var() {
1096        let profiles = vec![
1097            CxfProfileEnvVars {
1098                name: "baleares".to_string(),
1099                wsdl_path: "/a.wsdl".to_string(),
1100                service_name: "Svc".to_string(),
1101                port_name: "Port".to_string(),
1102                address: None,
1103                keystore_path: None,
1104                keystore_password: None,
1105                truststore_path: None,
1106                truststore_password: None,
1107                sig_username: None,
1108                sig_password: None,
1109                enc_username: None,
1110                security_actions_out: None,
1111                security_actions_in: None,
1112                signature_algorithm: None,
1113                signature_digest_algorithm: None,
1114                signature_c14n_algorithm: None,
1115                signature_parts: None,
1116                fault_status: None,
1117                tls: None,
1118            },
1119            CxfProfileEnvVars {
1120                name: "extremadura".to_string(),
1121                wsdl_path: "/b.wsdl".to_string(),
1122                service_name: "Svc2".to_string(),
1123                port_name: "Port2".to_string(),
1124                address: Some("http://host:9090/ws".to_string()),
1125                keystore_path: Some("/b.jks".to_string()),
1126                keystore_password: Some(Redacted::new("pass".to_string())),
1127                truststore_path: None,
1128                truststore_password: None,
1129                sig_username: Some("cert".to_string()),
1130                sig_password: Some(Redacted::new("sig_pass".to_string())),
1131                enc_username: None,
1132                security_actions_out: Some("Timestamp Signature".to_string()),
1133                security_actions_in: Some("Timestamp Signature".to_string()),
1134                signature_algorithm: None,
1135                signature_digest_algorithm: None,
1136                signature_c14n_algorithm: None,
1137                signature_parts: None,
1138                fault_status: None,
1139                tls: None,
1140            },
1141        ];
1142
1143        let cfg =
1144            BridgeProcessConfig::cxf_profiles(PathBuf::from("/tmp/cxf-bridge"), &profiles, 15_000);
1145
1146        assert_eq!(cfg.spec.name, "cxf-bridge");
1147        assert!(cfg.broker_url.is_empty());
1148        assert_eq!(cfg.broker_type, BrokerType::Generic);
1149        assert!(cfg.username.is_none());
1150        assert!(cfg.password.is_none());
1151
1152        // Find CXF_PROFILES env var
1153        let profiles_var = cfg
1154            .env_vars
1155            .iter()
1156            .find(|(k, _)| k == "CXF_PROFILES")
1157            .expect("CXF_PROFILES env var must exist");
1158        assert_eq!(profiles_var.1, "baleares,extremadura");
1159
1160        // Check baleares profile vars (no security)
1161        assert!(
1162            cfg.env_vars
1163                .iter()
1164                .any(|(k, v)| k == "CXF_PROFILE_BALEARES_WSDL_PATH" && v == "/a.wsdl")
1165        );
1166        assert!(
1167            cfg.env_vars
1168                .iter()
1169                .any(|(k, v)| k == "CXF_PROFILE_BALEARES_SERVICE_NAME" && v == "Svc")
1170        );
1171        assert!(
1172            cfg.env_vars
1173                .iter()
1174                .any(|(k, v)| k == "CXF_PROFILE_BALEARES_PORT_NAME" && v == "Port")
1175        );
1176        assert!(
1177            !cfg.env_vars
1178                .iter()
1179                .any(|(k, _)| k == "CXF_PROFILE_BALEARES_ADDRESS")
1180        );
1181
1182        // Check extremadura profile vars (with security)
1183        assert!(
1184            cfg.env_vars
1185                .iter()
1186                .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_WSDL_PATH" && v == "/b.wsdl")
1187        );
1188        assert!(
1189            cfg.env_vars
1190                .iter()
1191                .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_ADDRESS" && v == "http://host:9090/ws")
1192        );
1193        assert!(
1194            cfg.env_vars
1195                .iter()
1196                .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_KEYSTORE_PATH" && v == "/b.jks")
1197        );
1198        assert!(
1199            cfg.env_vars
1200                .iter()
1201                .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_KEYSTORE_PASSWORD" && v == "pass")
1202        );
1203        assert!(
1204            cfg.env_vars
1205                .iter()
1206                .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_SIG_USERNAME" && v == "cert")
1207        );
1208        assert!(
1209            cfg.env_vars
1210                .iter()
1211                .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_SIG_PASSWORD" && v == "sig_pass")
1212        );
1213        assert!(
1214            cfg.env_vars
1215                .iter()
1216                .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_SECURITY_ACTIONS_OUT"
1217                    && v == "Timestamp Signature")
1218        );
1219    }
1220
1221    #[test]
1222    fn cxf_profiles_single_profile_no_security() {
1223        let profiles = vec![CxfProfileEnvVars {
1224            name: "test".to_string(),
1225            wsdl_path: "service.wsdl".to_string(),
1226            service_name: "{http://example.com}Service".to_string(),
1227            port_name: "{http://example.com}Port".to_string(),
1228            address: None,
1229            keystore_path: None,
1230            keystore_password: None,
1231            truststore_path: None,
1232            truststore_password: None,
1233            sig_username: None,
1234            sig_password: None,
1235            enc_username: None,
1236            security_actions_out: None,
1237            security_actions_in: None,
1238            signature_algorithm: None,
1239            signature_digest_algorithm: None,
1240            signature_c14n_algorithm: None,
1241            signature_parts: None,
1242            fault_status: None,
1243            tls: None,
1244        }];
1245
1246        let cfg =
1247            BridgeProcessConfig::cxf_profiles(PathBuf::from("/tmp/cxf-bridge"), &profiles, 15_000);
1248
1249        assert_eq!(cfg.spec.name, "cxf-bridge");
1250        // CXF_PROFILES + 3 required vars (WSDL_PATH, SERVICE_NAME, PORT_NAME)
1251        assert_eq!(cfg.env_vars.len(), 4);
1252        assert_eq!(cfg.env_vars[0].0, "CXF_PROFILES");
1253        assert_eq!(cfg.env_vars[0].1, "test");
1254        assert_eq!(cfg.env_vars[1].0, "CXF_PROFILE_TEST_WSDL_PATH");
1255        assert_eq!(cfg.env_vars[1].1, "service.wsdl");
1256        assert_eq!(cfg.env_vars[2].0, "CXF_PROFILE_TEST_SERVICE_NAME");
1257        assert_eq!(cfg.env_vars[2].1, "{http://example.com}Service");
1258        assert_eq!(cfg.env_vars[3].0, "CXF_PROFILE_TEST_PORT_NAME");
1259        assert_eq!(cfg.env_vars[3].1, "{http://example.com}Port");
1260    }
1261
1262    #[test]
1263    fn profile_env_vars_to_env_vars_includes_all_fields() {
1264        let vars = CxfProfileEnvVars {
1265            name: "full".to_string(),
1266            wsdl_path: "/wsdl".to_string(),
1267            service_name: "Svc".to_string(),
1268            port_name: "Port".to_string(),
1269            address: Some("http://host:8080".to_string()),
1270            keystore_path: Some("/ks.jks".to_string()),
1271            keystore_password: Some(Redacted::new("ks_pass".to_string())),
1272            truststore_path: Some("/ts.jks".to_string()),
1273            truststore_password: Some(Redacted::new("ts_pass".to_string())),
1274            sig_username: Some("user".to_string()),
1275            sig_password: Some(Redacted::new("sig_pass".to_string())),
1276            enc_username: None,
1277            security_actions_out: Some("Timestamp Signature".to_string()),
1278            security_actions_in: Some("Timestamp".to_string()),
1279            signature_algorithm: None,
1280            signature_digest_algorithm: None,
1281            signature_c14n_algorithm: None,
1282            signature_parts: None,
1283            fault_status: Some(403),
1284            tls: None,
1285        };
1286
1287        let env = vars.to_env_vars();
1288        // 3 required + 1 address + 8 security + 1 fault_status = 13
1289        assert_eq!(env.len(), 13);
1290
1291        let keys: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
1292        assert!(keys.contains(&"CXF_PROFILE_FULL_WSDL_PATH"));
1293        assert!(keys.contains(&"CXF_PROFILE_FULL_SERVICE_NAME"));
1294        assert!(keys.contains(&"CXF_PROFILE_FULL_PORT_NAME"));
1295        assert!(keys.contains(&"CXF_PROFILE_FULL_ADDRESS"));
1296        assert!(keys.contains(&"CXF_PROFILE_FULL_KEYSTORE_PATH"));
1297        assert!(keys.contains(&"CXF_PROFILE_FULL_KEYSTORE_PASSWORD"));
1298        assert!(keys.contains(&"CXF_PROFILE_FULL_TRUSTSTORE_PATH"));
1299        assert!(keys.contains(&"CXF_PROFILE_FULL_TRUSTSTORE_PASSWORD"));
1300        assert!(keys.contains(&"CXF_PROFILE_FULL_SIG_USERNAME"));
1301        assert!(keys.contains(&"CXF_PROFILE_FULL_SIG_PASSWORD"));
1302        assert!(keys.contains(&"CXF_PROFILE_FULL_SECURITY_ACTIONS_OUT"));
1303        assert!(keys.contains(&"CXF_PROFILE_FULL_SECURITY_ACTIONS_IN"));
1304        let fault_status = env
1305            .iter()
1306            .find(|(k, _)| k == "CXF_PROFILE_FULL_FAULT_STATUS")
1307            .expect("fault_status env var emitted");
1308        assert_eq!(fault_status.1, "403");
1309    }
1310
1311    #[test]
1312    fn test_start_timeout_zero_rejected() {
1313        let config = BridgeProcessConfig::jms(
1314            PathBuf::from("/usr/bin/echo"),
1315            "tcp://localhost:61616".to_string(),
1316            BrokerType::ActiveMq,
1317            None,
1318            None,
1319            0,
1320            None,
1321            None,
1322        );
1323        let result = config.validate();
1324        assert!(result.is_err());
1325    }
1326
1327    #[test]
1328    fn test_bridge_rejects_zero_start_timeout() {
1329        let config = BridgeProcessConfig::jms(
1330            PathBuf::from("/usr/bin/echo"),
1331            "tcp://localhost:61616".to_string(),
1332            BrokerType::ActiveMq,
1333            None,
1334            None,
1335            0,
1336            None,
1337            None,
1338        );
1339        assert!(config.validate().is_err());
1340    }
1341
1342    #[tokio::test]
1343    async fn test_bridge_stop_completes() {
1344        use tokio::process::Command;
1345        use tokio::time::{Duration, timeout};
1346
1347        let child = Command::new("sh")
1348            .arg("-c")
1349            .arg("trap '' TERM; while true; do echo tick; sleep 1; done")
1350            .stdout(std::process::Stdio::null())
1351            .spawn()
1352            .expect("must spawn test child process");
1353
1354        let bridge = BridgeProcess {
1355            child,
1356            grpc_port: 0,
1357            soap_port: None,
1358            tls: crate::tls::BridgeTlsMaterial::generate().expect("test tls"),
1359            token: CancellationToken::new(),
1360            handle: None,
1361        };
1362
1363        let result = timeout(Duration::from_secs(10), bridge.stop()).await;
1364        assert!(result.is_ok(), "stop() must complete within 10s");
1365    }
1366
1367    // --- BRG-004: Redacted<T> tests ---
1368
1369    #[test]
1370    fn redacted_debug_displays_redacted() {
1371        let r = Redacted::new("secret_password".to_string());
1372        assert_eq!(format!("{r:?}"), "[REDACTED]");
1373    }
1374
1375    #[test]
1376    fn redacted_display_displays_redacted() {
1377        let r = Redacted::new("secret_password".to_string());
1378        assert_eq!(format!("{r}"), "[REDACTED]");
1379    }
1380
1381    #[test]
1382    fn redacted_deref_gives_inner_value() {
1383        let r = Redacted::new("secret".to_string());
1384        assert_eq!(&*r, "secret");
1385    }
1386
1387    #[test]
1388    fn redacted_into_inner_returns_value() {
1389        let r = Redacted::new("secret".to_string());
1390        assert_eq!(r.into_inner(), "secret");
1391    }
1392
1393    #[test]
1394    fn redacted_clone_works() {
1395        let r = Redacted::new("secret".to_string());
1396        let c = r.clone();
1397        assert_eq!(&*c, "secret");
1398        assert_eq!(format!("{c:?}"), "[REDACTED]");
1399    }
1400
1401    #[test]
1402    fn bridge_process_config_debug_redacts_password() {
1403        let cfg = BridgeProcessConfig::jms(
1404            PathBuf::from("/tmp/jms-bridge"),
1405            "tcp://localhost:61616".to_string(),
1406            BrokerType::ActiveMq,
1407            Some("user".to_string()),
1408            Some(Redacted::new("super_secret".to_string())),
1409            1000,
1410            None,
1411            None,
1412        );
1413        // The Redacted<T> password field must show [REDACTED] in debug output.
1414        // env_vars is a separate Vec<(String, String)> used for process injection
1415        // and legitimately contains the raw value — that is not a Redacted leak.
1416        let password_debug = format!("{:?}", cfg.password); // allow-secret
1417        assert!(
1418            !password_debug.contains("super_secret"),
1419            "Password field must not leak in Debug: {password_debug}"
1420        );
1421        assert_eq!(
1422            password_debug, "Some([REDACTED])",
1423            "Password field must show [REDACTED]: {password_debug}"
1424        );
1425    }
1426
1427    #[test]
1428    fn bridge_process_config_debug_redacts_broker_url_and_env_vars() {
1429        let cfg = BridgeProcessConfig {
1430            spec: &JMS_BRIDGE,
1431            binary_path: PathBuf::from("/tmp/jms-bridge"),
1432            broker_url: "amqp://u:SENTINEL-BRIDGE-PASS@host:5672".to_string(),
1433            broker_type: BrokerType::ActiveMq,
1434            username: Some("user".to_string()),
1435            password: Some(Redacted::new("pass".to_string())),
1436            start_timeout_ms: 1000,
1437            env_vars: vec![(
1438                "KEYSTORE_PASSWORD".to_string(),
1439                "SENTINEL-ENV-PASS".to_string(),
1440            )],
1441            expect_soap_ready: false,
1442        };
1443        let debug_output = format!("{cfg:?}");
1444        assert!(
1445            !debug_output.contains("SENTINEL-BRIDGE-PASS"),
1446            "broker_url must be redacted in Debug: {debug_output}"
1447        );
1448        assert!(
1449            !debug_output.contains("SENTINEL-ENV-PASS"),
1450            "env_vars values must be redacted in Debug: {debug_output}"
1451        );
1452        assert!(
1453            debug_output.contains("[REDACTED; 1 entries]"),
1454            "env_vars must show redacted count: {debug_output}"
1455        );
1456    }
1457
1458    #[test]
1459    fn bridge_process_config_debug_redacts_all_sensitive_fields() {
1460        let cfg = BridgeProcessConfig {
1461            spec: &JMS_BRIDGE,
1462            binary_path: PathBuf::from("/tmp/jms-bridge"),
1463            broker_url: "amqp://SENTINEL-BROKER-URL@host:5672".to_string(),
1464            broker_type: BrokerType::ActiveMq,
1465            username: Some("SENTINEL-USERNAME".to_string()),
1466            password: Some(Redacted::new("SENTINEL-PASSWORD".to_string())),
1467            start_timeout_ms: 1000,
1468            env_vars: vec![(
1469                "KEYSTORE_PASSWORD".to_string(),
1470                "SENTINEL-ENV-PASS".to_string(),
1471            )],
1472            expect_soap_ready: false,
1473        };
1474        let debug_output = format!("{cfg:?}");
1475        assert!(
1476            !debug_output.contains("SENTINEL-BROKER-URL")
1477                && !debug_output.contains("SENTINEL-USERNAME")
1478                && !debug_output.contains("SENTINEL-PASSWORD")
1479                && !debug_output.contains("SENTINEL-ENV-PASS"),
1480            "No sensitive field may leak in Debug: {debug_output}"
1481        );
1482        assert!(
1483            debug_output.contains("username: Some(\"[REDACTED]\")"),
1484            "username must be masked in Debug: {debug_output}"
1485        );
1486    }
1487
1488    #[test]
1489    fn cxf_profile_debug_redacts_passwords() {
1490        let profile = CxfProfileEnvVars {
1491            name: "test".to_string(),
1492            wsdl_path: "/a.wsdl".to_string(),
1493            service_name: "Svc".to_string(),
1494            port_name: "Port".to_string(),
1495            address: None,
1496            keystore_path: None,
1497            keystore_password: Some(Redacted::new("ks_secret_val".to_string())),
1498            truststore_path: None,
1499            truststore_password: Some(Redacted::new("ts_secret_val".to_string())),
1500            sig_username: None,
1501            sig_password: Some(Redacted::new("sig_secret_val".to_string())),
1502            enc_username: None,
1503            security_actions_out: None,
1504            security_actions_in: None,
1505            signature_algorithm: None,
1506            signature_digest_algorithm: None,
1507            signature_c14n_algorithm: None,
1508            signature_parts: None,
1509            fault_status: None,
1510            tls: None,
1511        };
1512        let debug_output = format!("{profile:?}");
1513        assert!(
1514            !debug_output.contains("ks_secret_val")
1515                && !debug_output.contains("ts_secret_val")
1516                && !debug_output.contains("sig_secret_val"),
1517            "Debug must not contain passwords: {debug_output}"
1518        );
1519    }
1520
1521    // --- Task 2.8: dual ready-line startup protocol tests (fake bridge) ---
1522
1523    /// Fake bridge binary: an executable `sh` script standing in for the native
1524    /// bridge on stdout. The tempdir is returned so the script file outlives
1525    /// every child the test spawns from it.
1526    fn fake_bridge_script(body: &str) -> (tempfile::TempDir, PathBuf) {
1527        let dir = tempfile::tempdir().expect("fake bridge tempdir");
1528        let path = dir.path().join("fake-bridge.sh");
1529        std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).expect("write fake bridge script");
1530        #[cfg(unix)]
1531        {
1532            use std::os::unix::fs::PermissionsExt;
1533            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
1534                .expect("chmod fake bridge script");
1535        }
1536        (dir, path)
1537    }
1538
1539    fn fake_bridge_config(
1540        script: &std::path::Path,
1541        expect_soap_ready: bool,
1542        start_timeout_ms: u64,
1543    ) -> BridgeProcessConfig {
1544        let mut config =
1545            BridgeProcessConfig::cxf_profiles(script.to_path_buf(), &[], start_timeout_ms);
1546        config.expect_soap_ready = expect_soap_ready;
1547        config
1548    }
1549
1550    /// Task 2.8: `ready` and `soap-ready` are accepted in ANY order under the
1551    /// single enclosing startup budget, and BOTH announced ports are stored.
1552    #[tokio::test]
1553    async fn process_dual_ready_reordered_is_accepted() {
1554        use std::time::{Duration, Instant};
1555
1556        for (label, body, expect_soap_ready, want_soap) in [
1557            (
1558                "soap-ready before ready (expect=true)",
1559                r#"echo '{"status":"soap-ready","port":23456}'; echo '{"status":"ready","port":12345}'"#,
1560                true,
1561                Some(23456u16),
1562            ),
1563            (
1564                "ready before soap-ready (expect=true)",
1565                r#"echo '{"status":"ready","port":12345}'; echo '{"status":"soap-ready","port":23456}'"#,
1566                true,
1567                Some(23456u16),
1568            ),
1569            (
1570                "soap-ready before ready (expect=false still stores the valid soap port)",
1571                r#"echo '{"status":"soap-ready","port":23456}'; echo '{"status":"ready","port":12345}'"#,
1572                false,
1573                Some(23456u16),
1574            ),
1575        ] {
1576            let (_dir, script) = fake_bridge_script(body);
1577            let config = fake_bridge_config(&script, expect_soap_ready, 10_000);
1578            let started = Instant::now();
1579            let process = BridgeProcess::start(&config).await.unwrap_or_else(|e| {
1580                panic!("{label}: dual ready lines in any order must start: {e}")
1581            });
1582            let elapsed = started.elapsed();
1583            assert!(
1584                elapsed < Duration::from_millis(10_000),
1585                "{label}: start must complete inside one config budget, took {elapsed:?}"
1586            );
1587            assert_eq!(process.grpc_port(), 12345, "{label}: gRPC port stored");
1588            assert_eq!(process.soap_port(), want_soap, "{label}: soap port stored");
1589            process
1590                .stop()
1591                .await
1592                .unwrap_or_else(|e| panic!("{label}: fake bridge must stop cleanly: {e}"));
1593        }
1594    }
1595
1596    /// Task 2.8: a partial (soap-only) announcement followed by the ready line
1597    /// completes within ONE `start_timeout_ms` budget — and the counterexample
1598    /// row pins that the budget ENCLOSES the whole wait (it is never reset per
1599    /// line). Counterexample layout: two independent gaps BOTH strictly inside
1600    /// the budget (2.5s < 4s) whose SUM comfortably exceeds it (5s > 4s), so a
1601    /// per-line-reset implementation would return Ok at ~5s while the correct
1602    /// one times out at ~4s. A marker file proves the partial soap-ready line
1603    /// was really emitted before the deadline — the timeout cannot be excused
1604    /// as "the partial never arrived".
1605    #[tokio::test]
1606    async fn partial_ready_is_bounded_and_completes_in_one_timeout() {
1607        use std::time::{Duration, Instant};
1608
1609        for (label, body_template, budget_ms, expect_ok, max_elapsed_ms) in [
1610            (
1611                "partial soap-ready then ready completes within ONE budget",
1612                r#"echo '{"status":"soap-ready","port":23456}'; sleep 1; echo '{"status":"ready","port":12345}'"#,
1613                6_000u64,
1614                true,
1615                6_000u64,
1616            ),
1617            (
1618                "counterexample: TOTAL budget exceeded fires StartTimeout — no per-line reset",
1619                r#"sleep 2.5; echo '{"status":"soap-ready","port":23456}'; touch '{marker}'; sleep 2.5; echo '{"status":"ready","port":12345}'"#,
1620                4_000u64,
1621                false,
1622                5_000u64,
1623            ),
1624        ] {
1625            // The counterexample proves the PARTIAL line really was emitted
1626            // before the deadline: the fake bridge drops a marker right after
1627            // the soap-ready print (test fixture state — no prod test hooks).
1628            let marker_dir = tempfile::tempdir().expect("marker tempdir");
1629            let marker = marker_dir.path().join("soap-ready-emitted");
1630            let body = body_template.replace("{marker}", &marker.display().to_string());
1631            let (_dir, script) = fake_bridge_script(&body);
1632            let config = fake_bridge_config(&script, true, budget_ms);
1633            let started = Instant::now();
1634            let result = BridgeProcess::start(&config).await;
1635            let elapsed = started.elapsed();
1636            assert!(
1637                elapsed < Duration::from_millis(max_elapsed_ms),
1638                "{label}: must resolve in {max_elapsed_ms}ms (one enclosing budget), took {elapsed:?}"
1639            );
1640            if expect_ok {
1641                let process =
1642                    result.unwrap_or_else(|e| panic!("{label}: start must complete: {e}"));
1643                assert_eq!(process.grpc_port(), 12345, "{label}: gRPC port");
1644                assert_eq!(process.soap_port(), Some(23456), "{label}: soap port");
1645                process
1646                    .stop()
1647                    .await
1648                    .unwrap_or_else(|e| panic!("{label}: fake bridge must stop cleanly: {e}"));
1649            } else {
1650                match result {
1651                    Err(BridgeError::Timeout(_)) => {}
1652                    Err(e) => panic!("{label}: expected StartTimeout, got {e}"),
1653                    Ok(_) => panic!(
1654                        "{label}: start must hit the single enclosing budget instead of \
1655                         waiting out per-line resets"
1656                    ),
1657                }
1658                assert!(
1659                    marker.exists(),
1660                    "{label}: the partial soap-ready line must have been emitted before \
1661                     the deadline — the timeout comes from the ENCLOSING budget, not \
1662                     from a missing partial line"
1663                );
1664            }
1665        }
1666    }
1667
1668    /// Task 2.8: a recognized `soap-ready` line with a non-numeric, missing, or
1669    /// out-of-range port fails startup with `BadReadyMessage` — in BOTH line
1670    /// orders, with `expect_soap_ready` enabled. Port validation is 1..=65535
1671    /// (untrusted stdout policy: never cast-wrap into a wrong `u16`).
1672    #[tokio::test]
1673    async fn malformed_soap_ready_fails_startup() {
1674        let malformed = [
1675            (
1676                "non-numeric port",
1677                r#"{"status":"soap-ready","port":"abc"}"#,
1678            ),
1679            ("missing port", r#"{"status":"soap-ready"}"#),
1680            (
1681                "port above u16 range",
1682                r#"{"status":"soap-ready","port":70000}"#,
1683            ),
1684            ("port zero", r#"{"status":"soap-ready","port":0}"#),
1685        ];
1686        for (kind, soap_line) in malformed {
1687            for (order, body) in [
1688                (
1689                    "soap-ready first",
1690                    format!("echo '{soap_line}'; echo '{{\"status\":\"ready\",\"port\":12345}}'"),
1691                ),
1692                (
1693                    "ready first",
1694                    format!("echo '{{\"status\":\"ready\",\"port\":12345}}'; echo '{soap_line}'"),
1695                ),
1696            ] {
1697                let label = format!("{kind}, {order}");
1698                let (_dir, script) = fake_bridge_script(&body);
1699                let config = fake_bridge_config(&script, true, 5_000);
1700                match BridgeProcess::start(&config).await {
1701                    Err(BridgeError::BadReadyMessage(_)) => {}
1702                    Err(e) => panic!("{label}: expected BadReadyMessage, got {e}"),
1703                    Ok(_) => panic!("{label}: malformed soap-ready must fail startup"),
1704                }
1705            }
1706        }
1707    }
1708
1709    /// Task 2.8: with `expect_soap_ready = false` the supervisor awaits ONLY the
1710    /// gRPC `ready` line and returns without any second wait (no soap-ready ever
1711    /// arrives — the fake bridge holds its stdout open afterwards to prove it),
1712    /// and the soap port stays `None` (no stale value).
1713    #[tokio::test]
1714    async fn no_soap_wait_when_unbound() {
1715        use std::time::{Duration, Instant};
1716
1717        // `exec` replaces the shell so the blocker IS the child: no orphaned
1718        // grandchildren after stop().
1719        let (_dir, script) =
1720            fake_bridge_script(r#"echo '{"status":"ready","port":12345}'; exec tail -f /dev/null"#);
1721        let config = fake_bridge_config(&script, false, 5_000);
1722        let started = Instant::now();
1723        let process = BridgeProcess::start(&config)
1724            .await
1725            .expect("start must return on the gRPC ready line alone");
1726        let elapsed = started.elapsed();
1727        assert!(
1728            elapsed < Duration::from_millis(2_500),
1729            "expect_soap_ready=false must not wait for a soap-ready line, took {elapsed:?}"
1730        );
1731        assert_eq!(process.grpc_port(), 12345);
1732        assert_eq!(
1733            process.soap_port(),
1734            None,
1735            "no soap-ready line → soap_port stays None"
1736        );
1737        process.stop().await.expect("fake bridge must stop cleanly");
1738    }
1739
1740    // --- R4-L7: Bounded stdout drain tests ---
1741
1742    /// Helper: spawn a child that writes to stdout, return (child, stdout BufReader).
1743    ///
1744    /// stdin/stderr are nulled so no descendant inherits the test binary's
1745    /// harness pipes. Under cargo-nextest, a leaked grandchild (e.g. an
1746    /// orphaned `sleep` outliving a SIGKILLed `sh`) that still holds the
1747    /// inherited stdin/stderr keeps nextest's pipes open past leak-timeout
1748    /// and the test is flagged LEAK even though it passed.
1749    async fn spawn_child_with_stdout(script: &str) -> tokio::process::Child {
1750        use tokio::process::Command;
1751
1752        Command::new("sh")
1753            .arg("-c")
1754            .arg(script)
1755            .stdin(std::process::Stdio::null())
1756            .stdout(std::process::Stdio::piped())
1757            .stderr(std::process::Stdio::null())
1758            .spawn()
1759            .expect("must spawn test child")
1760    }
1761
1762    #[tokio::test]
1763    async fn drain_chatty_child_no_deadlock() {
1764        // Child writes many short lines quickly, then exits
1765        let mut child =
1766            spawn_child_with_stdout("for i in $(seq 1 500); do echo \"line $i\"; done").await;
1767
1768        let stdout = child.stdout.take().expect("stdout piped");
1769        let reader = tokio::io::BufReader::new(stdout);
1770        let token = CancellationToken::new();
1771
1772        // Call the production drain function directly (I-1: no duplicated logic)
1773        let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1774
1775        // Wait for child to exit
1776        let status = child.wait().await.expect("wait for child");
1777        assert!(status.success(), "child should exit successfully");
1778
1779        // Drain task should complete promptly after child exits (EOF)
1780        let result = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
1781        assert!(result.is_ok(), "drain task should complete within 2s");
1782    }
1783
1784    #[tokio::test]
1785    async fn drain_oversized_line_bounded() {
1786        // Child writes a line >64 KiB without newline, then newline.
1787        // Use printf + head to generate 200 KiB of 'A' without python dependency.
1788        // Bound verification: the drain loop caps line_buf at MAX_LINE_BYTES (64 KiB)
1789        // via the `oversized` flag — bytes beyond the cap are discarded, not accumulated.
1790        // Enforced by code inspection (src/process.rs drain_stdout: `line_buf.len() < MAX_LINE_BYTES`
1791        // guard + `oversized = true` branch that skips push). This test verifies the function
1792        // completes within a tight time bound (500ms), which would fail if the loop stalled
1793        // or allocated unboundedly.
1794        let oversized_bytes = 200 * 1024; // 200 KiB
1795        let mut child = spawn_child_with_stdout(&format!(
1796            "head -c {oversized_bytes} /dev/zero | tr '\\0' 'A'; echo"
1797        ))
1798        .await;
1799
1800        let stdout = child.stdout.take().expect("stdout piped");
1801        let reader = tokio::io::BufReader::new(stdout);
1802        let token = CancellationToken::new();
1803
1804        let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1805
1806        let status = child.wait().await.expect("wait for child");
1807        assert!(status.success(), "child should exit successfully");
1808
1809        // Tighter bound: 500ms (was 2s). If the cap were broken, the loop would
1810        // still complete but would have allocated >64 KiB per line — caught by
1811        // code review + the oversized warning log assertion in integration tests.
1812        let result = tokio::time::timeout(std::time::Duration::from_millis(500), handle).await;
1813        assert!(result.is_ok(), "drain task should complete within 500ms");
1814    }
1815
1816    #[tokio::test]
1817    async fn drain_cancellation_exits_promptly() {
1818        // Child writes slowly (sleep between lines)
1819        let mut child = spawn_child_with_stdout("while true; do echo tick; sleep 1; done").await;
1820
1821        let stdout = child.stdout.take().expect("stdout piped");
1822        let reader = tokio::io::BufReader::new(stdout);
1823        let token = CancellationToken::new();
1824
1825        // Call the production drain function directly (I-1: no duplicated logic)
1826        let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1827
1828        // drain_stdout polls `token.cancelled()` first in a biased select,
1829        // so cancelling before the task's first poll still exits promptly.
1830        token.cancel();
1831        let result = tokio::time::timeout(std::time::Duration::from_millis(500), handle).await;
1832        assert!(
1833            result.is_ok(),
1834            "drain task should exit promptly after cancellation"
1835        );
1836
1837        // Clean up child
1838        let _ = child.kill().await;
1839    }
1840
1841    /// I-2: Verify the single-BufReader-for-both-phases invariant.
1842    /// The ready-detection phase and drain phase must share the SAME BufReader;
1843    /// otherwise trailing bytes after the ready message would be lost (a second
1844    /// reader would start at offset 0, missing data already consumed by phase 1).
1845    /// This test exercises the handoff by mimicking the ready-detection loop,
1846    /// then calling drain_stdout with the same reader to verify trailing lines
1847    /// are processed (not lost).
1848    #[tokio::test]
1849    async fn drain_handoff_preserves_trailing_bytes() {
1850        // Child emits ready JSON + trailing log lines
1851        let mut child = spawn_child_with_stdout(
1852            r#"echo '{"status":"ready","port":12345}'; echo "trailing-1"; echo "trailing-2"; echo "trailing-3""#,
1853        )
1854        .await;
1855
1856        let stdout = child.stdout.take().expect("stdout piped");
1857        let mut reader = tokio::io::BufReader::new(stdout);
1858
1859        // --- Phase 1: production readiness parser, not a test copy ---
1860        assert_eq!(
1861            read_ready_port(&mut reader).await.expect("ready port"),
1862            12345
1863        );
1864
1865        // --- Phase 2: drain with the SAME reader (I-2: handoff invariant) ---
1866        let token = CancellationToken::new();
1867        let handle = tokio::spawn(drain_stdout(reader, token.clone()));
1868
1869        // Wait for child to exit; the echo script terminates promptly, the
1870        // bound only guards a hung child from hanging the test forever
1871        let status = tokio::time::timeout(std::time::Duration::from_secs(5), child.wait())
1872            .await
1873            .expect("child exits within 5s")
1874            .expect("wait for child");
1875        assert!(status.success(), "child should exit successfully");
1876
1877        // Drain task should complete (trailing lines processed, not lost)
1878        let result = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
1879        assert!(
1880            result.is_ok(),
1881            "drain task should complete — trailing bytes preserved through handoff"
1882        );
1883    }
1884}