Skip to main content

camel_bridge/
process.rs

1//! Bridge child process ownership: spawn, readiness, teardown, and bounded output drains.
2//!
3//! On Linux and macOS a supervisor-first POSIX-shell watchdog over a parent-liveness
4//! pipe (`GroupGuard`, crate-internal) guarantees the bridge process group dies with the
5//! parent binary on every exit route — normal exit, panic, `SIGTERM`, `SIGINT`, and
6//! `SIGKILL` — using POSIX shell builtins, `setpgid`, `trap`, and `waitid`, with no
7//! `/proc` dependency. Windows has no process groups or POSIX shell, so
8//! [`BridgeProcess::stop`] and `Drop` kill the child directly; an unclean parent death
9//! can leak it. See [`BridgeProcess`].
10
11use std::fmt;
12use std::ops::Deref;
13use std::path::PathBuf;
14use std::sync::OnceLock;
15use thiserror::Error;
16use tokio::task::JoinHandle;
17use tokio_util::sync::CancellationToken;
18
19#[cfg(unix)]
20use crate::GroupGuard;
21use crate::spec::{BridgeSpec, CXF_BRIDGE, JMS_BRIDGE, XML_BRIDGE};
22
23// ---------------------------------------------------------------------------
24// Redacted<T> — wrapper that never leaks inner value via Debug/Display
25// ---------------------------------------------------------------------------
26
27/// A newtype that redacts its inner value in `Debug` and `Display` output.
28/// Used for password/credential fields to prevent accidental logging.
29#[derive(Clone)]
30pub struct Redacted<T>(T);
31
32impl<T> Redacted<T> {
33    pub fn new(value: T) -> Self {
34        Self(value)
35    }
36
37    pub fn into_inner(self) -> T {
38        self.0
39    }
40}
41
42impl<T> fmt::Debug for Redacted<T> {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        write!(f, "[REDACTED]")
45    }
46}
47
48impl<T> fmt::Display for Redacted<T> {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        write!(f, "[REDACTED]")
51    }
52}
53
54impl<T> Deref for Redacted<T> {
55    type Target = T;
56
57    fn deref(&self) -> &Self::Target {
58        &self.0
59    }
60}
61
62#[derive(Debug, Error)]
63#[non_exhaustive]
64pub enum BridgeError {
65    #[error("IO error: {0}")]
66    Io(#[from] std::io::Error),
67    #[error("Bridge timed out: {0}")]
68    Timeout(String),
69    #[error("Bridge stdout closed before ready message")]
70    StdoutClosed,
71    #[error("Bridge ready message malformed: {0}")]
72    BadReadyMessage(String),
73    #[error("Download failed: {0}")]
74    Download(String),
75    #[error("Checksum mismatch: expected {expected}, got {actual}")]
76    ChecksumMismatch { expected: String, actual: String },
77    #[error("URL not allowed: {0}")]
78    UrlNotAllowed(String),
79    #[error("Transport error: {0}")]
80    Transport(String),
81    #[error("Config error: {0}")]
82    Config(String),
83    #[error("Bridge supervision error: {0}")]
84    Supervision(String),
85    /// A startup permanent failure announced by the bridge (`{"status":
86    /// "permanent-failure","reason":"..."}`) or inferred from the reserved exit
87    /// code 78. Never retried (phase 4 parks the slot).
88    #[error("Bridge permanent failure: {0}")]
89    PermanentFailure(String),
90    #[error(
91        "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"
92    )]
93    CacheUnwritable {
94        path: std::path::PathBuf,
95        source: std::io::Error,
96    },
97}
98
99/// Broker TLS env var names (Task 2.1). The values are identical to the names.
100pub const BRIDGE_BROKER_TLS_CA_CERT_PATH: &str = "BRIDGE_BROKER_TLS_CA_CERT_PATH";
101pub const BRIDGE_BROKER_TLS_CLIENT_CERT_PATH: &str = "BRIDGE_BROKER_TLS_CLIENT_CERT_PATH";
102pub const BRIDGE_BROKER_TLS_CLIENT_KEY_PATH: &str = "BRIDGE_BROKER_TLS_CLIENT_KEY_PATH";
103pub const BRIDGE_BROKER_TLS_SERVER_NAME: &str = "BRIDGE_BROKER_TLS_SERVER_NAME";
104
105/// Every broker TLS key a JMS bridge `Command` must `env_remove` before
106/// injecting configured values, so inherited operator exports can never reach
107/// the child (Task 2.1).
108pub const BRIDGE_BROKER_TLS_ENV_KEYS: [&str; 4] = [
109    BRIDGE_BROKER_TLS_CA_CERT_PATH,
110    BRIDGE_BROKER_TLS_CLIENT_CERT_PATH,
111    BRIDGE_BROKER_TLS_CLIENT_KEY_PATH,
112    BRIDGE_BROKER_TLS_SERVER_NAME,
113];
114
115/// Broker TLS paths/SNI emitted as `BRIDGE_BROKER_TLS_*` (plain data: this
116/// crate does not depend on `camel-component-jms` config types).
117#[derive(Debug, Clone, Default, PartialEq, Eq)]
118pub struct JmsTlsEnv {
119    pub ca_cert_path: Option<String>,
120    pub client_cert_path: Option<String>,
121    pub client_key_path: Option<String>,
122    pub server_name: Option<String>,
123}
124
125#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
126#[serde(rename_all = "lowercase")]
127pub enum BrokerType {
128    #[serde(alias = "active_mq")]
129    ActiveMq,
130    Artemis,
131    Generic,
132}
133
134impl BrokerType {
135    pub fn as_env_str(&self) -> &'static str {
136        match self {
137            BrokerType::ActiveMq => "activemq",
138            BrokerType::Artemis => "artemis",
139            BrokerType::Generic => "generic",
140        }
141    }
142}
143
144impl std::str::FromStr for BrokerType {
145    type Err = BridgeError;
146
147    fn from_str(s: &str) -> Result<Self, Self::Err> {
148        match s.to_lowercase().as_str() {
149            "activemq" => Ok(BrokerType::ActiveMq),
150            "artemis" => Ok(BrokerType::Artemis),
151            "generic" => Ok(BrokerType::Generic),
152            other => Err(BridgeError::Config(format!("unknown broker type: {other}"))), // allow-secret
153        }
154    }
155}
156
157/// Environment variables for a profile's producer transport TLS material,
158/// emitted as `CXF_PROFILE_<NAME>_TLS_*`. Password fields use [`Redacted`] to
159/// prevent accidental credential leakage in logs (ADR-0051).
160#[derive(Debug, Default)]
161pub struct CxfTlsEnvVars {
162    pub keystore_path: Option<String>,
163    pub keystore_password: Option<Redacted<String>>,
164    pub keystore_type: Option<String>,
165    pub truststore_path: Option<String>,
166    pub truststore_password: Option<Redacted<String>>,
167    pub truststore_type: Option<String>,
168}
169
170impl CxfTlsEnvVars {
171    /// Emit the six `TLS_*` env vars under the given `CXF_PROFILE_<NAME>_`
172    /// prefix.
173    pub fn to_env_vars(&self, prefix: &str) -> Vec<(String, String)> {
174        let mut vars = Vec::new();
175        if let Some(ref v) = self.keystore_path {
176            vars.push((format!("{prefix}TLS_KEYSTORE_PATH"), v.clone()));
177        }
178        if let Some(ref v) = self.keystore_password {
179            vars.push((format!("{prefix}TLS_KEYSTORE_PASSWORD"), (**v).clone()));
180        }
181        if let Some(ref v) = self.keystore_type {
182            vars.push((format!("{prefix}TLS_KEYSTORE_TYPE"), v.clone()));
183        }
184        if let Some(ref v) = self.truststore_path {
185            vars.push((format!("{prefix}TLS_TRUSTSTORE_PATH"), v.clone()));
186        }
187        if let Some(ref v) = self.truststore_password {
188            vars.push((format!("{prefix}TLS_TRUSTSTORE_PASSWORD"), (**v).clone()));
189        }
190        if let Some(ref v) = self.truststore_type {
191            vars.push((format!("{prefix}TLS_TRUSTSTORE_TYPE"), v.clone()));
192        }
193        vars
194    }
195}
196
197/// Environment variables for a single CXF profile, used by the bridge Java side.
198/// Password fields use [`Redacted`] to prevent accidental credential leakage in logs.
199#[derive(Debug)]
200pub struct CxfProfileEnvVars {
201    pub name: String,
202    pub wsdl_path: String,
203    pub service_name: String,
204    pub port_name: String,
205    pub address: Option<String>,
206    pub keystore_path: Option<String>,
207    pub keystore_password: Option<Redacted<String>>,
208    pub truststore_path: Option<String>,
209    pub truststore_password: Option<Redacted<String>>,
210    pub sig_username: Option<String>,
211    pub sig_password: Option<Redacted<String>>,
212    pub enc_username: Option<String>,
213    pub security_actions_out: Option<String>,
214    pub security_actions_in: Option<String>,
215    pub signature_algorithm: Option<String>,
216    pub signature_digest_algorithm: Option<String>,
217    pub signature_c14n_algorithm: Option<String>,
218    pub signature_parts: Option<String>,
219    /// Optional comma-separated inbound signature algorithm allow-list,
220    /// emitted as `CXF_PROFILE_<NAME>_ALLOWED_SIGNATURE_ALGORITHMS`.
221    pub allowed_signature_algorithms: Option<String>,
222    /// Optional comma-separated inbound digest algorithm allow-list, emitted
223    /// as `CXF_PROFILE_<NAME>_ALLOWED_DIGEST_ALGORITHMS`.
224    pub allowed_digest_algorithms: Option<String>,
225    /// Per-profile expired pinned-certificate opt-in, emitted as
226    /// `CXF_PROFILE_<NAME>_ALLOW_EXPIRED_PINNED_CERTIFICATE` only when true.
227    pub allow_expired_pinned_certificate: bool,
228    /// Optional per-profile Timestamp TTL in seconds, emitted as
229    /// `CXF_PROFILE_<NAME>_TIMESTAMP_TTL_SECONDS` only when set.
230    pub timestamp_ttl_seconds: Option<u64>,
231    /// Optional per-profile Timestamp future tolerance in seconds, emitted as
232    /// `CXF_PROFILE_<NAME>_TIMESTAMP_FUTURE_TOLERANCE_SECONDS` only when set.
233    pub timestamp_future_tolerance_seconds: Option<u64>,
234    /// Optional per-profile HTTP status override for security (WSS) faults only,
235    /// emitted as `CXF_PROFILE_<NAME>_FAULT_STATUS`.
236    pub fault_status: Option<u16>,
237    /// Per-profile SOAP header allow-list, emitted as
238    /// `CXF_PROFILE_<NAME>_EXPOSED_SOAP_HEADERS` (comma-joined) only when
239    /// non-empty.
240    pub exposed_soap_headers: Vec<String>,
241    /// Producer transport TLS material (`[profiles.security.tls]`).
242    pub tls: Option<CxfTlsEnvVars>,
243}
244
245impl CxfProfileEnvVars {
246    pub fn to_env_vars(&self) -> Vec<(String, String)> {
247        let prefix = format!("CXF_PROFILE_{}_", self.name.to_uppercase());
248        let mut vars = vec![
249            (format!("{}WSDL_PATH", prefix), self.wsdl_path.clone()),
250            (format!("{}SERVICE_NAME", prefix), self.service_name.clone()),
251            (format!("{}PORT_NAME", prefix), self.port_name.clone()),
252        ];
253
254        if let Some(ref v) = self.address {
255            vars.push((format!("{}ADDRESS", prefix), v.clone()));
256        }
257        if let Some(ref v) = self.keystore_path {
258            vars.push((format!("{}KEYSTORE_PATH", prefix), v.clone()));
259        }
260        if let Some(ref v) = self.keystore_password {
261            vars.push((format!("{}KEYSTORE_PASSWORD", prefix), (**v).clone()));
262        }
263        if let Some(ref v) = self.truststore_path {
264            vars.push((format!("{}TRUSTSTORE_PATH", prefix), v.clone()));
265        }
266        if let Some(ref v) = self.truststore_password {
267            vars.push((format!("{}TRUSTSTORE_PASSWORD", prefix), (**v).clone()));
268        }
269        if let Some(ref v) = self.sig_username {
270            vars.push((format!("{}SIG_USERNAME", prefix), v.clone()));
271        }
272        if let Some(ref v) = self.sig_password {
273            vars.push((format!("{}SIG_PASSWORD", prefix), (**v).clone()));
274        }
275        if let Some(ref v) = self.enc_username {
276            vars.push((format!("{}ENC_USERNAME", prefix), v.clone()));
277        }
278        if let Some(ref v) = self.security_actions_out {
279            vars.push((format!("{}SECURITY_ACTIONS_OUT", prefix), v.clone()));
280        }
281        if let Some(ref v) = self.security_actions_in {
282            vars.push((format!("{}SECURITY_ACTIONS_IN", prefix), v.clone()));
283        }
284        if let Some(ref v) = self.signature_algorithm {
285            vars.push((format!("{}SIGNATURE_ALGORITHM", prefix), v.clone()));
286        }
287        if let Some(ref v) = self.signature_digest_algorithm {
288            vars.push((format!("{}SIGNATURE_DIGEST_ALGORITHM", prefix), v.clone()));
289        }
290        if let Some(ref v) = self.signature_c14n_algorithm {
291            vars.push((format!("{}SIGNATURE_C14N_ALGORITHM", prefix), v.clone()));
292        }
293        if let Some(ref v) = self.signature_parts {
294            vars.push((format!("{}SIGNATURE_PARTS", prefix), v.clone()));
295        }
296        if let Some(ref v) = self.allowed_signature_algorithms {
297            vars.push((format!("{}ALLOWED_SIGNATURE_ALGORITHMS", prefix), v.clone()));
298        }
299        if let Some(ref v) = self.allowed_digest_algorithms {
300            vars.push((format!("{}ALLOWED_DIGEST_ALGORITHMS", prefix), v.clone()));
301        }
302        if self.allow_expired_pinned_certificate {
303            vars.push((
304                format!("{}ALLOW_EXPIRED_PINNED_CERTIFICATE", prefix),
305                "true".to_string(),
306            ));
307        }
308        if let Some(v) = self.timestamp_ttl_seconds {
309            vars.push((format!("{}TIMESTAMP_TTL_SECONDS", prefix), v.to_string()));
310        }
311        if let Some(v) = self.timestamp_future_tolerance_seconds {
312            vars.push((
313                format!("{}TIMESTAMP_FUTURE_TOLERANCE_SECONDS", prefix),
314                v.to_string(),
315            ));
316        }
317        if let Some(v) = self.fault_status {
318            vars.push((format!("{}FAULT_STATUS", prefix), v.to_string()));
319        }
320        if !self.exposed_soap_headers.is_empty() {
321            vars.push((
322                format!("{}EXPOSED_SOAP_HEADERS", prefix),
323                self.exposed_soap_headers.join(","),
324            ));
325        }
326        if let Some(ref tls) = self.tls {
327            vars.extend(tls.to_env_vars(&prefix));
328        }
329
330        vars
331    }
332}
333
334/// Configuration for spawning a bridge subprocess.
335/// Password fields use [`Redacted`] to prevent accidental credential leakage in logs.
336///
337/// ADR-0051 credential boundary: manual-redaction
338pub struct BridgeProcessConfig {
339    pub spec: &'static BridgeSpec,
340    pub binary_path: PathBuf,
341    pub broker_url: String,
342    pub broker_type: BrokerType,
343    pub username: Option<String>,
344    pub password: Option<Redacted<String>>,
345    pub start_timeout_ms: u64,
346    pub env_vars: Vec<(String, String)>,
347    /// Keys removed from the spawned `Command`'s inherited environment before
348    /// [`env_vars`](Self::env_vars) is injected. JMS bridges list the four
349    /// `BRIDGE_BROKER_TLS_*` keys; other bridge kinds keep this empty.
350    pub env_remove_keys: Vec<String>,
351    /// When `true`, the supervisor awaits the bridge's `soap-ready` announcement
352    /// in addition to the gRPC `ready` line (either order) before `start()`
353    /// returns. Only a CXF bridge configured with a SOAP bind sets it; every
354    /// other bridge (and a CXF pool without a bind) keeps `false` and returns on
355    /// the gRPC `ready` line alone.
356    pub expect_soap_ready: bool,
357}
358
359impl fmt::Debug for BridgeProcessConfig {
360    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
361        f.debug_struct("BridgeProcessConfig")
362            .field("spec", &self.spec)
363            .field("binary_path", &self.binary_path)
364            .field("broker_url", &"[REDACTED]")
365            .field("broker_type", &self.broker_type)
366            .field("username", &self.username.as_ref().map(|_| "[REDACTED]"))
367            .field("password", &self.password)
368            .field("start_timeout_ms", &self.start_timeout_ms)
369            .field(
370                "env_vars",
371                &format!("[REDACTED; {} entries]", self.env_vars.len()),
372            )
373            .field("env_remove_keys", &self.env_remove_keys)
374            .field("expect_soap_ready", &self.expect_soap_ready)
375            .finish()
376    }
377}
378
379impl BridgeProcessConfig {
380    /// Constructor for the JMS bridge.
381    ///
382    /// `journal_dir` and `broker_key` are the Rust-resolved attempt-journal
383    /// root (`JMS_ATTEMPT_JOURNAL_DIR`) and the derived broker key
384    /// (`JMS_BRIDGE_BROKER_KEY`). Either may be `None`, which leaves the
385    /// variable unset so the bridge defers a queue subscribe to a
386    /// `FAILED_PRECONDITION`; producer-only and topic paths still work.
387    #[allow(clippy::too_many_arguments)]
388    pub fn jms(
389        binary_path: PathBuf,
390        broker_url: String,
391        broker_type: BrokerType,
392        username: Option<String>,
393        password: Option<Redacted<String>>,
394        start_timeout_ms: u64,
395        pool_idle_timeout_ms: u64,
396        pool_expiration_check_ms: u64,
397        journal_dir: Option<String>,
398        broker_key: Option<String>,
399        tls: Option<JmsTlsEnv>,
400    ) -> Self {
401        let mut env_vars = vec![
402            ("BRIDGE_BROKER_URL".to_string(), broker_url.clone()),
403            (
404                "BRIDGE_BROKER_TYPE".to_string(),
405                broker_type.as_env_str().to_string(),
406            ),
407            (
408                "JMS_POOL_IDLE_TIMEOUT_MS".to_string(),
409                pool_idle_timeout_ms.to_string(),
410            ),
411            (
412                "JMS_POOL_EXPIRATION_CHECK_MS".to_string(),
413                pool_expiration_check_ms.to_string(),
414            ),
415        ];
416        if let Some(u) = &username {
417            env_vars.push(("BRIDGE_USERNAME".to_string(), u.clone()));
418        }
419        if let Some(p) = &password {
420            env_vars.push(("BRIDGE_PASSWORD".to_string(), (**p).clone()));
421        }
422        if let Some(dir) = journal_dir {
423            env_vars.push(("JMS_ATTEMPT_JOURNAL_DIR".to_string(), dir));
424        }
425        if let Some(key) = broker_key {
426            env_vars.push(("JMS_BRIDGE_BROKER_KEY".to_string(), key));
427        }
428        let env_remove_keys = BRIDGE_BROKER_TLS_ENV_KEYS
429            .iter()
430            .map(|key| (*key).to_string())
431            .collect();
432        if let Some(tls) = tls {
433            if let Some(value) = tls.ca_cert_path {
434                env_vars.push((BRIDGE_BROKER_TLS_CA_CERT_PATH.to_string(), value));
435            }
436            if let Some(value) = tls.client_cert_path {
437                env_vars.push((BRIDGE_BROKER_TLS_CLIENT_CERT_PATH.to_string(), value));
438            }
439            if let Some(value) = tls.client_key_path {
440                env_vars.push((BRIDGE_BROKER_TLS_CLIENT_KEY_PATH.to_string(), value));
441            }
442            if let Some(value) = tls.server_name {
443                env_vars.push((BRIDGE_BROKER_TLS_SERVER_NAME.to_string(), value));
444            }
445        }
446        Self {
447            spec: &JMS_BRIDGE,
448            binary_path,
449            broker_url,
450            broker_type,
451            username,
452            password,
453            start_timeout_ms,
454            env_vars,
455            env_remove_keys,
456            expect_soap_ready: false,
457        }
458    }
459
460    /// Constructor for the XML bridge.
461    pub fn xml(binary_path: PathBuf, start_timeout_ms: u64) -> Self {
462        Self {
463            spec: &XML_BRIDGE,
464            binary_path,
465            broker_url: String::new(),
466            broker_type: BrokerType::Generic,
467            username: None,
468            password: None,
469            start_timeout_ms,
470            env_vars: vec![],
471            env_remove_keys: vec![],
472            expect_soap_ready: false,
473        }
474    }
475
476    /// Constructor for the CXF bridge with multi-profile support.
477    /// Generates `CXF_PROFILES=list` env var plus per-profile env vars.
478    pub fn cxf_profiles(
479        binary_path: PathBuf,
480        profiles: &[CxfProfileEnvVars],
481        start_timeout_ms: u64,
482    ) -> Self {
483        let profile_names: Vec<String> = profiles.iter().map(|p| p.name.clone()).collect();
484        let mut env_vars = vec![("CXF_PROFILES".to_string(), profile_names.join(","))];
485
486        for profile in profiles {
487            env_vars.extend(profile.to_env_vars());
488        }
489
490        Self {
491            spec: &CXF_BRIDGE,
492            binary_path,
493            broker_url: String::new(),
494            broker_type: BrokerType::Generic,
495            username: None,
496            password: None,
497            start_timeout_ms,
498            env_vars,
499            env_remove_keys: vec![],
500            expect_soap_ready: false,
501        }
502    }
503
504    pub fn validate(&self) -> Result<(), String> {
505        if self.start_timeout_ms == 0 {
506            return Err("start_timeout_ms must be > 0".to_string());
507        }
508        Ok(())
509    }
510}
511
512/// Process-lifetime runtime hosting the owned child-output drains.
513///
514/// The drains must outlive the runtime that called [`BridgeProcess::start`].
515/// Spawning them on the caller's runtime means a caller that retires its
516/// runtime (for example a short-lived harness thread) aborts the drains and
517/// closes the pipe readers; the still-running child then gets `EPIPE` on its
518/// next stderr/stdout write. Keeping the drains here decouples their lifetime
519/// from the caller: only [`BridgeProcess::stop`]/`Drop` cancel them.
520static DRAIN_RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
521
522fn drain_runtime() -> &'static tokio::runtime::Runtime {
523    DRAIN_RUNTIME.get_or_init(|| {
524        tokio::runtime::Builder::new_multi_thread()
525            .worker_threads(2)
526            .enable_io()
527            .enable_time()
528            .thread_name("camel-bridge-drain")
529            .build()
530            // The first spawn cannot proceed without a drain runtime; failing
531            // to build one means the process is already out of OS resources.
532            .expect("build camel-bridge drain runtime") // allow-unwrap
533    })
534}
535
536/// R4-L7: bounded child-output drain — shared between production start() and
537/// tests. `label` distinguishes "stdout" from "stderr" in log messages. Never
538/// stops reading: the OS pipe must stay drained (stopping = pipe fill = child
539/// blocked = worse regression). Bound single-line size to 64 KiB; rate-limit
540/// logging to 100 lines/second with drop summary.
541async fn drain_child_output<R: tokio::io::AsyncBufRead + Unpin>(
542    mut reader: R,
543    token: CancellationToken,
544    label: &str,
545) {
546    use tokio::io::AsyncBufReadExt;
547    use tokio::time::{Duration, Instant};
548
549    const MAX_LINE_BYTES: usize = 64 * 1024;
550    const LOG_INTERVAL: Duration = Duration::from_secs(1);
551    const LOG_BUDGET: u32 = 100;
552
553    let mut line_buf: Vec<u8> = Vec::new();
554    let mut oversized = false;
555    let mut interval_start = Instant::now();
556    let mut logged: u32 = 0;
557    let mut dropped: u32 = 0;
558
559    loop {
560        tokio::select! {
561            biased;
562            _ = token.cancelled() => break,
563            res = reader.fill_buf() => {
564                let chunk_len = match res {
565                    Ok(chunk) => {
566                        if chunk.is_empty() {
567                            break; // EOF
568                        }
569                        for &b in chunk {
570                            if b == b'\n' {
571                                if oversized {
572                                    // log-policy: degraded
573                                    if logged < LOG_BUDGET {
574                                        tracing::warn!(
575                                            "bridge {label}: oversized line (>{MAX_LINE_BYTES} bytes), truncated"
576                                        );
577                                        logged += 1;
578                                    } else {
579                                        dropped += 1;
580                                    }
581                                } else if logged < LOG_BUDGET {
582                                    // log-policy: normal
583                                    tracing::debug!(
584                                        target: "camel_bridge::child",
585                                        "{}",
586                                        String::from_utf8_lossy(&line_buf)
587                                    );
588                                    logged += 1;
589                                } else {
590                                    dropped += 1;
591                                }
592                                line_buf.clear();
593                                oversized = false;
594                            } else if !oversized {
595                                if line_buf.len() < MAX_LINE_BYTES {
596                                    line_buf.push(b);
597                                } else {
598                                    oversized = true;
599                                }
600                            }
601                        }
602                        chunk.len()
603                    }
604                    Err(_) => break,
605                };
606                reader.consume(chunk_len);
607                if interval_start.elapsed() >= LOG_INTERVAL {
608                    if dropped > 0 {
609                        // log-policy: normal
610                        tracing::debug!(
611                            "bridge {label}: dropped {dropped} lines in last interval"
612                        );
613                    }
614                    interval_start = Instant::now();
615                    logged = 0;
616                    dropped = 0;
617                }
618            }
619        }
620    }
621}
622
623/// Validate the announced port of a recognized ready line (`ready` or
624/// `soap-ready`): integer 1..=65535. Untrusted-stdout policy — a missing,
625/// non-numeric, or out-of-range port fails startup with `BadReadyMessage`
626/// instead of silently cast-wrapping into a wrong `u16` (e.g. 70000 → 4464).
627/// The reason carries the raw line, already bounded by the 64 KiB line cap.
628fn announced_port(value: &serde_json::Value, line: &str) -> Result<u16, BridgeError> {
629    match value.get("port").and_then(|p| p.as_u64()) {
630        Some(p) if (1..=u64::from(u16::MAX)).contains(&p) => Ok(p as u16),
631        _ => {
632            // log-policy: system-broken
633            tracing::error!("bridge ready message malformed: {line}");
634            Err(BridgeError::BadReadyMessage(line.to_string()))
635        }
636    }
637}
638
639#[derive(Debug, PartialEq)]
640enum ReadyLine {
641    Ready(u16),
642    SoapReady(u16),
643    PermanentFailure(String),
644    MalformedReady,
645}
646
647/// Classifies every startup status through the same strict port validator.
648fn parse_ready_line(line: &str) -> Option<ReadyLine> {
649    let value: serde_json::Value = serde_json::from_str(line).ok()?;
650    match value.get("status").and_then(|s| s.as_str())? {
651        status @ ("ready" | "soap-ready") => Some(match announced_port(&value, line) {
652            Ok(port) if status == "ready" => ReadyLine::Ready(port),
653            Ok(port) => ReadyLine::SoapReady(port),
654            Err(_) => ReadyLine::MalformedReady,
655        }),
656        "permanent-failure" => Some(ReadyLine::PermanentFailure(
657            value
658                .get("reason")
659                .and_then(|r| r.as_str())
660                .unwrap_or("bridge reported a permanent startup failure")
661                .to_string(),
662        )),
663        _ => None,
664    }
665}
666
667/// Reads both readiness announcements in either order, with a 64 KiB line cap.
668/// EOF classification belongs to the caller, outside the startup timeout.
669async fn read_ready_ports<R: tokio::io::AsyncBufRead + Unpin>(
670    reader: &mut R,
671    expect_soap_ready: bool,
672) -> Result<(u16, Option<u16>), BridgeError> {
673    use tokio::io::AsyncBufReadExt;
674    const READY_MAX_LINE: usize = 64 * 1024;
675    let mut buf = Vec::new();
676    let mut oversized = false;
677    let mut grpc_port = None;
678    let mut soap_port = None;
679    loop {
680        let chunk = reader.fill_buf().await?;
681        if chunk.is_empty() {
682            return Err(BridgeError::StdoutClosed);
683        }
684        let newline = chunk.iter().position(|&b| b == b'\n');
685        let take = newline.map_or(chunk.len(), |i| i + 1);
686        if !oversized && buf.len() + take <= READY_MAX_LINE {
687            buf.extend_from_slice(&chunk[..take]);
688        } else {
689            oversized = true;
690        }
691        reader.consume(take);
692        if newline.is_some() {
693            if !oversized {
694                let line = String::from_utf8_lossy(&buf);
695                match parse_ready_line(line.trim_end_matches('\n')) {
696                    Some(ReadyLine::Ready(port)) => grpc_port = Some(port),
697                    Some(ReadyLine::SoapReady(port)) => soap_port = Some(port),
698                    Some(ReadyLine::PermanentFailure(reason)) => {
699                        return Err(BridgeError::PermanentFailure(reason));
700                    }
701                    Some(ReadyLine::MalformedReady) => {
702                        return Err(BridgeError::BadReadyMessage(line.into_owned()));
703                    }
704                    None => {}
705                }
706                if let Some(port) = grpc_port
707                    && (!expect_soap_ready || soap_port.is_some())
708                {
709                    return Ok((port, soap_port));
710                }
711            }
712            buf.clear();
713            oversized = false;
714        }
715    }
716}
717
718#[cfg(test)]
719async fn read_ready_port<R: tokio::io::AsyncBufRead + Unpin>(
720    reader: &mut R,
721) -> Result<u16, BridgeError> {
722    read_ready_ports(reader, false).await.map(|(port, _)| port)
723}
724
725/// Classify EOF outside the readiness deadline, then bound cleanup separately.
726#[cfg(not(unix))]
727async fn eof_exit_error(child: &mut tokio::process::Child) -> BridgeError {
728    use tokio::time::{Duration, timeout};
729    match timeout(Duration::from_secs(2), child.wait()).await {
730        Ok(Ok(status)) if status.code() == Some(78) => BridgeError::PermanentFailure(
731            "bridge exited with code 78 before announcing readiness".to_string(),
732        ),
733        Ok(_) => BridgeError::StdoutClosed,
734        Err(_) => {
735            let _ = child.start_kill();
736            let _ = timeout(Duration::from_secs(2), child.wait()).await;
737            BridgeError::StdoutClosed
738        }
739    }
740}
741
742/// Unix: the bridge has already exited at EOF. Wait bounded and non-reaping,
743/// then run the guard's serialized shutdown (group TERM → KILL → pgid
744/// invalidation → reap) and obtain the exit status from that same ordering.
745#[cfg(unix)]
746async fn eof_exit_error(guard: &GroupGuard) -> BridgeError {
747    use tokio::time::Duration;
748    match guard.shutdown_classified(Duration::from_secs(2)).await {
749        Ok(Some(status)) if status.code() == Some(78) => BridgeError::PermanentFailure(
750            "bridge exited with code 78 before announcing readiness".to_string(),
751        ),
752        Ok(_) | Err(_) => BridgeError::StdoutClosed,
753    }
754}
755
756pub struct BridgeProcess {
757    /// Unix: the guard owns the bridge child and its process group.
758    #[cfg(unix)]
759    guard: GroupGuard,
760    /// Non-unix: the bare child (no group supervision on this platform).
761    #[cfg(not(unix))]
762    child: tokio::process::Child,
763    grpc_port: u16,
764    /// Announced SOAP listener port (`soap-ready` line). `None` when the bridge
765    /// runs without a SOAP bind. Fresh per [`BridgeProcess::start`] — a restart
766    /// builds a new process, so no stale port can survive into the next slot.
767    soap_port: Option<u16>,
768    tls: crate::tls::BridgeTlsMaterial,
769    /// Cancels the stdout drain task.
770    token: CancellationToken,
771    handle: Option<JoinHandle<()>>,
772    /// Cancels the stderr drain task (stderr is owned, never inherited).
773    stderr_token: CancellationToken,
774    stderr_handle: Option<JoinHandle<()>>,
775}
776
777impl BridgeProcess {
778    pub fn grpc_port(&self) -> u16 {
779        self.grpc_port
780    }
781
782    /// Announced SOAP listener port; `None` without a `soap-ready` line.
783    pub fn soap_port(&self) -> Option<u16> {
784        self.soap_port
785    }
786
787    /// PID of the owned bridge child (read-only lifecycle accessor).
788    pub fn child_pid(&self) -> Option<u32> {
789        #[cfg(unix)]
790        {
791            self.guard.bridge_pid()
792        }
793        #[cfg(not(unix))]
794        {
795            self.child.id()
796        }
797    }
798
799    /// Connect a mTLS tonic channel to this bridge process.
800    pub async fn connect(&self) -> Result<tonic::transport::Channel, BridgeError> {
801        crate::channel::connect_channel(self.grpc_port, &self.tls).await
802    }
803
804    /// Start the bridge process and connect a mTLS channel in one step.
805    pub async fn start_and_connect(
806        config: &BridgeProcessConfig,
807    ) -> Result<(Self, tonic::transport::Channel), BridgeError> {
808        let process = Self::start(config).await?;
809        let channel = process.connect().await?;
810        Ok((process, channel))
811    }
812
813    /// Spawn the bridge process. Reads the SSL port from stdout JSON line:
814    ///   {"status":"ready","port":PORT}
815    ///
816    /// Port handoff (bd rc-s7dyw): passes `QUARKUS_HTTP_SSL_PORT=0` so the
817    /// Quarkus JVM binds an OS-assigned port itself; PortAnnouncer reports
818    /// the ACTUAL bound port (from the Quarkus runtime registry) in the ready
819    /// line, and this side connects to the announced port. The port is held
820    /// by the JVM from the first moment it exists, so the ADR-0070
821    /// bind-read-drop race cannot occur. Build-time TLS props are in
822    /// application.yml; only runtime cert paths and the SSL port mode are
823    /// passed via env vars.
824    pub async fn start(config: &BridgeProcessConfig) -> Result<Self, BridgeError> {
825        use tokio::process::Command;
826        use tokio::time::{Duration, timeout};
827
828        config.validate().map_err(BridgeError::Config)?;
829
830        let tls = crate::tls::BridgeTlsMaterial::generate()?;
831
832        // Supervisor-first (unix): the watchdog process group must exist before
833        // the bridge. Every early return below drops this guard, which is the
834        // sole cleanup backstop (group KILL + liveness-pipe EOF).
835        #[cfg(unix)]
836        let guard = GroupGuard::establish()?;
837
838        // Port selection is JVM-authoritative (bd rc-s7dyw): 0 makes Quarkus
839        // bind an OS-assigned port and announce the actual bound port. The
840        // old bind-:0-read-drop here left the port unheld between the drop
841        // and the JVM bind (ADR-0070 port-toctou class); with the JVM owning
842        // the bind no such window exists.
843        let free_port: u16 = 0;
844
845        // If CAMEL_BRIDGE_LOG_STDERR is set, redirect stderr to a file for
846        // debugging. Otherwise stderr is piped and drained: it is never
847        // inherited (a leaked child must not hold the parent's stderr).
848        let stderr_stdio: std::process::Stdio =
849            if let Ok(log_dir) = std::env::var("CAMEL_BRIDGE_LOG_STDERR") {
850                let log_filename = config
851                    .spec
852                    .log_file_template
853                    .replace("{pid}", &std::process::id().to_string());
854                let log_path = if log_dir.is_empty() {
855                    format!("/tmp/{log_filename}")
856                } else {
857                    format!("{log_dir}/{log_filename}")
858                };
859                match std::fs::File::create(&log_path) {
860                    Ok(f) => {
861                        eprintln!("[camel-bridge] stderr → {}", log_path);
862                        f.into()
863                    }
864                    Err(e) => {
865                        eprintln!(
866                            "[camel-bridge] failed to create log file {}: {}",
867                            log_path, e
868                        );
869                        std::process::Stdio::piped()
870                    }
871                }
872            } else {
873                std::process::Stdio::piped()
874            };
875
876        let mut command = Command::new(&config.binary_path);
877        command
878            .stdin(std::process::Stdio::null())
879            .env("QUARKUS_HTTP_SSL_PORT", free_port.to_string())
880            .env(
881                "QUARKUS_TLS_BRIDGE_KEY_STORE_PEM_0_CERT",
882                &tls.server_pem_path,
883            )
884            .env(
885                "QUARKUS_TLS_BRIDGE_KEY_STORE_PEM_0_KEY",
886                &tls.server_key_path,
887            )
888            .env("QUARKUS_TLS_BRIDGE_TRUST_STORE_PEM_CERTS", &tls.ca_pem_path)
889            .stdout(std::process::Stdio::piped())
890            .stderr(stderr_stdio);
891
892        // Non-unix keeps the bare-child lifecycle: kill_on_drop is its only
893        // cancellation-safe backstop (there is no guard/watchdog).
894        #[cfg(not(unix))]
895        command.kill_on_drop(true);
896
897        // Cut inherited operator exports of the configured keys before injecting
898        // the Rust-owned values (Task 2.1: broker TLS inheritance isolation).
899        for key in &config.env_remove_keys {
900            command.env_remove(key);
901        }
902
903        // Inject bridge-specific env vars (e.g. JMS broker URL/credentials via ::jms()).
904        for (key, value) in &config.env_vars {
905            command.env(key, value);
906        }
907
908        // Spawn under the guard (unix) or bare (non-unix). The child's pipe
909        // ends register with the runtime active on THIS thread, so spawn inside
910        // the drain runtime: registering them on the caller's runtime would
911        // make the drain readers unusable (and closable) the moment that
912        // runtime is dropped.
913        #[cfg(unix)]
914        {
915            let _drain = drain_runtime().enter();
916            guard.spawn_bridge(command)?;
917        }
918        #[cfg(not(unix))]
919        let mut child = {
920            let _drain = drain_runtime().enter();
921            command.spawn()?
922        };
923
924        #[cfg(unix)]
925        let mut reader = {
926            let stdout = guard
927                .take_bridge_stdout()
928                .ok_or(BridgeError::StdoutClosed)?;
929            tokio::io::BufReader::new(stdout)
930        };
931        #[cfg(not(unix))]
932        let mut reader = {
933            let stdout = child.stdout.take().ok_or(BridgeError::StdoutClosed)?;
934            tokio::io::BufReader::new(stdout)
935        };
936
937        // Owned stderr, drained IMMEDIATELY (before readiness parsing): a
938        // 200 KiB stderr burst must never fill the pipe and stall startup. The
939        // drain is bounded (64 KiB lines, rate-limited logs) and cancelled by
940        // `stop()`/`Drop`. With file redirection there is no pipe to drain.
941        let stderr_token = CancellationToken::new();
942        #[cfg(unix)]
943        let stderr_pipe = guard.take_bridge_stderr();
944        #[cfg(not(unix))]
945        let stderr_pipe = child.stderr.take();
946        let stderr_handle = stderr_pipe.map(|stderr| {
947            let child_token = stderr_token.clone();
948            drain_runtime().spawn(async move {
949                drain_child_output(tokio::io::BufReader::new(stderr), child_token, "stderr").await;
950            })
951        });
952
953        // --- Ready-detection phase (bounded by ONE outer timeout + bounded line) ---
954        // Startup protocol (Task 2.8): parse the gRPC `ready` line AND the
955        // `soap-ready` line in ANY order. `config.start_timeout_ms` encloses the
956        // WHOLE wait — it is never reset per line or per chunk. With
957        // `expect_soap_ready = false` the loop returns on the gRPC `ready` line
958        // alone (a `soap-ready` seen before it is still stored when valid).
959        // R4-L7: use fill_buf()/consume() instead of Lines::next_line() to avoid
960        // unbounded buffer growth on lines without newlines.
961        let ready = timeout(
962            Duration::from_millis(config.start_timeout_ms),
963            read_ready_ports(&mut reader, config.expect_soap_ready),
964        )
965        .await;
966        let (port, soap_port) = match ready {
967            Ok(Ok(ports)) => ports,
968            Ok(Err(BridgeError::StdoutClosed)) => {
969                // EOF classification must not lose exit 78 to the readiness deadline.
970                #[cfg(unix)]
971                let error = eof_exit_error(&guard).await;
972                #[cfg(not(unix))]
973                let error = eof_exit_error(&mut child).await;
974                // log-policy: system-broken
975                tracing::error!("bridge stdout closed before ready message");
976                return Err(error);
977            }
978            Ok(Err(error)) => return Err(error),
979            Err(_) => {
980                let msg = format!(
981                    "{} failed to start: health check timeout after {}ms",
982                    config.spec.name, config.start_timeout_ms
983                );
984                // log-policy: system-broken
985                tracing::error!("{msg}");
986                return Err(BridgeError::Timeout(msg));
987            }
988        };
989
990        // --- Post-ready bounded stdout drain (R4-L7) ---
991        // Never stop reading: the OS pipe must stay drained (stopping = pipe fill
992        // = child blocked = worse regression). Bound single-line size to 64 KiB;
993        // rate-limit logging to 100 lines/second with drop summary.
994        let token = CancellationToken::new();
995        let child_token = token.clone();
996        let handle = drain_runtime().spawn(async move {
997            drain_child_output(reader, child_token, "stdout").await;
998        });
999
1000        Ok(BridgeProcess {
1001            #[cfg(unix)]
1002            guard,
1003            #[cfg(not(unix))]
1004            child,
1005            grpc_port: port,
1006            soap_port,
1007            tls,
1008            token,
1009            handle: Some(handle),
1010            stderr_token,
1011            stderr_handle,
1012        })
1013    }
1014
1015    /// Gracefully stop: SIGTERM to the bridge group, bounded wait, then KILL.
1016    pub async fn stop(mut self) -> Result<(), BridgeError> {
1017        use tokio::time::Duration;
1018
1019        self.token.cancel();
1020        if let Some(handle) = self.handle.take() {
1021            let join_result = tokio::time::timeout(Duration::from_secs(5), handle).await;
1022            if join_result.is_err() {
1023                tracing::warn!("bridge stdout drain task did not exit after cancellation");
1024            }
1025        }
1026
1027        self.stderr_token.cancel();
1028        if let Some(handle) = self.stderr_handle.take() {
1029            let join_result = tokio::time::timeout(Duration::from_secs(5), handle).await;
1030            if join_result.is_err() {
1031                tracing::warn!("bridge stderr drain task did not exit after cancellation");
1032            }
1033        }
1034
1035        // Unix: the guard owns the process group. No bare-PID signal is ever
1036        // issued — the fixed TERM → bounded wait → KILL sequence runs inside
1037        // `stop_group` and reaps both children.
1038        #[cfg(unix)]
1039        self.guard.stop_group(Duration::from_secs(5)).await?;
1040
1041        // Non-unix: preserve the bare-child kill/wait behavior exactly.
1042        #[cfg(not(unix))]
1043        {
1044            use tokio::time::sleep;
1045
1046            let _ = self.child.start_kill();
1047            tokio::select! {
1048                result = self.child.wait() => {
1049                    result?;
1050                }
1051                _ = sleep(Duration::from_secs(5)) => {
1052                    let _ = self.child.start_kill();
1053                    self.child.wait().await?;
1054                }
1055            }
1056        }
1057        Ok(())
1058    }
1059}
1060
1061impl Drop for BridgeProcess {
1062    fn drop(&mut self) {
1063        self.token.cancel();
1064        self.stderr_token.cancel();
1065        // Unix: the guard is the sole backstop. Its Drop performs a best-effort
1066        // group KILL and closes the liveness pipe (EOF backstop); it never
1067        // issues a bare-PID signal. Non-unix keeps the best-effort child kill.
1068        #[cfg(not(unix))]
1069        let _ = self.child.start_kill();
1070    }
1071}
1072
1073#[cfg(test)]
1074mod tests {
1075    use super::*;
1076    use camel_component_api::test_support::EnvGuard;
1077
1078    #[test]
1079    fn broker_type_from_str_activemq() {
1080        assert_eq!(
1081            "activemq".parse::<BrokerType>().unwrap(),
1082            BrokerType::ActiveMq
1083        );
1084        assert_eq!(
1085            "ACTIVEMQ".parse::<BrokerType>().unwrap(),
1086            BrokerType::ActiveMq
1087        );
1088    }
1089
1090    #[test]
1091    fn broker_type_from_str_artemis() {
1092        assert_eq!(
1093            "artemis".parse::<BrokerType>().unwrap(),
1094            BrokerType::Artemis
1095        );
1096    }
1097
1098    #[test]
1099    fn broker_type_from_str_generic() {
1100        assert_eq!(
1101            "generic".parse::<BrokerType>().unwrap(),
1102            BrokerType::Generic
1103        );
1104    }
1105
1106    #[test]
1107    fn broker_type_from_str_unknown_returns_err() {
1108        assert!("ibmmq".parse::<BrokerType>().is_err());
1109        assert!("UnknownBroker".parse::<BrokerType>().is_err());
1110    }
1111
1112    #[test]
1113    fn broker_type_env_str() {
1114        assert_eq!(BrokerType::ActiveMq.as_env_str(), "activemq");
1115        assert_eq!(BrokerType::Artemis.as_env_str(), "artemis");
1116        assert_eq!(BrokerType::Generic.as_env_str(), "generic");
1117    }
1118
1119    #[test]
1120    fn jms_constructor_uses_jms_spec() {
1121        let cfg = BridgeProcessConfig::jms(
1122            PathBuf::from("/tmp/jms-bridge"),
1123            "tcp://localhost:61616".to_string(),
1124            BrokerType::ActiveMq,
1125            Some("user".to_string()),
1126            Some(Redacted::new("pass".to_string())),
1127            1000,
1128            30_000,
1129            30_000,
1130            None,
1131            None,
1132            None,
1133        );
1134        assert_eq!(cfg.spec.name, "jms-bridge");
1135    }
1136
1137    #[test]
1138    fn xml_constructor_uses_xml_spec() {
1139        let cfg = BridgeProcessConfig::xml(PathBuf::from("/tmp/xml-bridge"), 1000);
1140        assert_eq!(cfg.spec.name, "xml-bridge");
1141    }
1142
1143    #[test]
1144    fn journal_env_passed_on_launch_and_restart() {
1145        for _ in 0..2 {
1146            let config = BridgeProcessConfig::jms(
1147                PathBuf::from("/tmp/jms-bridge"),
1148                "tcp://localhost:61616".into(),
1149                BrokerType::ActiveMq,
1150                None,
1151                None,
1152                1000,
1153                30_000,
1154                30_000,
1155                Some("/var/lib/state/rust-camel/jms-bridge".into()),
1156                Some("broker-key".into()),
1157                None,
1158            );
1159            assert!(config.env_vars.contains(&(
1160                "JMS_ATTEMPT_JOURNAL_DIR".into(),
1161                "/var/lib/state/rust-camel/jms-bridge".into()
1162            )));
1163            assert!(
1164                config
1165                    .env_vars
1166                    .contains(&("JMS_BRIDGE_BROKER_KEY".into(), "broker-key".into()))
1167            );
1168        }
1169        let unset = BridgeProcessConfig::jms(
1170            PathBuf::from("/tmp/jms-bridge"),
1171            "tcp://localhost:61616".into(),
1172            BrokerType::ActiveMq,
1173            None,
1174            None,
1175            1000,
1176            30_000,
1177            30_000,
1178            None,
1179            None,
1180            None,
1181        );
1182        assert!(
1183            !unset
1184                .env_vars
1185                .iter()
1186                .any(|(key, _)| key == "JMS_ATTEMPT_JOURNAL_DIR" || key == "JMS_BRIDGE_BROKER_KEY")
1187        );
1188    }
1189
1190    #[test]
1191    fn jms_pool_knob_env_vars_present() {
1192        let config = BridgeProcessConfig::jms(
1193            PathBuf::from("/tmp/jms-bridge"),
1194            "tcp://localhost:61616".to_string(),
1195            BrokerType::ActiveMq,
1196            Some("user".to_string()),
1197            Some(Redacted::new("pass".to_string())),
1198            1000,
1199            60_000,
1200            5_000,
1201            None,
1202            None,
1203            None,
1204        );
1205        assert!(
1206            config
1207                .env_vars
1208                .contains(&("JMS_POOL_IDLE_TIMEOUT_MS".to_string(), "60000".to_string()))
1209        );
1210        assert!(config.env_vars.contains(&(
1211            "JMS_POOL_EXPIRATION_CHECK_MS".to_string(),
1212            "5000".to_string()
1213        )));
1214    }
1215
1216    #[test]
1217    fn tls_env_emitted_for_configured_broker() {
1218        let config = BridgeProcessConfig::jms(
1219            PathBuf::from("/tmp/jms-bridge"),
1220            "ssl://localhost:61617".to_string(),
1221            BrokerType::ActiveMq,
1222            None,
1223            None,
1224            1000,
1225            30_000,
1226            30_000,
1227            None,
1228            None,
1229            Some(JmsTlsEnv {
1230                ca_cert_path: Some("/certs/ca.pem".to_string()),
1231                client_cert_path: Some("/certs/client.pem".to_string()),
1232                client_key_path: Some("/certs/client-key.pem".to_string()),
1233                server_name: Some("mq.internal.example".to_string()),
1234            }),
1235        );
1236        assert!(config.env_vars.contains(&(
1237            BRIDGE_BROKER_TLS_CA_CERT_PATH.to_string(),
1238            "/certs/ca.pem".to_string()
1239        )));
1240        assert!(config.env_vars.contains(&(
1241            BRIDGE_BROKER_TLS_CLIENT_CERT_PATH.to_string(),
1242            "/certs/client.pem".to_string()
1243        )));
1244        assert!(config.env_vars.contains(&(
1245            BRIDGE_BROKER_TLS_CLIENT_KEY_PATH.to_string(),
1246            "/certs/client-key.pem".to_string()
1247        )));
1248        assert!(config.env_vars.contains(&(
1249            BRIDGE_BROKER_TLS_SERVER_NAME.to_string(),
1250            "mq.internal.example".to_string()
1251        )));
1252    }
1253
1254    #[test]
1255    fn tls_env_absent_keys_removed() {
1256        let config = BridgeProcessConfig::jms(
1257            PathBuf::from("/tmp/jms-bridge"),
1258            "tcp://localhost:61616".to_string(),
1259            BrokerType::ActiveMq,
1260            None,
1261            None,
1262            1000,
1263            30_000,
1264            30_000,
1265            None,
1266            None,
1267            None,
1268        );
1269        for key in BRIDGE_BROKER_TLS_ENV_KEYS {
1270            assert!(
1271                config.env_remove_keys.contains(&key.to_string()),
1272                "inherited {key} must be removed on the child Command"
1273            );
1274            assert!(
1275                !config.env_vars.iter().any(|(name, _)| name == key),
1276                "unconfigured {key} must not be injected"
1277            );
1278        }
1279    }
1280
1281    /// Env marker marking the isolated TLS env-spawn child process.
1282    const TLS_SPAWN_ISOLATION_CHILD_ENV: &str = "CAMEL_TEST_TLS_ISOLATION_INNER";
1283    /// Full test path spawned for the isolated TLS env-spawn run.
1284    const TLS_SPAWN_ISOLATION_TEST: &str = "process::tests::tls_spawn_isolation_inner";
1285
1286    /// Task 2.1 spawn-path regression: the configured remove list is applied to
1287    /// the real child `Command` before `env_vars` injection. A parent export of
1288    /// a broker TLS key must never reach the bridge, and a configured value must
1289    /// win over the stale inherited one (remove-then-inject ordering).
1290    ///
1291    /// The scenario mutates the process-global environment, so it runs in a
1292    /// re-executed child process with a single-test envelope instead of racing
1293    /// the parallel suite's shared environment.
1294    #[cfg(unix)]
1295    #[test]
1296    fn tls_spawn_removes_inherited_and_injects_configured() {
1297        let exe = match std::env::current_exe() {
1298            Ok(exe) => exe,
1299            Err(e) => panic!("current test executable path: {e}"),
1300        };
1301        let output = match std::process::Command::new(exe)
1302            .args([
1303                "--exact",
1304                TLS_SPAWN_ISOLATION_TEST,
1305                "--test-threads=1",
1306                "--nocapture",
1307            ])
1308            .env(TLS_SPAWN_ISOLATION_CHILD_ENV, "1")
1309            .output()
1310        {
1311            Ok(output) => output,
1312            Err(e) => panic!("spawn isolated TLS env test: {e}"),
1313        };
1314        assert!(
1315            output.status.success(),
1316            "isolated TLS env child failed: status={:?}\nstdout:\n{}\nstderr:\n{}",
1317            output.status,
1318            String::from_utf8_lossy(&output.stdout),
1319            String::from_utf8_lossy(&output.stderr),
1320        );
1321    }
1322
1323    /// Child body for the isolated TLS env-spawn scenario. Inert unless the
1324    /// parent set the marker, so it is a no-op in normal suite runs.
1325    #[cfg(unix)]
1326    #[tokio::test]
1327    async fn tls_spawn_isolation_inner() {
1328        if std::env::var(TLS_SPAWN_ISOLATION_CHILD_ENV).ok().as_deref() != Some("1") {
1329            return;
1330        }
1331
1332        let _env_guard = EnvGuard::set(BRIDGE_BROKER_TLS_CA_CERT_PATH, "stale-value");
1333
1334        let observed_dir = tempfile::tempdir().expect("observed env tempdir");
1335        let observed_a = observed_dir.path().join("a.env");
1336        let observed_b = observed_dir.path().join("b.env");
1337
1338        let body = |path: &std::path::Path| {
1339            format!(
1340                "env > '{}'; echo '{{\"status\":\"ready\",\"port\":12345}}'",
1341                path.display()
1342            )
1343        };
1344        let (_script_dir_a, script_a) = fake_bridge_script(&body(&observed_a));
1345        let (_script_dir_b, script_b) = fake_bridge_script(&body(&observed_b));
1346
1347        let mut config = fake_bridge_config(&script_a, false, 10_000);
1348        config.env_remove_keys = vec![BRIDGE_BROKER_TLS_CA_CERT_PATH.to_string()];
1349        config.env_vars = vec![];
1350        let process = BridgeProcess::start(&config)
1351            .await
1352            .unwrap_or_else(|e| panic!("remove-only spawn must start: {e}"));
1353        let child_env_a = std::fs::read_to_string(&observed_a).expect("child env snapshot a");
1354        assert!(
1355            !child_env_a
1356                .lines()
1357                .any(|line| line == format!("{BRIDGE_BROKER_TLS_CA_CERT_PATH}=stale-value")),
1358            "inherited stale TLS CA path must be removed from the child env"
1359        );
1360        assert!(
1361            !child_env_a.contains("stale-value"),
1362            "stale value must not leak through any inherited variable"
1363        );
1364        process
1365            .stop()
1366            .await
1367            .unwrap_or_else(|e| panic!("remove-only fake bridge must stop cleanly: {e}"));
1368
1369        let mut config = fake_bridge_config(&script_b, false, 10_000);
1370        config.env_remove_keys = vec![BRIDGE_BROKER_TLS_CA_CERT_PATH.to_string()];
1371        config.env_vars = vec![(
1372            BRIDGE_BROKER_TLS_CA_CERT_PATH.to_string(),
1373            "/good/path".to_string(),
1374        )];
1375        let process = BridgeProcess::start(&config)
1376            .await
1377            .unwrap_or_else(|e| panic!("remove-then-inject spawn must start: {e}"));
1378        let child_env_b = std::fs::read_to_string(&observed_b).expect("child env snapshot b");
1379        assert!(
1380            child_env_b
1381                .lines()
1382                .any(|line| line == format!("{BRIDGE_BROKER_TLS_CA_CERT_PATH}=/good/path")),
1383            "configured TLS CA path must win over the stale inherited value"
1384        );
1385        assert!(
1386            !child_env_b.contains("stale-value"),
1387            "remove-then-inject must not leave the inherited value behind"
1388        );
1389        process
1390            .stop()
1391            .await
1392            .unwrap_or_else(|e| panic!("remove-then-inject fake bridge must stop cleanly: {e}"));
1393    }
1394
1395    #[test]
1396    fn partial_tls_emits_only_present() {
1397        let config = BridgeProcessConfig::jms(
1398            PathBuf::from("/tmp/jms-bridge"),
1399            "ssl://localhost:61617".to_string(),
1400            BrokerType::ActiveMq,
1401            None,
1402            None,
1403            1000,
1404            30_000,
1405            30_000,
1406            None,
1407            None,
1408            Some(JmsTlsEnv {
1409                ca_cert_path: Some("/certs/ca.pem".to_string()),
1410                ..JmsTlsEnv::default()
1411            }),
1412        );
1413        assert!(config.env_vars.contains(&(
1414            BRIDGE_BROKER_TLS_CA_CERT_PATH.to_string(),
1415            "/certs/ca.pem".to_string()
1416        )));
1417        for key in [
1418            BRIDGE_BROKER_TLS_CLIENT_CERT_PATH,
1419            BRIDGE_BROKER_TLS_CLIENT_KEY_PATH,
1420            BRIDGE_BROKER_TLS_SERVER_NAME,
1421        ] {
1422            assert!(
1423                !config.env_vars.iter().any(|(name, _)| name == key),
1424                "absent {key} must not be injected"
1425            );
1426            assert!(
1427                config.env_remove_keys.contains(&key.to_string()),
1428                "{key} must still be removed"
1429            );
1430        }
1431    }
1432
1433    #[test]
1434    fn parses_permanent_failure_status_line() {
1435        assert_eq!(
1436            parse_ready_line(
1437                r#"{"status":"permanent-failure","reason":"JMS_ATTEMPT_JOURNAL_DIR is not owner-private"}"#
1438            ),
1439            Some(ReadyLine::PermanentFailure(
1440                "JMS_ATTEMPT_JOURNAL_DIR is not owner-private".into()
1441            ))
1442        );
1443        assert_eq!(
1444            parse_ready_line(r#"{"status":"ready","port":8080}"#),
1445            Some(ReadyLine::Ready(8080))
1446        );
1447        assert_eq!(parse_ready_line("just a log line"), None);
1448        for status in ["ready", "soap-ready"] {
1449            for port in ["0", "65536", "-1", "1.5", "\"123\"", "null"] {
1450                assert_eq!(
1451                    parse_ready_line(&format!(r#"{{"status":"{status}","port":{port}}}"#)),
1452                    Some(ReadyLine::MalformedReady)
1453                );
1454            }
1455        }
1456    }
1457
1458    #[cfg(unix)]
1459    fn wait_pid_gone(pid: u32) -> bool {
1460        std::process::Command::new("sh").arg("-c").arg(format!(
1461            "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"
1462        )).status().map(|s| s.success()).unwrap_or(false)
1463    }
1464
1465    #[cfg(unix)]
1466    #[tokio::test]
1467    async fn eof_with_exit_78_maps_to_permanent() {
1468        // Exit occurs after stdout closes and after the readiness budget ends.
1469        let (_dir, script) = fake_bridge_script("exec 1>&-; sleep 0.3; exit 78");
1470        let result = BridgeProcess::start(&fake_bridge_config(&script, false, 150)).await;
1471        assert!(
1472            matches!(result, Err(BridgeError::PermanentFailure(reason)) if reason.contains("78"))
1473        );
1474    }
1475
1476    #[cfg(unix)]
1477    #[tokio::test]
1478    async fn eof_with_other_exit_stays_transient() {
1479        let (_dir, script) = fake_bridge_script("exit 1");
1480        let result = BridgeProcess::start(&fake_bridge_config(&script, false, 5000)).await;
1481        assert!(matches!(result, Err(BridgeError::StdoutClosed)));
1482    }
1483
1484    #[tokio::test]
1485    async fn oversized_ready_line_is_discarded_before_next_announcement() {
1486        let mut output = vec![b' '; 64 * 1024];
1487        output.extend_from_slice(b"{\"status\":\"ready\",\"port\":9999}\n");
1488        output.extend_from_slice(b"{\"status\":\"ready\",\"port\":12345}\n");
1489        let mut reader = tokio::io::BufReader::new(output.as_slice());
1490        assert_eq!(
1491            read_ready_port(&mut reader).await.expect("ready port"),
1492            12345
1493        );
1494    }
1495
1496    #[cfg(unix)]
1497    #[tokio::test]
1498    async fn eof_closed_stdout_child_is_bounded_other_and_killed() {
1499        let dir = tempfile::tempdir().expect("tempdir");
1500        let pidfile = dir.path().join("child.pid");
1501        let (_script_dir, script) = fake_bridge_script(&format!(
1502            "echo $$ > '{}'; exec 1>&-; exec sleep 30",
1503            pidfile.display()
1504        ));
1505        let result = tokio::time::timeout(
1506            std::time::Duration::from_secs(6),
1507            BridgeProcess::start(&fake_bridge_config(&script, false, 5000)),
1508        )
1509        .await
1510        .expect("bounded EOF classification");
1511        assert!(matches!(result, Err(BridgeError::StdoutClosed)));
1512        let pid = std::fs::read_to_string(pidfile)
1513            .expect("pidfile")
1514            .trim()
1515            .parse()
1516            .expect("pid");
1517        assert!(wait_pid_gone(pid), "EOF child must be killed");
1518    }
1519
1520    #[cfg(unix)]
1521    #[tokio::test]
1522    async fn permanent_line_returns_prompt_and_kills_child() {
1523        let dir = tempfile::tempdir().expect("tempdir");
1524        let pidfile = dir.path().join("child.pid");
1525        let (_script_dir, script) = fake_bridge_script(&format!(
1526            "echo $$ > '{}'; echo '{{\"status\":\"permanent-failure\",\"reason\":\"JMS_ATTEMPT_JOURNAL_DIR is not owner-private\"}}'; exec sleep 30",
1527            pidfile.display()
1528        ));
1529        let result = tokio::time::timeout(
1530            std::time::Duration::from_secs(1),
1531            BridgeProcess::start(&fake_bridge_config(&script, true, 300)),
1532        )
1533        .await
1534        .expect("prompt permanent failure");
1535        assert!(
1536            matches!(result, Err(BridgeError::PermanentFailure(reason)) if reason.contains("JMS_ATTEMPT_JOURNAL_DIR"))
1537        );
1538        let pid = std::fs::read_to_string(pidfile)
1539            .expect("pidfile")
1540            .trim()
1541            .parse()
1542            .expect("pid");
1543        assert!(wait_pid_gone(pid), "permanent failure child must be killed");
1544    }
1545
1546    #[cfg(unix)]
1547    #[tokio::test]
1548    async fn startup_cancellation_kills_child() {
1549        let dir = tempfile::tempdir().expect("tempdir");
1550        let pidfile = dir.path().join("child.pid");
1551        let (_script_dir, script) =
1552            fake_bridge_script(&format!("echo $$ > '{}'; exec sleep 30", pidfile.display()));
1553        let config = fake_bridge_config(&script, true, 5000);
1554        let task = tokio::spawn(async move { BridgeProcess::start(&config).await });
1555        let pid = tokio::time::timeout(std::time::Duration::from_secs(2), async {
1556            loop {
1557                if let Ok(text) = std::fs::read_to_string(&pidfile)
1558                    && let Ok(pid) = text.trim().parse::<u32>()
1559                {
1560                    break pid;
1561                }
1562                tokio::task::yield_now().await;
1563            }
1564        })
1565        .await
1566        .expect("child starts");
1567        task.abort();
1568        assert!(matches!(task.await, Err(error) if error.is_cancelled()));
1569        assert!(wait_pid_gone(pid), "cancelled startup child must be killed");
1570    }
1571
1572    #[test]
1573    fn default_expect_soap_ready_is_false() {
1574        let xml = BridgeProcessConfig::xml(PathBuf::from("/tmp/xml-bridge"), 1000);
1575        assert!(
1576            !xml.expect_soap_ready,
1577            "xml bridge must not expect soap-ready"
1578        );
1579        let jms = BridgeProcessConfig::jms(
1580            PathBuf::from("/tmp/jms-bridge"),
1581            "tcp://localhost:61616".to_string(),
1582            BrokerType::ActiveMq,
1583            None,
1584            None,
1585            1000,
1586            30_000,
1587            30_000,
1588            None,
1589            None,
1590            None,
1591        );
1592        assert!(
1593            !jms.expect_soap_ready,
1594            "jms bridge must not expect soap-ready"
1595        );
1596        let cxf = BridgeProcessConfig::cxf_profiles(PathBuf::from("/tmp/cxf-bridge"), &[], 1000);
1597        assert!(
1598            !cxf.expect_soap_ready,
1599            "a CXF pool without a SOAP bind must not expect soap-ready"
1600        );
1601    }
1602
1603    #[test]
1604    fn cxf_profiles_generates_cxf_profiles_env_var() {
1605        let profiles = vec![
1606            CxfProfileEnvVars {
1607                name: "baleares".to_string(),
1608                wsdl_path: "/a.wsdl".to_string(),
1609                service_name: "Svc".to_string(),
1610                port_name: "Port".to_string(),
1611                address: None,
1612                keystore_path: None,
1613                keystore_password: None,
1614                truststore_path: None,
1615                truststore_password: None,
1616                sig_username: None,
1617                sig_password: None,
1618                enc_username: None,
1619                security_actions_out: None,
1620                security_actions_in: None,
1621                signature_algorithm: None,
1622                signature_digest_algorithm: None,
1623                signature_c14n_algorithm: None,
1624                signature_parts: None,
1625                allowed_signature_algorithms: None,
1626                allowed_digest_algorithms: None,
1627                allow_expired_pinned_certificate: false,
1628                timestamp_ttl_seconds: None,
1629                timestamp_future_tolerance_seconds: None,
1630                fault_status: None,
1631                exposed_soap_headers: vec![],
1632                tls: None,
1633            },
1634            CxfProfileEnvVars {
1635                name: "extremadura".to_string(),
1636                wsdl_path: "/b.wsdl".to_string(),
1637                service_name: "Svc2".to_string(),
1638                port_name: "Port2".to_string(),
1639                address: Some("http://host:9090/ws".to_string()),
1640                keystore_path: Some("/b.jks".to_string()),
1641                keystore_password: Some(Redacted::new("pass".to_string())),
1642                truststore_path: None,
1643                truststore_password: None,
1644                sig_username: Some("cert".to_string()),
1645                sig_password: Some(Redacted::new("sig_pass".to_string())),
1646                enc_username: None,
1647                security_actions_out: Some("Timestamp Signature".to_string()),
1648                security_actions_in: Some("Timestamp Signature".to_string()),
1649                signature_algorithm: None,
1650                signature_digest_algorithm: None,
1651                signature_c14n_algorithm: None,
1652                signature_parts: None,
1653                allowed_signature_algorithms: None,
1654                allowed_digest_algorithms: None,
1655                allow_expired_pinned_certificate: false,
1656                timestamp_ttl_seconds: None,
1657                timestamp_future_tolerance_seconds: None,
1658                fault_status: None,
1659                exposed_soap_headers: vec![],
1660                tls: None,
1661            },
1662        ];
1663
1664        let cfg =
1665            BridgeProcessConfig::cxf_profiles(PathBuf::from("/tmp/cxf-bridge"), &profiles, 15_000);
1666
1667        assert_eq!(cfg.spec.name, "cxf-bridge");
1668        assert!(cfg.broker_url.is_empty());
1669        assert_eq!(cfg.broker_type, BrokerType::Generic);
1670        assert!(cfg.username.is_none());
1671        assert!(cfg.password.is_none());
1672
1673        // Find CXF_PROFILES env var
1674        let profiles_var = cfg
1675            .env_vars
1676            .iter()
1677            .find(|(k, _)| k == "CXF_PROFILES")
1678            .expect("CXF_PROFILES env var must exist");
1679        assert_eq!(profiles_var.1, "baleares,extremadura");
1680
1681        // Check baleares profile vars (no security)
1682        assert!(
1683            cfg.env_vars
1684                .iter()
1685                .any(|(k, v)| k == "CXF_PROFILE_BALEARES_WSDL_PATH" && v == "/a.wsdl")
1686        );
1687        assert!(
1688            cfg.env_vars
1689                .iter()
1690                .any(|(k, v)| k == "CXF_PROFILE_BALEARES_SERVICE_NAME" && v == "Svc")
1691        );
1692        assert!(
1693            cfg.env_vars
1694                .iter()
1695                .any(|(k, v)| k == "CXF_PROFILE_BALEARES_PORT_NAME" && v == "Port")
1696        );
1697        assert!(
1698            !cfg.env_vars
1699                .iter()
1700                .any(|(k, _)| k == "CXF_PROFILE_BALEARES_ADDRESS")
1701        );
1702
1703        // Check extremadura profile vars (with security)
1704        assert!(
1705            cfg.env_vars
1706                .iter()
1707                .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_WSDL_PATH" && v == "/b.wsdl")
1708        );
1709        assert!(
1710            cfg.env_vars
1711                .iter()
1712                .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_ADDRESS" && v == "http://host:9090/ws")
1713        );
1714        assert!(
1715            cfg.env_vars
1716                .iter()
1717                .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_KEYSTORE_PATH" && v == "/b.jks")
1718        );
1719        assert!(
1720            cfg.env_vars
1721                .iter()
1722                .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_KEYSTORE_PASSWORD" && v == "pass")
1723        );
1724        assert!(
1725            cfg.env_vars
1726                .iter()
1727                .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_SIG_USERNAME" && v == "cert")
1728        );
1729        assert!(
1730            cfg.env_vars
1731                .iter()
1732                .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_SIG_PASSWORD" && v == "sig_pass")
1733        );
1734        assert!(
1735            cfg.env_vars
1736                .iter()
1737                .any(|(k, v)| k == "CXF_PROFILE_EXTREMADURA_SECURITY_ACTIONS_OUT"
1738                    && v == "Timestamp Signature")
1739        );
1740    }
1741
1742    #[test]
1743    fn cxf_profiles_single_profile_no_security() {
1744        let profiles = vec![CxfProfileEnvVars {
1745            name: "test".to_string(),
1746            wsdl_path: "service.wsdl".to_string(),
1747            service_name: "{http://example.com}Service".to_string(),
1748            port_name: "{http://example.com}Port".to_string(),
1749            address: None,
1750            keystore_path: None,
1751            keystore_password: None,
1752            truststore_path: None,
1753            truststore_password: None,
1754            sig_username: None,
1755            sig_password: None,
1756            enc_username: None,
1757            security_actions_out: None,
1758            security_actions_in: None,
1759            signature_algorithm: None,
1760            signature_digest_algorithm: None,
1761            signature_c14n_algorithm: None,
1762            signature_parts: None,
1763            allowed_signature_algorithms: None,
1764            allowed_digest_algorithms: None,
1765            allow_expired_pinned_certificate: false,
1766            timestamp_ttl_seconds: None,
1767            timestamp_future_tolerance_seconds: None,
1768            fault_status: None,
1769            exposed_soap_headers: vec![],
1770            tls: None,
1771        }];
1772
1773        let cfg =
1774            BridgeProcessConfig::cxf_profiles(PathBuf::from("/tmp/cxf-bridge"), &profiles, 15_000);
1775
1776        assert_eq!(cfg.spec.name, "cxf-bridge");
1777        // CXF_PROFILES + 3 required vars (WSDL_PATH, SERVICE_NAME, PORT_NAME)
1778        assert_eq!(cfg.env_vars.len(), 4);
1779        assert_eq!(cfg.env_vars[0].0, "CXF_PROFILES");
1780        assert_eq!(cfg.env_vars[0].1, "test");
1781        assert_eq!(cfg.env_vars[1].0, "CXF_PROFILE_TEST_WSDL_PATH");
1782        assert_eq!(cfg.env_vars[1].1, "service.wsdl");
1783        assert_eq!(cfg.env_vars[2].0, "CXF_PROFILE_TEST_SERVICE_NAME");
1784        assert_eq!(cfg.env_vars[2].1, "{http://example.com}Service");
1785        assert_eq!(cfg.env_vars[3].0, "CXF_PROFILE_TEST_PORT_NAME");
1786        assert_eq!(cfg.env_vars[3].1, "{http://example.com}Port");
1787    }
1788
1789    #[test]
1790    fn profile_env_vars_to_env_vars_includes_all_fields() {
1791        let vars = CxfProfileEnvVars {
1792            name: "full".to_string(),
1793            wsdl_path: "/wsdl".to_string(),
1794            service_name: "Svc".to_string(),
1795            port_name: "Port".to_string(),
1796            address: Some("http://host:8080".to_string()),
1797            keystore_path: Some("/ks.jks".to_string()),
1798            keystore_password: Some(Redacted::new("ks_pass".to_string())),
1799            truststore_path: Some("/ts.jks".to_string()),
1800            truststore_password: Some(Redacted::new("ts_pass".to_string())),
1801            sig_username: Some("user".to_string()),
1802            sig_password: Some(Redacted::new("sig_pass".to_string())),
1803            enc_username: None,
1804            security_actions_out: Some("Timestamp Signature".to_string()),
1805            security_actions_in: Some("Timestamp".to_string()),
1806            signature_algorithm: None,
1807            signature_digest_algorithm: None,
1808            signature_c14n_algorithm: None,
1809            signature_parts: None,
1810            allowed_signature_algorithms: Some(
1811                "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256".to_string(),
1812            ),
1813            allowed_digest_algorithms: Some("http://www.w3.org/2001/04/xmlenc#sha256".to_string()),
1814            allow_expired_pinned_certificate: true,
1815            timestamp_ttl_seconds: Some(900),
1816            timestamp_future_tolerance_seconds: Some(120),
1817            fault_status: Some(403),
1818            exposed_soap_headers: vec![],
1819            tls: None,
1820        };
1821
1822        let env = vars.to_env_vars();
1823        // 3 required + 1 address + 8 security + 2 allowed_algorithms
1824        // + 1 expired-pinned-cert opt-in + 2 timestamp window + 1 fault_status = 18
1825        assert_eq!(env.len(), 18);
1826
1827        let keys: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
1828        assert!(keys.contains(&"CXF_PROFILE_FULL_WSDL_PATH"));
1829        assert!(keys.contains(&"CXF_PROFILE_FULL_SERVICE_NAME"));
1830        assert!(keys.contains(&"CXF_PROFILE_FULL_PORT_NAME"));
1831        assert!(keys.contains(&"CXF_PROFILE_FULL_ADDRESS"));
1832        assert!(keys.contains(&"CXF_PROFILE_FULL_KEYSTORE_PATH"));
1833        assert!(keys.contains(&"CXF_PROFILE_FULL_KEYSTORE_PASSWORD"));
1834        assert!(keys.contains(&"CXF_PROFILE_FULL_TRUSTSTORE_PATH"));
1835        assert!(keys.contains(&"CXF_PROFILE_FULL_TRUSTSTORE_PASSWORD"));
1836        assert!(keys.contains(&"CXF_PROFILE_FULL_SIG_USERNAME"));
1837        assert!(keys.contains(&"CXF_PROFILE_FULL_SIG_PASSWORD"));
1838        assert!(keys.contains(&"CXF_PROFILE_FULL_SECURITY_ACTIONS_OUT"));
1839        assert!(keys.contains(&"CXF_PROFILE_FULL_SECURITY_ACTIONS_IN"));
1840        assert!(keys.contains(&"CXF_PROFILE_FULL_ALLOWED_SIGNATURE_ALGORITHMS"));
1841        assert!(keys.contains(&"CXF_PROFILE_FULL_ALLOWED_DIGEST_ALGORITHMS"));
1842        let allow_expired = env
1843            .iter()
1844            .find(|(k, _)| k == "CXF_PROFILE_FULL_ALLOW_EXPIRED_PINNED_CERTIFICATE")
1845            .expect("expired-pinned-cert opt-in env var emitted");
1846        assert_eq!(allow_expired.1, "true");
1847        let fault_status = env
1848            .iter()
1849            .find(|(k, _)| k == "CXF_PROFILE_FULL_FAULT_STATUS")
1850            .expect("fault_status env var emitted");
1851        assert_eq!(fault_status.1, "403");
1852        let timestamp_ttl = env
1853            .iter()
1854            .find(|(k, _)| k == "CXF_PROFILE_FULL_TIMESTAMP_TTL_SECONDS")
1855            .expect("timestamp ttl env var emitted");
1856        assert_eq!(timestamp_ttl.1, "900");
1857        let timestamp_future_tolerance = env
1858            .iter()
1859            .find(|(k, _)| k == "CXF_PROFILE_FULL_TIMESTAMP_FUTURE_TOLERANCE_SECONDS")
1860            .expect("timestamp future tolerance env var emitted");
1861        assert_eq!(timestamp_future_tolerance.1, "120");
1862    }
1863
1864    #[test]
1865    fn profile_env_vars_omits_timestamp_window_when_unset() {
1866        let vars = CxfProfileEnvVars {
1867            name: "default".to_string(),
1868            wsdl_path: "/wsdl".to_string(),
1869            service_name: "Svc".to_string(),
1870            port_name: "Port".to_string(),
1871            address: None,
1872            keystore_path: None,
1873            keystore_password: None,
1874            truststore_path: None,
1875            truststore_password: None,
1876            sig_username: None,
1877            sig_password: None,
1878            enc_username: None,
1879            security_actions_out: None,
1880            security_actions_in: None,
1881            signature_algorithm: None,
1882            signature_digest_algorithm: None,
1883            signature_c14n_algorithm: None,
1884            signature_parts: None,
1885            allowed_signature_algorithms: None,
1886            allowed_digest_algorithms: None,
1887            allow_expired_pinned_certificate: false,
1888            timestamp_ttl_seconds: None,
1889            timestamp_future_tolerance_seconds: None,
1890            fault_status: None,
1891            exposed_soap_headers: vec![],
1892            tls: None,
1893        };
1894
1895        let env = vars.to_env_vars();
1896        assert!(
1897            !env.iter()
1898                .any(|(key, _)| key.ends_with("TIMESTAMP_TTL_SECONDS")),
1899            "unset ttl must not be emitted: {env:?}"
1900        );
1901        assert!(
1902            !env.iter()
1903                .any(|(key, _)| key.ends_with("TIMESTAMP_FUTURE_TOLERANCE_SECONDS")),
1904            "unset future tolerance must not be emitted: {env:?}"
1905        );
1906    }
1907
1908    #[test]
1909    fn profile_env_vars_emits_u64_max_timestamp_window_unchanged() {
1910        let vars = CxfProfileEnvVars {
1911            name: "max".to_string(),
1912            wsdl_path: "/wsdl".to_string(),
1913            service_name: "Svc".to_string(),
1914            port_name: "Port".to_string(),
1915            address: None,
1916            keystore_path: None,
1917            keystore_password: None,
1918            truststore_path: None,
1919            truststore_password: None,
1920            sig_username: None,
1921            sig_password: None,
1922            enc_username: None,
1923            security_actions_out: None,
1924            security_actions_in: None,
1925            signature_algorithm: None,
1926            signature_digest_algorithm: None,
1927            signature_c14n_algorithm: None,
1928            signature_parts: None,
1929            allowed_signature_algorithms: None,
1930            allowed_digest_algorithms: None,
1931            allow_expired_pinned_certificate: false,
1932            timestamp_ttl_seconds: Some(u64::MAX),
1933            timestamp_future_tolerance_seconds: Some(u64::MAX),
1934            fault_status: None,
1935            exposed_soap_headers: vec![],
1936            tls: None,
1937        };
1938
1939        let env = vars.to_env_vars();
1940        let ttl = env
1941            .iter()
1942            .find(|(k, _)| k == "CXF_PROFILE_MAX_TIMESTAMP_TTL_SECONDS")
1943            .expect("timestamp ttl env var emitted");
1944        assert_eq!(ttl.1, "18446744073709551615");
1945        let future = env
1946            .iter()
1947            .find(|(k, _)| k == "CXF_PROFILE_MAX_TIMESTAMP_FUTURE_TOLERANCE_SECONDS")
1948            .expect("timestamp future tolerance env var emitted");
1949        assert_eq!(future.1, "18446744073709551615");
1950    }
1951
1952    #[test]
1953    fn profile_env_vars_omits_allow_expired_pinned_certificate_when_false() {
1954        let vars = CxfProfileEnvVars {
1955            name: "default".to_string(),
1956            wsdl_path: "/wsdl".to_string(),
1957            service_name: "Svc".to_string(),
1958            port_name: "Port".to_string(),
1959            address: None,
1960            keystore_path: None,
1961            keystore_password: None,
1962            truststore_path: None,
1963            truststore_password: None,
1964            sig_username: None,
1965            sig_password: None,
1966            enc_username: None,
1967            security_actions_out: None,
1968            security_actions_in: None,
1969            signature_algorithm: None,
1970            signature_digest_algorithm: None,
1971            signature_c14n_algorithm: None,
1972            signature_parts: None,
1973            allowed_signature_algorithms: None,
1974            allowed_digest_algorithms: None,
1975            allow_expired_pinned_certificate: false,
1976            timestamp_ttl_seconds: None,
1977            timestamp_future_tolerance_seconds: None,
1978            fault_status: None,
1979            exposed_soap_headers: vec![],
1980            tls: None,
1981        };
1982
1983        let env = vars.to_env_vars();
1984        assert!(
1985            !env.iter()
1986                .any(|(key, _)| key.ends_with("ALLOW_EXPIRED_PINNED_CERTIFICATE")),
1987            "default-off must not emit the opt-in: {env:?}"
1988        );
1989    }
1990
1991    #[test]
1992    fn cxf_profile_env_var_emits_exposed_soap_headers() {
1993        const FIRST: &str = "{http://tempuri.org/commonSchema}requestHeaderType";
1994        const SECOND: &str = "{http://tempuri.org/resources}RequestHeaderElement";
1995
1996        let mut vars = CxfProfileEnvVars {
1997            name: "partner".to_string(),
1998            wsdl_path: "/wsdl".to_string(),
1999            service_name: "Svc".to_string(),
2000            port_name: "Port".to_string(),
2001            address: None,
2002            keystore_path: None,
2003            keystore_password: None,
2004            truststore_path: None,
2005            truststore_password: None,
2006            sig_username: None,
2007            sig_password: None,
2008            enc_username: None,
2009            security_actions_out: None,
2010            security_actions_in: None,
2011            signature_algorithm: None,
2012            signature_digest_algorithm: None,
2013            signature_c14n_algorithm: None,
2014            signature_parts: None,
2015            allowed_signature_algorithms: None,
2016            allowed_digest_algorithms: None,
2017            allow_expired_pinned_certificate: false,
2018            fault_status: None,
2019            timestamp_ttl_seconds: None,
2020            timestamp_future_tolerance_seconds: None,
2021            exposed_soap_headers: vec![FIRST.to_string(), SECOND.to_string()],
2022            tls: None,
2023        };
2024
2025        let env = vars.to_env_vars();
2026        let (_, value) = env
2027            .iter()
2028            .find(|(key, _)| key == "CXF_PROFILE_PARTNER_EXPOSED_SOAP_HEADERS")
2029            .expect("exposed header allow-list env var emitted");
2030        assert_eq!(value, &format!("{FIRST},{SECOND}"));
2031
2032        vars.exposed_soap_headers.clear();
2033        let env = vars.to_env_vars();
2034        assert!(
2035            !env.iter()
2036                .any(|(key, _)| key.ends_with("EXPOSED_SOAP_HEADERS")),
2037            "empty allow-list must not emit the env var: {env:?}"
2038        );
2039    }
2040
2041    #[test]
2042    fn test_start_timeout_zero_rejected() {
2043        let config = BridgeProcessConfig::jms(
2044            PathBuf::from("/usr/bin/echo"),
2045            "tcp://localhost:61616".to_string(),
2046            BrokerType::ActiveMq,
2047            None,
2048            None,
2049            0,
2050            30_000,
2051            30_000,
2052            None,
2053            None,
2054            None,
2055        );
2056        let result = config.validate();
2057        assert!(result.is_err());
2058    }
2059
2060    #[test]
2061    fn test_bridge_rejects_zero_start_timeout() {
2062        let config = BridgeProcessConfig::jms(
2063            PathBuf::from("/usr/bin/echo"),
2064            "tcp://localhost:61616".to_string(),
2065            BrokerType::ActiveMq,
2066            None,
2067            None,
2068            0,
2069            30_000,
2070            30_000,
2071            None,
2072            None,
2073            None,
2074        );
2075        assert!(config.validate().is_err());
2076    }
2077
2078    #[tokio::test]
2079    async fn test_bridge_stop_completes() {
2080        use tokio::time::{Duration, timeout};
2081
2082        let tls = crate::tls::BridgeTlsMaterial::generate().expect("test tls");
2083
2084        #[cfg(unix)]
2085        let bridge = {
2086            let guard = GroupGuard::establish().expect("establish guard");
2087            let mut command = tokio::process::Command::new("sh");
2088            command
2089                .arg("-c")
2090                .arg("trap '' TERM; while true; do echo tick; sleep 1; done")
2091                .stdin(std::process::Stdio::null())
2092                .stdout(std::process::Stdio::null())
2093                .stderr(std::process::Stdio::null());
2094            guard.spawn_bridge(command).expect("spawn test child");
2095            BridgeProcess {
2096                guard,
2097                grpc_port: 0,
2098                soap_port: None,
2099                tls,
2100                token: CancellationToken::new(),
2101                handle: None,
2102                stderr_token: CancellationToken::new(),
2103                stderr_handle: None,
2104            }
2105        };
2106
2107        #[cfg(not(unix))]
2108        let bridge = {
2109            let child = tokio::process::Command::new("sh")
2110                .arg("-c")
2111                .arg("trap '' TERM; while true; do echo tick; sleep 1; done")
2112                .stdout(std::process::Stdio::null())
2113                .spawn()
2114                .expect("must spawn test child process");
2115            BridgeProcess {
2116                child,
2117                grpc_port: 0,
2118                soap_port: None,
2119                tls,
2120                token: CancellationToken::new(),
2121                handle: None,
2122                stderr_token: CancellationToken::new(),
2123                stderr_handle: None,
2124            }
2125        };
2126
2127        let result = timeout(Duration::from_secs(10), bridge.stop()).await;
2128        assert!(result.is_ok(), "stop() must complete within 10s");
2129    }
2130
2131    // --- BRG-004: Redacted<T> tests ---
2132
2133    #[test]
2134    fn redacted_debug_displays_redacted() {
2135        let r = Redacted::new("secret_password".to_string());
2136        assert_eq!(format!("{r:?}"), "[REDACTED]");
2137    }
2138
2139    #[test]
2140    fn redacted_display_displays_redacted() {
2141        let r = Redacted::new("secret_password".to_string());
2142        assert_eq!(format!("{r}"), "[REDACTED]");
2143    }
2144
2145    #[test]
2146    fn redacted_deref_gives_inner_value() {
2147        let r = Redacted::new("secret".to_string());
2148        assert_eq!(&*r, "secret");
2149    }
2150
2151    #[test]
2152    fn redacted_into_inner_returns_value() {
2153        let r = Redacted::new("secret".to_string());
2154        assert_eq!(r.into_inner(), "secret");
2155    }
2156
2157    #[test]
2158    fn redacted_clone_works() {
2159        let r = Redacted::new("secret".to_string());
2160        let c = r.clone();
2161        assert_eq!(&*c, "secret");
2162        assert_eq!(format!("{c:?}"), "[REDACTED]");
2163    }
2164
2165    #[test]
2166    fn bridge_process_config_debug_redacts_password() {
2167        let cfg = BridgeProcessConfig::jms(
2168            PathBuf::from("/tmp/jms-bridge"),
2169            "tcp://localhost:61616".to_string(),
2170            BrokerType::ActiveMq,
2171            Some("user".to_string()),
2172            Some(Redacted::new("super_secret".to_string())),
2173            1000,
2174            30_000,
2175            30_000,
2176            None,
2177            None,
2178            None,
2179        );
2180        // The Redacted<T> password field must show [REDACTED] in debug output.
2181        // env_vars is a separate Vec<(String, String)> used for process injection
2182        // and legitimately contains the raw value — that is not a Redacted leak.
2183        let password_debug = format!("{:?}", cfg.password); // allow-secret
2184        assert!(
2185            !password_debug.contains("super_secret"),
2186            "Password field must not leak in Debug: {password_debug}"
2187        );
2188        assert_eq!(
2189            password_debug, "Some([REDACTED])",
2190            "Password field must show [REDACTED]: {password_debug}"
2191        );
2192    }
2193
2194    #[test]
2195    fn bridge_process_config_debug_redacts_broker_url_and_env_vars() {
2196        let cfg = BridgeProcessConfig {
2197            spec: &JMS_BRIDGE,
2198            binary_path: PathBuf::from("/tmp/jms-bridge"),
2199            broker_url: "amqp://u:SENTINEL-BRIDGE-PASS@host:5672".to_string(),
2200            broker_type: BrokerType::ActiveMq,
2201            username: Some("user".to_string()),
2202            password: Some(Redacted::new("pass".to_string())),
2203            start_timeout_ms: 1000,
2204            env_vars: vec![(
2205                "KEYSTORE_PASSWORD".to_string(),
2206                "SENTINEL-ENV-PASS".to_string(),
2207            )],
2208            env_remove_keys: vec![],
2209            expect_soap_ready: false,
2210        };
2211        let debug_output = format!("{cfg:?}");
2212        assert!(
2213            !debug_output.contains("SENTINEL-BRIDGE-PASS"),
2214            "broker_url must be redacted in Debug: {debug_output}"
2215        );
2216        assert!(
2217            !debug_output.contains("SENTINEL-ENV-PASS"),
2218            "env_vars values must be redacted in Debug: {debug_output}"
2219        );
2220        assert!(
2221            debug_output.contains("[REDACTED; 1 entries]"),
2222            "env_vars must show redacted count: {debug_output}"
2223        );
2224    }
2225
2226    #[test]
2227    fn bridge_process_config_debug_redacts_all_sensitive_fields() {
2228        let cfg = BridgeProcessConfig {
2229            spec: &JMS_BRIDGE,
2230            binary_path: PathBuf::from("/tmp/jms-bridge"),
2231            broker_url: "amqp://SENTINEL-BROKER-URL@host:5672".to_string(),
2232            broker_type: BrokerType::ActiveMq,
2233            username: Some("SENTINEL-USERNAME".to_string()),
2234            password: Some(Redacted::new("SENTINEL-PASSWORD".to_string())),
2235            start_timeout_ms: 1000,
2236            env_vars: vec![(
2237                "KEYSTORE_PASSWORD".to_string(),
2238                "SENTINEL-ENV-PASS".to_string(),
2239            )],
2240            env_remove_keys: vec![],
2241            expect_soap_ready: false,
2242        };
2243        let debug_output = format!("{cfg:?}");
2244        assert!(
2245            !debug_output.contains("SENTINEL-BROKER-URL")
2246                && !debug_output.contains("SENTINEL-USERNAME")
2247                && !debug_output.contains("SENTINEL-PASSWORD")
2248                && !debug_output.contains("SENTINEL-ENV-PASS"),
2249            "No sensitive field may leak in Debug: {debug_output}"
2250        );
2251        assert!(
2252            debug_output.contains("username: Some(\"[REDACTED]\")"),
2253            "username must be masked in Debug: {debug_output}"
2254        );
2255    }
2256
2257    #[test]
2258    fn cxf_profile_debug_redacts_passwords() {
2259        let profile = CxfProfileEnvVars {
2260            name: "test".to_string(),
2261            wsdl_path: "/a.wsdl".to_string(),
2262            service_name: "Svc".to_string(),
2263            port_name: "Port".to_string(),
2264            address: None,
2265            keystore_path: None,
2266            keystore_password: Some(Redacted::new("ks_secret_val".to_string())),
2267            truststore_path: None,
2268            truststore_password: Some(Redacted::new("ts_secret_val".to_string())),
2269            sig_username: None,
2270            sig_password: Some(Redacted::new("sig_secret_val".to_string())),
2271            enc_username: None,
2272            security_actions_out: None,
2273            security_actions_in: None,
2274            signature_algorithm: None,
2275            signature_digest_algorithm: None,
2276            signature_c14n_algorithm: None,
2277            signature_parts: None,
2278            allowed_signature_algorithms: None,
2279            allowed_digest_algorithms: None,
2280            allow_expired_pinned_certificate: false,
2281            timestamp_ttl_seconds: None,
2282            timestamp_future_tolerance_seconds: None,
2283            fault_status: None,
2284            exposed_soap_headers: vec![],
2285            tls: None,
2286        };
2287        let debug_output = format!("{profile:?}");
2288        assert!(
2289            !debug_output.contains("ks_secret_val")
2290                && !debug_output.contains("ts_secret_val")
2291                && !debug_output.contains("sig_secret_val"),
2292            "Debug must not contain passwords: {debug_output}"
2293        );
2294    }
2295
2296    // --- Task 2.8: dual ready-line startup protocol tests (fake bridge) ---
2297
2298    /// Fake bridge binary: an executable `sh` script standing in for the native
2299    /// bridge on stdout. The tempdir is returned so the script file outlives
2300    /// every child the test spawns from it.
2301    fn fake_bridge_script(body: &str) -> (tempfile::TempDir, PathBuf) {
2302        let dir = tempfile::tempdir().expect("fake bridge tempdir");
2303        let path = dir.path().join("fake-bridge.sh");
2304        std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).expect("write fake bridge script");
2305        #[cfg(unix)]
2306        {
2307            use std::os::unix::fs::PermissionsExt;
2308            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
2309                .expect("chmod fake bridge script");
2310        }
2311        (dir, path)
2312    }
2313
2314    fn fake_bridge_config(
2315        script: &std::path::Path,
2316        expect_soap_ready: bool,
2317        start_timeout_ms: u64,
2318    ) -> BridgeProcessConfig {
2319        let mut config =
2320            BridgeProcessConfig::cxf_profiles(script.to_path_buf(), &[], start_timeout_ms);
2321        config.expect_soap_ready = expect_soap_ready;
2322        config
2323    }
2324
2325    /// Task 2.8: `ready` and `soap-ready` are accepted in ANY order under the
2326    /// single enclosing startup budget, and BOTH announced ports are stored.
2327    #[tokio::test]
2328    async fn process_dual_ready_reordered_is_accepted() {
2329        use std::time::{Duration, Instant};
2330
2331        for (label, body, expect_soap_ready, want_soap) in [
2332            (
2333                "soap-ready before ready (expect=true)",
2334                r#"echo '{"status":"soap-ready","port":23456}'; echo '{"status":"ready","port":12345}'"#,
2335                true,
2336                Some(23456u16),
2337            ),
2338            (
2339                "ready before soap-ready (expect=true)",
2340                r#"echo '{"status":"ready","port":12345}'; echo '{"status":"soap-ready","port":23456}'"#,
2341                true,
2342                Some(23456u16),
2343            ),
2344            (
2345                "soap-ready before ready (expect=false still stores the valid soap port)",
2346                r#"echo '{"status":"soap-ready","port":23456}'; echo '{"status":"ready","port":12345}'"#,
2347                false,
2348                Some(23456u16),
2349            ),
2350        ] {
2351            let (_dir, script) = fake_bridge_script(body);
2352            let config = fake_bridge_config(&script, expect_soap_ready, 10_000);
2353            let started = Instant::now();
2354            let process = BridgeProcess::start(&config).await.unwrap_or_else(|e| {
2355                panic!("{label}: dual ready lines in any order must start: {e}")
2356            });
2357            let elapsed = started.elapsed();
2358            assert!(
2359                elapsed < Duration::from_millis(10_000),
2360                "{label}: start must complete inside one config budget, took {elapsed:?}"
2361            );
2362            assert_eq!(process.grpc_port(), 12345, "{label}: gRPC port stored");
2363            assert_eq!(process.soap_port(), want_soap, "{label}: soap port stored");
2364            process
2365                .stop()
2366                .await
2367                .unwrap_or_else(|e| panic!("{label}: fake bridge must stop cleanly: {e}"));
2368        }
2369    }
2370
2371    /// Task 2.8: a partial (soap-only) announcement followed by the ready line
2372    /// completes within ONE `start_timeout_ms` budget — and the counterexample
2373    /// row pins that the budget ENCLOSES the whole wait (it is never reset per
2374    /// line). Counterexample layout: two independent gaps BOTH strictly inside
2375    /// the budget (2.5s < 4s) whose SUM comfortably exceeds it (5s > 4s), so a
2376    /// per-line-reset implementation would return Ok at ~5s while the correct
2377    /// one times out at ~4s. A marker file proves the partial soap-ready line
2378    /// was really emitted before the deadline — the timeout cannot be excused
2379    /// as "the partial never arrived".
2380    #[tokio::test]
2381    async fn partial_ready_is_bounded_and_completes_in_one_timeout() {
2382        use std::time::{Duration, Instant};
2383
2384        for (label, body_template, budget_ms, expect_ok, max_elapsed_ms) in [
2385            (
2386                "partial soap-ready then ready completes within ONE budget",
2387                r#"echo '{"status":"soap-ready","port":23456}'; sleep 1; echo '{"status":"ready","port":12345}'"#,
2388                6_000u64,
2389                true,
2390                6_000u64,
2391            ),
2392            (
2393                "counterexample: TOTAL budget exceeded fires StartTimeout — no per-line reset",
2394                r#"sleep 2.5; echo '{"status":"soap-ready","port":23456}'; touch '{marker}'; sleep 2.5; echo '{"status":"ready","port":12345}'"#,
2395                4_000u64,
2396                false,
2397                5_000u64,
2398            ),
2399        ] {
2400            // The counterexample proves the PARTIAL line really was emitted
2401            // before the deadline: the fake bridge drops a marker right after
2402            // the soap-ready print (test fixture state — no prod test hooks).
2403            let marker_dir = tempfile::tempdir().expect("marker tempdir");
2404            let marker = marker_dir.path().join("soap-ready-emitted");
2405            let body = body_template.replace("{marker}", &marker.display().to_string());
2406            let (_dir, script) = fake_bridge_script(&body);
2407            let config = fake_bridge_config(&script, true, budget_ms);
2408            let started = Instant::now();
2409            let result = BridgeProcess::start(&config).await;
2410            let elapsed = started.elapsed();
2411            assert!(
2412                elapsed < Duration::from_millis(max_elapsed_ms),
2413                "{label}: must resolve in {max_elapsed_ms}ms (one enclosing budget), took {elapsed:?}"
2414            );
2415            if expect_ok {
2416                let process =
2417                    result.unwrap_or_else(|e| panic!("{label}: start must complete: {e}"));
2418                assert_eq!(process.grpc_port(), 12345, "{label}: gRPC port");
2419                assert_eq!(process.soap_port(), Some(23456), "{label}: soap port");
2420                process
2421                    .stop()
2422                    .await
2423                    .unwrap_or_else(|e| panic!("{label}: fake bridge must stop cleanly: {e}"));
2424            } else {
2425                match result {
2426                    Err(BridgeError::Timeout(_)) => {}
2427                    Err(e) => panic!("{label}: expected StartTimeout, got {e}"),
2428                    Ok(_) => panic!(
2429                        "{label}: start must hit the single enclosing budget instead of \
2430                         waiting out per-line resets"
2431                    ),
2432                }
2433                assert!(
2434                    marker.exists(),
2435                    "{label}: the partial soap-ready line must have been emitted before \
2436                     the deadline — the timeout comes from the ENCLOSING budget, not \
2437                     from a missing partial line"
2438                );
2439            }
2440        }
2441    }
2442
2443    /// Task 2.8: a recognized `soap-ready` line with a non-numeric, missing, or
2444    /// out-of-range port fails startup with `BadReadyMessage` — in BOTH line
2445    /// orders, with `expect_soap_ready` enabled. Port validation is 1..=65535
2446    /// (untrusted stdout policy: never cast-wrap into a wrong `u16`).
2447    #[tokio::test]
2448    async fn malformed_soap_ready_fails_startup() {
2449        let malformed = [
2450            (
2451                "non-numeric port",
2452                r#"{"status":"soap-ready","port":"abc"}"#,
2453            ),
2454            ("missing port", r#"{"status":"soap-ready"}"#),
2455            (
2456                "port above u16 range",
2457                r#"{"status":"soap-ready","port":70000}"#,
2458            ),
2459            ("port zero", r#"{"status":"soap-ready","port":0}"#),
2460        ];
2461        for (kind, soap_line) in malformed {
2462            for (order, body) in [
2463                (
2464                    "soap-ready first",
2465                    format!("echo '{soap_line}'; echo '{{\"status\":\"ready\",\"port\":12345}}'"),
2466                ),
2467                (
2468                    "ready first",
2469                    format!("echo '{{\"status\":\"ready\",\"port\":12345}}'; echo '{soap_line}'"),
2470                ),
2471            ] {
2472                let label = format!("{kind}, {order}");
2473                let (_dir, script) = fake_bridge_script(&body);
2474                let config = fake_bridge_config(&script, true, 5_000);
2475                match BridgeProcess::start(&config).await {
2476                    Err(BridgeError::BadReadyMessage(_)) => {}
2477                    Err(e) => panic!("{label}: expected BadReadyMessage, got {e}"),
2478                    Ok(_) => panic!("{label}: malformed soap-ready must fail startup"),
2479                }
2480            }
2481        }
2482    }
2483
2484    /// Task 2.8: with `expect_soap_ready = false` the supervisor awaits ONLY the
2485    /// gRPC `ready` line and returns without any second wait (no soap-ready ever
2486    /// arrives — the fake bridge holds its stdout open afterwards to prove it),
2487    /// and the soap port stays `None` (no stale value).
2488    #[tokio::test]
2489    async fn no_soap_wait_when_unbound() {
2490        use std::time::{Duration, Instant};
2491
2492        // `exec` replaces the shell so the blocker IS the child: no orphaned
2493        // grandchildren after stop().
2494        let (_dir, script) =
2495            fake_bridge_script(r#"echo '{"status":"ready","port":12345}'; exec tail -f /dev/null"#);
2496        let config = fake_bridge_config(&script, false, 5_000);
2497        let started = Instant::now();
2498        let process = BridgeProcess::start(&config)
2499            .await
2500            .expect("start must return on the gRPC ready line alone");
2501        let elapsed = started.elapsed();
2502        assert!(
2503            elapsed < Duration::from_millis(2_500),
2504            "expect_soap_ready=false must not wait for a soap-ready line, took {elapsed:?}"
2505        );
2506        assert_eq!(process.grpc_port(), 12345);
2507        assert_eq!(
2508            process.soap_port(),
2509            None,
2510            "no soap-ready line → soap_port stays None"
2511        );
2512        process.stop().await.expect("fake bridge must stop cleanly");
2513    }
2514
2515    // --- R4-L7: Bounded stdout drain tests ---
2516
2517    /// Helper: spawn a child that writes to stdout, return (child, stdout BufReader).
2518    ///
2519    /// stdin/stderr are nulled so no descendant inherits the test binary's
2520    /// harness pipes. Under cargo-nextest, a leaked grandchild (e.g. an
2521    /// orphaned `sleep` outliving a SIGKILLed `sh`) that still holds the
2522    /// inherited stdin/stderr keeps nextest's pipes open past leak-timeout
2523    /// and the test is flagged LEAK even though it passed.
2524    async fn spawn_child_with_stdout(script: &str) -> tokio::process::Child {
2525        use tokio::process::Command;
2526
2527        Command::new("sh")
2528            .arg("-c")
2529            .arg(script)
2530            .stdin(std::process::Stdio::null())
2531            .stdout(std::process::Stdio::piped())
2532            .stderr(std::process::Stdio::null())
2533            .spawn()
2534            .expect("must spawn test child")
2535    }
2536
2537    #[tokio::test]
2538    async fn drain_chatty_child_no_deadlock() {
2539        // Child writes many short lines quickly, then exits
2540        let mut child =
2541            spawn_child_with_stdout("for i in $(seq 1 500); do echo \"line $i\"; done").await;
2542
2543        let stdout = child.stdout.take().expect("stdout piped");
2544        let reader = tokio::io::BufReader::new(stdout);
2545        let token = CancellationToken::new();
2546
2547        // Call the production drain function directly (I-1: no duplicated logic)
2548        let handle = tokio::spawn(drain_child_output(reader, token.clone(), "stdout"));
2549
2550        // Wait for child to exit
2551        let status = child.wait().await.expect("wait for child"); // allow-test-wait: finite shell fixture exits; wait asserted
2552        assert!(status.success(), "child should exit successfully");
2553
2554        // Drain task should complete promptly after child exits (EOF)
2555        let result = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
2556        assert!(result.is_ok(), "drain task should complete within 2s");
2557    }
2558
2559    #[tokio::test]
2560    async fn drain_oversized_line_bounded() {
2561        use std::time::Duration;
2562
2563        // Child writes a line >64 KiB without newline, then newline.
2564        // Use printf + head to generate 200 KiB of 'A' without python dependency.
2565        // Bound verification: the drain loop caps line_buf at MAX_LINE_BYTES (64 KiB)
2566        // via the `oversized` flag — bytes beyond the cap are discarded, not accumulated.
2567        // Enforced by code inspection (src/process.rs drain_child_output: `line_buf.len() < MAX_LINE_BYTES`
2568        // guard + `oversized = true` branch that skips push). This test verifies the function
2569        // completes within a tight time bound (500ms), which would fail if the loop stalled
2570        // or allocated unboundedly.
2571        let oversized_bytes = 200 * 1024; // 200 KiB
2572        let mut child = spawn_child_with_stdout(&format!(
2573            "head -c {oversized_bytes} /dev/zero | tr '\\0' 'A'; echo"
2574        ))
2575        .await;
2576
2577        let stdout = child.stdout.take().expect("stdout piped");
2578        let reader = tokio::io::BufReader::new(stdout);
2579        let token = CancellationToken::new();
2580
2581        let handle = tokio::spawn(drain_child_output(reader, token.clone(), "stdout"));
2582
2583        let status = tokio::time::timeout(Duration::from_secs(30), child.wait())
2584            .await
2585            .expect("child exit within 30s: pipe-drain stall suspected")
2586            .expect("wait for child");
2587        assert!(status.success(), "child should exit successfully");
2588
2589        // Tighter bound: 500ms (was 2s). If the cap were broken, the loop would
2590        // still complete but would have allocated >64 KiB per line — caught by
2591        // code review + the oversized warning log assertion in integration tests.
2592        let result = tokio::time::timeout(std::time::Duration::from_millis(500), handle).await;
2593        assert!(result.is_ok(), "drain task should complete within 500ms");
2594    }
2595
2596    #[tokio::test]
2597    async fn drain_cancellation_exits_promptly() {
2598        // Child writes slowly (sleep between lines)
2599        let mut child = spawn_child_with_stdout("while true; do echo tick; sleep 1; done").await;
2600
2601        let stdout = child.stdout.take().expect("stdout piped");
2602        let reader = tokio::io::BufReader::new(stdout);
2603        let token = CancellationToken::new();
2604
2605        // Call the production drain function directly (I-1: no duplicated logic)
2606        let handle = tokio::spawn(drain_child_output(reader, token.clone(), "stdout"));
2607
2608        // drain_child_output polls `token.cancelled()` first in a biased select,
2609        // so cancelling before the task's first poll still exits promptly.
2610        token.cancel();
2611        let result = tokio::time::timeout(std::time::Duration::from_millis(500), handle).await;
2612        assert!(
2613            result.is_ok(),
2614            "drain task should exit promptly after cancellation"
2615        );
2616
2617        // Clean up child
2618        let _ = child.kill().await;
2619    }
2620
2621    /// I-2: Verify the single-BufReader-for-both-phases invariant.
2622    /// The ready-detection phase and drain phase must share the SAME BufReader;
2623    /// otherwise trailing bytes after the ready message would be lost (a second
2624    /// reader would start at offset 0, missing data already consumed by phase 1).
2625    /// This test exercises the handoff by mimicking the ready-detection loop,
2626    /// then calling drain_child_output with the same reader to verify trailing lines
2627    /// are processed (not lost).
2628    #[tokio::test]
2629    async fn drain_handoff_preserves_trailing_bytes() {
2630        // Child emits ready JSON + trailing log lines
2631        let mut child = spawn_child_with_stdout(
2632            r#"echo '{"status":"ready","port":12345}'; echo "trailing-1"; echo "trailing-2"; echo "trailing-3""#,
2633        )
2634        .await;
2635
2636        let stdout = child.stdout.take().expect("stdout piped");
2637        let mut reader = tokio::io::BufReader::new(stdout);
2638
2639        // --- Phase 1: production readiness parser, not a test copy ---
2640        assert_eq!(
2641            read_ready_port(&mut reader).await.expect("ready port"),
2642            12345
2643        );
2644
2645        // --- Phase 2: drain with the SAME reader (I-2: handoff invariant) ---
2646        let token = CancellationToken::new();
2647        let handle = tokio::spawn(drain_child_output(reader, token.clone(), "stdout"));
2648
2649        // Wait for child to exit; the echo script terminates promptly, the
2650        // bound only guards a hung child from hanging the test forever
2651        let status = tokio::time::timeout(std::time::Duration::from_secs(5), child.wait())
2652            .await
2653            .expect("child exits within 5s")
2654            .expect("wait for child");
2655        assert!(status.success(), "child should exit successfully");
2656
2657        // Drain task should complete (trailing lines processed, not lost)
2658        let result = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
2659        assert!(
2660            result.is_ok(),
2661            "drain task should complete — trailing bytes preserved through handoff"
2662        );
2663    }
2664}