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