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