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