Skip to main content

lenso_plugin_bundle/
lib.rs

1//! Immutable Plugin Release manifests, source materialization, and Bundle verification.
2
3mod model;
4
5use std::{
6    collections::{BTreeMap, BTreeSet},
7    fmt, fs,
8    path::{Component, Path, PathBuf},
9};
10
11use lenso_app_plan::{
12    CapabilityEndpointPlan, CapabilityOperationKind, CapabilityRequirementPlan, ExecutionClassId,
13    authoring::PluginDescriptor,
14};
15pub use model::*;
16use serde::{Deserialize, de::DeserializeOwned};
17use serde_json::Value;
18use sha2::{Digest, Sha256};
19
20/// The only manifest filename accepted in a materialized Plugin Bundle.
21pub const MANIFEST_FILE: &str = "lenso-plugin.json";
22
23/// Custom section carrying source-derived Plugin descriptor bytes.
24pub const PLUGIN_DESCRIPTOR_SECTION: &str = "lenso.plugin-descriptor.v1";
25
26/// Maximum accepted source-derived descriptor size.
27pub const MAX_PLUGIN_DESCRIPTOR_BYTES: usize = 64 * 1024;
28
29/// Source-only input for one generated V2 Plugin Bundle.
30#[derive(Clone, Debug, Eq, PartialEq)]
31pub struct SourcePluginBuild {
32    pub package_manifest: PathBuf,
33    pub wasm_module: PathBuf,
34    pub output: PathBuf,
35}
36
37#[derive(Clone, Debug)]
38struct SourceManifestDocument {
39    value: PluginManifestV2,
40    bytes: Vec<u8>,
41    digest: String,
42}
43
44impl SourceManifestDocument {
45    fn parse(input: &[u8]) -> Result<Self, BundleError> {
46        let value = strict_json::<PluginManifestV2>(input)?;
47        Self::from_value(value)
48    }
49
50    fn from_value(value: PluginManifestV2) -> Result<Self, BundleError> {
51        validate_source_manifest(&value)?;
52        let json = serde_json::to_value(&value)
53            .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
54        validate_json_value(&json)?;
55        let bytes = serde_json::to_vec(&json)
56            .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
57        let digest = sha256_digest(&bytes);
58        Ok(Self {
59            value,
60            bytes,
61            digest,
62        })
63    }
64}
65
66#[derive(Debug, Deserialize)]
67struct CargoManifest {
68    package: CargoPackage,
69}
70
71#[derive(Debug, Deserialize)]
72struct CargoPackage {
73    version: String,
74    metadata: CargoMetadata,
75}
76
77#[derive(Debug, Deserialize)]
78struct CargoMetadata {
79    lenso: CargoLensoMetadata,
80}
81
82#[derive(Debug, Deserialize)]
83#[serde(deny_unknown_fields, rename_all = "kebab-case")]
84struct CargoLensoMetadata {
85    plugin_id: String,
86    root_slot: String,
87}
88
89#[derive(Debug, Deserialize)]
90#[serde(deny_unknown_fields)]
91struct GuestRuntimeDescriptor {
92    abi: String,
93    capabilities: Vec<GuestCapability>,
94    #[serde(default)]
95    required_capabilities: Vec<GuestRequirement>,
96}
97
98#[derive(Debug, Deserialize)]
99#[serde(deny_unknown_fields)]
100struct GuestCapability {
101    capability_id: String,
102    descriptor_version: String,
103    request_operations: Vec<String>,
104    #[serde(default)]
105    stream_operations: Vec<String>,
106}
107
108#[derive(Debug, Deserialize)]
109#[serde(deny_unknown_fields)]
110struct GuestRequirement {
111    capability_id: String,
112    descriptor_version: String,
113    cardinality: String,
114}
115
116/// Verified closure of one immutable Plugin Release.
117#[derive(Clone, Debug, Eq, PartialEq)]
118pub struct VerifiedBundle {
119    pub plugin_id: String,
120    pub release_version: String,
121    pub manifest_digest: String,
122    pub artifact_digests: Vec<String>,
123    pub product_metadata_digests: Vec<String>,
124}
125
126/// A Plugin authoring or immutable Bundle invariant failed closed.
127#[derive(Clone, Debug, Eq, PartialEq)]
128pub enum BundleError {
129    InvalidManifest(String),
130    InvalidBundle(String),
131    DigestMismatch(String),
132    Io(String),
133    Wasm(String),
134}
135
136impl fmt::Display for BundleError {
137    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
138        match self {
139            Self::InvalidManifest(detail) => write!(formatter, "invalid Plugin Manifest: {detail}"),
140            Self::InvalidBundle(detail) => write!(formatter, "invalid Plugin Bundle: {detail}"),
141            Self::DigestMismatch(subject) => write!(formatter, "digest mismatch for {subject}"),
142            Self::Io(detail) => formatter.write_str(detail),
143            Self::Wasm(detail) => write!(
144                formatter,
145                "failed to encode WebAssembly Component: {detail}"
146            ),
147        }
148    }
149}
150
151impl std::error::Error for BundleError {}
152
153/// Builds a one-entry V2 Plugin Bundle entirely from package and source evidence.
154pub fn build_source_plugin_bundle(
155    build: &SourcePluginBuild,
156) -> Result<VerifiedBundle, BundleError> {
157    if build.output.exists() {
158        return invalid_bundle(format!(
159            "output `{}` already exists",
160            build.output.display()
161        ));
162    }
163    let package_bytes = read_regular_file(&build.package_manifest, "Cargo manifest")?;
164    let package = toml::from_slice::<CargoManifest>(&package_bytes)
165        .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
166    let module = read_regular_file(&build.wasm_module, "Plugin Wasm module")?;
167    let component = wit_component::ComponentEncoder::default()
168        .module(&module)
169        .map_err(|error| BundleError::Wasm(error.to_string()))?
170        .validate(true)
171        .encode()
172        .map_err(|error| BundleError::Wasm(error.to_string()))?;
173    let runtime_descriptor = extract_plugin_descriptor(&component)?;
174    let artifact = PluginArtifactV2 {
175        path: "plugin.wasm".to_owned(),
176        digest: sha256_digest(&component),
177        size: u64::try_from(component.len())
178            .map_err(|_| BundleError::InvalidBundle("Artifact size exceeds u64".to_owned()))?,
179        media_type: "application/wasm".to_owned(),
180        target: "wasm32-unknown-unknown".to_owned(),
181    };
182    let descriptor = portable_plugin_descriptor(
183        &package.package.metadata.lenso.plugin_id,
184        &package.package.version,
185        &package.package.metadata.lenso.root_slot,
186        &artifact.digest,
187        &runtime_descriptor,
188    )?;
189    let document = SourceManifestDocument::from_value(PluginManifestV2 {
190        schema_version: 2,
191        plugin_id: package.package.metadata.lenso.plugin_id,
192        release_version: package.package.version,
193        artifact,
194        entry: PluginEntryV2 { descriptor },
195    })?;
196
197    let output_parent = build.output.parent().unwrap_or_else(|| Path::new("."));
198    fs::create_dir_all(output_parent).map_err(io_error)?;
199    let staging = tempfile::Builder::new()
200        .prefix(".lenso-plugin-")
201        .tempdir_in(output_parent)
202        .map_err(io_error)?;
203    write_bundle_file(staging.path(), &document.value.artifact.path, &component)?;
204    fs::write(staging.path().join(MANIFEST_FILE), &document.bytes).map_err(io_error)?;
205    fs::rename(staging.path(), &build.output).map_err(io_error)?;
206    verify_bundle_directory(&build.output)
207}
208
209/// Verifies an already materialized directory as an exact immutable Bundle closure.
210pub fn verify_bundle_directory(root: &Path) -> Result<VerifiedBundle, BundleError> {
211    let manifest_path = root.join(MANIFEST_FILE);
212    let manifest_bytes = read_regular_file(&manifest_path, "Plugin Manifest")?;
213    let mut files = BTreeMap::new();
214    collect_bundle_files(root, root, &mut files)?;
215    files.remove(MANIFEST_FILE);
216    verify_source_bundle_files(&SourceManifestDocument::parse(&manifest_bytes)?, &files)
217}
218
219fn verify_source_bundle_files(
220    manifest: &SourceManifestDocument,
221    files: &BTreeMap<String, Vec<u8>>,
222) -> Result<VerifiedBundle, BundleError> {
223    let artifact = &manifest.value.artifact;
224    if files.len() != 1 {
225        return invalid_bundle("V2 Bundle must contain exactly one Artifact");
226    }
227    let Some(bytes) = files.get(&artifact.path) else {
228        return invalid_bundle("V2 Bundle does not contain its declared Artifact");
229    };
230    if artifact.size != u64::try_from(bytes.len()).unwrap_or(u64::MAX)
231        || artifact.digest != sha256_digest(bytes)
232    {
233        return Err(BundleError::DigestMismatch(artifact.path.clone()));
234    }
235    let runtime_descriptor = extract_plugin_descriptor(bytes)?;
236    let descriptor = portable_plugin_descriptor(
237        &manifest.value.plugin_id,
238        &manifest.value.release_version,
239        manifest
240            .value
241            .entry
242            .descriptor
243            .get("root_slot")
244            .and_then(Value::as_str)
245            .ok_or_else(|| BundleError::InvalidManifest("root_slot is required".to_owned()))?,
246        &artifact.digest,
247        &runtime_descriptor,
248    )?;
249    let packaged = serde_json::to_vec(&manifest.value.entry.descriptor)
250        .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
251    let derived = serde_json::to_vec(&descriptor)
252        .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
253    if derived != packaged {
254        return invalid_bundle("source descriptor does not match the V2 Plugin entry");
255    }
256    Ok(VerifiedBundle {
257        plugin_id: manifest.value.plugin_id.clone(),
258        release_version: manifest.value.release_version.clone(),
259        manifest_digest: manifest.digest.clone(),
260        artifact_digests: vec![artifact.digest.clone()],
261        product_metadata_digests: Vec::new(),
262    })
263}
264
265fn portable_plugin_descriptor(
266    plugin_id: &str,
267    release_version: &str,
268    root_slot: &str,
269    artifact_digest: &str,
270    encoded: &[u8],
271) -> Result<Value, BundleError> {
272    let runtime = strict_json::<GuestRuntimeDescriptor>(encoded)?;
273    if ![
274        "lenso.json-request@1",
275        "lenso.json-interactions@1",
276        "lenso.json-host-imports@1",
277    ]
278    .contains(&runtime.abi.as_str())
279    {
280        return invalid_manifest("unsupported guest Plugin ABI");
281    }
282    let mut descriptor = PluginDescriptor::new(plugin_id, release_version, root_slot)
283        .with_runtime_package(plugin_id, artifact_digest)
284        .with_execution_class(ExecutionClassId::new("lenso.wasm-component@1"));
285    for capability in runtime.capabilities {
286        let mut endpoint = CapabilityEndpointPlan::new(
287            capability.capability_id,
288            capability.descriptor_version,
289            capability
290                .request_operations
291                .iter()
292                .chain(&capability.stream_operations)
293                .cloned(),
294        );
295        for operation in capability.stream_operations {
296            endpoint = endpoint.with_operation_kind(operation, CapabilityOperationKind::Stream);
297        }
298        descriptor = descriptor.with_capability(endpoint);
299    }
300    for requirement in runtime.required_capabilities {
301        if requirement.cardinality != "one" {
302            return invalid_manifest("unsupported guest Capability cardinality");
303        }
304        descriptor = descriptor.with_requirement(CapabilityRequirementPlan::one(
305            requirement.capability_id,
306            requirement.descriptor_version,
307        ));
308    }
309    serde_json::to_value(descriptor)
310        .map_err(|error| BundleError::InvalidManifest(error.to_string()))
311}
312
313/// Extracts one canonical source-derived Plugin descriptor without executing it.
314pub fn extract_plugin_descriptor(component: &[u8]) -> Result<Vec<u8>, BundleError> {
315    let mut descriptors = Vec::new();
316    collect_plugin_descriptors(component, &mut descriptors)?;
317    let [descriptor] = descriptors.as_slice() else {
318        return invalid_bundle(if descriptors.is_empty() {
319            "Plugin Component does not contain a source-derived descriptor"
320        } else {
321            "Plugin Component contains duplicate source-derived descriptors"
322        });
323    };
324    let value = strict_json::<Value>(descriptor)?;
325    let canonical = serde_json::to_vec(&value)
326        .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
327    if canonical != *descriptor {
328        return invalid_bundle("Plugin descriptor is not canonical JSON");
329    }
330    Ok(descriptor.clone())
331}
332
333fn collect_plugin_descriptors(
334    bytes: &[u8],
335    descriptors: &mut Vec<Vec<u8>>,
336) -> Result<(), BundleError> {
337    for payload in wasmparser::Parser::new(0).parse_all(bytes) {
338        match payload.map_err(|error| BundleError::Wasm(error.to_string()))? {
339            wasmparser::Payload::CustomSection(section)
340                if section.name() == PLUGIN_DESCRIPTOR_SECTION =>
341            {
342                if section.data().len() > MAX_PLUGIN_DESCRIPTOR_BYTES {
343                    return invalid_bundle("Plugin descriptor exceeds the size limit");
344                }
345                descriptors.push(section.data().to_vec());
346            }
347            _ => {}
348        }
349    }
350    Ok(())
351}
352
353fn validate_source_manifest(manifest: &PluginManifestV2) -> Result<(), BundleError> {
354    if manifest.schema_version != 2 {
355        return invalid_manifest("unsupported schema version");
356    }
357    if manifest.plugin_id.is_empty() || semver::Version::parse(&manifest.release_version).is_err() {
358        return invalid_manifest("Plugin identity or Release version is invalid");
359    }
360    validate_relative_path(&manifest.artifact.path)?;
361    digest_component(&manifest.artifact.digest)?;
362    if manifest.artifact.size == 0
363        || manifest.artifact.media_type != "application/wasm"
364        || manifest.artifact.target != "wasm32-unknown-unknown"
365    {
366        return invalid_manifest("V2 Artifact size, Wasm media type, and target must be exact");
367    }
368    if !manifest.entry.descriptor.is_object() {
369        return invalid_manifest("V2 Plugin entry descriptor must be an object");
370    }
371    Ok(())
372}
373
374/// Validates publisher-owned Manifest semantics independently of Host policy.
375#[allow(clippy::too_many_lines)]
376/// Computes the canonical digest syntax used by Plugin Release documents and files.
377pub fn sha256_digest(bytes: &[u8]) -> String {
378    format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
379}
380
381fn strict_json<T: DeserializeOwned>(input: &[u8]) -> Result<T, BundleError> {
382    let mut deserializer = serde_json::Deserializer::from_slice(input);
383    let strict = StrictValue::deserialize(&mut deserializer)
384        .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
385    deserializer
386        .end()
387        .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
388    validate_json_value(&strict.0)?;
389    serde_json::from_value(strict.0)
390        .map_err(|error| BundleError::InvalidManifest(error.to_string()))
391}
392
393#[derive(Clone, Debug)]
394struct StrictValue(Value);
395
396impl<'de> Deserialize<'de> for StrictValue {
397    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
398    where
399        D: serde::Deserializer<'de>,
400    {
401        deserializer.deserialize_any(StrictVisitor)
402    }
403}
404
405struct StrictVisitor;
406
407impl<'de> serde::de::Visitor<'de> for StrictVisitor {
408    type Value = StrictValue;
409
410    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
411        formatter.write_str("strict Plugin Manifest JSON")
412    }
413
414    fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> {
415        Ok(StrictValue(Value::Bool(value)))
416    }
417
418    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
419        Ok(StrictValue(Value::Number(value.into())))
420    }
421
422    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
423    where
424        E: serde::de::Error,
425    {
426        u64::try_from(value)
427            .map_err(|_| E::custom("negative integers are forbidden"))
428            .and_then(|value| self.visit_u64(value))
429    }
430
431    fn visit_f64<E>(self, _: f64) -> Result<Self::Value, E>
432    where
433        E: serde::de::Error,
434    {
435        Err(E::custom("floating-point values are forbidden"))
436    }
437
438    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> {
439        Ok(StrictValue(Value::String(value.to_owned())))
440    }
441
442    fn visit_string<E>(self, value: String) -> Result<Self::Value, E> {
443        Ok(StrictValue(Value::String(value)))
444    }
445
446    fn visit_none<E>(self) -> Result<Self::Value, E> {
447        Ok(StrictValue(Value::Null))
448    }
449
450    fn visit_unit<E>(self) -> Result<Self::Value, E> {
451        Ok(StrictValue(Value::Null))
452    }
453
454    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
455    where
456        A: serde::de::SeqAccess<'de>,
457    {
458        let mut values = Vec::new();
459        while let Some(value) = sequence.next_element::<StrictValue>()? {
460            values.push(value.0);
461        }
462        Ok(StrictValue(Value::Array(values)))
463    }
464
465    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
466    where
467        A: serde::de::MapAccess<'de>,
468    {
469        let mut keys = BTreeSet::new();
470        let mut values = serde_json::Map::new();
471        while let Some(key) = map.next_key::<String>()? {
472            if !keys.insert(key.clone()) {
473                return Err(serde::de::Error::custom(format!("duplicate field `{key}`")));
474            }
475            values.insert(key, map.next_value::<StrictValue>()?.0);
476        }
477        Ok(StrictValue(Value::Object(values)))
478    }
479}
480
481fn validate_json_value(value: &Value) -> Result<(), BundleError> {
482    match value {
483        Value::Number(number) if !number.is_u64() => {
484            invalid_manifest("numbers must be non-negative integers")
485        }
486        Value::Array(values) => values.iter().try_for_each(validate_json_value),
487        Value::Object(values) => values.values().try_for_each(validate_json_value),
488        _ => Ok(()),
489    }
490}
491
492fn validate_relative_path(path: &str) -> Result<(), BundleError> {
493    if path.is_empty() || path.contains('\\') {
494        return invalid_manifest("Bundle path is empty or platform-ambiguous");
495    }
496    let path = Path::new(path);
497    if path.is_absolute()
498        || path
499            .components()
500            .any(|part| !matches!(part, Component::Normal(_)))
501    {
502        return invalid_manifest("Bundle path must contain only normalized relative segments");
503    }
504    Ok(())
505}
506
507fn digest_component(digest: &str) -> Result<&str, BundleError> {
508    let Some(value) = digest.strip_prefix("sha256:") else {
509        return invalid_manifest("digest does not use sha256 prefix");
510    };
511    if value.len() != 64
512        || !value
513            .bytes()
514            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
515    {
516        return invalid_manifest("digest is not 64 lowercase hexadecimal characters");
517    }
518    Ok(value)
519}
520
521fn read_regular_file(path: &Path, kind: &str) -> Result<Vec<u8>, BundleError> {
522    let metadata = fs::symlink_metadata(path)
523        .map_err(|error| BundleError::Io(format!("failed to inspect {kind}: {error}")))?;
524    if !metadata.is_file() || metadata.file_type().is_symlink() {
525        return invalid_bundle(format!("{kind} is not a regular file"));
526    }
527    fs::read(path).map_err(io_error)
528}
529
530fn write_bundle_file(root: &Path, relative: &str, bytes: &[u8]) -> Result<(), BundleError> {
531    let path = root.join(relative);
532    if let Some(parent) = path.parent() {
533        fs::create_dir_all(parent).map_err(io_error)?;
534    }
535    fs::write(path, bytes).map_err(io_error)
536}
537
538fn collect_bundle_files(
539    root: &Path,
540    directory: &Path,
541    files: &mut BTreeMap<String, Vec<u8>>,
542) -> Result<(), BundleError> {
543    let metadata = fs::symlink_metadata(directory).map_err(io_error)?;
544    if !metadata.is_dir() || metadata.file_type().is_symlink() {
545        return invalid_bundle("Bundle root contains a non-regular directory");
546    }
547    let mut entries = fs::read_dir(directory)
548        .map_err(io_error)?
549        .collect::<Result<Vec<_>, _>>()
550        .map_err(io_error)?;
551    entries.sort_by_key(std::fs::DirEntry::file_name);
552    for entry in entries {
553        let path = entry.path();
554        let metadata = fs::symlink_metadata(&path).map_err(io_error)?;
555        if metadata.file_type().is_symlink() {
556            return invalid_bundle("Bundle contains a symbolic link");
557        }
558        if metadata.is_dir() {
559            collect_bundle_files(root, &path, files)?;
560            continue;
561        }
562        if !metadata.is_file() {
563            return invalid_bundle("Bundle contains a non-regular file");
564        }
565        let relative = path
566            .strip_prefix(root)
567            .map_err(|_| BundleError::InvalidBundle("Bundle path escaped root".to_owned()))?
568            .to_str()
569            .ok_or_else(|| BundleError::InvalidBundle("Bundle path is not UTF-8".to_owned()))?
570            .replace(std::path::MAIN_SEPARATOR, "/");
571        validate_relative_path(&relative)?;
572        files.insert(relative, fs::read(path).map_err(io_error)?);
573    }
574    Ok(())
575}
576
577fn invalid_manifest<T>(detail: impl Into<String>) -> Result<T, BundleError> {
578    Err(BundleError::InvalidManifest(detail.into()))
579}
580
581fn invalid_bundle<T>(detail: impl Into<String>) -> Result<T, BundleError> {
582    Err(BundleError::InvalidBundle(detail.into()))
583}
584
585fn io_error(error: impl fmt::Display) -> BundleError {
586    BundleError::Io(error.to_string())
587}
588
589#[cfg(test)]
590mod tests {
591    use std::borrow::Cow;
592
593    use super::*;
594
595    fn wasm_with_descriptors(descriptors: &[&[u8]]) -> Vec<u8> {
596        let mut module = wasm_encoder::Module::new();
597        for descriptor in descriptors {
598            module.section(&wasm_encoder::CustomSection {
599                name: Cow::Borrowed(PLUGIN_DESCRIPTOR_SECTION),
600                data: Cow::Borrowed(descriptor),
601            });
602        }
603        module.finish()
604    }
605
606    #[test]
607    fn source_metadata_rejects_old_multi_entry_fields() {
608        let error = toml::from_str::<CargoManifest>(
609            r#"
610                [package]
611                version = "1.0.0"
612
613                [package.metadata.lenso]
614                plugin-id = "example.echo"
615                root-slot = "tools"
616                module-contributions = []
617            "#,
618        )
619        .unwrap_err();
620
621        assert!(error.to_string().contains("module-contributions"));
622    }
623
624    #[test]
625    fn descriptor_extraction_requires_one_canonical_descriptor() {
626        assert!(extract_plugin_descriptor(&wasm_with_descriptors(&[])).is_err());
627        let descriptor = br#"{"profile":"one"}"#;
628        assert!(
629            extract_plugin_descriptor(&wasm_with_descriptors(&[
630                descriptor.as_slice(),
631                descriptor.as_slice(),
632            ]))
633            .is_err()
634        );
635        assert!(extract_plugin_descriptor(&wasm_with_descriptors(&[b"{"])).is_err());
636        assert!(
637            extract_plugin_descriptor(&wasm_with_descriptors(&[br#"{ "profile": "one" }"#]))
638                .is_err()
639        );
640    }
641
642    #[test]
643    fn descriptor_extraction_rejects_oversized_evidence() {
644        let descriptor = vec![b' '; MAX_PLUGIN_DESCRIPTOR_BYTES + 1];
645        assert!(extract_plugin_descriptor(&wasm_with_descriptors(&[&descriptor])).is_err());
646    }
647
648    #[test]
649    fn strict_v2_manifest_rejects_duplicate_fields_and_path_escape() {
650        assert!(
651            SourceManifestDocument::parse(br#"{"schema_version":2,"schema_version":2}"#).is_err()
652        );
653        let manifest = PluginManifestV2 {
654            schema_version: 2,
655            plugin_id: "example.echo".to_owned(),
656            release_version: "1.0.0".to_owned(),
657            artifact: PluginArtifactV2 {
658                path: "../plugin.wasm".to_owned(),
659                digest: sha256_digest(b"plugin"),
660                size: 6,
661                media_type: "application/wasm".to_owned(),
662                target: "wasm32-unknown-unknown".to_owned(),
663            },
664            entry: PluginEntryV2 {
665                descriptor: serde_json::json!({"plugin_id":"example.echo"}),
666            },
667        };
668        assert!(SourceManifestDocument::from_value(manifest).is_err());
669    }
670}