Skip to main content

lenso_plugin_bundle/
lib.rs

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