1mod 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
23pub const MANIFEST_FILE: &str = "lenso-plugin.json";
25
26pub const PLUGIN_DESCRIPTOR_SECTION: &str = "lenso.plugin-descriptor.v1";
28
29pub const MAX_PLUGIN_DESCRIPTOR_BYTES: usize = 64 * 1024;
31
32#[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#[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#[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#[derive(Clone, Debug, Eq, PartialEq)]
88pub struct SourcePluginReleaseBuild {
89 pub contract: PluginContract,
90 pub implementations: Vec<SourcePluginImplementation>,
91 pub output: PathBuf,
92}
93
94#[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#[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#[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
336pub 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
397pub 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
463pub 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
530pub fn verify_bundle_directory(root: &Path) -> Result<VerifiedBundle, BundleError> {
532 verify_bundle_directory_with_limits(root, &BundleVerificationLimits::default())
533}
534
535pub 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
585pub 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
884pub 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#[allow(clippy::too_many_lines)]
1086pub 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}