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