use std::any::Any;
use std::fmt;
use std::sync::Arc;
use std::time::Duration;
use tokio::time::timeout;
use tracing::{error, warn};
use crate::context::PluginContextTable;
use crate::error::PluginError;
use crate::extensions::filter_extensions;
use crate::hooks::payload::{Extensions, PluginPayload, WriteToken};
use crate::plugin::OnError;
use crate::registry::{group_by_mode, HookEntry};
#[derive(Debug, Clone)]
pub struct ExecutorConfig {
pub timeout_seconds: u64,
pub short_circuit_on_deny: bool,
}
impl Default for ExecutorConfig {
fn default() -> Self {
Self {
timeout_seconds: 30,
short_circuit_on_deny: true,
}
}
}
#[derive(Debug)]
pub struct PipelineResult {
pub continue_processing: bool,
pub modified_payload: Option<Box<dyn PluginPayload>>,
pub modified_extensions: Option<Extensions>,
pub violation: Option<crate::error::PluginViolation>,
pub errors: Vec<crate::error::PluginErrorRecord>,
pub metadata: Option<serde_json::Value>,
pub context_table: PluginContextTable,
}
impl PipelineResult {
pub fn allowed_with(
payload: Box<dyn PluginPayload>,
extensions: Extensions,
context_table: PluginContextTable,
) -> Self {
Self {
continue_processing: true,
modified_payload: Some(payload),
modified_extensions: Some(extensions),
violation: None,
errors: Vec::new(),
metadata: None,
context_table,
}
}
pub fn denied(
violation: crate::error::PluginViolation,
extensions: Extensions,
context_table: PluginContextTable,
) -> Self {
Self {
continue_processing: false,
modified_payload: None,
modified_extensions: Some(extensions),
violation: Some(violation),
errors: Vec::new(),
metadata: None,
context_table,
}
}
pub fn with_errors(mut self, errors: Vec<crate::error::PluginErrorRecord>) -> Self {
self.errors = errors;
self
}
pub fn is_denied(&self) -> bool {
!self.continue_processing
}
}
pub struct BackgroundTasks {
tasks: Vec<(String, tokio::task::JoinHandle<()>)>,
}
impl BackgroundTasks {
pub fn empty() -> Self {
Self { tasks: Vec::new() }
}
fn from_handles(tasks: Vec<(String, tokio::task::JoinHandle<()>)>) -> Self {
Self { tasks }
}
pub fn is_empty(&self) -> bool {
self.tasks.is_empty()
}
pub fn len(&self) -> usize {
self.tasks.len()
}
pub async fn wait_for_background_tasks(self) -> Vec<crate::error::PluginError> {
let mut errors = Vec::new();
for (plugin_name, handle) in self.tasks {
if let Err(e) = handle.await {
errors.push(crate::error::PluginError::Execution {
plugin_name,
message: format!("background task panicked: {}", e),
source: None,
code: None,
details: std::collections::HashMap::new(),
proto_error_code: None,
});
}
}
errors
}
}
impl fmt::Debug for BackgroundTasks {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("BackgroundTasks")
.field("count", &self.tasks.len())
.finish()
}
}
#[derive(Clone)]
pub struct Executor {
config: ExecutorConfig,
}
impl Executor {
pub fn new(config: ExecutorConfig) -> Self {
Self { config }
}
pub async fn execute(
&self,
entries: &[HookEntry],
payload: Box<dyn PluginPayload>,
extensions: Extensions,
context_table: Option<PluginContextTable>,
task_tracker: &tokio_util::task::TaskTracker,
) -> (PipelineResult, BackgroundTasks) {
let mut ctx_table = context_table.unwrap_or_default();
if entries.is_empty() {
return (
PipelineResult::allowed_with(payload, extensions, ctx_table),
BackgroundTasks::empty(),
);
}
let (sequential, transform, audit, concurrent, fire_and_forget) = group_by_mode(entries);
let mut current_payload = payload;
let mut current_extensions = extensions;
let mut errors: Vec<crate::error::PluginErrorRecord> = Vec::new();
if let Some(v) = self
.run_serial_phase(
&sequential,
&mut current_payload,
&mut current_extensions,
&mut ctx_table,
true, true, "SEQUENTIAL",
&mut errors,
)
.await
{
return (
PipelineResult::denied(v, current_extensions, ctx_table).with_errors(errors),
BackgroundTasks::empty(),
);
}
self.run_serial_phase(
&transform,
&mut current_payload,
&mut current_extensions,
&mut ctx_table,
false, true, "TRANSFORM",
&mut errors,
)
.await;
self.run_ref_phase(
&audit,
&*current_payload,
¤t_extensions,
&ctx_table,
"AUDIT",
&mut errors,
)
.await;
if let Some(violation) = self
.run_concurrent_phase(
&concurrent,
&*current_payload,
¤t_extensions,
&ctx_table,
&mut errors,
)
.await
{
return (
PipelineResult::denied(violation, current_extensions, ctx_table)
.with_errors(errors),
BackgroundTasks::empty(),
);
}
let bg_handles = self.spawn_fire_and_forget(
&fire_and_forget,
&*current_payload,
¤t_extensions,
&ctx_table,
task_tracker,
);
(
PipelineResult::allowed_with(current_payload, current_extensions, ctx_table)
.with_errors(errors),
BackgroundTasks::from_handles(bg_handles),
)
}
#[allow(clippy::too_many_arguments)] async fn run_serial_phase(
&self,
entries: &[HookEntry],
payload: &mut Box<dyn PluginPayload>,
extensions: &mut Extensions,
ctx_table: &mut PluginContextTable,
can_block: bool,
can_modify: bool,
phase_label: &str,
errors: &mut Vec<crate::error::PluginErrorRecord>,
) -> Option<crate::error::PluginViolation> {
for entry in entries {
let plugin_name = entry.plugin_ref.name();
let plugin_id = entry.plugin_ref.id();
let on_error = entry.plugin_ref.trusted_config().on_error;
let mut ctx = ctx_table.take_context(plugin_id);
let capabilities: std::collections::HashSet<String> = entry
.plugin_ref
.trusted_config()
.capabilities
.iter()
.cloned()
.collect();
let mut filtered = filter_extensions(extensions, &capabilities);
if capabilities.contains("write_headers") {
filtered.http_write_token = Some(WriteToken::new());
}
if capabilities.contains("append_labels") {
filtered.labels_write_token = Some(WriteToken::new());
}
if capabilities.contains("append_delegation") {
filtered.delegation_write_token = Some(WriteToken::new());
}
let timeout_dur = Duration::from_secs(self.config.timeout_seconds);
let result = timeout(
timeout_dur,
entry.handler.invoke(&**payload, &filtered, &mut ctx),
)
.await;
match result {
Ok(Ok(result_box)) => {
if let Some(erased) = extract_erased(result_box) {
if !erased.continue_processing && can_block {
if let Some(mut v) = erased.violation {
v.plugin_name = Some(plugin_name.to_string());
return Some(v);
}
}
if can_modify {
if let Some(mp) = erased.modified_payload {
*payload = mp;
}
if let Some(owned) = erased.modified_extensions {
let valid = extensions.validate_immutable(&owned);
if !valid {
warn!(
"{} plugin '{}' violated immutable tier — \
modified an immutable extension slot. \
Extension changes rejected.",
phase_label, plugin_name
);
} else if capabilities.contains("read_labels") {
if let (Some(ref orig_sec), Some(ref new_sec)) =
(&extensions.security, &owned.security)
{
if !new_sec.labels.is_superset(&orig_sec.labels) {
warn!(
"{} plugin '{}' violated monotonic tier — \
removed a security label. \
Extension changes rejected.",
phase_label, plugin_name
);
} else {
extensions.merge_owned(owned);
}
} else {
extensions.merge_owned(owned);
}
} else {
extensions.merge_owned(owned);
}
}
}
}
},
Ok(Err(e)) => {
error!("{} plugin '{}' failed: {}", phase_label, plugin_name, e);
match on_error {
OnError::Fail if can_block => {
let mut v = crate::error::PluginViolation::new(
"plugin_error",
format!("Plugin '{}' failed: {}", plugin_name, e),
);
v.plugin_name = Some(plugin_name.to_string());
return Some(v);
},
OnError::Fail => {
warn!(
"{} plugin '{}' on_error=fail in non-blocking phase — not halting",
phase_label, plugin_name,
);
errors.push((&e).into());
},
OnError::Ignore => {
errors.push((&e).into());
},
OnError::Disable => {
warn!(
"{} plugin '{}' disabled after error",
phase_label, plugin_name
);
errors.push((&e).into());
entry.plugin_ref.disable();
},
}
},
Err(_) => {
error!("{} plugin '{}' timed out", phase_label, plugin_name);
let timeout_err = crate::error::PluginError::Timeout {
plugin_name: plugin_name.to_string(),
timeout_ms: timeout_dur.as_millis() as u64,
proto_error_code: None,
};
match on_error {
OnError::Fail if can_block => {
let mut v = crate::error::PluginViolation::new(
"plugin_timeout",
format!("Plugin '{}' timed out", plugin_name),
);
v.plugin_name = Some(plugin_name.to_string());
return Some(v);
},
OnError::Fail => {
warn!(
"{} plugin '{}' on_error=fail (timeout) in non-blocking phase — not halting",
phase_label, plugin_name,
);
errors.push((&timeout_err).into());
},
OnError::Ignore => {
errors.push((&timeout_err).into());
},
OnError::Disable => {
warn!(
"{} plugin '{}' disabled after timeout",
phase_label, plugin_name
);
errors.push((&timeout_err).into());
entry.plugin_ref.disable();
},
}
},
}
ctx_table.store_context(plugin_id, ctx);
}
None }
async fn run_ref_phase(
&self,
entries: &[HookEntry],
payload: &dyn PluginPayload,
extensions: &Extensions,
ctx_table: &PluginContextTable,
phase_label: &str,
errors: &mut Vec<crate::error::PluginErrorRecord>,
) {
for entry in entries {
let plugin_name = entry.plugin_ref.name().to_string();
let plugin_id = entry.plugin_ref.id();
let on_error = entry.plugin_ref.trusted_config().on_error;
let mut ctx = ctx_table.snapshot_context(plugin_id);
let capabilities: std::collections::HashSet<String> = entry
.plugin_ref
.trusted_config()
.capabilities
.iter()
.cloned()
.collect();
let filtered = filter_extensions(extensions, &capabilities);
let timeout_dur = Duration::from_secs(self.config.timeout_seconds);
let result = timeout(
timeout_dur,
entry.handler.invoke(payload, &filtered, &mut ctx),
)
.await;
match result {
Ok(Ok(_)) => {}, Ok(Err(e)) => {
warn!(
"{} plugin '{}' error (ignored): {}",
phase_label, plugin_name, e
);
errors.push((&e).into());
if matches!(on_error, OnError::Disable) {
warn!(
"{} plugin '{}' disabled after error",
phase_label, plugin_name
);
entry.plugin_ref.disable();
}
},
Err(_) => {
warn!(
"{} plugin '{}' timed out (ignored)",
phase_label, plugin_name
);
let timeout_err = crate::error::PluginError::Timeout {
plugin_name: plugin_name.clone(),
timeout_ms: timeout_dur.as_millis() as u64,
proto_error_code: None,
};
errors.push((&timeout_err).into());
if matches!(on_error, OnError::Disable) {
warn!(
"{} plugin '{}' disabled after timeout",
phase_label, plugin_name
);
entry.plugin_ref.disable();
}
},
}
}
}
async fn run_concurrent_phase(
&self,
entries: &[HookEntry],
payload: &dyn PluginPayload,
extensions: &Extensions,
ctx_table: &PluginContextTable,
errors: &mut Vec<crate::error::PluginErrorRecord>,
) -> Option<crate::error::PluginViolation> {
use cpex_orchestration::{run_branches, BranchConfig, BranchOutcome, ErasedBranch};
if entries.is_empty() {
return None;
}
enum BranchData {
Allow,
Deny(Option<crate::error::PluginViolation>),
Error(Box<PluginError>),
}
let shared_payload: Arc<Box<dyn PluginPayload>> = Arc::new(payload.clone_boxed());
let timeout_dur = Duration::from_secs(self.config.timeout_seconds);
let on_error_by_idx: Vec<OnError> = entries
.iter()
.map(|e| e.plugin_ref.trusted_config().on_error)
.collect();
let mut branches: Vec<ErasedBranch<BranchData>> = Vec::with_capacity(entries.len());
for entry in entries.iter() {
let handler = Arc::clone(&entry.handler);
let payload_clone = Arc::clone(&shared_payload);
let plugin_id = entry.plugin_ref.id();
let mut ctx = ctx_table.snapshot_context(plugin_id);
let plugin_name = entry.plugin_ref.name().to_string();
let capabilities: std::collections::HashSet<String> = entry
.plugin_ref
.trusted_config()
.capabilities
.iter()
.cloned()
.collect();
let filtered = Arc::new(filter_extensions(extensions, &capabilities));
branches.push(Box::pin(async move {
match handler.invoke(&**payload_clone, &filtered, &mut ctx).await {
Ok(result_box) => match extract_erased(result_box) {
Some(erased) if !erased.continue_processing => {
let violation = erased.violation.map(|mut v| {
v.plugin_name = Some(plugin_name);
v
});
BranchData::Deny(violation)
},
_ => BranchData::Allow,
},
Err(e) => BranchData::Error(e),
}
}));
}
let cfg = BranchConfig {
timeout_per_branch: Some(timeout_dur),
short_circuit_on_deny: self.config.short_circuit_on_deny,
};
let outcomes = run_branches(branches, cfg, |v: &BranchData| {
matches!(v, BranchData::Deny(_))
})
.await;
let mut first_violation: Option<crate::error::PluginViolation> = None;
for (idx, outcome) in outcomes.into_iter().enumerate() {
let entry = &entries[idx];
let plugin_name = entry.plugin_ref.name();
let on_error = on_error_by_idx[idx];
match outcome {
BranchOutcome::Completed(BranchData::Allow) => {},
BranchOutcome::Completed(BranchData::Deny(opt_v)) => {
let violation = opt_v.unwrap_or_else(|| {
let mut v = crate::error::PluginViolation::new(
"concurrent_deny",
format!("Plugin '{}' denied", plugin_name),
);
v.plugin_name = Some(plugin_name.to_string());
v
});
if first_violation.is_none() {
first_violation = Some(violation);
}
},
BranchOutcome::Completed(BranchData::Error(e)) => match on_error {
OnError::Fail => {
if first_violation.is_none() {
let mut v = crate::error::PluginViolation::new(
"plugin_error",
format!("Plugin '{}' failed: {}", plugin_name, e),
);
v.plugin_name = Some(plugin_name.to_string());
first_violation = Some(v);
}
},
OnError::Ignore => {
warn!("CONCURRENT plugin '{}' error (ignored): {}", plugin_name, e);
errors.push((&*e).into());
},
OnError::Disable => {
warn!("CONCURRENT plugin '{}' disabled after error", plugin_name);
errors.push((&*e).into());
entry.plugin_ref.disable();
},
},
BranchOutcome::TimedOut => {
let timeout_err = crate::error::PluginError::Timeout {
plugin_name: plugin_name.to_string(),
timeout_ms: timeout_dur.as_millis() as u64,
proto_error_code: None,
};
match on_error {
OnError::Fail => {
if first_violation.is_none() {
let mut v = crate::error::PluginViolation::new(
"plugin_timeout",
format!("Plugin '{}' timed out", plugin_name),
);
v.plugin_name = Some(plugin_name.to_string());
first_violation = Some(v);
}
},
OnError::Ignore => {
warn!("CONCURRENT plugin '{}' timed out (ignored)", plugin_name);
errors.push((&timeout_err).into());
},
OnError::Disable => {
warn!("CONCURRENT plugin '{}' disabled after timeout", plugin_name);
errors.push((&timeout_err).into());
entry.plugin_ref.disable();
},
}
},
BranchOutcome::Panicked(s) => {
error!("CONCURRENT plugin '{}' task panicked: {}", plugin_name, s);
let panic_err = crate::error::PluginError::Execution {
plugin_name: plugin_name.to_string(),
message: format!("task panicked: {}", s),
source: None,
code: Some("panic".into()),
details: std::collections::HashMap::new(),
proto_error_code: None,
};
match on_error {
OnError::Fail => {
if first_violation.is_none() {
let mut v = crate::error::PluginViolation::new(
"plugin_panic",
format!("Plugin '{}' task panicked: {}", plugin_name, s),
);
v.plugin_name = Some(plugin_name.to_string());
first_violation = Some(v);
}
},
OnError::Ignore => {
warn!("CONCURRENT plugin '{}' panicked (ignored)", plugin_name);
errors.push((&panic_err).into());
},
OnError::Disable => {
warn!("CONCURRENT plugin '{}' disabled after panic", plugin_name);
errors.push((&panic_err).into());
entry.plugin_ref.disable();
},
}
},
BranchOutcome::Aborted => {
},
}
}
first_violation
}
fn spawn_fire_and_forget(
&self,
entries: &[HookEntry],
payload: &dyn PluginPayload,
extensions: &Extensions,
ctx_table: &PluginContextTable,
task_tracker: &tokio_util::task::TaskTracker,
) -> Vec<(String, tokio::task::JoinHandle<()>)> {
if entries.is_empty() {
return Vec::new();
}
let timeout_dur = Duration::from_secs(self.config.timeout_seconds);
let mut handles = Vec::with_capacity(entries.len());
for entry in entries {
let plugin_name = entry.plugin_ref.name().to_string();
let handler = Arc::clone(&entry.handler);
let owned_payload = payload.clone_boxed();
let mut ctx = ctx_table.snapshot_context(entry.plugin_ref.id());
let dur = timeout_dur;
let name_for_log = plugin_name.clone();
let capabilities: std::collections::HashSet<String> = entry
.plugin_ref
.trusted_config()
.capabilities
.iter()
.cloned()
.collect();
let filtered = Arc::new(filter_extensions(extensions, &capabilities));
let handle = task_tracker.spawn(async move {
let result =
timeout(dur, handler.invoke(&*owned_payload, &filtered, &mut ctx)).await;
match result {
Ok(Ok(_)) => {}, Ok(Err(e)) => {
warn!(
"FIRE_AND_FORGET plugin '{}' error (ignored): {}",
name_for_log, e
);
},
Err(_) => {
warn!(
"FIRE_AND_FORGET plugin '{}' timed out (ignored)",
name_for_log
);
},
}
});
handles.push((plugin_name, handle));
}
handles
}
}
impl Default for Executor {
fn default() -> Self {
Self::new(ExecutorConfig::default())
}
}
pub struct ErasedResultFields {
pub continue_processing: bool,
pub modified_payload: Option<Box<dyn PluginPayload>>,
pub modified_extensions: Option<crate::hooks::payload::OwnedExtensions>,
pub violation: Option<crate::error::PluginViolation>,
}
pub fn extract_erased(result: Box<dyn Any + Send + Sync>) -> Option<ErasedResultFields> {
match result.downcast::<ErasedResultFields>() {
Ok(b) => Some(*b),
Err(_) => {
warn!("extract_erased: downcast failed — handler returned unexpected type");
None
},
}
}
pub fn erase_result<P: crate::hooks::PluginPayload>(
result: crate::hooks::PluginResult<P>,
) -> Box<dyn Any + Send + Sync> {
Box::new(ErasedResultFields {
continue_processing: result.continue_processing,
modified_payload: result
.modified_payload
.map(|p| Box::new(p) as Box<dyn PluginPayload>),
modified_extensions: result.modified_extensions,
violation: result.violation,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hooks::payload::PluginPayload;
use crate::hooks::PluginResult;
#[derive(Debug, Clone)]
#[allow(dead_code)] struct TestPayload {
value: String,
}
crate::impl_plugin_payload!(TestPayload);
#[test]
fn test_erase_result_allow() {
let result: PluginResult<TestPayload> = PluginResult::allow();
let erased = erase_result(result);
let fields = extract_erased(erased).unwrap();
assert!(fields.continue_processing);
assert!(fields.violation.is_none());
assert!(fields.modified_payload.is_none());
}
#[test]
fn test_erase_result_deny() {
let result: PluginResult<TestPayload> =
PluginResult::deny(crate::error::PluginViolation::new("test", "denied"));
let erased = erase_result(result);
let fields = extract_erased(erased).unwrap();
assert!(!fields.continue_processing);
assert_eq!(fields.violation.as_ref().unwrap().code, "test");
}
#[test]
fn test_erase_result_modify_payload() {
let result: PluginResult<TestPayload> = PluginResult::modify_payload(TestPayload {
value: "modified".into(),
});
let erased = erase_result(result);
let fields = extract_erased(erased).unwrap();
assert!(fields.continue_processing);
assert!(fields.modified_payload.is_some());
}
#[test]
fn test_erase_result_modify_extensions() {
let mut security = crate::extensions::SecurityExtension::default();
security.add_label("PII");
let ext = Extensions {
security: Some(Arc::new(security)),
..Default::default()
};
let owned = ext.cow_copy();
let result: PluginResult<TestPayload> = PluginResult::modify_extensions(owned);
let erased = erase_result(result);
let fields = extract_erased(erased).unwrap();
assert!(fields.continue_processing);
assert!(fields.modified_extensions.is_some());
let sec = fields
.modified_extensions
.as_ref()
.unwrap()
.security
.as_ref()
.unwrap();
assert!(sec.has_label("PII"));
}
#[test]
fn test_pipeline_result_allowed() {
let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
value: "test".into(),
});
let result =
PipelineResult::allowed_with(payload, Extensions::default(), PluginContextTable::new());
assert!(result.continue_processing);
assert!(result.modified_payload.is_some());
assert!(result.violation.is_none());
}
#[test]
fn test_pipeline_result_denied() {
let violation = crate::error::PluginViolation::new("test", "denied");
let result =
PipelineResult::denied(violation, Extensions::default(), PluginContextTable::new());
assert!(!result.continue_processing);
assert!(result.modified_payload.is_none());
assert!(result.violation.is_some());
}
#[tokio::test]
async fn test_executor_empty_entries() {
let executor = Executor::default();
let tracker = tokio_util::task::TaskTracker::new();
let payload: Box<dyn PluginPayload> = Box::new(TestPayload {
value: "test".into(),
});
let (result, _) = executor
.execute(&[], payload, Extensions::default(), None, &tracker)
.await;
assert!(result.continue_processing);
assert!(result.modified_payload.is_some());
}
}