1use std::hash::{Hash, Hasher};
28use std::path::Path;
29use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
30use std::sync::{Arc, RwLock};
31
32use hashbrown::HashMap;
33use tracing::{error, info, warn};
34
35use crate::config::{self, CpexConfig};
36use crate::context::PluginContextTable;
37use crate::error::PluginError;
38use crate::executor::{BackgroundTasks, Executor, ExecutorConfig, PipelineResult};
39use crate::factory::PluginFactoryRegistry;
40use crate::hooks::adapter::TypedHandlerAdapter;
41use crate::hooks::payload::{Extensions, PluginPayload};
42use crate::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult};
43use crate::hooks::HookType;
44use crate::plugin::{Plugin, PluginConfig};
45use crate::registry::{AnyHookHandler, PluginRef, PluginRegistry};
46
47pub const DEFAULT_ROUTE_CACHE_MAX_ENTRIES: usize = 10_000;
54
55#[derive(Debug, Clone)]
57pub struct ManagerConfig {
58 pub executor: ExecutorConfig,
60
61 pub route_cache_max_entries: usize,
66}
67
68impl Default for ManagerConfig {
69 fn default() -> Self {
70 Self {
71 executor: ExecutorConfig::default(),
72 route_cache_max_entries: DEFAULT_ROUTE_CACHE_MAX_ENTRIES,
73 }
74 }
75}
76
77#[derive(Debug, Clone)]
120struct RouteCacheKey {
121 entity_type: String,
122 entity_name: String,
123 hook_name: String,
124 scope: Option<String>,
125}
126
127impl Hash for RouteCacheKey {
128 fn hash<H: Hasher>(&self, state: &mut H) {
129 self.entity_type.as_str().hash(state);
130 self.entity_name.as_str().hash(state);
131 self.hook_name.as_str().hash(state);
132 self.scope.as_deref().hash(state);
133 }
134}
135
136impl PartialEq for RouteCacheKey {
137 fn eq(&self, other: &Self) -> bool {
138 self.entity_type == other.entity_type
139 && self.entity_name == other.entity_name
140 && self.hook_name == other.hook_name
141 && self.scope == other.scope
142 }
143}
144
145impl Eq for RouteCacheKey {}
146
147#[derive(Clone)]
160struct RuntimeSnapshot {
161 registry: PluginRegistry,
163
164 executor: Executor,
166
167 cpex_config: Option<CpexConfig>,
169
170 route_cache_max_entries: usize,
173
174 route_annotations: HashMap<AnnotationKey, crate::registry::HookEntry>,
202}
203
204#[derive(Debug, Clone, Hash, PartialEq, Eq)]
208struct AnnotationKey {
209 entity_type: String,
210 entity_name: String,
211 scope: Option<String>,
212 hook_name: String,
213}
214
215pub struct PluginManager {
216 runtime: arc_swap::ArcSwap<RuntimeSnapshot>,
219
220 factories: RwLock<PluginFactoryRegistry>,
229
230 route_cache: RwLock<HashMap<RouteCacheKey, Arc<Vec<crate::registry::HookEntry>>>>,
234
235 cache_hasher: hashbrown::DefaultHashBuilder,
237
238 route_cache_full_warned: AtomicBool,
242
243 initialized: AtomicBool,
246
247 generation: AtomicU64,
255
256 task_tracker: tokio_util::task::TaskTracker,
265
266 visitors: RwLock<Vec<Arc<dyn crate::visitor::ConfigVisitor>>>,
272}
273
274fn warn_on_inactive_settings(cfg: &CpexConfig) {
281 if !cfg.plugin_dirs.is_empty() {
282 warn!(
283 "config sets `plugin_dirs` (count={}) but the runtime does not \
284 scan directories for plugins — plugins must be registered via \
285 `register_factory()` and listed under `plugins:`. Setting ignored.",
286 cfg.plugin_dirs.len(),
287 );
288 }
289 if cfg.plugin_settings.parallel_execution_within_band {
290 warn!(
291 "config sets `plugin_settings.parallel_execution_within_band: true` \
292 but the runtime does not honor it — use `mode: concurrent` on \
293 individual plugins for parallel execution. Setting ignored.",
294 );
295 }
296 if cfg.plugin_settings.fail_on_plugin_error {
297 warn!(
298 "config sets `plugin_settings.fail_on_plugin_error: true` but the \
299 runtime does not honor it — use per-plugin `on_error: fail` for \
300 that behavior. Setting ignored.",
301 );
302 }
303}
304
305fn instantiate_plugins_into(
316 target_registry: &mut PluginRegistry,
317 plugin_configs: &[crate::plugin::PluginConfig],
318 factories: &PluginFactoryRegistry,
319) -> Result<(), Box<PluginError>> {
320 for plugin_config in plugin_configs {
321 let factory = factories
322 .get(&plugin_config.kind)
323 .ok_or_else(|| PluginError::Config {
324 message: format!(
325 "no factory registered for plugin kind '{}' (plugin '{}')",
326 plugin_config.kind, plugin_config.name
327 ),
328 })?;
329
330 let instance = factory.create(plugin_config)?;
331
332 target_registry
333 .register_multi_handler(instance.plugin, plugin_config.clone(), instance.handlers)
334 .map_err(|msg| Box::new(PluginError::Config { message: msg }))?;
335
336 info!(
337 "Registered plugin '{}' (kind: '{}') for hooks: {:?}",
338 plugin_config.name, plugin_config.kind, plugin_config.hooks
339 );
340 }
341 Ok(())
342}
343
344fn snapshot_from_config(registry: PluginRegistry, cpex_config: CpexConfig) -> RuntimeSnapshot {
349 let executor = Executor::new(ExecutorConfig {
350 timeout_seconds: cpex_config.plugin_settings.plugin_timeout,
351 short_circuit_on_deny: cpex_config.plugin_settings.short_circuit_on_deny,
352 });
353 let route_cache_max_entries = cpex_config.plugin_settings.route_cache_max_entries;
354 RuntimeSnapshot {
355 registry,
356 executor,
357 cpex_config: Some(cpex_config),
358 route_cache_max_entries,
359 route_annotations: HashMap::new(),
360 }
361}
362
363impl PluginManager {
364 pub fn new(config: ManagerConfig) -> Self {
366 let cache_hasher = hashbrown::DefaultHashBuilder::default();
367 let snapshot = RuntimeSnapshot {
368 registry: PluginRegistry::new(),
369 executor: Executor::new(config.executor),
370 cpex_config: None,
371 route_cache_max_entries: config.route_cache_max_entries,
372 route_annotations: HashMap::new(),
373 };
374 Self {
375 runtime: arc_swap::ArcSwap::from_pointee(snapshot),
376 factories: RwLock::new(PluginFactoryRegistry::new()),
377 route_cache: RwLock::new(HashMap::with_hasher(cache_hasher.clone())),
378 cache_hasher,
379 route_cache_full_warned: AtomicBool::new(false),
380 initialized: AtomicBool::new(false),
381 generation: AtomicU64::new(0),
382 task_tracker: tokio_util::task::TaskTracker::new(),
383 visitors: RwLock::new(Vec::new()),
384 }
385 }
386
387 fn load_runtime(&self) -> Arc<RuntimeSnapshot> {
389 self.runtime.load_full()
390 }
391
392 fn mutate_runtime<F, R>(&self, f: F) -> R
397 where
398 F: FnOnce(&mut RuntimeSnapshot) -> R,
399 {
400 let current = self.runtime.load_full();
401 let mut next = (*current).clone();
402 let result = f(&mut next);
403 self.runtime.store(Arc::new(next));
404 self.generation.fetch_add(1, Ordering::Release);
408 result
409 }
410
411 fn try_mutate_runtime<F, T, E>(&self, f: F) -> Result<T, E>
415 where
416 F: FnOnce(&mut RuntimeSnapshot) -> Result<T, E>,
417 {
418 let current = self.runtime.load_full();
419 let mut next = (*current).clone();
420 let result = f(&mut next)?;
421 self.runtime.store(Arc::new(next));
422 self.generation.fetch_add(1, Ordering::Release);
425 Ok(result)
426 }
427
428 pub fn config_generation(&self) -> u64 {
436 self.generation.load(Ordering::Acquire)
437 }
438
439 pub fn register_factory(
457 &self,
458 kind: impl Into<String>,
459 factory: Box<dyn crate::factory::PluginFactory>,
460 ) {
461 self.factories
462 .write()
463 .unwrap_or_else(|p| p.into_inner())
464 .register(kind, factory);
465 }
466
467 pub fn load_config_file(&self, path: &Path) -> Result<(), Box<PluginError>> {
487 let cpex_config = config::load_config(path)?;
488 self.load_config(cpex_config)
489 }
490
491 pub fn load_config(&self, cpex_config: CpexConfig) -> Result<(), Box<PluginError>> {
497 warn_on_inactive_settings(&cpex_config);
498
499 let factories = self.factories.read().unwrap_or_else(|p| p.into_inner());
505 let current = self.runtime.load_full();
506 let mut new_registry = current.registry.clone();
507
508 instantiate_plugins_into(&mut new_registry, &cpex_config.plugins, &factories)?;
509
510 drop(factories);
513
514 self.runtime
515 .store(Arc::new(snapshot_from_config(new_registry, cpex_config)));
516 self.generation.fetch_add(1, Ordering::Release);
520
521 self.clear_routing_cache();
523
524 Ok(())
525 }
526
527 pub fn register_visitor(&self, visitor: Arc<dyn crate::visitor::ConfigVisitor>) {
533 let mut v = self.visitors.write().unwrap_or_else(|p| p.into_inner());
534 v.push(visitor);
535 }
536
537 pub fn load_config_yaml(self: &Arc<Self>, yaml: &str) -> Result<(), Box<PluginError>> {
557 let raw: serde_yaml::Value = serde_yaml::from_str(yaml).map_err(|e| {
561 Box::new(PluginError::Config {
562 message: format!("YAML parse error: {}", e),
563 })
564 })?;
565 let cpex_config: CpexConfig = serde_yaml::from_value(raw.clone()).map_err(|e| {
566 Box::new(PluginError::Config {
567 message: format!("CpexConfig deserialize error: {}", e),
568 })
569 })?;
570
571 let parsed_routes: Vec<crate::config::RouteEntry> = cpex_config.routes.clone();
576 let parsed_plugins: Vec<crate::plugin::PluginConfig> = cpex_config.plugins.clone();
577
578 self.load_config(cpex_config)?;
580
581 let visitors = {
584 let v = self.visitors.read().unwrap_or_else(|p| p.into_inner());
585 if v.is_empty() {
586 return Ok(());
587 }
588 v.clone()
589 };
590
591 let mgr: Arc<PluginManager> = Arc::clone(self);
592 let global_yaml = raw
593 .get("global")
594 .cloned()
595 .unwrap_or(serde_yaml::Value::Null);
596 let defaults_yaml = global_yaml
597 .get("defaults")
598 .and_then(serde_yaml::Value::as_mapping)
599 .cloned();
600 let policies_yaml = global_yaml
601 .get("policies")
602 .and_then(serde_yaml::Value::as_mapping)
603 .cloned();
604 let routes_yaml: Vec<serde_yaml::Value> = raw
605 .get("routes")
606 .and_then(serde_yaml::Value::as_sequence)
607 .cloned()
608 .unwrap_or_default();
609
610 for visitor in &visitors {
611 visitor.visit_plugins(&mgr, &parsed_plugins).map_err(|e| {
612 Box::new(PluginError::Config {
613 message: format!("visitor '{}' visit_plugins: {}", visitor.name(), e),
614 })
615 })?;
616
617 visitor.visit_global(&mgr, &global_yaml).map_err(|e| {
618 Box::new(PluginError::Config {
619 message: format!("visitor '{}' visit_global: {}", visitor.name(), e),
620 })
621 })?;
622
623 if let Some(defaults) = &defaults_yaml {
624 for (k, v) in defaults {
625 let Some(entity_type) = k.as_str() else {
626 continue;
627 };
628 visitor.visit_default(&mgr, entity_type, v).map_err(|e| {
629 Box::new(PluginError::Config {
630 message: format!(
631 "visitor '{}' visit_default('{}'): {}",
632 visitor.name(),
633 entity_type,
634 e
635 ),
636 })
637 })?;
638 }
639 }
640
641 if let Some(policies) = &policies_yaml {
642 for (k, v) in policies {
643 let Some(tag) = k.as_str() else { continue };
644 visitor.visit_policy_bundle(&mgr, tag, v).map_err(|e| {
645 Box::new(PluginError::Config {
646 message: format!(
647 "visitor '{}' visit_policy_bundle('{}'): {}",
648 visitor.name(),
649 tag,
650 e
651 ),
652 })
653 })?;
654 }
655 }
656
657 for (i, parsed) in parsed_routes.iter().enumerate() {
658 let route_yaml = routes_yaml
659 .get(i)
660 .cloned()
661 .unwrap_or(serde_yaml::Value::Null);
662 visitor
663 .visit_route(&mgr, &route_yaml, parsed)
664 .map_err(|e| {
665 Box::new(PluginError::Config {
666 message: format!(
667 "visitor '{}' visit_route[{}]: {}",
668 visitor.name(),
669 i,
670 e
671 ),
672 })
673 })?;
674 }
675 }
676
677 Ok(())
678 }
679
680 pub fn from_config(
687 cpex_config: CpexConfig,
688 factories: &PluginFactoryRegistry,
689 ) -> Result<Self, Box<PluginError>> {
690 warn_on_inactive_settings(&cpex_config);
691
692 let manager = Self::new(ManagerConfig {
693 executor: ExecutorConfig::default(),
694 route_cache_max_entries: cpex_config.plugin_settings.route_cache_max_entries,
695 });
696
697 let mut new_registry = PluginRegistry::new();
699 instantiate_plugins_into(&mut new_registry, &cpex_config.plugins, factories)?;
700
701 manager
702 .runtime
703 .store(Arc::new(snapshot_from_config(new_registry, cpex_config)));
704
705 Ok(manager)
706 }
707
708 pub fn register_handler<H, P>(
733 &self,
734 plugin: Arc<P>,
735 config: PluginConfig,
736 ) -> Result<(), Box<PluginError>>
737 where
738 H: HookTypeDef,
739 H::Result: Into<PluginResult<H::Payload>>,
740 P: Plugin + HookHandler<H> + 'static,
741 {
742 let handler: Arc<dyn AnyHookHandler> =
743 Arc::new(TypedHandlerAdapter::<H, P>::new(Arc::clone(&plugin)));
744 self.try_mutate_runtime(|snap| {
745 snap.registry
746 .register::<H>(plugin, config, handler)
747 .map_err(|msg| Box::new(PluginError::Config { message: msg }))
748 })?;
749 self.clear_routing_cache();
750 Ok(())
751 }
752
753 pub fn register_handler_for_names<H, P>(
767 &self,
768 plugin: Arc<P>,
769 config: PluginConfig,
770 names: &[&str],
771 ) -> Result<(), Box<PluginError>>
772 where
773 H: HookTypeDef,
774 H::Result: Into<PluginResult<H::Payload>>,
775 P: Plugin + HookHandler<H> + 'static,
776 {
777 let handler: Arc<dyn AnyHookHandler> =
778 Arc::new(TypedHandlerAdapter::<H, P>::new(Arc::clone(&plugin)));
779 self.try_mutate_runtime(|snap| {
780 snap.registry
781 .register_for_names::<H>(plugin, config, handler, names)
782 .map_err(|msg| Box::new(PluginError::Config { message: msg }))
783 })?;
784 self.clear_routing_cache();
785 Ok(())
786 }
787
788 pub fn register_raw<H: HookTypeDef>(
794 &self,
795 plugin: Arc<dyn Plugin>,
796 config: PluginConfig,
797 handler: Arc<dyn AnyHookHandler>,
798 ) -> Result<(), Box<PluginError>> {
799 self.try_mutate_runtime(|snap| {
800 snap.registry
801 .register::<H>(plugin, config, handler)
802 .map_err(|msg| Box::new(PluginError::Config { message: msg }))
803 })?;
804 self.clear_routing_cache();
805 Ok(())
806 }
807
808 pub async fn initialize(&self) -> Result<(), Box<PluginError>> {
818 if self.initialized.load(Ordering::Acquire) {
819 return Ok(());
820 }
821
822 let snapshot = self.load_runtime();
825
826 info!(
827 "Initializing PluginManager with {} plugins",
828 snapshot.registry.plugin_count()
829 );
830
831 let mut initialized_plugins: Vec<String> = Vec::new();
832
833 for name in snapshot.registry.plugin_names() {
834 if let Some(plugin_ref) = snapshot.registry.get(&name) {
835 let plugin = plugin_ref.plugin().clone();
836 let plugin_name = name;
837
838 if let Err(e) = plugin.initialize().await {
839 error!("Failed to initialize plugin '{}': {}", plugin_name, e);
840
841 for init_name in initialized_plugins.iter().rev() {
843 if let Some(pr) = snapshot.registry.get(init_name) {
844 if let Err(shutdown_err) = pr.plugin().shutdown().await {
845 error!(
846 "Error shutting down plugin '{}' during rollback: {}",
847 init_name, shutdown_err
848 );
849 }
850 }
851 }
852
853 return Err(Box::new(PluginError::Execution {
854 plugin_name,
855 message: format!("initialization failed: {}", e),
856 source: Some(Box::new(e)),
857 code: None,
858 details: std::collections::HashMap::new(),
859 proto_error_code: None,
860 }));
861 }
862
863 initialized_plugins.push(plugin_name);
864 }
865 }
866
867 self.initialized.store(true, Ordering::Release);
868 info!("PluginManager initialized successfully");
869 Ok(())
870 }
871
872 pub async fn shutdown(&self) {
882 if !self.initialized.load(Ordering::Acquire) {
883 return;
884 }
885
886 info!("Shutting down PluginManager");
887
888 self.task_tracker.close();
895 self.task_tracker.wait().await;
896
897 let snapshot = self.load_runtime();
898 for name in snapshot.registry.plugin_names() {
899 if let Some(plugin_ref) = snapshot.registry.get(&name) {
900 let plugin = plugin_ref.plugin().clone();
901
902 if let Err(e) = plugin.shutdown().await {
903 error!("Error shutting down plugin '{}': {}", name, e);
904 }
906 }
907 }
908
909 self.initialized.store(false, Ordering::Release);
910 info!("PluginManager shutdown complete");
911 }
912
913 pub async fn invoke_by_name(
938 &self,
939 hook_name: &str,
940 payload: Box<dyn PluginPayload>,
941 extensions: Extensions,
942 context_table: Option<PluginContextTable>,
943 ) -> (PipelineResult, BackgroundTasks) {
944 let snapshot = self.load_runtime();
948 let hook_type = HookType::new(hook_name);
949 let all_entries = snapshot.registry.entries_for_hook(&hook_type);
950
951 if all_entries.is_empty() && snapshot.route_annotations.is_empty() {
956 return (
957 PipelineResult::allowed_with(
958 payload,
959 extensions,
960 context_table.unwrap_or_default(),
961 ),
962 BackgroundTasks::empty(),
963 );
964 }
965
966 let entries = self
967 .filter_entries_by_route(&snapshot, all_entries, &extensions, hook_name)
968 .await;
969
970 if entries.is_empty() {
971 return (
972 PipelineResult::allowed_with(
973 payload,
974 extensions,
975 context_table.unwrap_or_default(),
976 ),
977 BackgroundTasks::empty(),
978 );
979 }
980
981 snapshot
982 .executor
983 .execute(
984 &entries,
985 payload,
986 extensions,
987 context_table,
988 &self.task_tracker,
989 )
990 .await
991 }
992
993 pub async fn invoke<H: HookTypeDef>(
1024 &self,
1025 payload: H::Payload,
1026 extensions: Extensions,
1027 context_table: Option<PluginContextTable>,
1028 ) -> (PipelineResult, BackgroundTasks) {
1029 let snapshot = self.load_runtime();
1030 let hook_type = HookType::new(H::NAME);
1031 let all_entries = snapshot.registry.entries_for_hook(&hook_type);
1032
1033 if all_entries.is_empty() && snapshot.route_annotations.is_empty() {
1037 let boxed: Box<dyn PluginPayload> = Box::new(payload);
1038 return (
1039 PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()),
1040 BackgroundTasks::empty(),
1041 );
1042 }
1043
1044 let entries = self
1045 .filter_entries_by_route(&snapshot, all_entries, &extensions, H::NAME)
1046 .await;
1047
1048 if entries.is_empty() {
1049 let boxed: Box<dyn PluginPayload> = Box::new(payload);
1050 return (
1051 PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()),
1052 BackgroundTasks::empty(),
1053 );
1054 }
1055
1056 let boxed: Box<dyn PluginPayload> = Box::new(payload);
1057 snapshot
1058 .executor
1059 .execute(
1060 &entries,
1061 boxed,
1062 extensions,
1063 context_table,
1064 &self.task_tracker,
1065 )
1066 .await
1067 }
1068
1069 pub async fn invoke_named<H: HookTypeDef>(
1097 &self,
1098 hook_name: &str,
1099 payload: H::Payload,
1100 extensions: Extensions,
1101 context_table: Option<PluginContextTable>,
1102 ) -> (PipelineResult, BackgroundTasks) {
1103 let snapshot = self.load_runtime();
1104 let hook_type = HookType::new(hook_name);
1105 let all_entries = snapshot.registry.entries_for_hook(&hook_type);
1106
1107 if all_entries.is_empty() && snapshot.route_annotations.is_empty() {
1114 let boxed: Box<dyn PluginPayload> = Box::new(payload);
1115 return (
1116 PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()),
1117 BackgroundTasks::empty(),
1118 );
1119 }
1120
1121 let entries = self
1122 .filter_entries_by_route(&snapshot, all_entries, &extensions, hook_name)
1123 .await;
1124
1125 if entries.is_empty() {
1126 let boxed: Box<dyn PluginPayload> = Box::new(payload);
1127 return (
1128 PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()),
1129 BackgroundTasks::empty(),
1130 );
1131 }
1132
1133 let boxed: Box<dyn PluginPayload> = Box::new(payload);
1134 snapshot
1135 .executor
1136 .execute(
1137 &entries,
1138 boxed,
1139 extensions,
1140 context_table,
1141 &self.task_tracker,
1142 )
1143 .await
1144 }
1145
1146 pub fn find_plugin_entries(
1162 &self,
1163 plugin_name: &str,
1164 ) -> Vec<(String, crate::registry::HookEntry)> {
1165 let snapshot = self.load_runtime();
1166 snapshot.registry.entries_for_plugin(plugin_name)
1167 }
1168
1169 pub async fn invoke_entries<H: HookTypeDef>(
1185 &self,
1186 entries: &[crate::registry::HookEntry],
1187 payload: H::Payload,
1188 extensions: Extensions,
1189 context_table: Option<PluginContextTable>,
1190 ) -> (PipelineResult, BackgroundTasks) {
1191 if entries.is_empty() {
1192 let boxed: Box<dyn PluginPayload> = Box::new(payload);
1193 return (
1194 PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()),
1195 BackgroundTasks::empty(),
1196 );
1197 }
1198 let snapshot = self.load_runtime();
1199 let boxed: Box<dyn PluginPayload> = Box::new(payload);
1200 snapshot
1201 .executor
1202 .execute(
1203 entries,
1204 boxed,
1205 extensions,
1206 context_table,
1207 &self.task_tracker,
1208 )
1209 .await
1210 }
1211
1212 pub fn annotate_route<H>(
1240 &self,
1241 entity_type: impl Into<String>,
1242 entity_name: impl Into<String>,
1243 scope: Option<String>,
1244 hook_name: impl Into<String>,
1245 handler: Arc<H>,
1246 config: crate::plugin::PluginConfig,
1247 ) where
1248 H: crate::plugin::Plugin + crate::registry::AnyHookHandler + 'static,
1249 {
1250 let key = AnnotationKey {
1251 entity_type: entity_type.into(),
1252 entity_name: entity_name.into(),
1253 scope,
1254 hook_name: hook_name.into(),
1255 };
1256 let plugin_ref = Arc::new(crate::registry::PluginRef::new(
1257 handler.clone() as Arc<dyn crate::plugin::Plugin>,
1258 config,
1259 ));
1260 let entry = crate::registry::HookEntry {
1261 plugin_ref,
1262 handler: handler as Arc<dyn crate::registry::AnyHookHandler>,
1263 };
1264 self.mutate_runtime(|snap| {
1265 snap.route_annotations.insert(key, entry);
1266 });
1267 }
1268
1269 pub fn remove_route_annotation(
1273 &self,
1274 entity_type: &str,
1275 entity_name: &str,
1276 scope: Option<&str>,
1277 hook_name: &str,
1278 ) {
1279 let key = AnnotationKey {
1280 entity_type: entity_type.to_string(),
1281 entity_name: entity_name.to_string(),
1282 scope: scope.map(str::to_string),
1283 hook_name: hook_name.to_string(),
1284 };
1285 self.mutate_runtime(|snap| {
1286 snap.route_annotations.remove(&key);
1287 });
1288 }
1289
1290 async fn filter_entries_by_route(
1305 &self,
1306 snapshot: &RuntimeSnapshot,
1307 entries: &[crate::registry::HookEntry],
1308 extensions: &Extensions,
1309 hook_name: &str,
1310 ) -> Arc<Vec<crate::registry::HookEntry>> {
1311 if !snapshot.route_annotations.is_empty() {
1320 if let Some(meta) = &extensions.meta {
1321 if let (Some(et), Some(en)) = (&meta.entity_type, &meta.entity_name) {
1322 let scoped = meta.scope.as_ref().and_then(|s| {
1328 snapshot.route_annotations.get(&AnnotationKey {
1329 entity_type: et.clone(),
1330 entity_name: en.clone(),
1331 scope: Some(s.clone()),
1332 hook_name: hook_name.to_string(),
1333 })
1334 });
1335 let candidate = scoped.or_else(|| {
1336 snapshot.route_annotations.get(&AnnotationKey {
1337 entity_type: et.clone(),
1338 entity_name: en.clone(),
1339 scope: None,
1340 hook_name: hook_name.to_string(),
1341 })
1342 });
1343 if let Some(entry) = candidate {
1344 return Arc::new(vec![entry.clone()]);
1345 }
1346 }
1347 }
1348 }
1349
1350 let cpex_config = match &snapshot.cpex_config {
1355 Some(c) if c.routing_enabled() => c,
1356 _ => {
1357 let filtered: Vec<_> = entries
1358 .iter()
1359 .filter(|e| e.plugin_ref.trusted_config().passes_conditions(extensions))
1360 .cloned()
1361 .collect();
1362 return Arc::new(filtered);
1363 },
1364 };
1365
1366 let meta = match &extensions.meta {
1368 Some(m) => m,
1369 None => return Arc::new(entries.to_vec()),
1370 };
1371
1372 let (entity_type, entity_name) = match (&meta.entity_type, &meta.entity_name) {
1373 (Some(t), Some(n)) => (t.as_str(), n.as_str()),
1374 _ => return Arc::new(entries.to_vec()),
1375 };
1376
1377 let request_scope = meta.scope.as_deref();
1378
1379 let hash = {
1381 use std::hash::BuildHasher;
1382 let mut hasher = self.cache_hasher.build_hasher();
1383 entity_type.hash(&mut hasher);
1384 entity_name.hash(&mut hasher);
1385 hook_name.hash(&mut hasher);
1386 request_scope.hash(&mut hasher);
1387 hasher.finish()
1388 };
1389 {
1390 let cache = self
1397 .route_cache
1398 .read()
1399 .unwrap_or_else(|poisoned| poisoned.into_inner());
1400 if let Some((_, cached)) = cache.raw_entry().from_hash(hash, |key| {
1401 key.entity_type == entity_type
1402 && key.entity_name == entity_name
1403 && key.hook_name == hook_name
1404 && key.scope.as_deref() == request_scope
1405 }) {
1406 return Arc::clone(cached);
1407 }
1408 }
1409
1410 let resolved = if hook_name == crate::identity::HOOK_IDENTITY_RESOLVE {
1418 config::resolve_identity_plugins_for_route(
1419 cpex_config,
1420 entity_type,
1421 entity_name,
1422 request_scope,
1423 )
1424 } else {
1425 config::resolve_plugins_for_entity(
1426 cpex_config,
1427 entity_type,
1428 entity_name,
1429 request_scope,
1430 &meta.tags,
1431 )
1432 };
1433
1434 let mut filtered = Vec::new();
1438 for resolved_plugin in &resolved {
1439 if let Some(entry) = entries
1440 .iter()
1441 .find(|e| e.plugin_ref.name() == resolved_plugin.name)
1442 {
1443 if let Some(overrides) = &resolved_plugin.config_overrides {
1444 if let Some(override_entry) =
1446 self.create_override_instance(entry, overrides).await
1447 {
1448 filtered.push(override_entry);
1449 continue;
1450 }
1451 }
1452 filtered.push(entry.clone());
1453 }
1454 }
1455
1456 let cached = Arc::new(filtered);
1457
1458 let cache_key = RouteCacheKey {
1463 entity_type: entity_type.to_string(),
1464 entity_name: entity_name.to_string(),
1465 hook_name: hook_name.to_string(),
1466 scope: meta.scope.clone(),
1467 };
1468 let should_warn = {
1471 let mut cache = self
1472 .route_cache
1473 .write()
1474 .unwrap_or_else(|poisoned| poisoned.into_inner());
1475 if cache.len() >= snapshot.route_cache_max_entries {
1476 !self.route_cache_full_warned.swap(true, Ordering::AcqRel)
1477 } else {
1478 cache.insert(cache_key, Arc::clone(&cached));
1479 false
1480 }
1481 };
1482 if should_warn {
1483 warn!(
1484 max_entries = snapshot.route_cache_max_entries,
1485 "Routing cache at capacity — further routes will not be cached. \
1486 Increase plugin_settings.route_cache_max_entries or \
1487 investigate entity name growth.",
1488 );
1489 }
1490
1491 cached
1492 }
1493
1494 pub async fn build_override_entries(
1527 &self,
1528 plugin_name: &str,
1529 config_override: Option<&serde_yaml::Value>,
1530 capabilities_override: Option<&std::collections::HashSet<String>>,
1531 on_error_override: Option<crate::plugin::OnError>,
1532 ) -> Vec<(String, crate::registry::HookEntry)> {
1533 let base_entries = self.find_plugin_entries(plugin_name);
1534 if base_entries.is_empty() {
1535 return Vec::new();
1536 }
1537
1538 if config_override.is_none()
1540 && capabilities_override.is_none()
1541 && on_error_override.is_none()
1542 {
1543 return base_entries;
1544 }
1545
1546 let base_ref = Arc::clone(&base_entries[0].1.plugin_ref);
1550 let mut merged_config = base_ref.trusted_config().clone();
1551
1552 if let Some(caps) = capabilities_override {
1554 merged_config.capabilities = caps.clone();
1555 }
1556
1557 if let Some(oe) = on_error_override {
1559 merged_config.on_error = oe;
1560 }
1561
1562 if config_override.is_none() {
1566 let new_ref = Arc::new(crate::registry::PluginRef::new(
1567 Arc::clone(base_ref.plugin()),
1568 merged_config,
1569 ));
1570 return base_entries
1571 .into_iter()
1572 .map(|(hook_name, base_entry)| {
1573 (
1574 hook_name,
1575 crate::registry::HookEntry {
1576 plugin_ref: Arc::clone(&new_ref),
1577 handler: base_entry.handler,
1578 },
1579 )
1580 })
1581 .collect();
1582 }
1583
1584 let cfg_yaml = config_override.expect("checked above");
1590 let cfg_json = match serde_json::to_value(cfg_yaml) {
1591 Ok(v) => v,
1592 Err(e) => {
1593 error!(
1594 plugin = %plugin_name,
1595 error = %e,
1596 "build_override_entries: YAML→JSON config conversion failed",
1597 );
1598 return Vec::new();
1599 },
1600 };
1601 merged_config.config = Some(cfg_json);
1602
1603 let kind = merged_config.kind.clone();
1604 let instance = {
1605 let factories = self.factories.read().unwrap_or_else(|p| p.into_inner());
1606 let factory = match factories.get(&kind) {
1607 Some(f) => f,
1608 None => {
1609 error!(
1610 plugin = %plugin_name,
1611 kind = %kind,
1612 "build_override_entries: no factory registered for kind",
1613 );
1614 return Vec::new();
1615 },
1616 };
1617 match factory.create(&merged_config) {
1618 Ok(i) => i,
1619 Err(e) => {
1620 error!(
1621 plugin = %plugin_name,
1622 error = %e,
1623 "build_override_entries: factory.create failed",
1624 );
1625 return Vec::new();
1626 },
1627 }
1628 };
1629
1630 if let Err(e) = instance.plugin.initialize().await {
1631 error!(
1632 plugin = %plugin_name,
1633 error = %e,
1634 "build_override_entries: initialize() failed on new instance",
1635 );
1636 return Vec::new();
1637 }
1638
1639 let new_ref = Arc::new(crate::registry::PluginRef::new(
1643 Arc::clone(&instance.plugin),
1644 merged_config,
1645 ));
1646 instance
1647 .handlers
1648 .into_iter()
1649 .map(|(hook_name, handler)| {
1650 (
1651 hook_name.to_string(),
1652 crate::registry::HookEntry {
1653 plugin_ref: Arc::clone(&new_ref),
1654 handler,
1655 },
1656 )
1657 })
1658 .collect()
1659 }
1660
1661 async fn create_override_instance(
1686 &self,
1687 base_entry: &crate::registry::HookEntry,
1688 overrides: &serde_json::Value,
1689 ) -> Option<crate::registry::HookEntry> {
1690 let base_config = base_entry.plugin_ref.trusted_config();
1691 let kind = &base_config.kind;
1692
1693 let mut merged_config = base_config.clone();
1695 if let Some(override_config) = overrides.get("config") {
1696 if let Some(base_plugin_config) = &merged_config.config {
1698 let mut merged = base_plugin_config.clone();
1699 if let (Some(base_obj), Some(override_obj)) =
1700 (merged.as_object_mut(), override_config.as_object())
1701 {
1702 for (key, value) in override_obj {
1703 base_obj.insert(key.clone(), value.clone());
1704 }
1705 }
1706 merged_config.config = Some(merged);
1707 } else {
1708 merged_config.config = Some(override_config.clone());
1709 }
1710 }
1711
1712 let target_hook = base_entry.handler.hook_type_name();
1716 let instance = {
1717 let factories = self.factories.read().unwrap_or_else(|p| p.into_inner());
1718 let factory = match factories.get(kind) {
1719 Some(f) => f,
1720 None => return None,
1721 };
1722 match factory.create(&merged_config) {
1723 Ok(i) => i,
1724 Err(e) => {
1725 error!(
1726 "Failed to create override instance for '{}': {}",
1727 base_config.name, e
1728 );
1729 return None; },
1731 }
1732 };
1733
1734 let handler = instance
1737 .handlers
1738 .into_iter()
1739 .find(|(name, _)| *name == target_hook)
1740 .map(|(_, h)| h);
1741 let handler = match handler {
1742 Some(h) => h,
1743 None => {
1744 warn!(
1745 "Override instance for '{}' has no handler for hook '{}'",
1746 base_config.name, target_hook
1747 );
1748 return None;
1749 },
1750 };
1751
1752 if let Err(e) = instance.plugin.initialize().await {
1756 error!(
1757 "Failed to initialize override instance for '{}': {} — falling back to base",
1758 base_config.name, e
1759 );
1760 return None;
1761 }
1762
1763 let plugin_ref = Arc::new(crate::registry::PluginRef::new(
1767 instance.plugin,
1768 merged_config,
1769 ));
1770 Some(crate::registry::HookEntry {
1771 plugin_ref,
1772 handler,
1773 })
1774 }
1775
1776 pub fn clear_routing_cache(&self) {
1780 let mut cache = self
1781 .route_cache
1782 .write()
1783 .unwrap_or_else(|poisoned| poisoned.into_inner());
1784 cache.clear();
1785 self.route_cache_full_warned.store(false, Ordering::Release);
1786 }
1787
1788 pub fn routing_cache_size(&self) -> usize {
1790 self.route_cache
1791 .read()
1792 .unwrap_or_else(|poisoned| poisoned.into_inner())
1793 .len()
1794 }
1795
1796 pub fn has_hooks_for(&self, hook_name: &str) -> bool {
1811 let snapshot = self.load_runtime();
1812 snapshot.registry.has_hooks_for(&HookType::new(hook_name))
1813 || snapshot
1814 .route_annotations
1815 .keys()
1816 .any(|k| k.hook_name.as_str() == hook_name)
1817 }
1818
1819 pub fn get_plugin(&self, name: &str) -> Option<Arc<PluginRef>> {
1825 self.load_runtime().registry.get(name)
1826 }
1827
1828 pub fn plugin_count(&self) -> usize {
1830 self.load_runtime().registry.plugin_count()
1831 }
1832
1833 pub fn plugin_names(&self) -> Vec<String> {
1835 self.load_runtime().registry.plugin_names()
1836 }
1837
1838 pub fn is_initialized(&self) -> bool {
1840 self.initialized.load(Ordering::Acquire)
1841 }
1842
1843 pub fn unregister(&self, name: &str) -> Option<Arc<PluginRef>> {
1845 let removed = self.mutate_runtime(|snap| snap.registry.unregister(name));
1846 if removed.is_some() {
1847 self.clear_routing_cache();
1848 }
1849 removed
1850 }
1851}
1852
1853impl Default for PluginManager {
1854 fn default() -> Self {
1855 Self::new(ManagerConfig::default())
1856 }
1857}
1858
1859#[cfg(test)]
1860mod tests {
1861 use super::*;
1862 use crate::context::PluginContext;
1863 use crate::error::PluginViolation;
1864 use crate::hooks::payload::Extensions;
1865 use crate::hooks::{HookHandler, PluginResult};
1866 use crate::plugin::{OnError, PluginMode};
1867 use async_trait::async_trait;
1868
1869 #[derive(Debug, Clone)]
1872 struct TestPayload {
1873 value: String,
1874 }
1875 crate::impl_plugin_payload!(TestPayload);
1876
1877 struct TestHook;
1880 impl HookTypeDef for TestHook {
1881 type Payload = TestPayload;
1882 type Result = PluginResult<TestPayload>;
1883 const NAME: &'static str = "test_hook";
1884 }
1885
1886 struct AllowPlugin {
1891 cfg: PluginConfig,
1892 }
1893
1894 #[async_trait]
1895 impl Plugin for AllowPlugin {
1896 fn config(&self) -> &PluginConfig {
1897 &self.cfg
1898 }
1899 async fn initialize(&self) -> Result<(), Box<PluginError>> {
1900 Ok(())
1901 }
1902 async fn shutdown(&self) -> Result<(), Box<PluginError>> {
1903 Ok(())
1904 }
1905 }
1906
1907 impl HookHandler<TestHook> for AllowPlugin {
1908 async fn handle(
1909 &self,
1910 _payload: &TestPayload,
1911 _extensions: &Extensions,
1912 _ctx: &mut PluginContext,
1913 ) -> PluginResult<TestPayload> {
1914 PluginResult::allow()
1915 }
1916 }
1917
1918 struct DenyPlugin {
1920 cfg: PluginConfig,
1921 }
1922
1923 #[async_trait]
1924 impl Plugin for DenyPlugin {
1925 fn config(&self) -> &PluginConfig {
1926 &self.cfg
1927 }
1928 async fn initialize(&self) -> Result<(), Box<PluginError>> {
1929 Ok(())
1930 }
1931 async fn shutdown(&self) -> Result<(), Box<PluginError>> {
1932 Ok(())
1933 }
1934 }
1935
1936 impl HookHandler<TestHook> for DenyPlugin {
1937 async fn handle(
1938 &self,
1939 _payload: &TestPayload,
1940 _extensions: &Extensions,
1941 _ctx: &mut PluginContext,
1942 ) -> PluginResult<TestPayload> {
1943 PluginResult::deny(PluginViolation::new("denied", "test denial"))
1944 }
1945 }
1946
1947 struct ErrorHandler;
1949
1950 #[async_trait]
1951 impl AnyHookHandler for ErrorHandler {
1952 async fn invoke(
1953 &self,
1954 _payload: &dyn PluginPayload,
1955 _extensions: &Extensions,
1956 _ctx: &mut PluginContext,
1957 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
1958 Err(Box::new(PluginError::Execution {
1959 plugin_name: "error-plugin".into(),
1960 message: "simulated failure".into(),
1961 source: None,
1962 code: None,
1963 details: std::collections::HashMap::new(),
1964 proto_error_code: None,
1965 }))
1966 }
1967
1968 fn hook_type_name(&self) -> &'static str {
1969 "test_hook"
1970 }
1971 }
1972
1973 fn make_config(name: &str, priority: i32, mode: PluginMode) -> PluginConfig {
1976 make_config_with_on_error(name, priority, mode, OnError::Fail)
1977 }
1978
1979 fn make_config_with_on_error(
1980 name: &str,
1981 priority: i32,
1982 mode: PluginMode,
1983 on_error: OnError,
1984 ) -> PluginConfig {
1985 PluginConfig {
1986 name: name.to_string(),
1987 kind: "test".to_string(),
1988 description: None,
1989 author: None,
1990 version: None,
1991 hooks: vec!["test_hook".to_string()],
1992 mode,
1993 priority,
1994 on_error,
1995 capabilities: Default::default(),
1996 tags: Vec::new(),
1997 conditions: Vec::new(),
1998 config: None,
1999 }
2000 }
2001
2002 fn make_config_with_conditions(
2003 name: &str,
2004 conditions: Vec<crate::plugin::PluginCondition>,
2005 ) -> PluginConfig {
2006 let mut cfg = make_config(name, 10, PluginMode::Sequential);
2007 cfg.conditions = conditions;
2008 cfg
2009 }
2010
2011 #[tokio::test]
2014 async fn test_manager_lifecycle() {
2015 let mgr = PluginManager::default();
2016 assert!(!mgr.is_initialized());
2017 assert_eq!(mgr.plugin_count(), 0);
2018
2019 mgr.initialize().await.unwrap();
2020 assert!(mgr.is_initialized());
2021
2022 mgr.initialize().await.unwrap();
2024
2025 mgr.shutdown().await;
2026 assert!(!mgr.is_initialized());
2027 }
2028
2029 #[tokio::test]
2030 async fn test_invoke_by_name_no_plugins() {
2031 let mgr = PluginManager::default();
2032 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
2033 value: "test".into(),
2034 });
2035
2036 let (result, _) = mgr
2037 .invoke_by_name("test_hook", payload, Extensions::default(), None)
2038 .await;
2039
2040 assert!(result.continue_processing);
2041 assert!(result.modified_payload.is_some());
2042 }
2043
2044 #[tokio::test]
2045 async fn test_invoke_by_name_allow() {
2046 let mgr = PluginManager::default();
2047 let config = make_config("allow-plugin", 10, PluginMode::Sequential);
2048 let plugin = Arc::new(AllowPlugin {
2049 cfg: config.clone(),
2050 });
2051
2052 mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2054 mgr.initialize().await.unwrap();
2055
2056 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
2057 value: "test".into(),
2058 });
2059
2060 let (result, _) = mgr
2061 .invoke_by_name("test_hook", payload, Extensions::default(), None)
2062 .await;
2063
2064 assert!(result.continue_processing);
2065 }
2066
2067 #[tokio::test]
2068 async fn test_invoke_by_name_deny() {
2069 let mgr = PluginManager::default();
2070 let config = make_config("deny-plugin", 10, PluginMode::Sequential);
2071 let plugin = Arc::new(DenyPlugin {
2072 cfg: config.clone(),
2073 });
2074
2075 mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2076 mgr.initialize().await.unwrap();
2077
2078 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
2079 value: "test".into(),
2080 });
2081
2082 let (result, _) = mgr
2083 .invoke_by_name("test_hook", payload, Extensions::default(), None)
2084 .await;
2085
2086 assert!(!result.continue_processing);
2087 assert_eq!(result.violation.as_ref().unwrap().code, "denied");
2088 }
2089
2090 #[tokio::test]
2091 async fn test_invoke_typed() {
2092 let mgr = PluginManager::default();
2093 let config = make_config("allow-plugin", 10, PluginMode::Sequential);
2094 let plugin = Arc::new(AllowPlugin {
2095 cfg: config.clone(),
2096 });
2097
2098 mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2099 mgr.initialize().await.unwrap();
2100
2101 let payload = TestPayload {
2102 value: "typed".into(),
2103 };
2104
2105 let (result, _) = mgr
2106 .invoke::<TestHook>(payload, Extensions::default(), None)
2107 .await;
2108
2109 assert!(result.continue_processing);
2110 }
2111
2112 #[tokio::test]
2113 async fn test_invoke_named() {
2114 let mgr = PluginManager::default();
2117 let config = make_config("allow-plugin", 10, PluginMode::Sequential);
2118 let plugin = Arc::new(AllowPlugin {
2119 cfg: config.clone(),
2120 });
2121
2122 mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2123 mgr.initialize().await.unwrap();
2124
2125 let payload = TestPayload {
2126 value: "named".into(),
2127 };
2128
2129 let (result, _) = mgr
2132 .invoke_named::<TestHook>("test_hook", payload, Extensions::default(), None)
2133 .await;
2134
2135 assert!(result.continue_processing);
2136 }
2137
2138 #[tokio::test]
2139 async fn test_invoke_named_no_plugins_for_hook() {
2140 let mgr = PluginManager::default();
2142 let config = make_config("allow-plugin", 10, PluginMode::Sequential);
2143 let plugin = Arc::new(AllowPlugin {
2144 cfg: config.clone(),
2145 });
2146
2147 mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2148 mgr.initialize().await.unwrap();
2149
2150 let payload = TestPayload {
2151 value: "no-match".into(),
2152 };
2153
2154 let (result, _) = mgr
2156 .invoke_named::<TestHook>("other_hook", payload, Extensions::default(), None)
2157 .await;
2158
2159 assert!(result.continue_processing);
2161 }
2162
2163 #[tokio::test]
2164 async fn test_invoke_named_deny() {
2165 let mgr = PluginManager::default();
2166 let config = make_config("deny-plugin", 10, PluginMode::Sequential);
2167 let plugin = Arc::new(DenyPlugin {
2168 cfg: config.clone(),
2169 });
2170
2171 mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2172 mgr.initialize().await.unwrap();
2173
2174 let payload = TestPayload {
2175 value: "denied".into(),
2176 };
2177
2178 let (result, _) = mgr
2179 .invoke_named::<TestHook>("test_hook", payload, Extensions::default(), None)
2180 .await;
2181
2182 assert!(!result.continue_processing);
2183 assert_eq!(result.violation.as_ref().unwrap().code, "denied");
2184 }
2185
2186 #[tokio::test]
2187 async fn test_has_hooks_for() {
2188 let mgr = PluginManager::default();
2189 assert!(!mgr.has_hooks_for("test_hook"));
2190
2191 let config = make_config("p1", 10, PluginMode::Sequential);
2192 let plugin = Arc::new(AllowPlugin {
2193 cfg: config.clone(),
2194 });
2195 mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2196
2197 assert!(mgr.has_hooks_for("test_hook"));
2198 assert!(!mgr.has_hooks_for("other_hook"));
2199 }
2200
2201 #[tokio::test]
2206 async fn test_conditions_filter_plugins_when_routing_disabled() {
2207 use std::sync::atomic::{AtomicUsize, Ordering};
2208 use std::sync::Arc as StdArc;
2209
2210 let counts: StdArc<[AtomicUsize; 2]> =
2211 StdArc::new([AtomicUsize::new(0), AtomicUsize::new(0)]);
2212
2213 struct CountingHandler {
2214 idx: usize,
2215 counts: StdArc<[AtomicUsize; 2]>,
2216 }
2217 #[async_trait]
2218 impl AnyHookHandler for CountingHandler {
2219 async fn invoke(
2220 &self,
2221 _payload: &dyn PluginPayload,
2222 _extensions: &Extensions,
2223 _ctx: &mut PluginContext,
2224 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
2225 self.counts[self.idx].fetch_add(1, Ordering::SeqCst);
2226 let result: PluginResult<TestPayload> = PluginResult::allow();
2227 Ok(crate::executor::erase_result(result))
2228 }
2229 fn hook_type_name(&self) -> &'static str {
2230 "test_hook"
2231 }
2232 }
2233
2234 let mgr = PluginManager::default();
2235
2236 let mut tools = std::collections::HashSet::new();
2238 tools.insert("wanted_tool".to_string());
2239 let cfg_a = make_config_with_conditions(
2240 "plugin_a",
2241 vec![crate::plugin::PluginCondition {
2242 tools: Some(tools),
2243 ..Default::default()
2244 }],
2245 );
2246 let plugin_a = Arc::new(AllowPlugin { cfg: cfg_a.clone() });
2247 let handler_a: Arc<dyn AnyHookHandler> = Arc::new(CountingHandler {
2248 idx: 0,
2249 counts: StdArc::clone(&counts),
2250 });
2251 mgr.register_raw::<TestHook>(plugin_a, cfg_a, handler_a)
2252 .unwrap();
2253
2254 let cfg_b = make_config("plugin_b", 20, PluginMode::Sequential);
2256 let plugin_b = Arc::new(AllowPlugin { cfg: cfg_b.clone() });
2257 let handler_b: Arc<dyn AnyHookHandler> = Arc::new(CountingHandler {
2258 idx: 1,
2259 counts: StdArc::clone(&counts),
2260 });
2261 mgr.register_raw::<TestHook>(plugin_b, cfg_b, handler_b)
2262 .unwrap();
2263
2264 mgr.initialize().await.unwrap();
2265
2266 let ext_match = Extensions {
2268 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
2269 entity_type: Some("tool".into()),
2270 entity_name: Some("wanted_tool".into()),
2271 ..Default::default()
2272 })),
2273 ..Default::default()
2274 };
2275 let p: Box<dyn PluginPayload> = Box::new(TestPayload { value: "1".into() });
2276 let _ = mgr.invoke_by_name("test_hook", p, ext_match, None).await;
2277 assert_eq!(
2278 counts[0].load(Ordering::SeqCst),
2279 1,
2280 "plugin_a should fire on matching tool"
2281 );
2282 assert_eq!(
2283 counts[1].load(Ordering::SeqCst),
2284 1,
2285 "plugin_b should fire (no conditions)"
2286 );
2287
2288 let ext_no_match = Extensions {
2290 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
2291 entity_type: Some("tool".into()),
2292 entity_name: Some("other_tool".into()),
2293 ..Default::default()
2294 })),
2295 ..Default::default()
2296 };
2297 let p: Box<dyn PluginPayload> = Box::new(TestPayload { value: "2".into() });
2298 let _ = mgr.invoke_by_name("test_hook", p, ext_no_match, None).await;
2299 assert_eq!(
2300 counts[0].load(Ordering::SeqCst),
2301 1,
2302 "plugin_a should NOT fire on non-matching tool"
2303 );
2304 assert_eq!(
2305 counts[1].load(Ordering::SeqCst),
2306 2,
2307 "plugin_b should fire on every request"
2308 );
2309 }
2310
2311 #[tokio::test]
2314 async fn test_conditions_user_patterns_glob_filters() {
2315 use std::sync::atomic::{AtomicUsize, Ordering};
2316
2317 static FIRED: AtomicUsize = AtomicUsize::new(0);
2318 FIRED.store(0, Ordering::SeqCst);
2319
2320 struct CountHandler;
2321 #[async_trait]
2322 impl AnyHookHandler for CountHandler {
2323 async fn invoke(
2324 &self,
2325 _payload: &dyn PluginPayload,
2326 _extensions: &Extensions,
2327 _ctx: &mut PluginContext,
2328 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
2329 FIRED.fetch_add(1, Ordering::SeqCst);
2330 let result: PluginResult<TestPayload> = PluginResult::allow();
2331 Ok(crate::executor::erase_result(result))
2332 }
2333 fn hook_type_name(&self) -> &'static str {
2334 "test_hook"
2335 }
2336 }
2337
2338 let mgr = PluginManager::default();
2339 let cfg = make_config_with_conditions(
2340 "admin_only",
2341 vec![crate::plugin::PluginCondition {
2342 user_patterns: Some(vec!["admin-*".to_string()]),
2343 ..Default::default()
2344 }],
2345 );
2346 let plugin = Arc::new(AllowPlugin { cfg: cfg.clone() });
2347 let handler: Arc<dyn AnyHookHandler> = Arc::new(CountHandler);
2348 mgr.register_raw::<TestHook>(plugin, cfg, handler).unwrap();
2349 mgr.initialize().await.unwrap();
2350
2351 let ext_with_user = |id: &str| Extensions {
2352 security: Some(std::sync::Arc::new(crate::extensions::SecurityExtension {
2353 subject: Some(crate::extensions::security::SubjectExtension {
2354 id: Some(id.to_string()),
2355 ..Default::default()
2356 }),
2357 ..Default::default()
2358 })),
2359 ..Default::default()
2360 };
2361
2362 let p: Box<dyn PluginPayload> = Box::new(TestPayload { value: "1".into() });
2363 let _ = mgr
2364 .invoke_by_name("test_hook", p, ext_with_user("admin-alice"), None)
2365 .await;
2366 assert_eq!(
2367 FIRED.load(Ordering::SeqCst),
2368 1,
2369 "admin-alice should match admin-*"
2370 );
2371
2372 let p: Box<dyn PluginPayload> = Box::new(TestPayload { value: "2".into() });
2373 let _ = mgr
2374 .invoke_by_name("test_hook", p, ext_with_user("user-bob"), None)
2375 .await;
2376 assert_eq!(
2377 FIRED.load(Ordering::SeqCst),
2378 1,
2379 "user-bob should NOT match admin-*"
2380 );
2381 }
2382
2383 #[tokio::test]
2384 async fn test_unregister() {
2385 let mgr = PluginManager::default();
2386 let config = make_config("removable", 10, PluginMode::Sequential);
2387 let plugin = Arc::new(AllowPlugin {
2388 cfg: config.clone(),
2389 });
2390 mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2391
2392 assert_eq!(mgr.plugin_count(), 1);
2393 mgr.unregister("removable");
2394 assert_eq!(mgr.plugin_count(), 0);
2395 assert!(!mgr.has_hooks_for("test_hook"));
2396 }
2397
2398 #[tokio::test]
2404 async fn test_manager_arc_shareable_with_concurrent_dispatch_and_registration() {
2405 use std::sync::atomic::{AtomicUsize, Ordering};
2406
2407 static INVOKE_COUNT: AtomicUsize = AtomicUsize::new(0);
2408 INVOKE_COUNT.store(0, Ordering::SeqCst);
2409
2410 struct CountingHandler;
2411 #[async_trait]
2412 impl AnyHookHandler for CountingHandler {
2413 async fn invoke(
2414 &self,
2415 _payload: &dyn PluginPayload,
2416 _extensions: &Extensions,
2417 _ctx: &mut PluginContext,
2418 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
2419 INVOKE_COUNT.fetch_add(1, Ordering::SeqCst);
2420 let result: PluginResult<TestPayload> = PluginResult::allow();
2421 Ok(crate::executor::erase_result(result))
2422 }
2423 fn hook_type_name(&self) -> &'static str {
2424 "test_hook"
2425 }
2426 }
2427
2428 let mgr = Arc::new(PluginManager::default());
2429
2430 let cfg = make_config("p0", 10, PluginMode::Sequential);
2432 let plugin: Arc<AllowPlugin> = Arc::new(AllowPlugin { cfg: cfg.clone() });
2433 let handler: Arc<dyn AnyHookHandler> = Arc::new(CountingHandler);
2434 mgr.register_raw::<TestHook>(plugin, cfg, handler).unwrap();
2435 mgr.initialize().await.unwrap();
2436
2437 let n = 16;
2440 let mut handles = Vec::with_capacity(n + 1);
2441 for i in 0..n {
2442 let mgr = Arc::clone(&mgr);
2443 handles.push(tokio::spawn(async move {
2444 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
2445 value: format!("call-{}", i),
2446 });
2447 let (result, _) = mgr
2448 .invoke_by_name("test_hook", payload, Extensions::default(), None)
2449 .await;
2450 assert!(result.continue_processing);
2451 }));
2452 }
2453
2454 {
2456 let mgr = Arc::clone(&mgr);
2457 handles.push(tokio::spawn(async move {
2458 let cfg = make_config("p1-late", 20, PluginMode::Sequential);
2459 let plugin: Arc<AllowPlugin> = Arc::new(AllowPlugin { cfg: cfg.clone() });
2460 let handler: Arc<dyn AnyHookHandler> = Arc::new(CountingHandler);
2461 mgr.register_raw::<TestHook>(plugin, cfg, handler).unwrap();
2462 }));
2463 }
2464
2465 for h in handles {
2466 h.await.unwrap();
2467 }
2468
2469 assert!(INVOKE_COUNT.load(Ordering::SeqCst) >= n);
2474 assert_eq!(mgr.plugin_count(), 2);
2476 }
2477
2478 #[tokio::test]
2479 async fn test_audit_plugin_cannot_block() {
2480 let mgr = PluginManager::default();
2481 let config = make_config("audit-denier", 10, PluginMode::Audit);
2482 let plugin = Arc::new(DenyPlugin {
2483 cfg: config.clone(),
2484 });
2485
2486 mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2487 mgr.initialize().await.unwrap();
2488
2489 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
2490 value: "test".into(),
2491 });
2492
2493 let (result, _) = mgr
2494 .invoke_by_name("test_hook", payload, Extensions::default(), None)
2495 .await;
2496
2497 assert!(result.continue_processing);
2499 }
2500
2501 #[tokio::test]
2502 async fn test_on_error_disable_skips_plugin_on_subsequent_invocations() {
2503 let mgr = PluginManager::default();
2504
2505 let config =
2507 make_config_with_on_error("flaky-plugin", 10, PluginMode::Sequential, OnError::Disable);
2508 let plugin = Arc::new(AllowPlugin {
2509 cfg: config.clone(),
2510 });
2511 let handler: Arc<dyn AnyHookHandler> = Arc::new(ErrorHandler);
2512 mgr.register_raw::<TestHook>(plugin, config, handler)
2513 .unwrap();
2514
2515 let config2 = make_config("allow-plugin", 20, PluginMode::Sequential);
2517 let plugin2 = Arc::new(AllowPlugin {
2518 cfg: config2.clone(),
2519 });
2520 mgr.register_handler::<TestHook, _>(plugin2, config2)
2521 .unwrap();
2522
2523 mgr.initialize().await.unwrap();
2524
2525 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
2528 value: "first".into(),
2529 });
2530 let (result, _) = mgr
2531 .invoke_by_name("test_hook", payload, Extensions::default(), None)
2532 .await;
2533 assert!(result.continue_processing);
2534
2535 let plugin_ref = mgr.get_plugin("flaky-plugin").unwrap();
2537 assert!(plugin_ref.is_disabled());
2538 assert_eq!(plugin_ref.mode(), PluginMode::Disabled);
2539
2540 let payload2: Box<dyn PluginPayload> = Box::new(TestPayload {
2543 value: "second".into(),
2544 });
2545 let (result2, _) = mgr
2546 .invoke_by_name("test_hook", payload2, Extensions::default(), None)
2547 .await;
2548 assert!(result2.continue_processing);
2549 }
2550
2551 #[tokio::test]
2552 async fn test_on_error_ignore_continues_without_disabling() {
2553 let mgr = PluginManager::default();
2554
2555 let config =
2557 make_config_with_on_error("flaky-plugin", 10, PluginMode::Sequential, OnError::Ignore);
2558 let plugin = Arc::new(AllowPlugin {
2559 cfg: config.clone(),
2560 });
2561 let handler: Arc<dyn AnyHookHandler> = Arc::new(ErrorHandler);
2562 mgr.register_raw::<TestHook>(plugin, config, handler)
2563 .unwrap();
2564
2565 mgr.initialize().await.unwrap();
2566
2567 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
2569 value: "test".into(),
2570 });
2571 let (result, _) = mgr
2572 .invoke_by_name("test_hook", payload, Extensions::default(), None)
2573 .await;
2574 assert!(result.continue_processing);
2575
2576 let plugin_ref = mgr.get_plugin("flaky-plugin").unwrap();
2578 assert!(!plugin_ref.is_disabled());
2579 assert_eq!(plugin_ref.mode(), PluginMode::Sequential);
2580 }
2581
2582 #[tokio::test]
2586 async fn test_on_error_ignore_records_in_pipeline_errors() {
2587 let mgr = PluginManager::default();
2588 let config =
2589 make_config_with_on_error("flaky-plugin", 10, PluginMode::Sequential, OnError::Ignore);
2590 let plugin = Arc::new(AllowPlugin {
2591 cfg: config.clone(),
2592 });
2593 let handler: Arc<dyn AnyHookHandler> = Arc::new(ErrorHandler);
2594 mgr.register_raw::<TestHook>(plugin, config, handler)
2595 .unwrap();
2596
2597 mgr.initialize().await.unwrap();
2598
2599 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
2600 let (result, _) = mgr
2601 .invoke_by_name("test_hook", payload, Extensions::default(), None)
2602 .await;
2603
2604 assert!(result.continue_processing);
2606 assert_eq!(result.errors.len(), 1, "expected one error record");
2608 let rec = &result.errors[0];
2609 assert_eq!(rec.plugin_name, "error-plugin");
2610 assert!(
2611 rec.message.contains("simulated failure"),
2612 "message lost: {}",
2613 rec.message,
2614 );
2615 }
2616
2617 #[tokio::test]
2620 async fn test_on_error_disable_records_in_pipeline_errors() {
2621 let mgr = PluginManager::default();
2622 let config =
2623 make_config_with_on_error("flaky-plugin", 10, PluginMode::Sequential, OnError::Disable);
2624 let plugin = Arc::new(AllowPlugin {
2625 cfg: config.clone(),
2626 });
2627 let handler: Arc<dyn AnyHookHandler> = Arc::new(ErrorHandler);
2628 mgr.register_raw::<TestHook>(plugin, config, handler)
2629 .unwrap();
2630
2631 mgr.initialize().await.unwrap();
2632
2633 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
2634 let (result, _) = mgr
2635 .invoke_by_name("test_hook", payload, Extensions::default(), None)
2636 .await;
2637
2638 assert!(result.continue_processing);
2639 assert_eq!(result.errors.len(), 1);
2640 assert!(mgr.get_plugin("flaky-plugin").unwrap().is_disabled());
2642 }
2643
2644 #[tokio::test]
2645 async fn test_on_error_fail_halts_pipeline() {
2646 let mgr = PluginManager::default();
2647
2648 let config =
2650 make_config_with_on_error("strict-plugin", 10, PluginMode::Sequential, OnError::Fail);
2651 let plugin = Arc::new(AllowPlugin {
2652 cfg: config.clone(),
2653 });
2654 let handler: Arc<dyn AnyHookHandler> = Arc::new(ErrorHandler);
2655 mgr.register_raw::<TestHook>(plugin, config, handler)
2656 .unwrap();
2657
2658 mgr.initialize().await.unwrap();
2659
2660 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
2662 value: "test".into(),
2663 });
2664 let (result, _) = mgr
2665 .invoke_by_name("test_hook", payload, Extensions::default(), None)
2666 .await;
2667 assert!(!result.continue_processing);
2668 assert_eq!(result.violation.as_ref().unwrap().code, "plugin_error");
2669 assert_eq!(
2670 result.violation.as_ref().unwrap().plugin_name.as_deref(),
2671 Some("strict-plugin"),
2672 );
2673 }
2674
2675 struct TransformPlugin {
2679 cfg: PluginConfig,
2680 }
2681
2682 #[async_trait]
2683 impl Plugin for TransformPlugin {
2684 fn config(&self) -> &PluginConfig {
2685 &self.cfg
2686 }
2687 async fn initialize(&self) -> Result<(), Box<PluginError>> {
2688 Ok(())
2689 }
2690 async fn shutdown(&self) -> Result<(), Box<PluginError>> {
2691 Ok(())
2692 }
2693 }
2694
2695 impl HookHandler<TestHook> for TransformPlugin {
2696 async fn handle(
2697 &self,
2698 payload: &TestPayload,
2699 _extensions: &Extensions,
2700 _ctx: &mut PluginContext,
2701 ) -> PluginResult<TestPayload> {
2702 PluginResult::modify_payload(TestPayload {
2703 value: format!("{}_transformed", payload.value),
2704 })
2705 }
2706 }
2707
2708 struct SlowHandler {
2710 delay_ms: u64,
2711 }
2712
2713 #[async_trait]
2714 impl AnyHookHandler for SlowHandler {
2715 async fn invoke(
2716 &self,
2717 _payload: &dyn PluginPayload,
2718 _extensions: &Extensions,
2719 _ctx: &mut PluginContext,
2720 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
2721 tokio::time::sleep(std::time::Duration::from_millis(self.delay_ms)).await;
2722 let result: PluginResult<TestPayload> = PluginResult::allow();
2723 Ok(crate::executor::erase_result(result))
2724 }
2725
2726 fn hook_type_name(&self) -> &'static str {
2727 "test_hook"
2728 }
2729 }
2730
2731 #[tokio::test]
2734 async fn test_transform_modifies_payload() {
2735 let mgr = PluginManager::default();
2736 let config = make_config("transformer", 10, PluginMode::Transform);
2737 let plugin = Arc::new(TransformPlugin {
2738 cfg: config.clone(),
2739 });
2740
2741 mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2742 mgr.initialize().await.unwrap();
2743
2744 let payload = TestPayload {
2745 value: "original".into(),
2746 };
2747
2748 let (result, _) = mgr
2749 .invoke::<TestHook>(payload, Extensions::default(), None)
2750 .await;
2751
2752 assert!(result.continue_processing);
2753 let final_payload = result.modified_payload.unwrap();
2754 let typed = final_payload
2755 .as_any()
2756 .downcast_ref::<TestPayload>()
2757 .unwrap();
2758 assert_eq!(typed.value, "original_transformed");
2759 }
2760
2761 #[tokio::test]
2766 async fn test_transform_on_error_fail_does_not_halt_pipeline() {
2767 let mgr = PluginManager::default();
2768 let config =
2769 make_config_with_on_error("flaky-transform", 10, PluginMode::Transform, OnError::Fail);
2770 let plugin = Arc::new(AllowPlugin {
2771 cfg: config.clone(),
2772 });
2773 let handler: Arc<dyn AnyHookHandler> = Arc::new(ErrorHandler);
2774 mgr.register_raw::<TestHook>(plugin, config, handler)
2775 .unwrap();
2776
2777 mgr.initialize().await.unwrap();
2778
2779 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
2780 let (result, _) = mgr
2781 .invoke_by_name("test_hook", payload, Extensions::default(), None)
2782 .await;
2783
2784 assert!(
2785 result.continue_processing,
2786 "Transform on_error:Fail must not halt the pipeline (phase is non-blocking)",
2787 );
2788 assert!(result.violation.is_none());
2789 }
2790
2791 #[tokio::test]
2795 async fn test_audit_on_error_disable_disables_plugin() {
2796 let mgr = PluginManager::default();
2797 let config =
2798 make_config_with_on_error("flaky-audit", 10, PluginMode::Audit, OnError::Disable);
2799 let plugin = Arc::new(AllowPlugin {
2800 cfg: config.clone(),
2801 });
2802 let handler: Arc<dyn AnyHookHandler> = Arc::new(ErrorHandler);
2803 mgr.register_raw::<TestHook>(plugin, config, handler)
2804 .unwrap();
2805
2806 mgr.initialize().await.unwrap();
2807
2808 assert!(!mgr.get_plugin("flaky-audit").unwrap().is_disabled());
2809
2810 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
2813 let (result, _) = mgr
2814 .invoke_by_name("test_hook", payload, Extensions::default(), None)
2815 .await;
2816 assert!(result.continue_processing);
2817
2818 assert!(
2819 mgr.get_plugin("flaky-audit").unwrap().is_disabled(),
2820 "Audit phase must honor on_error:Disable",
2821 );
2822 }
2823
2824 #[tokio::test]
2825 async fn test_concurrent_multiple_plugins_all_run() {
2826 use std::sync::atomic::{AtomicUsize, Ordering};
2827
2828 static CALL_COUNT: AtomicUsize = AtomicUsize::new(0);
2830 CALL_COUNT.store(0, Ordering::SeqCst);
2831
2832 struct CountingHandler;
2833
2834 #[async_trait]
2835 impl AnyHookHandler for CountingHandler {
2836 async fn invoke(
2837 &self,
2838 _payload: &dyn PluginPayload,
2839 _extensions: &Extensions,
2840 _ctx: &mut PluginContext,
2841 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
2842 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2844 CALL_COUNT.fetch_add(1, Ordering::SeqCst);
2845 let result: PluginResult<TestPayload> = PluginResult::allow();
2846 Ok(crate::executor::erase_result(result))
2847 }
2848
2849 fn hook_type_name(&self) -> &'static str {
2850 "test_hook"
2851 }
2852 }
2853
2854 let mgr = PluginManager::default();
2855
2856 let c1 = make_config("concurrent-1", 10, PluginMode::Concurrent);
2857 let p1 = Arc::new(AllowPlugin { cfg: c1.clone() });
2858 let h1: Arc<dyn AnyHookHandler> = Arc::new(CountingHandler);
2859 mgr.register_raw::<TestHook>(p1, c1, h1).unwrap();
2860
2861 let c2 = make_config("concurrent-2", 20, PluginMode::Concurrent);
2862 let p2 = Arc::new(AllowPlugin { cfg: c2.clone() });
2863 let h2: Arc<dyn AnyHookHandler> = Arc::new(CountingHandler);
2864 mgr.register_raw::<TestHook>(p2, c2, h2).unwrap();
2865
2866 mgr.initialize().await.unwrap();
2867
2868 let start = std::time::Instant::now();
2869 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
2870 value: "test".into(),
2871 });
2872 let (result, _) = mgr
2873 .invoke_by_name("test_hook", payload, Extensions::default(), None)
2874 .await;
2875 let elapsed = start.elapsed();
2876
2877 assert!(result.continue_processing);
2878 assert_eq!(CALL_COUNT.load(Ordering::SeqCst), 2);
2879 assert!(
2881 elapsed.as_millis() < 90,
2882 "concurrent plugins ran serially: {}ms",
2883 elapsed.as_millis()
2884 );
2885 }
2886
2887 #[tokio::test]
2893 async fn test_concurrent_short_circuit_aborts_slow_plugin() {
2894 use std::sync::atomic::{AtomicUsize, Ordering};
2895 use std::time::Duration;
2896
2897 static SLOW_COMPLETED: AtomicUsize = AtomicUsize::new(0);
2898 SLOW_COMPLETED.store(0, Ordering::SeqCst);
2899
2900 struct DenyImmediately;
2901 #[async_trait]
2902 impl AnyHookHandler for DenyImmediately {
2903 async fn invoke(
2904 &self,
2905 _payload: &dyn PluginPayload,
2906 _extensions: &Extensions,
2907 _ctx: &mut PluginContext,
2908 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
2909 let result: PluginResult<TestPayload> =
2910 PluginResult::deny(PluginViolation::new("denied", "fast deny"));
2911 Ok(crate::executor::erase_result(result))
2912 }
2913 fn hook_type_name(&self) -> &'static str {
2914 "test_hook"
2915 }
2916 }
2917
2918 struct SlowSideEffect;
2919 #[async_trait]
2920 impl AnyHookHandler for SlowSideEffect {
2921 async fn invoke(
2922 &self,
2923 _payload: &dyn PluginPayload,
2924 _extensions: &Extensions,
2925 _ctx: &mut PluginContext,
2926 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
2927 tokio::time::sleep(Duration::from_secs(2)).await;
2928 SLOW_COMPLETED.fetch_add(1, Ordering::SeqCst);
2931 let result: PluginResult<TestPayload> = PluginResult::allow();
2932 Ok(crate::executor::erase_result(result))
2933 }
2934 fn hook_type_name(&self) -> &'static str {
2935 "test_hook"
2936 }
2937 }
2938
2939 let mgr = PluginManager::default();
2940
2941 let cfg_deny = make_config("denier", 10, PluginMode::Concurrent);
2942 let plugin_deny = Arc::new(AllowPlugin {
2943 cfg: cfg_deny.clone(),
2944 });
2945 mgr.register_raw::<TestHook>(
2946 plugin_deny,
2947 cfg_deny,
2948 Arc::new(DenyImmediately) as Arc<dyn AnyHookHandler>,
2949 )
2950 .unwrap();
2951
2952 let cfg_slow = make_config("slow", 20, PluginMode::Concurrent);
2953 let plugin_slow = Arc::new(AllowPlugin {
2954 cfg: cfg_slow.clone(),
2955 });
2956 mgr.register_raw::<TestHook>(
2957 plugin_slow,
2958 cfg_slow,
2959 Arc::new(SlowSideEffect) as Arc<dyn AnyHookHandler>,
2960 )
2961 .unwrap();
2962
2963 mgr.initialize().await.unwrap();
2964
2965 let start = std::time::Instant::now();
2968 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
2969 let (result, _) = mgr
2970 .invoke_by_name("test_hook", payload, Extensions::default(), None)
2971 .await;
2972 let elapsed = start.elapsed();
2973
2974 assert!(!result.continue_processing);
2975 assert!(
2976 elapsed < Duration::from_millis(500),
2977 "pipeline should short-circuit on deny, but took {}ms (slow plugin not aborted)",
2978 elapsed.as_millis(),
2979 );
2980
2981 tokio::time::sleep(Duration::from_millis(2_500)).await;
2984 assert_eq!(
2985 SLOW_COMPLETED.load(Ordering::SeqCst),
2986 0,
2987 "slow plugin's side effect ran after pipeline returned — task was not aborted",
2988 );
2989 }
2990
2991 #[tokio::test]
2994 async fn test_concurrent_no_short_circuit_runs_every_plugin() {
2995 use std::sync::atomic::{AtomicUsize, Ordering};
2996
2997 static ALLOW_RAN: AtomicUsize = AtomicUsize::new(0);
2998 ALLOW_RAN.store(0, Ordering::SeqCst);
2999
3000 struct DenyImmediately;
3001 #[async_trait]
3002 impl AnyHookHandler for DenyImmediately {
3003 async fn invoke(
3004 &self,
3005 _payload: &dyn PluginPayload,
3006 _extensions: &Extensions,
3007 _ctx: &mut PluginContext,
3008 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3009 let result: PluginResult<TestPayload> =
3010 PluginResult::deny(PluginViolation::new("denied", "fast deny"));
3011 Ok(crate::executor::erase_result(result))
3012 }
3013 fn hook_type_name(&self) -> &'static str {
3014 "test_hook"
3015 }
3016 }
3017
3018 struct AllowAndCount;
3019 #[async_trait]
3020 impl AnyHookHandler for AllowAndCount {
3021 async fn invoke(
3022 &self,
3023 _payload: &dyn PluginPayload,
3024 _extensions: &Extensions,
3025 _ctx: &mut PluginContext,
3026 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3027 ALLOW_RAN.fetch_add(1, Ordering::SeqCst);
3028 let result: PluginResult<TestPayload> = PluginResult::allow();
3029 Ok(crate::executor::erase_result(result))
3030 }
3031 fn hook_type_name(&self) -> &'static str {
3032 "test_hook"
3033 }
3034 }
3035
3036 let config = ManagerConfig {
3037 executor: crate::executor::ExecutorConfig {
3038 timeout_seconds: 30,
3039 short_circuit_on_deny: false,
3040 },
3041 route_cache_max_entries: DEFAULT_ROUTE_CACHE_MAX_ENTRIES,
3042 };
3043 let mgr = PluginManager::new(config);
3044
3045 let cfg_deny = make_config("denier", 10, PluginMode::Concurrent);
3046 let plugin_deny = Arc::new(AllowPlugin {
3047 cfg: cfg_deny.clone(),
3048 });
3049 mgr.register_raw::<TestHook>(
3050 plugin_deny,
3051 cfg_deny,
3052 Arc::new(DenyImmediately) as Arc<dyn AnyHookHandler>,
3053 )
3054 .unwrap();
3055
3056 let cfg_allow = make_config("allow", 20, PluginMode::Concurrent);
3057 let plugin_allow = Arc::new(AllowPlugin {
3058 cfg: cfg_allow.clone(),
3059 });
3060 mgr.register_raw::<TestHook>(
3061 plugin_allow,
3062 cfg_allow,
3063 Arc::new(AllowAndCount) as Arc<dyn AnyHookHandler>,
3064 )
3065 .unwrap();
3066
3067 mgr.initialize().await.unwrap();
3068
3069 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
3070 let (result, _) = mgr
3071 .invoke_by_name("test_hook", payload, Extensions::default(), None)
3072 .await;
3073
3074 assert!(!result.continue_processing);
3076 assert_eq!(ALLOW_RAN.load(Ordering::SeqCst), 1);
3078 }
3079
3080 struct PanicHandler;
3083
3084 #[async_trait]
3085 impl AnyHookHandler for PanicHandler {
3086 async fn invoke(
3087 &self,
3088 _payload: &dyn PluginPayload,
3089 _extensions: &Extensions,
3090 _ctx: &mut PluginContext,
3091 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3092 panic!("simulated panic in concurrent plugin task");
3093 }
3094 fn hook_type_name(&self) -> &'static str {
3095 "test_hook"
3096 }
3097 }
3098
3099 #[tokio::test]
3106 async fn test_concurrent_panic_with_on_error_fail_halts_pipeline() {
3107 let mgr = PluginManager::default();
3108
3109 let cfg =
3110 make_config_with_on_error("panic-plugin", 10, PluginMode::Concurrent, OnError::Fail);
3111 let plugin = Arc::new(AllowPlugin { cfg: cfg.clone() });
3112 let handler: Arc<dyn AnyHookHandler> = Arc::new(PanicHandler);
3113 mgr.register_raw::<TestHook>(plugin, cfg, handler).unwrap();
3114
3115 mgr.initialize().await.unwrap();
3116
3117 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
3118 let (result, _) = mgr
3119 .invoke_by_name("test_hook", payload, Extensions::default(), None)
3120 .await;
3121
3122 assert!(
3123 !result.continue_processing,
3124 "Fail must halt the pipeline on panic"
3125 );
3126 let v = result.violation.as_ref().expect("expected violation");
3127 assert_eq!(v.code, "plugin_panic");
3128 assert_eq!(v.plugin_name.as_deref(), Some("panic-plugin"));
3129 }
3130
3131 #[tokio::test]
3135 async fn test_concurrent_panic_with_on_error_disable_trips_circuit_breaker() {
3136 use std::sync::atomic::{AtomicUsize, Ordering};
3137
3138 static SURVIVOR_CALLS: AtomicUsize = AtomicUsize::new(0);
3139 SURVIVOR_CALLS.store(0, Ordering::SeqCst);
3140
3141 struct SurvivorHandler;
3142 #[async_trait]
3143 impl AnyHookHandler for SurvivorHandler {
3144 async fn invoke(
3145 &self,
3146 _payload: &dyn PluginPayload,
3147 _extensions: &Extensions,
3148 _ctx: &mut PluginContext,
3149 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3150 SURVIVOR_CALLS.fetch_add(1, Ordering::SeqCst);
3151 let result: PluginResult<TestPayload> = PluginResult::allow();
3152 Ok(crate::executor::erase_result(result))
3153 }
3154 fn hook_type_name(&self) -> &'static str {
3155 "test_hook"
3156 }
3157 }
3158
3159 let mgr = PluginManager::default();
3160
3161 let panic_cfg =
3162 make_config_with_on_error("panic-plugin", 10, PluginMode::Concurrent, OnError::Disable);
3163 let panic_plugin = Arc::new(AllowPlugin {
3164 cfg: panic_cfg.clone(),
3165 });
3166 let panic_handler: Arc<dyn AnyHookHandler> = Arc::new(PanicHandler);
3167 mgr.register_raw::<TestHook>(panic_plugin, panic_cfg, panic_handler)
3168 .unwrap();
3169
3170 let survivor_cfg = make_config("survivor", 20, PluginMode::Concurrent);
3171 let survivor_plugin = Arc::new(AllowPlugin {
3172 cfg: survivor_cfg.clone(),
3173 });
3174 let survivor_handler: Arc<dyn AnyHookHandler> = Arc::new(SurvivorHandler);
3175 mgr.register_raw::<TestHook>(survivor_plugin, survivor_cfg, survivor_handler)
3176 .unwrap();
3177
3178 mgr.initialize().await.unwrap();
3179
3180 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "1".into() });
3182 let (result1, _) = mgr
3183 .invoke_by_name("test_hook", payload, Extensions::default(), None)
3184 .await;
3185 assert!(
3186 result1.continue_processing,
3187 "Disable must not halt the pipeline"
3188 );
3189 assert_eq!(SURVIVOR_CALLS.load(Ordering::SeqCst), 1);
3190 assert!(
3191 mgr.get_plugin("panic-plugin").unwrap().is_disabled(),
3192 "panic plugin must be disabled after the panic",
3193 );
3194
3195 let payload2: Box<dyn PluginPayload> = Box::new(TestPayload { value: "2".into() });
3197 let (result2, _) = mgr
3198 .invoke_by_name("test_hook", payload2, Extensions::default(), None)
3199 .await;
3200 assert!(result2.continue_processing);
3201 assert_eq!(SURVIVOR_CALLS.load(Ordering::SeqCst), 2);
3203 }
3204
3205 #[tokio::test]
3206 async fn test_timeout_fires_on_slow_handler() {
3207 let config = ManagerConfig {
3209 executor: crate::executor::ExecutorConfig {
3210 timeout_seconds: 1,
3211 short_circuit_on_deny: true,
3212 },
3213 route_cache_max_entries: DEFAULT_ROUTE_CACHE_MAX_ENTRIES,
3214 };
3215 let mgr = PluginManager::new(config);
3216
3217 let plugin_config = make_config("slow-plugin", 10, PluginMode::Sequential);
3219 let plugin = Arc::new(AllowPlugin {
3220 cfg: plugin_config.clone(),
3221 });
3222 let handler: Arc<dyn AnyHookHandler> = Arc::new(SlowHandler { delay_ms: 5000 });
3223 mgr.register_raw::<TestHook>(plugin, plugin_config, handler)
3224 .unwrap();
3225
3226 mgr.initialize().await.unwrap();
3227
3228 let start = std::time::Instant::now();
3229 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
3230 value: "test".into(),
3231 });
3232 let (result, _) = mgr
3233 .invoke_by_name("test_hook", payload, Extensions::default(), None)
3234 .await;
3235 let elapsed = start.elapsed();
3236
3237 assert!(!result.continue_processing);
3239 assert_eq!(result.violation.as_ref().unwrap().code, "plugin_timeout");
3240 assert!(
3242 elapsed.as_secs() < 3,
3243 "timeout didn't fire: {}s",
3244 elapsed.as_secs()
3245 );
3246 }
3247
3248 #[tokio::test]
3249 async fn test_fire_and_forget_returns_before_task_completes() {
3250 use std::sync::atomic::{AtomicBool, Ordering};
3251
3252 static TASK_COMPLETED: AtomicBool = AtomicBool::new(false);
3253 TASK_COMPLETED.store(false, Ordering::SeqCst);
3254
3255 struct SlowFireAndForgetHandler;
3256
3257 #[async_trait]
3258 impl AnyHookHandler for SlowFireAndForgetHandler {
3259 async fn invoke(
3260 &self,
3261 _payload: &dyn PluginPayload,
3262 _extensions: &Extensions,
3263 _ctx: &mut PluginContext,
3264 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3265 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
3266 TASK_COMPLETED.store(true, Ordering::SeqCst);
3267 let result: PluginResult<TestPayload> = PluginResult::allow();
3268 Ok(crate::executor::erase_result(result))
3269 }
3270
3271 fn hook_type_name(&self) -> &'static str {
3272 "test_hook"
3273 }
3274 }
3275
3276 let mgr = PluginManager::default();
3277
3278 let config = make_config("fire-forget", 10, PluginMode::FireAndForget);
3279 let plugin = Arc::new(AllowPlugin {
3280 cfg: config.clone(),
3281 });
3282 let handler: Arc<dyn AnyHookHandler> = Arc::new(SlowFireAndForgetHandler);
3283 mgr.register_raw::<TestHook>(plugin, config, handler)
3284 .unwrap();
3285
3286 mgr.initialize().await.unwrap();
3287
3288 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
3289 value: "test".into(),
3290 });
3291 let (result, bg) = mgr
3292 .invoke_by_name("test_hook", payload, Extensions::default(), None)
3293 .await;
3294
3295 assert!(result.continue_processing);
3297 assert!(
3298 !TASK_COMPLETED.load(Ordering::SeqCst),
3299 "fire-and-forget task completed before pipeline returned"
3300 );
3301
3302 let errors = bg.wait_for_background_tasks().await;
3304 assert!(
3305 errors.is_empty(),
3306 "background task had errors: {:?}",
3307 errors
3308 );
3309 assert!(
3310 TASK_COMPLETED.load(Ordering::SeqCst),
3311 "fire-and-forget task never completed"
3312 );
3313 }
3314
3315 #[tokio::test]
3322 async fn test_shutdown_drains_in_flight_fire_and_forget_tasks() {
3323 use std::sync::atomic::{AtomicBool, Ordering};
3324
3325 static FAF_COMPLETED: AtomicBool = AtomicBool::new(false);
3326 FAF_COMPLETED.store(false, Ordering::SeqCst);
3327
3328 struct SlowFafHandler;
3329 #[async_trait]
3330 impl AnyHookHandler for SlowFafHandler {
3331 async fn invoke(
3332 &self,
3333 _payload: &dyn PluginPayload,
3334 _extensions: &Extensions,
3335 _ctx: &mut PluginContext,
3336 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3337 tokio::time::sleep(std::time::Duration::from_millis(150)).await;
3338 FAF_COMPLETED.store(true, Ordering::SeqCst);
3339 let result: PluginResult<TestPayload> = PluginResult::allow();
3340 Ok(crate::executor::erase_result(result))
3341 }
3342 fn hook_type_name(&self) -> &'static str {
3343 "test_hook"
3344 }
3345 }
3346
3347 let mgr = PluginManager::default();
3348 let config = make_config("slow-faf", 10, PluginMode::FireAndForget);
3349 let plugin = Arc::new(AllowPlugin {
3350 cfg: config.clone(),
3351 });
3352 let handler: Arc<dyn AnyHookHandler> = Arc::new(SlowFafHandler);
3353 mgr.register_raw::<TestHook>(plugin, config, handler)
3354 .unwrap();
3355 mgr.initialize().await.unwrap();
3356
3357 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
3360 let (_result, _bg_dropped) = mgr
3361 .invoke_by_name("test_hook", payload, Extensions::default(), None)
3362 .await;
3363
3364 assert!(!FAF_COMPLETED.load(Ordering::SeqCst));
3366
3367 mgr.shutdown().await;
3369
3370 assert!(
3372 FAF_COMPLETED.load(Ordering::SeqCst),
3373 "shutdown returned before fire-and-forget task finished — task was abandoned",
3374 );
3375 }
3376
3377 #[tokio::test]
3378 async fn test_global_state_flows_between_serial_plugins() {
3379 struct WriterHandler;
3382
3383 #[async_trait]
3384 impl AnyHookHandler for WriterHandler {
3385 async fn invoke(
3386 &self,
3387 _payload: &dyn PluginPayload,
3388 _extensions: &Extensions,
3389 ctx: &mut PluginContext,
3390 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3391 ctx.set_global("writer_was_here", serde_json::Value::Bool(true));
3392 let result: PluginResult<TestPayload> = PluginResult::allow();
3393 Ok(crate::executor::erase_result(result))
3394 }
3395 fn hook_type_name(&self) -> &'static str {
3396 "test_hook"
3397 }
3398 }
3399
3400 struct ReaderHandler {
3401 saw_writer: std::sync::Arc<std::sync::atomic::AtomicBool>,
3402 }
3403
3404 #[async_trait]
3405 impl AnyHookHandler for ReaderHandler {
3406 async fn invoke(
3407 &self,
3408 _payload: &dyn PluginPayload,
3409 _extensions: &Extensions,
3410 ctx: &mut PluginContext,
3411 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3412 if ctx.get_global("writer_was_here").is_some() {
3413 self.saw_writer
3414 .store(true, std::sync::atomic::Ordering::SeqCst);
3415 }
3416 let result: PluginResult<TestPayload> = PluginResult::allow();
3417 Ok(crate::executor::erase_result(result))
3418 }
3419 fn hook_type_name(&self) -> &'static str {
3420 "test_hook"
3421 }
3422 }
3423
3424 let saw_writer = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
3425
3426 let mgr = PluginManager::default();
3427
3428 let c1 = make_config("writer", 10, PluginMode::Sequential);
3430 let p1 = Arc::new(AllowPlugin { cfg: c1.clone() });
3431 let h1: Arc<dyn AnyHookHandler> = Arc::new(WriterHandler);
3432 mgr.register_raw::<TestHook>(p1, c1, h1).unwrap();
3433
3434 let c2 = make_config("reader", 20, PluginMode::Sequential);
3436 let p2 = Arc::new(AllowPlugin { cfg: c2.clone() });
3437 let h2: Arc<dyn AnyHookHandler> = Arc::new(ReaderHandler {
3438 saw_writer: saw_writer.clone(),
3439 });
3440 mgr.register_raw::<TestHook>(p2, c2, h2).unwrap();
3441
3442 mgr.initialize().await.unwrap();
3443
3444 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
3445 value: "test".into(),
3446 });
3447 let (result, _) = mgr
3448 .invoke_by_name("test_hook", payload, Extensions::default(), None)
3449 .await;
3450
3451 assert!(result.continue_processing);
3452 assert!(
3453 saw_writer.load(std::sync::atomic::Ordering::SeqCst),
3454 "reader plugin did not see writer's global_state change"
3455 );
3456 }
3457
3458 #[tokio::test]
3459 async fn test_local_state_persists_across_hook_invocations() {
3460 struct LocalWriterHandler;
3464
3465 #[async_trait]
3466 impl AnyHookHandler for LocalWriterHandler {
3467 async fn invoke(
3468 &self,
3469 _payload: &dyn PluginPayload,
3470 _extensions: &Extensions,
3471 ctx: &mut PluginContext,
3472 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3473 let count = ctx
3475 .get_local("call_count")
3476 .and_then(|v| v.as_u64())
3477 .unwrap_or(0);
3478 ctx.set_local("call_count", serde_json::Value::from(count + 1));
3479 let result: PluginResult<TestPayload> = PluginResult::allow();
3480 Ok(crate::executor::erase_result(result))
3481 }
3482 fn hook_type_name(&self) -> &'static str {
3483 "test_hook"
3484 }
3485 }
3486
3487 let mgr = PluginManager::default();
3488
3489 let config = make_config("counter", 10, PluginMode::Sequential);
3490 let plugin = Arc::new(AllowPlugin {
3491 cfg: config.clone(),
3492 });
3493 let handler: Arc<dyn AnyHookHandler> = Arc::new(LocalWriterHandler);
3494 mgr.register_raw::<TestHook>(plugin, config, handler)
3495 .unwrap();
3496
3497 mgr.initialize().await.unwrap();
3498
3499 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
3501 value: "first".into(),
3502 });
3503 let (result1, _) = mgr
3504 .invoke_by_name("test_hook", payload, Extensions::default(), None)
3505 .await;
3506 assert!(result1.continue_processing);
3507
3508 let table = &result1.context_table;
3510 let local = table
3511 .local_states
3512 .values()
3513 .next()
3514 .expect("context table should have one local_state entry");
3515 assert_eq!(local.get("call_count").unwrap().as_u64().unwrap(), 1);
3516
3517 let payload2: Box<dyn PluginPayload> = Box::new(TestPayload {
3519 value: "second".into(),
3520 });
3521 let (result2, _) = mgr
3522 .invoke_by_name(
3523 "test_hook",
3524 payload2,
3525 Extensions::default(),
3526 Some(result1.context_table),
3527 )
3528 .await;
3529 assert!(result2.continue_processing);
3530
3531 let table2 = &result2.context_table;
3533 let local2 = table2
3534 .local_states
3535 .values()
3536 .next()
3537 .expect("context table should have one local_state entry");
3538 assert_eq!(local2.get("call_count").unwrap().as_u64().unwrap(), 2);
3539 }
3540
3541 #[tokio::test]
3547 async fn test_global_state_propagates_in_priority_order() {
3548 struct GlobalChainHandler {
3552 tag: &'static str,
3553 }
3554
3555 #[async_trait]
3556 impl AnyHookHandler for GlobalChainHandler {
3557 async fn invoke(
3558 &self,
3559 _payload: &dyn PluginPayload,
3560 _extensions: &Extensions,
3561 ctx: &mut PluginContext,
3562 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3563 let mut chain = ctx
3564 .get_global("chain")
3565 .and_then(|v| v.as_array())
3566 .cloned()
3567 .unwrap_or_default();
3568 chain.push(serde_json::Value::String(self.tag.into()));
3569 ctx.set_global("chain", serde_json::Value::Array(chain));
3570 let result: PluginResult<TestPayload> = PluginResult::allow();
3571 Ok(crate::executor::erase_result(result))
3572 }
3573 fn hook_type_name(&self) -> &'static str {
3574 "test_hook"
3575 }
3576 }
3577
3578 let mgr = PluginManager::default();
3579
3580 let cfg_a = make_config("plugin_a", 10, PluginMode::Sequential);
3582 let plugin_a = Arc::new(AllowPlugin { cfg: cfg_a.clone() });
3583 let handler_a: Arc<dyn AnyHookHandler> = Arc::new(GlobalChainHandler { tag: "a" });
3584 mgr.register_raw::<TestHook>(plugin_a, cfg_a, handler_a)
3585 .unwrap();
3586
3587 let cfg_b = make_config("plugin_b", 20, PluginMode::Sequential);
3589 let plugin_b = Arc::new(AllowPlugin { cfg: cfg_b.clone() });
3590 let handler_b: Arc<dyn AnyHookHandler> = Arc::new(GlobalChainHandler { tag: "b" });
3591 mgr.register_raw::<TestHook>(plugin_b, cfg_b, handler_b)
3592 .unwrap();
3593
3594 mgr.initialize().await.unwrap();
3595
3596 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
3597 let (result, _) = mgr
3598 .invoke_by_name("test_hook", payload, Extensions::default(), None)
3599 .await;
3600 assert!(result.continue_processing);
3601
3602 let chain = result
3607 .context_table
3608 .global_state
3609 .get("chain")
3610 .and_then(|v| v.as_array())
3611 .expect("global_state.chain should be an array");
3612 let tags: Vec<&str> = chain.iter().filter_map(|v| v.as_str()).collect();
3613 assert_eq!(tags, vec!["a", "b"]);
3614 }
3615
3616 #[tokio::test]
3621 async fn test_all_five_phases_run_in_order_with_payload_chaining() {
3622 use std::sync::Arc as StdArc;
3623 use std::sync::Mutex as StdMutex;
3624
3625 let log: StdArc<StdMutex<Vec<&'static str>>> = StdArc::new(StdMutex::new(Vec::new()));
3626
3627 struct SeqHandler {
3629 log: StdArc<StdMutex<Vec<&'static str>>>,
3630 }
3631 #[async_trait]
3632 impl AnyHookHandler for SeqHandler {
3633 async fn invoke(
3634 &self,
3635 payload: &dyn PluginPayload,
3636 _extensions: &Extensions,
3637 _ctx: &mut PluginContext,
3638 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3639 self.log.lock().unwrap().push("seq");
3640 let typed = payload.as_any().downcast_ref::<TestPayload>().unwrap();
3641 let modified = TestPayload {
3642 value: format!("{}|seq", typed.value),
3643 };
3644 let result: PluginResult<TestPayload> = PluginResult::modify_payload(modified);
3645 Ok(crate::executor::erase_result(result))
3646 }
3647 fn hook_type_name(&self) -> &'static str {
3648 "test_hook"
3649 }
3650 }
3651
3652 struct TransformLogger {
3654 log: StdArc<StdMutex<Vec<&'static str>>>,
3655 }
3656 #[async_trait]
3657 impl AnyHookHandler for TransformLogger {
3658 async fn invoke(
3659 &self,
3660 payload: &dyn PluginPayload,
3661 _extensions: &Extensions,
3662 _ctx: &mut PluginContext,
3663 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3664 self.log.lock().unwrap().push("transform");
3665 let typed = payload.as_any().downcast_ref::<TestPayload>().unwrap();
3666 let modified = TestPayload {
3667 value: format!("{}|transform", typed.value),
3668 };
3669 let result: PluginResult<TestPayload> = PluginResult::modify_payload(modified);
3670 Ok(crate::executor::erase_result(result))
3671 }
3672 fn hook_type_name(&self) -> &'static str {
3673 "test_hook"
3674 }
3675 }
3676
3677 struct ObserverHandler {
3680 tag: &'static str,
3681 log: StdArc<StdMutex<Vec<&'static str>>>,
3682 expected_payload: &'static str,
3683 }
3684 #[async_trait]
3685 impl AnyHookHandler for ObserverHandler {
3686 async fn invoke(
3687 &self,
3688 payload: &dyn PluginPayload,
3689 _extensions: &Extensions,
3690 _ctx: &mut PluginContext,
3691 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3692 let typed = payload.as_any().downcast_ref::<TestPayload>().unwrap();
3693 assert_eq!(
3694 typed.value, self.expected_payload,
3695 "{} observed unexpected payload: got '{}', expected '{}'",
3696 self.tag, typed.value, self.expected_payload,
3697 );
3698 self.log.lock().unwrap().push(self.tag);
3699 let result: PluginResult<TestPayload> = PluginResult::allow();
3700 Ok(crate::executor::erase_result(result))
3701 }
3702 fn hook_type_name(&self) -> &'static str {
3703 "test_hook"
3704 }
3705 }
3706
3707 let mgr = PluginManager::default();
3708
3709 let cfg_seq = make_config("seq", 10, PluginMode::Sequential);
3710 mgr.register_raw::<TestHook>(
3711 Arc::new(AllowPlugin {
3712 cfg: cfg_seq.clone(),
3713 }),
3714 cfg_seq,
3715 Arc::new(SeqHandler {
3716 log: StdArc::clone(&log),
3717 }),
3718 )
3719 .unwrap();
3720
3721 let cfg_transform = make_config("transform", 10, PluginMode::Transform);
3722 mgr.register_raw::<TestHook>(
3723 Arc::new(AllowPlugin {
3724 cfg: cfg_transform.clone(),
3725 }),
3726 cfg_transform,
3727 Arc::new(TransformLogger {
3728 log: StdArc::clone(&log),
3729 }),
3730 )
3731 .unwrap();
3732
3733 let cfg_audit = make_config("audit", 10, PluginMode::Audit);
3734 mgr.register_raw::<TestHook>(
3735 Arc::new(AllowPlugin {
3736 cfg: cfg_audit.clone(),
3737 }),
3738 cfg_audit,
3739 Arc::new(ObserverHandler {
3740 tag: "audit",
3741 log: StdArc::clone(&log),
3742 expected_payload: "start|seq|transform",
3743 }),
3744 )
3745 .unwrap();
3746
3747 let cfg_concurrent = make_config("concurrent", 10, PluginMode::Concurrent);
3748 mgr.register_raw::<TestHook>(
3749 Arc::new(AllowPlugin {
3750 cfg: cfg_concurrent.clone(),
3751 }),
3752 cfg_concurrent,
3753 Arc::new(ObserverHandler {
3754 tag: "concurrent",
3755 log: StdArc::clone(&log),
3756 expected_payload: "start|seq|transform",
3757 }),
3758 )
3759 .unwrap();
3760
3761 let cfg_faf = make_config("faf", 10, PluginMode::FireAndForget);
3762 mgr.register_raw::<TestHook>(
3763 Arc::new(AllowPlugin {
3764 cfg: cfg_faf.clone(),
3765 }),
3766 cfg_faf,
3767 Arc::new(ObserverHandler {
3768 tag: "faf",
3769 log: StdArc::clone(&log),
3770 expected_payload: "start|seq|transform",
3771 }),
3772 )
3773 .unwrap();
3774
3775 mgr.initialize().await.unwrap();
3776
3777 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
3778 value: "start".into(),
3779 });
3780 let (result, bg) = mgr
3781 .invoke_by_name("test_hook", payload, Extensions::default(), None)
3782 .await;
3783
3784 assert!(result.continue_processing);
3785 let final_payload = result.modified_payload.unwrap();
3787 let typed = final_payload
3788 .as_any()
3789 .downcast_ref::<TestPayload>()
3790 .unwrap();
3791 assert_eq!(typed.value, "start|seq|transform");
3792
3793 let _ = bg.wait_for_background_tasks().await;
3796
3797 let log = log.lock().unwrap();
3798 assert_eq!(log[0], "seq", "first should be sequential phase");
3800 assert_eq!(log[1], "transform", "second should be transform phase");
3801 assert_eq!(log[2], "audit", "third should be audit phase");
3802 let post_audit: std::collections::HashSet<&&'static str> = log[3..].iter().collect();
3807 assert!(
3808 post_audit.contains(&"concurrent"),
3809 "concurrent phase must run"
3810 );
3811 assert!(post_audit.contains(&"faf"), "fire-and-forget must run");
3812 assert_eq!(log.len(), 5, "all five phases should have logged");
3813 }
3814
3815 #[tokio::test]
3819 async fn test_routing_works_for_all_entity_types() {
3820 use std::sync::atomic::{AtomicUsize, Ordering};
3821 use std::sync::Arc as StdArc;
3822
3823 struct CountHandler {
3826 counter: StdArc<AtomicUsize>,
3827 }
3828 #[async_trait]
3829 impl AnyHookHandler for CountHandler {
3830 async fn invoke(
3831 &self,
3832 _payload: &dyn PluginPayload,
3833 _extensions: &Extensions,
3834 _ctx: &mut PluginContext,
3835 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3836 self.counter.fetch_add(1, Ordering::SeqCst);
3837 let result: PluginResult<TestPayload> = PluginResult::allow();
3838 Ok(crate::executor::erase_result(result))
3839 }
3840 fn hook_type_name(&self) -> &'static str {
3841 "test_hook"
3842 }
3843 }
3844
3845 for (entity_type, route_field, route_value, request_name, should_match) in [
3848 ("resource", "resource", "my_resource", "my_resource", true),
3849 (
3850 "resource",
3851 "resource",
3852 "my_resource",
3853 "other_resource",
3854 false,
3855 ),
3856 ("prompt", "prompt", "my_prompt", "my_prompt", true),
3857 ("prompt", "prompt", "my_prompt", "other_prompt", false),
3858 ("llm", "llm", "gpt-4", "gpt-4", true),
3859 ("llm", "llm", "gpt-4", "claude", false),
3860 ] {
3861 let yaml = format!(
3862 r#"
3863plugin_settings:
3864 routing_enabled: true
3865plugins:
3866 - name: target
3867 kind: test/allow
3868 hooks: [test_hook]
3869 mode: sequential
3870routes:
3871 - {route_field}: {route_value}
3872 plugins:
3873 - target
3874"#
3875 );
3876 let cpex_config = crate::config::parse_config(&yaml).unwrap();
3877
3878 let mgr = PluginManager::default();
3879 let counter = StdArc::new(AtomicUsize::new(0));
3880 struct ParamFactory(StdArc<AtomicUsize>);
3882 impl crate::factory::PluginFactory for ParamFactory {
3883 fn create(
3884 &self,
3885 config: &PluginConfig,
3886 ) -> Result<crate::factory::PluginInstance, Box<PluginError>> {
3887 Ok(crate::factory::PluginInstance {
3888 plugin: Arc::new(AllowPlugin {
3889 cfg: config.clone(),
3890 }),
3891 handlers: vec![(
3892 "test_hook",
3893 Arc::new(CountHandler {
3894 counter: StdArc::clone(&self.0),
3895 }),
3896 )],
3897 })
3898 }
3899 }
3900 mgr.register_factory(
3901 "test/allow",
3902 Box::new(ParamFactory(StdArc::clone(&counter))),
3903 );
3904 mgr.load_config(cpex_config).unwrap();
3905 mgr.initialize().await.unwrap();
3906
3907 let p: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
3908 let ext = Extensions {
3909 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
3910 entity_type: Some(entity_type.into()),
3911 entity_name: Some(request_name.into()),
3912 ..Default::default()
3913 })),
3914 ..Default::default()
3915 };
3916 let _ = mgr.invoke_by_name("test_hook", p, ext, None).await;
3917
3918 let expected = if should_match { 1 } else { 0 };
3919 assert_eq!(
3920 counter.load(Ordering::SeqCst),
3921 expected,
3922 "entity_type={} route_field={} route_value={} request_name={} expected fire={}",
3923 entity_type,
3924 route_field,
3925 route_value,
3926 request_name,
3927 should_match,
3928 );
3929 }
3930 }
3931
3932 #[tokio::test]
3937 async fn test_initialize_rollback_on_failure() {
3938 use std::sync::atomic::{AtomicUsize, Ordering};
3939 use std::sync::Arc as StdArc;
3940
3941 let init_count_a = StdArc::new(AtomicUsize::new(0));
3943 let shutdown_count_a = StdArc::new(AtomicUsize::new(0));
3944 let init_count_b = StdArc::new(AtomicUsize::new(0));
3945 let shutdown_count_b = StdArc::new(AtomicUsize::new(0));
3946 let init_count_c = StdArc::new(AtomicUsize::new(0));
3947 let shutdown_count_c = StdArc::new(AtomicUsize::new(0));
3948
3949 struct LifecyclePlugin {
3950 cfg: PluginConfig,
3951 init_counter: StdArc<AtomicUsize>,
3952 shutdown_counter: StdArc<AtomicUsize>,
3953 fail_init: bool,
3954 }
3955 #[async_trait]
3956 impl Plugin for LifecyclePlugin {
3957 fn config(&self) -> &PluginConfig {
3958 &self.cfg
3959 }
3960 async fn initialize(&self) -> Result<(), Box<PluginError>> {
3961 self.init_counter.fetch_add(1, Ordering::SeqCst);
3962 if self.fail_init {
3963 Err(Box::new(PluginError::Config {
3964 message: "intentional init failure".into(),
3965 }))
3966 } else {
3967 Ok(())
3968 }
3969 }
3970 async fn shutdown(&self) -> Result<(), Box<PluginError>> {
3971 self.shutdown_counter.fetch_add(1, Ordering::SeqCst);
3972 Ok(())
3973 }
3974 }
3975 impl HookHandler<TestHook> for LifecyclePlugin {
3976 async fn handle(
3977 &self,
3978 _payload: &TestPayload,
3979 _extensions: &Extensions,
3980 _ctx: &mut PluginContext,
3981 ) -> PluginResult<TestPayload> {
3982 PluginResult::allow()
3983 }
3984 }
3985
3986 let mgr = PluginManager::default();
3987
3988 let cfg_a = make_config("a", 10, PluginMode::Sequential);
3990 let plugin_a = Arc::new(LifecyclePlugin {
3991 cfg: cfg_a.clone(),
3992 init_counter: StdArc::clone(&init_count_a),
3993 shutdown_counter: StdArc::clone(&shutdown_count_a),
3994 fail_init: false,
3995 });
3996 mgr.register_handler::<TestHook, _>(plugin_a, cfg_a)
3997 .unwrap();
3998
3999 let cfg_b = make_config("b", 20, PluginMode::Sequential);
4001 let plugin_b = Arc::new(LifecyclePlugin {
4002 cfg: cfg_b.clone(),
4003 init_counter: StdArc::clone(&init_count_b),
4004 shutdown_counter: StdArc::clone(&shutdown_count_b),
4005 fail_init: true,
4006 });
4007 mgr.register_handler::<TestHook, _>(plugin_b, cfg_b)
4008 .unwrap();
4009
4010 let cfg_c = make_config("c", 30, PluginMode::Sequential);
4012 let plugin_c = Arc::new(LifecyclePlugin {
4013 cfg: cfg_c.clone(),
4014 init_counter: StdArc::clone(&init_count_c),
4015 shutdown_counter: StdArc::clone(&shutdown_count_c),
4016 fail_init: false,
4017 });
4018 mgr.register_handler::<TestHook, _>(plugin_c, cfg_c)
4019 .unwrap();
4020
4021 let result = mgr.initialize().await;
4022 assert!(
4023 result.is_err(),
4024 "initialize() must propagate the init failure"
4025 );
4026
4027 let assert_pair_invariant =
4038 |init: &AtomicUsize, shutdown: &AtomicUsize, tag: &str| {
4039 let i = init.load(Ordering::SeqCst);
4040 let s = shutdown.load(Ordering::SeqCst);
4041 assert!(
4042 (i == 0 && s == 0) || (i == 1 && s == 1),
4043 "{}: init/shutdown should be paired (both 0 or both 1), got init={} shutdown={}",
4044 tag, i, s,
4045 );
4046 };
4047 assert_pair_invariant(&init_count_a, &shutdown_count_a, "A");
4048 assert_pair_invariant(&init_count_c, &shutdown_count_c, "C");
4049
4050 assert_eq!(
4052 init_count_b.load(Ordering::SeqCst),
4053 1,
4054 "B's initialize was called",
4055 );
4056 assert_eq!(
4057 shutdown_count_b.load(Ordering::SeqCst),
4058 0,
4059 "B failed to initialize; shutdown should not run for it",
4060 );
4061
4062 assert!(!mgr.is_initialized());
4064 }
4065
4066 struct AllowPluginFactory;
4070
4071 impl crate::factory::PluginFactory for AllowPluginFactory {
4072 fn create(
4073 &self,
4074 config: &PluginConfig,
4075 ) -> Result<crate::factory::PluginInstance, Box<PluginError>> {
4076 let plugin = Arc::new(AllowPlugin {
4077 cfg: config.clone(),
4078 });
4079 let handler: Arc<dyn AnyHookHandler> =
4080 Arc::new(TypedHandlerAdapter::<TestHook, AllowPlugin>::new(
4081 Arc::clone(&plugin),
4082 ));
4083 Ok(crate::factory::PluginInstance {
4084 plugin,
4085 handlers: vec![("test_hook", handler)],
4086 })
4087 }
4088 }
4089
4090 struct DenyPluginFactory;
4092
4093 impl crate::factory::PluginFactory for DenyPluginFactory {
4094 fn create(
4095 &self,
4096 config: &PluginConfig,
4097 ) -> Result<crate::factory::PluginInstance, Box<PluginError>> {
4098 let plugin = Arc::new(DenyPlugin {
4099 cfg: config.clone(),
4100 });
4101 let handler: Arc<dyn AnyHookHandler> =
4102 Arc::new(TypedHandlerAdapter::<TestHook, DenyPlugin>::new(
4103 Arc::clone(&plugin),
4104 ));
4105 Ok(crate::factory::PluginInstance {
4106 plugin,
4107 handlers: vec![("test_hook", handler)],
4108 })
4109 }
4110 }
4111
4112 #[tokio::test]
4113 async fn test_from_config_creates_manager() {
4114 let yaml = r#"
4115plugins:
4116 - name: allow_plugin
4117 kind: test/allow
4118 hooks: [test_hook]
4119 mode: sequential
4120 priority: 10
4121
4122plugin_settings:
4123 plugin_timeout: 60
4124"#;
4125 let cpex_config = crate::config::parse_config(yaml).unwrap();
4126
4127 let mut factories = PluginFactoryRegistry::new();
4128 factories.register("test/allow", Box::new(AllowPluginFactory));
4129
4130 let mgr = PluginManager::from_config(cpex_config, &factories).unwrap();
4131 mgr.initialize().await.unwrap();
4132
4133 assert_eq!(mgr.plugin_count(), 1);
4134 assert!(mgr.has_hooks_for("test_hook"));
4135 }
4136
4137 #[tokio::test]
4138 async fn test_from_config_invokes_correctly() {
4139 let yaml = r#"
4140plugins:
4141 - name: denier
4142 kind: test/deny
4143 hooks: [test_hook]
4144 mode: sequential
4145 priority: 10
4146"#;
4147 let cpex_config = crate::config::parse_config(yaml).unwrap();
4148
4149 let mut factories = PluginFactoryRegistry::new();
4150 factories.register("test/deny", Box::new(DenyPluginFactory));
4151
4152 let mgr = PluginManager::from_config(cpex_config, &factories).unwrap();
4153 mgr.initialize().await.unwrap();
4154
4155 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
4156 value: "test".into(),
4157 });
4158 let (result, _) = mgr
4161 .invoke_by_name("test_hook", payload, Extensions::default(), None)
4162 .await;
4163
4164 assert!(!result.continue_processing);
4165 assert_eq!(result.violation.as_ref().unwrap().code, "denied");
4166 }
4167
4168 #[tokio::test]
4169 async fn test_from_config_unknown_kind_rejected() {
4170 let yaml = r#"
4171plugins:
4172 - name: mystery
4173 kind: unknown/type
4174 hooks: [test_hook]
4175"#;
4176 let cpex_config = crate::config::parse_config(yaml).unwrap();
4177 let factories = PluginFactoryRegistry::new(); let result = PluginManager::from_config(cpex_config, &factories);
4180 match result {
4181 Err(e) => assert!(
4182 e.to_string().contains("no factory registered"),
4183 "got: {}",
4184 e
4185 ),
4186 Ok(_) => panic!("expected error for unknown kind"),
4187 }
4188 }
4189
4190 #[tokio::test]
4191 async fn test_from_config_multiple_plugins() {
4192 let yaml = r#"
4193plugins:
4194 - name: gate
4195 kind: test/deny
4196 hooks: [test_hook]
4197 mode: sequential
4198 priority: 5
4199 - name: fallback
4200 kind: test/allow
4201 hooks: [test_hook]
4202 mode: sequential
4203 priority: 10
4204"#;
4205 let cpex_config = crate::config::parse_config(yaml).unwrap();
4206
4207 let mut factories = PluginFactoryRegistry::new();
4208 factories.register("test/allow", Box::new(AllowPluginFactory));
4209 factories.register("test/deny", Box::new(DenyPluginFactory));
4210
4211 let mgr = PluginManager::from_config(cpex_config, &factories).unwrap();
4212 mgr.initialize().await.unwrap();
4213
4214 assert_eq!(mgr.plugin_count(), 2);
4215
4216 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
4218 value: "test".into(),
4219 });
4220 let (result, _) = mgr
4223 .invoke_by_name("test_hook", payload, Extensions::default(), None)
4224 .await;
4225
4226 assert!(!result.continue_processing); }
4228
4229 #[tokio::test]
4232 async fn test_routing_cache_populated_on_first_invoke() {
4233 let yaml = r#"
4234plugin_settings:
4235 routing_enabled: true
4236global:
4237 policies:
4238 all:
4239 plugins: [allow_plugin]
4240plugins:
4241 - name: allow_plugin
4242 kind: test/allow
4243 hooks: [test_hook]
4244 mode: sequential
4245 priority: 10
4246routes:
4247 - tool: get_compensation
4248"#;
4249 let cpex_config = crate::config::parse_config(yaml).unwrap();
4250 let mut factories = PluginFactoryRegistry::new();
4251 factories.register("test/allow", Box::new(AllowPluginFactory));
4252
4253 let mgr = PluginManager::from_config(cpex_config, &factories).unwrap();
4254 mgr.initialize().await.unwrap();
4255
4256 assert_eq!(mgr.routing_cache_size(), 0);
4257
4258 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
4260 value: "test".into(),
4261 });
4262 let ext = Extensions {
4263 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4264 entity_type: Some("tool".into()),
4265 entity_name: Some("get_compensation".into()),
4266 ..Default::default()
4267 })),
4268 ..Default::default()
4269 };
4270 mgr.invoke_by_name("test_hook", payload, ext, None).await;
4272
4273 assert_eq!(mgr.routing_cache_size(), 1);
4274
4275 let payload2: Box<dyn PluginPayload> = Box::new(TestPayload {
4277 value: "test2".into(),
4278 });
4279 let ext2 = Extensions {
4280 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4281 entity_type: Some("tool".into()),
4282 entity_name: Some("get_compensation".into()),
4283 ..Default::default()
4284 })),
4285 ..Default::default()
4286 };
4287 mgr.invoke_by_name("test_hook", payload2, ext2, None).await;
4288
4289 assert_eq!(mgr.routing_cache_size(), 1); }
4291
4292 #[tokio::test]
4293 async fn test_routing_cache_different_entities_separate() {
4294 let yaml = r#"
4295plugin_settings:
4296 routing_enabled: true
4297global:
4298 policies:
4299 all:
4300 plugins: [allow_plugin]
4301plugins:
4302 - name: allow_plugin
4303 kind: test/allow
4304 hooks: [test_hook]
4305 mode: sequential
4306routes:
4307 - tool: get_compensation
4308 - tool: send_email
4309"#;
4310 let cpex_config = crate::config::parse_config(yaml).unwrap();
4311 let mut factories = PluginFactoryRegistry::new();
4312 factories.register("test/allow", Box::new(AllowPluginFactory));
4313
4314 let mgr = PluginManager::from_config(cpex_config, &factories).unwrap();
4315 mgr.initialize().await.unwrap();
4316
4317 let p1: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4321 let e1 = Extensions {
4322 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4323 entity_type: Some("tool".into()),
4324 entity_name: Some("get_compensation".into()),
4325 ..Default::default()
4326 })),
4327 ..Default::default()
4328 };
4329 mgr.invoke_by_name("test_hook", p1, e1, None).await;
4330
4331 let p2: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4333 let e2 = Extensions {
4334 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4335 entity_type: Some("tool".into()),
4336 entity_name: Some("send_email".into()),
4337 ..Default::default()
4338 })),
4339 ..Default::default()
4340 };
4341 mgr.invoke_by_name("test_hook", p2, e2, None).await;
4342
4343 assert_eq!(mgr.routing_cache_size(), 2);
4344 }
4345
4346 #[tokio::test]
4347 async fn test_routing_cache_cleared() {
4348 let yaml = r#"
4349plugin_settings:
4350 routing_enabled: true
4351global:
4352 policies:
4353 all:
4354 plugins: [allow_plugin]
4355plugins:
4356 - name: allow_plugin
4357 kind: test/allow
4358 hooks: [test_hook]
4359 mode: sequential
4360routes:
4361 - tool: get_compensation
4362"#;
4363 let cpex_config = crate::config::parse_config(yaml).unwrap();
4364 let mut factories = PluginFactoryRegistry::new();
4365 factories.register("test/allow", Box::new(AllowPluginFactory));
4366
4367 let mgr = PluginManager::from_config(cpex_config, &factories).unwrap();
4368 mgr.initialize().await.unwrap();
4369
4370 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4372 let ext = Extensions {
4373 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4374 entity_type: Some("tool".into()),
4375 entity_name: Some("get_compensation".into()),
4376 ..Default::default()
4377 })),
4378 ..Default::default()
4379 };
4380 mgr.invoke_by_name("test_hook", payload, ext, None).await;
4381 assert_eq!(mgr.routing_cache_size(), 1);
4382
4383 mgr.clear_routing_cache();
4384 assert_eq!(mgr.routing_cache_size(), 0);
4385 }
4386
4387 #[tokio::test]
4388 async fn test_unregister_invalidates_routing_cache() {
4389 let yaml = r#"
4390plugin_settings:
4391 routing_enabled: true
4392global:
4393 policies:
4394 all:
4395 plugins: [allow_plugin]
4396plugins:
4397 - name: allow_plugin
4398 kind: test/allow
4399 hooks: [test_hook]
4400 mode: sequential
4401routes:
4402 - tool: get_compensation
4403"#;
4404 let cpex_config = crate::config::parse_config(yaml).unwrap();
4405 let mut factories = PluginFactoryRegistry::new();
4406 factories.register("test/allow", Box::new(AllowPluginFactory));
4407
4408 let mgr = PluginManager::from_config(cpex_config, &factories).unwrap();
4409 mgr.initialize().await.unwrap();
4410
4411 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4412 let ext = Extensions {
4413 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4414 entity_type: Some("tool".into()),
4415 entity_name: Some("get_compensation".into()),
4416 ..Default::default()
4417 })),
4418 ..Default::default()
4419 };
4420 mgr.invoke_by_name("test_hook", payload, ext, None).await;
4421 assert_eq!(mgr.routing_cache_size(), 1);
4422
4423 mgr.unregister("allow_plugin");
4426 assert_eq!(mgr.routing_cache_size(), 0);
4427 }
4428
4429 #[test]
4430 fn test_routing_cache_recovers_from_poisoned_lock() {
4431 use std::panic::AssertUnwindSafe;
4440
4441 let mgr = PluginManager::default();
4442
4443 let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
4444 let _guard = mgr.route_cache.write().unwrap();
4445 panic!("simulated panic while holding cache lock");
4446 }));
4447 assert!(result.is_err(), "expected the panic to be caught");
4448 assert!(
4449 mgr.route_cache.is_poisoned(),
4450 "lock should be poisoned after the panic",
4451 );
4452
4453 assert_eq!(mgr.routing_cache_size(), 0);
4455 mgr.clear_routing_cache();
4456 assert_eq!(mgr.routing_cache_size(), 0);
4457 }
4458
4459 #[tokio::test]
4460 async fn test_routing_cache_rejects_inserts_at_capacity() {
4461 let yaml = r#"
4463plugin_settings:
4464 routing_enabled: true
4465 route_cache_max_entries: 2
4466global:
4467 policies:
4468 all:
4469 plugins: [allow_plugin]
4470plugins:
4471 - name: allow_plugin
4472 kind: test/allow
4473 hooks: [test_hook]
4474 mode: sequential
4475routes:
4476 - tool: a
4477 - tool: b
4478 - tool: c
4479"#;
4480 let cpex_config = crate::config::parse_config(yaml).unwrap();
4481 let mut factories = PluginFactoryRegistry::new();
4482 factories.register("test/allow", Box::new(AllowPluginFactory));
4483
4484 let mgr = PluginManager::from_config(cpex_config, &factories).unwrap();
4485 mgr.initialize().await.unwrap();
4486
4487 let invoke_for = |entity: &'static str| -> (Box<dyn PluginPayload>, Extensions) {
4488 let p: Box<dyn PluginPayload> = Box::new(TestPayload {
4489 value: entity.into(),
4490 });
4491 let e = Extensions {
4492 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4493 entity_type: Some("tool".into()),
4494 entity_name: Some(entity.into()),
4495 ..Default::default()
4496 })),
4497 ..Default::default()
4498 };
4499 (p, e)
4500 };
4501
4502 let (p1, e1) = invoke_for("a");
4504 let (r1, _) = mgr.invoke_by_name("test_hook", p1, e1, None).await;
4505 assert!(r1.continue_processing);
4506 assert_eq!(mgr.routing_cache_size(), 1);
4507
4508 let (p2, e2) = invoke_for("b");
4509 let (r2, _) = mgr.invoke_by_name("test_hook", p2, e2, None).await;
4510 assert!(r2.continue_processing);
4511 assert_eq!(mgr.routing_cache_size(), 2);
4512
4513 let (p3, e3) = invoke_for("c");
4516 let (r3, _) = mgr.invoke_by_name("test_hook", p3, e3, None).await;
4517 assert!(
4518 r3.continue_processing,
4519 "slow path must still resolve when cache is full"
4520 );
4521 assert_eq!(mgr.routing_cache_size(), 2, "cache must not exceed cap");
4522
4523 let (p4, e4) = invoke_for("c");
4525 let (r4, _) = mgr.invoke_by_name("test_hook", p4, e4, None).await;
4526 assert!(r4.continue_processing);
4527 assert_eq!(mgr.routing_cache_size(), 2);
4528
4529 mgr.clear_routing_cache();
4531 let (p5, e5) = invoke_for("c");
4532 mgr.invoke_by_name("test_hook", p5, e5, None).await;
4533 assert_eq!(mgr.routing_cache_size(), 1);
4534 }
4535
4536 #[tokio::test]
4537 async fn test_register_handler_invalidates_routing_cache() {
4538 let yaml = r#"
4539plugin_settings:
4540 routing_enabled: true
4541global:
4542 policies:
4543 all:
4544 plugins: [allow_plugin]
4545plugins:
4546 - name: allow_plugin
4547 kind: test/allow
4548 hooks: [test_hook]
4549 mode: sequential
4550routes:
4551 - tool: get_compensation
4552"#;
4553 let cpex_config = crate::config::parse_config(yaml).unwrap();
4554 let mut factories = PluginFactoryRegistry::new();
4555 factories.register("test/allow", Box::new(AllowPluginFactory));
4556
4557 let mgr = PluginManager::from_config(cpex_config, &factories).unwrap();
4558 mgr.initialize().await.unwrap();
4559
4560 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4561 let ext = Extensions {
4562 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4563 entity_type: Some("tool".into()),
4564 entity_name: Some("get_compensation".into()),
4565 ..Default::default()
4566 })),
4567 ..Default::default()
4568 };
4569 mgr.invoke_by_name("test_hook", payload, ext, None).await;
4570 assert_eq!(mgr.routing_cache_size(), 1);
4571
4572 let extra_cfg = make_config("late_plugin", 20, PluginMode::Sequential);
4575 let extra = Arc::new(AllowPlugin {
4576 cfg: extra_cfg.clone(),
4577 });
4578 mgr.register_handler::<TestHook, _>(extra, extra_cfg)
4579 .unwrap();
4580 assert_eq!(mgr.routing_cache_size(), 0);
4581 }
4582
4583 #[tokio::test]
4584 async fn test_routing_cache_scope_creates_separate_entries() {
4585 let yaml = r#"
4586plugin_settings:
4587 routing_enabled: true
4588global:
4589 policies:
4590 all:
4591 plugins: [allow_plugin]
4592plugins:
4593 - name: allow_plugin
4594 kind: test/allow
4595 hooks: [test_hook]
4596 mode: sequential
4597routes:
4598 - tool: get_compensation
4599"#;
4600 let cpex_config = crate::config::parse_config(yaml).unwrap();
4601 let mut factories = PluginFactoryRegistry::new();
4602 factories.register("test/allow", Box::new(AllowPluginFactory));
4603
4604 let mgr = PluginManager::from_config(cpex_config, &factories).unwrap();
4605 mgr.initialize().await.unwrap();
4606
4607 let p1: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4611 let e1 = Extensions {
4612 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4613 entity_type: Some("tool".into()),
4614 entity_name: Some("get_compensation".into()),
4615 scope: Some("hr-server".into()),
4616 ..Default::default()
4617 })),
4618 ..Default::default()
4619 };
4620 mgr.invoke_by_name("test_hook", p1, e1, None).await;
4621
4622 let p2: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4623 let e2 = Extensions {
4624 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4625 entity_type: Some("tool".into()),
4626 entity_name: Some("get_compensation".into()),
4627 scope: Some("billing-server".into()),
4628 ..Default::default()
4629 })),
4630 ..Default::default()
4631 };
4632 mgr.invoke_by_name("test_hook", p2, e2, None).await;
4633
4634 assert_eq!(mgr.routing_cache_size(), 2); }
4636
4637 #[tokio::test]
4640 async fn test_route_override_creates_new_instance() {
4641 let yaml = r#"
4642plugin_settings:
4643 routing_enabled: true
4644plugins:
4645 - name: rate_limiter
4646 kind: test/allow
4647 hooks: [test_hook]
4648 mode: sequential
4649 priority: 10
4650 config:
4651 max_requests: 100
4652routes:
4653 - tool: get_compensation
4654 plugins:
4655 - rate_limiter:
4656 config:
4657 max_requests: 10
4658"#;
4659 let cpex_config = crate::config::parse_config(yaml).unwrap();
4660
4661 let mgr = PluginManager::default();
4663 mgr.register_factory("test/allow", Box::new(AllowPluginFactory));
4664 mgr.load_config(cpex_config).unwrap();
4665 mgr.initialize().await.unwrap();
4666
4667 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4669 let ext = Extensions {
4670 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4671 entity_type: Some("tool".into()),
4672 entity_name: Some("get_compensation".into()),
4673 ..Default::default()
4674 })),
4675 ..Default::default()
4676 };
4677 let (result, _) = mgr.invoke_by_name("test_hook", payload, ext, None).await;
4680
4681 assert!(result.continue_processing);
4683 assert_eq!(mgr.routing_cache_size(), 1);
4685 }
4686
4687 #[tokio::test]
4692 async fn test_route_override_initializes_new_instance() {
4693 use std::sync::atomic::{AtomicUsize, Ordering};
4694
4695 static INIT_COUNT: AtomicUsize = AtomicUsize::new(0);
4696 INIT_COUNT.store(0, Ordering::SeqCst);
4697
4698 struct InitTrackingPlugin {
4699 cfg: PluginConfig,
4700 }
4701
4702 #[async_trait]
4703 impl Plugin for InitTrackingPlugin {
4704 fn config(&self) -> &PluginConfig {
4705 &self.cfg
4706 }
4707 async fn initialize(&self) -> Result<(), Box<PluginError>> {
4708 INIT_COUNT.fetch_add(1, Ordering::SeqCst);
4709 Ok(())
4710 }
4711 async fn shutdown(&self) -> Result<(), Box<PluginError>> {
4712 Ok(())
4713 }
4714 }
4715
4716 impl HookHandler<TestHook> for InitTrackingPlugin {
4717 async fn handle(
4718 &self,
4719 _payload: &TestPayload,
4720 _extensions: &Extensions,
4721 _ctx: &mut PluginContext,
4722 ) -> PluginResult<TestPayload> {
4723 PluginResult::allow()
4724 }
4725 }
4726
4727 struct InitTrackingFactory;
4728 impl crate::factory::PluginFactory for InitTrackingFactory {
4729 fn create(
4730 &self,
4731 config: &PluginConfig,
4732 ) -> Result<crate::factory::PluginInstance, Box<PluginError>> {
4733 let plugin = Arc::new(InitTrackingPlugin {
4734 cfg: config.clone(),
4735 });
4736 let handler: Arc<dyn AnyHookHandler> =
4737 Arc::new(TypedHandlerAdapter::<TestHook, InitTrackingPlugin>::new(
4738 Arc::clone(&plugin),
4739 ));
4740 Ok(crate::factory::PluginInstance {
4741 plugin,
4742 handlers: vec![("test_hook", handler)],
4743 })
4744 }
4745 }
4746
4747 let yaml = r#"
4748plugin_settings:
4749 routing_enabled: true
4750plugins:
4751 - name: tracker
4752 kind: test/init_tracking
4753 hooks: [test_hook]
4754 mode: sequential
4755 priority: 10
4756 config:
4757 max_requests: 100
4758routes:
4759 - tool: get_compensation
4760 plugins:
4761 - tracker:
4762 config:
4763 max_requests: 10
4764"#;
4765 let cpex_config = crate::config::parse_config(yaml).unwrap();
4766
4767 let mgr = PluginManager::default();
4768 mgr.register_factory("test/init_tracking", Box::new(InitTrackingFactory));
4769 mgr.load_config(cpex_config).unwrap();
4770 mgr.initialize().await.unwrap();
4771
4772 assert_eq!(INIT_COUNT.load(Ordering::SeqCst), 1);
4774
4775 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4778 let (result, _) = mgr
4779 .invoke_by_name(
4780 "test_hook",
4781 payload,
4782 make_meta("tool", "get_compensation", None, &[]),
4783 None,
4784 )
4785 .await;
4786 assert!(result.continue_processing);
4787
4788 assert_eq!(
4789 INIT_COUNT.load(Ordering::SeqCst),
4790 2,
4791 "override instance must have initialize() called",
4792 );
4793 }
4794
4795 #[tokio::test]
4801 async fn test_route_override_circuit_breaker_isolated_from_base() {
4802 struct ErrorOnInvokeFactory;
4803 impl crate::factory::PluginFactory for ErrorOnInvokeFactory {
4804 fn create(
4805 &self,
4806 config: &PluginConfig,
4807 ) -> Result<crate::factory::PluginInstance, Box<PluginError>> {
4808 let plugin = Arc::new(AllowPlugin {
4809 cfg: config.clone(),
4810 });
4811 let handler: Arc<dyn AnyHookHandler> = Arc::new(ErrorHandler);
4812 Ok(crate::factory::PluginInstance {
4813 plugin,
4814 handlers: vec![("test_hook", handler)],
4815 })
4816 }
4817 }
4818
4819 let yaml = r#"
4820plugin_settings:
4821 routing_enabled: true
4822plugins:
4823 - name: flaky
4824 kind: test/error_on_invoke
4825 hooks: [test_hook]
4826 mode: sequential
4827 priority: 10
4828 on_error: disable
4829routes:
4830 - tool: get_compensation
4831 plugins:
4832 - flaky:
4833 config:
4834 something: changed
4835"#;
4836 let cpex_config = crate::config::parse_config(yaml).unwrap();
4837
4838 let mgr = PluginManager::default();
4839 mgr.register_factory("test/error_on_invoke", Box::new(ErrorOnInvokeFactory));
4840 mgr.load_config(cpex_config).unwrap();
4841 mgr.initialize().await.unwrap();
4842
4843 assert!(
4844 !mgr.get_plugin("flaky").unwrap().is_disabled(),
4845 "should start enabled"
4846 );
4847
4848 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4853 let _ = mgr
4854 .invoke_by_name(
4855 "test_hook",
4856 payload,
4857 make_meta("tool", "get_compensation", None, &[]),
4858 None,
4859 )
4860 .await;
4861
4862 assert!(
4863 !mgr.get_plugin("flaky").unwrap().is_disabled(),
4864 "base must NOT be disabled when an override trips its own circuit breaker",
4865 );
4866 }
4867
4868 #[tokio::test]
4869 async fn test_register_factory_then_load_config() {
4870 let yaml = r#"
4871plugins:
4872 - name: my_plugin
4873 kind: test/allow
4874 hooks: [test_hook]
4875 mode: sequential
4876 priority: 10
4877
4878plugin_settings:
4879 plugin_timeout: 45
4880"#;
4881 let cpex_config = crate::config::parse_config(yaml).unwrap();
4882
4883 let mgr = PluginManager::default();
4884 mgr.register_factory("test/allow", Box::new(AllowPluginFactory));
4885 mgr.load_config(cpex_config).unwrap();
4886 mgr.initialize().await.unwrap();
4887
4888 assert_eq!(mgr.plugin_count(), 1);
4889 assert!(mgr.has_hooks_for("test_hook"));
4890
4891 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4892 let (result, _) = mgr
4894 .invoke_by_name("test_hook", payload, Extensions::default(), None)
4895 .await;
4896 assert!(result.continue_processing);
4897 }
4898
4899 fn make_meta(
4903 entity_type: &str,
4904 entity_name: &str,
4905 scope: Option<&str>,
4906 tags: &[&str],
4907 ) -> Extensions {
4908 let mut tag_set = std::collections::HashSet::new();
4909 for t in tags {
4910 tag_set.insert(t.to_string());
4911 }
4912 Extensions {
4913 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4914 entity_type: Some(entity_type.into()),
4915 entity_name: Some(entity_name.into()),
4916 scope: scope.map(String::from),
4917 tags: tag_set,
4918 ..Default::default()
4919 })),
4920 ..Default::default()
4921 }
4922 }
4923
4924 #[tokio::test]
4925 async fn test_routing_full_flow_different_tools_different_plugins() {
4926 let yaml = r#"
4929plugin_settings:
4930 routing_enabled: true
4931global:
4932 policies:
4933 all:
4934 plugins: [identity]
4935 pii:
4936 plugins: [apl_policy]
4937plugins:
4938 - name: identity
4939 kind: test/allow
4940 hooks: [test_hook]
4941 mode: sequential
4942 priority: 1
4943 - name: apl_policy
4944 kind: test/deny
4945 hooks: [test_hook]
4946 mode: sequential
4947 priority: 10
4948 - name: rate_limiter
4949 kind: test/allow
4950 hooks: [test_hook]
4951 mode: sequential
4952 priority: 5
4953routes:
4954 - tool: get_compensation
4955 meta:
4956 tags: [pii]
4957 plugins:
4958 - rate_limiter
4959 - tool: send_email
4960 plugins:
4961 - rate_limiter
4962"#;
4963 let cpex_config = crate::config::parse_config(yaml).unwrap();
4964 let mgr = PluginManager::default();
4965 mgr.register_factory("test/allow", Box::new(AllowPluginFactory));
4966 mgr.register_factory("test/deny", Box::new(DenyPluginFactory));
4967 mgr.load_config(cpex_config).unwrap();
4968 mgr.initialize().await.unwrap();
4969
4970 let p1: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4975 let (r1, _) = mgr
4976 .invoke_by_name(
4977 "test_hook",
4978 p1,
4979 make_meta("tool", "get_compensation", None, &[]),
4980 None,
4981 )
4982 .await;
4983 assert!(!r1.continue_processing); let p2: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4988 let (r2, _) = mgr
4989 .invoke_by_name(
4990 "test_hook",
4991 p2,
4992 make_meta("tool", "send_email", None, &[]),
4993 None,
4994 )
4995 .await;
4996 assert!(r2.continue_processing); }
4998
4999 #[tokio::test]
5000 async fn test_routing_disabled_fires_all_plugins() {
5001 let yaml = r#"
5003plugins:
5004 - name: denier
5005 kind: test/deny
5006 hooks: [test_hook]
5007 mode: sequential
5008 priority: 10
5009 - name: allower
5010 kind: test/allow
5011 hooks: [test_hook]
5012 mode: sequential
5013 priority: 20
5014"#;
5015 let cpex_config = crate::config::parse_config(yaml).unwrap();
5016 let mgr = PluginManager::default();
5017 mgr.register_factory("test/allow", Box::new(AllowPluginFactory));
5018 mgr.register_factory("test/deny", Box::new(DenyPluginFactory));
5019 mgr.load_config(cpex_config).unwrap();
5020 mgr.initialize().await.unwrap();
5021
5022 let p: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
5026 let (result, _) = mgr
5027 .invoke_by_name(
5028 "test_hook",
5029 p,
5030 make_meta("tool", "anything", None, &[]),
5031 None,
5032 )
5033 .await;
5034 assert!(!result.continue_processing); }
5036
5037 #[tokio::test]
5038 async fn test_routing_no_meta_fires_all_plugins() {
5039 let yaml = r#"
5041plugin_settings:
5042 routing_enabled: true
5043global:
5044 policies:
5045 all:
5046 plugins: [allower]
5047plugins:
5048 - name: allower
5049 kind: test/allow
5050 hooks: [test_hook]
5051 mode: sequential
5052 - name: denier
5053 kind: test/deny
5054 hooks: [test_hook]
5055 mode: sequential
5056routes:
5057 - tool: get_compensation
5058 plugins:
5059 - denier
5060"#;
5061 let cpex_config = crate::config::parse_config(yaml).unwrap();
5062 let mgr = PluginManager::default();
5063 mgr.register_factory("test/allow", Box::new(AllowPluginFactory));
5064 mgr.register_factory("test/deny", Box::new(DenyPluginFactory));
5065 mgr.load_config(cpex_config).unwrap();
5066 mgr.initialize().await.unwrap();
5067
5068 let p: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
5072 let (result, _) = mgr
5073 .invoke_by_name("test_hook", p, Extensions::default(), None)
5074 .await;
5075 assert!(
5079 !result.continue_processing,
5080 "denier should run when no meta is provided (route filtering bypassed)",
5081 );
5082 assert!(
5083 result.violation.is_some(),
5084 "deny should produce a violation"
5085 );
5086 }
5087
5088 #[tokio::test]
5089 async fn test_routing_wildcard_catches_unmatched() {
5090 let yaml = r#"
5091plugin_settings:
5092 routing_enabled: true
5093global:
5094 policies:
5095 all:
5096 plugins: [identity]
5097plugins:
5098 - name: identity
5099 kind: test/allow
5100 hooks: [test_hook]
5101 mode: sequential
5102 priority: 1
5103 - name: specific_plugin
5104 kind: test/deny
5105 hooks: [test_hook]
5106 mode: sequential
5107 priority: 10
5108 - name: fallback_plugin
5109 kind: test/allow
5110 hooks: [test_hook]
5111 mode: sequential
5112 priority: 10
5113routes:
5114 - tool: get_compensation
5115 plugins:
5116 - specific_plugin
5117 - tool: "*"
5118 plugins:
5119 - fallback_plugin
5120"#;
5121 let cpex_config = crate::config::parse_config(yaml).unwrap();
5122 let mgr = PluginManager::default();
5123 mgr.register_factory("test/allow", Box::new(AllowPluginFactory));
5124 mgr.register_factory("test/deny", Box::new(DenyPluginFactory));
5125 mgr.load_config(cpex_config).unwrap();
5126 mgr.initialize().await.unwrap();
5127
5128 let p1: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
5132 let (r1, _) = mgr
5133 .invoke_by_name(
5134 "test_hook",
5135 p1,
5136 make_meta("tool", "get_compensation", None, &[]),
5137 None,
5138 )
5139 .await;
5140 assert!(!r1.continue_processing); let p2: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
5144 let (r2, _) = mgr
5145 .invoke_by_name(
5146 "test_hook",
5147 p2,
5148 make_meta("tool", "unknown_tool", None, &[]),
5149 None,
5150 )
5151 .await;
5152 assert!(r2.continue_processing); }
5154
5155 #[tokio::test]
5156 async fn test_routing_host_tags_activate_policy_groups() {
5157 let yaml = r#"
5158plugin_settings:
5159 routing_enabled: true
5160global:
5161 policies:
5162 all:
5163 plugins: [identity]
5164 urgent:
5165 plugins: [denier]
5166plugins:
5167 - name: identity
5168 kind: test/allow
5169 hooks: [test_hook]
5170 mode: sequential
5171 priority: 1
5172 - name: denier
5173 kind: test/deny
5174 hooks: [test_hook]
5175 mode: sequential
5176 priority: 10
5177routes:
5178 - tool: get_compensation
5179"#;
5180 let cpex_config = crate::config::parse_config(yaml).unwrap();
5181 let mgr = PluginManager::default();
5182 mgr.register_factory("test/allow", Box::new(AllowPluginFactory));
5183 mgr.register_factory("test/deny", Box::new(DenyPluginFactory));
5184 mgr.load_config(cpex_config).unwrap();
5185 mgr.initialize().await.unwrap();
5186
5187 let p1: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
5191 let (r1, _) = mgr
5192 .invoke_by_name(
5193 "test_hook",
5194 p1,
5195 make_meta("tool", "get_compensation", None, &[]),
5196 None,
5197 )
5198 .await;
5199 assert!(r1.continue_processing);
5200
5201 mgr.clear_routing_cache();
5203
5204 let p2: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
5206 let (r2, _) = mgr
5207 .invoke_by_name(
5208 "test_hook",
5209 p2,
5210 make_meta("tool", "get_compensation", None, &["urgent"]),
5211 None,
5212 )
5213 .await;
5214 assert!(!r2.continue_processing);
5215 }
5216
5217 #[tokio::test]
5218 async fn test_routing_works_with_typed_invoke() {
5219 let yaml = r#"
5220plugin_settings:
5221 routing_enabled: true
5222global:
5223 policies:
5224 all:
5225 plugins: [allower]
5226 pii:
5227 plugins: [denier]
5228plugins:
5229 - name: allower
5230 kind: test/allow
5231 hooks: [test_hook]
5232 mode: sequential
5233 priority: 1
5234 - name: denier
5235 kind: test/deny
5236 hooks: [test_hook]
5237 mode: sequential
5238 priority: 10
5239routes:
5240 - tool: get_compensation
5241 meta:
5242 tags: [pii]
5243 - tool: send_email
5244"#;
5245 let cpex_config = crate::config::parse_config(yaml).unwrap();
5246 let mgr = PluginManager::default();
5247 mgr.register_factory("test/allow", Box::new(AllowPluginFactory));
5248 mgr.register_factory("test/deny", Box::new(DenyPluginFactory));
5249 mgr.load_config(cpex_config).unwrap();
5250 mgr.initialize().await.unwrap();
5251
5252 let (r1, _) = mgr
5256 .invoke::<TestHook>(
5257 TestPayload { value: "t".into() },
5258 make_meta("tool", "get_compensation", None, &[]),
5259 None,
5260 )
5261 .await;
5262 assert!(!r1.continue_processing);
5263
5264 let (r2, _) = mgr
5266 .invoke::<TestHook>(
5267 TestPayload { value: "t".into() },
5268 make_meta("tool", "send_email", None, &[]),
5269 None,
5270 )
5271 .await;
5272 assert!(r2.continue_processing);
5273 }
5274
5275 struct LabelAdderHandler;
5279
5280 #[async_trait]
5281 impl AnyHookHandler for LabelAdderHandler {
5282 async fn invoke(
5283 &self,
5284 _payload: &dyn PluginPayload,
5285 extensions: &Extensions,
5286 _ctx: &mut PluginContext,
5287 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
5288 let mut ext = extensions.cow_copy();
5289 if let Some(ref mut sec) = ext.security {
5290 sec.add_label("PLUGIN_ADDED");
5291 }
5292 let mut result: PluginResult<TestPayload> = PluginResult::allow();
5293 result.modified_extensions = Some(ext);
5294 Ok(crate::executor::erase_result(result))
5295 }
5296 fn hook_type_name(&self) -> &'static str {
5297 "test_hook"
5298 }
5299 }
5300
5301 struct ImmutableTampererHandler;
5303
5304 #[async_trait]
5305 impl AnyHookHandler for ImmutableTampererHandler {
5306 async fn invoke(
5307 &self,
5308 _payload: &dyn PluginPayload,
5309 extensions: &Extensions,
5310 _ctx: &mut PluginContext,
5311 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
5312 let mut ext = extensions.cow_copy();
5313 ext.request = Some(std::sync::Arc::new(crate::extensions::RequestExtension {
5315 request_id: Some("TAMPERED".into()),
5316 ..Default::default()
5317 }));
5318 let mut result: PluginResult<TestPayload> = PluginResult::allow();
5319 result.modified_extensions = Some(ext);
5320 Ok(crate::executor::erase_result(result))
5321 }
5322 fn hook_type_name(&self) -> &'static str {
5323 "test_hook"
5324 }
5325 }
5326
5327 #[tokio::test]
5328 async fn test_executor_accepts_valid_label_addition() {
5329 let mgr = PluginManager::default();
5330 let mut config = make_config("label-adder", 10, PluginMode::Sequential);
5331 config.capabilities = ["append_labels".to_string(), "read_labels".to_string()].into();
5332 let plugin = Arc::new(AllowPlugin {
5333 cfg: config.clone(),
5334 });
5335 let handler: Arc<dyn AnyHookHandler> = Arc::new(LabelAdderHandler);
5336 mgr.register_raw::<TestHook>(plugin, config, handler)
5337 .unwrap();
5338 mgr.initialize().await.unwrap();
5339
5340 let mut security = crate::extensions::SecurityExtension::default();
5342 security.add_label("ORIGINAL");
5343
5344 let ext = Extensions {
5345 security: Some(Arc::new(security)),
5346 ..Default::default()
5347 };
5348
5349 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
5350 value: "test".into(),
5351 });
5352 let (result, _) = mgr.invoke_by_name("test_hook", payload, ext, None).await;
5353
5354 assert!(result.continue_processing);
5355 let modified = result.modified_extensions.as_ref().unwrap();
5357 let sec = modified.security.as_ref().unwrap();
5358 assert!(sec.has_label("ORIGINAL"));
5359 assert!(sec.has_label("PLUGIN_ADDED"));
5360 }
5361
5362 #[tokio::test]
5363 async fn test_executor_rejects_immutable_tampering() {
5364 let mgr = PluginManager::default();
5365 let config = make_config("tamperer", 10, PluginMode::Sequential);
5366 let plugin = Arc::new(AllowPlugin {
5367 cfg: config.clone(),
5368 });
5369 let handler: Arc<dyn AnyHookHandler> = Arc::new(ImmutableTampererHandler);
5370 mgr.register_raw::<TestHook>(plugin, config, handler)
5371 .unwrap();
5372 mgr.initialize().await.unwrap();
5373
5374 let ext = Extensions {
5376 request: Some(std::sync::Arc::new(crate::extensions::RequestExtension {
5377 request_id: Some("original-req-id".into()),
5378 ..Default::default()
5379 })),
5380 ..Default::default()
5381 };
5382
5383 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
5384 value: "test".into(),
5385 });
5386 let (result, _) = mgr.invoke_by_name("test_hook", payload, ext, None).await;
5387
5388 assert!(result.continue_processing);
5389 if let Some(ref modified) = result.modified_extensions {
5392 assert_eq!(
5394 modified.request.as_ref().unwrap().request_id.as_deref(),
5395 Some("original-req-id"),
5396 );
5397 }
5398 }
5399
5400 #[tokio::test]
5401 async fn test_capability_filtering_hides_security_from_plugin() {
5402 struct SecurityCheckerHandler {
5405 saw_security: std::sync::Arc<std::sync::atomic::AtomicBool>,
5406 }
5407
5408 #[async_trait]
5409 impl AnyHookHandler for SecurityCheckerHandler {
5410 async fn invoke(
5411 &self,
5412 _payload: &dyn PluginPayload,
5413 extensions: &Extensions,
5414 _ctx: &mut PluginContext,
5415 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
5416 if extensions.security.is_some() {
5418 self.saw_security
5419 .store(true, std::sync::atomic::Ordering::SeqCst);
5420 }
5421 let result: PluginResult<TestPayload> = PluginResult::allow();
5422 Ok(crate::executor::erase_result(result))
5423 }
5424 fn hook_type_name(&self) -> &'static str {
5425 "test_hook"
5426 }
5427 }
5428
5429 let saw_security = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
5430
5431 let mgr = PluginManager::default();
5432 let config = make_config("no-sec-caps", 10, PluginMode::Sequential);
5434 let plugin = Arc::new(AllowPlugin {
5435 cfg: config.clone(),
5436 });
5437 let handler: Arc<dyn AnyHookHandler> = Arc::new(SecurityCheckerHandler {
5438 saw_security: saw_security.clone(),
5439 });
5440 mgr.register_raw::<TestHook>(plugin, config, handler)
5441 .unwrap();
5442 mgr.initialize().await.unwrap();
5443
5444 let mut security = crate::extensions::SecurityExtension::default();
5446 security.add_label("SECRET");
5447 security.subject = Some(crate::extensions::security::SubjectExtension {
5448 id: Some("alice".into()),
5449 ..Default::default()
5450 });
5451
5452 let ext = Extensions {
5453 security: Some(Arc::new(security)),
5454 ..Default::default()
5455 };
5456
5457 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
5458 value: "test".into(),
5459 });
5460 let (result, _) = mgr.invoke_by_name("test_hook", payload, ext, None).await;
5461
5462 assert!(result.continue_processing);
5463 }
5470
5471 struct AsyncCounterPlugin {
5483 cfg: PluginConfig,
5484 counter: Arc<std::sync::atomic::AtomicU64>,
5485 }
5486
5487 #[async_trait]
5488 impl Plugin for AsyncCounterPlugin {
5489 fn config(&self) -> &PluginConfig {
5490 &self.cfg
5491 }
5492 }
5493
5494 impl HookHandler<TestHook> for AsyncCounterPlugin {
5495 async fn handle(
5496 &self,
5497 _payload: &TestPayload,
5498 _extensions: &Extensions,
5499 _ctx: &mut PluginContext,
5500 ) -> PluginResult<TestPayload> {
5501 tokio::task::yield_now().await;
5502 tokio::time::sleep(std::time::Duration::from_micros(1)).await;
5503 self.counter
5504 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
5505 PluginResult::allow()
5506 }
5507 }
5508
5509 #[tokio::test]
5512 async fn test_async_handler_registers_and_invokes() {
5513 let mgr = PluginManager::default();
5514 let counter = Arc::new(std::sync::atomic::AtomicU64::new(0));
5515 let cfg = make_config("async-counter", 10, PluginMode::Sequential);
5516 let plugin = Arc::new(AsyncCounterPlugin {
5517 cfg: cfg.clone(),
5518 counter: counter.clone(),
5519 });
5520
5521 mgr.register_handler::<TestHook, _>(plugin, cfg).unwrap();
5523 mgr.initialize().await.unwrap();
5524
5525 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
5526 value: "test".into(),
5527 });
5528 let (result, _) = mgr
5529 .invoke_by_name("test_hook", payload, Extensions::default(), None)
5530 .await;
5531
5532 assert!(result.continue_processing);
5533 assert!(result.violation.is_none());
5534 assert_eq!(
5537 counter.load(std::sync::atomic::Ordering::SeqCst),
5538 1,
5539 "async handler should have run once",
5540 );
5541 }
5542
5543 #[tokio::test]
5548 async fn test_mixed_sync_and_async_handlers_in_same_hook() {
5549 let mgr = PluginManager::default();
5550 let counter = Arc::new(std::sync::atomic::AtomicU64::new(0));
5551
5552 let sync_cfg = make_config("sync-allow", 10, PluginMode::Sequential);
5553 let sync_plugin = Arc::new(AllowPlugin {
5554 cfg: sync_cfg.clone(),
5555 });
5556 mgr.register_handler::<TestHook, _>(sync_plugin, sync_cfg)
5557 .unwrap();
5558
5559 let async_cfg = make_config("async-counter", 20, PluginMode::Sequential);
5560 let async_plugin = Arc::new(AsyncCounterPlugin {
5561 cfg: async_cfg.clone(),
5562 counter: counter.clone(),
5563 });
5564 mgr.register_handler::<TestHook, _>(async_plugin, async_cfg)
5565 .unwrap();
5566
5567 mgr.initialize().await.unwrap();
5568
5569 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
5570 value: "test".into(),
5571 });
5572 let (result, _) = mgr
5573 .invoke_by_name("test_hook", payload, Extensions::default(), None)
5574 .await;
5575
5576 assert!(result.continue_processing);
5577 assert_eq!(
5578 counter.load(std::sync::atomic::Ordering::SeqCst),
5579 1,
5580 "awaiting plugin should have run alongside the non-awaiting plugin",
5581 );
5582 }
5583}