Skip to main content

termivar_scanner/
plugin.rs

1//! Host-owned contract for source-linked native plugins.
2//!
3//! The opt-in `plugins` feature exposes a Rust trait boundary for trusted,
4//! in-process extensions. A plugin receives a borrowed [`PluginContext`]; it
5//! does not receive loose target/payload strings and cannot return findings or
6//! outcomes. The host owns authorization, transport, resource limits,
7//! cancellation, redaction, evidence provenance, and later verification.
8//!
9//! This is a cooperative capability contract, not a sandbox. Native plugin
10//! code linked by a host can still use capabilities obtained outside this API.
11
12use 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
49/// Source-level plugin API version supported by this host.
50///
51/// Preview compatibility requires the same major and minor components. The
52/// `0.2` line intentionally replaces the loose-input/direct-finding `0.1`
53/// contract.
54pub const PLUGIN_API_VERSION: &str = "0.2.0";
55
56/// Extension contract for source-linked native plugins.
57///
58/// Implementations record observations through [`PluginContext::record`] and
59/// use [`PluginContext::request`] for host-authorized network work. Successful
60/// completion grants no finding or verification authority.
61#[async_trait]
62pub trait Plugin: Send + Sync {
63    /// Plugin API line targeted by this implementation.
64    fn api_version(&self) -> &str {
65        PLUGIN_API_VERSION
66    }
67
68    /// Stable plugin identity.
69    fn id(&self) -> &str;
70
71    /// Human-readable plugin name.
72    fn name(&self) -> &str;
73
74    /// Plugin implementation version.
75    fn version(&self) -> &str;
76
77    /// Human-readable description.
78    fn description(&self) -> &str;
79
80    /// Human-readable author or owner.
81    fn author(&self) -> &str;
82
83    /// Informational plugin category.
84    fn category(&self) -> PluginCategory;
85
86    /// Validates static plugin prerequisites before registration.
87    fn validate(&self) -> Result<(), PluginError> {
88        Ok(())
89    }
90
91    /// Executes one host-authorized invocation.
92    async fn execute(&self, context: &PluginContext) -> Result<(), PluginError>;
93}
94
95/// Informational plugin categories; these do not assign severity or findings.
96#[non_exhaustive]
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
98#[serde(rename_all = "snake_case")]
99pub enum PluginCategory {
100    /// Browser/reflection-related observation producer.
101    XSS,
102    /// Database-behavior observation producer.
103    SQLi,
104    /// File/path observation producer.
105    LFI,
106    /// XML observation producer.
107    XXE,
108    /// Server-side request behavior observation producer.
109    SSRF,
110    /// Template behavior observation producer.
111    SSTI,
112    /// Execution behavior observation producer.
113    RCE,
114    /// Host-defined observation producer.
115    Custom,
116}
117
118impl PluginCategory {
119    /// Stable wire label.
120    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/// Typed plugin-boundary failures.
135#[non_exhaustive]
136#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
137pub enum PluginError {
138    /// Plugin identity was not registered.
139    #[error("plugin identity is not registered")]
140    NotFound,
141    /// Plugin identity is already registered.
142    #[error("plugin identity is already registered")]
143    DuplicateId,
144    /// Plugin identity has an invocation in flight and cannot be removed.
145    #[error("plugin identity has an invocation in flight")]
146    InUse,
147    /// Plugin descriptor, configuration, or request was invalid.
148    #[error("invalid plugin configuration: {0}")]
149    InvalidConfig(String),
150    /// Plugin targets another Preview API line.
151    #[error("incompatible plugin API version: expected {expected}, received {actual}")]
152    IncompatibleApiVersion {
153        /// Host API line.
154        expected: String,
155        /// Plugin API line.
156        actual: String,
157    },
158    /// Host configuration disabled this plugin.
159    #[error("plugin is disabled by host policy")]
160    Disabled,
161    /// Host cancelled the invocation.
162    #[error("plugin invocation was cancelled")]
163    Cancelled,
164    /// The invocation crossed its wall-clock budget.
165    #[error("plugin invocation exhausted its wall-clock budget")]
166    WallTimeExceeded,
167    /// One request crossed its timeout budget.
168    #[error("plugin request exhausted its timeout budget")]
169    RequestTimeout,
170    /// Plugin code abandoned a polled request before the broker returned.
171    #[error("plugin request was abandoned before a broker receipt")]
172    RequestAbandoned,
173    /// Input exceeded the immutable request budget.
174    #[error("plugin input uses {actual} bytes; maximum is {maximum}")]
175    InputBudgetExceeded {
176        /// Actual bytes.
177        actual: usize,
178        /// Maximum bytes.
179        maximum: usize,
180    },
181    /// Request dispatch count exceeded the immutable budget.
182    #[error("plugin request budget is exhausted")]
183    RequestBudgetExceeded,
184    /// One response exceeded its delivered-body budget.
185    #[error("plugin response delivered {actual} bytes; maximum is {maximum}")]
186    ResponseBodyBudgetExceeded {
187        /// Delivered bytes.
188        actual: u64,
189        /// Maximum bytes.
190        maximum: u64,
191    },
192    /// The per-response budget grants no body-capture authority.
193    #[error("plugin response body budget grants no capture authority")]
194    ResponseBodyBudgetUnavailable,
195    /// Invocation-wide delivered response bytes exceeded the budget.
196    #[error("plugin cumulative response body budget is exhausted")]
197    CumulativeBodyBudgetExceeded,
198    /// Observation count exceeded the immutable budget.
199    #[error("plugin observation count budget is exhausted")]
200    ObservationBudgetExceeded,
201    /// Observation text exceeded the immutable byte budget.
202    #[error("plugin observation byte budget is exhausted")]
203    ObservationBytesBudgetExceeded,
204    /// A URL was outside the exact authorized HTTP(S) origin.
205    #[error("plugin request is outside the authorized origin")]
206    ScopeViolation,
207    /// The host-owned broker rejected or failed a request.
208    #[error("host plugin request broker failed: {0}")]
209    BrokerFailure(String),
210    /// Plugin logic returned a failure.
211    #[error("plugin execution failed: {0}")]
212    ExecutionFailed(String),
213    /// Plugin code panicked in a registration callback or while executing.
214    #[error("plugin code panicked at the host boundary")]
215    Panicked,
216    /// A host-supplied plugin policy callback panicked.
217    #[error("host plugin policy callback panicked")]
218    HostCallbackPanicked,
219    /// Observation or request authority was already sealed.
220    #[error("plugin context is sealed")]
221    ContextSealed,
222    /// System time was earlier than the Unix epoch.
223    #[error("system clock is earlier than the Unix epoch")]
224    ClockBeforeUnixEpoch,
225    /// Internal synchronized state was poisoned.
226    #[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}