1#![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#[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#[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
77pub 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
283pub 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}