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