Skip to main content

lenso_plugin_bundle/
lib.rs

1//! Immutable Plugin Release manifests, source materialization, and Bundle verification.
2
3mod model;
4mod selection;
5
6use std::{
7    collections::{BTreeMap, BTreeSet},
8    fmt, fs,
9    io::Read as _,
10    path::{Component, Path, PathBuf},
11};
12
13use lenso_app_plan::{
14    CapabilityEndpointPlan, CapabilityOperationKind, CapabilityRequirementPlan, ExecutionClassId,
15    authoring::{PluginContract, PluginDescriptor, PluginImplementation},
16};
17pub use model::*;
18pub use selection::*;
19use serde::{Deserialize, de::DeserializeOwned};
20use serde_json::Value;
21use sha2::{Digest, Sha256};
22
23/// The only manifest filename accepted in a materialized Plugin Bundle.
24pub const MANIFEST_FILE: &str = "lenso-plugin.json";
25
26/// Custom section carrying source-derived Plugin descriptor bytes.
27pub const PLUGIN_DESCRIPTOR_SECTION: &str = "lenso.plugin-descriptor.v1";
28
29/// Maximum accepted source-derived descriptor size.
30pub const MAX_PLUGIN_DESCRIPTOR_BYTES: usize = 64 * 1024;
31
32/// Host-owned resource bounds for verifying an untrusted materialized Bundle.
33#[derive(Clone, Debug, Eq, PartialEq)]
34pub struct BundleVerificationLimits {
35    pub max_manifest_bytes: u64,
36    pub max_file_bytes: u64,
37    pub max_total_bytes: u64,
38    pub max_file_count: usize,
39    pub max_entry_count: usize,
40    pub max_directory_depth: usize,
41}
42
43impl Default for BundleVerificationLimits {
44    fn default() -> Self {
45        Self {
46            max_manifest_bytes: 1024 * 1024,
47            max_file_bytes: 256 * 1024 * 1024,
48            max_total_bytes: 512 * 1024 * 1024,
49            max_file_count: 128,
50            max_entry_count: 256,
51            max_directory_depth: 32,
52        }
53    }
54}
55
56#[derive(Clone, Debug, Eq, PartialEq)]
57struct BundleFileSummary {
58    size: u64,
59    digest: String,
60}
61
62/// Source-only input for one generated V2 Plugin Bundle.
63#[derive(Clone, Debug, Eq, PartialEq)]
64pub struct SourcePluginBuild {
65    pub package_manifest: PathBuf,
66    pub wasm_module: PathBuf,
67    pub output: PathBuf,
68}
69
70/// Source-only input for one precompiled Process Plugin Bundle.
71///
72/// The descriptor is generated by the language SDK. Bundle construction never
73/// executes the process; the Process Adapter repeats the descriptor handshake
74/// before readiness.
75#[derive(Clone, Debug, Eq, PartialEq)]
76pub struct SourceProcessPluginBuild {
77    pub package_manifest: PathBuf,
78    pub executable: PathBuf,
79    pub runtime_descriptor: PathBuf,
80    pub target: String,
81    pub output: PathBuf,
82}
83
84/// Source-only input for one V3 Plugin Release containing multiple implementations.
85#[derive(Clone, Debug, Eq, PartialEq)]
86pub struct SourcePluginReleaseBuild {
87    pub contract: PluginContract,
88    pub implementations: Vec<SourcePluginImplementation>,
89    pub output: PathBuf,
90}
91
92/// One already-built implementation Artifact admitted to a V3 Plugin Release.
93#[derive(Clone, Debug, Eq, PartialEq)]
94pub struct SourcePluginImplementation {
95    pub id: String,
96    pub host_targets: Vec<String>,
97    pub artifact: PathBuf,
98    pub bundle_path: String,
99    pub media_type: String,
100    pub target: String,
101    pub entrypoint: String,
102    pub execution_class: ExecutionClassId,
103}
104
105#[derive(Clone, Debug)]
106struct SourceManifestDocument {
107    value: PluginManifestV2,
108    bytes: Vec<u8>,
109    digest: String,
110}
111
112#[derive(Clone, Debug)]
113struct ManifestDocument {
114    value: PluginManifest,
115    digest: String,
116}
117
118impl ManifestDocument {
119    fn parse(input: &[u8]) -> Result<Self, BundleError> {
120        let value = strict_json::<Value>(input)?;
121        let schema_version = value
122            .get("schema_version")
123            .and_then(Value::as_u64)
124            .ok_or_else(|| BundleError::InvalidManifest("schema_version is required".to_owned()))?;
125        let value = match schema_version {
126            2 => PluginManifest::V2(
127                serde_json::from_value(value)
128                    .map_err(|error| BundleError::InvalidManifest(error.to_string()))?,
129            ),
130            3 => PluginManifest::V3(
131                serde_json::from_value(value)
132                    .map_err(|error| BundleError::InvalidManifest(error.to_string()))?,
133            ),
134            _ => return invalid_manifest("unsupported schema version"),
135        };
136        validate_manifest(&value)?;
137        let canonical = match &value {
138            PluginManifest::V2(value) => serde_json::to_vec(value),
139            PluginManifest::V3(value) => serde_json::to_vec(value),
140        }
141        .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
142        Ok(Self {
143            value,
144            digest: sha256_digest(&canonical),
145        })
146    }
147}
148
149impl SourceManifestDocument {
150    #[cfg(test)]
151    fn parse(input: &[u8]) -> Result<Self, BundleError> {
152        let value = strict_json::<PluginManifestV2>(input)?;
153        Self::from_value(value)
154    }
155
156    fn from_value(value: PluginManifestV2) -> Result<Self, BundleError> {
157        validate_source_manifest(&value)?;
158        let json = serde_json::to_value(&value)
159            .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
160        validate_json_value(&json)?;
161        let bytes = serde_json::to_vec(&json)
162            .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
163        let digest = sha256_digest(&bytes);
164        Ok(Self {
165            value,
166            bytes,
167            digest,
168        })
169    }
170}
171
172#[derive(Debug, Deserialize)]
173struct CargoManifest {
174    package: CargoPackage,
175}
176
177#[derive(Debug, Deserialize)]
178struct CargoPackage {
179    version: String,
180    metadata: CargoMetadata,
181}
182
183#[derive(Debug, Deserialize)]
184struct CargoMetadata {
185    lenso: CargoLensoMetadata,
186}
187
188#[derive(Debug, Deserialize)]
189#[serde(deny_unknown_fields, rename_all = "kebab-case")]
190struct CargoLensoMetadata {
191    plugin_id: String,
192    root_slot: String,
193}
194
195#[derive(Debug, Deserialize)]
196#[serde(deny_unknown_fields)]
197struct GuestRuntimeDescriptor {
198    abi: String,
199    capabilities: Vec<GuestCapability>,
200    #[serde(default)]
201    required_capabilities: Vec<GuestRequirement>,
202}
203
204#[derive(Debug, Deserialize)]
205#[serde(deny_unknown_fields)]
206struct GuestCapability {
207    capability_id: String,
208    descriptor_version: String,
209    request_operations: Vec<String>,
210    #[serde(default)]
211    stream_operations: Vec<String>,
212}
213
214#[derive(Debug, Deserialize)]
215#[serde(deny_unknown_fields)]
216struct GuestRequirement {
217    capability_id: String,
218    descriptor_version: String,
219    cardinality: String,
220}
221
222/// Verified closure of one immutable Plugin Release.
223#[derive(Clone, Debug, Eq, PartialEq)]
224pub struct VerifiedBundle {
225    pub plugin_id: String,
226    pub release_version: String,
227    pub manifest_digest: String,
228    pub artifact_digests: Vec<String>,
229    pub product_metadata_digests: Vec<String>,
230}
231
232/// A Plugin authoring or immutable Bundle invariant failed closed.
233#[derive(Clone, Debug, Eq, PartialEq)]
234pub enum BundleError {
235    InvalidManifest(String),
236    InvalidBundle(String),
237    DigestMismatch(String),
238    Io(String),
239    Wasm(String),
240}
241
242impl fmt::Display for BundleError {
243    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
244        match self {
245            Self::InvalidManifest(detail) => write!(formatter, "invalid Plugin Manifest: {detail}"),
246            Self::InvalidBundle(detail) => write!(formatter, "invalid Plugin Bundle: {detail}"),
247            Self::DigestMismatch(subject) => write!(formatter, "digest mismatch for {subject}"),
248            Self::Io(detail) => formatter.write_str(detail),
249            Self::Wasm(detail) => write!(
250                formatter,
251                "failed to encode WebAssembly Component: {detail}"
252            ),
253        }
254    }
255}
256
257impl std::error::Error for BundleError {}
258
259/// Builds a one-entry V2 Plugin Bundle entirely from package and source evidence.
260pub fn build_source_plugin_bundle(
261    build: &SourcePluginBuild,
262) -> Result<VerifiedBundle, BundleError> {
263    if build.output.exists() {
264        return invalid_bundle(format!(
265            "output `{}` already exists",
266            build.output.display()
267        ));
268    }
269    let package_bytes = read_regular_file(&build.package_manifest, "Cargo manifest")?;
270    let package = toml::from_slice::<CargoManifest>(&package_bytes)
271        .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
272    let module = read_regular_file(&build.wasm_module, "Plugin Wasm module")?;
273    let component = wit_component::ComponentEncoder::default()
274        .module(&module)
275        .map_err(|error| BundleError::Wasm(error.to_string()))?
276        .validate(true)
277        .encode()
278        .map_err(|error| BundleError::Wasm(error.to_string()))?;
279    let runtime_descriptor = extract_plugin_descriptor(&component)?;
280    let artifact = PluginArtifactV2 {
281        path: "plugin.wasm".to_owned(),
282        digest: sha256_digest(&component),
283        size: u64::try_from(component.len())
284            .map_err(|_| BundleError::InvalidBundle("Artifact size exceeds u64".to_owned()))?,
285        media_type: "application/wasm".to_owned(),
286        target: "wasm32-unknown-unknown".to_owned(),
287    };
288    let descriptor = portable_plugin_descriptor(
289        &package.package.metadata.lenso.plugin_id,
290        &package.package.version,
291        &package.package.metadata.lenso.root_slot,
292        &artifact.digest,
293        &runtime_descriptor,
294        "lenso.wasm-component@1",
295    )?;
296    let document = SourceManifestDocument::from_value(PluginManifestV2 {
297        schema_version: 2,
298        plugin_id: package.package.metadata.lenso.plugin_id,
299        release_version: package.package.version,
300        artifact,
301        entry: PluginEntryV2 { descriptor },
302    })?;
303
304    let output_parent = build.output.parent().unwrap_or_else(|| Path::new("."));
305    fs::create_dir_all(output_parent).map_err(io_error)?;
306    let staging = tempfile::Builder::new()
307        .prefix(".lenso-plugin-")
308        .tempdir_in(output_parent)
309        .map_err(io_error)?;
310    write_bundle_file(staging.path(), &document.value.artifact.path, &component)?;
311    fs::write(staging.path().join(MANIFEST_FILE), &document.bytes).map_err(io_error)?;
312    fs::rename(staging.path(), &build.output).map_err(io_error)?;
313    verify_bundle_directory(&build.output)
314}
315
316/// Builds a one-entry V2 Process Plugin Bundle from generated source evidence.
317pub fn build_source_process_plugin_bundle(
318    build: &SourceProcessPluginBuild,
319) -> Result<VerifiedBundle, BundleError> {
320    if build.output.exists() {
321        return invalid_bundle(format!(
322            "output `{}` already exists",
323            build.output.display()
324        ));
325    }
326    if build.target.trim().is_empty() {
327        return invalid_manifest("Process target is empty");
328    }
329    let package_bytes = read_regular_file(&build.package_manifest, "Cargo manifest")?;
330    let package = toml::from_slice::<CargoManifest>(&package_bytes)
331        .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
332    let executable = read_regular_file(&build.executable, "Process executable")?;
333    let encoded_descriptor = read_regular_file(&build.runtime_descriptor, "runtime descriptor")?;
334    let artifact = PluginArtifactV2 {
335        path: if cfg!(windows) {
336            "plugin.exe".to_owned()
337        } else {
338            "plugin".to_owned()
339        },
340        digest: sha256_digest(&executable),
341        size: u64::try_from(executable.len())
342            .map_err(|_| BundleError::InvalidBundle("Artifact size exceeds u64".to_owned()))?,
343        media_type: "application/vnd.lenso.process".to_owned(),
344        target: build.target.clone(),
345    };
346    let descriptor = portable_plugin_descriptor(
347        &package.package.metadata.lenso.plugin_id,
348        &package.package.version,
349        &package.package.metadata.lenso.root_slot,
350        &artifact.digest,
351        &encoded_descriptor,
352        "lenso.process@1",
353    )?;
354    let document = SourceManifestDocument::from_value(PluginManifestV2 {
355        schema_version: 2,
356        plugin_id: package.package.metadata.lenso.plugin_id,
357        release_version: package.package.version,
358        artifact,
359        entry: PluginEntryV2 { descriptor },
360    })?;
361
362    let output_parent = build.output.parent().unwrap_or_else(|| Path::new("."));
363    fs::create_dir_all(output_parent).map_err(io_error)?;
364    let staging = tempfile::Builder::new()
365        .prefix(".lenso-plugin-")
366        .tempdir_in(output_parent)
367        .map_err(io_error)?;
368    write_bundle_file(staging.path(), &document.value.artifact.path, &executable)?;
369    preserve_executable_permissions(
370        &build.executable,
371        &staging.path().join(&document.value.artifact.path),
372    )?;
373    fs::write(staging.path().join(MANIFEST_FILE), &document.bytes).map_err(io_error)?;
374    fs::rename(staging.path(), &build.output).map_err(io_error)?;
375    verify_bundle_directory(&build.output)
376}
377
378/// Materializes a V3 Plugin Bundle from one contract and built implementation Artifacts.
379pub fn build_source_plugin_release_bundle(
380    build: &SourcePluginReleaseBuild,
381) -> Result<VerifiedBundle, BundleError> {
382    if build.output.exists() {
383        return invalid_bundle(format!(
384            "output `{}` already exists",
385            build.output.display()
386        ));
387    }
388    let mut files = Vec::with_capacity(build.implementations.len());
389    let mut implementations = Vec::with_capacity(build.implementations.len());
390    for source in &build.implementations {
391        let bytes = read_regular_file(&source.artifact, "Plugin implementation Artifact")?;
392        let digest = sha256_digest(&bytes);
393        let artifact = PluginArtifactV2 {
394            path: source.bundle_path.clone(),
395            digest: digest.clone(),
396            size: u64::try_from(bytes.len())
397                .map_err(|_| BundleError::InvalidBundle("Artifact size exceeds u64".to_owned()))?,
398            media_type: source.media_type.clone(),
399            target: source.target.clone(),
400        };
401        implementations.push(PluginImplementationV3 {
402            id: source.id.clone(),
403            host_targets: source.host_targets.clone(),
404            artifact,
405            runtime: PluginImplementation::new(
406                build.contract.plugin_id(),
407                digest,
408                &source.entrypoint,
409                source.execution_class.clone(),
410            ),
411        });
412        files.push((source, bytes));
413    }
414    implementations.sort_by(|left, right| left.id.cmp(&right.id));
415    let manifest = PluginManifestV3 {
416        schema_version: 3,
417        contract: build.contract.clone(),
418        implementations,
419    };
420    validate_v3_manifest(&manifest)?;
421    let bytes = serde_json::to_vec(&manifest)
422        .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
423
424    let output_parent = build.output.parent().unwrap_or_else(|| Path::new("."));
425    fs::create_dir_all(output_parent).map_err(io_error)?;
426    let staging = tempfile::Builder::new()
427        .prefix(".lenso-plugin-")
428        .tempdir_in(output_parent)
429        .map_err(io_error)?;
430    for (source, artifact) in files {
431        write_bundle_file(staging.path(), &source.bundle_path, &artifact)?;
432        if source.media_type == "application/vnd.lenso.process" {
433            preserve_executable_permissions(
434                &source.artifact,
435                &staging.path().join(&source.bundle_path),
436            )?;
437        }
438    }
439    fs::write(staging.path().join(MANIFEST_FILE), bytes).map_err(io_error)?;
440    fs::rename(staging.path(), &build.output).map_err(io_error)?;
441    verify_bundle_directory(&build.output)
442}
443
444/// Verifies an already materialized directory as an exact immutable Bundle closure.
445pub fn verify_bundle_directory(root: &Path) -> Result<VerifiedBundle, BundleError> {
446    verify_bundle_directory_with_limits(root, &BundleVerificationLimits::default())
447}
448
449/// Verifies one Bundle with explicit Host-owned resource bounds.
450pub fn verify_bundle_directory_with_limits(
451    root: &Path,
452    limits: &BundleVerificationLimits,
453) -> Result<VerifiedBundle, BundleError> {
454    verify_bundle_document_with_limits(root, limits).map(|(verified, _)| verified)
455}
456
457fn verify_bundle_document_with_limits(
458    root: &Path,
459    limits: &BundleVerificationLimits,
460) -> Result<(VerifiedBundle, ManifestDocument), BundleError> {
461    verify_bundle_document_with_limits_after_manifest_read(root, limits, || {})
462}
463
464fn verify_bundle_document_with_limits_after_manifest_read(
465    root: &Path,
466    limits: &BundleVerificationLimits,
467    after_manifest_read: impl FnOnce(),
468) -> Result<(VerifiedBundle, ManifestDocument), BundleError> {
469    validate_verification_limits(limits)?;
470    let manifest_path = root.join(MANIFEST_FILE);
471    let manifest_bytes =
472        read_regular_file_bounded(&manifest_path, "Plugin Manifest", limits.max_manifest_bytes)?;
473    after_manifest_read();
474    let mut files = BTreeMap::new();
475    let mut total_size = 0_u64;
476    let mut entry_count = 0_usize;
477    collect_bundle_files(
478        root,
479        root,
480        0,
481        limits,
482        &mut entry_count,
483        &mut total_size,
484        &mut files,
485    )?;
486    let manifest_summary = files
487        .remove(MANIFEST_FILE)
488        .ok_or_else(|| BundleError::InvalidBundle("Bundle is missing its Manifest".to_owned()))?;
489    if manifest_summary.size != u64::try_from(manifest_bytes.len()).unwrap_or(u64::MAX)
490        || manifest_summary.digest != sha256_digest(&manifest_bytes)
491    {
492        return invalid_bundle("Plugin Manifest changed during Bundle verification");
493    }
494    let manifest = ManifestDocument::parse(&manifest_bytes)?;
495    let verified = verify_manifest_bundle_files(root, &manifest, &files, limits)?;
496    Ok((verified, manifest))
497}
498
499/// Strictly reads either supported Plugin Manifest version from a verified Bundle.
500pub fn read_bundle_manifest(root: &Path) -> Result<PluginManifest, BundleError> {
501    let (_, manifest) =
502        verify_bundle_document_with_limits(root, &BundleVerificationLimits::default())?;
503    Ok(manifest.value)
504}
505
506fn verify_manifest_bundle_files(
507    root: &Path,
508    manifest: &ManifestDocument,
509    files: &BTreeMap<String, BundleFileSummary>,
510    limits: &BundleVerificationLimits,
511) -> Result<VerifiedBundle, BundleError> {
512    match &manifest.value {
513        PluginManifest::V2(value) => verify_source_bundle_files(
514            &SourceManifestDocument {
515                value: value.clone(),
516                bytes: Vec::new(),
517                digest: manifest.digest.clone(),
518            },
519            root,
520            files,
521            limits,
522        ),
523        PluginManifest::V3(value) => {
524            verify_v3_bundle_files(root, value, &manifest.digest, files, limits)
525        }
526    }
527}
528
529fn verify_v3_bundle_files(
530    root: &Path,
531    manifest: &PluginManifestV3,
532    manifest_digest: &str,
533    files: &BTreeMap<String, BundleFileSummary>,
534    limits: &BundleVerificationLimits,
535) -> Result<VerifiedBundle, BundleError> {
536    if files.len() != manifest.implementations.len() {
537        return invalid_bundle("V3 Bundle closure does not equal its implementation Artifacts");
538    }
539    let mut artifact_digests = Vec::with_capacity(manifest.implementations.len());
540    for implementation in &manifest.implementations {
541        let artifact = &implementation.artifact;
542        let Some(summary) = files.get(&artifact.path) else {
543            return invalid_bundle(format!("V3 Bundle is missing `{}`", artifact.path));
544        };
545        if artifact.size != summary.size || artifact.digest != summary.digest {
546            return Err(BundleError::DigestMismatch(artifact.path.clone()));
547        }
548        if implementation.runtime.runtime_package_revision() != artifact.digest {
549            return invalid_manifest("implementation revision must equal its Artifact digest");
550        }
551        let descriptor = manifest.contract.resolve(&implementation.runtime);
552        if artifact.media_type == "application/wasm" {
553            let bytes = read_verified_bundle_artifact(root, artifact, limits)?;
554            let encoded = extract_plugin_descriptor(&bytes)?;
555            let derived = portable_plugin_descriptor(
556                manifest.contract.plugin_id(),
557                manifest.contract.release_version(),
558                manifest.contract.root_slot(),
559                &artifact.digest,
560                &encoded,
561                implementation.runtime.execution_class().as_str(),
562            )?;
563            let derived = serde_json::from_value::<PluginDescriptor>(derived)
564                .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
565            if derived.contract() != manifest.contract
566                || derived.implementation() != implementation.runtime
567            {
568                return invalid_bundle(
569                    "Wasm source descriptor does not match its V3 Contract and implementation",
570                );
571            }
572        } else if descriptor.provided_capabilities().is_empty() {
573            return invalid_manifest("implementation Contract must provide a Capability");
574        }
575        artifact_digests.push(artifact.digest.clone());
576    }
577    Ok(VerifiedBundle {
578        plugin_id: manifest.contract.plugin_id().to_owned(),
579        release_version: manifest.contract.release_version().to_owned(),
580        manifest_digest: manifest_digest.to_owned(),
581        artifact_digests,
582        product_metadata_digests: Vec::new(),
583    })
584}
585
586fn verify_source_bundle_files(
587    manifest: &SourceManifestDocument,
588    root: &Path,
589    files: &BTreeMap<String, BundleFileSummary>,
590    limits: &BundleVerificationLimits,
591) -> Result<VerifiedBundle, BundleError> {
592    let artifact = &manifest.value.artifact;
593    if files.len() != 1 {
594        return invalid_bundle("V2 Bundle must contain exactly one Artifact");
595    }
596    let Some(summary) = files.get(&artifact.path) else {
597        return invalid_bundle("V2 Bundle does not contain its declared Artifact");
598    };
599    if artifact.size != summary.size || artifact.digest != summary.digest {
600        return Err(BundleError::DigestMismatch(artifact.path.clone()));
601    }
602    if artifact.media_type == "application/wasm" {
603        let bytes = read_verified_bundle_artifact(root, artifact, limits)?;
604        let runtime_descriptor = extract_plugin_descriptor(&bytes)?;
605        let descriptor = portable_plugin_descriptor(
606            &manifest.value.plugin_id,
607            &manifest.value.release_version,
608            manifest
609                .value
610                .entry
611                .descriptor
612                .get("root_slot")
613                .and_then(Value::as_str)
614                .ok_or_else(|| BundleError::InvalidManifest("root_slot is required".to_owned()))?,
615            &artifact.digest,
616            &runtime_descriptor,
617            "lenso.wasm-component@1",
618        )?;
619        let packaged = serde_json::to_vec(&manifest.value.entry.descriptor)
620            .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
621        let derived = serde_json::to_vec(&descriptor)
622            .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
623        if derived != packaged {
624            return invalid_bundle("source descriptor does not match the V2 Plugin entry");
625        }
626    } else {
627        validate_process_descriptor(manifest)?;
628    }
629    Ok(VerifiedBundle {
630        plugin_id: manifest.value.plugin_id.clone(),
631        release_version: manifest.value.release_version.clone(),
632        manifest_digest: manifest.digest.clone(),
633        artifact_digests: vec![artifact.digest.clone()],
634        product_metadata_digests: Vec::new(),
635    })
636}
637
638fn portable_plugin_descriptor(
639    plugin_id: &str,
640    release_version: &str,
641    root_slot: &str,
642    artifact_digest: &str,
643    encoded: &[u8],
644    execution_class: &str,
645) -> Result<Value, BundleError> {
646    let runtime = strict_json::<GuestRuntimeDescriptor>(encoded)?;
647    if ![
648        "lenso.json-request@1",
649        "lenso.json-interactions@1",
650        "lenso.json-host-imports@1",
651    ]
652    .contains(&runtime.abi.as_str())
653    {
654        return invalid_manifest("unsupported guest Plugin ABI");
655    }
656    let mut descriptor = PluginDescriptor::new(plugin_id, release_version, root_slot)
657        .with_runtime_package(plugin_id, artifact_digest)
658        .with_entrypoint("plugin")
659        .with_execution_class(ExecutionClassId::new(execution_class));
660    for capability in runtime.capabilities {
661        let mut endpoint = CapabilityEndpointPlan::new(
662            capability.capability_id,
663            capability.descriptor_version,
664            capability
665                .request_operations
666                .iter()
667                .chain(&capability.stream_operations)
668                .cloned(),
669        );
670        for operation in capability.stream_operations {
671            endpoint = endpoint.with_operation_kind(operation, CapabilityOperationKind::Stream);
672        }
673        descriptor = descriptor.with_capability(endpoint);
674    }
675    for requirement in runtime.required_capabilities {
676        if requirement.cardinality != "one" {
677            return invalid_manifest("unsupported guest Capability cardinality");
678        }
679        descriptor = descriptor.with_requirement(CapabilityRequirementPlan::one(
680            requirement.capability_id,
681            requirement.descriptor_version,
682        ));
683    }
684    serde_json::to_value(descriptor)
685        .map_err(|error| BundleError::InvalidManifest(error.to_string()))
686}
687
688fn validate_process_descriptor(manifest: &SourceManifestDocument) -> Result<(), BundleError> {
689    if manifest.value.artifact.media_type != "application/vnd.lenso.process" {
690        return invalid_manifest("non-Wasm V2 Artifact must be a Process executable");
691    }
692    let descriptor =
693        serde_json::from_value::<PluginDescriptor>(manifest.value.entry.descriptor.clone())
694            .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
695    if descriptor.plugin_id() != manifest.value.plugin_id
696        || descriptor.release_version() != manifest.value.release_version
697        || descriptor.root_slot().is_empty()
698        || descriptor.runtime_package_id() != manifest.value.plugin_id
699        || descriptor.runtime_package_revision() != manifest.value.artifact.digest
700        || descriptor.entrypoint() != "plugin"
701        || descriptor.execution_class().as_str() != "lenso.process@1"
702        || descriptor.provided_capabilities().is_empty()
703    {
704        return invalid_manifest("Process descriptor does not close exact Bundle authority");
705    }
706    Ok(())
707}
708
709/// Extracts one canonical source-derived Plugin descriptor without executing it.
710pub fn extract_plugin_descriptor(component: &[u8]) -> Result<Vec<u8>, BundleError> {
711    let mut descriptors = Vec::new();
712    collect_plugin_descriptors(component, &mut descriptors)?;
713    let [descriptor] = descriptors.as_slice() else {
714        return invalid_bundle(if descriptors.is_empty() {
715            "Plugin Component does not contain a source-derived descriptor"
716        } else {
717            "Plugin Component contains duplicate source-derived descriptors"
718        });
719    };
720    let value = strict_json::<Value>(descriptor)?;
721    let canonical = serde_json::to_vec(&value)
722        .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
723    if canonical != *descriptor {
724        return invalid_bundle("Plugin descriptor is not canonical JSON");
725    }
726    Ok(descriptor.clone())
727}
728
729fn collect_plugin_descriptors(
730    bytes: &[u8],
731    descriptors: &mut Vec<Vec<u8>>,
732) -> Result<(), BundleError> {
733    for payload in wasmparser::Parser::new(0).parse_all(bytes) {
734        match payload.map_err(|error| BundleError::Wasm(error.to_string()))? {
735            wasmparser::Payload::CustomSection(section)
736                if section.name() == PLUGIN_DESCRIPTOR_SECTION =>
737            {
738                if section.data().len() > MAX_PLUGIN_DESCRIPTOR_BYTES {
739                    return invalid_bundle("Plugin descriptor exceeds the size limit");
740                }
741                descriptors.push(section.data().to_vec());
742            }
743            _ => {}
744        }
745    }
746    Ok(())
747}
748
749fn validate_source_manifest(manifest: &PluginManifestV2) -> Result<(), BundleError> {
750    if manifest.schema_version != 2 {
751        return invalid_manifest("unsupported schema version");
752    }
753    if manifest.plugin_id.is_empty() || semver::Version::parse(&manifest.release_version).is_err() {
754        return invalid_manifest("Plugin identity or Release version is invalid");
755    }
756    validate_relative_path(&manifest.artifact.path)?;
757    digest_component(&manifest.artifact.digest)?;
758    if manifest.artifact.size == 0 {
759        return invalid_manifest("V2 Artifact size must be non-zero");
760    }
761    match manifest.artifact.media_type.as_str() {
762        "application/wasm" if manifest.artifact.target == "wasm32-unknown-unknown" => {}
763        "application/vnd.lenso.process" if !manifest.artifact.target.trim().is_empty() => {}
764        _ => return invalid_manifest("V2 Artifact media type and target are not supported"),
765    }
766    if !manifest.entry.descriptor.is_object() {
767        return invalid_manifest("V2 Plugin entry descriptor must be an object");
768    }
769    Ok(())
770}
771
772fn validate_manifest(manifest: &PluginManifest) -> Result<(), BundleError> {
773    match manifest {
774        PluginManifest::V2(value) => validate_source_manifest(value),
775        PluginManifest::V3(value) => validate_v3_manifest(value),
776    }
777}
778
779fn validate_v3_manifest(manifest: &PluginManifestV3) -> Result<(), BundleError> {
780    if manifest.schema_version != 3 {
781        return invalid_manifest("unsupported schema version");
782    }
783    if manifest.contract.plugin_id().is_empty()
784        || semver::Version::parse(manifest.contract.release_version()).is_err()
785        || manifest.contract.root_slot().is_empty()
786        || manifest.contract.provided_capabilities().is_empty()
787        || manifest.implementations.is_empty()
788    {
789        return invalid_manifest("V3 Contract or implementation set is invalid");
790    }
791    let mut ids = BTreeSet::new();
792    let mut paths = BTreeSet::new();
793    for implementation in &manifest.implementations {
794        if implementation.id.trim().is_empty() || !ids.insert(&implementation.id) {
795            return invalid_manifest("V3 implementation ids must be non-empty and unique");
796        }
797        if implementation.host_targets.is_empty()
798            || implementation
799                .host_targets
800                .iter()
801                .any(|target| target.trim().is_empty())
802        {
803            return invalid_manifest("V3 implementation host targets must be non-empty");
804        }
805        validate_artifact(&implementation.artifact)?;
806        if !paths.insert(&implementation.artifact.path) {
807            return invalid_manifest("V3 implementation Artifact paths must be unique");
808        }
809        if implementation.runtime.runtime_package_id() != manifest.contract.plugin_id()
810            || implementation.runtime.runtime_package_revision() != implementation.artifact.digest
811            || implementation.runtime.entrypoint().is_empty()
812        {
813            return invalid_manifest("V3 implementation does not close Plugin authority");
814        }
815    }
816    Ok(())
817}
818
819fn validate_artifact(artifact: &PluginArtifactV2) -> Result<(), BundleError> {
820    validate_relative_path(&artifact.path)?;
821    digest_component(&artifact.digest)?;
822    if artifact.size == 0 {
823        return invalid_manifest("Artifact size must be non-zero");
824    }
825    match artifact.media_type.as_str() {
826        "application/wasm" if artifact.target == "wasm32-unknown-unknown" => Ok(()),
827        "application/vnd.lenso.process" | "application/javascript"
828            if !artifact.target.trim().is_empty() =>
829        {
830            Ok(())
831        }
832        _ => invalid_manifest("Artifact media type and target are not supported"),
833    }
834}
835
836/// Validates publisher-owned Manifest semantics independently of Host policy.
837#[allow(clippy::too_many_lines)]
838/// Computes the canonical digest syntax used by Plugin Release documents and files.
839pub fn sha256_digest(bytes: &[u8]) -> String {
840    format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
841}
842
843fn strict_json<T: DeserializeOwned>(input: &[u8]) -> Result<T, BundleError> {
844    let mut deserializer = serde_json::Deserializer::from_slice(input);
845    let strict = StrictValue::deserialize(&mut deserializer)
846        .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
847    deserializer
848        .end()
849        .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
850    validate_json_value(&strict.0)?;
851    serde_json::from_value(strict.0)
852        .map_err(|error| BundleError::InvalidManifest(error.to_string()))
853}
854
855#[derive(Clone, Debug)]
856struct StrictValue(Value);
857
858impl<'de> Deserialize<'de> for StrictValue {
859    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
860    where
861        D: serde::Deserializer<'de>,
862    {
863        deserializer.deserialize_any(StrictVisitor)
864    }
865}
866
867struct StrictVisitor;
868
869impl<'de> serde::de::Visitor<'de> for StrictVisitor {
870    type Value = StrictValue;
871
872    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
873        formatter.write_str("strict Plugin Manifest JSON")
874    }
875
876    fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> {
877        Ok(StrictValue(Value::Bool(value)))
878    }
879
880    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
881        Ok(StrictValue(Value::Number(value.into())))
882    }
883
884    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
885    where
886        E: serde::de::Error,
887    {
888        u64::try_from(value)
889            .map_err(|_| E::custom("negative integers are forbidden"))
890            .and_then(|value| self.visit_u64(value))
891    }
892
893    fn visit_f64<E>(self, _: f64) -> Result<Self::Value, E>
894    where
895        E: serde::de::Error,
896    {
897        Err(E::custom("floating-point values are forbidden"))
898    }
899
900    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> {
901        Ok(StrictValue(Value::String(value.to_owned())))
902    }
903
904    fn visit_string<E>(self, value: String) -> Result<Self::Value, E> {
905        Ok(StrictValue(Value::String(value)))
906    }
907
908    fn visit_none<E>(self) -> Result<Self::Value, E> {
909        Ok(StrictValue(Value::Null))
910    }
911
912    fn visit_unit<E>(self) -> Result<Self::Value, E> {
913        Ok(StrictValue(Value::Null))
914    }
915
916    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
917    where
918        A: serde::de::SeqAccess<'de>,
919    {
920        let mut values = Vec::new();
921        while let Some(value) = sequence.next_element::<StrictValue>()? {
922            values.push(value.0);
923        }
924        Ok(StrictValue(Value::Array(values)))
925    }
926
927    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
928    where
929        A: serde::de::MapAccess<'de>,
930    {
931        let mut keys = BTreeSet::new();
932        let mut values = serde_json::Map::new();
933        while let Some(key) = map.next_key::<String>()? {
934            if !keys.insert(key.clone()) {
935                return Err(serde::de::Error::custom(format!("duplicate field `{key}`")));
936            }
937            values.insert(key, map.next_value::<StrictValue>()?.0);
938        }
939        Ok(StrictValue(Value::Object(values)))
940    }
941}
942
943fn validate_json_value(value: &Value) -> Result<(), BundleError> {
944    match value {
945        Value::Number(number) if !number.is_u64() => {
946            invalid_manifest("numbers must be non-negative integers")
947        }
948        Value::Array(values) => values.iter().try_for_each(validate_json_value),
949        Value::Object(values) => values.values().try_for_each(validate_json_value),
950        _ => Ok(()),
951    }
952}
953
954fn validate_relative_path(path: &str) -> Result<(), BundleError> {
955    if path.is_empty() || path.contains('\\') {
956        return invalid_manifest("Bundle path is empty or platform-ambiguous");
957    }
958    let path = Path::new(path);
959    if path.is_absolute()
960        || path
961            .components()
962            .any(|part| !matches!(part, Component::Normal(_)))
963    {
964        return invalid_manifest("Bundle path must contain only normalized relative segments");
965    }
966    Ok(())
967}
968
969fn digest_component(digest: &str) -> Result<&str, BundleError> {
970    let Some(value) = digest.strip_prefix("sha256:") else {
971        return invalid_manifest("digest does not use sha256 prefix");
972    };
973    if value.len() != 64
974        || !value
975            .bytes()
976            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
977    {
978        return invalid_manifest("digest is not 64 lowercase hexadecimal characters");
979    }
980    Ok(value)
981}
982
983fn read_regular_file(path: &Path, kind: &str) -> Result<Vec<u8>, BundleError> {
984    let metadata = fs::symlink_metadata(path)
985        .map_err(|error| BundleError::Io(format!("failed to inspect {kind}: {error}")))?;
986    if !metadata.is_file() || metadata.file_type().is_symlink() {
987        return invalid_bundle(format!("{kind} is not a regular file"));
988    }
989    fs::read(path).map_err(io_error)
990}
991
992fn validate_verification_limits(limits: &BundleVerificationLimits) -> Result<(), BundleError> {
993    if limits.max_manifest_bytes == 0
994        || limits.max_file_bytes == 0
995        || limits.max_total_bytes == 0
996        || limits.max_file_count == 0
997        || limits.max_entry_count == 0
998        || limits.max_directory_depth == 0
999        || limits.max_file_count > limits.max_entry_count
1000        || limits.max_manifest_bytes > limits.max_file_bytes
1001        || limits.max_file_bytes > limits.max_total_bytes
1002    {
1003        return invalid_bundle("Bundle verification limits are invalid");
1004    }
1005    Ok(())
1006}
1007
1008fn read_regular_file_bounded(path: &Path, kind: &str, limit: u64) -> Result<Vec<u8>, BundleError> {
1009    read_regular_file_bounded_after_inspection(path, kind, limit, || {})
1010}
1011
1012fn read_regular_file_bounded_after_inspection(
1013    path: &Path,
1014    kind: &str,
1015    limit: u64,
1016    after_inspection: impl FnOnce(),
1017) -> Result<Vec<u8>, BundleError> {
1018    let metadata = fs::symlink_metadata(path)
1019        .map_err(|error| BundleError::Io(format!("failed to inspect {kind}: {error}")))?;
1020    if !metadata.is_file() || metadata.file_type().is_symlink() {
1021        return invalid_bundle(format!("{kind} is not a regular file"));
1022    }
1023    if metadata.len() > limit {
1024        return invalid_bundle(format!("{kind} exceeds the configured size limit"));
1025    }
1026    after_inspection();
1027    let file = fs::File::open(path).map_err(io_error)?;
1028    let opened = file.metadata().map_err(io_error)?;
1029    if !opened.is_file() || !same_file_identity(&metadata, &opened) {
1030        return invalid_bundle(format!("{kind} changed during bounded read"));
1031    }
1032    let mut bytes = Vec::with_capacity(usize::try_from(metadata.len()).unwrap_or(0));
1033    file.take(limit.saturating_add(1))
1034        .read_to_end(&mut bytes)
1035        .map_err(io_error)?;
1036    if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > limit {
1037        return invalid_bundle(format!("{kind} exceeds the configured size limit"));
1038    }
1039    Ok(bytes)
1040}
1041
1042fn read_verified_bundle_artifact(
1043    root: &Path,
1044    artifact: &PluginArtifactV2,
1045    limits: &BundleVerificationLimits,
1046) -> Result<Vec<u8>, BundleError> {
1047    let bytes = read_regular_file_bounded(
1048        &root.join(&artifact.path),
1049        "Plugin Artifact",
1050        limits.max_file_bytes,
1051    )?;
1052    if u64::try_from(bytes.len()).unwrap_or(u64::MAX) != artifact.size
1053        || sha256_digest(&bytes) != artifact.digest
1054    {
1055        return Err(BundleError::DigestMismatch(artifact.path.clone()));
1056    }
1057    Ok(bytes)
1058}
1059
1060fn write_bundle_file(root: &Path, relative: &str, bytes: &[u8]) -> Result<(), BundleError> {
1061    let path = root.join(relative);
1062    if let Some(parent) = path.parent() {
1063        fs::create_dir_all(parent).map_err(io_error)?;
1064    }
1065    fs::write(path, bytes).map_err(io_error)
1066}
1067
1068#[cfg(unix)]
1069fn preserve_executable_permissions(source: &Path, destination: &Path) -> Result<(), BundleError> {
1070    use std::os::unix::fs::PermissionsExt as _;
1071
1072    let source_permissions = fs::metadata(source).map_err(io_error)?.permissions();
1073    let mode = source_permissions.mode();
1074    if mode & 0o111 == 0 {
1075        return invalid_bundle("Process executable has no executable permission bit");
1076    }
1077    fs::set_permissions(destination, fs::Permissions::from_mode(mode)).map_err(io_error)
1078}
1079
1080#[cfg(not(unix))]
1081fn preserve_executable_permissions(_: &Path, _: &Path) -> Result<(), BundleError> {
1082    Ok(())
1083}
1084
1085fn collect_bundle_files(
1086    root: &Path,
1087    directory: &Path,
1088    depth: usize,
1089    limits: &BundleVerificationLimits,
1090    entry_count: &mut usize,
1091    total_size: &mut u64,
1092    files: &mut BTreeMap<String, BundleFileSummary>,
1093) -> Result<(), BundleError> {
1094    if depth > limits.max_directory_depth {
1095        return invalid_bundle("Bundle directory depth exceeds the configured limit");
1096    }
1097    let metadata = fs::symlink_metadata(directory).map_err(io_error)?;
1098    if !metadata.is_dir() || metadata.file_type().is_symlink() {
1099        return invalid_bundle("Bundle root contains a non-regular directory");
1100    }
1101    for entry in fs::read_dir(directory).map_err(io_error)? {
1102        let entry = entry.map_err(io_error)?;
1103        *entry_count = entry_count
1104            .checked_add(1)
1105            .ok_or_else(|| BundleError::InvalidBundle("Bundle entry count overflow".to_owned()))?;
1106        if *entry_count > limits.max_entry_count {
1107            return invalid_bundle("Bundle entry count exceeds the configured limit");
1108        }
1109        let path = entry.path();
1110        let metadata = fs::symlink_metadata(&path).map_err(io_error)?;
1111        if metadata.file_type().is_symlink() {
1112            return invalid_bundle("Bundle contains a symbolic link");
1113        }
1114        if metadata.is_dir() {
1115            collect_bundle_files(
1116                root,
1117                &path,
1118                depth + 1,
1119                limits,
1120                entry_count,
1121                total_size,
1122                files,
1123            )?;
1124            continue;
1125        }
1126        if !metadata.is_file() {
1127            return invalid_bundle("Bundle contains a non-regular file");
1128        }
1129        let relative = path
1130            .strip_prefix(root)
1131            .map_err(|_| BundleError::InvalidBundle("Bundle path escaped root".to_owned()))?
1132            .to_str()
1133            .ok_or_else(|| BundleError::InvalidBundle("Bundle path is not UTF-8".to_owned()))?
1134            .replace(std::path::MAIN_SEPARATOR, "/");
1135        validate_relative_path(&relative)?;
1136        if files.len() >= limits.max_file_count {
1137            return invalid_bundle("Bundle file count exceeds the configured limit");
1138        }
1139        let summary = summarize_bundle_file(&path, &metadata, limits.max_file_bytes)?;
1140        *total_size = total_size
1141            .checked_add(summary.size)
1142            .ok_or_else(|| BundleError::InvalidBundle("Bundle total size overflow".to_owned()))?;
1143        if *total_size > limits.max_total_bytes {
1144            return invalid_bundle("Bundle total size exceeds the configured limit");
1145        }
1146        files.insert(relative, summary);
1147    }
1148    Ok(())
1149}
1150
1151fn summarize_bundle_file(
1152    path: &Path,
1153    metadata: &fs::Metadata,
1154    max_file_bytes: u64,
1155) -> Result<BundleFileSummary, BundleError> {
1156    if metadata.len() > max_file_bytes {
1157        return invalid_bundle("Bundle file exceeds the configured size limit");
1158    }
1159    let mut file = fs::File::open(path).map_err(io_error)?;
1160    let opened = file.metadata().map_err(io_error)?;
1161    if !opened.is_file() || !same_file_identity(metadata, &opened) || opened.len() > max_file_bytes
1162    {
1163        return invalid_bundle("Bundle file changed during verification");
1164    }
1165    let mut hasher = Sha256::new();
1166    let mut size = 0_u64;
1167    let mut buffer = vec![0_u8; 64 * 1024];
1168    loop {
1169        let read = file.read(&mut buffer).map_err(io_error)?;
1170        if read == 0 {
1171            break;
1172        }
1173        size = size
1174            .checked_add(u64::try_from(read).expect("buffer length fits u64"))
1175            .ok_or_else(|| BundleError::InvalidBundle("Bundle file size overflow".to_owned()))?;
1176        if size > max_file_bytes {
1177            return invalid_bundle("Bundle file exceeds the configured size limit");
1178        }
1179        hasher.update(&buffer[..read]);
1180    }
1181    if size != opened.len() {
1182        return invalid_bundle("Bundle file changed during verification");
1183    }
1184    Ok(BundleFileSummary {
1185        size,
1186        digest: format!("sha256:{}", hex::encode(hasher.finalize())),
1187    })
1188}
1189
1190#[cfg(unix)]
1191fn same_file_identity(inspected: &fs::Metadata, opened: &fs::Metadata) -> bool {
1192    use std::os::unix::fs::MetadataExt as _;
1193
1194    inspected.dev() == opened.dev() && inspected.ino() == opened.ino()
1195}
1196
1197#[cfg(not(unix))]
1198fn same_file_identity(_: &fs::Metadata, _: &fs::Metadata) -> bool {
1199    true
1200}
1201
1202fn invalid_manifest<T>(detail: impl Into<String>) -> Result<T, BundleError> {
1203    Err(BundleError::InvalidManifest(detail.into()))
1204}
1205
1206fn invalid_bundle<T>(detail: impl Into<String>) -> Result<T, BundleError> {
1207    Err(BundleError::InvalidBundle(detail.into()))
1208}
1209
1210fn io_error(error: impl fmt::Display) -> BundleError {
1211    BundleError::Io(error.to_string())
1212}
1213
1214#[cfg(test)]
1215mod tests {
1216    use std::borrow::Cow;
1217
1218    use super::*;
1219
1220    #[cfg(unix)]
1221    #[test]
1222    fn bounded_reader_rejects_a_symlink_swap_between_inspection_and_open() {
1223        use std::os::unix::fs::symlink;
1224
1225        let directory = tempfile::tempdir().unwrap();
1226        let selected = directory.path().join("selected");
1227        let replacement = directory.path().join("replacement");
1228        fs::write(&selected, b"selected").unwrap();
1229        fs::write(&replacement, b"selected").unwrap();
1230
1231        let result = read_regular_file_bounded_after_inspection(&selected, "test file", 64, || {
1232            fs::remove_file(&selected).unwrap();
1233            symlink(&replacement, &selected).unwrap();
1234        });
1235
1236        assert!(matches!(
1237            result,
1238            Err(BundleError::InvalidBundle(detail)) if detail.contains("changed during bounded read")
1239        ));
1240    }
1241
1242    fn wasm_with_descriptors(descriptors: &[&[u8]]) -> Vec<u8> {
1243        let mut module = wasm_encoder::Module::new();
1244        for descriptor in descriptors {
1245            module.section(&wasm_encoder::CustomSection {
1246                name: Cow::Borrowed(PLUGIN_DESCRIPTOR_SECTION),
1247                data: Cow::Borrowed(descriptor),
1248            });
1249        }
1250        module.finish()
1251    }
1252
1253    #[test]
1254    fn source_metadata_rejects_old_multi_entry_fields() {
1255        let error = toml::from_str::<CargoManifest>(
1256            r#"
1257                [package]
1258                version = "1.0.0"
1259
1260                [package.metadata.lenso]
1261                plugin-id = "example.echo"
1262                root-slot = "tools"
1263                module-contributions = []
1264            "#,
1265        )
1266        .unwrap_err();
1267
1268        assert!(error.to_string().contains("module-contributions"));
1269    }
1270
1271    #[test]
1272    fn descriptor_extraction_requires_one_canonical_descriptor() {
1273        assert!(extract_plugin_descriptor(&wasm_with_descriptors(&[])).is_err());
1274        let descriptor = br#"{"profile":"one"}"#;
1275        assert!(
1276            extract_plugin_descriptor(&wasm_with_descriptors(&[
1277                descriptor.as_slice(),
1278                descriptor.as_slice(),
1279            ]))
1280            .is_err()
1281        );
1282        assert!(extract_plugin_descriptor(&wasm_with_descriptors(&[b"{"])).is_err());
1283        assert!(
1284            extract_plugin_descriptor(&wasm_with_descriptors(&[br#"{ "profile": "one" }"#]))
1285                .is_err()
1286        );
1287    }
1288
1289    #[test]
1290    fn descriptor_extraction_rejects_oversized_evidence() {
1291        let descriptor = vec![b' '; MAX_PLUGIN_DESCRIPTOR_BYTES + 1];
1292        assert!(extract_plugin_descriptor(&wasm_with_descriptors(&[&descriptor])).is_err());
1293    }
1294
1295    #[test]
1296    fn strict_v2_manifest_rejects_duplicate_fields_and_path_escape() {
1297        assert!(
1298            SourceManifestDocument::parse(br#"{"schema_version":2,"schema_version":2}"#).is_err()
1299        );
1300        let manifest = PluginManifestV2 {
1301            schema_version: 2,
1302            plugin_id: "example.echo".to_owned(),
1303            release_version: "1.0.0".to_owned(),
1304            artifact: PluginArtifactV2 {
1305                path: "../plugin.wasm".to_owned(),
1306                digest: sha256_digest(b"plugin"),
1307                size: 6,
1308                media_type: "application/wasm".to_owned(),
1309                target: "wasm32-unknown-unknown".to_owned(),
1310            },
1311            entry: PluginEntryV2 {
1312                descriptor: serde_json::json!({"plugin_id":"example.echo"}),
1313            },
1314        };
1315        assert!(SourceManifestDocument::from_value(manifest).is_err());
1316    }
1317
1318    #[test]
1319    fn process_bundle_is_built_without_executing_the_artifact() {
1320        let root = tempfile::tempdir().unwrap();
1321        let manifest = root.path().join("Cargo.toml");
1322        fs::write(
1323            &manifest,
1324            r#"[package]
1325name = "example-process"
1326version = "1.0.0"
1327
1328[package.metadata.lenso]
1329plugin-id = "example.process"
1330root-slot = "tools"
1331"#,
1332        )
1333        .unwrap();
1334        let descriptor = root.path().join("descriptor.json");
1335        fs::write(
1336            &descriptor,
1337            br#"{"abi":"lenso.json-request@1","capabilities":[{"capability_id":"example.echo@1","descriptor_version":"1.0.0","request_operations":["echo"]}]}"#,
1338        )
1339        .unwrap();
1340        let output = root.path().join("example.process.lenso-plugin");
1341        let verified = build_source_process_plugin_bundle(&SourceProcessPluginBuild {
1342            package_manifest: manifest,
1343            executable: std::env::current_exe().unwrap(),
1344            runtime_descriptor: descriptor,
1345            target: "test-host".to_owned(),
1346            output: output.clone(),
1347        })
1348        .unwrap();
1349
1350        assert_eq!(verified.plugin_id, "example.process");
1351        assert_eq!(verified, verify_bundle_directory(&output).unwrap());
1352        let document =
1353            SourceManifestDocument::parse(&fs::read(output.join(MANIFEST_FILE)).unwrap()).unwrap();
1354        assert_eq!(
1355            document.value.artifact.media_type,
1356            "application/vnd.lenso.process"
1357        );
1358        assert_eq!(
1359            document.value.entry.descriptor["execution_class"],
1360            "lenso.process@1"
1361        );
1362
1363        let bounded = BundleVerificationLimits {
1364            max_manifest_bytes: 16 * 1024,
1365            max_file_bytes: 16 * 1024,
1366            max_total_bytes: 32 * 1024,
1367            ..BundleVerificationLimits::default()
1368        };
1369        assert!(matches!(
1370            verify_bundle_directory_with_limits(&output, &bounded),
1371            Err(BundleError::InvalidBundle(detail)) if detail.contains("size limit")
1372        ));
1373
1374        let file_count_bounded = BundleVerificationLimits {
1375            max_file_count: 1,
1376            ..BundleVerificationLimits::default()
1377        };
1378        assert!(matches!(
1379            verify_bundle_directory_with_limits(&output, &file_count_bounded),
1380            Err(BundleError::InvalidBundle(detail)) if detail.contains("file count")
1381        ));
1382
1383        for index in 0..64 {
1384            fs::create_dir(output.join(format!("empty-directory-{index}"))).unwrap();
1385        }
1386        let entry_count_bounded = BundleVerificationLimits {
1387            max_file_count: 2,
1388            max_entry_count: 4,
1389            ..BundleVerificationLimits::default()
1390        };
1391        assert!(matches!(
1392            verify_bundle_directory_with_limits(&output, &entry_count_bounded),
1393            Err(BundleError::InvalidBundle(detail)) if detail.contains("entry count")
1394        ));
1395
1396        let manifest_path = output.join(MANIFEST_FILE);
1397        let drift = verify_bundle_document_with_limits_after_manifest_read(
1398            &output,
1399            &BundleVerificationLimits::default(),
1400            || fs::write(&manifest_path, br#"{"schema_version":2}"#).unwrap(),
1401        );
1402        assert!(matches!(
1403            drift,
1404            Err(BundleError::InvalidBundle(detail)) if detail.contains("Manifest changed")
1405        ));
1406    }
1407
1408    #[test]
1409    fn v3_release_selects_one_implementation_by_host_policy() {
1410        let root = tempfile::tempdir().unwrap();
1411        let process = std::env::current_exe().unwrap();
1412        let script = root.path().join("plugin.js");
1413        fs::write(
1414            &script,
1415            b"export function invoke(request) { return request; }",
1416        )
1417        .unwrap();
1418        let output = root.path().join("example.multi.lenso-plugin");
1419        let contract = PluginContract::new("example.multi", "1.0.0", "tools").with_capability(
1420            CapabilityEndpointPlan::new("example.echo@1", "1.0.0", ["echo"]),
1421        );
1422        build_source_plugin_release_bundle(&SourcePluginReleaseBuild {
1423            contract,
1424            implementations: vec![
1425                SourcePluginImplementation {
1426                    id: "bun".to_owned(),
1427                    host_targets: vec!["test-host".to_owned()],
1428                    artifact: process,
1429                    bundle_path: "implementations/bun/plugin".to_owned(),
1430                    media_type: "application/vnd.lenso.process".to_owned(),
1431                    target: "test-host".to_owned(),
1432                    entrypoint: "plugin".to_owned(),
1433                    execution_class: ExecutionClassId::new("lenso.process@1"),
1434                },
1435                SourcePluginImplementation {
1436                    id: "quickjs".to_owned(),
1437                    host_targets: vec!["*".to_owned()],
1438                    artifact: script,
1439                    bundle_path: "implementations/quickjs/plugin.js".to_owned(),
1440                    media_type: "application/javascript".to_owned(),
1441                    target: "javascript-es2023".to_owned(),
1442                    entrypoint: "plugin.js".to_owned(),
1443                    execution_class: ExecutionClassId::new("lenso.quickjs@1"),
1444                },
1445            ],
1446            output: output.clone(),
1447        })
1448        .unwrap();
1449
1450        let manifest = read_bundle_manifest(&output).unwrap();
1451        let selected = resolve_implementation(
1452            &manifest,
1453            &ImplementationPolicy {
1454                host_target: "test-host".to_owned(),
1455                execution_classes: vec![
1456                    ExecutionClassId::new("lenso.quickjs@1"),
1457                    ExecutionClassId::new("lenso.process@1"),
1458                ],
1459            },
1460        )
1461        .unwrap();
1462        assert_eq!(selected.implementation_id, "quickjs");
1463        assert_eq!(
1464            selected.descriptor.execution_class().as_str(),
1465            "lenso.quickjs@1"
1466        );
1467        assert_eq!(
1468            selected.descriptor.contract(),
1469            match manifest {
1470                PluginManifest::V3(value) => value.contract,
1471                PluginManifest::V2(_) => panic!("expected V3 manifest"),
1472            }
1473        );
1474    }
1475}