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