1mod model;
4
5use std::{
6 collections::{BTreeMap, BTreeSet},
7 fmt, fs,
8 path::{Component, Path, PathBuf},
9};
10
11pub use model::*;
12use serde::{Deserialize, de::DeserializeOwned};
13use serde_json::Value;
14use sha2::{Digest, Sha256};
15
16pub const MANIFEST_FILE: &str = "lenso-plugin.json";
18
19pub const PLUGIN_DESCRIPTOR_SECTION: &str = "lenso.plugin-descriptor.v1";
21
22pub const MAX_PLUGIN_DESCRIPTOR_BYTES: usize = 64 * 1024;
24
25#[derive(Clone, Debug, Eq, PartialEq)]
27pub struct ArtifactSource {
28 pub artifact_id: String,
29 pub path: PathBuf,
30}
31
32#[derive(Clone, Debug, Eq, PartialEq)]
34pub struct BundleBuild {
35 pub template: PathBuf,
36 pub output: PathBuf,
37 pub artifact_sources: Vec<ArtifactSource>,
38}
39
40#[derive(Clone, Debug, Eq, PartialEq)]
42pub struct SourcePluginBuild {
43 pub package_manifest: PathBuf,
44 pub wasm_module: PathBuf,
45 pub output: PathBuf,
46}
47
48#[derive(Clone, Debug)]
50pub struct ManifestDocument {
51 value: PluginManifest,
52 bytes: Vec<u8>,
53 digest: String,
54}
55
56impl ManifestDocument {
57 pub fn parse(input: &[u8]) -> Result<Self, BundleError> {
59 let value = strict_json::<PluginManifest>(input)?;
60 Self::from_value(value)
61 }
62
63 pub fn from_value(value: PluginManifest) -> Result<Self, BundleError> {
65 validate_manifest(&value)?;
66 let json = serde_json::to_value(&value)
67 .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
68 validate_json_value(&json)?;
69 let bytes = serde_json::to_vec(&json)
70 .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
71 let digest = sha256_digest(&bytes);
72 Ok(Self {
73 value,
74 bytes,
75 digest,
76 })
77 }
78
79 pub const fn value(&self) -> &PluginManifest {
80 &self.value
81 }
82
83 pub fn bytes(&self) -> &[u8] {
84 &self.bytes
85 }
86
87 pub fn digest(&self) -> &str {
88 &self.digest
89 }
90}
91
92#[derive(Clone, Debug)]
93struct SourceManifestDocument {
94 value: PluginManifestV2,
95 bytes: Vec<u8>,
96 digest: String,
97}
98
99impl SourceManifestDocument {
100 fn parse(input: &[u8]) -> Result<Self, BundleError> {
101 let value = strict_json::<PluginManifestV2>(input)?;
102 Self::from_value(value)
103 }
104
105 fn from_value(value: PluginManifestV2) -> Result<Self, BundleError> {
106 validate_source_manifest(&value)?;
107 let json = serde_json::to_value(&value)
108 .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
109 validate_json_value(&json)?;
110 let bytes = serde_json::to_vec(&json)
111 .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
112 let digest = sha256_digest(&bytes);
113 Ok(Self {
114 value,
115 bytes,
116 digest,
117 })
118 }
119}
120
121#[derive(Debug, Deserialize)]
122struct CargoManifest {
123 package: CargoPackage,
124}
125
126#[derive(Debug, Deserialize)]
127struct CargoPackage {
128 version: String,
129 metadata: CargoMetadata,
130}
131
132#[derive(Debug, Deserialize)]
133struct CargoMetadata {
134 lenso: CargoLensoMetadata,
135}
136
137#[derive(Debug, Deserialize)]
138#[serde(deny_unknown_fields, rename_all = "kebab-case")]
139struct CargoLensoMetadata {
140 plugin_id: String,
141}
142
143#[derive(Clone, Debug, Eq, PartialEq)]
145pub struct VerifiedBundle {
146 pub plugin_id: String,
147 pub release_version: String,
148 pub manifest_digest: String,
149 pub artifact_digests: Vec<String>,
150 pub product_metadata_digests: Vec<String>,
151}
152
153#[derive(Clone, Debug, Eq, PartialEq)]
155pub enum BundleError {
156 InvalidManifest(String),
157 InvalidBundle(String),
158 DigestMismatch(String),
159 Io(String),
160 Wasm(String),
161}
162
163impl fmt::Display for BundleError {
164 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
165 match self {
166 Self::InvalidManifest(detail) => write!(formatter, "invalid Plugin Manifest: {detail}"),
167 Self::InvalidBundle(detail) => write!(formatter, "invalid Plugin Bundle: {detail}"),
168 Self::DigestMismatch(subject) => write!(formatter, "digest mismatch for {subject}"),
169 Self::Io(detail) => formatter.write_str(detail),
170 Self::Wasm(detail) => write!(
171 formatter,
172 "failed to encode WebAssembly Component: {detail}"
173 ),
174 }
175 }
176}
177
178impl std::error::Error for BundleError {}
179
180pub fn build_source_plugin_bundle(
182 build: &SourcePluginBuild,
183) -> Result<VerifiedBundle, BundleError> {
184 if build.output.exists() {
185 return invalid_bundle(format!(
186 "output `{}` already exists",
187 build.output.display()
188 ));
189 }
190 let package_bytes = read_regular_file(&build.package_manifest, "Cargo manifest")?;
191 let package = toml::from_slice::<CargoManifest>(&package_bytes)
192 .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
193 let module = read_regular_file(&build.wasm_module, "Plugin Wasm module")?;
194 let component = wit_component::ComponentEncoder::default()
195 .module(&module)
196 .map_err(|error| BundleError::Wasm(error.to_string()))?
197 .validate(true)
198 .encode()
199 .map_err(|error| BundleError::Wasm(error.to_string()))?;
200 let descriptor = extract_plugin_descriptor(&component)?;
201 let descriptor = strict_json::<Value>(&descriptor)?;
202 let artifact = PluginArtifactV2 {
203 path: "plugin.wasm".to_owned(),
204 digest: sha256_digest(&component),
205 size: u64::try_from(component.len())
206 .map_err(|_| BundleError::InvalidBundle("Artifact size exceeds u64".to_owned()))?,
207 media_type: "application/wasm".to_owned(),
208 target: "wasm32-unknown-unknown".to_owned(),
209 };
210 let document = SourceManifestDocument::from_value(PluginManifestV2 {
211 schema_version: 2,
212 plugin_id: package.package.metadata.lenso.plugin_id,
213 release_version: package.package.version,
214 artifact,
215 entry: PluginEntryV2 { descriptor },
216 })?;
217
218 let output_parent = build.output.parent().unwrap_or_else(|| Path::new("."));
219 fs::create_dir_all(output_parent).map_err(io_error)?;
220 let staging = tempfile::Builder::new()
221 .prefix(".lenso-plugin-")
222 .tempdir_in(output_parent)
223 .map_err(io_error)?;
224 write_bundle_file(staging.path(), &document.value.artifact.path, &component)?;
225 fs::write(staging.path().join(MANIFEST_FILE), &document.bytes).map_err(io_error)?;
226 fs::rename(staging.path(), &build.output).map_err(io_error)?;
227 verify_bundle_directory(&build.output)
228}
229
230pub fn build_bundle(build: &BundleBuild) -> Result<VerifiedBundle, BundleError> {
235 if build.output.exists() {
236 return invalid_bundle(format!(
237 "output `{}` already exists",
238 build.output.display()
239 ));
240 }
241 let template = read_regular_file(&build.template, "Plugin Manifest template")?;
242 let mut manifest = ManifestDocument::parse(&template)?.value;
243 let template_root = build.template.parent().unwrap_or_else(|| Path::new("."));
244 let sources = build
245 .artifact_sources
246 .iter()
247 .map(|source| (source.artifact_id.as_str(), source.path.as_path()))
248 .collect::<BTreeMap<_, _>>();
249 if sources.len() != build.artifact_sources.len() {
250 return invalid_bundle("Artifact source IDs must be unique");
251 }
252 if sources
253 .keys()
254 .any(|id| !manifest.artifacts.iter().any(|artifact| artifact.id == *id))
255 {
256 return invalid_bundle("Artifact source references an unknown Artifact ID");
257 }
258
259 let output_parent = build.output.parent().unwrap_or_else(|| Path::new("."));
260 fs::create_dir_all(output_parent).map_err(io_error)?;
261 let staging = tempfile::Builder::new()
262 .prefix(".lenso-plugin-")
263 .tempdir_in(output_parent)
264 .map_err(io_error)?;
265 let mut files = BTreeMap::new();
266 for artifact in &mut manifest.artifacts {
267 validate_relative_path(&artifact.path)?;
268 let source = sources
269 .get(artifact.id.as_str())
270 .copied()
271 .map_or_else(|| template_root.join(&artifact.path), Path::to_path_buf);
272 let source_bytes = read_regular_file(&source, &format!("Artifact `{}`", artifact.id))?;
273 let bytes = if artifact.kind == ArtifactKind::WasmComponent {
274 wit_component::ComponentEncoder::default()
275 .module(&source_bytes)
276 .map_err(|error| BundleError::Wasm(error.to_string()))?
277 .validate(true)
278 .encode()
279 .map_err(|error| BundleError::Wasm(error.to_string()))?
280 } else {
281 source_bytes
282 };
283 artifact.digest = sha256_digest(&bytes);
284 artifact.size = u64::try_from(bytes.len())
285 .map_err(|_| BundleError::InvalidBundle("Artifact size exceeds u64".to_owned()))?;
286 write_bundle_file(staging.path(), &artifact.path, &bytes)?;
287 files.insert(artifact.path.clone(), bytes);
288 }
289 for metadata in &mut manifest.product_metadata {
290 validate_relative_path(&metadata.path)?;
291 let bytes = read_regular_file(
292 &template_root.join(&metadata.path),
293 &format!("Product Metadata `{}`", metadata.id),
294 )?;
295 metadata.digest = sha256_digest(&bytes);
296 write_bundle_file(staging.path(), &metadata.path, &bytes)?;
297 files.insert(metadata.path.clone(), bytes);
298 }
299
300 let document = ManifestDocument::from_value(manifest)?;
301 fs::write(staging.path().join(MANIFEST_FILE), document.bytes()).map_err(io_error)?;
302 let verified = verify_bundle_files(&document, &files)?;
303 fs::rename(staging.path(), &build.output).map_err(io_error)?;
304 Ok(verified)
305}
306
307pub fn verify_bundle_directory(root: &Path) -> Result<VerifiedBundle, BundleError> {
309 let manifest_path = root.join(MANIFEST_FILE);
310 let manifest_bytes = read_regular_file(&manifest_path, "Plugin Manifest")?;
311 let mut files = BTreeMap::new();
312 collect_bundle_files(root, root, &mut files)?;
313 files.remove(MANIFEST_FILE);
314 match manifest_schema_version(&manifest_bytes)? {
315 1 => verify_bundle_files(&ManifestDocument::parse(&manifest_bytes)?, &files),
316 2 => verify_source_bundle_files(&SourceManifestDocument::parse(&manifest_bytes)?, &files),
317 _ => invalid_manifest("unsupported schema version"),
318 }
319}
320
321pub fn verify_bundle_files(
323 manifest: &ManifestDocument,
324 files: &BTreeMap<String, Vec<u8>>,
325) -> Result<VerifiedBundle, BundleError> {
326 let declared_paths = manifest
327 .value()
328 .artifacts
329 .iter()
330 .map(|artifact| artifact.path.as_str())
331 .chain(
332 manifest
333 .value()
334 .product_metadata
335 .iter()
336 .map(|metadata| metadata.path.as_str()),
337 )
338 .collect::<BTreeSet<_>>();
339 if declared_paths.len()
340 != manifest.value().artifacts.len() + manifest.value().product_metadata.len()
341 {
342 return invalid_bundle("Manifest declares a duplicate Bundle path");
343 }
344 if files.len() != declared_paths.len()
345 || files
346 .keys()
347 .any(|path| !declared_paths.contains(path.as_str()))
348 {
349 return invalid_bundle("Bundle files do not exactly close over declared paths");
350 }
351 let mut artifact_digests = Vec::new();
352 for artifact in &manifest.value().artifacts {
353 let bytes = files.get(&artifact.path).ok_or_else(|| {
354 BundleError::InvalidBundle(format!("missing Artifact `{}`", artifact.id))
355 })?;
356 if u64::try_from(bytes.len()).unwrap_or(u64::MAX) != artifact.size {
357 return invalid_bundle(format!("Artifact `{}` size does not match", artifact.id));
358 }
359 if sha256_digest(bytes) != artifact.digest {
360 return Err(BundleError::DigestMismatch(format!(
361 "Artifact `{}`",
362 artifact.id
363 )));
364 }
365 artifact_digests.push(artifact.digest.clone());
366 }
367 artifact_digests.sort();
368 artifact_digests.dedup();
369 let mut product_metadata_digests = Vec::new();
370 for metadata in &manifest.value().product_metadata {
371 let bytes = files.get(&metadata.path).ok_or_else(|| {
372 BundleError::InvalidBundle(format!("missing Product Metadata `{}`", metadata.id))
373 })?;
374 if sha256_digest(bytes) != metadata.digest {
375 return Err(BundleError::DigestMismatch(format!(
376 "Product Metadata `{}`",
377 metadata.id
378 )));
379 }
380 product_metadata_digests.push(metadata.digest.clone());
381 }
382 product_metadata_digests.sort();
383 product_metadata_digests.dedup();
384 Ok(VerifiedBundle {
385 plugin_id: manifest.value().plugin_id.clone(),
386 release_version: manifest.value().release_version.clone(),
387 manifest_digest: manifest.digest().to_owned(),
388 artifact_digests,
389 product_metadata_digests,
390 })
391}
392
393fn verify_source_bundle_files(
394 manifest: &SourceManifestDocument,
395 files: &BTreeMap<String, Vec<u8>>,
396) -> Result<VerifiedBundle, BundleError> {
397 let artifact = &manifest.value.artifact;
398 if files.len() != 1 {
399 return invalid_bundle("V2 Bundle must contain exactly one Artifact");
400 }
401 let Some(bytes) = files.get(&artifact.path) else {
402 return invalid_bundle("V2 Bundle does not contain its declared Artifact");
403 };
404 if artifact.size != u64::try_from(bytes.len()).unwrap_or(u64::MAX)
405 || artifact.digest != sha256_digest(bytes)
406 {
407 return Err(BundleError::DigestMismatch(artifact.path.clone()));
408 }
409 let descriptor = extract_plugin_descriptor(bytes)?;
410 let packaged = serde_json::to_vec(&manifest.value.entry.descriptor)
411 .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
412 if descriptor != packaged {
413 return invalid_bundle("source descriptor does not match the V2 Plugin entry");
414 }
415 Ok(VerifiedBundle {
416 plugin_id: manifest.value.plugin_id.clone(),
417 release_version: manifest.value.release_version.clone(),
418 manifest_digest: manifest.digest.clone(),
419 artifact_digests: vec![artifact.digest.clone()],
420 product_metadata_digests: Vec::new(),
421 })
422}
423
424fn manifest_schema_version(input: &[u8]) -> Result<u64, BundleError> {
425 let value = strict_json::<Value>(input)?;
426 value
427 .get("schema_version")
428 .and_then(Value::as_u64)
429 .ok_or_else(|| BundleError::InvalidManifest("schema_version is required".to_owned()))
430}
431
432pub fn extract_plugin_descriptor(component: &[u8]) -> Result<Vec<u8>, BundleError> {
434 let mut descriptors = Vec::new();
435 collect_plugin_descriptors(component, &mut descriptors)?;
436 let [descriptor] = descriptors.as_slice() else {
437 return invalid_bundle(if descriptors.is_empty() {
438 "Plugin Component does not contain a source-derived descriptor"
439 } else {
440 "Plugin Component contains duplicate source-derived descriptors"
441 });
442 };
443 let value = strict_json::<Value>(descriptor)?;
444 let canonical = serde_json::to_vec(&value)
445 .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
446 if canonical != *descriptor {
447 return invalid_bundle("Plugin descriptor is not canonical JSON");
448 }
449 Ok(descriptor.clone())
450}
451
452fn collect_plugin_descriptors(
453 bytes: &[u8],
454 descriptors: &mut Vec<Vec<u8>>,
455) -> Result<(), BundleError> {
456 for payload in wasmparser::Parser::new(0).parse_all(bytes) {
457 match payload.map_err(|error| BundleError::Wasm(error.to_string()))? {
458 wasmparser::Payload::CustomSection(section)
459 if section.name() == PLUGIN_DESCRIPTOR_SECTION =>
460 {
461 if section.data().len() > MAX_PLUGIN_DESCRIPTOR_BYTES {
462 return invalid_bundle("Plugin descriptor exceeds the size limit");
463 }
464 descriptors.push(section.data().to_vec());
465 }
466 _ => {}
467 }
468 }
469 Ok(())
470}
471
472fn validate_source_manifest(manifest: &PluginManifestV2) -> Result<(), BundleError> {
473 if manifest.schema_version != 2 {
474 return invalid_manifest("unsupported schema version");
475 }
476 if manifest.plugin_id.is_empty() || semver::Version::parse(&manifest.release_version).is_err() {
477 return invalid_manifest("Plugin identity or Release version is invalid");
478 }
479 validate_relative_path(&manifest.artifact.path)?;
480 digest_component(&manifest.artifact.digest)?;
481 if manifest.artifact.size == 0
482 || manifest.artifact.media_type != "application/wasm"
483 || manifest.artifact.target != "wasm32-unknown-unknown"
484 {
485 return invalid_manifest("V2 Artifact size, Wasm media type, and target must be exact");
486 }
487 if !manifest.entry.descriptor.is_object() {
488 return invalid_manifest("V2 Plugin entry descriptor must be an object");
489 }
490 Ok(())
491}
492
493#[allow(clippy::too_many_lines)]
495pub fn validate_manifest(manifest: &PluginManifest) -> Result<(), BundleError> {
496 if manifest.schema_version != 1 {
497 return invalid_manifest("unsupported schema version");
498 }
499 if manifest.plugin_id.is_empty() || semver::Version::parse(&manifest.release_version).is_err() {
500 return invalid_manifest("Plugin identity or Release version is invalid");
501 }
502 let artifact_ids = manifest
503 .artifacts
504 .iter()
505 .map(|artifact| artifact.id.as_str())
506 .collect::<Vec<_>>();
507 let contribution_ids = manifest
508 .module_contributions
509 .iter()
510 .map(|contribution| contribution.id.as_str())
511 .collect::<Vec<_>>();
512 let data_ids = manifest
513 .data_contributions
514 .iter()
515 .map(|contribution| contribution.id.as_str())
516 .collect::<Vec<_>>();
517 let permission_ids = manifest
518 .permission_requests
519 .iter()
520 .map(|request| request.id.as_str())
521 .collect::<Vec<_>>();
522 let feature_ids = manifest
523 .features
524 .iter()
525 .map(|feature| feature.id.as_str())
526 .collect::<Vec<_>>();
527 let metadata_ids = manifest
528 .product_metadata
529 .iter()
530 .map(|metadata| metadata.id.as_str())
531 .collect::<Vec<_>>();
532 ensure_sorted_unique(&artifact_ids, "Artifact ID")?;
533 ensure_sorted_unique(&contribution_ids, "Module contribution ID")?;
534 ensure_sorted_unique(&data_ids, "Data contribution ID")?;
535 ensure_sorted_unique(&permission_ids, "permission request ID")?;
536 ensure_sorted_unique(&feature_ids, "Feature ID")?;
537 ensure_sorted_unique(&metadata_ids, "Product Metadata ID")?;
538 let artifact_set = artifact_ids.iter().copied().collect::<BTreeSet<_>>();
539 let contribution_set = contribution_ids.iter().copied().collect::<BTreeSet<_>>();
540 let data_set = data_ids.iter().copied().collect::<BTreeSet<_>>();
541 let permission_set = permission_ids.iter().copied().collect::<BTreeSet<_>>();
542 let metadata_set = metadata_ids.iter().copied().collect::<BTreeSet<_>>();
543 for artifact in &manifest.artifacts {
544 digest_component(&artifact.digest)?;
545 validate_relative_path(&artifact.path)?;
546 if artifact.id.is_empty() || artifact.media_type.is_empty() || artifact.targets.is_empty() {
547 return invalid_manifest("Artifact identity, media type, and targets must be explicit");
548 }
549 ensure_sorted_unique(&artifact.targets, "Artifact target")?;
550 }
551 for contribution in &manifest.module_contributions {
552 digest_component(&contribution.configuration_schema_digest)?;
553 if contribution.id.is_empty()
554 || contribution.package_id.is_empty()
555 || contribution.implementations.is_empty()
556 {
557 return invalid_manifest(
558 "Module contribution identity and implementations are required",
559 );
560 }
561 let provided = contribution
562 .provides
563 .iter()
564 .map(|capability| capability.capability_id.as_str())
565 .collect::<Vec<_>>();
566 ensure_sorted_unique(&provided, "provided Capability")?;
567 for capability in &contribution.provides {
568 digest_component(&capability.descriptor_digest)?;
569 validate_semver(
570 &capability.descriptor_version,
571 "Capability Descriptor version",
572 )?;
573 ensure_sorted_unique(&capability.request_operations, "Capability Operation")?;
574 if capability
575 .operation_kinds
576 .keys()
577 .any(|operation| !capability.request_operations.contains(operation))
578 {
579 return invalid_manifest(
580 "Capability interaction kind references an unknown Operation",
581 );
582 }
583 }
584 let required = contribution
585 .requires
586 .iter()
587 .map(|capability| capability.capability_id.as_str())
588 .collect::<Vec<_>>();
589 ensure_sorted_unique(&required, "required Capability")?;
590 for capability in &contribution.requires {
591 validate_semver(
592 &capability.descriptor_version,
593 "required Capability Descriptor version",
594 )?;
595 }
596 ensure_sorted_unique(
597 &contribution.permission_request_ids,
598 "Module permission request",
599 )?;
600 if contribution
601 .permission_request_ids
602 .iter()
603 .any(|request| !permission_set.contains(request.as_str()))
604 {
605 return invalid_manifest(
606 "Module contribution references an unknown permission request",
607 );
608 }
609 if let Some(state) = &contribution.state {
610 if state.state_schema_id.is_empty() {
611 return invalid_manifest("state Schema identity is empty");
612 }
613 digest_component(&state.state_schema_digest)?;
614 }
615 let variants = contribution
616 .implementations
617 .iter()
618 .map(|variant| variant.id.as_str())
619 .collect::<Vec<_>>();
620 ensure_sorted_unique(&variants, "implementation variant ID")?;
621 for variant in &contribution.implementations {
622 if variant.artifact.is_some() == variant.built_in_factory.is_some() {
623 return invalid_manifest("implementation must select exactly one execution input");
624 }
625 if variant.built_in_factory.is_some()
626 && variant.execution_class != "lenso.native-rust@1"
627 {
628 return invalid_manifest("only native Rust may select a built-in factory");
629 }
630 if variant
631 .artifact
632 .as_deref()
633 .is_some_and(|artifact| !artifact_set.contains(artifact))
634 {
635 return invalid_manifest("implementation references an unknown Artifact");
636 }
637 if variant.entrypoint.is_empty()
638 || variant.execution_class.is_empty()
639 || variant.targets.is_empty()
640 || variant.profiles.is_empty()
641 {
642 return invalid_manifest(
643 "implementation entrypoint, class, targets, and Profiles are required",
644 );
645 }
646 ensure_sorted_unique(&variant.targets, "implementation target")?;
647 ensure_sorted_unique(&variant.profiles, "implementation Profile")?;
648 }
649 }
650 for contribution in &manifest.data_contributions {
651 if !artifact_set.contains(contribution.artifact.as_str())
652 || contribution.media_type.is_empty()
653 || contribution.content_schema_id.is_empty()
654 {
655 return invalid_manifest("Data contribution references incomplete authority");
656 }
657 digest_component(&contribution.content_schema_digest)?;
658 digest_component(&contribution.product_metadata_digest)?;
659 }
660 for request in &manifest.permission_requests {
661 if request.id.is_empty()
662 || request.resource_kind.is_empty()
663 || request.explanation_key.is_empty()
664 {
665 return invalid_manifest("permission request identity is incomplete");
666 }
667 }
668 for metadata in &manifest.product_metadata {
669 if metadata.id.is_empty() || metadata.namespace.is_empty() || metadata.schema_id.is_empty()
670 {
671 return invalid_manifest("Product Metadata identity is incomplete");
672 }
673 validate_relative_path(&metadata.path)?;
674 digest_component(&metadata.digest)?;
675 }
676 for feature in &manifest.features {
677 validate_refs(
678 &feature.module_contribution_ids,
679 &contribution_set,
680 "Module contribution",
681 )?;
682 validate_refs(
683 &feature.data_contribution_ids,
684 &data_set,
685 "Data contribution",
686 )?;
687 validate_refs(&feature.artifact_ids, &artifact_set, "Artifact")?;
688 validate_refs(
689 &feature.permission_request_ids,
690 &permission_set,
691 "permission request",
692 )?;
693 validate_refs(
694 &feature.product_metadata_ids,
695 &metadata_set,
696 "Product Metadata",
697 )?;
698 }
699 for template in &manifest.binding_templates {
700 if !contribution_set.contains(template.consumer_contribution_id.as_str())
701 || !contribution_set.contains(template.provider_contribution_id.as_str())
702 || template.capability_id.is_empty()
703 {
704 return invalid_manifest("binding template references unknown contribution authority");
705 }
706 let consumer = manifest
707 .module_contributions
708 .iter()
709 .find(|value| value.id == template.consumer_contribution_id)
710 .expect("validated contribution");
711 let provider = manifest
712 .module_contributions
713 .iter()
714 .find(|value| value.id == template.provider_contribution_id)
715 .expect("validated contribution");
716 if !consumer
717 .requires
718 .iter()
719 .any(|value| value.capability_id == template.capability_id)
720 || !provider
721 .provides
722 .iter()
723 .any(|value| value.capability_id == template.capability_id)
724 {
725 return invalid_manifest("binding template Capability is not required and provided");
726 }
727 }
728 Ok(())
729}
730
731pub fn sha256_digest(bytes: &[u8]) -> String {
733 format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
734}
735
736fn strict_json<T: DeserializeOwned>(input: &[u8]) -> Result<T, BundleError> {
737 let mut deserializer = serde_json::Deserializer::from_slice(input);
738 let strict = StrictValue::deserialize(&mut deserializer)
739 .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
740 deserializer
741 .end()
742 .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
743 validate_json_value(&strict.0)?;
744 serde_json::from_value(strict.0)
745 .map_err(|error| BundleError::InvalidManifest(error.to_string()))
746}
747
748#[derive(Clone, Debug)]
749struct StrictValue(Value);
750
751impl<'de> Deserialize<'de> for StrictValue {
752 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
753 where
754 D: serde::Deserializer<'de>,
755 {
756 deserializer.deserialize_any(StrictVisitor)
757 }
758}
759
760struct StrictVisitor;
761
762impl<'de> serde::de::Visitor<'de> for StrictVisitor {
763 type Value = StrictValue;
764
765 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
766 formatter.write_str("strict Plugin Manifest JSON")
767 }
768
769 fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> {
770 Ok(StrictValue(Value::Bool(value)))
771 }
772
773 fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
774 Ok(StrictValue(Value::Number(value.into())))
775 }
776
777 fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
778 where
779 E: serde::de::Error,
780 {
781 u64::try_from(value)
782 .map_err(|_| E::custom("negative integers are forbidden"))
783 .and_then(|value| self.visit_u64(value))
784 }
785
786 fn visit_f64<E>(self, _: f64) -> Result<Self::Value, E>
787 where
788 E: serde::de::Error,
789 {
790 Err(E::custom("floating-point values are forbidden"))
791 }
792
793 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> {
794 Ok(StrictValue(Value::String(value.to_owned())))
795 }
796
797 fn visit_string<E>(self, value: String) -> Result<Self::Value, E> {
798 Ok(StrictValue(Value::String(value)))
799 }
800
801 fn visit_none<E>(self) -> Result<Self::Value, E> {
802 Ok(StrictValue(Value::Null))
803 }
804
805 fn visit_unit<E>(self) -> Result<Self::Value, E> {
806 Ok(StrictValue(Value::Null))
807 }
808
809 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
810 where
811 A: serde::de::SeqAccess<'de>,
812 {
813 let mut values = Vec::new();
814 while let Some(value) = sequence.next_element::<StrictValue>()? {
815 values.push(value.0);
816 }
817 Ok(StrictValue(Value::Array(values)))
818 }
819
820 fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
821 where
822 A: serde::de::MapAccess<'de>,
823 {
824 let mut keys = BTreeSet::new();
825 let mut values = serde_json::Map::new();
826 while let Some(key) = map.next_key::<String>()? {
827 if !keys.insert(key.clone()) {
828 return Err(serde::de::Error::custom(format!("duplicate field `{key}`")));
829 }
830 values.insert(key, map.next_value::<StrictValue>()?.0);
831 }
832 Ok(StrictValue(Value::Object(values)))
833 }
834}
835
836fn validate_json_value(value: &Value) -> Result<(), BundleError> {
837 match value {
838 Value::Number(number) if !number.is_u64() => {
839 invalid_manifest("numbers must be non-negative integers")
840 }
841 Value::Array(values) => values.iter().try_for_each(validate_json_value),
842 Value::Object(values) => values.values().try_for_each(validate_json_value),
843 _ => Ok(()),
844 }
845}
846
847fn validate_relative_path(path: &str) -> Result<(), BundleError> {
848 if path.is_empty() || path.contains('\\') {
849 return invalid_manifest("Bundle path is empty or platform-ambiguous");
850 }
851 let path = Path::new(path);
852 if path.is_absolute()
853 || path
854 .components()
855 .any(|part| !matches!(part, Component::Normal(_)))
856 {
857 return invalid_manifest("Bundle path must contain only normalized relative segments");
858 }
859 Ok(())
860}
861
862fn digest_component(digest: &str) -> Result<&str, BundleError> {
863 let Some(value) = digest.strip_prefix("sha256:") else {
864 return invalid_manifest("digest does not use sha256 prefix");
865 };
866 if value.len() != 64
867 || !value
868 .bytes()
869 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
870 {
871 return invalid_manifest("digest is not 64 lowercase hexadecimal characters");
872 }
873 Ok(value)
874}
875
876fn validate_semver(value: &str, kind: &str) -> Result<(), BundleError> {
877 semver::Version::parse(value)
878 .map(|_| ())
879 .map_err(|_| BundleError::InvalidManifest(format!("{kind} is not SemVer")))
880}
881
882fn ensure_sorted_unique<T: Ord + fmt::Display>(
883 values: &[T],
884 kind: &str,
885) -> Result<(), BundleError> {
886 if values.windows(2).any(|pair| pair[0] >= pair[1]) {
887 return invalid_manifest(format!("{kind} entries must be sorted and unique"));
888 }
889 Ok(())
890}
891
892fn validate_refs<'a>(
893 values: &'a [String],
894 known: &BTreeSet<&'a str>,
895 kind: &str,
896) -> Result<(), BundleError> {
897 ensure_sorted_unique(values, kind)?;
898 if values.iter().any(|value| !known.contains(value.as_str())) {
899 return invalid_manifest(format!("Feature references an unknown {kind}"));
900 }
901 Ok(())
902}
903
904fn read_regular_file(path: &Path, kind: &str) -> Result<Vec<u8>, BundleError> {
905 let metadata = fs::symlink_metadata(path)
906 .map_err(|error| BundleError::Io(format!("failed to inspect {kind}: {error}")))?;
907 if !metadata.is_file() || metadata.file_type().is_symlink() {
908 return invalid_bundle(format!("{kind} is not a regular file"));
909 }
910 fs::read(path).map_err(io_error)
911}
912
913fn write_bundle_file(root: &Path, relative: &str, bytes: &[u8]) -> Result<(), BundleError> {
914 let path = root.join(relative);
915 if let Some(parent) = path.parent() {
916 fs::create_dir_all(parent).map_err(io_error)?;
917 }
918 fs::write(path, bytes).map_err(io_error)
919}
920
921fn collect_bundle_files(
922 root: &Path,
923 directory: &Path,
924 files: &mut BTreeMap<String, Vec<u8>>,
925) -> Result<(), BundleError> {
926 let metadata = fs::symlink_metadata(directory).map_err(io_error)?;
927 if !metadata.is_dir() || metadata.file_type().is_symlink() {
928 return invalid_bundle("Bundle root contains a non-regular directory");
929 }
930 let mut entries = fs::read_dir(directory)
931 .map_err(io_error)?
932 .collect::<Result<Vec<_>, _>>()
933 .map_err(io_error)?;
934 entries.sort_by_key(std::fs::DirEntry::file_name);
935 for entry in entries {
936 let path = entry.path();
937 let metadata = fs::symlink_metadata(&path).map_err(io_error)?;
938 if metadata.file_type().is_symlink() {
939 return invalid_bundle("Bundle contains a symbolic link");
940 }
941 if metadata.is_dir() {
942 collect_bundle_files(root, &path, files)?;
943 continue;
944 }
945 if !metadata.is_file() {
946 return invalid_bundle("Bundle contains a non-regular file");
947 }
948 let relative = path
949 .strip_prefix(root)
950 .map_err(|_| BundleError::InvalidBundle("Bundle path escaped root".to_owned()))?
951 .to_str()
952 .ok_or_else(|| BundleError::InvalidBundle("Bundle path is not UTF-8".to_owned()))?
953 .replace(std::path::MAIN_SEPARATOR, "/");
954 validate_relative_path(&relative)?;
955 files.insert(relative, fs::read(path).map_err(io_error)?);
956 }
957 Ok(())
958}
959
960fn invalid_manifest<T>(detail: impl Into<String>) -> Result<T, BundleError> {
961 Err(BundleError::InvalidManifest(detail.into()))
962}
963
964fn invalid_bundle<T>(detail: impl Into<String>) -> Result<T, BundleError> {
965 Err(BundleError::InvalidBundle(detail.into()))
966}
967
968fn io_error(error: impl fmt::Display) -> BundleError {
969 BundleError::Io(error.to_string())
970}
971
972#[cfg(test)]
973mod tests {
974 use std::borrow::Cow;
975
976 use super::*;
977
978 const ZERO_DIGEST: &str =
979 "sha256:0000000000000000000000000000000000000000000000000000000000000000";
980
981 fn template() -> PluginManifest {
982 PluginManifest {
983 schema_version: 1,
984 plugin_id: "example.quickjs".to_owned(),
985 release_version: "1.0.0".to_owned(),
986 artifacts: vec![ArtifactDeclaration {
987 id: "script".to_owned(),
988 kind: ArtifactKind::QuickJsModule,
989 digest: ZERO_DIGEST.to_owned(),
990 size: 0,
991 media_type: "text/javascript".to_owned(),
992 path: "plugin.mjs".to_owned(),
993 targets: vec!["aarch64-macos".to_owned()],
994 }],
995 module_contributions: Vec::new(),
996 data_contributions: Vec::new(),
997 permission_requests: Vec::new(),
998 features: Vec::new(),
999 binding_templates: Vec::new(),
1000 product_metadata: Vec::new(),
1001 }
1002 }
1003
1004 fn wasm_template_with_duplicated_descriptor() -> PluginManifest {
1005 PluginManifest {
1006 schema_version: 1,
1007 plugin_id: "example.echo".to_owned(),
1008 release_version: "1.0.0".to_owned(),
1009 artifacts: vec![ArtifactDeclaration {
1010 id: "guest".to_owned(),
1011 kind: ArtifactKind::WasmComponent,
1012 digest: ZERO_DIGEST.to_owned(),
1013 size: 0,
1014 media_type: "application/wasm".to_owned(),
1015 path: "plugin.wasm".to_owned(),
1016 targets: vec!["wasm32-unknown-unknown".to_owned()],
1017 }],
1018 module_contributions: vec![ModuleContribution {
1019 id: "echo".to_owned(),
1020 package_id: "example.echo".to_owned(),
1021 configuration_schema_digest: ZERO_DIGEST.to_owned(),
1022 provides: vec![CapabilityDeclaration {
1023 capability_id: "test.echo@1".to_owned(),
1024 descriptor_version: "1.0.0".to_owned(),
1025 descriptor_digest: ZERO_DIGEST.to_owned(),
1026 request_operations: vec!["echo".to_owned()],
1027 operation_kinds: BTreeMap::new(),
1028 }],
1029 requires: Vec::new(),
1030 implementations: vec![ImplementationVariant {
1031 id: "wasm".to_owned(),
1032 artifact: Some("guest".to_owned()),
1033 built_in_factory: None,
1034 entrypoint: "echo".to_owned(),
1035 execution_class: "lenso.wasm-component@1".to_owned(),
1036 targets: vec!["wasm32-unknown-unknown".to_owned()],
1037 profiles: vec!["provide-request-v1".to_owned()],
1038 support_channel: SupportChannel::Preview,
1039 trust: TrustLevel::Constrained,
1040 }],
1041 permission_request_ids: Vec::new(),
1042 state: None,
1043 }],
1044 data_contributions: Vec::new(),
1045 permission_requests: Vec::new(),
1046 features: Vec::new(),
1047 binding_templates: Vec::new(),
1048 product_metadata: Vec::new(),
1049 }
1050 }
1051
1052 fn wasm_with_descriptors(descriptors: &[&[u8]]) -> Vec<u8> {
1053 let mut module = wasm_encoder::Module::new();
1054 for descriptor in descriptors {
1055 module.section(&wasm_encoder::CustomSection {
1056 name: Cow::Borrowed(PLUGIN_DESCRIPTOR_SECTION),
1057 data: Cow::Borrowed(descriptor),
1058 });
1059 }
1060 module.finish()
1061 }
1062
1063 #[test]
1064 fn publisher_template_duplicates_guest_descriptor() {
1065 let guest_descriptor = lenso_guest_sdk::encode_guest_descriptor(
1066 &[lenso_guest_sdk::GuestProvidedCapability {
1067 capability_id: "test.echo@1",
1068 descriptor_version: "1.0.0",
1069 request_operations: &["echo"],
1070 stream_operations: &[],
1071 }],
1072 &[],
1073 );
1074 let guest: Value = serde_json::from_str(&guest_descriptor).unwrap();
1075 let template = wasm_template_with_duplicated_descriptor();
1076 let contribution = &template.module_contributions[0];
1077 let implementation = &contribution.implementations[0];
1078
1079 assert_eq!(
1080 (
1081 contribution.provides[0].capability_id.as_str(),
1082 contribution.provides[0].descriptor_version.as_str(),
1083 contribution.provides[0].request_operations.as_slice(),
1084 implementation.artifact.as_deref(),
1085 implementation.execution_class.as_str(),
1086 implementation.targets.as_slice(),
1087 implementation.trust,
1088 ),
1089 (
1090 guest["capabilities"][0]["capability_id"].as_str().unwrap(),
1091 guest["capabilities"][0]["descriptor_version"]
1092 .as_str()
1093 .unwrap(),
1094 &["echo".to_owned()][..],
1095 Some("guest"),
1096 "lenso.wasm-component@1",
1097 &["wasm32-unknown-unknown".to_owned()][..],
1098 TrustLevel::Constrained,
1099 )
1100 );
1101 }
1102
1103 #[test]
1104 fn bundle_build_requires_a_publisher_template_before_reading_artifacts() {
1105 let source = tempfile::tempdir().unwrap();
1106 let error = build_bundle(&BundleBuild {
1107 template: source.path().join("missing-template.json"),
1108 output: source.path().join("dist/plugin"),
1109 artifact_sources: Vec::new(),
1110 })
1111 .unwrap_err();
1112
1113 assert!(error.to_string().contains("Plugin Manifest template"));
1114 }
1115
1116 #[test]
1117 fn source_metadata_rejects_publisher_owned_runtime_fields() {
1118 let error = toml::from_str::<CargoManifest>(
1119 r#"
1120 [package]
1121 version = "1.0.0"
1122
1123 [package.metadata.lenso]
1124 plugin-id = "example.echo"
1125 module-contributions = []
1126 "#,
1127 )
1128 .unwrap_err();
1129
1130 assert!(error.to_string().contains("module-contributions"));
1131 }
1132
1133 #[test]
1134 fn descriptor_extraction_rejects_missing_evidence() {
1135 let error = extract_plugin_descriptor(&wasm_with_descriptors(&[])).unwrap_err();
1136
1137 assert!(error.to_string().contains("does not contain"));
1138 }
1139
1140 #[test]
1141 fn descriptor_extraction_rejects_duplicate_evidence() {
1142 let descriptor = br#"{"profile":"one"}"#;
1143 let error = extract_plugin_descriptor(&wasm_with_descriptors(&[
1144 descriptor.as_slice(),
1145 descriptor.as_slice(),
1146 ]))
1147 .unwrap_err();
1148
1149 assert!(error.to_string().contains("duplicate"));
1150 }
1151
1152 #[test]
1153 fn descriptor_extraction_rejects_oversized_evidence() {
1154 let descriptor = vec![b' '; MAX_PLUGIN_DESCRIPTOR_BYTES + 1];
1155 let error = extract_plugin_descriptor(&wasm_with_descriptors(&[&descriptor])).unwrap_err();
1156
1157 assert!(error.to_string().contains("size limit"));
1158 }
1159
1160 #[test]
1161 fn descriptor_extraction_rejects_malformed_or_noncanonical_json() {
1162 let malformed = extract_plugin_descriptor(&wasm_with_descriptors(&[b"{"])).unwrap_err();
1163 let noncanonical =
1164 extract_plugin_descriptor(&wasm_with_descriptors(&[br#"{ "profile": "one" }"#]))
1165 .unwrap_err();
1166
1167 assert!(
1168 malformed.to_string().contains("invalid Plugin Manifest")
1169 && noncanonical.to_string().contains("not canonical")
1170 );
1171 }
1172
1173 #[test]
1174 fn descriptor_changes_are_closed_by_the_final_artifact_digest() {
1175 let first = wasm_with_descriptors(&[br#"{"profile":"one"}"#]);
1176 let second = wasm_with_descriptors(&[br#"{"profile":"two"}"#]);
1177
1178 assert_ne!(sha256_digest(&first), sha256_digest(&second));
1179 }
1180
1181 #[test]
1182 fn v1_creation_and_verification_remain_compatible_during_migration() {
1183 let source = tempfile::tempdir().unwrap();
1184 let template_path = source.path().join("lenso-plugin.template.json");
1185 fs::write(&template_path, serde_json::to_vec(&template()).unwrap()).unwrap();
1186 fs::write(
1187 source.path().join("source.mjs"),
1188 b"export const ready = true;\n",
1189 )
1190 .unwrap();
1191 let output = source.path().join("dist/plugin");
1192 let built = build_bundle(&BundleBuild {
1193 template: template_path,
1194 output: output.clone(),
1195 artifact_sources: vec![ArtifactSource {
1196 artifact_id: "script".to_owned(),
1197 path: source.path().join("source.mjs"),
1198 }],
1199 })
1200 .unwrap();
1201 assert_eq!(built, verify_bundle_directory(&output).unwrap());
1202 assert_ne!(built.artifact_digests, [ZERO_DIGEST]);
1203 }
1204
1205 #[test]
1206 fn verification_rejects_tampering_and_undeclared_files() {
1207 let mut manifest = template();
1208 let bytes = b"ready".to_vec();
1209 manifest.artifacts[0].digest = sha256_digest(&bytes);
1210 manifest.artifacts[0].size = u64::try_from(bytes.len()).unwrap();
1211 let document = ManifestDocument::from_value(manifest).unwrap();
1212 let mut files = BTreeMap::from([("plugin.mjs".to_owned(), bytes)]);
1213 verify_bundle_files(&document, &files).unwrap();
1214 files.insert("extra".to_owned(), Vec::new());
1215 assert!(verify_bundle_files(&document, &files).is_err());
1216 }
1217
1218 #[test]
1219 fn parsing_rejects_duplicate_fields_and_path_escape() {
1220 assert!(ManifestDocument::parse(br#"{"schema_version":1,"schema_version":1}"#).is_err());
1221 let mut manifest = template();
1222 manifest.artifacts[0].path = "../plugin.mjs".to_owned();
1223 assert!(ManifestDocument::from_value(manifest).is_err());
1224 }
1225}