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