1use async_trait::async_trait;
13use serde::Serialize;
14
15mod context;
16mod execution;
17mod limits;
18mod metadata;
19mod recorder;
20mod registry;
21mod transport;
22
23pub use context::{PluginContext, PluginExecutionRequest, PluginExecutionResult, PluginUsage};
24pub use limits::{PluginBudget, PluginConfig};
25pub use metadata::PluginMetadata;
26pub use recorder::{PluginObservation, PluginRedactionPolicy, SecretRedactionPolicy};
27pub use registry::PluginRegistry;
28pub use transport::{PluginHttpMethod, PluginHttpRequest, PluginHttpResponse, PluginRequestBroker};
29
30#[cfg(test)]
31use limits::{
32 invalid_config, HARD_MAX_PLUGIN_OBSERVATION_BYTES, HARD_MAX_PLUGIN_TEXT_LIST_ITEMS,
33 MAX_PLUGIN_REDACTION_LITERAL_COUNT, MAX_PLUGIN_TEXT_BYTES, MAX_PLUGIN_URL_BYTES,
34};
35#[cfg(test)]
36use recorder::sanitize_error;
37#[cfg(test)]
38use std::{
39 sync::{Arc, Mutex},
40 time::{Duration, UNIX_EPOCH},
41};
42#[cfg(test)]
43use termivar_core::{EntityId, EvidenceKind, EvidenceValue, KnowledgePredicate};
44#[cfg(test)]
45use tokio_util::sync::CancellationToken;
46#[cfg(test)]
47use url::Url;
48
49pub const PLUGIN_API_VERSION: &str = "0.2.0";
55
56#[async_trait]
62pub trait Plugin: Send + Sync {
63 fn api_version(&self) -> &str {
65 PLUGIN_API_VERSION
66 }
67
68 fn id(&self) -> &str;
70
71 fn name(&self) -> &str;
73
74 fn version(&self) -> &str;
76
77 fn description(&self) -> &str;
79
80 fn author(&self) -> &str;
82
83 fn category(&self) -> PluginCategory;
85
86 fn validate(&self) -> Result<(), PluginError> {
88 Ok(())
89 }
90
91 async fn execute(&self, context: &PluginContext) -> Result<(), PluginError>;
93}
94
95#[non_exhaustive]
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
98#[serde(rename_all = "snake_case")]
99pub enum PluginCategory {
100 XSS,
102 SQLi,
104 LFI,
106 XXE,
108 SSRF,
110 SSTI,
112 RCE,
114 Custom,
116}
117
118impl PluginCategory {
119 pub const fn as_str(self) -> &'static str {
121 match self {
122 Self::XSS => "xss",
123 Self::SQLi => "sqli",
124 Self::LFI => "lfi",
125 Self::XXE => "xxe",
126 Self::SSRF => "ssrf",
127 Self::SSTI => "ssti",
128 Self::RCE => "rce",
129 Self::Custom => "custom",
130 }
131 }
132}
133
134#[non_exhaustive]
136#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
137pub enum PluginError {
138 #[error("plugin identity is not registered")]
140 NotFound,
141 #[error("plugin identity is already registered")]
143 DuplicateId,
144 #[error("plugin identity has an invocation in flight")]
146 InUse,
147 #[error("invalid plugin configuration: {0}")]
149 InvalidConfig(String),
150 #[error("incompatible plugin API version: expected {expected}, received {actual}")]
152 IncompatibleApiVersion {
153 expected: String,
155 actual: String,
157 },
158 #[error("plugin is disabled by host policy")]
160 Disabled,
161 #[error("plugin invocation was cancelled")]
163 Cancelled,
164 #[error("plugin invocation exhausted its wall-clock budget")]
166 WallTimeExceeded,
167 #[error("plugin request exhausted its timeout budget")]
169 RequestTimeout,
170 #[error("plugin request was abandoned before a broker receipt")]
172 RequestAbandoned,
173 #[error("plugin input uses {actual} bytes; maximum is {maximum}")]
175 InputBudgetExceeded {
176 actual: usize,
178 maximum: usize,
180 },
181 #[error("plugin request budget is exhausted")]
183 RequestBudgetExceeded,
184 #[error("plugin response delivered {actual} bytes; maximum is {maximum}")]
186 ResponseBodyBudgetExceeded {
187 actual: u64,
189 maximum: u64,
191 },
192 #[error("plugin response body budget grants no capture authority")]
194 ResponseBodyBudgetUnavailable,
195 #[error("plugin cumulative response body budget is exhausted")]
197 CumulativeBodyBudgetExceeded,
198 #[error("plugin observation count budget is exhausted")]
200 ObservationBudgetExceeded,
201 #[error("plugin observation byte budget is exhausted")]
203 ObservationBytesBudgetExceeded,
204 #[error("plugin request is outside the authorized origin")]
206 ScopeViolation,
207 #[error("host plugin request broker failed: {0}")]
209 BrokerFailure(String),
210 #[error("plugin execution failed: {0}")]
212 ExecutionFailed(String),
213 #[error("plugin code panicked at the host boundary")]
215 Panicked,
216 #[error("host plugin policy callback panicked")]
218 HostCallbackPanicked,
219 #[error("plugin context is sealed")]
221 ContextSealed,
222 #[error("system clock is earlier than the Unix epoch")]
224 ClockBeforeUnixEpoch,
225 #[error("plugin host state is unavailable")]
227 HostStateUnavailable,
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use std::sync::{
234 atomic::{AtomicUsize, Ordering},
235 Barrier,
236 };
237
238 struct StaticBroker {
239 calls: AtomicUsize,
240 response: Mutex<Option<Result<PluginHttpResponse, PluginError>>>,
241 delay: Duration,
242 }
243
244 impl StaticBroker {
245 fn success(origin: &Url, body: &[u8]) -> Arc<Self> {
246 Arc::new(Self {
247 calls: AtomicUsize::new(0),
248 response: Mutex::new(Some(Ok(PluginHttpResponse::new(
249 200,
250 origin.clone(),
251 body.to_vec(),
252 )
253 .expect("valid response")))),
254 delay: Duration::ZERO,
255 })
256 }
257
258 fn calls(&self) -> usize {
259 self.calls.load(Ordering::SeqCst)
260 }
261 }
262
263 #[async_trait]
264 impl PluginRequestBroker for StaticBroker {
265 async fn execute(
266 &self,
267 _request: PluginHttpRequest,
268 ) -> Result<PluginHttpResponse, PluginError> {
269 self.calls.fetch_add(1, Ordering::SeqCst);
270 tokio::time::sleep(self.delay).await;
271 self.response
272 .lock()
273 .map_err(|_| PluginError::HostStateUnavailable)?
274 .take()
275 .unwrap_or_else(|| Err(PluginError::BrokerFailure("no response".to_owned())))
276 }
277 }
278
279 #[derive(Clone, Copy)]
280 enum Behavior {
281 Record,
282 RecordThenPending,
283 RecordThenPanic,
284 Request,
285 ErrorAfterRecord,
286 ErrorOnly,
287 LongSecretError,
288 IncompatibleError,
289 Pending,
290 Empty,
291 }
292
293 struct TestPlugin {
294 id: String,
295 api: String,
296 calls: Arc<AtomicUsize>,
297 behavior: Behavior,
298 }
299
300 #[async_trait]
301 impl Plugin for TestPlugin {
302 fn api_version(&self) -> &str {
303 &self.api
304 }
305
306 fn id(&self) -> &str {
307 &self.id
308 }
309
310 fn name(&self) -> &str {
311 "Trait Boundary Fixture"
312 }
313
314 fn version(&self) -> &str {
315 "0.1.0"
316 }
317
318 fn description(&self) -> &str {
319 "Records an informational observation for contract tests"
320 }
321
322 fn author(&self) -> &str {
323 "Termivar tests"
324 }
325
326 fn category(&self) -> PluginCategory {
327 PluginCategory::Custom
328 }
329
330 async fn execute(&self, context: &PluginContext) -> Result<(), PluginError> {
331 self.calls.fetch_add(1, Ordering::SeqCst);
332 match self.behavior {
333 Behavior::Record
334 | Behavior::RecordThenPending
335 | Behavior::RecordThenPanic
336 | Behavior::ErrorAfterRecord => {
337 context.record(observation(EvidenceValue::Text(
338 String::from_utf8_lossy(context.input()).into_owned(),
339 )))?;
340 if matches!(self.behavior, Behavior::ErrorAfterRecord) {
341 return Err(PluginError::ExecutionFailed(
342 "token=fixture-secret".to_owned(),
343 ));
344 }
345 if matches!(self.behavior, Behavior::RecordThenPending) {
346 std::future::pending::<()>().await;
347 }
348 if matches!(self.behavior, Behavior::RecordThenPanic) {
349 panic!("plugin fixture panic after staged evidence");
350 }
351 },
352 Behavior::ErrorOnly => {
353 return Err(PluginError::ExecutionFailed(
354 "plugin error for host sanitization".to_owned(),
355 ));
356 },
357 Behavior::LongSecretError => {
358 return Err(PluginError::ExecutionFailed(
359 "s".repeat(MAX_PLUGIN_TEXT_BYTES + 1),
360 ));
361 },
362 Behavior::IncompatibleError => {
363 return Err(PluginError::IncompatibleApiVersion {
364 expected: format!(
365 "token=plugin-secret{}",
366 "x".repeat(MAX_PLUGIN_TEXT_BYTES * 4)
367 ),
368 actual: "token=plugin-actual-secret".to_owned(),
369 });
370 },
371 Behavior::Request => {
372 let url = context
373 .authorized_origin()
374 .join("fixture")
375 .map_err(|_| invalid_config("fixture URL"))?;
376 let response = context.request(PluginHttpMethod::Get, url).await?;
377 context.record(observation(EvidenceValue::Unsigned(u64::from(
378 response.status(),
379 ))))?;
380 },
381 Behavior::Pending => std::future::pending::<()>().await,
382 Behavior::Empty => {},
383 }
384 Ok(())
385 }
386 }
387
388 fn plugin(id: &str, behavior: Behavior) -> (Arc<TestPlugin>, Arc<AtomicUsize>) {
389 let calls = Arc::new(AtomicUsize::new(0));
390 (
391 Arc::new(TestPlugin {
392 id: id.to_owned(),
393 api: PLUGIN_API_VERSION.to_owned(),
394 calls: calls.clone(),
395 behavior,
396 }),
397 calls,
398 )
399 }
400
401 fn origin() -> Url {
402 Url::parse("https://example.test/").expect("valid origin")
403 }
404
405 fn request(broker: Arc<dyn PluginRequestBroker>) -> PluginExecutionRequest {
406 PluginExecutionRequest::new(
407 EntityId::new("authorized-origin:test").expect("valid subject"),
408 origin(),
409 "case:plugin:test",
410 broker,
411 )
412 .expect("valid request")
413 }
414
415 fn observation(value: EvidenceValue) -> PluginObservation {
416 PluginObservation::new(
417 EvidenceKind::Custom("plugin.fixture".to_owned()),
418 KnowledgePredicate::new("plugin.fixture", "marker").expect("valid predicate"),
419 value,
420 "trait-boundary",
421 )
422 .expect("valid observation")
423 }
424
425 #[test]
426 fn api_line_and_clock_fail_closed() {
427 let registry = PluginRegistry::new();
428 let calls = Arc::new(AtomicUsize::new(0));
429 let incompatible = Arc::new(TestPlugin {
430 id: "old-api".to_owned(),
431 api: "0.1.9".to_owned(),
432 calls,
433 behavior: Behavior::Empty,
434 });
435 assert!(matches!(
436 registry.register(incompatible, PluginConfig::default()),
437 Err(PluginError::IncompatibleApiVersion { .. })
438 ));
439
440 let (before_epoch, _) = plugin("clock", Behavior::Empty);
441 assert_eq!(
442 registry.register_at(
443 before_epoch,
444 PluginConfig::default(),
445 UNIX_EPOCH - Duration::from_secs(1),
446 ),
447 Err(PluginError::ClockBeforeUnixEpoch)
448 );
449 assert_eq!(registry.count(), 0);
450
451 struct PanickingDescriptor;
452 #[async_trait]
453 impl Plugin for PanickingDescriptor {
454 fn id(&self) -> &str {
455 panic!("descriptor panic")
456 }
457 fn name(&self) -> &str {
458 "Panicking Fixture"
459 }
460 fn version(&self) -> &str {
461 "0.1.0"
462 }
463 fn description(&self) -> &str {
464 "Exercises registration panic isolation"
465 }
466 fn author(&self) -> &str {
467 "Termivar tests"
468 }
469 fn category(&self) -> PluginCategory {
470 PluginCategory::Custom
471 }
472 async fn execute(&self, _context: &PluginContext) -> Result<(), PluginError> {
473 Ok(())
474 }
475 }
476 assert_eq!(
477 registry.register(Arc::new(PanickingDescriptor), PluginConfig::default()),
478 Err(PluginError::Panicked)
479 );
480 assert_eq!(registry.count(), 0);
481
482 struct PanickingValidation;
483 #[async_trait]
484 impl Plugin for PanickingValidation {
485 fn id(&self) -> &str {
486 "panicking-validation"
487 }
488 fn name(&self) -> &str {
489 "Panicking Validation Fixture"
490 }
491 fn version(&self) -> &str {
492 "0.1.0"
493 }
494 fn description(&self) -> &str {
495 "Exercises validation panic isolation"
496 }
497 fn author(&self) -> &str {
498 "Termivar tests"
499 }
500 fn category(&self) -> PluginCategory {
501 PluginCategory::Custom
502 }
503 fn validate(&self) -> Result<(), PluginError> {
504 panic!("validation panic")
505 }
506 async fn execute(&self, _context: &PluginContext) -> Result<(), PluginError> {
507 Ok(())
508 }
509 }
510 assert_eq!(
511 registry.register(Arc::new(PanickingValidation), PluginConfig::default()),
512 Err(PluginError::Panicked)
513 );
514 assert_eq!(registry.count(), 0);
515 }
516
517 #[test]
518 fn registration_snapshots_each_descriptor_field_once() {
519 struct FlappingDescriptor {
520 api_calls: AtomicUsize,
521 id_calls: AtomicUsize,
522 name_calls: AtomicUsize,
523 version_calls: AtomicUsize,
524 description_calls: AtomicUsize,
525 author_calls: AtomicUsize,
526 category_calls: AtomicUsize,
527 validate_calls: AtomicUsize,
528 }
529
530 #[async_trait]
531 impl Plugin for FlappingDescriptor {
532 fn api_version(&self) -> &str {
533 if self.api_calls.fetch_add(1, Ordering::SeqCst) == 0 {
534 PLUGIN_API_VERSION
535 } else {
536 "0.1.0"
537 }
538 }
539
540 fn id(&self) -> &str {
541 if self.id_calls.fetch_add(1, Ordering::SeqCst) == 0 {
542 "flapping-descriptor"
543 } else {
544 ""
545 }
546 }
547
548 fn name(&self) -> &str {
549 if self.name_calls.fetch_add(1, Ordering::SeqCst) == 0 {
550 "Flapping Descriptor"
551 } else {
552 ""
553 }
554 }
555
556 fn version(&self) -> &str {
557 if self.version_calls.fetch_add(1, Ordering::SeqCst) == 0 {
558 "1.0.0"
559 } else {
560 ""
561 }
562 }
563
564 fn description(&self) -> &str {
565 if self.description_calls.fetch_add(1, Ordering::SeqCst) == 0 {
566 "Proves one-shot descriptor capture"
567 } else {
568 ""
569 }
570 }
571
572 fn author(&self) -> &str {
573 if self.author_calls.fetch_add(1, Ordering::SeqCst) == 0 {
574 "Termivar tests"
575 } else {
576 ""
577 }
578 }
579
580 fn category(&self) -> PluginCategory {
581 if self.category_calls.fetch_add(1, Ordering::SeqCst) == 0 {
582 PluginCategory::Custom
583 } else {
584 PluginCategory::RCE
585 }
586 }
587
588 fn validate(&self) -> Result<(), PluginError> {
589 self.validate_calls.fetch_add(1, Ordering::SeqCst);
590 Ok(())
591 }
592
593 async fn execute(&self, _context: &PluginContext) -> Result<(), PluginError> {
594 Ok(())
595 }
596 }
597
598 let plugin = Arc::new(FlappingDescriptor {
599 api_calls: AtomicUsize::new(0),
600 id_calls: AtomicUsize::new(0),
601 name_calls: AtomicUsize::new(0),
602 version_calls: AtomicUsize::new(0),
603 description_calls: AtomicUsize::new(0),
604 author_calls: AtomicUsize::new(0),
605 category_calls: AtomicUsize::new(0),
606 validate_calls: AtomicUsize::new(0),
607 });
608 let registry = PluginRegistry::new();
609 registry
610 .register(plugin.clone(), PluginConfig::default())
611 .expect("the first descriptor snapshot is valid");
612
613 let metadata = registry
614 .get_metadata("flapping-descriptor")
615 .expect("snapshotted descriptor is registered");
616 assert_eq!(metadata.api_version(), PLUGIN_API_VERSION);
617 assert_eq!(metadata.name(), "Flapping Descriptor");
618 assert_eq!(metadata.category(), PluginCategory::Custom);
619 for calls in [
620 &plugin.api_calls,
621 &plugin.id_calls,
622 &plugin.name_calls,
623 &plugin.version_calls,
624 &plugin.description_calls,
625 &plugin.author_calls,
626 &plugin.category_calls,
627 &plugin.validate_calls,
628 ] {
629 assert_eq!(calls.load(Ordering::SeqCst), 1);
630 }
631 }
632
633 #[test]
634 fn duplicate_registration_is_atomic_under_concurrency() {
635 let registry = Arc::new(PluginRegistry::new());
636 let barrier = Arc::new(Barrier::new(3));
637 let mut workers = Vec::new();
638 for _ in 0..2 {
639 let registry = registry.clone();
640 let barrier = barrier.clone();
641 workers.push(std::thread::spawn(move || {
642 let (candidate, _) = plugin("duplicate", Behavior::Empty);
643 barrier.wait();
644 registry.register(candidate, PluginConfig::default())
645 }));
646 }
647 barrier.wait();
648 let results: Vec<_> = workers
649 .into_iter()
650 .map(|worker| worker.join().expect("worker did not panic"))
651 .collect();
652 assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
653 assert_eq!(
654 results
655 .iter()
656 .filter(|result| matches!(result, Err(PluginError::DuplicateId)))
657 .count(),
658 1
659 );
660 assert_eq!(registry.count(), 1);
661 assert_eq!(registry.list_all().len(), 1);
662 }
663
664 #[tokio::test]
665 async fn disabled_plugin_is_never_polled_and_metadata_stays_consistent() {
666 let registry = PluginRegistry::new();
667 let (candidate, calls) = plugin("disabled", Behavior::Record);
668 registry
669 .register(candidate, PluginConfig::new(false))
670 .expect("registration succeeds");
671 let broker = StaticBroker::success(&origin(), b"");
672 assert_eq!(
673 registry.execute("disabled", request(broker)).await,
674 Err(PluginError::Disabled)
675 );
676 assert_eq!(calls.load(Ordering::SeqCst), 0);
677 let metadata = registry.get_metadata("disabled").expect("metadata");
678 assert!(!metadata.enabled());
679 assert_eq!(metadata.execution_count(), 0);
680 assert_eq!(metadata.success_count(), 0);
681 assert_eq!(metadata.error_count(), 0);
682 }
683
684 #[tokio::test]
685 async fn active_invocation_leases_prevent_unregister_reregister_aba() {
686 struct HoldingPlugin {
687 entered: Arc<tokio::sync::Notify>,
688 release: Arc<tokio::sync::Notify>,
689 }
690 #[async_trait]
691 impl Plugin for HoldingPlugin {
692 fn id(&self) -> &str {
693 "leased"
694 }
695 fn name(&self) -> &str {
696 "Invocation Lease Fixture"
697 }
698 fn version(&self) -> &str {
699 "0.1.0"
700 }
701 fn description(&self) -> &str {
702 "Holds one invocation while registry mutation is attempted"
703 }
704 fn author(&self) -> &str {
705 "Termivar tests"
706 }
707 fn category(&self) -> PluginCategory {
708 PluginCategory::Custom
709 }
710 async fn execute(&self, context: &PluginContext) -> Result<(), PluginError> {
711 self.entered.notify_one();
712 self.release.notified().await;
713 context.record(observation(EvidenceValue::Text(
714 String::from_utf8_lossy(context.input()).into_owned(),
715 )))
716 }
717 }
718
719 let registry = Arc::new(PluginRegistry::new());
720 let entered = Arc::new(tokio::sync::Notify::new());
721 let release = Arc::new(tokio::sync::Notify::new());
722 registry
723 .register(
724 Arc::new(HoldingPlugin {
725 entered: entered.clone(),
726 release: release.clone(),
727 }),
728 PluginConfig::default(),
729 )
730 .expect("registration succeeds");
731 let invocation_registry = registry.clone();
732 let invocation = tokio::spawn(async move {
733 invocation_registry
734 .execute(
735 "leased",
736 request(StaticBroker::success(&origin(), b""))
737 .with_input(b"original-entry".to_vec())
738 .expect("input"),
739 )
740 .await
741 });
742 entered.notified().await;
743
744 assert_eq!(registry.unregister("leased"), Err(PluginError::InUse));
745 let (replacement, replacement_calls) = plugin("leased", Behavior::Record);
746 assert_eq!(
747 registry.register(replacement, PluginConfig::default()),
748 Err(PluginError::DuplicateId)
749 );
750 assert_eq!(replacement_calls.load(Ordering::SeqCst), 0);
751 let active_metadata = registry.get_metadata("leased").expect("metadata");
752 assert_eq!(active_metadata.execution_count(), 1);
753 assert_eq!(active_metadata.success_count(), 0);
754 assert_eq!(active_metadata.error_count(), 0);
755
756 release.notify_one();
757 let result = invocation
758 .await
759 .expect("invocation task did not panic")
760 .expect("original invocation succeeds");
761 assert_eq!(result.plugin_id(), "leased");
762 let serialized = serde_json::to_string(&result).expect("result serializes");
763 assert!(serialized.contains("original-entry"));
764 let completed_metadata = registry.get_metadata("leased").expect("metadata");
765 assert_eq!(completed_metadata.execution_count(), 1);
766 assert_eq!(completed_metadata.success_count(), 1);
767 assert_eq!(completed_metadata.error_count(), 0);
768
769 registry.unregister("leased").expect("lease released");
770 let (replacement, replacement_calls) = plugin("leased", Behavior::Record);
771 registry
772 .register(replacement, PluginConfig::default())
773 .expect("same ID can be registered only after the invocation drains");
774 let result = registry
775 .execute(
776 "leased",
777 request(StaticBroker::success(&origin(), b""))
778 .with_input(b"replacement-entry".to_vec())
779 .expect("input"),
780 )
781 .await
782 .expect("replacement invocation succeeds");
783 assert_eq!(replacement_calls.load(Ordering::SeqCst), 1);
784 let serialized = serde_json::to_string(&result).expect("result serializes");
785 assert!(serialized.contains("replacement-entry"));
786 assert!(!serialized.contains("original-entry"));
787 }
788
789 #[tokio::test]
790 async fn successful_observation_has_host_owned_provenance_and_no_claim() {
791 let registry = PluginRegistry::new();
792 let (candidate, _) = plugin("observer", Behavior::Record);
793 registry
794 .register(candidate, PluginConfig::default())
795 .expect("registration succeeds");
796 let broker = StaticBroker::success(&origin(), b"");
797 let result = registry
798 .execute(
799 "observer",
800 request(broker)
801 .with_input(b"marker".to_vec())
802 .expect("bounded input"),
803 )
804 .await
805 .expect("execution succeeds");
806 assert_eq!(result.observations().len(), 1);
807 let evidence = &result.observations()[0];
808 assert_eq!(evidence.subject().as_str(), "authorized-origin:test");
809 assert_eq!(evidence.source().component(), "observer");
810 assert_eq!(evidence.source().correlation_id(), Some("case:plugin:test"));
811 let json = serde_json::to_string(&result).expect("serializes");
812 assert!(!json.contains("finding"));
813 assert!(!json.contains("outcome"));
814 assert!(!json.contains("severity"));
815 assert_eq!(result.usage().observations(), 1);
816 }
817
818 #[tokio::test]
819 async fn redaction_removes_headers_literals_and_debug_secrets() {
820 let registry = PluginRegistry::new();
821 let (candidate, _) = plugin("redactor", Behavior::Record);
822 registry
823 .register(candidate, PluginConfig::default())
824 .expect("registration succeeds");
825 let broker = StaticBroker::success(&origin(), b"");
826 let redaction = Arc::new(
827 SecretRedactionPolicy::new([
828 "tenant".to_owned(),
829 "tenant-secret".to_owned(),
830 "REDACTED".to_owned(),
831 "[REDACTED]-tenant-secret".to_owned(),
832 "DACTED]-inside-secret".to_owned(),
833 ])
834 .expect("redaction policy"),
835 );
836 let literal_cases = [
837 "tenant-secret tenant [REDACTED]",
838 "[REDACTED]-tenant-secret",
839 "[REDACTED]-inside-secret",
840 ];
841 for value in literal_cases {
842 let once = redaction.redact(value);
843 assert!(!once.contains("tenant-secret"));
844 assert!(!once.contains("inside-secret"));
845 assert_eq!(redaction.redact(&once), once);
846 }
847 let dense = SecretRedactionPolicy::new(
848 (1..=MAX_PLUGIN_REDACTION_LITERAL_COUNT).map(|length| "a".repeat(length)),
849 )
850 .expect("dense overlap policy");
851 let dense_input = "a".repeat(HARD_MAX_PLUGIN_OBSERVATION_BYTES as usize);
852 let dense_once = dense.redact(&dense_input);
853 assert_eq!(dense_once, "[REDACTED]");
854 assert_eq!(dense.redact(&dense_once), dense_once);
855 let execution = request(broker)
856 .with_input(b"Authorization: Bearer abc\ntoken=xyz\ntenant-secret".to_vec())
857 .expect("input")
858 .with_redaction(redaction.clone());
859 let debug = format!("{execution:?} {redaction:?}");
860 assert!(!debug.contains("tenant-secret"));
861 assert!(!debug.contains("Bearer"));
862 let result = registry
863 .execute("redactor", execution)
864 .await
865 .expect("execution succeeds");
866 let serialized = serde_json::to_string(&result).expect("serializes");
867 assert!(!serialized.contains("Bearer abc"));
868 assert!(!serialized.contains("xyz"));
869 assert!(!serialized.contains("tenant-secret"));
870 assert!(serialized.contains("REDACTED"));
871
872 struct ExpandingRedactor;
873 impl PluginRedactionPolicy for ExpandingRedactor {
874 fn redact(&self, value: &str) -> String {
875 format!("{value}0123456789")
876 }
877 }
878 struct TwoObservationPlugin;
879 #[async_trait]
880 impl Plugin for TwoObservationPlugin {
881 fn id(&self) -> &str {
882 "expanding-redactor"
883 }
884 fn name(&self) -> &str {
885 "Expanding Redactor Fixture"
886 }
887 fn version(&self) -> &str {
888 "0.1.0"
889 }
890 fn description(&self) -> &str {
891 "Exercises retained observation accounting"
892 }
893 fn author(&self) -> &str {
894 "Termivar tests"
895 }
896 fn category(&self) -> PluginCategory {
897 PluginCategory::Custom
898 }
899 async fn execute(&self, context: &PluginContext) -> Result<(), PluginError> {
900 context.record(observation(EvidenceValue::Text("x".to_owned())))?;
901 context.record(observation(EvidenceValue::Text("y".to_owned())))
902 }
903 }
904 let registry = PluginRegistry::new();
905 registry
906 .register(Arc::new(TwoObservationPlugin), PluginConfig::default())
907 .expect("registration succeeds");
908 let budget = PluginBudget::default()
909 .with_max_observation_bytes(25)
910 .expect("budget");
911 let execution = request(StaticBroker::success(&origin(), b""))
912 .with_budget(budget)
913 .expect("request")
914 .with_redaction(Arc::new(ExpandingRedactor));
915 assert_eq!(
916 registry.execute("expanding-redactor", execution).await,
917 Err(PluginError::ObservationBytesBudgetExceeded)
918 );
919 }
920
921 #[tokio::test]
922 async fn plugin_error_rolls_back_staged_evidence_and_redacts_detail() {
923 let registry = PluginRegistry::new();
924 let (candidate, _) = plugin("rollback", Behavior::ErrorAfterRecord);
925 registry
926 .register(candidate, PluginConfig::default())
927 .expect("registration succeeds");
928 let broker = StaticBroker::success(&origin(), b"");
929 let error = registry
930 .execute("rollback", request(broker))
931 .await
932 .expect_err("execution must fail");
933 assert!(!error.to_string().contains("fixture-secret"));
934 assert_eq!(
935 error,
936 PluginError::ExecutionFailed("plugin execution failed".to_owned())
937 );
938 let metadata = registry.get_metadata("rollback").expect("metadata");
939 assert_eq!(metadata.execution_count(), 1);
940 assert_eq!(metadata.success_count(), 0);
941 assert_eq!(metadata.error_count(), 1);
942
943 let oversized = format!(
944 "token=fixture-secret{}",
945 "x".repeat(MAX_PLUGIN_TEXT_BYTES * 4)
946 );
947 let bounded = sanitize_error(
948 &SecretRedactionPolicy::default(),
949 PluginError::ExecutionFailed(oversized),
950 );
951 let detail = match bounded {
952 PluginError::ExecutionFailed(detail) => detail,
953 other => panic!("unexpected error: {other}"),
954 };
955 assert!(detail.len() <= MAX_PLUGIN_TEXT_BYTES);
956 assert!(!detail.contains("fixture-secret"));
957 assert_eq!(detail, "plugin execution failed");
958
959 let boundary_secret = "s".repeat(MAX_PLUGIN_TEXT_BYTES + 1);
960 let redaction = Arc::new(
961 SecretRedactionPolicy::new([boundary_secret.clone()])
962 .expect("boundary redaction policy"),
963 );
964 let registry = PluginRegistry::new();
965 let (candidate, _) = plugin("long-plugin-error", Behavior::LongSecretError);
966 registry
967 .register(candidate, PluginConfig::default())
968 .expect("registration succeeds");
969 let execution =
970 request(StaticBroker::success(&origin(), b"")).with_redaction(redaction.clone());
971 assert_eq!(
972 registry.execute("long-plugin-error", execution).await,
973 Err(PluginError::ExecutionFailed(
974 "plugin execution failed".to_owned()
975 ))
976 );
977
978 let registry = PluginRegistry::new();
979 let (candidate, _) = plugin("long-broker-error", Behavior::Request);
980 registry
981 .register(candidate, PluginConfig::default())
982 .expect("registration succeeds");
983 let broker = Arc::new(StaticBroker {
984 calls: AtomicUsize::new(0),
985 response: Mutex::new(Some(Err(PluginError::BrokerFailure(boundary_secret)))),
986 delay: Duration::ZERO,
987 });
988 let execution = request(broker).with_redaction(redaction);
989 assert_eq!(
990 registry.execute("long-broker-error", execution).await,
991 Err(PluginError::BrokerFailure(
992 "host plugin request broker failed".to_owned()
993 ))
994 );
995
996 let registry = PluginRegistry::new();
997 let (candidate, _) = plugin("plugin-api-error", Behavior::IncompatibleError);
998 registry
999 .register(candidate, PluginConfig::default())
1000 .expect("registration succeeds");
1001 assert_eq!(
1002 registry
1003 .execute(
1004 "plugin-api-error",
1005 request(StaticBroker::success(&origin(), b"")),
1006 )
1007 .await,
1008 Err(PluginError::IncompatibleApiVersion {
1009 expected: PLUGIN_API_VERSION.to_owned(),
1010 actual: "[invalid]".to_owned(),
1011 })
1012 );
1013
1014 let registry = PluginRegistry::new();
1015 let (candidate, _) = plugin("broker-api-error", Behavior::Request);
1016 registry
1017 .register(candidate, PluginConfig::default())
1018 .expect("registration succeeds");
1019 let broker = Arc::new(StaticBroker {
1020 calls: AtomicUsize::new(0),
1021 response: Mutex::new(Some(Err(PluginError::IncompatibleApiVersion {
1022 expected: format!(
1023 "token=broker-secret{}",
1024 "x".repeat(MAX_PLUGIN_TEXT_BYTES * 4)
1025 ),
1026 actual: "token=broker-actual-secret".to_owned(),
1027 }))),
1028 delay: Duration::ZERO,
1029 });
1030 assert_eq!(
1031 registry.execute("broker-api-error", request(broker)).await,
1032 Err(PluginError::IncompatibleApiVersion {
1033 expected: PLUGIN_API_VERSION.to_owned(),
1034 actual: "[invalid]".to_owned(),
1035 })
1036 );
1037
1038 struct PanickingRedactor;
1039 impl PluginRedactionPolicy for PanickingRedactor {
1040 fn redact(&self, _value: &str) -> String {
1041 panic!("host redaction panic")
1042 }
1043 }
1044 let registry = PluginRegistry::new();
1045 let (candidate, _) = plugin("panicking-redactor", Behavior::ErrorOnly);
1046 registry
1047 .register(candidate, PluginConfig::default())
1048 .expect("registration succeeds");
1049 let execution = request(StaticBroker::success(&origin(), b""))
1050 .with_redaction(Arc::new(PanickingRedactor));
1051 assert_eq!(
1052 registry.execute("panicking-redactor", execution).await,
1053 Err(PluginError::HostCallbackPanicked)
1054 );
1055 let metadata = registry
1056 .get_metadata("panicking-redactor")
1057 .expect("metadata");
1058 assert_eq!(metadata.execution_count(), 1);
1059 assert_eq!(metadata.success_count(), 0);
1060 assert_eq!(metadata.error_count(), 1);
1061 }
1062
1063 #[tokio::test]
1064 async fn timeout_cancellation_and_panic_are_typed_failures() {
1065 for (id, behavior, expected) in [
1066 (
1067 "timeout",
1068 Behavior::RecordThenPending,
1069 PluginError::WallTimeExceeded,
1070 ),
1071 ("panic", Behavior::RecordThenPanic, PluginError::Panicked),
1072 ] {
1073 let registry = PluginRegistry::new();
1074 let (candidate, _) = plugin(id, behavior);
1075 registry
1076 .register(candidate, PluginConfig::default())
1077 .expect("registration succeeds");
1078 let broker = StaticBroker::success(&origin(), b"");
1079 let budget = PluginBudget::default()
1080 .with_max_wall_time(Duration::from_millis(5))
1081 .expect("budget");
1082 let error = registry
1083 .execute(
1084 id,
1085 request(broker)
1086 .with_budget(budget)
1087 .and_then(|request| request.with_input(b"staged".to_vec()))
1088 .expect("request"),
1089 )
1090 .await
1091 .expect_err("must fail");
1092 assert_eq!(error, expected);
1093 let metadata = registry.get_metadata(id).expect("metadata");
1094 assert_eq!(metadata.execution_count(), 1);
1095 assert_eq!(metadata.success_count(), 0);
1096 assert_eq!(metadata.error_count(), 1);
1097 }
1098
1099 struct ConstructionPanicPlugin;
1100 impl Plugin for ConstructionPanicPlugin {
1101 fn id(&self) -> &str {
1102 "construction-panic"
1103 }
1104 fn name(&self) -> &str {
1105 "Construction Panic Fixture"
1106 }
1107 fn version(&self) -> &str {
1108 "0.1.0"
1109 }
1110 fn description(&self) -> &str {
1111 "Panics while constructing its boxed execution future"
1112 }
1113 fn author(&self) -> &str {
1114 "Termivar tests"
1115 }
1116 fn category(&self) -> PluginCategory {
1117 PluginCategory::Custom
1118 }
1119 fn execute<'life0, 'life1, 'async_trait>(
1120 &'life0 self,
1121 context: &'life1 PluginContext,
1122 ) -> std::pin::Pin<
1123 Box<
1124 dyn std::future::Future<Output = Result<(), PluginError>> + Send + 'async_trait,
1125 >,
1126 >
1127 where
1128 'life0: 'async_trait,
1129 'life1: 'async_trait,
1130 Self: 'async_trait,
1131 {
1132 context
1133 .record(observation(EvidenceValue::Text("staged".to_owned())))
1134 .expect("staging succeeds before construction panic");
1135 panic!("plugin fixture panic during future construction");
1136 }
1137 }
1138 let registry = PluginRegistry::new();
1139 registry
1140 .register(Arc::new(ConstructionPanicPlugin), PluginConfig::default())
1141 .expect("registration succeeds");
1142 assert_eq!(
1143 registry
1144 .execute(
1145 "construction-panic",
1146 request(StaticBroker::success(&origin(), b"")),
1147 )
1148 .await,
1149 Err(PluginError::Panicked)
1150 );
1151 let metadata = registry
1152 .get_metadata("construction-panic")
1153 .expect("metadata");
1154 assert_eq!(metadata.execution_count(), 1);
1155 assert_eq!(metadata.success_count(), 0);
1156 assert_eq!(metadata.error_count(), 1);
1157
1158 struct DropPanicFuture {
1159 ready: bool,
1160 }
1161 impl std::future::Future for DropPanicFuture {
1162 type Output = Result<(), PluginError>;
1163
1164 fn poll(
1165 self: std::pin::Pin<&mut Self>,
1166 _context: &mut std::task::Context<'_>,
1167 ) -> std::task::Poll<Self::Output> {
1168 if self.ready {
1169 std::task::Poll::Ready(Ok(()))
1170 } else {
1171 std::task::Poll::Pending
1172 }
1173 }
1174 }
1175 impl Drop for DropPanicFuture {
1176 fn drop(&mut self) {
1177 panic!("plugin fixture panic while dropping execution future");
1178 }
1179 }
1180 struct DropPanicPlugin {
1181 id: &'static str,
1182 ready: bool,
1183 }
1184 impl Plugin for DropPanicPlugin {
1185 fn id(&self) -> &str {
1186 self.id
1187 }
1188 fn name(&self) -> &str {
1189 "Drop Panic Fixture"
1190 }
1191 fn version(&self) -> &str {
1192 "0.1.0"
1193 }
1194 fn description(&self) -> &str {
1195 "Panics while dropping its boxed execution future"
1196 }
1197 fn author(&self) -> &str {
1198 "Termivar tests"
1199 }
1200 fn category(&self) -> PluginCategory {
1201 PluginCategory::Custom
1202 }
1203 fn execute<'life0, 'life1, 'async_trait>(
1204 &'life0 self,
1205 _context: &'life1 PluginContext,
1206 ) -> std::pin::Pin<
1207 Box<
1208 dyn std::future::Future<Output = Result<(), PluginError>> + Send + 'async_trait,
1209 >,
1210 >
1211 where
1212 'life0: 'async_trait,
1213 'life1: 'async_trait,
1214 Self: 'async_trait,
1215 {
1216 Box::pin(DropPanicFuture { ready: self.ready })
1217 }
1218 }
1219 for (id, ready) in [("ready-drop-panic", true), ("pending-drop-panic", false)] {
1220 let registry = PluginRegistry::new();
1221 registry
1222 .register(
1223 Arc::new(DropPanicPlugin { id, ready }),
1224 PluginConfig::default(),
1225 )
1226 .expect("registration succeeds");
1227 let budget = PluginBudget::default()
1228 .with_max_wall_time(Duration::from_millis(5))
1229 .expect("budget");
1230 assert_eq!(
1231 registry
1232 .execute(
1233 id,
1234 request(StaticBroker::success(&origin(), b""))
1235 .with_budget(budget)
1236 .expect("request"),
1237 )
1238 .await,
1239 Err(PluginError::Panicked)
1240 );
1241 let metadata = registry.get_metadata(id).expect("metadata");
1242 assert_eq!(metadata.execution_count(), 1);
1243 assert_eq!(metadata.success_count(), 0);
1244 assert_eq!(metadata.error_count(), 1);
1245 }
1246
1247 struct AbandonRequestPlugin;
1248 #[async_trait]
1249 impl Plugin for AbandonRequestPlugin {
1250 fn id(&self) -> &str {
1251 "abandon-request"
1252 }
1253 fn name(&self) -> &str {
1254 "Abandoned Request Fixture"
1255 }
1256 fn version(&self) -> &str {
1257 "0.1.0"
1258 }
1259 fn description(&self) -> &str {
1260 "Polls and drops a broker request"
1261 }
1262 fn author(&self) -> &str {
1263 "Termivar tests"
1264 }
1265 fn category(&self) -> PluginCategory {
1266 PluginCategory::Custom
1267 }
1268 async fn execute(&self, context: &PluginContext) -> Result<(), PluginError> {
1269 let future =
1270 context.request(PluginHttpMethod::Get, context.authorized_origin().clone());
1271 tokio::pin!(future);
1272 tokio::select! {
1273 biased;
1274 result = &mut future => {
1275 result?;
1276 return Err(invalid_config("pending broker completed unexpectedly"));
1277 },
1278 () = tokio::task::yield_now() => {},
1279 }
1280 Ok(())
1281 }
1282 }
1283 struct PendingBroker {
1284 calls: AtomicUsize,
1285 cancellation: Mutex<Option<CancellationToken>>,
1286 }
1287 #[async_trait]
1288 impl PluginRequestBroker for PendingBroker {
1289 async fn execute(
1290 &self,
1291 request: PluginHttpRequest,
1292 ) -> Result<PluginHttpResponse, PluginError> {
1293 self.calls.fetch_add(1, Ordering::SeqCst);
1294 *self
1295 .cancellation
1296 .lock()
1297 .map_err(|_| PluginError::HostStateUnavailable)? =
1298 Some(request.cancellation().clone());
1299 std::future::pending().await
1300 }
1301 }
1302 let registry = PluginRegistry::new();
1303 registry
1304 .register(Arc::new(AbandonRequestPlugin), PluginConfig::default())
1305 .expect("registration succeeds");
1306 let pending = Arc::new(PendingBroker {
1307 calls: AtomicUsize::new(0),
1308 cancellation: Mutex::new(None),
1309 });
1310 assert_eq!(
1311 registry
1312 .execute("abandon-request", request(pending.clone()))
1313 .await,
1314 Err(PluginError::RequestAbandoned)
1315 );
1316 assert_eq!(pending.calls.load(Ordering::SeqCst), 1);
1317 assert!(pending
1318 .cancellation
1319 .lock()
1320 .expect("cancellation receipt")
1321 .as_ref()
1322 .is_some_and(CancellationToken::is_cancelled));
1323
1324 let registry = PluginRegistry::new();
1325 let (candidate, calls) = plugin("cancelled", Behavior::Record);
1326 registry
1327 .register(candidate, PluginConfig::default())
1328 .expect("registration succeeds");
1329 let cancellation = CancellationToken::new();
1330 cancellation.cancel();
1331 let broker = StaticBroker::success(&origin(), b"");
1332 assert_eq!(
1333 registry
1334 .execute("cancelled", request(broker).with_cancellation(cancellation),)
1335 .await,
1336 Err(PluginError::Cancelled)
1337 );
1338 assert_eq!(calls.load(Ordering::SeqCst), 0);
1339
1340 let registry = PluginRegistry::new();
1341 let (candidate, _) = plugin("mid-cancel", Behavior::Pending);
1342 registry
1343 .register(candidate, PluginConfig::default())
1344 .expect("registration succeeds");
1345 let cancellation = CancellationToken::new();
1346 let cancellation_signal = cancellation.clone();
1347 let cancel_task = tokio::spawn(async move {
1348 tokio::time::sleep(Duration::from_millis(5)).await;
1349 cancellation_signal.cancel();
1350 });
1351 let broker = StaticBroker::success(&origin(), b"");
1352 assert_eq!(
1353 registry
1354 .execute(
1355 "mid-cancel",
1356 request(broker).with_cancellation(cancellation),
1357 )
1358 .await,
1359 Err(PluginError::Cancelled)
1360 );
1361 cancel_task.await.expect("cancellation task joins");
1362 assert_eq!(
1363 registry
1364 .get_metadata("mid-cancel")
1365 .expect("metadata")
1366 .error_count(),
1367 1
1368 );
1369 }
1370
1371 #[tokio::test]
1372 async fn input_observation_and_request_budgets_fail_closed() {
1373 let broker = StaticBroker::success(&origin(), b"");
1374 let tiny_input = PluginBudget::default()
1375 .with_max_input_bytes(3)
1376 .expect("budget");
1377 assert!(matches!(
1378 request(broker.clone())
1379 .with_budget(tiny_input)
1380 .expect("empty input fits")
1381 .with_input("éé".as_bytes().to_vec()),
1382 Err(PluginError::InputBudgetExceeded {
1383 actual: 4,
1384 maximum: 3
1385 })
1386 ));
1387
1388 let registry = PluginRegistry::new();
1389 let (candidate, _) = plugin("no-request", Behavior::Request);
1390 registry
1391 .register(candidate, PluginConfig::default())
1392 .expect("registration succeeds");
1393 let zero = PluginBudget::default()
1394 .with_max_requests(0)
1395 .expect("zero authority");
1396 assert_eq!(
1397 registry
1398 .execute(
1399 "no-request",
1400 request(broker.clone()).with_budget(zero).expect("request"),
1401 )
1402 .await,
1403 Err(PluginError::RequestBudgetExceeded)
1404 );
1405 assert_eq!(broker.calls(), 0);
1406
1407 let registry = PluginRegistry::new();
1408 let (candidate, _) = plugin("no-observation", Behavior::Record);
1409 registry
1410 .register(candidate, PluginConfig::default())
1411 .expect("registration succeeds");
1412 let zero = PluginBudget::default()
1413 .with_max_observations(0)
1414 .expect("zero authority");
1415 assert_eq!(
1416 registry
1417 .execute(
1418 "no-observation",
1419 request(broker).with_budget(zero).expect("request"),
1420 )
1421 .await,
1422 Err(PluginError::ObservationBudgetExceeded)
1423 );
1424
1425 let registry = PluginRegistry::new();
1426 let (candidate, _) = plugin("observation-bytes", Behavior::Record);
1427 registry
1428 .register(candidate, PluginConfig::default())
1429 .expect("registration succeeds");
1430 let bytes = PluginBudget::default()
1431 .with_max_observation_bytes(3)
1432 .expect("budget");
1433 let execution = request(StaticBroker::success(&origin(), b""))
1434 .with_budget(bytes)
1435 .expect("request")
1436 .with_input(b"four".to_vec())
1437 .expect("input budget");
1438 assert_eq!(
1439 registry.execute("observation-bytes", execution).await,
1440 Err(PluginError::ObservationBytesBudgetExceeded)
1441 );
1442
1443 struct EmptyListPlugin;
1444 #[async_trait]
1445 impl Plugin for EmptyListPlugin {
1446 fn id(&self) -> &str {
1447 "empty-list"
1448 }
1449 fn name(&self) -> &str {
1450 "Empty List Fixture"
1451 }
1452 fn version(&self) -> &str {
1453 "0.1.0"
1454 }
1455 fn description(&self) -> &str {
1456 "Exercises structural observation accounting"
1457 }
1458 fn author(&self) -> &str {
1459 "Termivar tests"
1460 }
1461 fn category(&self) -> PluginCategory {
1462 PluginCategory::Custom
1463 }
1464 async fn execute(&self, context: &PluginContext) -> Result<(), PluginError> {
1465 context.record(observation(EvidenceValue::TextList(vec![
1466 String::new();
1467 HARD_MAX_PLUGIN_TEXT_LIST_ITEMS
1468 ])))
1469 }
1470 }
1471 let registry = PluginRegistry::new();
1472 registry
1473 .register(Arc::new(EmptyListPlugin), PluginConfig::default())
1474 .expect("registration succeeds");
1475 let zero_bytes = PluginBudget::default()
1476 .with_max_observation_bytes(0)
1477 .expect("zero byte authority");
1478 assert_eq!(
1479 registry
1480 .execute(
1481 "empty-list",
1482 request(StaticBroker::success(&origin(), b""))
1483 .with_budget(zero_bytes)
1484 .expect("request"),
1485 )
1486 .await,
1487 Err(PluginError::ObservationBytesBudgetExceeded)
1488 );
1489 }
1490
1491 #[tokio::test]
1492 async fn scope_and_body_budgets_are_enforced_around_the_host_broker() {
1493 assert!(PluginHttpResponse::new(200, origin(), b"x".to_vec())
1494 .and_then(|response| response.with_capture_metadata(2, false))
1495 .is_err());
1496
1497 struct ScopePlugin;
1498 #[async_trait]
1499 impl Plugin for ScopePlugin {
1500 fn id(&self) -> &str {
1501 "scope"
1502 }
1503 fn name(&self) -> &str {
1504 "Scope Fixture"
1505 }
1506 fn version(&self) -> &str {
1507 "0.1.0"
1508 }
1509 fn description(&self) -> &str {
1510 "Exercises broker scope"
1511 }
1512 fn author(&self) -> &str {
1513 "Termivar tests"
1514 }
1515 fn category(&self) -> PluginCategory {
1516 PluginCategory::Custom
1517 }
1518 async fn execute(&self, context: &PluginContext) -> Result<(), PluginError> {
1519 context
1520 .request(
1521 PluginHttpMethod::Get,
1522 Url::parse("https://other.test/").map_err(|_| invalid_config("URL"))?,
1523 )
1524 .await?;
1525 Ok(())
1526 }
1527 }
1528 let registry = PluginRegistry::new();
1529 registry
1530 .register(Arc::new(ScopePlugin), PluginConfig::default())
1531 .expect("registration succeeds");
1532 let broker = StaticBroker::success(&origin(), b"");
1533 assert_eq!(
1534 registry.execute("scope", request(broker.clone())).await,
1535 Err(PluginError::ScopeViolation)
1536 );
1537 assert_eq!(broker.calls(), 0);
1538
1539 let registry = PluginRegistry::new();
1540 let (candidate, _) = plugin("body", Behavior::Request);
1541 registry
1542 .register(candidate, PluginConfig::default())
1543 .expect("registration succeeds");
1544 let broker = StaticBroker::success(&origin(), b"four");
1545 let budget = PluginBudget::default()
1546 .with_max_response_body_bytes(3)
1547 .expect("budget");
1548 assert!(matches!(
1549 registry
1550 .execute(
1551 "body",
1552 request(broker).with_budget(budget).expect("request")
1553 )
1554 .await,
1555 Err(PluginError::ResponseBodyBudgetExceeded {
1556 actual: 4,
1557 maximum: 3
1558 })
1559 ));
1560
1561 let registry = PluginRegistry::new();
1562 let (candidate, _) = plugin("long-final-url", Behavior::Request);
1563 registry
1564 .register(candidate, PluginConfig::default())
1565 .expect("registration succeeds");
1566 let long_final = Url::parse(&format!(
1567 "https://example.test/{}",
1568 "a".repeat(MAX_PLUGIN_URL_BYTES)
1569 ))
1570 .expect("valid long URL");
1571 let long_final_broker = StaticBroker::success(&long_final, b"");
1572 assert_eq!(
1573 registry
1574 .execute("long-final-url", request(long_final_broker))
1575 .await,
1576 Err(PluginError::ScopeViolation)
1577 );
1578
1579 let registry = PluginRegistry::new();
1580 let (candidate, _) = plugin("request-timeout", Behavior::Request);
1581 registry
1582 .register(candidate, PluginConfig::default())
1583 .expect("registration succeeds");
1584 let slow = Arc::new(StaticBroker {
1585 calls: AtomicUsize::new(0),
1586 response: Mutex::new(Some(Ok(
1587 PluginHttpResponse::new(200, origin(), Vec::new()).expect("response")
1588 ))),
1589 delay: Duration::from_millis(50),
1590 });
1591 let budget = PluginBudget::default()
1592 .with_request_timeout(Duration::from_millis(5))
1593 .expect("budget");
1594 assert_eq!(
1595 registry
1596 .execute(
1597 "request-timeout",
1598 request(slow).with_budget(budget).expect("request"),
1599 )
1600 .await,
1601 Err(PluginError::RequestTimeout)
1602 );
1603
1604 let registry = PluginRegistry::new();
1605 let (candidate, _) = plugin("redirect-scope", Behavior::Request);
1606 registry
1607 .register(candidate, PluginConfig::default())
1608 .expect("registration succeeds");
1609 let other = Url::parse("https://other.test/").expect("valid URL");
1610 let redirected = StaticBroker::success(&other, b"");
1611 assert_eq!(
1612 registry
1613 .execute("redirect-scope", request(redirected.clone()))
1614 .await,
1615 Err(PluginError::ScopeViolation)
1616 );
1617 assert_eq!(redirected.calls(), 1);
1618
1619 struct TwoRequestPlugin;
1620 #[async_trait]
1621 impl Plugin for TwoRequestPlugin {
1622 fn id(&self) -> &str {
1623 "cumulative"
1624 }
1625 fn name(&self) -> &str {
1626 "Cumulative Body Fixture"
1627 }
1628 fn version(&self) -> &str {
1629 "0.1.0"
1630 }
1631 fn description(&self) -> &str {
1632 "Exercises cumulative response accounting"
1633 }
1634 fn author(&self) -> &str {
1635 "Termivar tests"
1636 }
1637 fn category(&self) -> PluginCategory {
1638 PluginCategory::Custom
1639 }
1640 async fn execute(&self, context: &PluginContext) -> Result<(), PluginError> {
1641 for path in ["one", "two"] {
1642 let url = context
1643 .authorized_origin()
1644 .join(path)
1645 .map_err(|_| invalid_config("fixture URL"))?;
1646 context.request(PluginHttpMethod::Get, url).await?;
1647 }
1648 Ok(())
1649 }
1650 }
1651 struct RepeatBroker {
1652 calls: AtomicUsize,
1653 captures: Mutex<Vec<(u64, bool)>>,
1654 }
1655 #[async_trait]
1656 impl PluginRequestBroker for RepeatBroker {
1657 async fn execute(
1658 &self,
1659 request: PluginHttpRequest,
1660 ) -> Result<PluginHttpResponse, PluginError> {
1661 self.calls.fetch_add(1, Ordering::SeqCst);
1662 self.captures
1663 .lock()
1664 .map_err(|_| PluginError::HostStateUnavailable)?
1665 .push((
1666 request.max_response_body_bytes(),
1667 request.cancellation().is_cancelled(),
1668 ));
1669 PluginHttpResponse::new(200, request.url().clone(), b"abc".to_vec())
1670 }
1671 }
1672 let registry = PluginRegistry::new();
1673 registry
1674 .register(Arc::new(TwoRequestPlugin), PluginConfig::default())
1675 .expect("registration succeeds");
1676 let repeated = Arc::new(RepeatBroker {
1677 calls: AtomicUsize::new(0),
1678 captures: Mutex::new(Vec::new()),
1679 });
1680 let budget = PluginBudget::default()
1681 .with_max_response_body_bytes(3)
1682 .and_then(|budget| budget.with_max_cumulative_body_bytes(3))
1683 .expect("budget");
1684 assert_eq!(
1685 registry
1686 .execute(
1687 "cumulative",
1688 request(repeated.clone())
1689 .with_budget(budget)
1690 .expect("request"),
1691 )
1692 .await,
1693 Err(PluginError::CumulativeBodyBudgetExceeded)
1694 );
1695 assert_eq!(repeated.calls.load(Ordering::SeqCst), 1);
1696 assert_eq!(
1697 *repeated.captures.lock().expect("capture log"),
1698 vec![(3, false)]
1699 );
1700
1701 struct CappedBroker {
1702 captures: Mutex<Vec<(u64, bool)>>,
1703 }
1704 #[async_trait]
1705 impl PluginRequestBroker for CappedBroker {
1706 async fn execute(
1707 &self,
1708 request: PluginHttpRequest,
1709 ) -> Result<PluginHttpResponse, PluginError> {
1710 let limit = request.max_response_body_bytes();
1711 self.captures
1712 .lock()
1713 .map_err(|_| PluginError::HostStateUnavailable)?
1714 .push((limit, request.cancellation().is_cancelled()));
1715 let body = b"abcd";
1716 let retained = usize::try_from(limit).unwrap_or(usize::MAX).min(body.len());
1717 PluginHttpResponse::new(200, request.url().clone(), body[..retained].to_vec())?
1718 .with_capture_metadata(retained as u64, retained < body.len())
1719 }
1720 }
1721 let registry = PluginRegistry::new();
1722 registry
1723 .register(Arc::new(TwoRequestPlugin), PluginConfig::default())
1724 .expect("registration succeeds");
1725 let capped = Arc::new(CappedBroker {
1726 captures: Mutex::new(Vec::new()),
1727 });
1728 let budget = PluginBudget::default()
1729 .with_max_response_body_bytes(4)
1730 .and_then(|budget| budget.with_max_cumulative_body_bytes(6))
1731 .expect("budget");
1732 let result = registry
1733 .execute(
1734 "cumulative",
1735 request(capped.clone())
1736 .with_budget(budget)
1737 .expect("request"),
1738 )
1739 .await
1740 .expect("a compliant broker stays inside the shared envelope");
1741 assert_eq!(result.usage().response_body_bytes(), 6);
1742 assert_eq!(
1743 *capped.captures.lock().expect("capture log"),
1744 vec![(4, false), (2, false)]
1745 );
1746
1747 struct ConcurrentRequestPlugin;
1748 #[async_trait]
1749 impl Plugin for ConcurrentRequestPlugin {
1750 fn id(&self) -> &str {
1751 "concurrent-capture"
1752 }
1753 fn name(&self) -> &str {
1754 "Concurrent Capture Fixture"
1755 }
1756 fn version(&self) -> &str {
1757 "0.1.0"
1758 }
1759 fn description(&self) -> &str {
1760 "Exercises in-flight cumulative reservations"
1761 }
1762 fn author(&self) -> &str {
1763 "Termivar tests"
1764 }
1765 fn category(&self) -> PluginCategory {
1766 PluginCategory::Custom
1767 }
1768 async fn execute(&self, context: &PluginContext) -> Result<(), PluginError> {
1769 let one = context
1770 .authorized_origin()
1771 .join("one")
1772 .map_err(|_| invalid_config("fixture URL"))?;
1773 let two = context
1774 .authorized_origin()
1775 .join("two")
1776 .map_err(|_| invalid_config("fixture URL"))?;
1777 tokio::try_join!(
1778 context.request(PluginHttpMethod::Get, one),
1779 context.request(PluginHttpMethod::Get, two),
1780 )?;
1781 Ok(())
1782 }
1783 }
1784 struct ConcurrentCaptureBroker {
1785 barrier: tokio::sync::Barrier,
1786 captures: Mutex<Vec<u64>>,
1787 }
1788 #[async_trait]
1789 impl PluginRequestBroker for ConcurrentCaptureBroker {
1790 async fn execute(
1791 &self,
1792 request: PluginHttpRequest,
1793 ) -> Result<PluginHttpResponse, PluginError> {
1794 let limit = request.max_response_body_bytes();
1795 self.captures
1796 .lock()
1797 .map_err(|_| PluginError::HostStateUnavailable)?
1798 .push(limit);
1799 self.barrier.wait().await;
1800 PluginHttpResponse::new(
1801 200,
1802 request.url().clone(),
1803 vec![b'x'; usize::try_from(limit).unwrap_or(usize::MAX)],
1804 )
1805 }
1806 }
1807 let registry = PluginRegistry::new();
1808 registry
1809 .register(Arc::new(ConcurrentRequestPlugin), PluginConfig::default())
1810 .expect("registration succeeds");
1811 let concurrent = Arc::new(ConcurrentCaptureBroker {
1812 barrier: tokio::sync::Barrier::new(2),
1813 captures: Mutex::new(Vec::new()),
1814 });
1815 let budget = PluginBudget::default()
1816 .with_max_response_body_bytes(4)
1817 .and_then(|budget| budget.with_max_cumulative_body_bytes(6))
1818 .expect("budget");
1819 let result = registry
1820 .execute(
1821 "concurrent-capture",
1822 request(concurrent.clone())
1823 .with_budget(budget)
1824 .expect("request"),
1825 )
1826 .await
1827 .expect("concurrent captures stay inside the shared envelope");
1828 let mut captures = concurrent.captures.lock().expect("capture log").clone();
1829 captures.sort_unstable();
1830 assert_eq!(captures, vec![2, 4]);
1831 assert_eq!(result.usage().response_body_bytes(), 6);
1832 }
1833
1834 #[tokio::test]
1835 async fn configuration_and_metadata_share_one_entry() {
1836 let registry = PluginRegistry::new();
1837 let (candidate, _) = plugin("metadata", Behavior::Empty);
1838 registry
1839 .register(candidate, PluginConfig::default())
1840 .expect("registration succeeds");
1841 assert!(registry
1842 .get_config("metadata")
1843 .expect("configuration")
1844 .enabled());
1845 registry
1846 .update_config("metadata", PluginConfig::new(false))
1847 .expect("configuration update");
1848 assert!(!registry
1849 .get_metadata("metadata")
1850 .expect("metadata")
1851 .enabled());
1852 registry.unregister("metadata").expect("unregister");
1853 assert!(registry.get("metadata").is_none());
1854 assert!(registry.get_config("metadata").is_none());
1855 assert!(registry.get_metadata("metadata").is_none());
1856
1857 struct YieldingPlugin;
1858 #[async_trait]
1859 impl Plugin for YieldingPlugin {
1860 fn id(&self) -> &str {
1861 "coherent-stats"
1862 }
1863 fn name(&self) -> &str {
1864 "Coherent Stats Fixture"
1865 }
1866 fn version(&self) -> &str {
1867 "0.1.0"
1868 }
1869 fn description(&self) -> &str {
1870 "Exercises concurrent metadata snapshots"
1871 }
1872 fn author(&self) -> &str {
1873 "Termivar tests"
1874 }
1875 fn category(&self) -> PluginCategory {
1876 PluginCategory::Custom
1877 }
1878 async fn execute(&self, _context: &PluginContext) -> Result<(), PluginError> {
1879 for _ in 0..4 {
1880 tokio::task::yield_now().await;
1881 }
1882 Ok(())
1883 }
1884 }
1885 let registry = Arc::new(PluginRegistry::new());
1886 registry
1887 .register(Arc::new(YieldingPlugin), PluginConfig::default())
1888 .expect("registration succeeds");
1889 let mut tasks = Vec::new();
1890 for _ in 0..32 {
1891 let registry = registry.clone();
1892 tasks.push(tokio::spawn(async move {
1893 registry
1894 .execute(
1895 "coherent-stats",
1896 request(StaticBroker::success(&origin(), b"")),
1897 )
1898 .await
1899 }));
1900 }
1901 while tasks.iter().any(|task| !task.is_finished()) {
1902 let metadata = registry.get_metadata("coherent-stats").expect("metadata");
1903 assert!(
1904 metadata.execution_count()
1905 >= metadata
1906 .success_count()
1907 .saturating_add(metadata.error_count())
1908 );
1909 tokio::task::yield_now().await;
1910 }
1911 for task in tasks {
1912 task.await
1913 .expect("execution task joins")
1914 .expect("execution succeeds");
1915 }
1916 let metadata = registry.get_metadata("coherent-stats").expect("metadata");
1917 assert_eq!(metadata.execution_count(), 32);
1918 assert_eq!(metadata.success_count(), 32);
1919 assert_eq!(metadata.error_count(), 0);
1920 }
1921}