1use apl_core::rules::{CompiledRoute, Effect};
43use cpex_core::manager::PluginManager;
44use cpex_core::plugin::PluginMode;
45
46pub trait PluginModeLookup {
56 fn mode_for(&self, name: &str) -> Option<PluginMode>;
59}
60
61impl PluginModeLookup for PluginManager {
62 fn mode_for(&self, name: &str) -> Option<PluginMode> {
63 self.get_plugin(name).map(|p| p.mode())
64 }
65}
66
67pub fn validate_parallel_plugin_modes<L: PluginModeLookup + ?Sized>(
77 route: &CompiledRoute,
78 registry: &L,
79) -> Result<(), String> {
80 let mut errors: Vec<String> = Vec::new();
81 for (phase_name, effects) in [
82 ("pre_invocation", route.policy.as_slice()),
83 ("post_invocation", route.post_policy.as_slice()),
84 ] {
85 for (idx, effect) in effects.iter().enumerate() {
86 walk_effect(
87 effect,
88 &format!("routes.{}.{}[{}]", route.route_key, phase_name, idx),
89 false,
90 registry,
91 &mut errors,
92 );
93 }
94 }
95 if errors.is_empty() {
96 Ok(())
97 } else {
98 Err(errors.join("; "))
99 }
100}
101
102fn walk_effect<L: PluginModeLookup + ?Sized>(
108 effect: &Effect,
109 location: &str,
110 under_parallel: bool,
111 registry: &L,
112 errors: &mut Vec<String>,
113) {
114 match effect {
115 Effect::Plugin { name } if under_parallel => {
116 check_plugin_mode(name, location, registry, errors);
117 },
118 Effect::Parallel(inner) => {
119 for e in inner {
120 walk_effect(e, location, true, registry, errors);
121 }
122 },
123 Effect::Sequential(inner) => {
124 for e in inner {
125 walk_effect(e, location, under_parallel, registry, errors);
126 }
127 },
128 Effect::When { body, .. } => {
129 for e in body {
133 walk_effect(e, location, under_parallel, registry, errors);
134 }
135 },
136 Effect::Pdp {
137 on_allow, on_deny, ..
138 } => {
139 for e in on_allow.iter().chain(on_deny.iter()) {
140 walk_effect(e, location, under_parallel, registry, errors);
141 }
142 },
143 _ => {},
149 }
150}
151
152fn check_plugin_mode<L: PluginModeLookup + ?Sized>(
153 name: &str,
154 location: &str,
155 registry: &L,
156 errors: &mut Vec<String>,
157) {
158 let mode = match registry.mode_for(name) {
159 Some(m) => m,
160 None => {
161 errors.push(format!(
162 "{}: `parallel:` references unknown plugin `{}`",
163 location, name
164 ));
165 return;
166 },
167 };
168 if !is_safe_in_parallel(mode) {
169 errors.push(format!(
170 "{}: plugin `{}` has mode `{}` which can modify state; parallel \
171 branches discard mutations, so this would silently lose its effect. \
172 Use `sequential:` for ordered mutations or change the plugin's mode.",
173 location, name, mode,
174 ));
175 }
176}
177
178fn is_safe_in_parallel(mode: PluginMode) -> bool {
181 matches!(
182 mode,
183 PluginMode::Audit
184 | PluginMode::Concurrent
185 | PluginMode::FireAndForget
186 | PluginMode::Disabled
187 )
188}
189
190#[cfg(test)]
195mod tests {
196 use super::*;
197 use apl_core::rules::Expression;
198 use std::collections::HashMap;
199
200 struct MockLookup(HashMap<String, PluginMode>);
204
205 impl MockLookup {
206 fn new() -> Self {
207 Self(HashMap::new())
208 }
209 fn with(mut self, name: &str, mode: PluginMode) -> Self {
210 self.0.insert(name.to_string(), mode);
211 self
212 }
213 }
214
215 impl PluginModeLookup for MockLookup {
216 fn mode_for(&self, name: &str) -> Option<PluginMode> {
217 self.0.get(name).copied()
218 }
219 }
220
221 fn route_with_policy(effects: Vec<Effect>) -> CompiledRoute {
222 let mut r = CompiledRoute::new("test_route");
223 r.policy = effects;
224 r
225 }
226
227 fn rule(effects: Vec<Effect>) -> Effect {
228 Effect::When {
229 condition: Expression::Always,
230 body: effects,
231 source: "test".into(),
232 }
233 }
234
235 fn parallel_plugin(name: &str) -> Effect {
236 Effect::Parallel(vec![Effect::Plugin { name: name.into() }])
237 }
238
239 #[test]
242 fn audit_plugin_in_parallel_is_accepted() {
243 let reg = MockLookup::new().with("audit_logger", PluginMode::Audit);
244 let route = route_with_policy(vec![rule(vec![parallel_plugin("audit_logger")])]);
245 assert!(validate_parallel_plugin_modes(&route, ®).is_ok());
246 }
247
248 #[test]
249 fn concurrent_plugin_in_parallel_is_accepted() {
250 let reg = MockLookup::new().with("pii_scanner", PluginMode::Concurrent);
251 let route = route_with_policy(vec![rule(vec![parallel_plugin("pii_scanner")])]);
252 assert!(validate_parallel_plugin_modes(&route, ®).is_ok());
253 }
254
255 #[test]
256 fn fire_and_forget_in_parallel_is_accepted() {
257 let reg = MockLookup::new().with("metrics", PluginMode::FireAndForget);
258 let route = route_with_policy(vec![rule(vec![parallel_plugin("metrics")])]);
259 assert!(validate_parallel_plugin_modes(&route, ®).is_ok());
260 }
261
262 #[test]
265 fn sequential_plugin_in_parallel_is_rejected() {
266 let reg = MockLookup::new().with("mutator", PluginMode::Sequential);
267 let route = route_with_policy(vec![rule(vec![parallel_plugin("mutator")])]);
268 let err = validate_parallel_plugin_modes(&route, ®).unwrap_err();
269 assert!(err.contains("mutator"), "names plugin: {}", err);
270 assert!(err.contains("sequential"), "names mode: {}", err);
271 assert!(err.contains("`sequential:`"), "suggests fix: {}", err);
272 }
273
274 #[test]
275 fn transform_plugin_in_parallel_is_rejected() {
276 let reg = MockLookup::new().with("redactor", PluginMode::Transform);
277 let route = route_with_policy(vec![rule(vec![parallel_plugin("redactor")])]);
278 let err = validate_parallel_plugin_modes(&route, ®).unwrap_err();
279 assert!(err.contains("transform"));
280 }
281
282 #[test]
283 fn unknown_plugin_in_parallel_is_rejected() {
284 let reg = MockLookup::new();
285 let route = route_with_policy(vec![rule(vec![parallel_plugin("ghost")])]);
286 let err = validate_parallel_plugin_modes(&route, ®).unwrap_err();
287 assert!(err.contains("unknown plugin"));
288 assert!(err.contains("ghost"));
289 }
290
291 #[test]
294 fn sequential_plugin_outside_parallel_is_allowed() {
295 let reg = MockLookup::new().with("mutator", PluginMode::Sequential);
298 let route = route_with_policy(vec![rule(vec![Effect::Plugin {
299 name: "mutator".into(),
300 }])]);
301 assert!(validate_parallel_plugin_modes(&route, ®).is_ok());
302 }
303
304 #[test]
305 fn nested_sequential_inside_parallel_still_validates_plugins() {
306 let reg = MockLookup::new().with("mutator", PluginMode::Sequential);
310 let route = route_with_policy(vec![rule(vec![Effect::Parallel(vec![
311 Effect::Sequential(vec![Effect::Plugin {
312 name: "mutator".into(),
313 }]),
314 ])])]);
315 let err = validate_parallel_plugin_modes(&route, ®).unwrap_err();
316 assert!(err.contains("mutator"));
317 }
318
319 #[test]
322 fn multiple_violations_all_reported() {
323 let reg = MockLookup::new()
326 .with("a", PluginMode::Sequential)
327 .with("b", PluginMode::Transform);
328 let route = route_with_policy(vec![rule(vec![Effect::Parallel(vec![
329 Effect::Plugin { name: "a".into() },
330 Effect::Plugin { name: "b".into() },
331 ])])]);
332 let err = validate_parallel_plugin_modes(&route, ®).unwrap_err();
333 assert!(err.contains("`a`"), "names a: {}", err);
334 assert!(err.contains("`b`"), "names b: {}", err);
335 }
336
337 #[test]
338 fn post_policy_phase_is_validated_too() {
339 let reg = MockLookup::new().with("mutator", PluginMode::Sequential);
340 let mut route = CompiledRoute::new("test_route");
341 route.post_policy = vec![rule(vec![parallel_plugin("mutator")])];
342 let err = validate_parallel_plugin_modes(&route, ®).unwrap_err();
343 assert!(err.contains("post_invocation"));
344 }
345}