Skip to main content

camel_core/
startup_validation.rs

1//! ADR-0033 startup-validation phase.
2//!
3//! `ConfigCheck` impls are registered for Require-Explicit-Choice security
4//! defaults. The fail-closed `run_startup_validation()` walks the registry
5//! and returns `Err(CamelError::Config)` if any check fails. See ADR-0033 in
6//! `docs/adr/0033-…`.
7
8use crate::lifecycle::application::route_definition::{BuilderStep, RouteDefinition};
9use camel_api::{CamelError, ConfigValidationError};
10use camel_endpoint::uri::parse_bool_param;
11
12/// A single startup-time configuration check.
13///
14/// Each `ConfigCheck` is run as part of the fail-closed startup-validation phase
15/// established by ADR-0033. The check owns a name (for error reporting), a human
16/// description, and a synchronous `run` that returns `Ok(())` if the check passes
17/// or `Err(CamelError::Config)` if the check fails and the operator config is
18/// invalid.
19pub trait ConfigCheck: Send + Sync {
20    /// Stable identifier for this check (e.g. `"grpc-tls"`, `"sql-dynamic-query"`).
21    /// Used in error messages and `camel doctor` output.
22    fn name(&self) -> &'static str;
23
24    /// One-line human description of what this check enforces.
25    fn description(&self) -> &'static str;
26
27    /// Run the check. Returns `Err(CamelError::Config(_))` to fail-closed.
28    fn run(&self) -> Result<(), CamelError>;
29}
30
31/// Aggregated result of running every registered `ConfigCheck`.
32///
33/// Batch 1 ships the type so later batches can populate `failures`. Skeleton
34/// phase always produces a `StartupValidationReport { failures: vec![] }`.
35#[derive(Debug, Default, Clone)]
36pub struct StartupValidationReport {
37    /// Names of every `ConfigCheck` that returned `Err`, in registration order.
38    pub failures: Vec<String>,
39}
40
41impl StartupValidationReport {
42    /// True when no check failed. Caller MUST refuse to start the runtime when
43    /// this is `false`.
44    pub fn is_ok(&self) -> bool {
45        self.failures.is_empty()
46    }
47}
48
49/// Entry point for the startup-validation phase.
50///
51/// Walks the supplied `ConfigCheck` registry, collects failures, and fails closed
52/// when any check returns `Err`. Established by ADR-0033.
53pub fn run_startup_validation(
54    checks: Vec<Box<dyn ConfigCheck>>,
55) -> Result<StartupValidationReport, CamelError> {
56    let mut report = StartupValidationReport::default();
57    for check in &checks {
58        if let Err(e) = check.run() {
59            report.failures.push(format!("{}: {}", check.name(), e));
60        }
61    }
62    if !report.is_ok() {
63        return Err(CamelError::Config(report.failures.join("; ")));
64    }
65    Ok(report)
66}
67
68/// ConfigCheck: SQL dynamic-query intent must match capability (H7).
69///
70/// If `use_message_body_for_sql=true` but `allow_dynamic_query=false`,
71/// the operator intent (use body as query) is blocked by the capability
72/// gate — this is a misconfiguration that would silently produce empty
73/// queries. Fail closed at startup instead.
74pub struct SqlDynamicQueryCheck {
75    pub use_message_body_for_sql: bool,
76    pub allow_dynamic_query: bool,
77}
78
79impl ConfigCheck for SqlDynamicQueryCheck {
80    fn name(&self) -> &'static str {
81        "sql-dynamic-query"
82    }
83    fn description(&self) -> &'static str {
84        "SQL use_message_body_for_sql requires allow_dynamic_query=true"
85    }
86    fn run(&self) -> Result<(), CamelError> {
87        if self.use_message_body_for_sql && !self.allow_dynamic_query {
88            return Err(CamelError::from(
89                ConfigValidationError::SqlDynamicQueryWithoutAllowDynamic,
90            ));
91        }
92        Ok(())
93    }
94}
95
96/// Scan a list of `RouteDefinition`s and emit a `SqlDynamicQueryCheck` for
97/// every `sql:` URI whose parameters declare the dynamic-query intent
98/// (`useMessageBodyForSql` / `allowDynamicQuery`).
99///
100/// Walked URIs: the route's `from_uri` plus every `BuilderStep::To` URI
101/// reachable through structural step variants (filter, choice, split,
102/// multicast, throttle, etc.). Dynamic-URI steps (routing slip, recipient
103/// list, dynamic router) are skipped — their URIs are runtime-resolved and
104/// cannot be statically validated.
105///
106/// Invalid URIs are silently ignored: this scanner is best-effort defense in
107/// depth, not a primary security gate. The authoritative runtime check
108/// (`SqlProducer::resolve_query_source`) closes the SQLi vector regardless
109/// of what the static scanner finds here.
110pub fn scan_route_definitions_for_sql_checks(
111    routes: &[RouteDefinition],
112) -> Vec<Box<dyn ConfigCheck>> {
113    let mut out: Vec<Box<dyn ConfigCheck>> = Vec::new();
114    for route in routes {
115        collect_sql_checks_for_uri(route.from_uri(), &mut out);
116        for step in route.steps() {
117            walk_step_uris(step, &mut out);
118        }
119    }
120    out
121}
122
123fn collect_sql_checks_for_uri(uri: &str, out: &mut Vec<Box<dyn ConfigCheck>>) {
124    let Ok(parts) = camel_endpoint::parse_uri(uri) else {
125        return;
126    };
127    if parts.scheme != "sql" {
128        return;
129    }
130    // Match the camel-sql parser: keys are case-preserved by parse_uri (it
131    // only decodes percent-encoding), and the SQL config looks them up by
132    // their camelCase name (`useMessageBodyForSql`, `allowDynamicQuery`).
133    let use_body = parts
134        .params
135        .get("useMessageBodyForSql")
136        .and_then(|v| parse_bool_param(v).ok())
137        .unwrap_or(false);
138    let allow_dynamic = parts
139        .params
140        .get("allowDynamicQuery")
141        .and_then(|v| parse_bool_param(v).ok())
142        .unwrap_or(false);
143    if use_body || allow_dynamic {
144        // Only emit a check when the operator declared an intent that
145        // participates in the matrix. Endpoints with neither flag set
146        // cannot be misconfigured in the H7 sense — skip the noise.
147        out.push(Box::new(SqlDynamicQueryCheck {
148            use_message_body_for_sql: use_body,
149            allow_dynamic_query: allow_dynamic,
150        }));
151    }
152}
153
154fn walk_step_uris(step: &BuilderStep, out: &mut Vec<Box<dyn ConfigCheck>>) {
155    match step {
156        BuilderStep::To(uri) => collect_sql_checks_for_uri(uri, out),
157        BuilderStep::WireTap { uri } | BuilderStep::Enrich { uri, .. } => {
158            collect_sql_checks_for_uri(uri, out);
159        }
160        BuilderStep::PollEnrich { uri, .. } => {
161            collect_sql_checks_for_uri(uri, out);
162        }
163        BuilderStep::Filter { steps, .. }
164        | BuilderStep::DeclarativeFilter { steps, .. }
165        | BuilderStep::Split { steps, .. }
166        | BuilderStep::DeclarativeSplit { steps, .. }
167        | BuilderStep::DeclarativeStreamSplit { steps, .. }
168        | BuilderStep::Multicast { steps, .. }
169        | BuilderStep::Throttle { steps, .. }
170        | BuilderStep::LoadBalance { steps, .. }
171        | BuilderStep::Loop { steps, .. }
172        | BuilderStep::DeclarativeLoop { steps, .. }
173        | BuilderStep::IdempotentConsumer { steps, .. } => {
174            for s in steps {
175                walk_step_uris(s, out);
176            }
177        }
178        BuilderStep::Choice { whens, otherwise } => {
179            for when in whens {
180                for s in &when.steps {
181                    walk_step_uris(s, out);
182                }
183            }
184            if let Some(ow) = otherwise {
185                for s in ow {
186                    walk_step_uris(s, out);
187                }
188            }
189        }
190        BuilderStep::DeclarativeChoice { whens, otherwise } => {
191            for when in whens {
192                for s in &when.steps {
193                    walk_step_uris(s, out);
194                }
195            }
196            if let Some(ow) = otherwise {
197                for s in ow {
198                    walk_step_uris(s, out);
199                }
200            }
201        }
202        BuilderStep::DeclarativeDoTry {
203            try_steps,
204            catch,
205            finally,
206        } => {
207            for s in try_steps {
208                walk_step_uris(s, out);
209            }
210            for clause in catch {
211                for s in &clause.steps {
212                    walk_step_uris(s, out);
213                }
214            }
215            if let Some(fin) = finally {
216                for s in &fin.steps {
217                    walk_step_uris(s, out);
218                }
219            }
220        }
221        // All remaining variants are process-mode (no static URI) or
222        // runtime-resolved dynamic URIs (routing slip, recipient list,
223        // dynamic router) — skip.
224        _ => {}
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    /// Smoke test: the empty registry returns `Ok` with an empty failure list.
233    #[test]
234    fn empty_registry_returns_empty_ok_report() {
235        let report = run_startup_validation(vec![]).expect("empty registry must return Ok");
236        assert!(report.is_ok());
237        assert!(report.failures.is_empty());
238    }
239
240    /// Trait smoke test: a trivial `ConfigCheck` impl can be defined by callers
241    /// and dispatched dynamically. This guards the trait surface against
242    /// accidental breaking changes between batches.
243    #[test]
244    fn trait_is_object_safe_and_dispatchable() {
245        struct AlwaysOk;
246        impl ConfigCheck for AlwaysOk {
247            fn name(&self) -> &'static str {
248                "always-ok"
249            }
250            fn description(&self) -> &'static str {
251                "always-ok skeleton check"
252            }
253            fn run(&self) -> Result<(), CamelError> {
254                Ok(())
255            }
256        }
257
258        let check: Box<dyn ConfigCheck> = Box::new(AlwaysOk);
259        assert_eq!(check.name(), "always-ok");
260        assert!(check.run().is_ok());
261    }
262
263    /// H7: SQL endpoint with `use_message_body_for_sql=true` and
264    /// `allow_dynamic_query=false` is a misconfiguration that would silently
265    /// produce empty queries — fail closed at startup.
266    #[test]
267    fn sql_dynamic_query_check_refuses_startup() {
268        let check = SqlDynamicQueryCheck {
269            use_message_body_for_sql: true,
270            allow_dynamic_query: false,
271        };
272        let result = run_startup_validation(vec![Box::new(check)]);
273        match result {
274            Err(CamelError::Config(msg)) => {
275                assert!(msg.contains("sql-dynamic-query"));
276                assert!(msg.contains("allow_dynamic_query"));
277            }
278            other => panic!("expected CamelError::Config, got {other:?}"),
279        }
280    }
281
282    /// H7: the inner `SqlDynamicQueryCheck::run()` returns the typed
283    /// `ConfigValidationError::SqlDynamicQueryWithoutAllowDynamic` so
284    /// operators can match on the variant directly. The outer
285    /// `run_startup_validation` wraps it into a `Config(String)` joined
286    /// report (asserted above); this test guards the typed inner path
287    /// so the promotion to typed errors (rc-r8fd) doesn't regress.
288    #[test]
289    fn sql_dynamic_query_check_run_returns_typed_error() {
290        let check = SqlDynamicQueryCheck {
291            use_message_body_for_sql: true,
292            allow_dynamic_query: false,
293        };
294        let result = check.run();
295        assert!(
296            matches!(
297                result,
298                Err(CamelError::ConfigValidation(
299                    ConfigValidationError::SqlDynamicQueryWithoutAllowDynamic,
300                ))
301            ),
302            "expected ConfigValidation(SqlDynamicQueryWithoutAllowDynamic), got: {result:?}"
303        );
304    }
305
306    /// H7: explicit opt-in satisfies the check — startup proceeds.
307    #[test]
308    fn sql_dynamic_query_check_passes_with_opt_in() {
309        let check = SqlDynamicQueryCheck {
310            use_message_body_for_sql: true,
311            allow_dynamic_query: true,
312        };
313        let report =
314            run_startup_validation(vec![Box::new(check)]).expect("opt-in must satisfy the check");
315        assert!(report.is_ok());
316    }
317
318    /// H7: when `use_message_body_for_sql=false`, the dynamic-query gate
319    /// is irrelevant and the check passes regardless of the opt-in flag.
320    #[test]
321    fn sql_dynamic_query_check_passes_without_body_sql() {
322        let check = SqlDynamicQueryCheck {
323            use_message_body_for_sql: false,
324            allow_dynamic_query: false,
325        };
326        let report = run_startup_validation(vec![Box::new(check)])
327            .expect("no body-sourced queries → no fail-closed condition");
328        assert!(report.is_ok());
329    }
330
331    /// Scanner: a route with a `from: sql:...` URI whose params declare
332    /// `useMessageBodyForSql=true` but no `allowDynamicQuery` must produce
333    /// a failing `SqlDynamicQueryCheck`.
334    #[test]
335    fn scanner_flags_from_sql_endpoint_with_body_but_no_allow() {
336        let route = RouteDefinition::new(
337            "sql:select * from t?db_url=postgres://x/y&useMessageBodyForSql=true",
338            vec![],
339        )
340        .with_route_id("r".to_string());
341        let checks = scan_route_definitions_for_sql_checks(&[route]);
342        assert_eq!(checks.len(), 1);
343        assert!(run_startup_validation(checks).is_err());
344    }
345
346    /// Scanner: a route with a `to: sql:...` step in the top-level step
347    /// vector is found.
348    #[test]
349    fn scanner_walks_top_level_to_sql_step() {
350        let route = RouteDefinition::new(
351            "direct:start",
352            vec![BuilderStep::To(
353                "sql:select 1?db_url=postgres://x/y&useMessageBodyForSql=true".to_string(),
354            )],
355        )
356        .with_route_id("r".to_string());
357        let checks = scan_route_definitions_for_sql_checks(&[route]);
358        assert_eq!(checks.len(), 1);
359        assert!(run_startup_validation(checks).is_err());
360    }
361
362    /// Scanner: a route with no SQL URIs produces no checks.
363    #[test]
364    fn scanner_emits_nothing_for_non_sql_route() {
365        let route = RouteDefinition::new("timer:tick?period=1000", vec![]);
366        let checks = scan_route_definitions_for_sql_checks(&[route]);
367        assert!(checks.is_empty());
368    }
369
370    /// Scanner: a SQL endpoint without the body-mode flag is not flagged
371    /// (the operator has not opted into dynamic queries, so there is no
372    /// intent to validate against the capability gate).
373    #[test]
374    fn scanner_skips_sql_endpoint_without_dynamic_intent() {
375        let route = RouteDefinition::new("sql:select 1?db_url=postgres://x/y", vec![]);
376        let checks = scan_route_definitions_for_sql_checks(&[route]);
377        assert!(checks.is_empty());
378    }
379}