use apl_core::rules::{CompiledRoute, Effect};
use cpex_core::manager::PluginManager;
use cpex_core::plugin::PluginMode;
pub trait PluginModeLookup {
fn mode_for(&self, name: &str) -> Option<PluginMode>;
}
impl PluginModeLookup for PluginManager {
fn mode_for(&self, name: &str) -> Option<PluginMode> {
self.get_plugin(name).map(|p| p.mode())
}
}
pub fn validate_parallel_plugin_modes<L: PluginModeLookup + ?Sized>(
route: &CompiledRoute,
registry: &L,
) -> Result<(), String> {
let mut errors: Vec<String> = Vec::new();
for (phase_name, effects) in [
("pre_invocation", route.policy.as_slice()),
("post_invocation", route.post_policy.as_slice()),
] {
for (idx, effect) in effects.iter().enumerate() {
walk_effect(
effect,
&format!("routes.{}.{}[{}]", route.route_key, phase_name, idx),
false,
registry,
&mut errors,
);
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors.join("; "))
}
}
fn walk_effect<L: PluginModeLookup + ?Sized>(
effect: &Effect,
location: &str,
under_parallel: bool,
registry: &L,
errors: &mut Vec<String>,
) {
match effect {
Effect::Plugin { name } if under_parallel => {
check_plugin_mode(name, location, registry, errors);
},
Effect::Parallel(inner) => {
for e in inner {
walk_effect(e, location, true, registry, errors);
}
},
Effect::Sequential(inner) => {
for e in inner {
walk_effect(e, location, under_parallel, registry, errors);
}
},
Effect::When { body, .. } => {
for e in body {
walk_effect(e, location, under_parallel, registry, errors);
}
},
Effect::Pdp {
on_allow, on_deny, ..
} => {
for e in on_allow.iter().chain(on_deny.iter()) {
walk_effect(e, location, under_parallel, registry, errors);
}
},
_ => {},
}
}
fn check_plugin_mode<L: PluginModeLookup + ?Sized>(
name: &str,
location: &str,
registry: &L,
errors: &mut Vec<String>,
) {
let mode = match registry.mode_for(name) {
Some(m) => m,
None => {
errors.push(format!(
"{}: `parallel:` references unknown plugin `{}`",
location, name
));
return;
},
};
if !is_safe_in_parallel(mode) {
errors.push(format!(
"{}: plugin `{}` has mode `{}` which can modify state; parallel \
branches discard mutations, so this would silently lose its effect. \
Use `sequential:` for ordered mutations or change the plugin's mode.",
location, name, mode,
));
}
}
fn is_safe_in_parallel(mode: PluginMode) -> bool {
matches!(
mode,
PluginMode::Audit
| PluginMode::Concurrent
| PluginMode::FireAndForget
| PluginMode::Disabled
)
}
#[cfg(test)]
mod tests {
use super::*;
use apl_core::rules::Expression;
use std::collections::HashMap;
struct MockLookup(HashMap<String, PluginMode>);
impl MockLookup {
fn new() -> Self {
Self(HashMap::new())
}
fn with(mut self, name: &str, mode: PluginMode) -> Self {
self.0.insert(name.to_string(), mode);
self
}
}
impl PluginModeLookup for MockLookup {
fn mode_for(&self, name: &str) -> Option<PluginMode> {
self.0.get(name).copied()
}
}
fn route_with_policy(effects: Vec<Effect>) -> CompiledRoute {
let mut r = CompiledRoute::new("test_route");
r.policy = effects;
r
}
fn rule(effects: Vec<Effect>) -> Effect {
Effect::When {
condition: Expression::Always,
body: effects,
source: "test".into(),
}
}
fn parallel_plugin(name: &str) -> Effect {
Effect::Parallel(vec![Effect::Plugin { name: name.into() }])
}
#[test]
fn audit_plugin_in_parallel_is_accepted() {
let reg = MockLookup::new().with("audit_logger", PluginMode::Audit);
let route = route_with_policy(vec![rule(vec![parallel_plugin("audit_logger")])]);
assert!(validate_parallel_plugin_modes(&route, ®).is_ok());
}
#[test]
fn concurrent_plugin_in_parallel_is_accepted() {
let reg = MockLookup::new().with("pii_scanner", PluginMode::Concurrent);
let route = route_with_policy(vec![rule(vec![parallel_plugin("pii_scanner")])]);
assert!(validate_parallel_plugin_modes(&route, ®).is_ok());
}
#[test]
fn fire_and_forget_in_parallel_is_accepted() {
let reg = MockLookup::new().with("metrics", PluginMode::FireAndForget);
let route = route_with_policy(vec![rule(vec![parallel_plugin("metrics")])]);
assert!(validate_parallel_plugin_modes(&route, ®).is_ok());
}
#[test]
fn sequential_plugin_in_parallel_is_rejected() {
let reg = MockLookup::new().with("mutator", PluginMode::Sequential);
let route = route_with_policy(vec![rule(vec![parallel_plugin("mutator")])]);
let err = validate_parallel_plugin_modes(&route, ®).unwrap_err();
assert!(err.contains("mutator"), "names plugin: {}", err);
assert!(err.contains("sequential"), "names mode: {}", err);
assert!(err.contains("`sequential:`"), "suggests fix: {}", err);
}
#[test]
fn transform_plugin_in_parallel_is_rejected() {
let reg = MockLookup::new().with("redactor", PluginMode::Transform);
let route = route_with_policy(vec![rule(vec![parallel_plugin("redactor")])]);
let err = validate_parallel_plugin_modes(&route, ®).unwrap_err();
assert!(err.contains("transform"));
}
#[test]
fn unknown_plugin_in_parallel_is_rejected() {
let reg = MockLookup::new();
let route = route_with_policy(vec![rule(vec![parallel_plugin("ghost")])]);
let err = validate_parallel_plugin_modes(&route, ®).unwrap_err();
assert!(err.contains("unknown plugin"));
assert!(err.contains("ghost"));
}
#[test]
fn sequential_plugin_outside_parallel_is_allowed() {
let reg = MockLookup::new().with("mutator", PluginMode::Sequential);
let route = route_with_policy(vec![rule(vec![Effect::Plugin {
name: "mutator".into(),
}])]);
assert!(validate_parallel_plugin_modes(&route, ®).is_ok());
}
#[test]
fn nested_sequential_inside_parallel_still_validates_plugins() {
let reg = MockLookup::new().with("mutator", PluginMode::Sequential);
let route = route_with_policy(vec![rule(vec![Effect::Parallel(vec![
Effect::Sequential(vec![Effect::Plugin {
name: "mutator".into(),
}]),
])])]);
let err = validate_parallel_plugin_modes(&route, ®).unwrap_err();
assert!(err.contains("mutator"));
}
#[test]
fn multiple_violations_all_reported() {
let reg = MockLookup::new()
.with("a", PluginMode::Sequential)
.with("b", PluginMode::Transform);
let route = route_with_policy(vec![rule(vec![Effect::Parallel(vec![
Effect::Plugin { name: "a".into() },
Effect::Plugin { name: "b".into() },
])])]);
let err = validate_parallel_plugin_modes(&route, ®).unwrap_err();
assert!(err.contains("`a`"), "names a: {}", err);
assert!(err.contains("`b`"), "names b: {}", err);
}
#[test]
fn post_policy_phase_is_validated_too() {
let reg = MockLookup::new().with("mutator", PluginMode::Sequential);
let mut route = CompiledRoute::new("test_route");
route.post_policy = vec![rule(vec![parallel_plugin("mutator")])];
let err = validate_parallel_plugin_modes(&route, ®).unwrap_err();
assert!(err.contains("post_invocation"));
}
}