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
11pub use model::*;
12use serde::{Deserialize, de::DeserializeOwned};
13use serde_json::Value;
14use sha2::{Digest, Sha256};
15
16/// The only manifest filename accepted in a materialized Plugin Bundle.
17pub const MANIFEST_FILE: &str = "lenso-plugin.json";
18
19/// Custom section carrying source-derived Plugin descriptor bytes.
20pub const PLUGIN_DESCRIPTOR_SECTION: &str = "lenso.plugin-descriptor.v1";
21
22/// Maximum accepted source-derived descriptor size.
23pub const MAX_PLUGIN_DESCRIPTOR_BYTES: usize = 64 * 1024;
24
25/// A source Artifact selected while materializing one Release.
26#[derive(Clone, Debug, Eq, PartialEq)]
27pub struct ArtifactSource {
28    pub artifact_id: String,
29    pub path: PathBuf,
30}
31
32/// Exact input to one fail-closed Bundle build.
33#[derive(Clone, Debug, Eq, PartialEq)]
34pub struct BundleBuild {
35    pub template: PathBuf,
36    pub output: PathBuf,
37    pub artifact_sources: Vec<ArtifactSource>,
38}
39
40/// Source-only input for one generated V2 Plugin Bundle.
41#[derive(Clone, Debug, Eq, PartialEq)]
42pub struct SourcePluginBuild {
43    pub package_manifest: PathBuf,
44    pub wasm_module: PathBuf,
45    pub output: PathBuf,
46}
47
48/// Canonical Plugin Manifest plus its content identity.
49#[derive(Clone, Debug)]
50pub struct ManifestDocument {
51    value: PluginManifest,
52    bytes: Vec<u8>,
53    digest: String,
54}
55
56impl ManifestDocument {
57    /// Strictly parses and validates one publisher Manifest.
58    pub fn parse(input: &[u8]) -> Result<Self, BundleError> {
59        let value = strict_json::<PluginManifest>(input)?;
60        Self::from_value(value)
61    }
62
63    /// Validates and canonicalizes one typed publisher Manifest.
64    pub fn from_value(value: PluginManifest) -> Result<Self, BundleError> {
65        validate_manifest(&value)?;
66        let json = serde_json::to_value(&value)
67            .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
68        validate_json_value(&json)?;
69        let bytes = serde_json::to_vec(&json)
70            .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
71        let digest = sha256_digest(&bytes);
72        Ok(Self {
73            value,
74            bytes,
75            digest,
76        })
77    }
78
79    pub const fn value(&self) -> &PluginManifest {
80        &self.value
81    }
82
83    pub fn bytes(&self) -> &[u8] {
84        &self.bytes
85    }
86
87    pub fn digest(&self) -> &str {
88        &self.digest
89    }
90}
91
92#[derive(Clone, Debug)]
93struct SourceManifestDocument {
94    value: PluginManifestV2,
95    bytes: Vec<u8>,
96    digest: String,
97}
98
99impl SourceManifestDocument {
100    fn parse(input: &[u8]) -> Result<Self, BundleError> {
101        let value = strict_json::<PluginManifestV2>(input)?;
102        Self::from_value(value)
103    }
104
105    fn from_value(value: PluginManifestV2) -> Result<Self, BundleError> {
106        validate_source_manifest(&value)?;
107        let json = serde_json::to_value(&value)
108            .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
109        validate_json_value(&json)?;
110        let bytes = serde_json::to_vec(&json)
111            .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
112        let digest = sha256_digest(&bytes);
113        Ok(Self {
114            value,
115            bytes,
116            digest,
117        })
118    }
119}
120
121#[derive(Debug, Deserialize)]
122struct CargoManifest {
123    package: CargoPackage,
124}
125
126#[derive(Debug, Deserialize)]
127struct CargoPackage {
128    version: String,
129    metadata: CargoMetadata,
130}
131
132#[derive(Debug, Deserialize)]
133struct CargoMetadata {
134    lenso: CargoLensoMetadata,
135}
136
137#[derive(Debug, Deserialize)]
138#[serde(deny_unknown_fields, rename_all = "kebab-case")]
139struct CargoLensoMetadata {
140    plugin_id: String,
141}
142
143/// Verified closure of one immutable Plugin Release.
144#[derive(Clone, Debug, Eq, PartialEq)]
145pub struct VerifiedBundle {
146    pub plugin_id: String,
147    pub release_version: String,
148    pub manifest_digest: String,
149    pub artifact_digests: Vec<String>,
150    pub product_metadata_digests: Vec<String>,
151}
152
153/// A Plugin authoring or immutable Bundle invariant failed closed.
154#[derive(Clone, Debug, Eq, PartialEq)]
155pub enum BundleError {
156    InvalidManifest(String),
157    InvalidBundle(String),
158    DigestMismatch(String),
159    Io(String),
160    Wasm(String),
161}
162
163impl fmt::Display for BundleError {
164    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
165        match self {
166            Self::InvalidManifest(detail) => write!(formatter, "invalid Plugin Manifest: {detail}"),
167            Self::InvalidBundle(detail) => write!(formatter, "invalid Plugin Bundle: {detail}"),
168            Self::DigestMismatch(subject) => write!(formatter, "digest mismatch for {subject}"),
169            Self::Io(detail) => formatter.write_str(detail),
170            Self::Wasm(detail) => write!(
171                formatter,
172                "failed to encode WebAssembly Component: {detail}"
173            ),
174        }
175    }
176}
177
178impl std::error::Error for BundleError {}
179
180/// Builds a one-entry V2 Plugin Bundle entirely from package and source evidence.
181pub fn build_source_plugin_bundle(
182    build: &SourcePluginBuild,
183) -> Result<VerifiedBundle, BundleError> {
184    if build.output.exists() {
185        return invalid_bundle(format!(
186            "output `{}` already exists",
187            build.output.display()
188        ));
189    }
190    let package_bytes = read_regular_file(&build.package_manifest, "Cargo manifest")?;
191    let package = toml::from_slice::<CargoManifest>(&package_bytes)
192        .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
193    let module = read_regular_file(&build.wasm_module, "Plugin Wasm module")?;
194    let component = wit_component::ComponentEncoder::default()
195        .module(&module)
196        .map_err(|error| BundleError::Wasm(error.to_string()))?
197        .validate(true)
198        .encode()
199        .map_err(|error| BundleError::Wasm(error.to_string()))?;
200    let descriptor = extract_plugin_descriptor(&component)?;
201    let descriptor = strict_json::<Value>(&descriptor)?;
202    let artifact = PluginArtifactV2 {
203        path: "plugin.wasm".to_owned(),
204        digest: sha256_digest(&component),
205        size: u64::try_from(component.len())
206            .map_err(|_| BundleError::InvalidBundle("Artifact size exceeds u64".to_owned()))?,
207        media_type: "application/wasm".to_owned(),
208        target: "wasm32-unknown-unknown".to_owned(),
209    };
210    let document = SourceManifestDocument::from_value(PluginManifestV2 {
211        schema_version: 2,
212        plugin_id: package.package.metadata.lenso.plugin_id,
213        release_version: package.package.version,
214        artifact,
215        entry: PluginEntryV2 { descriptor },
216    })?;
217
218    let output_parent = build.output.parent().unwrap_or_else(|| Path::new("."));
219    fs::create_dir_all(output_parent).map_err(io_error)?;
220    let staging = tempfile::Builder::new()
221        .prefix(".lenso-plugin-")
222        .tempdir_in(output_parent)
223        .map_err(io_error)?;
224    write_bundle_file(staging.path(), &document.value.artifact.path, &component)?;
225    fs::write(staging.path().join(MANIFEST_FILE), &document.bytes).map_err(io_error)?;
226    fs::rename(staging.path(), &build.output).map_err(io_error)?;
227    verify_bundle_directory(&build.output)
228}
229
230/// Builds an immutable Bundle directory without executing publisher code.
231///
232/// This is the schema-version-1 compatibility creator. New Plugin authoring
233/// uses [`build_source_plugin_bundle`].
234pub fn build_bundle(build: &BundleBuild) -> Result<VerifiedBundle, BundleError> {
235    if build.output.exists() {
236        return invalid_bundle(format!(
237            "output `{}` already exists",
238            build.output.display()
239        ));
240    }
241    let template = read_regular_file(&build.template, "Plugin Manifest template")?;
242    let mut manifest = ManifestDocument::parse(&template)?.value;
243    let template_root = build.template.parent().unwrap_or_else(|| Path::new("."));
244    let sources = build
245        .artifact_sources
246        .iter()
247        .map(|source| (source.artifact_id.as_str(), source.path.as_path()))
248        .collect::<BTreeMap<_, _>>();
249    if sources.len() != build.artifact_sources.len() {
250        return invalid_bundle("Artifact source IDs must be unique");
251    }
252    if sources
253        .keys()
254        .any(|id| !manifest.artifacts.iter().any(|artifact| artifact.id == *id))
255    {
256        return invalid_bundle("Artifact source references an unknown Artifact ID");
257    }
258
259    let output_parent = build.output.parent().unwrap_or_else(|| Path::new("."));
260    fs::create_dir_all(output_parent).map_err(io_error)?;
261    let staging = tempfile::Builder::new()
262        .prefix(".lenso-plugin-")
263        .tempdir_in(output_parent)
264        .map_err(io_error)?;
265    let mut files = BTreeMap::new();
266    for artifact in &mut manifest.artifacts {
267        validate_relative_path(&artifact.path)?;
268        let source = sources
269            .get(artifact.id.as_str())
270            .copied()
271            .map_or_else(|| template_root.join(&artifact.path), Path::to_path_buf);
272        let source_bytes = read_regular_file(&source, &format!("Artifact `{}`", artifact.id))?;
273        let bytes = if artifact.kind == ArtifactKind::WasmComponent {
274            wit_component::ComponentEncoder::default()
275                .module(&source_bytes)
276                .map_err(|error| BundleError::Wasm(error.to_string()))?
277                .validate(true)
278                .encode()
279                .map_err(|error| BundleError::Wasm(error.to_string()))?
280        } else {
281            source_bytes
282        };
283        artifact.digest = sha256_digest(&bytes);
284        artifact.size = u64::try_from(bytes.len())
285            .map_err(|_| BundleError::InvalidBundle("Artifact size exceeds u64".to_owned()))?;
286        write_bundle_file(staging.path(), &artifact.path, &bytes)?;
287        files.insert(artifact.path.clone(), bytes);
288    }
289    for metadata in &mut manifest.product_metadata {
290        validate_relative_path(&metadata.path)?;
291        let bytes = read_regular_file(
292            &template_root.join(&metadata.path),
293            &format!("Product Metadata `{}`", metadata.id),
294        )?;
295        metadata.digest = sha256_digest(&bytes);
296        write_bundle_file(staging.path(), &metadata.path, &bytes)?;
297        files.insert(metadata.path.clone(), bytes);
298    }
299
300    let document = ManifestDocument::from_value(manifest)?;
301    fs::write(staging.path().join(MANIFEST_FILE), document.bytes()).map_err(io_error)?;
302    let verified = verify_bundle_files(&document, &files)?;
303    fs::rename(staging.path(), &build.output).map_err(io_error)?;
304    Ok(verified)
305}
306
307/// Verifies an already materialized directory as an exact immutable Bundle closure.
308pub fn verify_bundle_directory(root: &Path) -> Result<VerifiedBundle, BundleError> {
309    let manifest_path = root.join(MANIFEST_FILE);
310    let manifest_bytes = read_regular_file(&manifest_path, "Plugin Manifest")?;
311    let mut files = BTreeMap::new();
312    collect_bundle_files(root, root, &mut files)?;
313    files.remove(MANIFEST_FILE);
314    match manifest_schema_version(&manifest_bytes)? {
315        1 => verify_bundle_files(&ManifestDocument::parse(&manifest_bytes)?, &files),
316        2 => verify_source_bundle_files(&SourceManifestDocument::parse(&manifest_bytes)?, &files),
317        _ => invalid_manifest("unsupported schema version"),
318    }
319}
320
321/// Verifies exact declared bytes without performing admission or granting authority.
322pub fn verify_bundle_files(
323    manifest: &ManifestDocument,
324    files: &BTreeMap<String, Vec<u8>>,
325) -> Result<VerifiedBundle, BundleError> {
326    let declared_paths = manifest
327        .value()
328        .artifacts
329        .iter()
330        .map(|artifact| artifact.path.as_str())
331        .chain(
332            manifest
333                .value()
334                .product_metadata
335                .iter()
336                .map(|metadata| metadata.path.as_str()),
337        )
338        .collect::<BTreeSet<_>>();
339    if declared_paths.len()
340        != manifest.value().artifacts.len() + manifest.value().product_metadata.len()
341    {
342        return invalid_bundle("Manifest declares a duplicate Bundle path");
343    }
344    if files.len() != declared_paths.len()
345        || files
346            .keys()
347            .any(|path| !declared_paths.contains(path.as_str()))
348    {
349        return invalid_bundle("Bundle files do not exactly close over declared paths");
350    }
351    let mut artifact_digests = Vec::new();
352    for artifact in &manifest.value().artifacts {
353        let bytes = files.get(&artifact.path).ok_or_else(|| {
354            BundleError::InvalidBundle(format!("missing Artifact `{}`", artifact.id))
355        })?;
356        if u64::try_from(bytes.len()).unwrap_or(u64::MAX) != artifact.size {
357            return invalid_bundle(format!("Artifact `{}` size does not match", artifact.id));
358        }
359        if sha256_digest(bytes) != artifact.digest {
360            return Err(BundleError::DigestMismatch(format!(
361                "Artifact `{}`",
362                artifact.id
363            )));
364        }
365        artifact_digests.push(artifact.digest.clone());
366    }
367    artifact_digests.sort();
368    artifact_digests.dedup();
369    let mut product_metadata_digests = Vec::new();
370    for metadata in &manifest.value().product_metadata {
371        let bytes = files.get(&metadata.path).ok_or_else(|| {
372            BundleError::InvalidBundle(format!("missing Product Metadata `{}`", metadata.id))
373        })?;
374        if sha256_digest(bytes) != metadata.digest {
375            return Err(BundleError::DigestMismatch(format!(
376                "Product Metadata `{}`",
377                metadata.id
378            )));
379        }
380        product_metadata_digests.push(metadata.digest.clone());
381    }
382    product_metadata_digests.sort();
383    product_metadata_digests.dedup();
384    Ok(VerifiedBundle {
385        plugin_id: manifest.value().plugin_id.clone(),
386        release_version: manifest.value().release_version.clone(),
387        manifest_digest: manifest.digest().to_owned(),
388        artifact_digests,
389        product_metadata_digests,
390    })
391}
392
393fn verify_source_bundle_files(
394    manifest: &SourceManifestDocument,
395    files: &BTreeMap<String, Vec<u8>>,
396) -> Result<VerifiedBundle, BundleError> {
397    let artifact = &manifest.value.artifact;
398    if files.len() != 1 {
399        return invalid_bundle("V2 Bundle must contain exactly one Artifact");
400    }
401    let Some(bytes) = files.get(&artifact.path) else {
402        return invalid_bundle("V2 Bundle does not contain its declared Artifact");
403    };
404    if artifact.size != u64::try_from(bytes.len()).unwrap_or(u64::MAX)
405        || artifact.digest != sha256_digest(bytes)
406    {
407        return Err(BundleError::DigestMismatch(artifact.path.clone()));
408    }
409    let descriptor = extract_plugin_descriptor(bytes)?;
410    let packaged = serde_json::to_vec(&manifest.value.entry.descriptor)
411        .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
412    if descriptor != packaged {
413        return invalid_bundle("source descriptor does not match the V2 Plugin entry");
414    }
415    Ok(VerifiedBundle {
416        plugin_id: manifest.value.plugin_id.clone(),
417        release_version: manifest.value.release_version.clone(),
418        manifest_digest: manifest.digest.clone(),
419        artifact_digests: vec![artifact.digest.clone()],
420        product_metadata_digests: Vec::new(),
421    })
422}
423
424fn manifest_schema_version(input: &[u8]) -> Result<u64, BundleError> {
425    let value = strict_json::<Value>(input)?;
426    value
427        .get("schema_version")
428        .and_then(Value::as_u64)
429        .ok_or_else(|| BundleError::InvalidManifest("schema_version is required".to_owned()))
430}
431
432/// Extracts one canonical source-derived Plugin descriptor without executing it.
433pub fn extract_plugin_descriptor(component: &[u8]) -> Result<Vec<u8>, BundleError> {
434    let mut descriptors = Vec::new();
435    collect_plugin_descriptors(component, &mut descriptors)?;
436    let [descriptor] = descriptors.as_slice() else {
437        return invalid_bundle(if descriptors.is_empty() {
438            "Plugin Component does not contain a source-derived descriptor"
439        } else {
440            "Plugin Component contains duplicate source-derived descriptors"
441        });
442    };
443    let value = strict_json::<Value>(descriptor)?;
444    let canonical = serde_json::to_vec(&value)
445        .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
446    if canonical != *descriptor {
447        return invalid_bundle("Plugin descriptor is not canonical JSON");
448    }
449    Ok(descriptor.clone())
450}
451
452fn collect_plugin_descriptors(
453    bytes: &[u8],
454    descriptors: &mut Vec<Vec<u8>>,
455) -> Result<(), BundleError> {
456    for payload in wasmparser::Parser::new(0).parse_all(bytes) {
457        match payload.map_err(|error| BundleError::Wasm(error.to_string()))? {
458            wasmparser::Payload::CustomSection(section)
459                if section.name() == PLUGIN_DESCRIPTOR_SECTION =>
460            {
461                if section.data().len() > MAX_PLUGIN_DESCRIPTOR_BYTES {
462                    return invalid_bundle("Plugin descriptor exceeds the size limit");
463                }
464                descriptors.push(section.data().to_vec());
465            }
466            _ => {}
467        }
468    }
469    Ok(())
470}
471
472fn validate_source_manifest(manifest: &PluginManifestV2) -> Result<(), BundleError> {
473    if manifest.schema_version != 2 {
474        return invalid_manifest("unsupported schema version");
475    }
476    if manifest.plugin_id.is_empty() || semver::Version::parse(&manifest.release_version).is_err() {
477        return invalid_manifest("Plugin identity or Release version is invalid");
478    }
479    validate_relative_path(&manifest.artifact.path)?;
480    digest_component(&manifest.artifact.digest)?;
481    if manifest.artifact.size == 0
482        || manifest.artifact.media_type != "application/wasm"
483        || manifest.artifact.target != "wasm32-unknown-unknown"
484    {
485        return invalid_manifest("V2 Artifact size, Wasm media type, and target must be exact");
486    }
487    if !manifest.entry.descriptor.is_object() {
488        return invalid_manifest("V2 Plugin entry descriptor must be an object");
489    }
490    Ok(())
491}
492
493/// Validates publisher-owned Manifest semantics independently of Host policy.
494#[allow(clippy::too_many_lines)]
495pub fn validate_manifest(manifest: &PluginManifest) -> Result<(), BundleError> {
496    if manifest.schema_version != 1 {
497        return invalid_manifest("unsupported schema version");
498    }
499    if manifest.plugin_id.is_empty() || semver::Version::parse(&manifest.release_version).is_err() {
500        return invalid_manifest("Plugin identity or Release version is invalid");
501    }
502    let artifact_ids = manifest
503        .artifacts
504        .iter()
505        .map(|artifact| artifact.id.as_str())
506        .collect::<Vec<_>>();
507    let contribution_ids = manifest
508        .module_contributions
509        .iter()
510        .map(|contribution| contribution.id.as_str())
511        .collect::<Vec<_>>();
512    let data_ids = manifest
513        .data_contributions
514        .iter()
515        .map(|contribution| contribution.id.as_str())
516        .collect::<Vec<_>>();
517    let permission_ids = manifest
518        .permission_requests
519        .iter()
520        .map(|request| request.id.as_str())
521        .collect::<Vec<_>>();
522    let feature_ids = manifest
523        .features
524        .iter()
525        .map(|feature| feature.id.as_str())
526        .collect::<Vec<_>>();
527    let metadata_ids = manifest
528        .product_metadata
529        .iter()
530        .map(|metadata| metadata.id.as_str())
531        .collect::<Vec<_>>();
532    ensure_sorted_unique(&artifact_ids, "Artifact ID")?;
533    ensure_sorted_unique(&contribution_ids, "Module contribution ID")?;
534    ensure_sorted_unique(&data_ids, "Data contribution ID")?;
535    ensure_sorted_unique(&permission_ids, "permission request ID")?;
536    ensure_sorted_unique(&feature_ids, "Feature ID")?;
537    ensure_sorted_unique(&metadata_ids, "Product Metadata ID")?;
538    let artifact_set = artifact_ids.iter().copied().collect::<BTreeSet<_>>();
539    let contribution_set = contribution_ids.iter().copied().collect::<BTreeSet<_>>();
540    let data_set = data_ids.iter().copied().collect::<BTreeSet<_>>();
541    let permission_set = permission_ids.iter().copied().collect::<BTreeSet<_>>();
542    let metadata_set = metadata_ids.iter().copied().collect::<BTreeSet<_>>();
543    for artifact in &manifest.artifacts {
544        digest_component(&artifact.digest)?;
545        validate_relative_path(&artifact.path)?;
546        if artifact.id.is_empty() || artifact.media_type.is_empty() || artifact.targets.is_empty() {
547            return invalid_manifest("Artifact identity, media type, and targets must be explicit");
548        }
549        ensure_sorted_unique(&artifact.targets, "Artifact target")?;
550    }
551    for contribution in &manifest.module_contributions {
552        digest_component(&contribution.configuration_schema_digest)?;
553        if contribution.id.is_empty()
554            || contribution.package_id.is_empty()
555            || contribution.implementations.is_empty()
556        {
557            return invalid_manifest(
558                "Module contribution identity and implementations are required",
559            );
560        }
561        let provided = contribution
562            .provides
563            .iter()
564            .map(|capability| capability.capability_id.as_str())
565            .collect::<Vec<_>>();
566        ensure_sorted_unique(&provided, "provided Capability")?;
567        for capability in &contribution.provides {
568            digest_component(&capability.descriptor_digest)?;
569            validate_semver(
570                &capability.descriptor_version,
571                "Capability Descriptor version",
572            )?;
573            ensure_sorted_unique(&capability.request_operations, "Capability Operation")?;
574            if capability
575                .operation_kinds
576                .keys()
577                .any(|operation| !capability.request_operations.contains(operation))
578            {
579                return invalid_manifest(
580                    "Capability interaction kind references an unknown Operation",
581                );
582            }
583        }
584        let required = contribution
585            .requires
586            .iter()
587            .map(|capability| capability.capability_id.as_str())
588            .collect::<Vec<_>>();
589        ensure_sorted_unique(&required, "required Capability")?;
590        for capability in &contribution.requires {
591            validate_semver(
592                &capability.descriptor_version,
593                "required Capability Descriptor version",
594            )?;
595        }
596        ensure_sorted_unique(
597            &contribution.permission_request_ids,
598            "Module permission request",
599        )?;
600        if contribution
601            .permission_request_ids
602            .iter()
603            .any(|request| !permission_set.contains(request.as_str()))
604        {
605            return invalid_manifest(
606                "Module contribution references an unknown permission request",
607            );
608        }
609        if let Some(state) = &contribution.state {
610            if state.state_schema_id.is_empty() {
611                return invalid_manifest("state Schema identity is empty");
612            }
613            digest_component(&state.state_schema_digest)?;
614        }
615        let variants = contribution
616            .implementations
617            .iter()
618            .map(|variant| variant.id.as_str())
619            .collect::<Vec<_>>();
620        ensure_sorted_unique(&variants, "implementation variant ID")?;
621        for variant in &contribution.implementations {
622            if variant.artifact.is_some() == variant.built_in_factory.is_some() {
623                return invalid_manifest("implementation must select exactly one execution input");
624            }
625            if variant.built_in_factory.is_some()
626                && variant.execution_class != "lenso.native-rust@1"
627            {
628                return invalid_manifest("only native Rust may select a built-in factory");
629            }
630            if variant
631                .artifact
632                .as_deref()
633                .is_some_and(|artifact| !artifact_set.contains(artifact))
634            {
635                return invalid_manifest("implementation references an unknown Artifact");
636            }
637            if variant.entrypoint.is_empty()
638                || variant.execution_class.is_empty()
639                || variant.targets.is_empty()
640                || variant.profiles.is_empty()
641            {
642                return invalid_manifest(
643                    "implementation entrypoint, class, targets, and Profiles are required",
644                );
645            }
646            ensure_sorted_unique(&variant.targets, "implementation target")?;
647            ensure_sorted_unique(&variant.profiles, "implementation Profile")?;
648        }
649    }
650    for contribution in &manifest.data_contributions {
651        if !artifact_set.contains(contribution.artifact.as_str())
652            || contribution.media_type.is_empty()
653            || contribution.content_schema_id.is_empty()
654        {
655            return invalid_manifest("Data contribution references incomplete authority");
656        }
657        digest_component(&contribution.content_schema_digest)?;
658        digest_component(&contribution.product_metadata_digest)?;
659    }
660    for request in &manifest.permission_requests {
661        if request.id.is_empty()
662            || request.resource_kind.is_empty()
663            || request.explanation_key.is_empty()
664        {
665            return invalid_manifest("permission request identity is incomplete");
666        }
667    }
668    for metadata in &manifest.product_metadata {
669        if metadata.id.is_empty() || metadata.namespace.is_empty() || metadata.schema_id.is_empty()
670        {
671            return invalid_manifest("Product Metadata identity is incomplete");
672        }
673        validate_relative_path(&metadata.path)?;
674        digest_component(&metadata.digest)?;
675    }
676    for feature in &manifest.features {
677        validate_refs(
678            &feature.module_contribution_ids,
679            &contribution_set,
680            "Module contribution",
681        )?;
682        validate_refs(
683            &feature.data_contribution_ids,
684            &data_set,
685            "Data contribution",
686        )?;
687        validate_refs(&feature.artifact_ids, &artifact_set, "Artifact")?;
688        validate_refs(
689            &feature.permission_request_ids,
690            &permission_set,
691            "permission request",
692        )?;
693        validate_refs(
694            &feature.product_metadata_ids,
695            &metadata_set,
696            "Product Metadata",
697        )?;
698    }
699    for template in &manifest.binding_templates {
700        if !contribution_set.contains(template.consumer_contribution_id.as_str())
701            || !contribution_set.contains(template.provider_contribution_id.as_str())
702            || template.capability_id.is_empty()
703        {
704            return invalid_manifest("binding template references unknown contribution authority");
705        }
706        let consumer = manifest
707            .module_contributions
708            .iter()
709            .find(|value| value.id == template.consumer_contribution_id)
710            .expect("validated contribution");
711        let provider = manifest
712            .module_contributions
713            .iter()
714            .find(|value| value.id == template.provider_contribution_id)
715            .expect("validated contribution");
716        if !consumer
717            .requires
718            .iter()
719            .any(|value| value.capability_id == template.capability_id)
720            || !provider
721                .provides
722                .iter()
723                .any(|value| value.capability_id == template.capability_id)
724        {
725            return invalid_manifest("binding template Capability is not required and provided");
726        }
727    }
728    Ok(())
729}
730
731/// Computes the canonical digest syntax used by Plugin Release documents and files.
732pub fn sha256_digest(bytes: &[u8]) -> String {
733    format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
734}
735
736fn strict_json<T: DeserializeOwned>(input: &[u8]) -> Result<T, BundleError> {
737    let mut deserializer = serde_json::Deserializer::from_slice(input);
738    let strict = StrictValue::deserialize(&mut deserializer)
739        .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
740    deserializer
741        .end()
742        .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
743    validate_json_value(&strict.0)?;
744    serde_json::from_value(strict.0)
745        .map_err(|error| BundleError::InvalidManifest(error.to_string()))
746}
747
748#[derive(Clone, Debug)]
749struct StrictValue(Value);
750
751impl<'de> Deserialize<'de> for StrictValue {
752    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
753    where
754        D: serde::Deserializer<'de>,
755    {
756        deserializer.deserialize_any(StrictVisitor)
757    }
758}
759
760struct StrictVisitor;
761
762impl<'de> serde::de::Visitor<'de> for StrictVisitor {
763    type Value = StrictValue;
764
765    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
766        formatter.write_str("strict Plugin Manifest JSON")
767    }
768
769    fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> {
770        Ok(StrictValue(Value::Bool(value)))
771    }
772
773    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
774        Ok(StrictValue(Value::Number(value.into())))
775    }
776
777    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
778    where
779        E: serde::de::Error,
780    {
781        u64::try_from(value)
782            .map_err(|_| E::custom("negative integers are forbidden"))
783            .and_then(|value| self.visit_u64(value))
784    }
785
786    fn visit_f64<E>(self, _: f64) -> Result<Self::Value, E>
787    where
788        E: serde::de::Error,
789    {
790        Err(E::custom("floating-point values are forbidden"))
791    }
792
793    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> {
794        Ok(StrictValue(Value::String(value.to_owned())))
795    }
796
797    fn visit_string<E>(self, value: String) -> Result<Self::Value, E> {
798        Ok(StrictValue(Value::String(value)))
799    }
800
801    fn visit_none<E>(self) -> Result<Self::Value, E> {
802        Ok(StrictValue(Value::Null))
803    }
804
805    fn visit_unit<E>(self) -> Result<Self::Value, E> {
806        Ok(StrictValue(Value::Null))
807    }
808
809    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
810    where
811        A: serde::de::SeqAccess<'de>,
812    {
813        let mut values = Vec::new();
814        while let Some(value) = sequence.next_element::<StrictValue>()? {
815            values.push(value.0);
816        }
817        Ok(StrictValue(Value::Array(values)))
818    }
819
820    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
821    where
822        A: serde::de::MapAccess<'de>,
823    {
824        let mut keys = BTreeSet::new();
825        let mut values = serde_json::Map::new();
826        while let Some(key) = map.next_key::<String>()? {
827            if !keys.insert(key.clone()) {
828                return Err(serde::de::Error::custom(format!("duplicate field `{key}`")));
829            }
830            values.insert(key, map.next_value::<StrictValue>()?.0);
831        }
832        Ok(StrictValue(Value::Object(values)))
833    }
834}
835
836fn validate_json_value(value: &Value) -> Result<(), BundleError> {
837    match value {
838        Value::Number(number) if !number.is_u64() => {
839            invalid_manifest("numbers must be non-negative integers")
840        }
841        Value::Array(values) => values.iter().try_for_each(validate_json_value),
842        Value::Object(values) => values.values().try_for_each(validate_json_value),
843        _ => Ok(()),
844    }
845}
846
847fn validate_relative_path(path: &str) -> Result<(), BundleError> {
848    if path.is_empty() || path.contains('\\') {
849        return invalid_manifest("Bundle path is empty or platform-ambiguous");
850    }
851    let path = Path::new(path);
852    if path.is_absolute()
853        || path
854            .components()
855            .any(|part| !matches!(part, Component::Normal(_)))
856    {
857        return invalid_manifest("Bundle path must contain only normalized relative segments");
858    }
859    Ok(())
860}
861
862fn digest_component(digest: &str) -> Result<&str, BundleError> {
863    let Some(value) = digest.strip_prefix("sha256:") else {
864        return invalid_manifest("digest does not use sha256 prefix");
865    };
866    if value.len() != 64
867        || !value
868            .bytes()
869            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
870    {
871        return invalid_manifest("digest is not 64 lowercase hexadecimal characters");
872    }
873    Ok(value)
874}
875
876fn validate_semver(value: &str, kind: &str) -> Result<(), BundleError> {
877    semver::Version::parse(value)
878        .map(|_| ())
879        .map_err(|_| BundleError::InvalidManifest(format!("{kind} is not SemVer")))
880}
881
882fn ensure_sorted_unique<T: Ord + fmt::Display>(
883    values: &[T],
884    kind: &str,
885) -> Result<(), BundleError> {
886    if values.windows(2).any(|pair| pair[0] >= pair[1]) {
887        return invalid_manifest(format!("{kind} entries must be sorted and unique"));
888    }
889    Ok(())
890}
891
892fn validate_refs<'a>(
893    values: &'a [String],
894    known: &BTreeSet<&'a str>,
895    kind: &str,
896) -> Result<(), BundleError> {
897    ensure_sorted_unique(values, kind)?;
898    if values.iter().any(|value| !known.contains(value.as_str())) {
899        return invalid_manifest(format!("Feature references an unknown {kind}"));
900    }
901    Ok(())
902}
903
904fn read_regular_file(path: &Path, kind: &str) -> Result<Vec<u8>, BundleError> {
905    let metadata = fs::symlink_metadata(path)
906        .map_err(|error| BundleError::Io(format!("failed to inspect {kind}: {error}")))?;
907    if !metadata.is_file() || metadata.file_type().is_symlink() {
908        return invalid_bundle(format!("{kind} is not a regular file"));
909    }
910    fs::read(path).map_err(io_error)
911}
912
913fn write_bundle_file(root: &Path, relative: &str, bytes: &[u8]) -> Result<(), BundleError> {
914    let path = root.join(relative);
915    if let Some(parent) = path.parent() {
916        fs::create_dir_all(parent).map_err(io_error)?;
917    }
918    fs::write(path, bytes).map_err(io_error)
919}
920
921fn collect_bundle_files(
922    root: &Path,
923    directory: &Path,
924    files: &mut BTreeMap<String, Vec<u8>>,
925) -> Result<(), BundleError> {
926    let metadata = fs::symlink_metadata(directory).map_err(io_error)?;
927    if !metadata.is_dir() || metadata.file_type().is_symlink() {
928        return invalid_bundle("Bundle root contains a non-regular directory");
929    }
930    let mut entries = fs::read_dir(directory)
931        .map_err(io_error)?
932        .collect::<Result<Vec<_>, _>>()
933        .map_err(io_error)?;
934    entries.sort_by_key(std::fs::DirEntry::file_name);
935    for entry in entries {
936        let path = entry.path();
937        let metadata = fs::symlink_metadata(&path).map_err(io_error)?;
938        if metadata.file_type().is_symlink() {
939            return invalid_bundle("Bundle contains a symbolic link");
940        }
941        if metadata.is_dir() {
942            collect_bundle_files(root, &path, files)?;
943            continue;
944        }
945        if !metadata.is_file() {
946            return invalid_bundle("Bundle contains a non-regular file");
947        }
948        let relative = path
949            .strip_prefix(root)
950            .map_err(|_| BundleError::InvalidBundle("Bundle path escaped root".to_owned()))?
951            .to_str()
952            .ok_or_else(|| BundleError::InvalidBundle("Bundle path is not UTF-8".to_owned()))?
953            .replace(std::path::MAIN_SEPARATOR, "/");
954        validate_relative_path(&relative)?;
955        files.insert(relative, fs::read(path).map_err(io_error)?);
956    }
957    Ok(())
958}
959
960fn invalid_manifest<T>(detail: impl Into<String>) -> Result<T, BundleError> {
961    Err(BundleError::InvalidManifest(detail.into()))
962}
963
964fn invalid_bundle<T>(detail: impl Into<String>) -> Result<T, BundleError> {
965    Err(BundleError::InvalidBundle(detail.into()))
966}
967
968fn io_error(error: impl fmt::Display) -> BundleError {
969    BundleError::Io(error.to_string())
970}
971
972#[cfg(test)]
973mod tests {
974    use std::borrow::Cow;
975
976    use super::*;
977
978    const ZERO_DIGEST: &str =
979        "sha256:0000000000000000000000000000000000000000000000000000000000000000";
980
981    fn template() -> PluginManifest {
982        PluginManifest {
983            schema_version: 1,
984            plugin_id: "example.quickjs".to_owned(),
985            release_version: "1.0.0".to_owned(),
986            artifacts: vec![ArtifactDeclaration {
987                id: "script".to_owned(),
988                kind: ArtifactKind::QuickJsModule,
989                digest: ZERO_DIGEST.to_owned(),
990                size: 0,
991                media_type: "text/javascript".to_owned(),
992                path: "plugin.mjs".to_owned(),
993                targets: vec!["aarch64-macos".to_owned()],
994            }],
995            module_contributions: Vec::new(),
996            data_contributions: Vec::new(),
997            permission_requests: Vec::new(),
998            features: Vec::new(),
999            binding_templates: Vec::new(),
1000            product_metadata: Vec::new(),
1001        }
1002    }
1003
1004    fn wasm_template_with_duplicated_descriptor() -> PluginManifest {
1005        PluginManifest {
1006            schema_version: 1,
1007            plugin_id: "example.echo".to_owned(),
1008            release_version: "1.0.0".to_owned(),
1009            artifacts: vec![ArtifactDeclaration {
1010                id: "guest".to_owned(),
1011                kind: ArtifactKind::WasmComponent,
1012                digest: ZERO_DIGEST.to_owned(),
1013                size: 0,
1014                media_type: "application/wasm".to_owned(),
1015                path: "plugin.wasm".to_owned(),
1016                targets: vec!["wasm32-unknown-unknown".to_owned()],
1017            }],
1018            module_contributions: vec![ModuleContribution {
1019                id: "echo".to_owned(),
1020                package_id: "example.echo".to_owned(),
1021                configuration_schema_digest: ZERO_DIGEST.to_owned(),
1022                provides: vec![CapabilityDeclaration {
1023                    capability_id: "test.echo@1".to_owned(),
1024                    descriptor_version: "1.0.0".to_owned(),
1025                    descriptor_digest: ZERO_DIGEST.to_owned(),
1026                    request_operations: vec!["echo".to_owned()],
1027                    operation_kinds: BTreeMap::new(),
1028                }],
1029                requires: Vec::new(),
1030                implementations: vec![ImplementationVariant {
1031                    id: "wasm".to_owned(),
1032                    artifact: Some("guest".to_owned()),
1033                    built_in_factory: None,
1034                    entrypoint: "echo".to_owned(),
1035                    execution_class: "lenso.wasm-component@1".to_owned(),
1036                    targets: vec!["wasm32-unknown-unknown".to_owned()],
1037                    profiles: vec!["provide-request-v1".to_owned()],
1038                    support_channel: SupportChannel::Preview,
1039                    trust: TrustLevel::Constrained,
1040                }],
1041                permission_request_ids: Vec::new(),
1042                state: None,
1043            }],
1044            data_contributions: Vec::new(),
1045            permission_requests: Vec::new(),
1046            features: Vec::new(),
1047            binding_templates: Vec::new(),
1048            product_metadata: Vec::new(),
1049        }
1050    }
1051
1052    fn wasm_with_descriptors(descriptors: &[&[u8]]) -> Vec<u8> {
1053        let mut module = wasm_encoder::Module::new();
1054        for descriptor in descriptors {
1055            module.section(&wasm_encoder::CustomSection {
1056                name: Cow::Borrowed(PLUGIN_DESCRIPTOR_SECTION),
1057                data: Cow::Borrowed(descriptor),
1058            });
1059        }
1060        module.finish()
1061    }
1062
1063    #[test]
1064    fn publisher_template_duplicates_guest_descriptor() {
1065        let guest_descriptor = lenso_guest_sdk::encode_guest_descriptor(
1066            &[lenso_guest_sdk::GuestProvidedCapability {
1067                capability_id: "test.echo@1",
1068                descriptor_version: "1.0.0",
1069                request_operations: &["echo"],
1070                stream_operations: &[],
1071            }],
1072            &[],
1073        );
1074        let guest: Value = serde_json::from_str(&guest_descriptor).unwrap();
1075        let template = wasm_template_with_duplicated_descriptor();
1076        let contribution = &template.module_contributions[0];
1077        let implementation = &contribution.implementations[0];
1078
1079        assert_eq!(
1080            (
1081                contribution.provides[0].capability_id.as_str(),
1082                contribution.provides[0].descriptor_version.as_str(),
1083                contribution.provides[0].request_operations.as_slice(),
1084                implementation.artifact.as_deref(),
1085                implementation.execution_class.as_str(),
1086                implementation.targets.as_slice(),
1087                implementation.trust,
1088            ),
1089            (
1090                guest["capabilities"][0]["capability_id"].as_str().unwrap(),
1091                guest["capabilities"][0]["descriptor_version"]
1092                    .as_str()
1093                    .unwrap(),
1094                &["echo".to_owned()][..],
1095                Some("guest"),
1096                "lenso.wasm-component@1",
1097                &["wasm32-unknown-unknown".to_owned()][..],
1098                TrustLevel::Constrained,
1099            )
1100        );
1101    }
1102
1103    #[test]
1104    fn bundle_build_requires_a_publisher_template_before_reading_artifacts() {
1105        let source = tempfile::tempdir().unwrap();
1106        let error = build_bundle(&BundleBuild {
1107            template: source.path().join("missing-template.json"),
1108            output: source.path().join("dist/plugin"),
1109            artifact_sources: Vec::new(),
1110        })
1111        .unwrap_err();
1112
1113        assert!(error.to_string().contains("Plugin Manifest template"));
1114    }
1115
1116    #[test]
1117    fn source_metadata_rejects_publisher_owned_runtime_fields() {
1118        let error = toml::from_str::<CargoManifest>(
1119            r#"
1120                [package]
1121                version = "1.0.0"
1122
1123                [package.metadata.lenso]
1124                plugin-id = "example.echo"
1125                module-contributions = []
1126            "#,
1127        )
1128        .unwrap_err();
1129
1130        assert!(error.to_string().contains("module-contributions"));
1131    }
1132
1133    #[test]
1134    fn descriptor_extraction_rejects_missing_evidence() {
1135        let error = extract_plugin_descriptor(&wasm_with_descriptors(&[])).unwrap_err();
1136
1137        assert!(error.to_string().contains("does not contain"));
1138    }
1139
1140    #[test]
1141    fn descriptor_extraction_rejects_duplicate_evidence() {
1142        let descriptor = br#"{"profile":"one"}"#;
1143        let error = extract_plugin_descriptor(&wasm_with_descriptors(&[
1144            descriptor.as_slice(),
1145            descriptor.as_slice(),
1146        ]))
1147        .unwrap_err();
1148
1149        assert!(error.to_string().contains("duplicate"));
1150    }
1151
1152    #[test]
1153    fn descriptor_extraction_rejects_oversized_evidence() {
1154        let descriptor = vec![b' '; MAX_PLUGIN_DESCRIPTOR_BYTES + 1];
1155        let error = extract_plugin_descriptor(&wasm_with_descriptors(&[&descriptor])).unwrap_err();
1156
1157        assert!(error.to_string().contains("size limit"));
1158    }
1159
1160    #[test]
1161    fn descriptor_extraction_rejects_malformed_or_noncanonical_json() {
1162        let malformed = extract_plugin_descriptor(&wasm_with_descriptors(&[b"{"])).unwrap_err();
1163        let noncanonical =
1164            extract_plugin_descriptor(&wasm_with_descriptors(&[br#"{ "profile": "one" }"#]))
1165                .unwrap_err();
1166
1167        assert!(
1168            malformed.to_string().contains("invalid Plugin Manifest")
1169                && noncanonical.to_string().contains("not canonical")
1170        );
1171    }
1172
1173    #[test]
1174    fn descriptor_changes_are_closed_by_the_final_artifact_digest() {
1175        let first = wasm_with_descriptors(&[br#"{"profile":"one"}"#]);
1176        let second = wasm_with_descriptors(&[br#"{"profile":"two"}"#]);
1177
1178        assert_ne!(sha256_digest(&first), sha256_digest(&second));
1179    }
1180
1181    #[test]
1182    fn v1_creation_and_verification_remain_compatible_during_migration() {
1183        let source = tempfile::tempdir().unwrap();
1184        let template_path = source.path().join("lenso-plugin.template.json");
1185        fs::write(&template_path, serde_json::to_vec(&template()).unwrap()).unwrap();
1186        fs::write(
1187            source.path().join("source.mjs"),
1188            b"export const ready = true;\n",
1189        )
1190        .unwrap();
1191        let output = source.path().join("dist/plugin");
1192        let built = build_bundle(&BundleBuild {
1193            template: template_path,
1194            output: output.clone(),
1195            artifact_sources: vec![ArtifactSource {
1196                artifact_id: "script".to_owned(),
1197                path: source.path().join("source.mjs"),
1198            }],
1199        })
1200        .unwrap();
1201        assert_eq!(built, verify_bundle_directory(&output).unwrap());
1202        assert_ne!(built.artifact_digests, [ZERO_DIGEST]);
1203    }
1204
1205    #[test]
1206    fn verification_rejects_tampering_and_undeclared_files() {
1207        let mut manifest = template();
1208        let bytes = b"ready".to_vec();
1209        manifest.artifacts[0].digest = sha256_digest(&bytes);
1210        manifest.artifacts[0].size = u64::try_from(bytes.len()).unwrap();
1211        let document = ManifestDocument::from_value(manifest).unwrap();
1212        let mut files = BTreeMap::from([("plugin.mjs".to_owned(), bytes)]);
1213        verify_bundle_files(&document, &files).unwrap();
1214        files.insert("extra".to_owned(), Vec::new());
1215        assert!(verify_bundle_files(&document, &files).is_err());
1216    }
1217
1218    #[test]
1219    fn parsing_rejects_duplicate_fields_and_path_escape() {
1220        assert!(ManifestDocument::parse(br#"{"schema_version":1,"schema_version":1}"#).is_err());
1221        let mut manifest = template();
1222        manifest.artifacts[0].path = "../plugin.mjs".to_owned();
1223        assert!(ManifestDocument::from_value(manifest).is_err());
1224    }
1225}