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