1use std::hash::{Hash, Hasher};
26use std::path::Path;
27use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
28use std::sync::{Arc, RwLock};
29
30use hashbrown::HashMap;
31use tracing::{error, info, warn};
32
33use crate::config::{self, PolicyConfig};
34use crate::context::PluginContextTable;
35use crate::error::PluginError;
36use crate::executor::{BackgroundTasks, Executor, ExecutorConfig, PipelineResult};
37use crate::factory::PluginFactoryRegistry;
38use crate::hooks::HookType;
39use crate::hooks::adapter::TypedHandlerAdapter;
40use crate::hooks::payload::{Extensions, PluginPayload};
41use crate::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult};
42use crate::plugin::{Plugin, PluginConfig};
43use crate::registry::{AnyHookHandler, PluginRef, PluginRegistry};
44
45pub const DEFAULT_ROUTE_CACHE_MAX_ENTRIES: usize = 10_000;
48
49#[derive(Debug, Clone)]
51pub struct PolicyEngineConfig {
52 pub executor: ExecutorConfig,
54
55 pub route_cache_max_entries: usize,
60}
61
62impl Default for PolicyEngineConfig {
63 fn default() -> Self {
64 Self {
65 executor: ExecutorConfig::default(),
66 route_cache_max_entries: DEFAULT_ROUTE_CACHE_MAX_ENTRIES,
67 }
68 }
69}
70
71#[derive(Debug, Clone)]
113struct RouteCacheKey {
114 entity_type: String,
115 entity_name: String,
116 hook_name: String,
117 scope: Option<String>,
118}
119
120impl Hash for RouteCacheKey {
121 fn hash<H: Hasher>(&self, state: &mut H) {
122 self.entity_type.as_str().hash(state);
123 self.entity_name.as_str().hash(state);
124 self.hook_name.as_str().hash(state);
125 self.scope.as_deref().hash(state);
126 }
127}
128
129impl PartialEq for RouteCacheKey {
130 fn eq(&self, other: &Self) -> bool {
131 self.entity_type == other.entity_type
132 && self.entity_name == other.entity_name
133 && self.hook_name == other.hook_name
134 && self.scope == other.scope
135 }
136}
137
138impl Eq for RouteCacheKey {}
139
140#[derive(Clone)]
153struct RuntimeSnapshot {
154 registry: PluginRegistry,
156
157 executor: Executor,
159
160 policy_config: Option<PolicyConfig>,
162
163 route_cache_max_entries: usize,
166
167 route_annotations: HashMap<AnnotationKey, crate::registry::HookEntry>,
195}
196
197#[derive(Debug, Clone, Hash, PartialEq, Eq)]
201struct AnnotationKey {
202 entity_type: String,
203 entity_name: String,
204 scope: Option<String>,
205 hook_name: String,
206}
207
208pub struct PolicyEngine {
210 runtime: arc_swap::ArcSwap<RuntimeSnapshot>,
213
214 factories: RwLock<PluginFactoryRegistry>,
223
224 route_cache: RwLock<HashMap<RouteCacheKey, Arc<Vec<crate::registry::HookEntry>>>>,
228
229 cache_hasher: hashbrown::DefaultHashBuilder,
231
232 route_cache_full_warned: AtomicBool,
236
237 initialized: AtomicBool,
240
241 generation: AtomicU64,
249
250 task_tracker: tokio_util::task::TaskTracker,
259
260 visitors: RwLock<Vec<Arc<dyn crate::visitor::ConfigVisitor>>>,
266}
267
268fn warn_on_inactive_settings(cfg: &PolicyConfig) {
275 if !cfg.plugin_dirs.is_empty() {
276 warn!(
277 "config sets `plugin_dirs` (count={}) but the runtime does not \
278 scan directories for plugins — plugins must be registered via \
279 `register_factory()` and listed under `plugins:`. Setting ignored.",
280 cfg.plugin_dirs.len(),
281 );
282 }
283 if cfg.plugin_settings.parallel_execution_within_band {
284 warn!(
285 "config sets `plugin_settings.parallel_execution_within_band: true` \
286 but the runtime does not honor it — use `mode: concurrent` on \
287 individual plugins for parallel execution. Setting ignored.",
288 );
289 }
290 if cfg.plugin_settings.fail_on_plugin_error {
291 warn!(
292 "config sets `plugin_settings.fail_on_plugin_error: true` but the \
293 runtime does not honor it — use per-plugin `on_error: fail` for \
294 that behavior. Setting ignored.",
295 );
296 }
297}
298
299fn instantiate_plugins_into(
310 target_registry: &mut PluginRegistry,
311 plugin_configs: &[crate::plugin::PluginConfig],
312 factories: &PluginFactoryRegistry,
313) -> Result<(), Box<PluginError>> {
314 for plugin_config in plugin_configs {
315 let factory = factories
316 .get(&plugin_config.kind)
317 .ok_or_else(|| PluginError::Config {
318 message: format!(
319 "no factory registered for plugin kind '{}' (plugin '{}')",
320 plugin_config.kind, plugin_config.name
321 ),
322 })?;
323
324 let instance = factory.create(plugin_config)?;
325
326 target_registry
327 .register_multi_handler(instance.plugin, plugin_config.clone(), instance.handlers)
328 .map_err(|msg| Box::new(PluginError::Config { message: msg }))?;
329
330 info!(
331 "Registered plugin '{}' (kind: '{}') for hooks: {:?}",
332 plugin_config.name, plugin_config.kind, plugin_config.hooks
333 );
334 }
335 Ok(())
336}
337
338fn snapshot_from_config(registry: PluginRegistry, policy_config: PolicyConfig) -> RuntimeSnapshot {
343 let executor = Executor::new(ExecutorConfig {
344 timeout_seconds: policy_config.plugin_settings.plugin_timeout,
345 short_circuit_on_deny: policy_config.plugin_settings.short_circuit_on_deny,
346 });
347 let route_cache_max_entries = policy_config.plugin_settings.route_cache_max_entries;
348 RuntimeSnapshot {
349 registry,
350 executor,
351 policy_config: Some(policy_config),
352 route_cache_max_entries,
353 route_annotations: HashMap::new(),
354 }
355}
356
357impl PolicyEngine {
358 pub fn new(config: PolicyEngineConfig) -> Self {
360 let cache_hasher = hashbrown::DefaultHashBuilder::default();
361 let snapshot = RuntimeSnapshot {
362 registry: PluginRegistry::new(),
363 executor: Executor::new(config.executor),
364 policy_config: None,
365 route_cache_max_entries: config.route_cache_max_entries,
366 route_annotations: HashMap::new(),
367 };
368 Self {
369 runtime: arc_swap::ArcSwap::from_pointee(snapshot),
370 factories: RwLock::new(PluginFactoryRegistry::new()),
371 route_cache: RwLock::new(HashMap::with_hasher(cache_hasher.clone())),
372 cache_hasher,
373 route_cache_full_warned: AtomicBool::new(false),
374 initialized: AtomicBool::new(false),
375 generation: AtomicU64::new(0),
376 task_tracker: tokio_util::task::TaskTracker::new(),
377 visitors: RwLock::new(Vec::new()),
378 }
379 }
380
381 fn load_runtime(&self) -> Arc<RuntimeSnapshot> {
383 self.runtime.load_full()
384 }
385
386 fn mutate_runtime<F, R>(&self, f: F) -> R
391 where
392 F: FnOnce(&mut RuntimeSnapshot) -> R,
393 {
394 let current = self.runtime.load_full();
395 let mut next = (*current).clone();
396 let result = f(&mut next);
397 self.runtime.store(Arc::new(next));
398 self.generation.fetch_add(1, Ordering::Release);
402 result
403 }
404
405 fn try_mutate_runtime<F, T, E>(&self, f: F) -> Result<T, E>
409 where
410 F: FnOnce(&mut RuntimeSnapshot) -> Result<T, E>,
411 {
412 let current = self.runtime.load_full();
413 let mut next = (*current).clone();
414 let result = f(&mut next)?;
415 self.runtime.store(Arc::new(next));
416 self.generation.fetch_add(1, Ordering::Release);
419 Ok(result)
420 }
421
422 pub fn config_generation(&self) -> u64 {
430 self.generation.load(Ordering::Acquire)
431 }
432
433 pub fn register_factory(
447 &self,
448 kind: impl Into<String>,
449 factory: Box<dyn crate::factory::PluginFactory>,
450 ) {
451 self.factories
452 .write()
453 .unwrap_or_else(std::sync::PoisonError::into_inner)
454 .register(kind, factory);
455 }
456
457 pub fn load_config_file(&self, path: &Path) -> Result<(), Box<PluginError>> {
477 let policy_config = config::load_config(path)?;
478 self.load_config(policy_config)
479 }
480
481 pub fn load_config(&self, policy_config: PolicyConfig) -> Result<(), Box<PluginError>> {
493 warn_on_inactive_settings(&policy_config);
494
495 let factories = self
501 .factories
502 .read()
503 .unwrap_or_else(std::sync::PoisonError::into_inner);
504 let current = self.runtime.load_full();
505 let mut new_registry = current.registry.clone();
506
507 instantiate_plugins_into(&mut new_registry, &policy_config.plugins, &factories)?;
508
509 drop(factories);
512
513 self.runtime
514 .store(Arc::new(snapshot_from_config(new_registry, policy_config)));
515 self.generation.fetch_add(1, Ordering::Release);
519
520 self.clear_routing_cache();
522
523 Ok(())
524 }
525
526 pub fn register_visitor(&self, visitor: Arc<dyn crate::visitor::ConfigVisitor>) {
532 let mut v = self
533 .visitors
534 .write()
535 .unwrap_or_else(std::sync::PoisonError::into_inner);
536 v.push(visitor);
537 }
538
539 pub fn load_config_yaml(self: &Arc<Self>, yaml: &str) -> Result<(), Box<PluginError>> {
566 let raw: serde_yaml::Value = serde_yaml::from_str(yaml).map_err(|e| {
570 Box::new(PluginError::Config {
571 message: format!("YAML parse error: {e}"),
572 })
573 })?;
574 let mut policy_config: PolicyConfig = serde_yaml::from_value(raw.clone()).map_err(|e| {
575 Box::new(PluginError::Config {
576 message: format!("PolicyConfig deserialize error: {e}"),
577 })
578 })?;
579
580 crate::config::reject_renamed_identity_key(&raw)?;
586 crate::config::merge_groups_into_policies(&mut policy_config);
587 crate::config::validate_config(&policy_config)?;
588
589 let parsed_routes: Vec<crate::config::RouteEntry> = policy_config.routes.clone();
594 let parsed_plugins: Vec<crate::plugin::PluginConfig> = policy_config.plugins.clone();
595
596 self.load_config(policy_config)?;
598
599 let visitors = {
602 let v = self
603 .visitors
604 .read()
605 .unwrap_or_else(std::sync::PoisonError::into_inner);
606 if v.is_empty() {
607 return Ok(());
608 }
609 v.clone()
610 };
611
612 let mgr: Arc<PolicyEngine> = Arc::clone(self);
613 let global_yaml = raw
614 .get("global")
615 .cloned()
616 .unwrap_or(serde_yaml::Value::Null);
617 let defaults_yaml = global_yaml
618 .get("defaults")
619 .and_then(serde_yaml::Value::as_mapping)
620 .cloned();
621 let policies_yaml = {
627 let from_policies = global_yaml
628 .get("policies")
629 .and_then(serde_yaml::Value::as_mapping)
630 .cloned();
631 let from_groups = raw
632 .get("groups")
633 .and_then(serde_yaml::Value::as_mapping)
634 .cloned();
635 match (from_policies, from_groups) {
636 (None, None) => None,
637 (Some(p), None) => Some(p),
638 (None, Some(g)) => Some(g),
639 (Some(mut p), Some(g)) => {
640 for (k, v) in g {
641 p.insert(k, v);
642 }
643 Some(p)
644 },
645 }
646 };
647 let routes_yaml: Vec<serde_yaml::Value> = raw
648 .get("routes")
649 .and_then(serde_yaml::Value::as_sequence)
650 .cloned()
651 .unwrap_or_default();
652
653 for visitor in &visitors {
654 visitor.visit_plugins(&mgr, &parsed_plugins).map_err(|e| {
655 Box::new(PluginError::Config {
656 message: format!("visitor '{}' visit_plugins: {}", visitor.name(), e),
657 })
658 })?;
659
660 visitor.visit_global(&mgr, &global_yaml).map_err(|e| {
661 Box::new(PluginError::Config {
662 message: format!("visitor '{}' visit_global: {}", visitor.name(), e),
663 })
664 })?;
665
666 if let Some(defaults) = &defaults_yaml {
667 for (k, v) in defaults {
668 let Some(entity_type) = k.as_str() else {
669 continue;
670 };
671 visitor.visit_default(&mgr, entity_type, v).map_err(|e| {
672 Box::new(PluginError::Config {
673 message: format!(
674 "visitor '{}' visit_default('{}'): {}",
675 visitor.name(),
676 entity_type,
677 e
678 ),
679 })
680 })?;
681 }
682 }
683
684 if let Some(policies) = &policies_yaml {
685 for (k, v) in policies {
686 let Some(tag) = k.as_str() else { continue };
687 visitor.visit_policy_bundle(&mgr, tag, v).map_err(|e| {
688 Box::new(PluginError::Config {
689 message: format!(
690 "visitor '{}' visit_policy_bundle('{}'): {}",
691 visitor.name(),
692 tag,
693 e
694 ),
695 })
696 })?;
697 }
698 }
699
700 for (i, parsed) in parsed_routes.iter().enumerate() {
701 let route_yaml = routes_yaml
702 .get(i)
703 .cloned()
704 .unwrap_or(serde_yaml::Value::Null);
705 visitor
706 .visit_route(&mgr, &route_yaml, parsed)
707 .map_err(|e| {
708 Box::new(PluginError::Config {
709 message: format!(
710 "visitor '{}' visit_route[{}]: {}",
711 visitor.name(),
712 i,
713 e
714 ),
715 })
716 })?;
717 }
718 }
719
720 Ok(())
721 }
722
723 pub fn from_config(
735 policy_config: PolicyConfig,
736 factories: &PluginFactoryRegistry,
737 ) -> Result<Self, Box<PluginError>> {
738 warn_on_inactive_settings(&policy_config);
739
740 let engine = Self::new(PolicyEngineConfig {
741 executor: ExecutorConfig::default(),
742 route_cache_max_entries: policy_config.plugin_settings.route_cache_max_entries,
743 });
744
745 let mut new_registry = PluginRegistry::new();
747 instantiate_plugins_into(&mut new_registry, &policy_config.plugins, factories)?;
748
749 engine
750 .runtime
751 .store(Arc::new(snapshot_from_config(new_registry, policy_config)));
752
753 Ok(engine)
754 }
755
756 pub fn register_handler<H, P>(
781 &self,
782 plugin: Arc<P>,
783 config: PluginConfig,
784 ) -> Result<(), Box<PluginError>>
785 where
786 H: HookTypeDef,
787 H::Result: Into<PluginResult<H::Payload>>,
788 P: Plugin + HookHandler<H> + 'static,
789 {
790 let handler: Arc<dyn AnyHookHandler> =
791 Arc::new(TypedHandlerAdapter::<H, P>::new(Arc::clone(&plugin)));
792 self.try_mutate_runtime(|snap| {
793 snap.registry
794 .register::<H>(plugin, config, handler)
795 .map_err(|msg| Box::new(PluginError::Config { message: msg }))
796 })?;
797 self.clear_routing_cache();
798 Ok(())
799 }
800
801 pub fn register_handler_for_names<H, P>(
819 &self,
820 plugin: Arc<P>,
821 config: PluginConfig,
822 names: &[&str],
823 ) -> Result<(), Box<PluginError>>
824 where
825 H: HookTypeDef,
826 H::Result: Into<PluginResult<H::Payload>>,
827 P: Plugin + HookHandler<H> + 'static,
828 {
829 let handler: Arc<dyn AnyHookHandler> =
830 Arc::new(TypedHandlerAdapter::<H, P>::new(Arc::clone(&plugin)));
831 self.try_mutate_runtime(|snap| {
832 snap.registry
833 .register_for_names::<H>(plugin, config, handler, names)
834 .map_err(|msg| Box::new(PluginError::Config { message: msg }))
835 })?;
836 self.clear_routing_cache();
837 Ok(())
838 }
839
840 pub fn register_raw<H: HookTypeDef>(
850 &self,
851 plugin: Arc<dyn Plugin>,
852 config: PluginConfig,
853 handler: Arc<dyn AnyHookHandler>,
854 ) -> Result<(), Box<PluginError>> {
855 self.try_mutate_runtime(|snap| {
856 snap.registry
857 .register::<H>(plugin, config, handler)
858 .map_err(|msg| Box::new(PluginError::Config { message: msg }))
859 })?;
860 self.clear_routing_cache();
861 Ok(())
862 }
863
864 pub async fn initialize(&self) -> Result<(), Box<PluginError>> {
875 if self.initialized.load(Ordering::Acquire) {
876 return Ok(());
877 }
878
879 let snapshot = self.load_runtime();
882
883 info!(
884 "Initializing PolicyEngine with {} plugins",
885 snapshot.registry.plugin_count()
886 );
887
888 let mut initialized_plugins: Vec<String> = Vec::new();
889
890 for name in snapshot.registry.plugin_names() {
891 if let Some(plugin_ref) = snapshot.registry.get(&name) {
892 let plugin = plugin_ref.plugin().clone();
893 let plugin_name = name;
894
895 if let Err(e) = plugin.initialize().await {
896 error!("Failed to initialize plugin '{}': {}", plugin_name, e);
897
898 for init_name in initialized_plugins.iter().rev() {
899 if let Some(pr) = snapshot.registry.get(init_name)
900 && let Err(shutdown_err) = pr.plugin().shutdown().await
901 {
902 error!(
903 "Error shutting down plugin '{}' during rollback: {}",
904 init_name, shutdown_err
905 );
906 }
907 }
908
909 return Err(Box::new(PluginError::Execution {
910 plugin_name,
911 message: format!("initialization failed: {e}"),
912 source: Some(Box::new(e)),
913 code: None,
914 details: std::collections::HashMap::new(),
915 proto_error_code: None,
916 }));
917 }
918
919 initialized_plugins.push(plugin_name);
920 }
921 }
922
923 self.initialized.store(true, Ordering::Release);
924 info!("PolicyEngine initialized successfully");
925 Ok(())
926 }
927
928 pub async fn shutdown(&self) {
938 if !self.initialized.load(Ordering::Acquire) {
939 return;
940 }
941
942 info!("Shutting down PolicyEngine");
943
944 self.task_tracker.close();
951 self.task_tracker.wait().await;
952
953 let snapshot = self.load_runtime();
954 for name in snapshot.registry.plugin_names() {
955 if let Some(plugin_ref) = snapshot.registry.get(&name) {
956 let plugin = plugin_ref.plugin().clone();
957
958 if let Err(e) = plugin.shutdown().await {
959 error!("Error shutting down plugin '{}': {}", name, e);
960 }
962 }
963 }
964
965 self.initialized.store(false, Ordering::Release);
966 info!("PolicyEngine shutdown complete");
967 }
968
969 pub async fn invoke_by_name(
990 &self,
991 hook_name: &str,
992 payload: Box<dyn PluginPayload>,
993 extensions: Extensions,
994 context_table: Option<PluginContextTable>,
995 ) -> (PipelineResult, BackgroundTasks) {
996 let snapshot = self.load_runtime();
1000 let hook_type = HookType::new(hook_name);
1001 let all_entries = snapshot.registry.entries_for_hook(&hook_type);
1002
1003 if all_entries.is_empty() && snapshot.route_annotations.is_empty() {
1008 return (
1009 PipelineResult::allowed_with(
1010 payload,
1011 extensions,
1012 context_table.unwrap_or_default(),
1013 ),
1014 BackgroundTasks::empty(),
1015 );
1016 }
1017
1018 let entries = self
1019 .filter_entries_by_route(&snapshot, all_entries, &extensions, hook_name)
1020 .await;
1021
1022 if entries.is_empty() {
1023 return (
1024 PipelineResult::allowed_with(
1025 payload,
1026 extensions,
1027 context_table.unwrap_or_default(),
1028 ),
1029 BackgroundTasks::empty(),
1030 );
1031 }
1032
1033 snapshot
1034 .executor
1035 .execute(
1036 &entries,
1037 payload,
1038 extensions,
1039 context_table,
1040 &self.task_tracker,
1041 )
1042 .await
1043 }
1044
1045 pub async fn invoke<H: HookTypeDef>(
1071 &self,
1072 payload: H::Payload,
1073 extensions: Extensions,
1074 context_table: Option<PluginContextTable>,
1075 ) -> (PipelineResult, BackgroundTasks) {
1076 let snapshot = self.load_runtime();
1077 let hook_type = HookType::new(H::NAME);
1078 let all_entries = snapshot.registry.entries_for_hook(&hook_type);
1079
1080 if all_entries.is_empty() && snapshot.route_annotations.is_empty() {
1084 let boxed: Box<dyn PluginPayload> = Box::new(payload);
1085 return (
1086 PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()),
1087 BackgroundTasks::empty(),
1088 );
1089 }
1090
1091 let entries = self
1092 .filter_entries_by_route(&snapshot, all_entries, &extensions, H::NAME)
1093 .await;
1094
1095 if entries.is_empty() {
1096 let boxed: Box<dyn PluginPayload> = Box::new(payload);
1097 return (
1098 PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()),
1099 BackgroundTasks::empty(),
1100 );
1101 }
1102
1103 let boxed: Box<dyn PluginPayload> = Box::new(payload);
1104 snapshot
1105 .executor
1106 .execute(
1107 &entries,
1108 boxed,
1109 extensions,
1110 context_table,
1111 &self.task_tracker,
1112 )
1113 .await
1114 }
1115
1116 pub async fn invoke_named<H: HookTypeDef>(
1144 &self,
1145 hook_name: &str,
1146 payload: H::Payload,
1147 extensions: Extensions,
1148 context_table: Option<PluginContextTable>,
1149 ) -> (PipelineResult, BackgroundTasks) {
1150 let snapshot = self.load_runtime();
1151 let hook_type = HookType::new(hook_name);
1152 let all_entries = snapshot.registry.entries_for_hook(&hook_type);
1153
1154 if all_entries.is_empty() && snapshot.route_annotations.is_empty() {
1161 let boxed: Box<dyn PluginPayload> = Box::new(payload);
1162 return (
1163 PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()),
1164 BackgroundTasks::empty(),
1165 );
1166 }
1167
1168 let entries = self
1169 .filter_entries_by_route(&snapshot, all_entries, &extensions, hook_name)
1170 .await;
1171
1172 if entries.is_empty() {
1173 let boxed: Box<dyn PluginPayload> = Box::new(payload);
1174 return (
1175 PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()),
1176 BackgroundTasks::empty(),
1177 );
1178 }
1179
1180 let boxed: Box<dyn PluginPayload> = Box::new(payload);
1181 snapshot
1182 .executor
1183 .execute(
1184 &entries,
1185 boxed,
1186 extensions,
1187 context_table,
1188 &self.task_tracker,
1189 )
1190 .await
1191 }
1192
1193 pub fn find_plugin_entries(
1209 &self,
1210 plugin_name: &str,
1211 ) -> Vec<(String, crate::registry::HookEntry)> {
1212 let snapshot = self.load_runtime();
1213 snapshot.registry.entries_for_plugin(plugin_name)
1214 }
1215
1216 pub async fn invoke_entries<H: HookTypeDef>(
1232 &self,
1233 entries: &[crate::registry::HookEntry],
1234 payload: H::Payload,
1235 extensions: Extensions,
1236 context_table: Option<PluginContextTable>,
1237 ) -> (PipelineResult, BackgroundTasks) {
1238 if entries.is_empty() {
1239 let boxed: Box<dyn PluginPayload> = Box::new(payload);
1240 return (
1241 PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()),
1242 BackgroundTasks::empty(),
1243 );
1244 }
1245 let snapshot = self.load_runtime();
1246 let boxed: Box<dyn PluginPayload> = Box::new(payload);
1247 snapshot
1248 .executor
1249 .execute(
1250 entries,
1251 boxed,
1252 extensions,
1253 context_table,
1254 &self.task_tracker,
1255 )
1256 .await
1257 }
1258
1259 pub fn annotate_route<H>(
1283 &self,
1284 entity_type: impl Into<String>,
1285 entity_name: impl Into<String>,
1286 scope: Option<String>,
1287 hook_name: impl Into<String>,
1288 handler: Arc<H>,
1289 config: crate::plugin::PluginConfig,
1290 ) where
1291 H: crate::plugin::Plugin + crate::registry::AnyHookHandler + 'static,
1292 {
1293 let key = AnnotationKey {
1294 entity_type: entity_type.into(),
1295 entity_name: entity_name.into(),
1296 scope,
1297 hook_name: hook_name.into(),
1298 };
1299 let plugin_ref = Arc::new(crate::registry::PluginRef::new(handler.clone(), config));
1300 let entry = crate::registry::HookEntry {
1301 plugin_ref,
1302 handler,
1303 };
1304 self.mutate_runtime(|snap| {
1305 snap.route_annotations.insert(key, entry);
1306 });
1307 }
1308
1309 pub fn remove_route_annotation(
1313 &self,
1314 entity_type: &str,
1315 entity_name: &str,
1316 scope: Option<&str>,
1317 hook_name: &str,
1318 ) {
1319 let key = AnnotationKey {
1320 entity_type: entity_type.to_owned(),
1321 entity_name: entity_name.to_owned(),
1322 scope: scope.map(str::to_owned),
1323 hook_name: hook_name.to_owned(),
1324 };
1325 self.mutate_runtime(|snap| {
1326 snap.route_annotations.remove(&key);
1327 });
1328 }
1329
1330 async fn filter_entries_by_route(
1341 &self,
1342 snapshot: &RuntimeSnapshot,
1343 entries: &[crate::registry::HookEntry],
1344 extensions: &Extensions,
1345 hook_name: &str,
1346 ) -> Arc<Vec<crate::registry::HookEntry>> {
1347 if !snapshot.route_annotations.is_empty()
1356 && let Some(meta) = &extensions.meta
1357 && let (Some(et), Some(en)) = (&meta.entity_type, &meta.entity_name)
1358 {
1359 let scoped = meta.scope.as_ref().and_then(|s| {
1365 snapshot.route_annotations.get(&AnnotationKey {
1366 entity_type: et.clone(),
1367 entity_name: en.clone(),
1368 scope: Some(s.clone()),
1369 hook_name: hook_name.to_owned(),
1370 })
1371 });
1372 let candidate = scoped.or_else(|| {
1373 snapshot.route_annotations.get(&AnnotationKey {
1374 entity_type: et.clone(),
1375 entity_name: en.clone(),
1376 scope: None,
1377 hook_name: hook_name.to_owned(),
1378 })
1379 });
1380 if let Some(entry) = candidate {
1381 return Arc::new(vec![entry.clone()]);
1382 }
1383 }
1384
1385 let policy_config = match &snapshot.policy_config {
1390 Some(c) if c.routing_enabled() => c,
1391 _ => {
1392 let filtered: Vec<_> = entries
1393 .iter()
1394 .filter(|e| e.plugin_ref.trusted_config().passes_conditions(extensions))
1395 .cloned()
1396 .collect();
1397 return Arc::new(filtered);
1398 },
1399 };
1400
1401 let meta = match &extensions.meta {
1402 Some(m) => m,
1403 None => return Arc::new(entries.to_vec()),
1404 };
1405
1406 let (entity_type, entity_name) = match (&meta.entity_type, &meta.entity_name) {
1407 (Some(t), Some(n)) => (t.as_str(), n.as_str()),
1408 _ => return Arc::new(entries.to_vec()),
1409 };
1410
1411 let request_scope = meta.scope.as_deref();
1412
1413 let hash = {
1415 use std::hash::BuildHasher as _;
1416 let mut hasher = self.cache_hasher.build_hasher();
1417 entity_type.hash(&mut hasher);
1418 entity_name.hash(&mut hasher);
1419 hook_name.hash(&mut hasher);
1420 request_scope.hash(&mut hasher);
1421 hasher.finish()
1422 };
1423 {
1424 let cache = self
1431 .route_cache
1432 .read()
1433 .unwrap_or_else(std::sync::PoisonError::into_inner);
1434 if let Some((_, cached)) = cache.raw_entry().from_hash(hash, |key| {
1435 key.entity_type == entity_type
1436 && key.entity_name == entity_name
1437 && key.hook_name == hook_name
1438 && key.scope.as_deref() == request_scope
1439 }) {
1440 return Arc::clone(cached);
1441 }
1442 }
1443
1444 let resolved = if hook_name == crate::identity::HOOK_IDENTITY_RESOLVE {
1452 config::resolve_identity_plugins_for_route(
1453 policy_config,
1454 entity_type,
1455 entity_name,
1456 request_scope,
1457 )
1458 } else {
1459 config::resolve_plugins_for_entity(
1460 policy_config,
1461 entity_type,
1462 entity_name,
1463 request_scope,
1464 &meta.tags,
1465 )
1466 };
1467
1468 let mut filtered = Vec::new();
1472 for resolved_plugin in &resolved {
1473 if let Some(entry) = entries
1474 .iter()
1475 .find(|e| e.plugin_ref.name() == resolved_plugin.name)
1476 {
1477 if let Some(overrides) = &resolved_plugin.config_overrides {
1478 if let Some(override_entry) =
1480 self.create_override_instance(entry, overrides).await
1481 {
1482 filtered.push(override_entry);
1483 continue;
1484 }
1485 }
1486 filtered.push(entry.clone());
1487 }
1488 }
1489
1490 let cached = Arc::new(filtered);
1491
1492 let cache_key = RouteCacheKey {
1497 entity_type: entity_type.to_owned(),
1498 entity_name: entity_name.to_owned(),
1499 hook_name: hook_name.to_owned(),
1500 scope: meta.scope.clone(),
1501 };
1502 let should_warn = {
1505 let mut cache = self
1506 .route_cache
1507 .write()
1508 .unwrap_or_else(std::sync::PoisonError::into_inner);
1509 if cache.len() >= snapshot.route_cache_max_entries {
1510 !self.route_cache_full_warned.swap(true, Ordering::AcqRel)
1511 } else {
1512 cache.insert(cache_key, Arc::clone(&cached));
1513 false
1514 }
1515 };
1516 if should_warn {
1517 warn!(
1518 max_entries = snapshot.route_cache_max_entries,
1519 "Routing cache at capacity — further routes will not be cached. \
1520 Increase plugin_settings.route_cache_max_entries or \
1521 investigate entity name growth.",
1522 );
1523 }
1524
1525 cached
1526 }
1527
1528 pub async fn build_override_entries(
1561 &self,
1562 plugin_name: &str,
1563 config_override: Option<&serde_yaml::Value>,
1564 capabilities_override: Option<&std::collections::HashSet<String>>,
1565 on_error_override: Option<crate::plugin::OnError>,
1566 ) -> Vec<(String, crate::registry::HookEntry)> {
1567 let base_entries = self.find_plugin_entries(plugin_name);
1568 if base_entries.is_empty() {
1569 return Vec::new();
1570 }
1571
1572 if config_override.is_none()
1574 && capabilities_override.is_none()
1575 && on_error_override.is_none()
1576 {
1577 return base_entries;
1578 }
1579
1580 let Some(base_ref) = base_entries.first().map(|(_, e)| Arc::clone(&e.plugin_ref)) else {
1584 return Vec::new();
1586 };
1587 let mut merged_config = base_ref.trusted_config().clone();
1588
1589 if let Some(caps) = capabilities_override {
1591 merged_config.capabilities = caps.clone();
1592 }
1593
1594 if let Some(oe) = on_error_override {
1596 merged_config.on_error = oe;
1597 }
1598
1599 if config_override.is_none() {
1603 let new_ref = Arc::new(crate::registry::PluginRef::new(
1604 Arc::clone(base_ref.plugin()),
1605 merged_config,
1606 ));
1607 return base_entries
1608 .into_iter()
1609 .map(|(hook_name, base_entry)| {
1610 (
1611 hook_name,
1612 crate::registry::HookEntry {
1613 plugin_ref: Arc::clone(&new_ref),
1614 handler: base_entry.handler,
1615 },
1616 )
1617 })
1618 .collect();
1619 }
1620
1621 let Some(cfg_yaml) = config_override else {
1627 return base_entries;
1629 };
1630 let cfg_json = match serde_json::to_value(cfg_yaml) {
1631 Ok(v) => v,
1632 Err(e) => {
1633 error!(
1634 plugin = %plugin_name,
1635 error = %e,
1636 "build_override_entries: YAML→JSON config conversion failed",
1637 );
1638 return Vec::new();
1639 },
1640 };
1641 merged_config.config = Some(cfg_json);
1642
1643 let kind = merged_config.kind.clone();
1644 let factory = {
1648 let factories = self
1649 .factories
1650 .read()
1651 .unwrap_or_else(std::sync::PoisonError::into_inner);
1652 if let Some(f) = factories.get(&kind) {
1653 f
1654 } else {
1655 error!(
1656 plugin = %plugin_name,
1657 kind = %kind,
1658 "build_override_entries: no factory registered for kind",
1659 );
1660 return Vec::new();
1661 }
1662 };
1663 let instance = {
1664 match factory.create(&merged_config) {
1665 Ok(i) => i,
1666 Err(e) => {
1667 error!(
1668 plugin = %plugin_name,
1669 error = %e,
1670 "build_override_entries: factory.create failed",
1671 );
1672 return Vec::new();
1673 },
1674 }
1675 };
1676
1677 if let Err(e) = instance.plugin.initialize().await {
1678 error!(
1679 plugin = %plugin_name,
1680 error = %e,
1681 "build_override_entries: initialize() failed on new instance",
1682 );
1683 return Vec::new();
1684 }
1685
1686 let new_ref = Arc::new(crate::registry::PluginRef::new(
1690 Arc::clone(&instance.plugin),
1691 merged_config,
1692 ));
1693 instance
1694 .handlers
1695 .into_iter()
1696 .map(|(hook_name, handler)| {
1697 (
1698 hook_name.to_owned(),
1699 crate::registry::HookEntry {
1700 plugin_ref: Arc::clone(&new_ref),
1701 handler,
1702 },
1703 )
1704 })
1705 .collect()
1706 }
1707
1708 async fn create_override_instance(
1733 &self,
1734 base_entry: &crate::registry::HookEntry,
1735 overrides: &serde_json::Value,
1736 ) -> Option<crate::registry::HookEntry> {
1737 let base_config = base_entry.plugin_ref.trusted_config();
1738 let kind = &base_config.kind;
1739
1740 let mut merged_config = base_config.clone();
1742 if let Some(override_config) = overrides.get("config") {
1743 if let Some(base_plugin_config) = &merged_config.config {
1745 let mut merged = base_plugin_config.clone();
1746 if let (Some(base_obj), Some(override_obj)) =
1747 (merged.as_object_mut(), override_config.as_object())
1748 {
1749 for (key, value) in override_obj {
1750 base_obj.insert(key.clone(), value.clone());
1751 }
1752 }
1753 merged_config.config = Some(merged);
1754 } else {
1755 merged_config.config = Some(override_config.clone());
1756 }
1757 }
1758
1759 let target_hook = base_entry.handler.hook_type_name();
1763 let factory = {
1765 let factories = self
1766 .factories
1767 .read()
1768 .unwrap_or_else(std::sync::PoisonError::into_inner);
1769 match factories.get(kind) {
1770 Some(f) => f,
1771 None => return None,
1772 }
1773 };
1774 let instance = {
1775 match factory.create(&merged_config) {
1776 Ok(i) => i,
1777 Err(e) => {
1778 error!(
1779 "Failed to create override instance for '{}': {}",
1780 base_config.name, e
1781 );
1782 return None; },
1784 }
1785 };
1786
1787 let handler = instance
1790 .handlers
1791 .into_iter()
1792 .find(|(name, _)| *name == target_hook)
1793 .map(|(_, h)| h);
1794 let handler = if let Some(h) = handler {
1795 h
1796 } else {
1797 warn!(
1798 "Override instance for '{}' has no handler for hook '{}'",
1799 base_config.name, target_hook
1800 );
1801 return None;
1802 };
1803
1804 if let Err(e) = instance.plugin.initialize().await {
1808 error!(
1809 "Failed to initialize override instance for '{}': {} — falling back to base",
1810 base_config.name, e
1811 );
1812 return None;
1813 }
1814
1815 let plugin_ref = Arc::new(crate::registry::PluginRef::new(
1819 instance.plugin,
1820 merged_config,
1821 ));
1822 Some(crate::registry::HookEntry {
1823 plugin_ref,
1824 handler,
1825 })
1826 }
1827
1828 pub fn clear_routing_cache(&self) {
1832 {
1833 let mut cache = self
1834 .route_cache
1835 .write()
1836 .unwrap_or_else(std::sync::PoisonError::into_inner);
1837 cache.clear();
1838 }
1839 self.route_cache_full_warned.store(false, Ordering::Release);
1842 }
1843
1844 pub fn routing_cache_size(&self) -> usize {
1846 self.route_cache
1847 .read()
1848 .unwrap_or_else(std::sync::PoisonError::into_inner)
1849 .len()
1850 }
1851
1852 pub fn has_hooks_for(&self, hook_name: &str) -> bool {
1863 let snapshot = self.load_runtime();
1864 snapshot.registry.has_hooks_for(&HookType::new(hook_name))
1865 || snapshot
1866 .route_annotations
1867 .keys()
1868 .any(|k| k.hook_name.as_str() == hook_name)
1869 }
1870
1871 pub fn get_plugin(&self, name: &str) -> Option<Arc<PluginRef>> {
1877 self.load_runtime().registry.get(name)
1878 }
1879
1880 pub fn plugin_count(&self) -> usize {
1882 self.load_runtime().registry.plugin_count()
1883 }
1884
1885 pub fn plugin_names(&self) -> Vec<String> {
1887 self.load_runtime().registry.plugin_names()
1888 }
1889
1890 pub fn is_initialized(&self) -> bool {
1892 self.initialized.load(Ordering::Acquire)
1893 }
1894
1895 pub fn unregister(&self, name: &str) -> Option<Arc<PluginRef>> {
1897 let removed = self.mutate_runtime(|snap| snap.registry.unregister(name));
1898 if removed.is_some() {
1899 self.clear_routing_cache();
1900 }
1901 removed
1902 }
1903}
1904
1905impl Default for PolicyEngine {
1906 fn default() -> Self {
1907 Self::new(PolicyEngineConfig::default())
1908 }
1909}
1910
1911#[cfg(test)]
1912#[allow(
1913 clippy::needless_raw_string_hashes,
1914 clippy::needless_raw_strings,
1915 clippy::significant_drop_tightening,
1916 trivial_casts,
1917 clippy::expect_used,
1918 clippy::indexing_slicing,
1919 clippy::panic,
1920 clippy::print_stderr,
1921 clippy::print_stdout,
1922 clippy::unwrap_used,
1923 reason = "tests"
1924)]
1925mod tests {
1926 use super::*;
1927 use crate::context::PluginContext;
1928 use crate::error::PluginViolation;
1929 use crate::hooks::payload::Extensions;
1930 use crate::hooks::{HookHandler, PluginResult};
1931 use crate::plugin::{OnError, PluginMode};
1932 use async_trait::async_trait;
1933
1934 #[derive(Debug, Clone)]
1937 struct TestPayload {
1938 value: String,
1939 }
1940 crate::impl_plugin_payload!(TestPayload);
1941
1942 struct TestHook;
1945 impl HookTypeDef for TestHook {
1946 type Payload = TestPayload;
1947 type Result = PluginResult<TestPayload>;
1948 const NAME: &'static str = "test_hook";
1949 }
1950
1951 struct AllowPlugin {
1956 cfg: PluginConfig,
1957 }
1958
1959 #[async_trait]
1960 impl Plugin for AllowPlugin {
1961 fn config(&self) -> &PluginConfig {
1962 &self.cfg
1963 }
1964 async fn initialize(&self) -> Result<(), Box<PluginError>> {
1965 Ok(())
1966 }
1967 async fn shutdown(&self) -> Result<(), Box<PluginError>> {
1968 Ok(())
1969 }
1970 }
1971
1972 impl HookHandler<TestHook> for AllowPlugin {
1973 async fn handle(
1974 &self,
1975 _payload: &TestPayload,
1976 _extensions: &Extensions,
1977 _ctx: &mut PluginContext,
1978 ) -> PluginResult<TestPayload> {
1979 PluginResult::allow()
1980 }
1981 }
1982
1983 struct DenyPlugin {
1985 cfg: PluginConfig,
1986 }
1987
1988 #[async_trait]
1989 impl Plugin for DenyPlugin {
1990 fn config(&self) -> &PluginConfig {
1991 &self.cfg
1992 }
1993 async fn initialize(&self) -> Result<(), Box<PluginError>> {
1994 Ok(())
1995 }
1996 async fn shutdown(&self) -> Result<(), Box<PluginError>> {
1997 Ok(())
1998 }
1999 }
2000
2001 impl HookHandler<TestHook> for DenyPlugin {
2002 async fn handle(
2003 &self,
2004 _payload: &TestPayload,
2005 _extensions: &Extensions,
2006 _ctx: &mut PluginContext,
2007 ) -> PluginResult<TestPayload> {
2008 PluginResult::deny(PluginViolation::new("denied", "test denial"))
2009 }
2010 }
2011
2012 struct ErrorHandler;
2014
2015 #[async_trait]
2016 impl AnyHookHandler for ErrorHandler {
2017 async fn invoke(
2018 &self,
2019 _payload: &dyn PluginPayload,
2020 _extensions: &Extensions,
2021 _ctx: &mut PluginContext,
2022 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
2023 Err(Box::new(PluginError::Execution {
2024 plugin_name: "error-plugin".into(),
2025 message: "simulated failure".into(),
2026 source: None,
2027 code: None,
2028 details: std::collections::HashMap::new(),
2029 proto_error_code: None,
2030 }))
2031 }
2032
2033 fn hook_type_name(&self) -> &'static str {
2034 "test_hook"
2035 }
2036 }
2037
2038 fn make_config(name: &str, priority: i32, mode: PluginMode) -> PluginConfig {
2041 make_config_with_on_error(name, priority, mode, OnError::Fail)
2042 }
2043
2044 fn make_config_with_on_error(
2045 name: &str,
2046 priority: i32,
2047 mode: PluginMode,
2048 on_error: OnError,
2049 ) -> PluginConfig {
2050 PluginConfig {
2051 name: name.to_owned(),
2052 kind: "test".to_owned(),
2053 description: None,
2054 author: None,
2055 version: None,
2056 hooks: vec!["test_hook".to_owned()],
2057 mode,
2058 priority,
2059 on_error,
2060 capabilities: Default::default(),
2061 tags: Vec::new(),
2062 conditions: Vec::new(),
2063 config: None,
2064 }
2065 }
2066
2067 fn make_config_with_conditions(
2068 name: &str,
2069 conditions: Vec<crate::plugin::PluginCondition>,
2070 ) -> PluginConfig {
2071 let mut cfg = make_config(name, 10, PluginMode::Sequential);
2072 cfg.conditions = conditions;
2073 cfg
2074 }
2075
2076 #[tokio::test]
2079 async fn test_manager_lifecycle() {
2080 let mgr = PolicyEngine::default();
2081 assert!(!mgr.is_initialized());
2082 assert_eq!(mgr.plugin_count(), 0);
2083
2084 mgr.initialize().await.unwrap();
2085 assert!(mgr.is_initialized());
2086
2087 mgr.initialize().await.unwrap();
2089
2090 mgr.shutdown().await;
2091 assert!(!mgr.is_initialized());
2092 }
2093
2094 #[tokio::test]
2095 async fn test_invoke_by_name_no_plugins() {
2096 let mgr = PolicyEngine::default();
2097 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
2098 value: "test".into(),
2099 });
2100
2101 let (result, _) = mgr
2102 .invoke_by_name("test_hook", payload, Extensions::default(), None)
2103 .await;
2104
2105 assert!(result.continue_processing);
2106 assert!(result.modified_payload.is_some());
2107 }
2108
2109 #[tokio::test]
2110 async fn test_invoke_by_name_allow() {
2111 let mgr = PolicyEngine::default();
2112 let config = make_config("allow-plugin", 10, PluginMode::Sequential);
2113 let plugin = Arc::new(AllowPlugin {
2114 cfg: config.clone(),
2115 });
2116
2117 mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2118 mgr.initialize().await.unwrap();
2119
2120 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
2121 value: "test".into(),
2122 });
2123
2124 let (result, _) = mgr
2125 .invoke_by_name("test_hook", payload, Extensions::default(), None)
2126 .await;
2127
2128 assert!(result.continue_processing);
2129 }
2130
2131 #[tokio::test]
2132 async fn test_invoke_by_name_deny() {
2133 let mgr = PolicyEngine::default();
2134 let config = make_config("deny-plugin", 10, PluginMode::Sequential);
2135 let plugin = Arc::new(DenyPlugin {
2136 cfg: config.clone(),
2137 });
2138
2139 mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2140 mgr.initialize().await.unwrap();
2141
2142 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
2143 value: "test".into(),
2144 });
2145
2146 let (result, _) = mgr
2147 .invoke_by_name("test_hook", payload, Extensions::default(), None)
2148 .await;
2149
2150 assert!(!result.continue_processing);
2151 assert_eq!(result.violation.as_ref().unwrap().code, "denied");
2152 }
2153
2154 #[tokio::test]
2155 async fn test_invoke_typed() {
2156 let mgr = PolicyEngine::default();
2157 let config = make_config("allow-plugin", 10, PluginMode::Sequential);
2158 let plugin = Arc::new(AllowPlugin {
2159 cfg: config.clone(),
2160 });
2161
2162 mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2163 mgr.initialize().await.unwrap();
2164
2165 let payload = TestPayload {
2166 value: "typed".into(),
2167 };
2168
2169 let (result, _) = mgr
2170 .invoke::<TestHook>(payload, Extensions::default(), None)
2171 .await;
2172
2173 assert!(result.continue_processing);
2174 }
2175
2176 #[tokio::test]
2177 async fn test_invoke_named() {
2178 let mgr = PolicyEngine::default();
2181 let config = make_config("allow-plugin", 10, PluginMode::Sequential);
2182 let plugin = Arc::new(AllowPlugin {
2183 cfg: config.clone(),
2184 });
2185
2186 mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2187 mgr.initialize().await.unwrap();
2188
2189 let payload = TestPayload {
2190 value: "named".into(),
2191 };
2192
2193 let (result, _) = mgr
2196 .invoke_named::<TestHook>("test_hook", payload, Extensions::default(), None)
2197 .await;
2198
2199 assert!(result.continue_processing);
2200 }
2201
2202 #[tokio::test]
2203 async fn test_invoke_named_no_plugins_for_hook() {
2204 let mgr = PolicyEngine::default();
2206 let config = make_config("allow-plugin", 10, PluginMode::Sequential);
2207 let plugin = Arc::new(AllowPlugin {
2208 cfg: config.clone(),
2209 });
2210
2211 mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2212 mgr.initialize().await.unwrap();
2213
2214 let payload = TestPayload {
2215 value: "no-match".into(),
2216 };
2217
2218 let (result, _) = mgr
2220 .invoke_named::<TestHook>("other_hook", payload, Extensions::default(), None)
2221 .await;
2222
2223 assert!(result.continue_processing);
2225 }
2226
2227 #[tokio::test]
2228 async fn test_invoke_named_deny() {
2229 let mgr = PolicyEngine::default();
2230 let config = make_config("deny-plugin", 10, PluginMode::Sequential);
2231 let plugin = Arc::new(DenyPlugin {
2232 cfg: config.clone(),
2233 });
2234
2235 mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2236 mgr.initialize().await.unwrap();
2237
2238 let payload = TestPayload {
2239 value: "denied".into(),
2240 };
2241
2242 let (result, _) = mgr
2243 .invoke_named::<TestHook>("test_hook", payload, Extensions::default(), None)
2244 .await;
2245
2246 assert!(!result.continue_processing);
2247 assert_eq!(result.violation.as_ref().unwrap().code, "denied");
2248 }
2249
2250 #[tokio::test]
2251 async fn test_has_hooks_for() {
2252 let mgr = PolicyEngine::default();
2253 assert!(!mgr.has_hooks_for("test_hook"));
2254
2255 let config = make_config("p1", 10, PluginMode::Sequential);
2256 let plugin = Arc::new(AllowPlugin {
2257 cfg: config.clone(),
2258 });
2259 mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2260
2261 assert!(mgr.has_hooks_for("test_hook"));
2262 assert!(!mgr.has_hooks_for("other_hook"));
2263 }
2264
2265 #[tokio::test]
2269 async fn test_conditions_filter_plugins_when_routing_disabled() {
2270 use std::sync::Arc as StdArc;
2271 use std::sync::atomic::{AtomicUsize, Ordering};
2272
2273 let counts: StdArc<[AtomicUsize; 2]> =
2274 StdArc::new([AtomicUsize::new(0), AtomicUsize::new(0)]);
2275
2276 struct CountingHandler {
2277 idx: usize,
2278 counts: StdArc<[AtomicUsize; 2]>,
2279 }
2280 #[async_trait]
2281 impl AnyHookHandler for CountingHandler {
2282 async fn invoke(
2283 &self,
2284 _payload: &dyn PluginPayload,
2285 _extensions: &Extensions,
2286 _ctx: &mut PluginContext,
2287 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
2288 self.counts[self.idx].fetch_add(1, Ordering::SeqCst);
2289 let result: PluginResult<TestPayload> = PluginResult::allow();
2290 Ok(crate::executor::erase_result(result))
2291 }
2292 fn hook_type_name(&self) -> &'static str {
2293 "test_hook"
2294 }
2295 }
2296
2297 let mgr = PolicyEngine::default();
2298
2299 let mut tools = std::collections::HashSet::new();
2301 tools.insert("wanted_tool".to_owned());
2302 let cfg_a = make_config_with_conditions(
2303 "plugin_a",
2304 vec![crate::plugin::PluginCondition {
2305 tools: Some(tools),
2306 ..Default::default()
2307 }],
2308 );
2309 let plugin_a = Arc::new(AllowPlugin { cfg: cfg_a.clone() });
2310 let handler_a: Arc<dyn AnyHookHandler> = Arc::new(CountingHandler {
2311 idx: 0,
2312 counts: StdArc::clone(&counts),
2313 });
2314 mgr.register_raw::<TestHook>(plugin_a, cfg_a, handler_a)
2315 .unwrap();
2316
2317 let cfg_b = make_config("plugin_b", 20, PluginMode::Sequential);
2319 let plugin_b = Arc::new(AllowPlugin { cfg: cfg_b.clone() });
2320 let handler_b: Arc<dyn AnyHookHandler> = Arc::new(CountingHandler {
2321 idx: 1,
2322 counts: StdArc::clone(&counts),
2323 });
2324 mgr.register_raw::<TestHook>(plugin_b, cfg_b, handler_b)
2325 .unwrap();
2326
2327 mgr.initialize().await.unwrap();
2328
2329 let ext_match = Extensions {
2331 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
2332 entity_type: Some("tool".into()),
2333 entity_name: Some("wanted_tool".into()),
2334 ..Default::default()
2335 })),
2336 ..Default::default()
2337 };
2338 let p: Box<dyn PluginPayload> = Box::new(TestPayload { value: "1".into() });
2339 let _ = mgr.invoke_by_name("test_hook", p, ext_match, None).await;
2340 assert_eq!(
2341 counts[0].load(Ordering::SeqCst),
2342 1,
2343 "plugin_a should fire on matching tool"
2344 );
2345 assert_eq!(
2346 counts[1].load(Ordering::SeqCst),
2347 1,
2348 "plugin_b should fire (no conditions)"
2349 );
2350
2351 let ext_no_match = Extensions {
2353 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
2354 entity_type: Some("tool".into()),
2355 entity_name: Some("other_tool".into()),
2356 ..Default::default()
2357 })),
2358 ..Default::default()
2359 };
2360 let p: Box<dyn PluginPayload> = Box::new(TestPayload { value: "2".into() });
2361 let _ = mgr.invoke_by_name("test_hook", p, ext_no_match, None).await;
2362 assert_eq!(
2363 counts[0].load(Ordering::SeqCst),
2364 1,
2365 "plugin_a should NOT fire on non-matching tool"
2366 );
2367 assert_eq!(
2368 counts[1].load(Ordering::SeqCst),
2369 2,
2370 "plugin_b should fire on every request"
2371 );
2372 }
2373
2374 #[tokio::test]
2377 async fn test_conditions_user_patterns_glob_filters() {
2378 use std::sync::atomic::{AtomicUsize, Ordering};
2379
2380 static FIRED: AtomicUsize = AtomicUsize::new(0);
2381 FIRED.store(0, Ordering::SeqCst);
2382
2383 struct CountHandler;
2384 #[async_trait]
2385 impl AnyHookHandler for CountHandler {
2386 async fn invoke(
2387 &self,
2388 _payload: &dyn PluginPayload,
2389 _extensions: &Extensions,
2390 _ctx: &mut PluginContext,
2391 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
2392 FIRED.fetch_add(1, Ordering::SeqCst);
2393 let result: PluginResult<TestPayload> = PluginResult::allow();
2394 Ok(crate::executor::erase_result(result))
2395 }
2396 fn hook_type_name(&self) -> &'static str {
2397 "test_hook"
2398 }
2399 }
2400
2401 let mgr = PolicyEngine::default();
2402 let cfg = make_config_with_conditions(
2403 "admin_only",
2404 vec![crate::plugin::PluginCondition {
2405 user_patterns: Some(vec!["admin-*".to_owned()]),
2406 ..Default::default()
2407 }],
2408 );
2409 let plugin = Arc::new(AllowPlugin { cfg: cfg.clone() });
2410 let handler: Arc<dyn AnyHookHandler> = Arc::new(CountHandler);
2411 mgr.register_raw::<TestHook>(plugin, cfg, handler).unwrap();
2412 mgr.initialize().await.unwrap();
2413
2414 let ext_with_user = |id: &str| Extensions {
2415 security: Some(std::sync::Arc::new(crate::extensions::SecurityExtension {
2416 subject: Some(crate::extensions::security::SubjectExtension {
2417 id: Some(id.to_owned()),
2418 ..Default::default()
2419 }),
2420 ..Default::default()
2421 })),
2422 ..Default::default()
2423 };
2424
2425 let p: Box<dyn PluginPayload> = Box::new(TestPayload { value: "1".into() });
2426 let _ = mgr
2427 .invoke_by_name("test_hook", p, ext_with_user("admin-alice"), None)
2428 .await;
2429 assert_eq!(
2430 FIRED.load(Ordering::SeqCst),
2431 1,
2432 "admin-alice should match admin-*"
2433 );
2434
2435 let p: Box<dyn PluginPayload> = Box::new(TestPayload { value: "2".into() });
2436 let _ = mgr
2437 .invoke_by_name("test_hook", p, ext_with_user("user-bob"), None)
2438 .await;
2439 assert_eq!(
2440 FIRED.load(Ordering::SeqCst),
2441 1,
2442 "user-bob should NOT match admin-*"
2443 );
2444 }
2445
2446 #[tokio::test]
2447 async fn test_unregister() {
2448 let mgr = PolicyEngine::default();
2449 let config = make_config("removable", 10, PluginMode::Sequential);
2450 let plugin = Arc::new(AllowPlugin {
2451 cfg: config.clone(),
2452 });
2453 mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2454
2455 assert_eq!(mgr.plugin_count(), 1);
2456 mgr.unregister("removable");
2457 assert_eq!(mgr.plugin_count(), 0);
2458 assert!(!mgr.has_hooks_for("test_hook"));
2459 }
2460
2461 #[tokio::test]
2467 async fn test_manager_arc_shareable_with_concurrent_dispatch_and_registration() {
2468 use std::sync::atomic::{AtomicUsize, Ordering};
2469
2470 static INVOKE_COUNT: AtomicUsize = AtomicUsize::new(0);
2471 INVOKE_COUNT.store(0, Ordering::SeqCst);
2472
2473 struct CountingHandler;
2474 #[async_trait]
2475 impl AnyHookHandler for CountingHandler {
2476 async fn invoke(
2477 &self,
2478 _payload: &dyn PluginPayload,
2479 _extensions: &Extensions,
2480 _ctx: &mut PluginContext,
2481 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
2482 INVOKE_COUNT.fetch_add(1, Ordering::SeqCst);
2483 let result: PluginResult<TestPayload> = PluginResult::allow();
2484 Ok(crate::executor::erase_result(result))
2485 }
2486 fn hook_type_name(&self) -> &'static str {
2487 "test_hook"
2488 }
2489 }
2490
2491 let mgr = Arc::new(PolicyEngine::default());
2492
2493 let cfg = make_config("p0", 10, PluginMode::Sequential);
2495 let plugin: Arc<AllowPlugin> = Arc::new(AllowPlugin { cfg: cfg.clone() });
2496 let handler: Arc<dyn AnyHookHandler> = Arc::new(CountingHandler);
2497 mgr.register_raw::<TestHook>(plugin, cfg, handler).unwrap();
2498 mgr.initialize().await.unwrap();
2499
2500 let n = 16;
2503 let mut handles = Vec::with_capacity(n + 1);
2504 for i in 0..n {
2505 let mgr = Arc::clone(&mgr);
2506 handles.push(tokio::spawn(async move {
2507 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
2508 value: format!("call-{i}"),
2509 });
2510 let (result, _) = mgr
2511 .invoke_by_name("test_hook", payload, Extensions::default(), None)
2512 .await;
2513 assert!(result.continue_processing);
2514 }));
2515 }
2516
2517 {
2519 let mgr = Arc::clone(&mgr);
2520 handles.push(tokio::spawn(async move {
2521 let cfg = make_config("p1-late", 20, PluginMode::Sequential);
2522 let plugin: Arc<AllowPlugin> = Arc::new(AllowPlugin { cfg: cfg.clone() });
2523 let handler: Arc<dyn AnyHookHandler> = Arc::new(CountingHandler);
2524 mgr.register_raw::<TestHook>(plugin, cfg, handler).unwrap();
2525 }));
2526 }
2527
2528 for h in handles {
2529 h.await.unwrap();
2530 }
2531
2532 assert!(INVOKE_COUNT.load(Ordering::SeqCst) >= n);
2537 assert_eq!(mgr.plugin_count(), 2);
2539 }
2540
2541 #[tokio::test]
2542 async fn test_audit_plugin_cannot_block() {
2543 let mgr = PolicyEngine::default();
2544 let config = make_config("audit-denier", 10, PluginMode::Audit);
2545 let plugin = Arc::new(DenyPlugin {
2546 cfg: config.clone(),
2547 });
2548
2549 mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2550 mgr.initialize().await.unwrap();
2551
2552 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
2553 value: "test".into(),
2554 });
2555
2556 let (result, _) = mgr
2557 .invoke_by_name("test_hook", payload, Extensions::default(), None)
2558 .await;
2559
2560 assert!(result.continue_processing);
2562 }
2563
2564 #[tokio::test]
2565 async fn test_on_error_disable_skips_plugin_on_subsequent_invocations() {
2566 let mgr = PolicyEngine::default();
2567
2568 let config =
2570 make_config_with_on_error("flaky-plugin", 10, PluginMode::Sequential, OnError::Disable);
2571 let plugin = Arc::new(AllowPlugin {
2572 cfg: config.clone(),
2573 });
2574 let handler: Arc<dyn AnyHookHandler> = Arc::new(ErrorHandler);
2575 mgr.register_raw::<TestHook>(plugin, config, handler)
2576 .unwrap();
2577
2578 let config2 = make_config("allow-plugin", 20, PluginMode::Sequential);
2580 let plugin2 = Arc::new(AllowPlugin {
2581 cfg: config2.clone(),
2582 });
2583 mgr.register_handler::<TestHook, _>(plugin2, config2)
2584 .unwrap();
2585
2586 mgr.initialize().await.unwrap();
2587
2588 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
2591 value: "first".into(),
2592 });
2593 let (result, _) = mgr
2594 .invoke_by_name("test_hook", payload, Extensions::default(), None)
2595 .await;
2596 assert!(result.continue_processing);
2597
2598 let plugin_ref = mgr.get_plugin("flaky-plugin").unwrap();
2600 assert!(plugin_ref.is_disabled());
2601 assert_eq!(plugin_ref.mode(), PluginMode::Disabled);
2602
2603 let payload2: Box<dyn PluginPayload> = Box::new(TestPayload {
2606 value: "second".into(),
2607 });
2608 let (result2, _) = mgr
2609 .invoke_by_name("test_hook", payload2, Extensions::default(), None)
2610 .await;
2611 assert!(result2.continue_processing);
2612 }
2613
2614 #[tokio::test]
2615 async fn test_on_error_ignore_continues_without_disabling() {
2616 let mgr = PolicyEngine::default();
2617
2618 let config =
2620 make_config_with_on_error("flaky-plugin", 10, PluginMode::Sequential, OnError::Ignore);
2621 let plugin = Arc::new(AllowPlugin {
2622 cfg: config.clone(),
2623 });
2624 let handler: Arc<dyn AnyHookHandler> = Arc::new(ErrorHandler);
2625 mgr.register_raw::<TestHook>(plugin, config, handler)
2626 .unwrap();
2627
2628 mgr.initialize().await.unwrap();
2629
2630 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
2632 value: "test".into(),
2633 });
2634 let (result, _) = mgr
2635 .invoke_by_name("test_hook", payload, Extensions::default(), None)
2636 .await;
2637 assert!(result.continue_processing);
2638
2639 let plugin_ref = mgr.get_plugin("flaky-plugin").unwrap();
2641 assert!(!plugin_ref.is_disabled());
2642 assert_eq!(plugin_ref.mode(), PluginMode::Sequential);
2643 }
2644
2645 #[tokio::test]
2649 async fn test_on_error_ignore_records_in_pipeline_errors() {
2650 let mgr = PolicyEngine::default();
2651 let config =
2652 make_config_with_on_error("flaky-plugin", 10, PluginMode::Sequential, OnError::Ignore);
2653 let plugin = Arc::new(AllowPlugin {
2654 cfg: config.clone(),
2655 });
2656 let handler: Arc<dyn AnyHookHandler> = Arc::new(ErrorHandler);
2657 mgr.register_raw::<TestHook>(plugin, config, handler)
2658 .unwrap();
2659
2660 mgr.initialize().await.unwrap();
2661
2662 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
2663 let (result, _) = mgr
2664 .invoke_by_name("test_hook", payload, Extensions::default(), None)
2665 .await;
2666
2667 assert!(result.continue_processing);
2669 assert_eq!(result.errors.len(), 1, "expected one error record");
2671 let rec = &result.errors[0];
2672 assert_eq!(rec.plugin_name, "error-plugin");
2673 assert!(
2674 rec.message.contains("simulated failure"),
2675 "message lost: {}",
2676 rec.message,
2677 );
2678 }
2679
2680 #[tokio::test]
2683 async fn test_on_error_disable_records_in_pipeline_errors() {
2684 let mgr = PolicyEngine::default();
2685 let config =
2686 make_config_with_on_error("flaky-plugin", 10, PluginMode::Sequential, OnError::Disable);
2687 let plugin = Arc::new(AllowPlugin {
2688 cfg: config.clone(),
2689 });
2690 let handler: Arc<dyn AnyHookHandler> = Arc::new(ErrorHandler);
2691 mgr.register_raw::<TestHook>(plugin, config, handler)
2692 .unwrap();
2693
2694 mgr.initialize().await.unwrap();
2695
2696 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
2697 let (result, _) = mgr
2698 .invoke_by_name("test_hook", payload, Extensions::default(), None)
2699 .await;
2700
2701 assert!(result.continue_processing);
2702 assert_eq!(result.errors.len(), 1);
2703 assert!(mgr.get_plugin("flaky-plugin").unwrap().is_disabled());
2705 }
2706
2707 #[tokio::test]
2708 async fn test_on_error_fail_halts_pipeline() {
2709 let mgr = PolicyEngine::default();
2710
2711 let config =
2713 make_config_with_on_error("strict-plugin", 10, PluginMode::Sequential, OnError::Fail);
2714 let plugin = Arc::new(AllowPlugin {
2715 cfg: config.clone(),
2716 });
2717 let handler: Arc<dyn AnyHookHandler> = Arc::new(ErrorHandler);
2718 mgr.register_raw::<TestHook>(plugin, config, handler)
2719 .unwrap();
2720
2721 mgr.initialize().await.unwrap();
2722
2723 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
2725 value: "test".into(),
2726 });
2727 let (result, _) = mgr
2728 .invoke_by_name("test_hook", payload, Extensions::default(), None)
2729 .await;
2730 assert!(!result.continue_processing);
2731 assert_eq!(result.violation.as_ref().unwrap().code, "plugin_error");
2732 assert_eq!(
2733 result.violation.as_ref().unwrap().plugin_name.as_deref(),
2734 Some("strict-plugin"),
2735 );
2736 }
2737
2738 struct TransformPlugin {
2742 cfg: PluginConfig,
2743 }
2744
2745 #[async_trait]
2746 impl Plugin for TransformPlugin {
2747 fn config(&self) -> &PluginConfig {
2748 &self.cfg
2749 }
2750 async fn initialize(&self) -> Result<(), Box<PluginError>> {
2751 Ok(())
2752 }
2753 async fn shutdown(&self) -> Result<(), Box<PluginError>> {
2754 Ok(())
2755 }
2756 }
2757
2758 impl HookHandler<TestHook> for TransformPlugin {
2759 async fn handle(
2760 &self,
2761 payload: &TestPayload,
2762 _extensions: &Extensions,
2763 _ctx: &mut PluginContext,
2764 ) -> PluginResult<TestPayload> {
2765 PluginResult::modify_payload(TestPayload {
2766 value: format!("{}_transformed", payload.value),
2767 })
2768 }
2769 }
2770
2771 struct SlowHandler {
2773 delay_ms: u64,
2774 }
2775
2776 #[async_trait]
2777 impl AnyHookHandler for SlowHandler {
2778 async fn invoke(
2779 &self,
2780 _payload: &dyn PluginPayload,
2781 _extensions: &Extensions,
2782 _ctx: &mut PluginContext,
2783 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
2784 tokio::time::sleep(std::time::Duration::from_millis(self.delay_ms)).await;
2785 let result: PluginResult<TestPayload> = PluginResult::allow();
2786 Ok(crate::executor::erase_result(result))
2787 }
2788
2789 fn hook_type_name(&self) -> &'static str {
2790 "test_hook"
2791 }
2792 }
2793
2794 #[tokio::test]
2797 async fn test_transform_modifies_payload() {
2798 let mgr = PolicyEngine::default();
2799 let config = make_config("transformer", 10, PluginMode::Transform);
2800 let plugin = Arc::new(TransformPlugin {
2801 cfg: config.clone(),
2802 });
2803
2804 mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2805 mgr.initialize().await.unwrap();
2806
2807 let payload = TestPayload {
2808 value: "original".into(),
2809 };
2810
2811 let (result, _) = mgr
2812 .invoke::<TestHook>(payload, Extensions::default(), None)
2813 .await;
2814
2815 assert!(result.continue_processing);
2816 assert!(
2817 result.payload_modified,
2818 "the transform accepted a new payload, so the result must say so"
2819 );
2820 let final_payload = result.modified_payload.unwrap();
2821 let typed = final_payload
2822 .as_any()
2823 .downcast_ref::<TestPayload>()
2824 .unwrap();
2825 assert_eq!(typed.value, "original_transformed");
2826 }
2827
2828 #[tokio::test]
2833 async fn allow_without_mutation_reports_payload_unmodified() {
2834 let mgr = PolicyEngine::default();
2835 let config = make_config("allow-plugin", 10, PluginMode::Sequential);
2836 let plugin = Arc::new(AllowPlugin {
2837 cfg: config.clone(),
2838 });
2839
2840 mgr.register_handler::<TestHook, _>(plugin, config).unwrap();
2841 mgr.initialize().await.unwrap();
2842
2843 let payload = TestPayload {
2844 value: "original".into(),
2845 };
2846
2847 let (result, _) = mgr
2848 .invoke::<TestHook>(payload, Extensions::default(), None)
2849 .await;
2850
2851 assert!(result.continue_processing);
2852 assert!(result.modified_payload.is_some());
2853 assert!(!result.payload_modified);
2854 }
2855
2856 #[tokio::test]
2861 async fn test_transform_on_error_fail_does_not_halt_pipeline() {
2862 let mgr = PolicyEngine::default();
2863 let config =
2864 make_config_with_on_error("flaky-transform", 10, PluginMode::Transform, OnError::Fail);
2865 let plugin = Arc::new(AllowPlugin {
2866 cfg: config.clone(),
2867 });
2868 let handler: Arc<dyn AnyHookHandler> = Arc::new(ErrorHandler);
2869 mgr.register_raw::<TestHook>(plugin, config, handler)
2870 .unwrap();
2871
2872 mgr.initialize().await.unwrap();
2873
2874 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
2875 let (result, _) = mgr
2876 .invoke_by_name("test_hook", payload, Extensions::default(), None)
2877 .await;
2878
2879 assert!(
2880 result.continue_processing,
2881 "Transform on_error:Fail must not halt the pipeline (phase is non-blocking)",
2882 );
2883 assert!(result.violation.is_none());
2884 }
2885
2886 #[tokio::test]
2890 async fn test_audit_on_error_disable_disables_plugin() {
2891 let mgr = PolicyEngine::default();
2892 let config =
2893 make_config_with_on_error("flaky-audit", 10, PluginMode::Audit, OnError::Disable);
2894 let plugin = Arc::new(AllowPlugin {
2895 cfg: config.clone(),
2896 });
2897 let handler: Arc<dyn AnyHookHandler> = Arc::new(ErrorHandler);
2898 mgr.register_raw::<TestHook>(plugin, config, handler)
2899 .unwrap();
2900
2901 mgr.initialize().await.unwrap();
2902
2903 assert!(!mgr.get_plugin("flaky-audit").unwrap().is_disabled());
2904
2905 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
2908 let (result, _) = mgr
2909 .invoke_by_name("test_hook", payload, Extensions::default(), None)
2910 .await;
2911 assert!(result.continue_processing);
2912
2913 assert!(
2914 mgr.get_plugin("flaky-audit").unwrap().is_disabled(),
2915 "Audit phase must honor on_error:Disable",
2916 );
2917 }
2918
2919 #[tokio::test]
2920 async fn test_concurrent_multiple_plugins_all_run() {
2921 use std::sync::atomic::{AtomicUsize, Ordering};
2922
2923 static CALL_COUNT: AtomicUsize = AtomicUsize::new(0);
2925 CALL_COUNT.store(0, Ordering::SeqCst);
2926
2927 struct CountingHandler;
2928
2929 #[async_trait]
2930 impl AnyHookHandler for CountingHandler {
2931 async fn invoke(
2932 &self,
2933 _payload: &dyn PluginPayload,
2934 _extensions: &Extensions,
2935 _ctx: &mut PluginContext,
2936 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
2937 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2939 CALL_COUNT.fetch_add(1, Ordering::SeqCst);
2940 let result: PluginResult<TestPayload> = PluginResult::allow();
2941 Ok(crate::executor::erase_result(result))
2942 }
2943
2944 fn hook_type_name(&self) -> &'static str {
2945 "test_hook"
2946 }
2947 }
2948
2949 let mgr = PolicyEngine::default();
2950
2951 let c1 = make_config("concurrent-1", 10, PluginMode::Concurrent);
2952 let p1 = Arc::new(AllowPlugin { cfg: c1.clone() });
2953 let h1: Arc<dyn AnyHookHandler> = Arc::new(CountingHandler);
2954 mgr.register_raw::<TestHook>(p1, c1, h1).unwrap();
2955
2956 let c2 = make_config("concurrent-2", 20, PluginMode::Concurrent);
2957 let p2 = Arc::new(AllowPlugin { cfg: c2.clone() });
2958 let h2: Arc<dyn AnyHookHandler> = Arc::new(CountingHandler);
2959 mgr.register_raw::<TestHook>(p2, c2, h2).unwrap();
2960
2961 mgr.initialize().await.unwrap();
2962
2963 let start = std::time::Instant::now();
2964 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
2965 value: "test".into(),
2966 });
2967 let (result, _) = mgr
2968 .invoke_by_name("test_hook", payload, Extensions::default(), None)
2969 .await;
2970 let elapsed = start.elapsed();
2971
2972 assert!(result.continue_processing);
2973 assert_eq!(CALL_COUNT.load(Ordering::SeqCst), 2);
2974 assert!(
2976 elapsed.as_millis() < 90,
2977 "concurrent plugins ran serially: {}ms",
2978 elapsed.as_millis()
2979 );
2980 }
2981
2982 #[tokio::test]
2988 async fn test_concurrent_short_circuit_aborts_slow_plugin() {
2989 use std::sync::atomic::{AtomicUsize, Ordering};
2990 use std::time::Duration;
2991
2992 static SLOW_COMPLETED: AtomicUsize = AtomicUsize::new(0);
2993 SLOW_COMPLETED.store(0, Ordering::SeqCst);
2994
2995 struct DenyImmediately;
2996 #[async_trait]
2997 impl AnyHookHandler for DenyImmediately {
2998 async fn invoke(
2999 &self,
3000 _payload: &dyn PluginPayload,
3001 _extensions: &Extensions,
3002 _ctx: &mut PluginContext,
3003 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3004 let result: PluginResult<TestPayload> =
3005 PluginResult::deny(PluginViolation::new("denied", "fast deny"));
3006 Ok(crate::executor::erase_result(result))
3007 }
3008 fn hook_type_name(&self) -> &'static str {
3009 "test_hook"
3010 }
3011 }
3012
3013 struct SlowSideEffect;
3014 #[async_trait]
3015 impl AnyHookHandler for SlowSideEffect {
3016 async fn invoke(
3017 &self,
3018 _payload: &dyn PluginPayload,
3019 _extensions: &Extensions,
3020 _ctx: &mut PluginContext,
3021 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3022 tokio::time::sleep(Duration::from_secs(2)).await;
3023 SLOW_COMPLETED.fetch_add(1, Ordering::SeqCst);
3026 let result: PluginResult<TestPayload> = PluginResult::allow();
3027 Ok(crate::executor::erase_result(result))
3028 }
3029 fn hook_type_name(&self) -> &'static str {
3030 "test_hook"
3031 }
3032 }
3033
3034 let mgr = PolicyEngine::default();
3035
3036 let cfg_deny = make_config("denier", 10, PluginMode::Concurrent);
3037 let plugin_deny = Arc::new(AllowPlugin {
3038 cfg: cfg_deny.clone(),
3039 });
3040 mgr.register_raw::<TestHook>(
3041 plugin_deny,
3042 cfg_deny,
3043 Arc::new(DenyImmediately) as Arc<dyn AnyHookHandler>,
3044 )
3045 .unwrap();
3046
3047 let cfg_slow = make_config("slow", 20, PluginMode::Concurrent);
3048 let plugin_slow = Arc::new(AllowPlugin {
3049 cfg: cfg_slow.clone(),
3050 });
3051 mgr.register_raw::<TestHook>(
3052 plugin_slow,
3053 cfg_slow,
3054 Arc::new(SlowSideEffect) as Arc<dyn AnyHookHandler>,
3055 )
3056 .unwrap();
3057
3058 mgr.initialize().await.unwrap();
3059
3060 let start = std::time::Instant::now();
3063 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
3064 let (result, _) = mgr
3065 .invoke_by_name("test_hook", payload, Extensions::default(), None)
3066 .await;
3067 let elapsed = start.elapsed();
3068
3069 assert!(!result.continue_processing);
3070 assert!(
3071 elapsed < Duration::from_millis(500),
3072 "pipeline should short-circuit on deny, but took {}ms (slow plugin not aborted)",
3073 elapsed.as_millis(),
3074 );
3075
3076 tokio::time::sleep(Duration::from_millis(2_500)).await;
3079 assert_eq!(
3080 SLOW_COMPLETED.load(Ordering::SeqCst),
3081 0,
3082 "slow plugin's side effect ran after pipeline returned — task was not aborted",
3083 );
3084 }
3085
3086 #[tokio::test]
3089 async fn test_concurrent_no_short_circuit_runs_every_plugin() {
3090 use std::sync::atomic::{AtomicUsize, Ordering};
3091
3092 static ALLOW_RAN: AtomicUsize = AtomicUsize::new(0);
3093 ALLOW_RAN.store(0, Ordering::SeqCst);
3094
3095 struct DenyImmediately;
3096 #[async_trait]
3097 impl AnyHookHandler for DenyImmediately {
3098 async fn invoke(
3099 &self,
3100 _payload: &dyn PluginPayload,
3101 _extensions: &Extensions,
3102 _ctx: &mut PluginContext,
3103 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3104 let result: PluginResult<TestPayload> =
3105 PluginResult::deny(PluginViolation::new("denied", "fast deny"));
3106 Ok(crate::executor::erase_result(result))
3107 }
3108 fn hook_type_name(&self) -> &'static str {
3109 "test_hook"
3110 }
3111 }
3112
3113 struct AllowAndCount;
3114 #[async_trait]
3115 impl AnyHookHandler for AllowAndCount {
3116 async fn invoke(
3117 &self,
3118 _payload: &dyn PluginPayload,
3119 _extensions: &Extensions,
3120 _ctx: &mut PluginContext,
3121 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3122 ALLOW_RAN.fetch_add(1, Ordering::SeqCst);
3123 let result: PluginResult<TestPayload> = PluginResult::allow();
3124 Ok(crate::executor::erase_result(result))
3125 }
3126 fn hook_type_name(&self) -> &'static str {
3127 "test_hook"
3128 }
3129 }
3130
3131 let config = PolicyEngineConfig {
3132 executor: crate::executor::ExecutorConfig {
3133 timeout_seconds: 30,
3134 short_circuit_on_deny: false,
3135 },
3136 route_cache_max_entries: DEFAULT_ROUTE_CACHE_MAX_ENTRIES,
3137 };
3138 let mgr = PolicyEngine::new(config);
3139
3140 let cfg_deny = make_config("denier", 10, PluginMode::Concurrent);
3141 let plugin_deny = Arc::new(AllowPlugin {
3142 cfg: cfg_deny.clone(),
3143 });
3144 mgr.register_raw::<TestHook>(
3145 plugin_deny,
3146 cfg_deny,
3147 Arc::new(DenyImmediately) as Arc<dyn AnyHookHandler>,
3148 )
3149 .unwrap();
3150
3151 let cfg_allow = make_config("allow", 20, PluginMode::Concurrent);
3152 let plugin_allow = Arc::new(AllowPlugin {
3153 cfg: cfg_allow.clone(),
3154 });
3155 mgr.register_raw::<TestHook>(
3156 plugin_allow,
3157 cfg_allow,
3158 Arc::new(AllowAndCount) as Arc<dyn AnyHookHandler>,
3159 )
3160 .unwrap();
3161
3162 mgr.initialize().await.unwrap();
3163
3164 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
3165 let (result, _) = mgr
3166 .invoke_by_name("test_hook", payload, Extensions::default(), None)
3167 .await;
3168
3169 assert!(!result.continue_processing);
3171 assert_eq!(ALLOW_RAN.load(Ordering::SeqCst), 1);
3173 }
3174
3175 struct PanicHandler;
3178
3179 #[async_trait]
3180 impl AnyHookHandler for PanicHandler {
3181 async fn invoke(
3182 &self,
3183 _payload: &dyn PluginPayload,
3184 _extensions: &Extensions,
3185 _ctx: &mut PluginContext,
3186 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3187 panic!("simulated panic in concurrent plugin task");
3188 }
3189 fn hook_type_name(&self) -> &'static str {
3190 "test_hook"
3191 }
3192 }
3193
3194 #[tokio::test]
3201 async fn test_concurrent_panic_with_on_error_fail_halts_pipeline() {
3202 let mgr = PolicyEngine::default();
3203
3204 let cfg =
3205 make_config_with_on_error("panic-plugin", 10, PluginMode::Concurrent, OnError::Fail);
3206 let plugin = Arc::new(AllowPlugin { cfg: cfg.clone() });
3207 let handler: Arc<dyn AnyHookHandler> = Arc::new(PanicHandler);
3208 mgr.register_raw::<TestHook>(plugin, cfg, handler).unwrap();
3209
3210 mgr.initialize().await.unwrap();
3211
3212 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
3213 let (result, _) = mgr
3214 .invoke_by_name("test_hook", payload, Extensions::default(), None)
3215 .await;
3216
3217 assert!(
3218 !result.continue_processing,
3219 "Fail must halt the pipeline on panic"
3220 );
3221 let v = result.violation.as_ref().expect("expected violation");
3222 assert_eq!(v.code, "plugin_panic");
3223 assert_eq!(v.plugin_name.as_deref(), Some("panic-plugin"));
3224 }
3225
3226 #[tokio::test]
3230 async fn test_concurrent_panic_with_on_error_disable_trips_circuit_breaker() {
3231 use std::sync::atomic::{AtomicUsize, Ordering};
3232
3233 static SURVIVOR_CALLS: AtomicUsize = AtomicUsize::new(0);
3234 SURVIVOR_CALLS.store(0, Ordering::SeqCst);
3235
3236 struct SurvivorHandler;
3237 #[async_trait]
3238 impl AnyHookHandler for SurvivorHandler {
3239 async fn invoke(
3240 &self,
3241 _payload: &dyn PluginPayload,
3242 _extensions: &Extensions,
3243 _ctx: &mut PluginContext,
3244 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3245 SURVIVOR_CALLS.fetch_add(1, Ordering::SeqCst);
3246 let result: PluginResult<TestPayload> = PluginResult::allow();
3247 Ok(crate::executor::erase_result(result))
3248 }
3249 fn hook_type_name(&self) -> &'static str {
3250 "test_hook"
3251 }
3252 }
3253
3254 let mgr = PolicyEngine::default();
3255
3256 let panic_cfg =
3257 make_config_with_on_error("panic-plugin", 10, PluginMode::Concurrent, OnError::Disable);
3258 let panic_plugin = Arc::new(AllowPlugin {
3259 cfg: panic_cfg.clone(),
3260 });
3261 let panic_handler: Arc<dyn AnyHookHandler> = Arc::new(PanicHandler);
3262 mgr.register_raw::<TestHook>(panic_plugin, panic_cfg, panic_handler)
3263 .unwrap();
3264
3265 let survivor_cfg = make_config("survivor", 20, PluginMode::Concurrent);
3266 let survivor_plugin = Arc::new(AllowPlugin {
3267 cfg: survivor_cfg.clone(),
3268 });
3269 let survivor_handler: Arc<dyn AnyHookHandler> = Arc::new(SurvivorHandler);
3270 mgr.register_raw::<TestHook>(survivor_plugin, survivor_cfg, survivor_handler)
3271 .unwrap();
3272
3273 mgr.initialize().await.unwrap();
3274
3275 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "1".into() });
3277 let (result1, _) = mgr
3278 .invoke_by_name("test_hook", payload, Extensions::default(), None)
3279 .await;
3280 assert!(
3281 result1.continue_processing,
3282 "Disable must not halt the pipeline"
3283 );
3284 assert_eq!(SURVIVOR_CALLS.load(Ordering::SeqCst), 1);
3285 assert!(
3286 mgr.get_plugin("panic-plugin").unwrap().is_disabled(),
3287 "panic plugin must be disabled after the panic",
3288 );
3289
3290 let payload2: Box<dyn PluginPayload> = Box::new(TestPayload { value: "2".into() });
3292 let (result2, _) = mgr
3293 .invoke_by_name("test_hook", payload2, Extensions::default(), None)
3294 .await;
3295 assert!(result2.continue_processing);
3296 assert_eq!(SURVIVOR_CALLS.load(Ordering::SeqCst), 2);
3298 }
3299
3300 #[tokio::test]
3301 async fn test_timeout_fires_on_slow_handler() {
3302 let config = PolicyEngineConfig {
3303 executor: crate::executor::ExecutorConfig {
3304 timeout_seconds: 1,
3305 short_circuit_on_deny: true,
3306 },
3307 route_cache_max_entries: DEFAULT_ROUTE_CACHE_MAX_ENTRIES,
3308 };
3309 let mgr = PolicyEngine::new(config);
3310
3311 let plugin_config = make_config("slow-plugin", 10, PluginMode::Sequential);
3313 let plugin = Arc::new(AllowPlugin {
3314 cfg: plugin_config.clone(),
3315 });
3316 let handler: Arc<dyn AnyHookHandler> = Arc::new(SlowHandler { delay_ms: 5000 });
3317 mgr.register_raw::<TestHook>(plugin, plugin_config, handler)
3318 .unwrap();
3319
3320 mgr.initialize().await.unwrap();
3321
3322 let start = std::time::Instant::now();
3323 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
3324 value: "test".into(),
3325 });
3326 let (result, _) = mgr
3327 .invoke_by_name("test_hook", payload, Extensions::default(), None)
3328 .await;
3329 let elapsed = start.elapsed();
3330
3331 assert!(!result.continue_processing);
3333 assert_eq!(result.violation.as_ref().unwrap().code, "plugin_timeout");
3334 assert!(
3336 elapsed.as_secs() < 3,
3337 "timeout didn't fire: {}s",
3338 elapsed.as_secs()
3339 );
3340 }
3341
3342 #[tokio::test]
3343 async fn test_fire_and_forget_returns_before_task_completes() {
3344 use std::sync::atomic::{AtomicBool, Ordering};
3345
3346 static TASK_COMPLETED: AtomicBool = AtomicBool::new(false);
3347 TASK_COMPLETED.store(false, Ordering::SeqCst);
3348
3349 struct SlowFireAndForgetHandler;
3350
3351 #[async_trait]
3352 impl AnyHookHandler for SlowFireAndForgetHandler {
3353 async fn invoke(
3354 &self,
3355 _payload: &dyn PluginPayload,
3356 _extensions: &Extensions,
3357 _ctx: &mut PluginContext,
3358 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3359 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
3360 TASK_COMPLETED.store(true, Ordering::SeqCst);
3361 let result: PluginResult<TestPayload> = PluginResult::allow();
3362 Ok(crate::executor::erase_result(result))
3363 }
3364
3365 fn hook_type_name(&self) -> &'static str {
3366 "test_hook"
3367 }
3368 }
3369
3370 let mgr = PolicyEngine::default();
3371
3372 let config = make_config("fire-forget", 10, PluginMode::FireAndForget);
3373 let plugin = Arc::new(AllowPlugin {
3374 cfg: config.clone(),
3375 });
3376 let handler: Arc<dyn AnyHookHandler> = Arc::new(SlowFireAndForgetHandler);
3377 mgr.register_raw::<TestHook>(plugin, config, handler)
3378 .unwrap();
3379
3380 mgr.initialize().await.unwrap();
3381
3382 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
3383 value: "test".into(),
3384 });
3385 let (result, bg) = mgr
3386 .invoke_by_name("test_hook", payload, Extensions::default(), None)
3387 .await;
3388
3389 assert!(result.continue_processing);
3391 assert!(
3392 !TASK_COMPLETED.load(Ordering::SeqCst),
3393 "fire-and-forget task completed before pipeline returned"
3394 );
3395
3396 let errors = bg.wait_for_background_tasks().await;
3398 assert!(errors.is_empty(), "background task had errors: {errors:?}");
3399 assert!(
3400 TASK_COMPLETED.load(Ordering::SeqCst),
3401 "fire-and-forget task never completed"
3402 );
3403 }
3404
3405 #[tokio::test]
3412 async fn test_shutdown_drains_in_flight_fire_and_forget_tasks() {
3413 use std::sync::atomic::{AtomicBool, Ordering};
3414
3415 static FAF_COMPLETED: AtomicBool = AtomicBool::new(false);
3416 FAF_COMPLETED.store(false, Ordering::SeqCst);
3417
3418 struct SlowFafHandler;
3419 #[async_trait]
3420 impl AnyHookHandler for SlowFafHandler {
3421 async fn invoke(
3422 &self,
3423 _payload: &dyn PluginPayload,
3424 _extensions: &Extensions,
3425 _ctx: &mut PluginContext,
3426 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3427 tokio::time::sleep(std::time::Duration::from_millis(150)).await;
3428 FAF_COMPLETED.store(true, Ordering::SeqCst);
3429 let result: PluginResult<TestPayload> = PluginResult::allow();
3430 Ok(crate::executor::erase_result(result))
3431 }
3432 fn hook_type_name(&self) -> &'static str {
3433 "test_hook"
3434 }
3435 }
3436
3437 let mgr = PolicyEngine::default();
3438 let config = make_config("slow-faf", 10, PluginMode::FireAndForget);
3439 let plugin = Arc::new(AllowPlugin {
3440 cfg: config.clone(),
3441 });
3442 let handler: Arc<dyn AnyHookHandler> = Arc::new(SlowFafHandler);
3443 mgr.register_raw::<TestHook>(plugin, config, handler)
3444 .unwrap();
3445 mgr.initialize().await.unwrap();
3446
3447 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
3450 let (_result, _bg_dropped) = mgr
3451 .invoke_by_name("test_hook", payload, Extensions::default(), None)
3452 .await;
3453
3454 assert!(!FAF_COMPLETED.load(Ordering::SeqCst));
3456
3457 mgr.shutdown().await;
3459
3460 assert!(
3462 FAF_COMPLETED.load(Ordering::SeqCst),
3463 "shutdown returned before fire-and-forget task finished — task was abandoned",
3464 );
3465 }
3466
3467 #[tokio::test]
3468 async fn test_global_state_flows_between_serial_plugins() {
3469 struct WriterHandler;
3472
3473 #[async_trait]
3474 impl AnyHookHandler for WriterHandler {
3475 async fn invoke(
3476 &self,
3477 _payload: &dyn PluginPayload,
3478 _extensions: &Extensions,
3479 ctx: &mut PluginContext,
3480 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3481 ctx.set_global("writer_was_here", serde_json::Value::Bool(true));
3482 let result: PluginResult<TestPayload> = PluginResult::allow();
3483 Ok(crate::executor::erase_result(result))
3484 }
3485 fn hook_type_name(&self) -> &'static str {
3486 "test_hook"
3487 }
3488 }
3489
3490 struct ReaderHandler {
3491 saw_writer: std::sync::Arc<std::sync::atomic::AtomicBool>,
3492 }
3493
3494 #[async_trait]
3495 impl AnyHookHandler for ReaderHandler {
3496 async fn invoke(
3497 &self,
3498 _payload: &dyn PluginPayload,
3499 _extensions: &Extensions,
3500 ctx: &mut PluginContext,
3501 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3502 if ctx.get_global("writer_was_here").is_some() {
3503 self.saw_writer
3504 .store(true, std::sync::atomic::Ordering::SeqCst);
3505 }
3506 let result: PluginResult<TestPayload> = PluginResult::allow();
3507 Ok(crate::executor::erase_result(result))
3508 }
3509 fn hook_type_name(&self) -> &'static str {
3510 "test_hook"
3511 }
3512 }
3513
3514 let saw_writer = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
3515
3516 let mgr = PolicyEngine::default();
3517
3518 let c1 = make_config("writer", 10, PluginMode::Sequential);
3520 let p1 = Arc::new(AllowPlugin { cfg: c1.clone() });
3521 let h1: Arc<dyn AnyHookHandler> = Arc::new(WriterHandler);
3522 mgr.register_raw::<TestHook>(p1, c1, h1).unwrap();
3523
3524 let c2 = make_config("reader", 20, PluginMode::Sequential);
3526 let p2 = Arc::new(AllowPlugin { cfg: c2.clone() });
3527 let h2: Arc<dyn AnyHookHandler> = Arc::new(ReaderHandler {
3528 saw_writer: saw_writer.clone(),
3529 });
3530 mgr.register_raw::<TestHook>(p2, c2, h2).unwrap();
3531
3532 mgr.initialize().await.unwrap();
3533
3534 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
3535 value: "test".into(),
3536 });
3537 let (result, _) = mgr
3538 .invoke_by_name("test_hook", payload, Extensions::default(), None)
3539 .await;
3540
3541 assert!(result.continue_processing);
3542 assert!(
3543 saw_writer.load(std::sync::atomic::Ordering::SeqCst),
3544 "reader plugin did not see writer's global_state change"
3545 );
3546 }
3547
3548 #[tokio::test]
3549 async fn test_local_state_persists_across_hook_invocations() {
3550 struct LocalWriterHandler;
3554
3555 #[async_trait]
3556 impl AnyHookHandler for LocalWriterHandler {
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 count = ctx
3564 .get_local("call_count")
3565 .and_then(serde_json::Value::as_u64)
3566 .unwrap_or(0);
3567 ctx.set_local("call_count", serde_json::Value::from(count + 1));
3568 let result: PluginResult<TestPayload> = PluginResult::allow();
3569 Ok(crate::executor::erase_result(result))
3570 }
3571 fn hook_type_name(&self) -> &'static str {
3572 "test_hook"
3573 }
3574 }
3575
3576 let mgr = PolicyEngine::default();
3577
3578 let config = make_config("counter", 10, PluginMode::Sequential);
3579 let plugin = Arc::new(AllowPlugin {
3580 cfg: config.clone(),
3581 });
3582 let handler: Arc<dyn AnyHookHandler> = Arc::new(LocalWriterHandler);
3583 mgr.register_raw::<TestHook>(plugin, config, handler)
3584 .unwrap();
3585
3586 mgr.initialize().await.unwrap();
3587
3588 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
3590 value: "first".into(),
3591 });
3592 let (result1, _) = mgr
3593 .invoke_by_name("test_hook", payload, Extensions::default(), None)
3594 .await;
3595 assert!(result1.continue_processing);
3596
3597 let table = &result1.context_table;
3599 let local = table
3600 .local_states
3601 .values()
3602 .next()
3603 .expect("context table should have one local_state entry");
3604 assert_eq!(local.get("call_count").unwrap().as_u64().unwrap(), 1);
3605
3606 let payload2: Box<dyn PluginPayload> = Box::new(TestPayload {
3608 value: "second".into(),
3609 });
3610 let (result2, _) = mgr
3611 .invoke_by_name(
3612 "test_hook",
3613 payload2,
3614 Extensions::default(),
3615 Some(result1.context_table),
3616 )
3617 .await;
3618 assert!(result2.continue_processing);
3619
3620 let table2 = &result2.context_table;
3622 let local2 = table2
3623 .local_states
3624 .values()
3625 .next()
3626 .expect("context table should have one local_state entry");
3627 assert_eq!(local2.get("call_count").unwrap().as_u64().unwrap(), 2);
3628 }
3629
3630 #[tokio::test]
3636 async fn test_global_state_propagates_in_priority_order() {
3637 struct GlobalChainHandler {
3641 tag: &'static str,
3642 }
3643
3644 #[async_trait]
3645 impl AnyHookHandler for GlobalChainHandler {
3646 async fn invoke(
3647 &self,
3648 _payload: &dyn PluginPayload,
3649 _extensions: &Extensions,
3650 ctx: &mut PluginContext,
3651 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3652 let mut chain = ctx
3653 .get_global("chain")
3654 .and_then(|v| v.as_array())
3655 .cloned()
3656 .unwrap_or_default();
3657 chain.push(serde_json::Value::String(self.tag.into()));
3658 ctx.set_global("chain", serde_json::Value::Array(chain));
3659 let result: PluginResult<TestPayload> = PluginResult::allow();
3660 Ok(crate::executor::erase_result(result))
3661 }
3662 fn hook_type_name(&self) -> &'static str {
3663 "test_hook"
3664 }
3665 }
3666
3667 let mgr = PolicyEngine::default();
3668
3669 let cfg_a = make_config("plugin_a", 10, PluginMode::Sequential);
3671 let plugin_a = Arc::new(AllowPlugin { cfg: cfg_a.clone() });
3672 let handler_a: Arc<dyn AnyHookHandler> = Arc::new(GlobalChainHandler { tag: "a" });
3673 mgr.register_raw::<TestHook>(plugin_a, cfg_a, handler_a)
3674 .unwrap();
3675
3676 let cfg_b = make_config("plugin_b", 20, PluginMode::Sequential);
3678 let plugin_b = Arc::new(AllowPlugin { cfg: cfg_b.clone() });
3679 let handler_b: Arc<dyn AnyHookHandler> = Arc::new(GlobalChainHandler { tag: "b" });
3680 mgr.register_raw::<TestHook>(plugin_b, cfg_b, handler_b)
3681 .unwrap();
3682
3683 mgr.initialize().await.unwrap();
3684
3685 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
3686 let (result, _) = mgr
3687 .invoke_by_name("test_hook", payload, Extensions::default(), None)
3688 .await;
3689 assert!(result.continue_processing);
3690
3691 let chain = result
3696 .context_table
3697 .global_state
3698 .get("chain")
3699 .and_then(|v| v.as_array())
3700 .expect("global_state.chain should be an array");
3701 let tags: Vec<&str> = chain.iter().filter_map(|v| v.as_str()).collect();
3702 assert_eq!(tags, vec!["a", "b"]);
3703 }
3704
3705 #[tokio::test]
3710 async fn test_all_five_phases_run_in_order_with_payload_chaining() {
3711 use std::sync::Arc as StdArc;
3712 use std::sync::Mutex as StdMutex;
3713
3714 let log: StdArc<StdMutex<Vec<&'static str>>> = StdArc::new(StdMutex::new(Vec::new()));
3715
3716 struct SeqHandler {
3718 log: StdArc<StdMutex<Vec<&'static str>>>,
3719 }
3720 #[async_trait]
3721 impl AnyHookHandler for SeqHandler {
3722 async fn invoke(
3723 &self,
3724 payload: &dyn PluginPayload,
3725 _extensions: &Extensions,
3726 _ctx: &mut PluginContext,
3727 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3728 self.log.lock().unwrap().push("seq");
3729 let typed = payload.as_any().downcast_ref::<TestPayload>().unwrap();
3730 let modified = TestPayload {
3731 value: format!("{}|seq", typed.value),
3732 };
3733 let result: PluginResult<TestPayload> = PluginResult::modify_payload(modified);
3734 Ok(crate::executor::erase_result(result))
3735 }
3736 fn hook_type_name(&self) -> &'static str {
3737 "test_hook"
3738 }
3739 }
3740
3741 struct TransformLogger {
3743 log: StdArc<StdMutex<Vec<&'static str>>>,
3744 }
3745 #[async_trait]
3746 impl AnyHookHandler for TransformLogger {
3747 async fn invoke(
3748 &self,
3749 payload: &dyn PluginPayload,
3750 _extensions: &Extensions,
3751 _ctx: &mut PluginContext,
3752 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3753 self.log.lock().unwrap().push("transform");
3754 let typed = payload.as_any().downcast_ref::<TestPayload>().unwrap();
3755 let modified = TestPayload {
3756 value: format!("{}|transform", typed.value),
3757 };
3758 let result: PluginResult<TestPayload> = PluginResult::modify_payload(modified);
3759 Ok(crate::executor::erase_result(result))
3760 }
3761 fn hook_type_name(&self) -> &'static str {
3762 "test_hook"
3763 }
3764 }
3765
3766 struct ObserverHandler {
3769 tag: &'static str,
3770 log: StdArc<StdMutex<Vec<&'static str>>>,
3771 expected_payload: &'static str,
3772 }
3773 #[async_trait]
3774 impl AnyHookHandler for ObserverHandler {
3775 async fn invoke(
3776 &self,
3777 payload: &dyn PluginPayload,
3778 _extensions: &Extensions,
3779 _ctx: &mut PluginContext,
3780 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3781 let typed = payload.as_any().downcast_ref::<TestPayload>().unwrap();
3782 assert_eq!(
3783 typed.value, self.expected_payload,
3784 "{} observed unexpected payload: got '{}', expected '{}'",
3785 self.tag, typed.value, self.expected_payload,
3786 );
3787 self.log.lock().unwrap().push(self.tag);
3788 let result: PluginResult<TestPayload> = PluginResult::allow();
3789 Ok(crate::executor::erase_result(result))
3790 }
3791 fn hook_type_name(&self) -> &'static str {
3792 "test_hook"
3793 }
3794 }
3795
3796 let mgr = PolicyEngine::default();
3797
3798 let cfg_seq = make_config("seq", 10, PluginMode::Sequential);
3799 mgr.register_raw::<TestHook>(
3800 Arc::new(AllowPlugin {
3801 cfg: cfg_seq.clone(),
3802 }),
3803 cfg_seq,
3804 Arc::new(SeqHandler {
3805 log: StdArc::clone(&log),
3806 }),
3807 )
3808 .unwrap();
3809
3810 let cfg_transform = make_config("transform", 10, PluginMode::Transform);
3811 mgr.register_raw::<TestHook>(
3812 Arc::new(AllowPlugin {
3813 cfg: cfg_transform.clone(),
3814 }),
3815 cfg_transform,
3816 Arc::new(TransformLogger {
3817 log: StdArc::clone(&log),
3818 }),
3819 )
3820 .unwrap();
3821
3822 let cfg_audit = make_config("audit", 10, PluginMode::Audit);
3823 mgr.register_raw::<TestHook>(
3824 Arc::new(AllowPlugin {
3825 cfg: cfg_audit.clone(),
3826 }),
3827 cfg_audit,
3828 Arc::new(ObserverHandler {
3829 tag: "audit",
3830 log: StdArc::clone(&log),
3831 expected_payload: "start|seq|transform",
3832 }),
3833 )
3834 .unwrap();
3835
3836 let cfg_concurrent = make_config("concurrent", 10, PluginMode::Concurrent);
3837 mgr.register_raw::<TestHook>(
3838 Arc::new(AllowPlugin {
3839 cfg: cfg_concurrent.clone(),
3840 }),
3841 cfg_concurrent,
3842 Arc::new(ObserverHandler {
3843 tag: "concurrent",
3844 log: StdArc::clone(&log),
3845 expected_payload: "start|seq|transform",
3846 }),
3847 )
3848 .unwrap();
3849
3850 let cfg_faf = make_config("faf", 10, PluginMode::FireAndForget);
3851 mgr.register_raw::<TestHook>(
3852 Arc::new(AllowPlugin {
3853 cfg: cfg_faf.clone(),
3854 }),
3855 cfg_faf,
3856 Arc::new(ObserverHandler {
3857 tag: "faf",
3858 log: StdArc::clone(&log),
3859 expected_payload: "start|seq|transform",
3860 }),
3861 )
3862 .unwrap();
3863
3864 mgr.initialize().await.unwrap();
3865
3866 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
3867 value: "start".into(),
3868 });
3869 let (result, bg) = mgr
3870 .invoke_by_name("test_hook", payload, Extensions::default(), None)
3871 .await;
3872
3873 assert!(result.continue_processing);
3874 let final_payload = result.modified_payload.unwrap();
3876 let typed = final_payload
3877 .as_any()
3878 .downcast_ref::<TestPayload>()
3879 .unwrap();
3880 assert_eq!(typed.value, "start|seq|transform");
3881
3882 let _ = bg.wait_for_background_tasks().await;
3885
3886 let log = log.lock().unwrap();
3887 assert_eq!(log[0], "seq", "first should be sequential phase");
3889 assert_eq!(log[1], "transform", "second should be transform phase");
3890 assert_eq!(log[2], "audit", "third should be audit phase");
3891 let post_audit: std::collections::HashSet<&&'static str> = log[3..].iter().collect();
3896 assert!(
3897 post_audit.contains(&"concurrent"),
3898 "concurrent phase must run"
3899 );
3900 assert!(post_audit.contains(&"faf"), "fire-and-forget must run");
3901 assert_eq!(log.len(), 5, "all five phases should have logged");
3902 }
3903
3904 #[tokio::test]
3908 async fn test_routing_works_for_all_entity_types() {
3909 use std::sync::Arc as StdArc;
3910 use std::sync::atomic::{AtomicUsize, Ordering};
3911
3912 struct CountHandler {
3915 counter: StdArc<AtomicUsize>,
3916 }
3917 #[async_trait]
3918 impl AnyHookHandler for CountHandler {
3919 async fn invoke(
3920 &self,
3921 _payload: &dyn PluginPayload,
3922 _extensions: &Extensions,
3923 _ctx: &mut PluginContext,
3924 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
3925 self.counter.fetch_add(1, Ordering::SeqCst);
3926 let result: PluginResult<TestPayload> = PluginResult::allow();
3927 Ok(crate::executor::erase_result(result))
3928 }
3929 fn hook_type_name(&self) -> &'static str {
3930 "test_hook"
3931 }
3932 }
3933
3934 for (entity_type, route_field, route_value, request_name, should_match) in [
3937 ("resource", "resource", "my_resource", "my_resource", true),
3938 (
3939 "resource",
3940 "resource",
3941 "my_resource",
3942 "other_resource",
3943 false,
3944 ),
3945 ("prompt", "prompt", "my_prompt", "my_prompt", true),
3946 ("prompt", "prompt", "my_prompt", "other_prompt", false),
3947 ("llm", "llm", "gpt-4", "gpt-4", true),
3948 ("llm", "llm", "gpt-4", "claude", false),
3949 ] {
3950 let yaml = format!(
3951 r#"
3952plugin_settings:
3953 routing_enabled: true
3954plugins:
3955 - name: target
3956 kind: test/allow
3957 hooks: [test_hook]
3958 mode: sequential
3959routes:
3960 - {route_field}: {route_value}
3961 plugins:
3962 - target
3963"#
3964 );
3965 let policy_config = crate::config::parse_config(&yaml).unwrap();
3966
3967 let mgr = PolicyEngine::default();
3968 let counter = StdArc::new(AtomicUsize::new(0));
3969 struct ParamFactory(StdArc<AtomicUsize>);
3971 impl crate::factory::PluginFactory for ParamFactory {
3972 fn create(
3973 &self,
3974 config: &PluginConfig,
3975 ) -> Result<crate::factory::PluginInstance, Box<PluginError>> {
3976 Ok(crate::factory::PluginInstance {
3977 plugin: Arc::new(AllowPlugin {
3978 cfg: config.clone(),
3979 }),
3980 handlers: vec![(
3981 "test_hook",
3982 Arc::new(CountHandler {
3983 counter: StdArc::clone(&self.0),
3984 }),
3985 )],
3986 })
3987 }
3988 }
3989 mgr.register_factory(
3990 "test/allow",
3991 Box::new(ParamFactory(StdArc::clone(&counter))),
3992 );
3993 mgr.load_config(policy_config).unwrap();
3994 mgr.initialize().await.unwrap();
3995
3996 let p: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
3997 let ext = Extensions {
3998 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
3999 entity_type: Some(entity_type.into()),
4000 entity_name: Some(request_name.into()),
4001 ..Default::default()
4002 })),
4003 ..Default::default()
4004 };
4005 let _ = mgr.invoke_by_name("test_hook", p, ext, None).await;
4006
4007 let expected = if should_match { 1 } else { 0 };
4008 assert_eq!(
4009 counter.load(Ordering::SeqCst),
4010 expected,
4011 "entity_type={entity_type} route_field={route_field} route_value={route_value} request_name={request_name} expected fire={should_match}",
4012 );
4013 }
4014 }
4015
4016 #[tokio::test]
4021 async fn test_initialize_rollback_on_failure() {
4022 use std::sync::Arc as StdArc;
4023 use std::sync::atomic::{AtomicUsize, Ordering};
4024
4025 let init_count_a = StdArc::new(AtomicUsize::new(0));
4027 let shutdown_count_a = StdArc::new(AtomicUsize::new(0));
4028 let init_count_b = StdArc::new(AtomicUsize::new(0));
4029 let shutdown_count_b = StdArc::new(AtomicUsize::new(0));
4030 let init_count_c = StdArc::new(AtomicUsize::new(0));
4031 let shutdown_count_c = StdArc::new(AtomicUsize::new(0));
4032
4033 struct LifecyclePlugin {
4034 cfg: PluginConfig,
4035 init_counter: StdArc<AtomicUsize>,
4036 shutdown_counter: StdArc<AtomicUsize>,
4037 fail_init: bool,
4038 }
4039 #[async_trait]
4040 impl Plugin for LifecyclePlugin {
4041 fn config(&self) -> &PluginConfig {
4042 &self.cfg
4043 }
4044 async fn initialize(&self) -> Result<(), Box<PluginError>> {
4045 self.init_counter.fetch_add(1, Ordering::SeqCst);
4046 if self.fail_init {
4047 Err(Box::new(PluginError::Config {
4048 message: "intentional init failure".into(),
4049 }))
4050 } else {
4051 Ok(())
4052 }
4053 }
4054 async fn shutdown(&self) -> Result<(), Box<PluginError>> {
4055 self.shutdown_counter.fetch_add(1, Ordering::SeqCst);
4056 Ok(())
4057 }
4058 }
4059 impl HookHandler<TestHook> for LifecyclePlugin {
4060 async fn handle(
4061 &self,
4062 _payload: &TestPayload,
4063 _extensions: &Extensions,
4064 _ctx: &mut PluginContext,
4065 ) -> PluginResult<TestPayload> {
4066 PluginResult::allow()
4067 }
4068 }
4069
4070 let mgr = PolicyEngine::default();
4071
4072 let cfg_a = make_config("a", 10, PluginMode::Sequential);
4074 let plugin_a = Arc::new(LifecyclePlugin {
4075 cfg: cfg_a.clone(),
4076 init_counter: StdArc::clone(&init_count_a),
4077 shutdown_counter: StdArc::clone(&shutdown_count_a),
4078 fail_init: false,
4079 });
4080 mgr.register_handler::<TestHook, _>(plugin_a, cfg_a)
4081 .unwrap();
4082
4083 let cfg_b = make_config("b", 20, PluginMode::Sequential);
4085 let plugin_b = Arc::new(LifecyclePlugin {
4086 cfg: cfg_b.clone(),
4087 init_counter: StdArc::clone(&init_count_b),
4088 shutdown_counter: StdArc::clone(&shutdown_count_b),
4089 fail_init: true,
4090 });
4091 mgr.register_handler::<TestHook, _>(plugin_b, cfg_b)
4092 .unwrap();
4093
4094 let cfg_c = make_config("c", 30, PluginMode::Sequential);
4096 let plugin_c = Arc::new(LifecyclePlugin {
4097 cfg: cfg_c.clone(),
4098 init_counter: StdArc::clone(&init_count_c),
4099 shutdown_counter: StdArc::clone(&shutdown_count_c),
4100 fail_init: false,
4101 });
4102 mgr.register_handler::<TestHook, _>(plugin_c, cfg_c)
4103 .unwrap();
4104
4105 let result = mgr.initialize().await;
4106 assert!(
4107 result.is_err(),
4108 "initialize() must propagate the init failure"
4109 );
4110
4111 let assert_pair_invariant = |init: &AtomicUsize, shutdown: &AtomicUsize, tag: &str| {
4122 let i = init.load(Ordering::SeqCst);
4123 let s = shutdown.load(Ordering::SeqCst);
4124 assert!(
4125 (i == 0 && s == 0) || (i == 1 && s == 1),
4126 "{tag}: init/shutdown should be paired (both 0 or both 1), got init={i} shutdown={s}",
4127 );
4128 };
4129 assert_pair_invariant(&init_count_a, &shutdown_count_a, "A");
4130 assert_pair_invariant(&init_count_c, &shutdown_count_c, "C");
4131
4132 assert_eq!(
4134 init_count_b.load(Ordering::SeqCst),
4135 1,
4136 "B's initialize was called",
4137 );
4138 assert_eq!(
4139 shutdown_count_b.load(Ordering::SeqCst),
4140 0,
4141 "B failed to initialize; shutdown should not run for it",
4142 );
4143
4144 assert!(!mgr.is_initialized());
4146 }
4147
4148 struct AllowPluginFactory;
4152
4153 impl crate::factory::PluginFactory for AllowPluginFactory {
4154 fn create(
4155 &self,
4156 config: &PluginConfig,
4157 ) -> Result<crate::factory::PluginInstance, Box<PluginError>> {
4158 let plugin = Arc::new(AllowPlugin {
4159 cfg: config.clone(),
4160 });
4161 let handler: Arc<dyn AnyHookHandler> =
4162 Arc::new(TypedHandlerAdapter::<TestHook, AllowPlugin>::new(
4163 Arc::clone(&plugin),
4164 ));
4165 Ok(crate::factory::PluginInstance {
4166 plugin,
4167 handlers: vec![("test_hook", handler)],
4168 })
4169 }
4170 }
4171
4172 struct DenyPluginFactory;
4174
4175 impl crate::factory::PluginFactory for DenyPluginFactory {
4176 fn create(
4177 &self,
4178 config: &PluginConfig,
4179 ) -> Result<crate::factory::PluginInstance, Box<PluginError>> {
4180 let plugin = Arc::new(DenyPlugin {
4181 cfg: config.clone(),
4182 });
4183 let handler: Arc<dyn AnyHookHandler> =
4184 Arc::new(TypedHandlerAdapter::<TestHook, DenyPlugin>::new(
4185 Arc::clone(&plugin),
4186 ));
4187 Ok(crate::factory::PluginInstance {
4188 plugin,
4189 handlers: vec![("test_hook", handler)],
4190 })
4191 }
4192 }
4193
4194 #[tokio::test]
4195 async fn test_from_config_creates_manager() {
4196 let yaml = r#"
4197plugins:
4198 - name: allow_plugin
4199 kind: test/allow
4200 hooks: [test_hook]
4201 mode: sequential
4202 priority: 10
4203
4204plugin_settings:
4205 plugin_timeout: 60
4206"#;
4207 let policy_config = crate::config::parse_config(yaml).unwrap();
4208
4209 let mut factories = PluginFactoryRegistry::new();
4210 factories.register("test/allow", Box::new(AllowPluginFactory));
4211
4212 let mgr = PolicyEngine::from_config(policy_config, &factories).unwrap();
4213 mgr.initialize().await.unwrap();
4214
4215 assert_eq!(mgr.plugin_count(), 1);
4216 assert!(mgr.has_hooks_for("test_hook"));
4217 }
4218
4219 #[tokio::test]
4220 async fn test_from_config_invokes_correctly() {
4221 let yaml = r#"
4222plugins:
4223 - name: denier
4224 kind: test/deny
4225 hooks: [test_hook]
4226 mode: sequential
4227 priority: 10
4228"#;
4229 let policy_config = crate::config::parse_config(yaml).unwrap();
4230
4231 let mut factories = PluginFactoryRegistry::new();
4232 factories.register("test/deny", Box::new(DenyPluginFactory));
4233
4234 let mgr = PolicyEngine::from_config(policy_config, &factories).unwrap();
4235 mgr.initialize().await.unwrap();
4236
4237 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
4238 value: "test".into(),
4239 });
4240 let (result, _) = mgr
4243 .invoke_by_name("test_hook", payload, Extensions::default(), None)
4244 .await;
4245
4246 assert!(!result.continue_processing);
4247 assert_eq!(result.violation.as_ref().unwrap().code, "denied");
4248 }
4249
4250 #[tokio::test]
4251 async fn test_from_config_unknown_kind_rejected() {
4252 let yaml = r#"
4253plugins:
4254 - name: mystery
4255 kind: unknown/type
4256 hooks: [test_hook]
4257"#;
4258 let policy_config = crate::config::parse_config(yaml).unwrap();
4259 let factories = PluginFactoryRegistry::new(); let result = PolicyEngine::from_config(policy_config, &factories);
4262 match result {
4263 Err(e) => assert!(e.to_string().contains("no factory registered"), "got: {e}"),
4264 Ok(_) => panic!("expected error for unknown kind"),
4265 }
4266 }
4267
4268 #[tokio::test]
4269 async fn test_from_config_multiple_plugins() {
4270 let yaml = r#"
4271plugins:
4272 - name: gate
4273 kind: test/deny
4274 hooks: [test_hook]
4275 mode: sequential
4276 priority: 5
4277 - name: fallback
4278 kind: test/allow
4279 hooks: [test_hook]
4280 mode: sequential
4281 priority: 10
4282"#;
4283 let policy_config = crate::config::parse_config(yaml).unwrap();
4284
4285 let mut factories = PluginFactoryRegistry::new();
4286 factories.register("test/allow", Box::new(AllowPluginFactory));
4287 factories.register("test/deny", Box::new(DenyPluginFactory));
4288
4289 let mgr = PolicyEngine::from_config(policy_config, &factories).unwrap();
4290 mgr.initialize().await.unwrap();
4291
4292 assert_eq!(mgr.plugin_count(), 2);
4293
4294 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
4296 value: "test".into(),
4297 });
4298 let (result, _) = mgr
4301 .invoke_by_name("test_hook", payload, Extensions::default(), None)
4302 .await;
4303
4304 assert!(!result.continue_processing); }
4306
4307 #[tokio::test]
4310 async fn test_routing_cache_populated_on_first_invoke() {
4311 let yaml = r#"
4312plugin_settings:
4313 routing_enabled: true
4314global:
4315 policies:
4316 all:
4317 plugins: [allow_plugin]
4318plugins:
4319 - name: allow_plugin
4320 kind: test/allow
4321 hooks: [test_hook]
4322 mode: sequential
4323 priority: 10
4324routes:
4325 - tool: get_compensation
4326"#;
4327 let policy_config = crate::config::parse_config(yaml).unwrap();
4328 let mut factories = PluginFactoryRegistry::new();
4329 factories.register("test/allow", Box::new(AllowPluginFactory));
4330
4331 let mgr = PolicyEngine::from_config(policy_config, &factories).unwrap();
4332 mgr.initialize().await.unwrap();
4333
4334 assert_eq!(mgr.routing_cache_size(), 0);
4335
4336 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
4338 value: "test".into(),
4339 });
4340 let ext = Extensions {
4341 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4342 entity_type: Some("tool".into()),
4343 entity_name: Some("get_compensation".into()),
4344 ..Default::default()
4345 })),
4346 ..Default::default()
4347 };
4348 mgr.invoke_by_name("test_hook", payload, ext, None).await;
4350
4351 assert_eq!(mgr.routing_cache_size(), 1);
4352
4353 let payload2: Box<dyn PluginPayload> = Box::new(TestPayload {
4355 value: "test2".into(),
4356 });
4357 let ext2 = Extensions {
4358 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4359 entity_type: Some("tool".into()),
4360 entity_name: Some("get_compensation".into()),
4361 ..Default::default()
4362 })),
4363 ..Default::default()
4364 };
4365 mgr.invoke_by_name("test_hook", payload2, ext2, None).await;
4366
4367 assert_eq!(mgr.routing_cache_size(), 1); }
4369
4370 #[tokio::test]
4377 async fn load_config_yaml_folds_top_level_group_into_route_resolution() {
4378 let yaml = r#"
4379plugin_settings:
4380 routing_enabled: true
4381plugins:
4382 - name: gate
4383 kind: test/deny
4384 hooks: [test_hook]
4385 mode: sequential
4386groups:
4387 hr-tools:
4388 plugins: [gate]
4389routes:
4390 - tool: get_compensation
4391 groups: hr-tools
4392"#;
4393 let mgr = Arc::new(PolicyEngine::default());
4394 mgr.register_factory("test/deny", Box::new(DenyPluginFactory));
4395 mgr.load_config_yaml(yaml).expect("config must load");
4396
4397 let ext = Extensions {
4398 meta: Some(Arc::new(crate::hooks::payload::MetaExtension {
4399 entity_type: Some("tool".into()),
4400 entity_name: Some("get_compensation".into()),
4401 ..Default::default()
4402 })),
4403 ..Default::default()
4404 };
4405 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "x".into() });
4406 let (result, _bg) = mgr.invoke_by_name("test_hook", payload, ext, None).await;
4407
4408 assert!(
4409 !result.continue_processing,
4410 "route must resolve the top-level group's plugin and deny; it was allowed, \
4411 so the group wasn't folded into the load path",
4412 );
4413 assert_eq!(result.violation.as_ref().unwrap().code, "denied");
4414 }
4415
4416 #[test]
4422 fn load_config_yaml_compiles_top_level_group_via_visitor() {
4423 use crate::visitor::{ConfigVisitor, VisitorError};
4424 use std::sync::Mutex as StdMutex;
4425
4426 #[derive(Default)]
4427 struct RecordingVisitor {
4428 bundles: StdMutex<Vec<String>>,
4429 }
4430 impl ConfigVisitor for RecordingVisitor {
4431 fn name(&self) -> &str {
4432 "recording"
4433 }
4434 fn visit_policy_bundle(
4435 &self,
4436 _mgr: &Arc<PolicyEngine>,
4437 tag: &str,
4438 _yaml: &serde_yaml::Value,
4439 ) -> Result<(), VisitorError> {
4440 self.bundles.lock().unwrap().push(tag.to_owned());
4441 Ok(())
4442 }
4443 }
4444
4445 let yaml = r#"
4446plugin_settings:
4447 routing_enabled: true
4448groups:
4449 hr-tools:
4450 authorization:
4451 pre_invocation:
4452 - "require(role.hr)"
4453routes:
4454 - tool: get_compensation
4455 groups: hr-tools
4456"#;
4457 let mgr = Arc::new(PolicyEngine::default());
4458 let recorder = Arc::new(RecordingVisitor::default());
4459 mgr.register_visitor(recorder.clone());
4460 mgr.load_config_yaml(yaml).expect("config must load");
4461
4462 let seen = recorder.bundles.lock().unwrap();
4463 assert!(
4464 seen.iter().any(|b| b == "hr-tools"),
4465 "top-level groups: bundle must be visited for compilation; saw: {seen:?}",
4466 );
4467 }
4468
4469 #[tokio::test]
4470 async fn test_routing_cache_different_entities_separate() {
4471 let yaml = r#"
4472plugin_settings:
4473 routing_enabled: true
4474global:
4475 policies:
4476 all:
4477 plugins: [allow_plugin]
4478plugins:
4479 - name: allow_plugin
4480 kind: test/allow
4481 hooks: [test_hook]
4482 mode: sequential
4483routes:
4484 - tool: get_compensation
4485 - tool: send_email
4486"#;
4487 let policy_config = crate::config::parse_config(yaml).unwrap();
4488 let mut factories = PluginFactoryRegistry::new();
4489 factories.register("test/allow", Box::new(AllowPluginFactory));
4490
4491 let mgr = PolicyEngine::from_config(policy_config, &factories).unwrap();
4492 mgr.initialize().await.unwrap();
4493
4494 let p1: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4498 let e1 = Extensions {
4499 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4500 entity_type: Some("tool".into()),
4501 entity_name: Some("get_compensation".into()),
4502 ..Default::default()
4503 })),
4504 ..Default::default()
4505 };
4506 mgr.invoke_by_name("test_hook", p1, e1, None).await;
4507
4508 let p2: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4510 let e2 = Extensions {
4511 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4512 entity_type: Some("tool".into()),
4513 entity_name: Some("send_email".into()),
4514 ..Default::default()
4515 })),
4516 ..Default::default()
4517 };
4518 mgr.invoke_by_name("test_hook", p2, e2, None).await;
4519
4520 assert_eq!(mgr.routing_cache_size(), 2);
4521 }
4522
4523 #[tokio::test]
4524 async fn test_routing_cache_cleared() {
4525 let yaml = r#"
4526plugin_settings:
4527 routing_enabled: true
4528global:
4529 policies:
4530 all:
4531 plugins: [allow_plugin]
4532plugins:
4533 - name: allow_plugin
4534 kind: test/allow
4535 hooks: [test_hook]
4536 mode: sequential
4537routes:
4538 - tool: get_compensation
4539"#;
4540 let policy_config = crate::config::parse_config(yaml).unwrap();
4541 let mut factories = PluginFactoryRegistry::new();
4542 factories.register("test/allow", Box::new(AllowPluginFactory));
4543
4544 let mgr = PolicyEngine::from_config(policy_config, &factories).unwrap();
4545 mgr.initialize().await.unwrap();
4546
4547 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4549 let ext = Extensions {
4550 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4551 entity_type: Some("tool".into()),
4552 entity_name: Some("get_compensation".into()),
4553 ..Default::default()
4554 })),
4555 ..Default::default()
4556 };
4557 mgr.invoke_by_name("test_hook", payload, ext, None).await;
4558 assert_eq!(mgr.routing_cache_size(), 1);
4559
4560 mgr.clear_routing_cache();
4561 assert_eq!(mgr.routing_cache_size(), 0);
4562 }
4563
4564 #[tokio::test]
4565 async fn test_unregister_invalidates_routing_cache() {
4566 let yaml = r#"
4567plugin_settings:
4568 routing_enabled: true
4569global:
4570 policies:
4571 all:
4572 plugins: [allow_plugin]
4573plugins:
4574 - name: allow_plugin
4575 kind: test/allow
4576 hooks: [test_hook]
4577 mode: sequential
4578routes:
4579 - tool: get_compensation
4580"#;
4581 let policy_config = crate::config::parse_config(yaml).unwrap();
4582 let mut factories = PluginFactoryRegistry::new();
4583 factories.register("test/allow", Box::new(AllowPluginFactory));
4584
4585 let mgr = PolicyEngine::from_config(policy_config, &factories).unwrap();
4586 mgr.initialize().await.unwrap();
4587
4588 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4589 let ext = Extensions {
4590 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4591 entity_type: Some("tool".into()),
4592 entity_name: Some("get_compensation".into()),
4593 ..Default::default()
4594 })),
4595 ..Default::default()
4596 };
4597 mgr.invoke_by_name("test_hook", payload, ext, None).await;
4598 assert_eq!(mgr.routing_cache_size(), 1);
4599
4600 mgr.unregister("allow_plugin");
4603 assert_eq!(mgr.routing_cache_size(), 0);
4604 }
4605
4606 #[test]
4607 fn test_routing_cache_recovers_from_poisoned_lock() {
4608 use std::panic::AssertUnwindSafe;
4617
4618 let mgr = PolicyEngine::default();
4619
4620 let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
4621 let _guard = mgr.route_cache.write().unwrap();
4622 panic!("simulated panic while holding cache lock");
4623 }));
4624 assert!(result.is_err(), "expected the panic to be caught");
4625 assert!(
4626 mgr.route_cache.is_poisoned(),
4627 "lock should be poisoned after the panic",
4628 );
4629
4630 assert_eq!(mgr.routing_cache_size(), 0);
4632 mgr.clear_routing_cache();
4633 assert_eq!(mgr.routing_cache_size(), 0);
4634 }
4635
4636 #[tokio::test]
4637 async fn test_routing_cache_rejects_inserts_at_capacity() {
4638 let yaml = r#"
4640plugin_settings:
4641 routing_enabled: true
4642 route_cache_max_entries: 2
4643global:
4644 policies:
4645 all:
4646 plugins: [allow_plugin]
4647plugins:
4648 - name: allow_plugin
4649 kind: test/allow
4650 hooks: [test_hook]
4651 mode: sequential
4652routes:
4653 - tool: a
4654 - tool: b
4655 - tool: c
4656"#;
4657 let policy_config = crate::config::parse_config(yaml).unwrap();
4658 let mut factories = PluginFactoryRegistry::new();
4659 factories.register("test/allow", Box::new(AllowPluginFactory));
4660
4661 let mgr = PolicyEngine::from_config(policy_config, &factories).unwrap();
4662 mgr.initialize().await.unwrap();
4663
4664 let invoke_for = |entity: &'static str| -> (Box<dyn PluginPayload>, Extensions) {
4665 let p: Box<dyn PluginPayload> = Box::new(TestPayload {
4666 value: entity.into(),
4667 });
4668 let e = Extensions {
4669 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4670 entity_type: Some("tool".into()),
4671 entity_name: Some(entity.into()),
4672 ..Default::default()
4673 })),
4674 ..Default::default()
4675 };
4676 (p, e)
4677 };
4678
4679 let (p1, e1) = invoke_for("a");
4681 let (r1, _) = mgr.invoke_by_name("test_hook", p1, e1, None).await;
4682 assert!(r1.continue_processing);
4683 assert_eq!(mgr.routing_cache_size(), 1);
4684
4685 let (p2, e2) = invoke_for("b");
4686 let (r2, _) = mgr.invoke_by_name("test_hook", p2, e2, None).await;
4687 assert!(r2.continue_processing);
4688 assert_eq!(mgr.routing_cache_size(), 2);
4689
4690 let (p3, e3) = invoke_for("c");
4693 let (r3, _) = mgr.invoke_by_name("test_hook", p3, e3, None).await;
4694 assert!(
4695 r3.continue_processing,
4696 "slow path must still resolve when cache is full"
4697 );
4698 assert_eq!(mgr.routing_cache_size(), 2, "cache must not exceed cap");
4699
4700 let (p4, e4) = invoke_for("c");
4702 let (r4, _) = mgr.invoke_by_name("test_hook", p4, e4, None).await;
4703 assert!(r4.continue_processing);
4704 assert_eq!(mgr.routing_cache_size(), 2);
4705
4706 mgr.clear_routing_cache();
4708 let (p5, e5) = invoke_for("c");
4709 mgr.invoke_by_name("test_hook", p5, e5, None).await;
4710 assert_eq!(mgr.routing_cache_size(), 1);
4711 }
4712
4713 #[tokio::test]
4714 async fn test_register_handler_invalidates_routing_cache() {
4715 let yaml = r#"
4716plugin_settings:
4717 routing_enabled: true
4718global:
4719 policies:
4720 all:
4721 plugins: [allow_plugin]
4722plugins:
4723 - name: allow_plugin
4724 kind: test/allow
4725 hooks: [test_hook]
4726 mode: sequential
4727routes:
4728 - tool: get_compensation
4729"#;
4730 let policy_config = crate::config::parse_config(yaml).unwrap();
4731 let mut factories = PluginFactoryRegistry::new();
4732 factories.register("test/allow", Box::new(AllowPluginFactory));
4733
4734 let mgr = PolicyEngine::from_config(policy_config, &factories).unwrap();
4735 mgr.initialize().await.unwrap();
4736
4737 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4738 let ext = Extensions {
4739 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4740 entity_type: Some("tool".into()),
4741 entity_name: Some("get_compensation".into()),
4742 ..Default::default()
4743 })),
4744 ..Default::default()
4745 };
4746 mgr.invoke_by_name("test_hook", payload, ext, None).await;
4747 assert_eq!(mgr.routing_cache_size(), 1);
4748
4749 let extra_cfg = make_config("late_plugin", 20, PluginMode::Sequential);
4752 let extra = Arc::new(AllowPlugin {
4753 cfg: extra_cfg.clone(),
4754 });
4755 mgr.register_handler::<TestHook, _>(extra, extra_cfg)
4756 .unwrap();
4757 assert_eq!(mgr.routing_cache_size(), 0);
4758 }
4759
4760 #[tokio::test]
4761 async fn test_routing_cache_scope_creates_separate_entries() {
4762 let yaml = r#"
4763plugin_settings:
4764 routing_enabled: true
4765global:
4766 policies:
4767 all:
4768 plugins: [allow_plugin]
4769plugins:
4770 - name: allow_plugin
4771 kind: test/allow
4772 hooks: [test_hook]
4773 mode: sequential
4774routes:
4775 - tool: get_compensation
4776"#;
4777 let policy_config = crate::config::parse_config(yaml).unwrap();
4778 let mut factories = PluginFactoryRegistry::new();
4779 factories.register("test/allow", Box::new(AllowPluginFactory));
4780
4781 let mgr = PolicyEngine::from_config(policy_config, &factories).unwrap();
4782 mgr.initialize().await.unwrap();
4783
4784 let p1: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4788 let e1 = Extensions {
4789 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4790 entity_type: Some("tool".into()),
4791 entity_name: Some("get_compensation".into()),
4792 scope: Some("hr-server".into()),
4793 ..Default::default()
4794 })),
4795 ..Default::default()
4796 };
4797 mgr.invoke_by_name("test_hook", p1, e1, None).await;
4798
4799 let p2: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4800 let e2 = Extensions {
4801 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4802 entity_type: Some("tool".into()),
4803 entity_name: Some("get_compensation".into()),
4804 scope: Some("billing-server".into()),
4805 ..Default::default()
4806 })),
4807 ..Default::default()
4808 };
4809 mgr.invoke_by_name("test_hook", p2, e2, None).await;
4810
4811 assert_eq!(mgr.routing_cache_size(), 2); }
4813
4814 #[tokio::test]
4817 async fn test_route_override_creates_new_instance() {
4818 let yaml = r#"
4819plugin_settings:
4820 routing_enabled: true
4821plugins:
4822 - name: rate_limiter
4823 kind: test/allow
4824 hooks: [test_hook]
4825 mode: sequential
4826 priority: 10
4827 config:
4828 max_requests: 100
4829routes:
4830 - tool: get_compensation
4831 plugins:
4832 - rate_limiter:
4833 config:
4834 max_requests: 10
4835"#;
4836 let policy_config = crate::config::parse_config(yaml).unwrap();
4837
4838 let mgr = PolicyEngine::default();
4840 mgr.register_factory("test/allow", Box::new(AllowPluginFactory));
4841 mgr.load_config(policy_config).unwrap();
4842 mgr.initialize().await.unwrap();
4843
4844 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4846 let ext = Extensions {
4847 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
4848 entity_type: Some("tool".into()),
4849 entity_name: Some("get_compensation".into()),
4850 ..Default::default()
4851 })),
4852 ..Default::default()
4853 };
4854 let (result, _) = mgr.invoke_by_name("test_hook", payload, ext, None).await;
4857
4858 assert!(result.continue_processing);
4860 assert_eq!(mgr.routing_cache_size(), 1);
4862 }
4863
4864 #[tokio::test]
4869 async fn test_route_override_initializes_new_instance() {
4870 use std::sync::atomic::{AtomicUsize, Ordering};
4871
4872 static INIT_COUNT: AtomicUsize = AtomicUsize::new(0);
4873 INIT_COUNT.store(0, Ordering::SeqCst);
4874
4875 struct InitTrackingPlugin {
4876 cfg: PluginConfig,
4877 }
4878
4879 #[async_trait]
4880 impl Plugin for InitTrackingPlugin {
4881 fn config(&self) -> &PluginConfig {
4882 &self.cfg
4883 }
4884 async fn initialize(&self) -> Result<(), Box<PluginError>> {
4885 INIT_COUNT.fetch_add(1, Ordering::SeqCst);
4886 Ok(())
4887 }
4888 async fn shutdown(&self) -> Result<(), Box<PluginError>> {
4889 Ok(())
4890 }
4891 }
4892
4893 impl HookHandler<TestHook> for InitTrackingPlugin {
4894 async fn handle(
4895 &self,
4896 _payload: &TestPayload,
4897 _extensions: &Extensions,
4898 _ctx: &mut PluginContext,
4899 ) -> PluginResult<TestPayload> {
4900 PluginResult::allow()
4901 }
4902 }
4903
4904 struct InitTrackingFactory;
4905 impl crate::factory::PluginFactory for InitTrackingFactory {
4906 fn create(
4907 &self,
4908 config: &PluginConfig,
4909 ) -> Result<crate::factory::PluginInstance, Box<PluginError>> {
4910 let plugin = Arc::new(InitTrackingPlugin {
4911 cfg: config.clone(),
4912 });
4913 let handler: Arc<dyn AnyHookHandler> =
4914 Arc::new(TypedHandlerAdapter::<TestHook, InitTrackingPlugin>::new(
4915 Arc::clone(&plugin),
4916 ));
4917 Ok(crate::factory::PluginInstance {
4918 plugin,
4919 handlers: vec![("test_hook", handler)],
4920 })
4921 }
4922 }
4923
4924 let yaml = r#"
4925plugin_settings:
4926 routing_enabled: true
4927plugins:
4928 - name: tracker
4929 kind: test/init_tracking
4930 hooks: [test_hook]
4931 mode: sequential
4932 priority: 10
4933 config:
4934 max_requests: 100
4935routes:
4936 - tool: get_compensation
4937 plugins:
4938 - tracker:
4939 config:
4940 max_requests: 10
4941"#;
4942 let policy_config = crate::config::parse_config(yaml).unwrap();
4943
4944 let mgr = PolicyEngine::default();
4945 mgr.register_factory("test/init_tracking", Box::new(InitTrackingFactory));
4946 mgr.load_config(policy_config).unwrap();
4947 mgr.initialize().await.unwrap();
4948
4949 assert_eq!(INIT_COUNT.load(Ordering::SeqCst), 1);
4951
4952 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
4955 let (result, _) = mgr
4956 .invoke_by_name(
4957 "test_hook",
4958 payload,
4959 make_meta("tool", "get_compensation", None, &[]),
4960 None,
4961 )
4962 .await;
4963 assert!(result.continue_processing);
4964
4965 assert_eq!(
4966 INIT_COUNT.load(Ordering::SeqCst),
4967 2,
4968 "override instance must have initialize() called",
4969 );
4970 }
4971
4972 #[tokio::test]
4978 async fn test_route_override_circuit_breaker_isolated_from_base() {
4979 struct ErrorOnInvokeFactory;
4980 impl crate::factory::PluginFactory for ErrorOnInvokeFactory {
4981 fn create(
4982 &self,
4983 config: &PluginConfig,
4984 ) -> Result<crate::factory::PluginInstance, Box<PluginError>> {
4985 let plugin = Arc::new(AllowPlugin {
4986 cfg: config.clone(),
4987 });
4988 let handler: Arc<dyn AnyHookHandler> = Arc::new(ErrorHandler);
4989 Ok(crate::factory::PluginInstance {
4990 plugin,
4991 handlers: vec![("test_hook", handler)],
4992 })
4993 }
4994 }
4995
4996 let yaml = r#"
4997plugin_settings:
4998 routing_enabled: true
4999plugins:
5000 - name: flaky
5001 kind: test/error_on_invoke
5002 hooks: [test_hook]
5003 mode: sequential
5004 priority: 10
5005 on_error: disable
5006routes:
5007 - tool: get_compensation
5008 plugins:
5009 - flaky:
5010 config:
5011 something: changed
5012"#;
5013 let policy_config = crate::config::parse_config(yaml).unwrap();
5014
5015 let mgr = PolicyEngine::default();
5016 mgr.register_factory("test/error_on_invoke", Box::new(ErrorOnInvokeFactory));
5017 mgr.load_config(policy_config).unwrap();
5018 mgr.initialize().await.unwrap();
5019
5020 assert!(
5021 !mgr.get_plugin("flaky").unwrap().is_disabled(),
5022 "should start enabled"
5023 );
5024
5025 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
5030 let _ = mgr
5031 .invoke_by_name(
5032 "test_hook",
5033 payload,
5034 make_meta("tool", "get_compensation", None, &[]),
5035 None,
5036 )
5037 .await;
5038
5039 assert!(
5040 !mgr.get_plugin("flaky").unwrap().is_disabled(),
5041 "base must NOT be disabled when an override trips its own circuit breaker",
5042 );
5043 }
5044
5045 #[tokio::test]
5046 async fn test_register_factory_then_load_config() {
5047 let yaml = r#"
5048plugins:
5049 - name: my_plugin
5050 kind: test/allow
5051 hooks: [test_hook]
5052 mode: sequential
5053 priority: 10
5054
5055plugin_settings:
5056 plugin_timeout: 45
5057"#;
5058 let policy_config = crate::config::parse_config(yaml).unwrap();
5059
5060 let mgr = PolicyEngine::default();
5061 mgr.register_factory("test/allow", Box::new(AllowPluginFactory));
5062 mgr.load_config(policy_config).unwrap();
5063 mgr.initialize().await.unwrap();
5064
5065 assert_eq!(mgr.plugin_count(), 1);
5066 assert!(mgr.has_hooks_for("test_hook"));
5067
5068 let payload: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
5069 let (result, _) = mgr
5071 .invoke_by_name("test_hook", payload, Extensions::default(), None)
5072 .await;
5073 assert!(result.continue_processing);
5074 }
5075
5076 fn make_meta(
5080 entity_type: &str,
5081 entity_name: &str,
5082 scope: Option<&str>,
5083 tags: &[&str],
5084 ) -> Extensions {
5085 let mut tag_set = std::collections::HashSet::new();
5086 for t in tags {
5087 tag_set.insert(t.to_string());
5088 }
5089 Extensions {
5090 meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension {
5091 entity_type: Some(entity_type.into()),
5092 entity_name: Some(entity_name.into()),
5093 scope: scope.map(String::from),
5094 tags: tag_set,
5095 ..Default::default()
5096 })),
5097 ..Default::default()
5098 }
5099 }
5100
5101 #[tokio::test]
5102 async fn test_routing_full_flow_different_tools_different_plugins() {
5103 let yaml = r#"
5106plugin_settings:
5107 routing_enabled: true
5108global:
5109 policies:
5110 all:
5111 plugins: [identity]
5112 pii:
5113 plugins: [apl_policy]
5114plugins:
5115 - name: identity
5116 kind: test/allow
5117 hooks: [test_hook]
5118 mode: sequential
5119 priority: 1
5120 - name: apl_policy
5121 kind: test/deny
5122 hooks: [test_hook]
5123 mode: sequential
5124 priority: 10
5125 - name: rate_limiter
5126 kind: test/allow
5127 hooks: [test_hook]
5128 mode: sequential
5129 priority: 5
5130routes:
5131 - tool: get_compensation
5132 meta:
5133 tags: [pii]
5134 plugins:
5135 - rate_limiter
5136 - tool: send_email
5137 plugins:
5138 - rate_limiter
5139"#;
5140 let policy_config = crate::config::parse_config(yaml).unwrap();
5141 let mgr = PolicyEngine::default();
5142 mgr.register_factory("test/allow", Box::new(AllowPluginFactory));
5143 mgr.register_factory("test/deny", Box::new(DenyPluginFactory));
5144 mgr.load_config(policy_config).unwrap();
5145 mgr.initialize().await.unwrap();
5146
5147 let p1: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
5152 let (r1, _) = mgr
5153 .invoke_by_name(
5154 "test_hook",
5155 p1,
5156 make_meta("tool", "get_compensation", None, &[]),
5157 None,
5158 )
5159 .await;
5160 assert!(!r1.continue_processing); let p2: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
5165 let (r2, _) = mgr
5166 .invoke_by_name(
5167 "test_hook",
5168 p2,
5169 make_meta("tool", "send_email", None, &[]),
5170 None,
5171 )
5172 .await;
5173 assert!(r2.continue_processing); }
5175
5176 #[tokio::test]
5177 async fn test_routing_disabled_fires_all_plugins() {
5178 let yaml = r#"
5180plugins:
5181 - name: denier
5182 kind: test/deny
5183 hooks: [test_hook]
5184 mode: sequential
5185 priority: 10
5186 - name: allower
5187 kind: test/allow
5188 hooks: [test_hook]
5189 mode: sequential
5190 priority: 20
5191"#;
5192 let policy_config = crate::config::parse_config(yaml).unwrap();
5193 let mgr = PolicyEngine::default();
5194 mgr.register_factory("test/allow", Box::new(AllowPluginFactory));
5195 mgr.register_factory("test/deny", Box::new(DenyPluginFactory));
5196 mgr.load_config(policy_config).unwrap();
5197 mgr.initialize().await.unwrap();
5198
5199 let p: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
5203 let (result, _) = mgr
5204 .invoke_by_name(
5205 "test_hook",
5206 p,
5207 make_meta("tool", "anything", None, &[]),
5208 None,
5209 )
5210 .await;
5211 assert!(!result.continue_processing); }
5213
5214 #[tokio::test]
5215 async fn test_routing_no_meta_fires_all_plugins() {
5216 let yaml = r#"
5218plugin_settings:
5219 routing_enabled: true
5220global:
5221 policies:
5222 all:
5223 plugins: [allower]
5224plugins:
5225 - name: allower
5226 kind: test/allow
5227 hooks: [test_hook]
5228 mode: sequential
5229 - name: denier
5230 kind: test/deny
5231 hooks: [test_hook]
5232 mode: sequential
5233routes:
5234 - tool: get_compensation
5235 plugins:
5236 - denier
5237"#;
5238 let policy_config = crate::config::parse_config(yaml).unwrap();
5239 let mgr = PolicyEngine::default();
5240 mgr.register_factory("test/allow", Box::new(AllowPluginFactory));
5241 mgr.register_factory("test/deny", Box::new(DenyPluginFactory));
5242 mgr.load_config(policy_config).unwrap();
5243 mgr.initialize().await.unwrap();
5244
5245 let p: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
5249 let (result, _) = mgr
5250 .invoke_by_name("test_hook", p, Extensions::default(), None)
5251 .await;
5252 assert!(
5256 !result.continue_processing,
5257 "denier should run when no meta is provided (route filtering bypassed)",
5258 );
5259 assert!(
5260 result.violation.is_some(),
5261 "deny should produce a violation"
5262 );
5263 }
5264
5265 #[tokio::test]
5266 async fn test_routing_wildcard_catches_unmatched() {
5267 let yaml = r#"
5268plugin_settings:
5269 routing_enabled: true
5270global:
5271 policies:
5272 all:
5273 plugins: [identity]
5274plugins:
5275 - name: identity
5276 kind: test/allow
5277 hooks: [test_hook]
5278 mode: sequential
5279 priority: 1
5280 - name: specific_plugin
5281 kind: test/deny
5282 hooks: [test_hook]
5283 mode: sequential
5284 priority: 10
5285 - name: fallback_plugin
5286 kind: test/allow
5287 hooks: [test_hook]
5288 mode: sequential
5289 priority: 10
5290routes:
5291 - tool: get_compensation
5292 plugins:
5293 - specific_plugin
5294 - tool: "*"
5295 plugins:
5296 - fallback_plugin
5297"#;
5298 let policy_config = crate::config::parse_config(yaml).unwrap();
5299 let mgr = PolicyEngine::default();
5300 mgr.register_factory("test/allow", Box::new(AllowPluginFactory));
5301 mgr.register_factory("test/deny", Box::new(DenyPluginFactory));
5302 mgr.load_config(policy_config).unwrap();
5303 mgr.initialize().await.unwrap();
5304
5305 let p1: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
5309 let (r1, _) = mgr
5310 .invoke_by_name(
5311 "test_hook",
5312 p1,
5313 make_meta("tool", "get_compensation", None, &[]),
5314 None,
5315 )
5316 .await;
5317 assert!(!r1.continue_processing); let p2: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
5321 let (r2, _) = mgr
5322 .invoke_by_name(
5323 "test_hook",
5324 p2,
5325 make_meta("tool", "unknown_tool", None, &[]),
5326 None,
5327 )
5328 .await;
5329 assert!(r2.continue_processing); }
5331
5332 #[tokio::test]
5333 async fn test_routing_host_tags_activate_policy_groups() {
5334 let yaml = r#"
5335plugin_settings:
5336 routing_enabled: true
5337global:
5338 policies:
5339 all:
5340 plugins: [identity]
5341 urgent:
5342 plugins: [denier]
5343plugins:
5344 - name: identity
5345 kind: test/allow
5346 hooks: [test_hook]
5347 mode: sequential
5348 priority: 1
5349 - name: denier
5350 kind: test/deny
5351 hooks: [test_hook]
5352 mode: sequential
5353 priority: 10
5354routes:
5355 - tool: get_compensation
5356"#;
5357 let policy_config = crate::config::parse_config(yaml).unwrap();
5358 let mgr = PolicyEngine::default();
5359 mgr.register_factory("test/allow", Box::new(AllowPluginFactory));
5360 mgr.register_factory("test/deny", Box::new(DenyPluginFactory));
5361 mgr.load_config(policy_config).unwrap();
5362 mgr.initialize().await.unwrap();
5363
5364 let p1: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
5368 let (r1, _) = mgr
5369 .invoke_by_name(
5370 "test_hook",
5371 p1,
5372 make_meta("tool", "get_compensation", None, &[]),
5373 None,
5374 )
5375 .await;
5376 assert!(r1.continue_processing);
5377
5378 mgr.clear_routing_cache();
5380
5381 let p2: Box<dyn PluginPayload> = Box::new(TestPayload { value: "t".into() });
5383 let (r2, _) = mgr
5384 .invoke_by_name(
5385 "test_hook",
5386 p2,
5387 make_meta("tool", "get_compensation", None, &["urgent"]),
5388 None,
5389 )
5390 .await;
5391 assert!(!r2.continue_processing);
5392 }
5393
5394 #[tokio::test]
5395 async fn test_routing_works_with_typed_invoke() {
5396 let yaml = r#"
5397plugin_settings:
5398 routing_enabled: true
5399global:
5400 policies:
5401 all:
5402 plugins: [allower]
5403 pii:
5404 plugins: [denier]
5405plugins:
5406 - name: allower
5407 kind: test/allow
5408 hooks: [test_hook]
5409 mode: sequential
5410 priority: 1
5411 - name: denier
5412 kind: test/deny
5413 hooks: [test_hook]
5414 mode: sequential
5415 priority: 10
5416routes:
5417 - tool: get_compensation
5418 meta:
5419 tags: [pii]
5420 - tool: send_email
5421"#;
5422 let policy_config = crate::config::parse_config(yaml).unwrap();
5423 let mgr = PolicyEngine::default();
5424 mgr.register_factory("test/allow", Box::new(AllowPluginFactory));
5425 mgr.register_factory("test/deny", Box::new(DenyPluginFactory));
5426 mgr.load_config(policy_config).unwrap();
5427 mgr.initialize().await.unwrap();
5428
5429 let (r1, _) = mgr
5433 .invoke::<TestHook>(
5434 TestPayload { value: "t".into() },
5435 make_meta("tool", "get_compensation", None, &[]),
5436 None,
5437 )
5438 .await;
5439 assert!(!r1.continue_processing);
5440
5441 let (r2, _) = mgr
5443 .invoke::<TestHook>(
5444 TestPayload { value: "t".into() },
5445 make_meta("tool", "send_email", None, &[]),
5446 None,
5447 )
5448 .await;
5449 assert!(r2.continue_processing);
5450 }
5451
5452 struct LabelAdderHandler;
5456
5457 #[async_trait]
5458 impl AnyHookHandler for LabelAdderHandler {
5459 async fn invoke(
5460 &self,
5461 _payload: &dyn PluginPayload,
5462 extensions: &Extensions,
5463 _ctx: &mut PluginContext,
5464 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
5465 let mut ext = extensions.cow_copy();
5466 if let Some(ref mut sec) = ext.security {
5467 sec.add_label("PLUGIN_ADDED");
5468 }
5469 let mut result: PluginResult<TestPayload> = PluginResult::allow();
5470 result.modified_extensions = Some(ext);
5471 Ok(crate::executor::erase_result(result))
5472 }
5473 fn hook_type_name(&self) -> &'static str {
5474 "test_hook"
5475 }
5476 }
5477
5478 struct ImmutableTampererHandler;
5480
5481 #[async_trait]
5482 impl AnyHookHandler for ImmutableTampererHandler {
5483 async fn invoke(
5484 &self,
5485 _payload: &dyn PluginPayload,
5486 extensions: &Extensions,
5487 _ctx: &mut PluginContext,
5488 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
5489 let mut ext = extensions.cow_copy();
5490 ext.request = Some(std::sync::Arc::new(crate::extensions::RequestExtension {
5492 request_id: Some("TAMPERED".into()),
5493 ..Default::default()
5494 }));
5495 let mut result: PluginResult<TestPayload> = PluginResult::allow();
5496 result.modified_extensions = Some(ext);
5497 Ok(crate::executor::erase_result(result))
5498 }
5499 fn hook_type_name(&self) -> &'static str {
5500 "test_hook"
5501 }
5502 }
5503
5504 #[tokio::test]
5505 async fn test_executor_accepts_valid_label_addition() {
5506 let mgr = PolicyEngine::default();
5507 let mut config = make_config("label-adder", 10, PluginMode::Sequential);
5508 config.capabilities = ["append_labels".to_owned(), "read_labels".to_owned()].into();
5509 let plugin = Arc::new(AllowPlugin {
5510 cfg: config.clone(),
5511 });
5512 let handler: Arc<dyn AnyHookHandler> = Arc::new(LabelAdderHandler);
5513 mgr.register_raw::<TestHook>(plugin, config, handler)
5514 .unwrap();
5515 mgr.initialize().await.unwrap();
5516
5517 let mut security = crate::extensions::SecurityExtension::default();
5518 security.add_label("ORIGINAL");
5519
5520 let ext = Extensions {
5521 security: Some(Arc::new(security)),
5522 ..Default::default()
5523 };
5524
5525 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
5526 value: "test".into(),
5527 });
5528 let (result, _) = mgr.invoke_by_name("test_hook", payload, ext, None).await;
5529
5530 assert!(result.continue_processing);
5531 let modified = result.modified_extensions.as_ref().unwrap();
5533 let sec = modified.security.as_ref().unwrap();
5534 assert!(sec.has_label("ORIGINAL"));
5535 assert!(sec.has_label("PLUGIN_ADDED"));
5536 }
5537
5538 #[tokio::test]
5539 async fn test_executor_rejects_immutable_tampering() {
5540 let mgr = PolicyEngine::default();
5541 let config = make_config("tamperer", 10, PluginMode::Sequential);
5542 let plugin = Arc::new(AllowPlugin {
5543 cfg: config.clone(),
5544 });
5545 let handler: Arc<dyn AnyHookHandler> = Arc::new(ImmutableTampererHandler);
5546 mgr.register_raw::<TestHook>(plugin, config, handler)
5547 .unwrap();
5548 mgr.initialize().await.unwrap();
5549
5550 let ext = Extensions {
5551 request: Some(std::sync::Arc::new(crate::extensions::RequestExtension {
5552 request_id: Some("original-req-id".into()),
5553 ..Default::default()
5554 })),
5555 ..Default::default()
5556 };
5557
5558 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
5559 value: "test".into(),
5560 });
5561 let (result, _) = mgr.invoke_by_name("test_hook", payload, ext, None).await;
5562
5563 assert!(result.continue_processing);
5564 if let Some(ref modified) = result.modified_extensions {
5567 assert_eq!(
5569 modified.request.as_ref().unwrap().request_id.as_deref(),
5570 Some("original-req-id"),
5571 );
5572 }
5573 }
5574
5575 #[tokio::test]
5576 async fn test_capability_filtering_hides_security_from_plugin() {
5577 struct SecurityCheckerHandler {
5580 saw_security: std::sync::Arc<std::sync::atomic::AtomicBool>,
5581 }
5582
5583 #[async_trait]
5584 impl AnyHookHandler for SecurityCheckerHandler {
5585 async fn invoke(
5586 &self,
5587 _payload: &dyn PluginPayload,
5588 extensions: &Extensions,
5589 _ctx: &mut PluginContext,
5590 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
5591 if extensions.security.is_some() {
5593 self.saw_security
5594 .store(true, std::sync::atomic::Ordering::SeqCst);
5595 }
5596 let result: PluginResult<TestPayload> = PluginResult::allow();
5597 Ok(crate::executor::erase_result(result))
5598 }
5599 fn hook_type_name(&self) -> &'static str {
5600 "test_hook"
5601 }
5602 }
5603
5604 let saw_security = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
5605
5606 let mgr = PolicyEngine::default();
5607 let config = make_config("no-sec-caps", 10, PluginMode::Sequential);
5609 let plugin = Arc::new(AllowPlugin {
5610 cfg: config.clone(),
5611 });
5612 let handler: Arc<dyn AnyHookHandler> = Arc::new(SecurityCheckerHandler {
5613 saw_security: saw_security.clone(),
5614 });
5615 mgr.register_raw::<TestHook>(plugin, config, handler)
5616 .unwrap();
5617 mgr.initialize().await.unwrap();
5618
5619 let mut security = crate::extensions::SecurityExtension::default();
5620 security.add_label("SECRET");
5621 security.subject = Some(crate::extensions::security::SubjectExtension {
5622 id: Some("alice".into()),
5623 ..Default::default()
5624 });
5625
5626 let ext = Extensions {
5627 security: Some(Arc::new(security)),
5628 ..Default::default()
5629 };
5630
5631 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
5632 value: "test".into(),
5633 });
5634 let (result, _) = mgr.invoke_by_name("test_hook", payload, ext, None).await;
5635
5636 assert!(result.continue_processing);
5637 }
5644
5645 struct AsyncCounterPlugin {
5649 cfg: PluginConfig,
5650 counter: Arc<std::sync::atomic::AtomicU64>,
5651 }
5652
5653 #[async_trait]
5654 impl Plugin for AsyncCounterPlugin {
5655 fn config(&self) -> &PluginConfig {
5656 &self.cfg
5657 }
5658 }
5659
5660 impl HookHandler<TestHook> for AsyncCounterPlugin {
5661 async fn handle(
5662 &self,
5663 _payload: &TestPayload,
5664 _extensions: &Extensions,
5665 _ctx: &mut PluginContext,
5666 ) -> PluginResult<TestPayload> {
5667 tokio::task::yield_now().await;
5668 tokio::time::sleep(std::time::Duration::from_micros(1)).await;
5669 self.counter
5670 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
5671 PluginResult::allow()
5672 }
5673 }
5674
5675 #[tokio::test]
5678 async fn test_async_handler_registers_and_invokes() {
5679 let mgr = PolicyEngine::default();
5680 let counter = Arc::new(std::sync::atomic::AtomicU64::new(0));
5681 let cfg = make_config("async-counter", 10, PluginMode::Sequential);
5682 let plugin = Arc::new(AsyncCounterPlugin {
5683 cfg: cfg.clone(),
5684 counter: counter.clone(),
5685 });
5686
5687 mgr.register_handler::<TestHook, _>(plugin, cfg).unwrap();
5689 mgr.initialize().await.unwrap();
5690
5691 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
5692 value: "test".into(),
5693 });
5694 let (result, _) = mgr
5695 .invoke_by_name("test_hook", payload, Extensions::default(), None)
5696 .await;
5697
5698 assert!(result.continue_processing);
5699 assert!(result.violation.is_none());
5700 assert_eq!(
5703 counter.load(std::sync::atomic::Ordering::SeqCst),
5704 1,
5705 "async handler should have run once",
5706 );
5707 }
5708
5709 #[tokio::test]
5714 async fn test_mixed_sync_and_async_handlers_in_same_hook() {
5715 let mgr = PolicyEngine::default();
5716 let counter = Arc::new(std::sync::atomic::AtomicU64::new(0));
5717
5718 let sync_cfg = make_config("sync-allow", 10, PluginMode::Sequential);
5719 let sync_plugin = Arc::new(AllowPlugin {
5720 cfg: sync_cfg.clone(),
5721 });
5722 mgr.register_handler::<TestHook, _>(sync_plugin, sync_cfg)
5723 .unwrap();
5724
5725 let async_cfg = make_config("async-counter", 20, PluginMode::Sequential);
5726 let async_plugin = Arc::new(AsyncCounterPlugin {
5727 cfg: async_cfg.clone(),
5728 counter: counter.clone(),
5729 });
5730 mgr.register_handler::<TestHook, _>(async_plugin, async_cfg)
5731 .unwrap();
5732
5733 mgr.initialize().await.unwrap();
5734
5735 let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
5736 value: "test".into(),
5737 });
5738 let (result, _) = mgr
5739 .invoke_by_name("test_hook", payload, Extensions::default(), None)
5740 .await;
5741
5742 assert!(result.continue_processing);
5743 assert_eq!(
5744 counter.load(std::sync::atomic::Ordering::SeqCst),
5745 1,
5746 "awaiting plugin should have run alongside the non-awaiting plugin",
5747 );
5748 }
5749
5750 #[test]
5758 fn loading_a_missing_config_file_reports_the_path() {
5759 let mgr = PolicyEngine::default();
5760 let err = mgr
5761 .load_config_file(std::path::Path::new("/nonexistent/ppe-test/policy.yaml"))
5762 .expect_err("a missing file must not load");
5763 let msg = err.to_string();
5764 assert!(msg.contains("policy.yaml"), "must name the file: {msg}");
5765 }
5766
5767 #[test]
5772 fn settings_the_runtime_ignores_still_load() {
5773 let mgr = Arc::new(PolicyEngine::default());
5774 let yaml = r#"
5775plugin_dirs: ["/opt/plugins"]
5776plugin_settings:
5777 parallel_execution_within_band: true
5778 fail_on_plugin_error: true
5779"#;
5780 mgr.load_config_yaml(yaml)
5781 .expect("inactive settings warn, they do not fail the load");
5782 }
5783
5784 #[test]
5788 fn top_level_groups_and_global_policies_are_merged() {
5789 use crate::visitor::{ConfigVisitor, VisitorError};
5790 use std::sync::Mutex as StdMutex;
5791
5792 #[derive(Default)]
5793 struct BundleRecorder {
5794 seen: StdMutex<Vec<String>>,
5795 }
5796 impl ConfigVisitor for BundleRecorder {
5797 fn name(&self) -> &str {
5798 "recorder"
5799 }
5800 fn visit_policy_bundle(
5801 &self,
5802 _mgr: &Arc<PolicyEngine>,
5803 tag: &str,
5804 _yaml: &serde_yaml::Value,
5805 ) -> Result<(), VisitorError> {
5806 self.seen.lock().unwrap().push(tag.to_owned());
5807 Ok(())
5808 }
5809 }
5810
5811 let yaml = r#"
5812plugin_settings:
5813 routing_enabled: true
5814groups:
5815 from-groups:
5816 authorization:
5817 pre_invocation:
5818 - "require(authenticated)"
5819global:
5820 policies:
5821 from-global:
5822 authorization:
5823 pre_invocation:
5824 - "require(authenticated)"
5825"#;
5826 let mgr = Arc::new(PolicyEngine::default());
5827 let recorder = Arc::new(BundleRecorder::default());
5828 mgr.register_visitor(recorder.clone());
5829 mgr.load_config_yaml(yaml).expect("config must load");
5830
5831 let seen = recorder.seen.lock().unwrap();
5832 assert!(
5833 seen.iter().any(|t| t == "from-groups"),
5834 "the top-level groups bundle must survive the merge; saw {seen:?}"
5835 );
5836 assert!(
5837 seen.iter().any(|t| t == "from-global"),
5838 "and so must the global.policies one; saw {seen:?}"
5839 );
5840 }
5841
5842 #[test]
5846 fn a_visitor_refusal_aborts_the_load_and_is_attributed() {
5847 use crate::visitor::{ConfigVisitor, VisitorError};
5848
5849 struct Refuser(&'static str);
5850 impl ConfigVisitor for Refuser {
5851 fn name(&self) -> &str {
5852 "refuser"
5853 }
5854 fn visit_plugins(
5855 &self,
5856 _mgr: &Arc<PolicyEngine>,
5857 _plugins: &[PluginConfig],
5858 ) -> Result<(), VisitorError> {
5859 if self.0 == "plugins" {
5860 return Err("no".into());
5861 }
5862 Ok(())
5863 }
5864 fn visit_global(
5865 &self,
5866 _mgr: &Arc<PolicyEngine>,
5867 _yaml: &serde_yaml::Value,
5868 ) -> Result<(), VisitorError> {
5869 if self.0 == "global" {
5870 return Err("no".into());
5871 }
5872 Ok(())
5873 }
5874 fn visit_default(
5875 &self,
5876 _mgr: &Arc<PolicyEngine>,
5877 _entity_type: &str,
5878 _yaml: &serde_yaml::Value,
5879 ) -> Result<(), VisitorError> {
5880 if self.0 == "default" {
5881 return Err("no".into());
5882 }
5883 Ok(())
5884 }
5885 fn visit_policy_bundle(
5886 &self,
5887 _mgr: &Arc<PolicyEngine>,
5888 _tag: &str,
5889 _yaml: &serde_yaml::Value,
5890 ) -> Result<(), VisitorError> {
5891 if self.0 == "bundle" {
5892 return Err("no".into());
5893 }
5894 Ok(())
5895 }
5896 }
5897
5898 let yaml = r#"
5899global:
5900 defaults:
5901 tool:
5902 authorization:
5903 pre_invocation:
5904 - "require(authenticated)"
5905 policies:
5906 a-tag:
5907 authorization:
5908 pre_invocation:
5909 - "require(authenticated)"
5910"#;
5911 for (section, expect) in [
5914 ("plugins", "visit_plugins"),
5915 ("global", "visit_global"),
5916 ("default", "visit_default"),
5917 ("bundle", "visit_policy_bundle"),
5918 ] {
5919 let mgr = Arc::new(PolicyEngine::default());
5920 mgr.register_visitor(Arc::new(Refuser(section)));
5921 let err = mgr
5922 .load_config_yaml(yaml)
5923 .expect_err("a refusing visitor must abort the load");
5924 let msg = err.to_string();
5925 assert!(
5926 msg.contains("refuser"),
5927 "the error must name the visitor: {msg}"
5928 );
5929 assert!(
5930 msg.contains(expect),
5931 "and the section it refused; expected {expect} in: {msg}"
5932 );
5933 }
5934 }
5935
5936 #[test]
5944 fn removing_an_absent_route_annotation_is_a_no_op() {
5945 let mgr = PolicyEngine::default();
5946 mgr.remove_route_annotation("tool", "never-annotated", None, "cmf.tool_pre_invoke");
5947 mgr.remove_route_annotation(
5948 "tool",
5949 "never-annotated",
5950 Some("scope"),
5951 "cmf.tool_pre_invoke",
5952 );
5953 }
5954
5955 #[test]
5958 fn plugin_names_lists_what_was_registered() {
5959 let mgr = Arc::new(PolicyEngine::default());
5960 assert!(
5961 mgr.plugin_names().is_empty(),
5962 "an empty engine registers nothing"
5963 );
5964
5965 mgr.register_factory("test/allow", Box::new(AllowPluginFactory));
5966 let yaml = r#"
5967plugins:
5968 - name: first
5969 kind: test/allow
5970 hooks: [test_hook]
5971 - name: second
5972 kind: test/allow
5973 hooks: [test_hook]
5974"#;
5975 mgr.load_config_yaml(yaml).expect("config must load");
5976 let mut names = mgr.plugin_names();
5977 names.sort();
5978 assert_eq!(names, vec!["first".to_owned(), "second".to_owned()]);
5979 }
5980
5981 #[test]
5986 fn the_route_cache_key_distinguishes_every_field() {
5987 let base = RouteCacheKey {
5988 entity_type: "tool".into(),
5989 entity_name: "get_x".into(),
5990 hook_name: "cmf.tool_pre_invoke".into(),
5991 scope: None,
5992 };
5993 assert_eq!(base, base.clone(), "a key equals itself");
5994
5995 let variants = [
5996 RouteCacheKey {
5997 entity_type: "prompt".into(),
5998 ..base.clone()
5999 },
6000 RouteCacheKey {
6001 entity_name: "other".into(),
6002 ..base.clone()
6003 },
6004 RouteCacheKey {
6005 hook_name: "cmf.tool_post_invoke".into(),
6006 ..base.clone()
6007 },
6008 RouteCacheKey {
6009 scope: Some("read".into()),
6010 ..base.clone()
6011 },
6012 ];
6013 for v in variants {
6014 assert_ne!(
6015 base, v,
6016 "a key differing in one field must not compare equal, or two \
6017 routes would share a cache entry"
6018 );
6019 }
6020 }
6021}