Skip to main content

player_plugin/
sdk.rs

1//! Safe author-facing construction and generated native adapters.
2
3#![deny(unsafe_code)]
4
5use std::collections::HashSet;
6use std::path::Path;
7use std::sync::Arc;
8
9use player_plugin_abi::export::{
10    ExportFailure, ExportInterface as RawExportInterface, ExportInterfaceKind, ExportInvocation,
11    ExportOperation, ExportPlugin as RawExportPlugin, ExportProgress,
12};
13use player_plugin_abi::{
14    VESPER_MAX_CAPABILITY_INSTANCE_ID_BYTES, VESPER_MAX_PLUGIN_ID_BYTES,
15    VESPER_MAX_PLUGIN_NAME_BYTES, status,
16};
17use serde::Serialize;
18use serde::de::DeserializeOwned;
19use thiserror::Error;
20
21use crate::plugin_reference::is_reverse_dns;
22use crate::{
23    BenchmarkEventBatch, BenchmarkSink, BenchmarkSinkError, MAX_PIPELINE_EVENT_INPUT_BYTES,
24    PipelineEvent, PipelineEventHook, PipelineEventHookError, PostDownloadProcessor,
25    ProcessorError, ProcessorProgress,
26};
27
28mod session;
29mod session_capabilities;
30
31/// Capability families that can be exported by a native Rust plugin.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33pub enum PluginCapability {
34    PostDownloadProcessor,
35    PipelineEventHook,
36    BenchmarkSink,
37    NativeDecoder,
38    FrameProcessor,
39    AudioProcessor,
40    SourceNormalizerPacket,
41    SourceNormalizerResource,
42}
43
44impl From<ExportInterfaceKind> for PluginCapability {
45    fn from(value: ExportInterfaceKind) -> Self {
46        match value {
47            ExportInterfaceKind::PostDownloadProcessor => Self::PostDownloadProcessor,
48            ExportInterfaceKind::PipelineEventHook => Self::PipelineEventHook,
49            ExportInterfaceKind::BenchmarkSink => Self::BenchmarkSink,
50            ExportInterfaceKind::NativeDecoder => Self::NativeDecoder,
51            ExportInterfaceKind::FrameProcessor => Self::FrameProcessor,
52            ExportInterfaceKind::AudioProcessor => Self::AudioProcessor,
53            ExportInterfaceKind::SourceNormalizerPacket => Self::SourceNormalizerPacket,
54            ExportInterfaceKind::SourceNormalizerResource => Self::SourceNormalizerResource,
55        }
56    }
57}
58
59/// Errors reported while constructing one exported plugin root.
60#[derive(Debug, Error, Clone, PartialEq, Eq)]
61pub enum PluginBuildError {
62    #[error("plugin_id must be a valid reverse-DNS identity")]
63    InvalidPluginId,
64    #[error("capability instance id must be a valid reverse-DNS identity")]
65    InvalidCapabilityInstanceId,
66    #[error("plugin_name must contain between 1 and {VESPER_MAX_PLUGIN_NAME_BYTES} UTF-8 bytes")]
67    InvalidPluginName,
68    #[error("plugin must expose at least one capability interface")]
69    NoInterfaces,
70    #[error("duplicate {capability:?} capability instance `{instance_id}`")]
71    DuplicateInterface {
72        capability: PluginCapability,
73        instance_id: String,
74    },
75}
76
77/// Safe builder used by Rust plugin authors.
78pub struct PluginBuilder {
79    plugin_id: String,
80    plugin_name: String,
81    interfaces: Vec<Arc<dyn RawExportInterface>>,
82    interface_keys: HashSet<(ExportInterfaceKind, String)>,
83}
84
85impl PluginBuilder {
86    pub fn new(
87        plugin_id: impl Into<String>,
88        plugin_name: impl Into<String>,
89    ) -> Result<Self, PluginBuildError> {
90        let plugin_id = plugin_id.into();
91        if !is_reverse_dns(&plugin_id, VESPER_MAX_PLUGIN_ID_BYTES) {
92            return Err(PluginBuildError::InvalidPluginId);
93        }
94        let plugin_name = plugin_name.into();
95        if plugin_name.is_empty() || plugin_name.len() > VESPER_MAX_PLUGIN_NAME_BYTES {
96            return Err(PluginBuildError::InvalidPluginName);
97        }
98        Ok(Self {
99            plugin_id,
100            plugin_name,
101            interfaces: Vec::new(),
102            interface_keys: HashSet::new(),
103        })
104    }
105
106    pub fn with_post_download_processor<P>(
107        self,
108        instance_id: impl Into<String>,
109        processor: P,
110    ) -> Result<Self, PluginBuildError>
111    where
112        P: PostDownloadProcessor + 'static,
113    {
114        let instance_id = instance_id.into();
115        self.with_interface(
116            ExportInterfaceKind::PostDownloadProcessor,
117            instance_id.clone(),
118            Arc::new(PostDownloadAdapter {
119                instance_id,
120                processor,
121            }),
122        )
123    }
124
125    pub fn with_pipeline_event_hook<H>(
126        self,
127        instance_id: impl Into<String>,
128        hook: H,
129    ) -> Result<Self, PluginBuildError>
130    where
131        H: PipelineEventHook + 'static,
132    {
133        let instance_id = instance_id.into();
134        self.with_interface(
135            ExportInterfaceKind::PipelineEventHook,
136            instance_id.clone(),
137            Arc::new(PipelineEventHookAdapter { instance_id, hook }),
138        )
139    }
140
141    pub fn with_benchmark_sink<S>(
142        self,
143        instance_id: impl Into<String>,
144        sink: S,
145    ) -> Result<Self, PluginBuildError>
146    where
147        S: BenchmarkSink + 'static,
148    {
149        let instance_id = instance_id.into();
150        self.with_interface(
151            ExportInterfaceKind::BenchmarkSink,
152            instance_id.clone(),
153            Arc::new(BenchmarkSinkAdapter { instance_id, sink }),
154        )
155    }
156
157    pub fn with_native_decoder<F>(
158        self,
159        instance_id: impl Into<String>,
160        factory: F,
161    ) -> Result<Self, PluginBuildError>
162    where
163        F: crate::NativeDecoderPluginFactory + 'static,
164    {
165        let instance_id = instance_id.into();
166        self.with_interface(
167            ExportInterfaceKind::NativeDecoder,
168            instance_id.clone(),
169            Arc::new(session_capabilities::NativeDecoderAdapter::new(
170                instance_id,
171                factory,
172            )),
173        )
174    }
175
176    pub fn with_frame_processor<F>(
177        self,
178        instance_id: impl Into<String>,
179        factory: F,
180    ) -> Result<Self, PluginBuildError>
181    where
182        F: crate::FrameProcessorPluginFactory + 'static,
183    {
184        let instance_id = instance_id.into();
185        self.with_interface(
186            ExportInterfaceKind::FrameProcessor,
187            instance_id.clone(),
188            Arc::new(session_capabilities::FrameProcessorAdapter::new(
189                instance_id,
190                factory,
191            )),
192        )
193    }
194
195    pub fn with_audio_processor<F>(
196        self,
197        instance_id: impl Into<String>,
198        factory: F,
199    ) -> Result<Self, PluginBuildError>
200    where
201        F: crate::AudioProcessorPluginFactory + 'static,
202    {
203        let instance_id = instance_id.into();
204        self.with_interface(
205            ExportInterfaceKind::AudioProcessor,
206            instance_id.clone(),
207            Arc::new(session_capabilities::AudioProcessorAdapter::new(
208                instance_id,
209                factory,
210            )),
211        )
212    }
213
214    pub fn with_source_normalizer_packet<F>(
215        self,
216        instance_id: impl Into<String>,
217        factory: F,
218    ) -> Result<Self, PluginBuildError>
219    where
220        F: crate::SourceNormalizerPacketPluginFactory + 'static,
221    {
222        let instance_id = instance_id.into();
223        self.with_interface(
224            ExportInterfaceKind::SourceNormalizerPacket,
225            instance_id.clone(),
226            Arc::new(session_capabilities::SourceNormalizerPacketAdapter::new(
227                instance_id,
228                factory,
229            )),
230        )
231    }
232
233    pub fn with_source_normalizer_resource<F>(
234        self,
235        instance_id: impl Into<String>,
236        factory: F,
237    ) -> Result<Self, PluginBuildError>
238    where
239        F: crate::SourceNormalizerResourcePluginFactory + 'static,
240    {
241        let instance_id = instance_id.into();
242        self.with_interface(
243            ExportInterfaceKind::SourceNormalizerResource,
244            instance_id.clone(),
245            Arc::new(session_capabilities::SourceNormalizerResourceAdapter::new(
246                instance_id,
247                factory,
248            )),
249        )
250    }
251
252    pub fn build(self) -> Result<Plugin, PluginBuildError> {
253        if self.interfaces.is_empty() {
254            return Err(PluginBuildError::NoInterfaces);
255        }
256        Ok(Plugin {
257            plugin_id: self.plugin_id,
258            plugin_name: self.plugin_name,
259            interfaces: self.interfaces,
260        })
261    }
262
263    fn with_interface(
264        mut self,
265        kind: ExportInterfaceKind,
266        instance_id: String,
267        interface: Arc<dyn RawExportInterface>,
268    ) -> Result<Self, PluginBuildError> {
269        if !is_reverse_dns(&instance_id, VESPER_MAX_CAPABILITY_INSTANCE_ID_BYTES) {
270            return Err(PluginBuildError::InvalidCapabilityInstanceId);
271        }
272        if !self.interface_keys.insert((kind, instance_id.clone())) {
273            return Err(PluginBuildError::DuplicateInterface {
274                capability: kind.into(),
275                instance_id,
276            });
277        }
278        self.interfaces.push(interface);
279        Ok(self)
280    }
281}
282
283/// Fully validated plugin definition returned by an exported factory.
284pub struct Plugin {
285    plugin_id: String,
286    plugin_name: String,
287    interfaces: Vec<Arc<dyn RawExportInterface>>,
288}
289
290impl Plugin {
291    pub fn builder(
292        plugin_id: impl Into<String>,
293        plugin_name: impl Into<String>,
294    ) -> Result<PluginBuilder, PluginBuildError> {
295        PluginBuilder::new(plugin_id, plugin_name)
296    }
297
298    fn invalid() -> Self {
299        Self {
300            plugin_id: String::new(),
301            plugin_name: String::new(),
302            interfaces: Vec::new(),
303        }
304    }
305}
306
307impl RawExportPlugin for Plugin {
308    fn plugin_id(&self) -> &str {
309        &self.plugin_id
310    }
311
312    fn plugin_name(&self) -> &str {
313        &self.plugin_name
314    }
315
316    fn interfaces(&self) -> Vec<Arc<dyn RawExportInterface>> {
317        self.interfaces.clone()
318    }
319}
320
321#[doc(hidden)]
322pub trait PluginFactoryResult {
323    fn into_plugin(self) -> Option<Plugin>;
324}
325
326impl PluginFactoryResult for Plugin {
327    fn into_plugin(self) -> Option<Plugin> {
328        Some(self)
329    }
330}
331
332impl<E> PluginFactoryResult for Result<Plugin, E> {
333    fn into_plugin(self) -> Option<Plugin> {
334        self.ok()
335    }
336}
337
338pub(crate) fn export_plugin<R>(factory: fn() -> R) -> *const player_plugin_abi::VesperPluginRoot
339where
340    R: PluginFactoryResult,
341{
342    player_plugin_abi::export::export_plugin(move || {
343        factory().into_plugin().unwrap_or_else(Plugin::invalid)
344    })
345}
346
347impl ProcessorProgress for ExportProgress<'_> {
348    fn on_progress(&self, ratio: f32) {
349        ExportProgress::on_progress(self, f64::from(ratio));
350    }
351
352    fn is_cancelled(&self) -> bool {
353        ExportProgress::is_cancelled(self)
354    }
355}
356
357struct PostDownloadAdapter<P> {
358    instance_id: String,
359    processor: P,
360}
361
362impl<P> RawExportInterface for PostDownloadAdapter<P>
363where
364    P: PostDownloadProcessor,
365{
366    fn kind(&self) -> ExportInterfaceKind {
367        ExportInterfaceKind::PostDownloadProcessor
368    }
369
370    fn instance_id(&self) -> &str {
371        &self.instance_id
372    }
373
374    fn invoke(&self, operation: ExportOperation<'_>) -> Result<ExportInvocation, ExportFailure> {
375        match operation {
376            ExportOperation::Capabilities => json_invocation(&self.processor.capabilities()),
377            ExportOperation::PostDownloadProcess {
378                input_json,
379                output_path,
380                progress,
381                assemble,
382            } => {
383                let input =
384                    decode::<crate::CompletedDownloadInfo>(input_json).map_err(|error| {
385                        failure(
386                            status::INVALID_ARGUMENT,
387                            &ProcessorError::PayloadCodec(error),
388                        )
389                    })?;
390                let output_path = std::str::from_utf8(output_path).map_err(|error| {
391                    failure(
392                        status::INVALID_ARGUMENT,
393                        &ProcessorError::OutputPath(error.to_string()),
394                    )
395                })?;
396                let result = if assemble {
397                    self.processor
398                        .assemble(&input, Path::new(output_path), &progress)
399                } else {
400                    self.processor
401                        .process(&input, Path::new(output_path), &progress)
402                };
403                match result {
404                    Ok(output) => json_invocation(&output),
405                    Err(error) => {
406                        let status = match error {
407                            ProcessorError::Cancelled => status::CANCELLED,
408                            ProcessorError::AbiViolation(_) => status::ABI_VIOLATION,
409                            _ => status::FAILURE,
410                        };
411                        Err(failure(status, &error))
412                    }
413                }
414            }
415            _ => Err(unexpected_operation("post-download processor")),
416        }
417    }
418}
419
420struct PipelineEventHookAdapter<H> {
421    instance_id: String,
422    hook: H,
423}
424
425impl<H> RawExportInterface for PipelineEventHookAdapter<H>
426where
427    H: PipelineEventHook,
428{
429    fn kind(&self) -> ExportInterfaceKind {
430        ExportInterfaceKind::PipelineEventHook
431    }
432
433    fn instance_id(&self) -> &str {
434        &self.instance_id
435    }
436
437    fn invoke(&self, operation: ExportOperation<'_>) -> Result<ExportInvocation, ExportFailure> {
438        let ExportOperation::PipelineEvent { event_json } = operation else {
439            return Err(unexpected_operation("pipeline event hook"));
440        };
441        if event_json.len() > MAX_PIPELINE_EVENT_INPUT_BYTES {
442            return Err(failure(
443                status::INVALID_ARGUMENT,
444                &PipelineEventHookError::ProtocolViolation(format!(
445                    "pipeline event input exceeds the {MAX_PIPELINE_EVENT_INPUT_BYTES}-byte transport limit"
446                )),
447            ));
448        }
449        let event = decode::<PipelineEvent>(event_json).map_err(|error| {
450            failure(
451                status::INVALID_ARGUMENT,
452                &PipelineEventHookError::PayloadCodec(error),
453            )
454        })?;
455        event
456            .validate()
457            .map_err(|error| failure(status::INVALID_ARGUMENT, &error))?;
458        let outcome = match self.hook.on_event(&event) {
459            Ok(outcome) => outcome,
460            Err(error) => {
461                if let Err(protocol_error) = error.validate_author_failure() {
462                    return Err(failure(status::ABI_VIOLATION, &protocol_error));
463                }
464                return Err(failure(status::FAILURE, &error));
465            }
466        };
467        outcome
468            .validate()
469            .map_err(|error| failure(status::ABI_VIOLATION, &error))?;
470        json_invocation(&outcome)
471    }
472}
473
474struct BenchmarkSinkAdapter<S> {
475    instance_id: String,
476    sink: S,
477}
478
479impl<S> RawExportInterface for BenchmarkSinkAdapter<S>
480where
481    S: BenchmarkSink,
482{
483    fn kind(&self) -> ExportInterfaceKind {
484        ExportInterfaceKind::BenchmarkSink
485    }
486
487    fn instance_id(&self) -> &str {
488        &self.instance_id
489    }
490
491    fn invoke(&self, operation: ExportOperation<'_>) -> Result<ExportInvocation, ExportFailure> {
492        match operation {
493            ExportOperation::BenchmarkBatch { batch_json } => {
494                let batch = decode::<BenchmarkEventBatch>(batch_json).map_err(|error| {
495                    failure(
496                        status::INVALID_ARGUMENT,
497                        &BenchmarkSinkError::PayloadCodec(error),
498                    )
499                })?;
500                batch
501                    .validate()
502                    .map_err(|error| failure(status::INVALID_ARGUMENT, &error))?;
503                let sink_status = self
504                    .sink
505                    .on_event_batch(&batch)
506                    .map_err(|error| failure(status::FAILURE, &error))?;
507                sink_status
508                    .validate_for_batch(batch.events.len())
509                    .map_err(|error| failure(status::ABI_VIOLATION, &error))?;
510                json_invocation(&sink_status)
511            }
512            ExportOperation::BenchmarkFlush => {
513                let report = self
514                    .sink
515                    .flush()
516                    .map_err(|error| failure(status::FAILURE, &error))?;
517                report
518                    .validate()
519                    .map_err(|error| failure(status::ABI_VIOLATION, &error))?;
520                json_invocation(&report)
521            }
522            _ => Err(unexpected_operation("benchmark sink")),
523        }
524    }
525}
526
527fn decode<T>(bytes: &[u8]) -> Result<T, String>
528where
529    T: DeserializeOwned,
530{
531    serde_json::from_slice(bytes).map_err(|error| error.to_string())
532}
533
534fn json_invocation<T>(value: &T) -> Result<ExportInvocation, ExportFailure>
535where
536    T: Serialize,
537{
538    encode(value).map(ExportInvocation::Json)
539}
540
541fn encode<T>(value: &T) -> Result<Vec<u8>, ExportFailure>
542where
543    T: Serialize,
544{
545    serde_json::to_vec(value).map_err(|error| {
546        ExportFailure::with_status(status::ABI_VIOLATION, error.to_string().into_bytes())
547    })
548}
549
550fn failure<T>(failure_status: u32, value: &T) -> ExportFailure
551where
552    T: Serialize,
553{
554    let payload = serde_json::to_vec(value).unwrap_or_else(|error| error.to_string().into_bytes());
555    ExportFailure::with_status(failure_status, payload)
556}
557
558fn unexpected_operation(interface: &str) -> ExportFailure {
559    ExportFailure::with_status(
560        status::ABI_VIOLATION,
561        format!("unexpected operation for {interface}").into_bytes(),
562    )
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568    use crate::{PipelineEventHookOutcome, PluginDiagnostic};
569
570    fn pipeline_event(event_name: &str) -> PipelineEvent {
571        PipelineEvent {
572            run_id: "run-1".to_owned(),
573            session_id: "session-1".to_owned(),
574            platform: "test".to_owned(),
575            protocol: None,
576            event_name: event_name.to_owned(),
577            timestamp_ns: 1,
578            thread: None,
579            resource_identity: Some("download-task:1".to_owned()),
580            attributes: Default::default(),
581            diagnostic: None,
582        }
583    }
584
585    struct Hook;
586
587    impl PipelineEventHook for Hook {
588        fn on_event(
589            &self,
590            _event: &PipelineEvent,
591        ) -> Result<PipelineEventHookOutcome, PipelineEventHookError> {
592            Ok(PipelineEventHookOutcome::accepted())
593        }
594    }
595
596    #[test]
597    fn builder_validates_identity_and_duplicate_interfaces() {
598        assert!(matches!(
599            PluginBuilder::new("invalid", "Fixture"),
600            Err(PluginBuildError::InvalidPluginId)
601        ));
602        let builder = PluginBuilder::new("dev.vesper.fixture", "Fixture")
603            .and_then(|builder| builder.with_pipeline_event_hook("dev.vesper.fixture.hook", Hook))
604            .expect("first interface");
605        assert!(matches!(
606            builder.with_pipeline_event_hook("dev.vesper.fixture.hook", Hook),
607            Err(PluginBuildError::DuplicateInterface { .. })
608        ));
609    }
610
611    #[test]
612    fn hook_adapter_round_trips_typed_json() {
613        let plugin = PluginBuilder::new("dev.vesper.fixture", "Fixture")
614            .and_then(|builder| builder.with_pipeline_event_hook("dev.vesper.fixture.hook", Hook))
615            .and_then(PluginBuilder::build)
616            .expect("plugin");
617        let event = pipeline_event("download.task.completed");
618        let input = serde_json::to_vec(&event).expect("serialize event");
619        let invocation = plugin.interfaces[0]
620            .invoke(ExportOperation::PipelineEvent { event_json: &input })
621            .expect("invoke hook");
622        let ExportInvocation::Json(output) = invocation else {
623            panic!("expected JSON output");
624        };
625        let outcome: PipelineEventHookOutcome =
626            serde_json::from_slice(&output).expect("decode outcome");
627        assert!(outcome.accepted);
628        assert!(outcome.diagnostics.is_empty());
629    }
630
631    #[test]
632    fn hook_adapter_rejects_protocol_violations() {
633        struct InvalidHook;
634
635        impl PipelineEventHook for InvalidHook {
636            fn on_event(
637                &self,
638                _event: &PipelineEvent,
639            ) -> Result<PipelineEventHookOutcome, PipelineEventHookError> {
640                Ok(PipelineEventHookOutcome {
641                    accepted: true,
642                    measurements: Vec::new(),
643                    diagnostics: vec![PluginDiagnostic {
644                        code: "x".repeat(300),
645                        severity: crate::PluginDiagnosticSeverity::Error,
646                        message: "invalid".to_owned(),
647                        attributes: Default::default(),
648                    }],
649                })
650            }
651        }
652
653        let plugin = PluginBuilder::new("dev.vesper.fixture", "Fixture")
654            .and_then(|builder| {
655                builder.with_pipeline_event_hook("dev.vesper.fixture.hook", InvalidHook)
656            })
657            .and_then(PluginBuilder::build)
658            .expect("plugin");
659        let input = serde_json::to_vec(&pipeline_event("download.task.completed"))
660            .expect("serialize event");
661        assert!(
662            plugin.interfaces[0]
663                .invoke(ExportOperation::PipelineEvent { event_json: &input })
664                .is_err()
665        );
666    }
667
668    #[test]
669    fn hook_adapter_rejects_oversized_input_before_invoking_author_code() {
670        use std::sync::atomic::{AtomicUsize, Ordering};
671
672        struct CountingHook(Arc<AtomicUsize>);
673
674        impl PipelineEventHook for CountingHook {
675            fn on_event(
676                &self,
677                _event: &PipelineEvent,
678            ) -> Result<PipelineEventHookOutcome, PipelineEventHookError> {
679                self.0.fetch_add(1, Ordering::SeqCst);
680                Ok(PipelineEventHookOutcome::accepted())
681            }
682        }
683
684        let calls = Arc::new(AtomicUsize::new(0));
685        let plugin = PluginBuilder::new("dev.vesper.fixture", "Fixture")
686            .and_then(|builder| {
687                builder.with_pipeline_event_hook(
688                    "dev.vesper.fixture.hook",
689                    CountingHook(calls.clone()),
690                )
691            })
692            .and_then(PluginBuilder::build)
693            .expect("plugin");
694        let input = vec![b' '; MAX_PIPELINE_EVENT_INPUT_BYTES + 1];
695        let error = plugin.interfaces[0]
696            .invoke(ExportOperation::PipelineEvent { event_json: &input })
697            .expect_err("oversized input must be rejected");
698
699        assert_eq!(error.status(), status::INVALID_ARGUMENT);
700        assert_eq!(calls.load(Ordering::SeqCst), 0);
701    }
702
703    #[test]
704    fn hook_adapter_accepts_unknown_event_names_and_ignores_appended_json_fields() {
705        let plugin = PluginBuilder::new("dev.vesper.fixture", "Fixture")
706            .and_then(|builder| builder.with_pipeline_event_hook("dev.vesper.fixture.hook", Hook))
707            .and_then(PluginBuilder::build)
708            .expect("plugin");
709        let mut value = serde_json::to_value(pipeline_event("vendor.future.event"))
710            .expect("serialize event value");
711        value.as_object_mut().expect("event object").insert(
712            "futureField".to_owned(),
713            serde_json::json!({ "nested": true }),
714        );
715        let input = serde_json::to_vec(&value).expect("serialize extended event");
716
717        assert!(
718            plugin.interfaces[0]
719                .invoke(ExportOperation::PipelineEvent { event_json: &input })
720                .is_ok()
721        );
722    }
723
724    #[test]
725    fn hook_adapter_marks_host_owned_author_errors_as_abi_violations() {
726        struct InvalidErrorHook;
727
728        impl PipelineEventHook for InvalidErrorHook {
729            fn on_event(
730                &self,
731                _event: &PipelineEvent,
732            ) -> Result<PipelineEventHookOutcome, PipelineEventHookError> {
733                Err(PipelineEventHookError::AbiViolation(
734                    "forged by author code".to_owned(),
735                ))
736            }
737        }
738
739        let plugin = PluginBuilder::new("dev.vesper.fixture", "Fixture")
740            .and_then(|builder| {
741                builder.with_pipeline_event_hook("dev.vesper.fixture.hook", InvalidErrorHook)
742            })
743            .and_then(PluginBuilder::build)
744            .expect("plugin");
745        let input = serde_json::to_vec(&pipeline_event("download.task.completed"))
746            .expect("serialize event");
747        let error = plugin.interfaces[0]
748            .invoke(ExportOperation::PipelineEvent { event_json: &input })
749            .expect_err("host-owned error kind must fail the ABI contract");
750
751        assert_eq!(error.status(), status::ABI_VIOLATION);
752    }
753}