1#![allow(unsafe_code)]
33
34use std::fmt;
35use std::mem::ManuallyDrop;
36use std::panic::{catch_unwind, AssertUnwindSafe};
37
38use crate::is_valid_name;
39
40pub const ABI_MAJOR: u32 = 1;
42pub const ABI_MINOR: u32 = 0;
44
45pub const DYLIB_ENTRY_SYMBOL: &str = "rich_plugin_entry";
47
48pub const MAX_CAPABILITIES: usize = 64;
50pub const MAX_FIELD_LEN: usize = 1024;
52
53pub const STATUS_OK: u32 = 0;
55pub const STATUS_ERROR: u32 = 1;
57
58#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
61#[non_exhaustive]
62pub enum CapabilityKind {
63 Transform,
66 Highlighter,
71 FenceMarkup,
73 FenceAnsi,
76}
77
78impl CapabilityKind {
79 pub const ALL: [CapabilityKind; 4] = [
81 CapabilityKind::Transform,
82 CapabilityKind::Highlighter,
83 CapabilityKind::FenceMarkup,
84 CapabilityKind::FenceAnsi,
85 ];
86
87 pub const fn code(self) -> u32 {
89 match self {
90 CapabilityKind::Transform => 1,
91 CapabilityKind::Highlighter => 2,
92 CapabilityKind::FenceMarkup => 3,
93 CapabilityKind::FenceAnsi => 4,
94 }
95 }
96
97 pub fn from_code(code: u32) -> Option<Self> {
99 Self::ALL.into_iter().find(|kind| kind.code() == code)
100 }
101
102 pub const fn as_str(self) -> &'static str {
104 match self {
105 CapabilityKind::Transform => "transform",
106 CapabilityKind::Highlighter => "highlighter",
107 CapabilityKind::FenceMarkup => "fence-markup",
108 CapabilityKind::FenceAnsi => "fence-ansi",
109 }
110 }
111
112 pub fn parse(name: &str) -> Option<Self> {
114 Self::ALL.into_iter().find(|kind| kind.as_str() == name)
115 }
116
117 pub const fn produces_ansi(self) -> bool {
119 matches!(self, CapabilityKind::FenceAnsi)
120 }
121}
122
123impl fmt::Display for CapabilityKind {
124 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125 f.write_str(self.as_str())
126 }
127}
128
129#[derive(Clone, Debug, PartialEq, Eq)]
131pub struct AbiCapability {
132 pub kind: CapabilityKind,
133 pub name: String,
135}
136
137#[derive(Clone, Debug, PartialEq, Eq)]
141pub struct PluginAbi {
142 pub abi_major: u32,
143 pub abi_minor: u32,
144 pub name: String,
146 pub version: String,
147 pub description: String,
148 pub capabilities: Vec<AbiCapability>,
150}
151
152#[derive(Clone, Debug, PartialEq, Eq)]
154pub enum AbiError {
155 Incompatible { major: u32, minor: u32 },
157 Invalid(String),
159}
160
161impl fmt::Display for AbiError {
162 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163 match self {
164 AbiError::Incompatible { major, minor } => write!(
165 f,
166 "built for plugin ABI {major}.{minor}, but this host supports ABI \
167 {ABI_MAJOR}.x; rebuild it against rs-rich-plugin-api with ABI {ABI_MAJOR}"
168 ),
169 AbiError::Invalid(message) => f.write_str(message),
170 }
171 }
172}
173
174impl std::error::Error for AbiError {}
175
176const MANIFEST_MAGIC: &str = "rich-plugin-abi";
178
179impl PluginAbi {
180 pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
182 PluginAbi {
183 abi_major: ABI_MAJOR,
184 abi_minor: ABI_MINOR,
185 name: name.into(),
186 version: version.into(),
187 description: String::new(),
188 capabilities: Vec::new(),
189 }
190 }
191
192 pub fn validate(&self) -> Result<(), AbiError> {
194 if self.abi_major != ABI_MAJOR {
195 return Err(AbiError::Incompatible {
196 major: self.abi_major,
197 minor: self.abi_minor,
198 });
199 }
200 if !is_valid_name(&self.name) {
201 return Err(AbiError::Invalid(format!(
202 "invalid plugin name {:?}: use lowercase letters, digits, '-', '_' and '.'",
203 self.name
204 )));
205 }
206 for (field, value) in [
207 ("version", &self.version),
208 ("description", &self.description),
209 ] {
210 if value.len() > MAX_FIELD_LEN || value.chars().any(char::is_control) {
211 return Err(AbiError::Invalid(format!(
212 "the {field} must be one line of at most {MAX_FIELD_LEN} bytes"
213 )));
214 }
215 }
216 if self.version.is_empty() {
217 return Err(AbiError::Invalid("the version is empty".into()));
218 }
219 if self.capabilities.len() > MAX_CAPABILITIES {
220 return Err(AbiError::Invalid(format!(
221 "{} capabilities declared; at most {MAX_CAPABILITIES} are allowed",
222 self.capabilities.len()
223 )));
224 }
225 for (i, capability) in self.capabilities.iter().enumerate() {
226 if !is_valid_name(&capability.name) {
227 return Err(AbiError::Invalid(format!(
228 "invalid {} name {:?}",
229 capability.kind, capability.name
230 )));
231 }
232 if self.capabilities[..i].contains(capability) {
233 return Err(AbiError::Invalid(format!(
234 "{} {:?} is declared twice",
235 capability.kind, capability.name
236 )));
237 }
238 }
239 Ok(())
240 }
241
242 pub fn to_manifest(&self) -> String {
254 let mut out = format!(
255 "{MANIFEST_MAGIC} {}.{}\nname {}\nversion {}\n",
256 self.abi_major, self.abi_minor, self.name, self.version
257 );
258 if !self.description.is_empty() {
259 out.push_str(&format!("description {}\n", self.description));
260 }
261 for capability in &self.capabilities {
262 out.push_str(&format!(
263 "capability {} {}\n",
264 capability.kind, capability.name
265 ));
266 }
267 out
268 }
269
270 pub fn parse_manifest(text: &str) -> Result<PluginAbi, AbiError> {
274 let mut lines = text.lines();
275 let first = lines.next().unwrap_or_default();
276 let version = first
277 .strip_prefix(MANIFEST_MAGIC)
278 .and_then(|rest| rest.strip_prefix(' '))
279 .ok_or_else(|| {
280 AbiError::Invalid(format!(
281 "the manifest must start with `{MANIFEST_MAGIC} MAJOR.MINOR`"
282 ))
283 })?;
284 let (major, minor) = version
285 .split_once('.')
286 .and_then(|(major, minor)| Some((major.parse().ok()?, minor.parse().ok()?)))
287 .ok_or_else(|| AbiError::Invalid(format!("invalid ABI version {version:?}")))?;
288 if major != ABI_MAJOR {
289 return Err(AbiError::Incompatible { major, minor });
290 }
291 let mut abi = PluginAbi {
292 abi_major: major,
293 abi_minor: minor,
294 name: String::new(),
295 version: String::new(),
296 description: String::new(),
297 capabilities: Vec::new(),
298 };
299 for line in lines {
300 if line.trim().is_empty() {
301 continue;
302 }
303 let (key, value) = line.split_once(' ').unwrap_or((line, ""));
304 match key {
305 "name" => abi.name = value.to_string(),
306 "version" => abi.version = value.to_string(),
307 "description" => abi.description = value.to_string(),
308 "capability" => {
309 let (kind, name) = value.split_once(' ').ok_or_else(|| {
310 AbiError::Invalid(format!("expected `capability KIND NAME`: {line:?}"))
311 })?;
312 let kind = CapabilityKind::parse(kind).ok_or_else(|| {
313 AbiError::Invalid(format!(
314 "unknown capability kind {kind:?}; this host knows {}",
315 CapabilityKind::ALL.map(CapabilityKind::as_str).join(", ")
316 ))
317 })?;
318 abi.capabilities.push(AbiCapability {
319 kind,
320 name: name.to_string(),
321 });
322 }
323 _ => {}
324 }
325 }
326 abi.validate()?;
327 Ok(abi)
328 }
329}
330
331pub mod wasm {
343 pub const MEMORY: &str = "memory";
344 pub const ALLOC: &str = "rich_plugin_alloc";
345 pub const MANIFEST: &str = "rich_plugin_manifest";
346 pub const CALL: &str = "rich_plugin_call";
347 pub const ERROR_BIT: u64 = 1 << 63;
349}
350
351#[repr(C)]
357#[derive(Clone, Copy, Debug)]
358pub struct AbiStr {
359 pub ptr: *const u8,
360 pub len: usize,
361}
362
363impl AbiStr {
364 pub fn new(text: &str) -> Self {
366 AbiStr {
367 ptr: text.as_ptr(),
368 len: text.len(),
369 }
370 }
371
372 pub unsafe fn as_str<'a>(self) -> Result<&'a str, AbiError> {
379 if self.ptr.is_null() {
380 return if self.len == 0 {
381 Ok("")
382 } else {
383 Err(AbiError::Invalid("a null string with a length".into()))
384 };
385 }
386 let bytes = unsafe { std::slice::from_raw_parts(self.ptr, self.len) };
388 std::str::from_utf8(bytes).map_err(|_| AbiError::Invalid("a string is not UTF-8".into()))
389 }
390}
391
392#[repr(C)]
395#[derive(Clone, Copy, Debug)]
396pub struct AbiCapabilityEntry {
397 pub kind: u32,
398 pub name: AbiStr,
399}
400
401#[repr(C)]
404#[derive(Debug)]
405pub struct AbiOutput {
406 pub ptr: *mut u8,
407 pub len: usize,
408 pub cap: usize,
409}
410
411impl AbiOutput {
412 pub const fn empty() -> Self {
414 AbiOutput {
415 ptr: std::ptr::null_mut(),
416 len: 0,
417 cap: 0,
418 }
419 }
420
421 pub fn from_string(text: String) -> Self {
423 let mut bytes = ManuallyDrop::new(text.into_bytes());
424 AbiOutput {
425 ptr: bytes.as_mut_ptr(),
426 len: bytes.len(),
427 cap: bytes.capacity(),
428 }
429 }
430}
431
432pub type AbiCallFn = unsafe extern "C" fn(
436 capability: usize,
437 input: AbiStr,
438 width: u32,
439 output: *mut AbiOutput,
440) -> u32;
441
442pub type AbiFreeFn = unsafe extern "C" fn(output: AbiOutput);
444
445#[repr(C)]
449#[derive(Clone, Copy, Debug)]
450pub struct PluginVTable {
451 pub call: Option<AbiCallFn>,
452 pub free: Option<AbiFreeFn>,
453}
454
455#[derive(Clone, Copy, Debug)]
457pub struct PluginFunctions {
458 pub call: AbiCallFn,
459 pub free: AbiFreeFn,
460}
461
462#[repr(C)]
466#[derive(Debug)]
467pub struct PluginDescriptor {
468 pub abi_major: u32,
469 pub abi_minor: u32,
470 pub name: AbiStr,
471 pub version: AbiStr,
472 pub description: AbiStr,
473 pub capabilities: *const AbiCapabilityEntry,
474 pub capability_count: usize,
475 pub vtable: PluginVTable,
476}
477
478pub unsafe fn read_descriptor(
489 descriptor: *const PluginDescriptor,
490) -> Result<(PluginAbi, PluginFunctions), AbiError> {
491 if descriptor.is_null() {
492 return Err(AbiError::Invalid(format!(
493 "{DYLIB_ENTRY_SYMBOL} returned no descriptor (it failed to initialise)"
494 )));
495 }
496 let (major, minor) = unsafe {
498 let version = descriptor.cast::<u32>();
499 (version.read(), version.add(1).read())
500 };
501 if major != ABI_MAJOR {
502 return Err(AbiError::Incompatible { major, minor });
503 }
504 let descriptor = unsafe { &*descriptor };
506 if descriptor.capability_count > MAX_CAPABILITIES {
507 return Err(AbiError::Invalid(format!(
508 "{} capabilities declared; at most {MAX_CAPABILITIES} are allowed",
509 descriptor.capability_count
510 )));
511 }
512 let entries: &[AbiCapabilityEntry] = if descriptor.capability_count == 0 {
513 &[]
514 } else if descriptor.capabilities.is_null() {
515 return Err(AbiError::Invalid("a null capability list".into()));
516 } else {
517 unsafe { std::slice::from_raw_parts(descriptor.capabilities, descriptor.capability_count) }
519 };
520 let text = |s: AbiStr, what: &str| -> Result<String, AbiError> {
521 if s.len > MAX_FIELD_LEN {
522 return Err(AbiError::Invalid(format!("the {what} is too long")));
523 }
524 unsafe { s.as_str() }.map(str::to_string)
526 };
527 let mut capabilities = Vec::with_capacity(entries.len());
528 for entry in entries {
529 let kind = CapabilityKind::from_code(entry.kind).ok_or_else(|| {
530 AbiError::Invalid(format!(
531 "unknown capability kind {}; this host knows {}",
532 entry.kind,
533 CapabilityKind::ALL.map(CapabilityKind::as_str).join(", ")
534 ))
535 })?;
536 capabilities.push(AbiCapability {
537 kind,
538 name: text(entry.name, "capability name")?,
539 });
540 }
541 let abi = PluginAbi {
542 abi_major: major,
543 abi_minor: minor,
544 name: text(descriptor.name, "name")?,
545 version: text(descriptor.version, "version")?,
546 description: text(descriptor.description, "description")?,
547 capabilities,
548 };
549 abi.validate()?;
550 let (Some(call), Some(free)) = (descriptor.vtable.call, descriptor.vtable.free) else {
551 return Err(AbiError::Invalid(
552 "a null function in the vtable (`call` and `free` are required)".into(),
553 ));
554 };
555 Ok((abi, PluginFunctions { call, free }))
556}
557
558pub type ExportFn = fn(input: &str, width: u32) -> Result<String, String>;
565
566pub struct Exports {
569 abi: PluginAbi,
570 functions: Vec<ExportFn>,
571}
572
573impl Exports {
574 pub fn new(name: &str, version: &str) -> Self {
576 Exports {
577 abi: PluginAbi::new(name, version),
578 functions: Vec::new(),
579 }
580 }
581
582 pub fn description(mut self, description: &str) -> Self {
584 self.abi.description = description.to_string();
585 self
586 }
587
588 pub fn capability(mut self, kind: CapabilityKind, name: &str, function: ExportFn) -> Self {
590 self.abi.capabilities.push(AbiCapability {
591 kind,
592 name: name.to_string(),
593 });
594 self.functions.push(function);
595 self
596 }
597
598 pub fn transform(self, name: &str, function: ExportFn) -> Self {
600 self.capability(CapabilityKind::Transform, name, function)
601 }
602
603 pub fn highlighter(self, name: &str, function: ExportFn) -> Self {
605 self.capability(CapabilityKind::Highlighter, name, function)
606 }
607
608 pub fn fence_markup(self, language: &str, function: ExportFn) -> Self {
610 self.capability(CapabilityKind::FenceMarkup, language, function)
611 }
612
613 pub fn fence_ansi(self, language: &str, function: ExportFn) -> Self {
615 self.capability(CapabilityKind::FenceAnsi, language, function)
616 }
617
618 pub fn abi(&self) -> &PluginAbi {
620 &self.abi
621 }
622}
623
624#[doc(hidden)]
627pub struct Exported {
628 exports: Exports,
629 _entries: Vec<AbiCapabilityEntry>,
630 descriptor: PluginDescriptor,
631}
632
633unsafe impl Send for Exported {}
637unsafe impl Sync for Exported {}
639
640impl Exported {
641 pub fn new(exports: Exports, call: AbiCallFn) -> Self {
642 let entries: Vec<AbiCapabilityEntry> = exports
643 .abi
644 .capabilities
645 .iter()
646 .map(|capability| AbiCapabilityEntry {
647 kind: capability.kind.code(),
648 name: AbiStr::new(&capability.name),
649 })
650 .collect();
651 let descriptor = PluginDescriptor {
652 abi_major: exports.abi.abi_major,
653 abi_minor: exports.abi.abi_minor,
654 name: AbiStr::new(&exports.abi.name),
655 version: AbiStr::new(&exports.abi.version),
656 description: AbiStr::new(&exports.abi.description),
657 capabilities: entries.as_ptr(),
658 capability_count: entries.len(),
659 vtable: PluginVTable {
660 call: Some(call),
661 free: Some(free_output),
662 },
663 };
664 Exported {
665 exports,
666 _entries: entries,
667 descriptor,
668 }
669 }
670
671 pub fn descriptor(&'static self) -> *const PluginDescriptor {
672 &self.descriptor
673 }
674
675 pub unsafe fn call(
682 this: Option<&Exported>,
683 capability: usize,
684 input: AbiStr,
685 width: u32,
686 output: *mut AbiOutput,
687 ) -> u32 {
688 let result = catch_unwind(AssertUnwindSafe(|| {
689 let this = this.ok_or("the plugin was called before it was initialised")?;
690 let function = this
691 .exports
692 .functions
693 .get(capability)
694 .ok_or_else(|| format!("no capability number {capability}"))?;
695 let input = unsafe { input.as_str() }.map_err(|e| e.to_string())?;
697 function(input, width)
698 }));
699 let (status, text) = match result {
700 Ok(Ok(text)) => (STATUS_OK, text),
701 Ok(Err(message)) => (STATUS_ERROR, message),
702 Err(_) => (STATUS_ERROR, "the plugin panicked".to_string()),
703 };
704 if output.is_null() {
705 return STATUS_ERROR;
706 }
707 unsafe { output.write(AbiOutput::from_string(text)) };
709 status
710 }
711
712 pub fn entry(
715 slot: &'static std::sync::OnceLock<Exported>,
716 make: fn() -> Exports,
717 call: AbiCallFn,
718 ) -> *const PluginDescriptor {
719 match catch_unwind(AssertUnwindSafe(|| {
720 slot.get_or_init(|| Exported::new(make(), call))
721 })) {
722 Ok(exported) => exported.descriptor(),
723 Err(_) => std::ptr::null(),
724 }
725 }
726}
727
728pub unsafe extern "C" fn free_output(output: AbiOutput) {
735 if !output.ptr.is_null() {
736 drop(unsafe { Vec::from_raw_parts(output.ptr, output.len, output.cap) });
738 }
739}
740
741#[macro_export]
763macro_rules! export_dylib_plugin {
764 ($exports:expr) => {
765 #[doc(hidden)]
766 static __RICH_PLUGIN_EXPORTED: ::std::sync::OnceLock<$crate::abi::Exported> =
767 ::std::sync::OnceLock::new();
768
769 #[doc(hidden)]
770 #[allow(unsafe_code)]
771 unsafe extern "C" fn __rich_plugin_call(
772 capability: usize,
773 input: $crate::abi::AbiStr,
774 width: u32,
775 output: *mut $crate::abi::AbiOutput,
776 ) -> u32 {
777 unsafe {
779 $crate::abi::Exported::call(
780 __RICH_PLUGIN_EXPORTED.get(),
781 capability,
782 input,
783 width,
784 output,
785 )
786 }
787 }
788
789 #[allow(unsafe_code)]
791 #[unsafe(no_mangle)]
792 pub extern "C" fn rich_plugin_entry() -> *const $crate::abi::PluginDescriptor {
793 $crate::abi::Exported::entry(&__RICH_PLUGIN_EXPORTED, $exports, __rich_plugin_call)
794 }
795 };
796}
797
798#[cfg(test)]
799mod tests {
800 use super::*;
801
802 fn upper(input: &str, _: u32) -> Result<String, String> {
803 Ok(input.to_uppercase())
804 }
805
806 fn fails(_: &str, _: u32) -> Result<String, String> {
807 Err("no".into())
808 }
809
810 fn panics(_: &str, _: u32) -> Result<String, String> {
811 panic!("boom")
812 }
813
814 fn exports() -> Exports {
815 Exports::new("shout", "0.1.0")
816 .description("Upper-cases text")
817 .transform("upper", upper)
818 .fence_markup("shout", fails)
819 .highlighter("boom", panics)
820 }
821
822 crate::export_dylib_plugin!(exports);
823
824 unsafe fn call(descriptor: &PluginFunctions, capability: usize, input: &str) -> (u32, String) {
825 let mut output = AbiOutput::empty();
826 let status = unsafe { (descriptor.call)(capability, AbiStr::new(input), 80, &mut output) };
827 let text = unsafe { std::slice::from_raw_parts(output.ptr, output.len) };
828 let text = String::from_utf8(text.to_vec()).unwrap();
829 unsafe { (descriptor.free)(output) };
830 (status, text)
831 }
832
833 #[test]
834 fn a_descriptor_round_trips_through_the_c_abi() {
835 let (abi, vtable) = unsafe { read_descriptor(rich_plugin_entry()) }.unwrap();
836 assert_eq!(abi, *exports().abi());
837 assert_eq!(abi.capabilities[1].kind, CapabilityKind::FenceMarkup);
838 assert_eq!(
839 unsafe { call(&vtable, 0, "hi") },
840 (STATUS_OK, "HI".to_string())
841 );
842 assert_eq!(
843 unsafe { call(&vtable, 1, "hi") },
844 (STATUS_ERROR, "no".to_string())
845 );
846 let (status, message) = unsafe { call(&vtable, 2, "hi") };
847 assert_eq!(status, STATUS_ERROR);
848 assert!(message.contains("panicked"), "{message}");
849 let (status, message) = unsafe { call(&vtable, 9, "hi") };
850 assert_eq!(status, STATUS_ERROR);
851 assert!(message.contains("no capability"), "{message}");
852 }
853
854 #[test]
855 fn another_major_is_refused_before_the_rest_is_read() {
856 let words: [u32; 2] = [ABI_MAJOR + 1, 0];
859 let error = unsafe { read_descriptor(words.as_ptr().cast()) }.unwrap_err();
860 assert_eq!(
861 error,
862 AbiError::Incompatible {
863 major: ABI_MAJOR + 1,
864 minor: 0
865 }
866 );
867 assert!(error.to_string().contains("rebuild"));
868 assert!(unsafe { read_descriptor(std::ptr::null()) }.is_err());
869 }
870
871 #[test]
872 fn a_null_function_in_the_vtable_is_refused() {
873 for (call, free) in [(false, true), (true, false), (false, false)] {
875 let mut exported = Exported::new(exports(), __rich_plugin_call);
876 let vtable = &mut exported.descriptor.vtable;
877 if !call {
878 vtable.call = None;
879 }
880 if !free {
881 vtable.free = None;
882 }
883 let error = unsafe { read_descriptor(&exported.descriptor) }.unwrap_err();
884 assert!(error.to_string().contains("null function"), "{error}");
885 }
886 }
887
888 #[test]
889 fn a_newer_minor_loads_but_an_unknown_kind_does_not() {
890 let mut exported = Exported::new(exports(), __rich_plugin_call);
891 exported.descriptor.abi_minor = ABI_MINOR + 3;
892 let (abi, _) = unsafe { read_descriptor(&exported.descriptor) }.unwrap();
893 assert_eq!(abi.abi_minor, ABI_MINOR + 3);
894 let entries = [AbiCapabilityEntry {
895 kind: 99,
896 name: AbiStr::new("x"),
897 }];
898 exported.descriptor.capabilities = entries.as_ptr();
899 exported.descriptor.capability_count = 1;
900 let error = unsafe { read_descriptor(&exported.descriptor) }.unwrap_err();
901 assert!(error.to_string().contains("unknown capability kind 99"));
902 }
903
904 #[test]
905 fn manifests_round_trip_and_are_checked() {
906 let abi = exports().abi().clone();
907 let manifest = abi.to_manifest();
908 assert!(manifest.starts_with("rich-plugin-abi 1.0\nname shout\n"));
909 assert_eq!(PluginAbi::parse_manifest(&manifest).unwrap(), abi);
910 let extra = manifest.replace("name shout", "name shout\nhomepage x");
912 assert_eq!(PluginAbi::parse_manifest(&extra).unwrap(), abi);
913 let other = manifest.replace("rich-plugin-abi 1.0", "rich-plugin-abi 2.0");
914 assert_eq!(
915 PluginAbi::parse_manifest(&other),
916 Err(AbiError::Incompatible { major: 2, minor: 0 })
917 );
918 for bad in [
919 "",
920 "hello",
921 "rich-plugin-abi one",
922 "rich-plugin-abi 1.0\nname Bad\nversion 1",
923 "rich-plugin-abi 1.0\nname ok\n",
924 "rich-plugin-abi 1.0\nname ok\nversion 1\ncapability sing x",
925 "rich-plugin-abi 1.0\nname ok\nversion 1\ncapability transform X",
926 "rich-plugin-abi 1.0\nname ok\nversion 1\ncapability transform x\ncapability transform x",
927 ] {
928 assert!(PluginAbi::parse_manifest(bad).is_err(), "{bad:?}");
929 }
930 }
931}