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 #[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#[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#[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
338pub 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
399pub 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
465pub 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
532pub fn verify_bundle_directory(root: &Path) -> Result<VerifiedBundle, BundleError> {
534 verify_bundle_directory_with_limits(root, &BundleVerificationLimits::default())
535}
536
537pub 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
587pub 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
899pub 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#[allow(clippy::too_many_lines)]
1101pub 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}