1mod model;
4
5use std::{
6 collections::{BTreeMap, BTreeSet},
7 fmt, fs,
8 path::{Component, Path, PathBuf},
9};
10
11use lenso_app_plan::{
12 CapabilityEndpointPlan, CapabilityOperationKind, CapabilityRequirementPlan, ExecutionClassId,
13 authoring::PluginDescriptor,
14};
15pub use model::*;
16use serde::{Deserialize, de::DeserializeOwned};
17use serde_json::Value;
18use sha2::{Digest, Sha256};
19
20pub const MANIFEST_FILE: &str = "lenso-plugin.json";
22
23pub const PLUGIN_DESCRIPTOR_SECTION: &str = "lenso.plugin-descriptor.v1";
25
26pub const MAX_PLUGIN_DESCRIPTOR_BYTES: usize = 64 * 1024;
28
29#[derive(Clone, Debug, Eq, PartialEq)]
31pub struct SourcePluginBuild {
32 pub package_manifest: PathBuf,
33 pub wasm_module: PathBuf,
34 pub output: PathBuf,
35}
36
37#[derive(Clone, Debug)]
38struct SourceManifestDocument {
39 value: PluginManifestV2,
40 bytes: Vec<u8>,
41 digest: String,
42}
43
44impl SourceManifestDocument {
45 fn parse(input: &[u8]) -> Result<Self, BundleError> {
46 let value = strict_json::<PluginManifestV2>(input)?;
47 Self::from_value(value)
48 }
49
50 fn from_value(value: PluginManifestV2) -> Result<Self, BundleError> {
51 validate_source_manifest(&value)?;
52 let json = serde_json::to_value(&value)
53 .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
54 validate_json_value(&json)?;
55 let bytes = serde_json::to_vec(&json)
56 .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
57 let digest = sha256_digest(&bytes);
58 Ok(Self {
59 value,
60 bytes,
61 digest,
62 })
63 }
64}
65
66#[derive(Debug, Deserialize)]
67struct CargoManifest {
68 package: CargoPackage,
69}
70
71#[derive(Debug, Deserialize)]
72struct CargoPackage {
73 version: String,
74 metadata: CargoMetadata,
75}
76
77#[derive(Debug, Deserialize)]
78struct CargoMetadata {
79 lenso: CargoLensoMetadata,
80}
81
82#[derive(Debug, Deserialize)]
83#[serde(deny_unknown_fields, rename_all = "kebab-case")]
84struct CargoLensoMetadata {
85 plugin_id: String,
86 root_slot: String,
87}
88
89#[derive(Debug, Deserialize)]
90#[serde(deny_unknown_fields)]
91struct GuestRuntimeDescriptor {
92 abi: String,
93 capabilities: Vec<GuestCapability>,
94 #[serde(default)]
95 required_capabilities: Vec<GuestRequirement>,
96}
97
98#[derive(Debug, Deserialize)]
99#[serde(deny_unknown_fields)]
100struct GuestCapability {
101 capability_id: String,
102 descriptor_version: String,
103 request_operations: Vec<String>,
104 #[serde(default)]
105 stream_operations: Vec<String>,
106}
107
108#[derive(Debug, Deserialize)]
109#[serde(deny_unknown_fields)]
110struct GuestRequirement {
111 capability_id: String,
112 descriptor_version: String,
113 cardinality: String,
114}
115
116#[derive(Clone, Debug, Eq, PartialEq)]
118pub struct VerifiedBundle {
119 pub plugin_id: String,
120 pub release_version: String,
121 pub manifest_digest: String,
122 pub artifact_digests: Vec<String>,
123 pub product_metadata_digests: Vec<String>,
124}
125
126#[derive(Clone, Debug, Eq, PartialEq)]
128pub enum BundleError {
129 InvalidManifest(String),
130 InvalidBundle(String),
131 DigestMismatch(String),
132 Io(String),
133 Wasm(String),
134}
135
136impl fmt::Display for BundleError {
137 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
138 match self {
139 Self::InvalidManifest(detail) => write!(formatter, "invalid Plugin Manifest: {detail}"),
140 Self::InvalidBundle(detail) => write!(formatter, "invalid Plugin Bundle: {detail}"),
141 Self::DigestMismatch(subject) => write!(formatter, "digest mismatch for {subject}"),
142 Self::Io(detail) => formatter.write_str(detail),
143 Self::Wasm(detail) => write!(
144 formatter,
145 "failed to encode WebAssembly Component: {detail}"
146 ),
147 }
148 }
149}
150
151impl std::error::Error for BundleError {}
152
153pub fn build_source_plugin_bundle(
155 build: &SourcePluginBuild,
156) -> Result<VerifiedBundle, BundleError> {
157 if build.output.exists() {
158 return invalid_bundle(format!(
159 "output `{}` already exists",
160 build.output.display()
161 ));
162 }
163 let package_bytes = read_regular_file(&build.package_manifest, "Cargo manifest")?;
164 let package = toml::from_slice::<CargoManifest>(&package_bytes)
165 .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
166 let module = read_regular_file(&build.wasm_module, "Plugin Wasm module")?;
167 let component = wit_component::ComponentEncoder::default()
168 .module(&module)
169 .map_err(|error| BundleError::Wasm(error.to_string()))?
170 .validate(true)
171 .encode()
172 .map_err(|error| BundleError::Wasm(error.to_string()))?;
173 let runtime_descriptor = extract_plugin_descriptor(&component)?;
174 let artifact = PluginArtifactV2 {
175 path: "plugin.wasm".to_owned(),
176 digest: sha256_digest(&component),
177 size: u64::try_from(component.len())
178 .map_err(|_| BundleError::InvalidBundle("Artifact size exceeds u64".to_owned()))?,
179 media_type: "application/wasm".to_owned(),
180 target: "wasm32-unknown-unknown".to_owned(),
181 };
182 let descriptor = portable_plugin_descriptor(
183 &package.package.metadata.lenso.plugin_id,
184 &package.package.version,
185 &package.package.metadata.lenso.root_slot,
186 &artifact.digest,
187 &runtime_descriptor,
188 )?;
189 let document = SourceManifestDocument::from_value(PluginManifestV2 {
190 schema_version: 2,
191 plugin_id: package.package.metadata.lenso.plugin_id,
192 release_version: package.package.version,
193 artifact,
194 entry: PluginEntryV2 { descriptor },
195 })?;
196
197 let output_parent = build.output.parent().unwrap_or_else(|| Path::new("."));
198 fs::create_dir_all(output_parent).map_err(io_error)?;
199 let staging = tempfile::Builder::new()
200 .prefix(".lenso-plugin-")
201 .tempdir_in(output_parent)
202 .map_err(io_error)?;
203 write_bundle_file(staging.path(), &document.value.artifact.path, &component)?;
204 fs::write(staging.path().join(MANIFEST_FILE), &document.bytes).map_err(io_error)?;
205 fs::rename(staging.path(), &build.output).map_err(io_error)?;
206 verify_bundle_directory(&build.output)
207}
208
209pub fn verify_bundle_directory(root: &Path) -> Result<VerifiedBundle, BundleError> {
211 let manifest_path = root.join(MANIFEST_FILE);
212 let manifest_bytes = read_regular_file(&manifest_path, "Plugin Manifest")?;
213 let mut files = BTreeMap::new();
214 collect_bundle_files(root, root, &mut files)?;
215 files.remove(MANIFEST_FILE);
216 verify_source_bundle_files(&SourceManifestDocument::parse(&manifest_bytes)?, &files)
217}
218
219fn verify_source_bundle_files(
220 manifest: &SourceManifestDocument,
221 files: &BTreeMap<String, Vec<u8>>,
222) -> Result<VerifiedBundle, BundleError> {
223 let artifact = &manifest.value.artifact;
224 if files.len() != 1 {
225 return invalid_bundle("V2 Bundle must contain exactly one Artifact");
226 }
227 let Some(bytes) = files.get(&artifact.path) else {
228 return invalid_bundle("V2 Bundle does not contain its declared Artifact");
229 };
230 if artifact.size != u64::try_from(bytes.len()).unwrap_or(u64::MAX)
231 || artifact.digest != sha256_digest(bytes)
232 {
233 return Err(BundleError::DigestMismatch(artifact.path.clone()));
234 }
235 let runtime_descriptor = extract_plugin_descriptor(bytes)?;
236 let descriptor = portable_plugin_descriptor(
237 &manifest.value.plugin_id,
238 &manifest.value.release_version,
239 manifest
240 .value
241 .entry
242 .descriptor
243 .get("root_slot")
244 .and_then(Value::as_str)
245 .ok_or_else(|| BundleError::InvalidManifest("root_slot is required".to_owned()))?,
246 &artifact.digest,
247 &runtime_descriptor,
248 )?;
249 let packaged = serde_json::to_vec(&manifest.value.entry.descriptor)
250 .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
251 let derived = serde_json::to_vec(&descriptor)
252 .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
253 if derived != packaged {
254 return invalid_bundle("source descriptor does not match the V2 Plugin entry");
255 }
256 Ok(VerifiedBundle {
257 plugin_id: manifest.value.plugin_id.clone(),
258 release_version: manifest.value.release_version.clone(),
259 manifest_digest: manifest.digest.clone(),
260 artifact_digests: vec![artifact.digest.clone()],
261 product_metadata_digests: Vec::new(),
262 })
263}
264
265fn portable_plugin_descriptor(
266 plugin_id: &str,
267 release_version: &str,
268 root_slot: &str,
269 artifact_digest: &str,
270 encoded: &[u8],
271) -> Result<Value, BundleError> {
272 let runtime = strict_json::<GuestRuntimeDescriptor>(encoded)?;
273 if ![
274 "lenso.json-request@1",
275 "lenso.json-interactions@1",
276 "lenso.json-host-imports@1",
277 ]
278 .contains(&runtime.abi.as_str())
279 {
280 return invalid_manifest("unsupported guest Plugin ABI");
281 }
282 let mut descriptor = PluginDescriptor::new(plugin_id, release_version, root_slot)
283 .with_runtime_package(plugin_id, artifact_digest)
284 .with_execution_class(ExecutionClassId::new("lenso.wasm-component@1"));
285 for capability in runtime.capabilities {
286 let mut endpoint = CapabilityEndpointPlan::new(
287 capability.capability_id,
288 capability.descriptor_version,
289 capability
290 .request_operations
291 .iter()
292 .chain(&capability.stream_operations)
293 .cloned(),
294 );
295 for operation in capability.stream_operations {
296 endpoint = endpoint.with_operation_kind(operation, CapabilityOperationKind::Stream);
297 }
298 descriptor = descriptor.with_capability(endpoint);
299 }
300 for requirement in runtime.required_capabilities {
301 if requirement.cardinality != "one" {
302 return invalid_manifest("unsupported guest Capability cardinality");
303 }
304 descriptor = descriptor.with_requirement(CapabilityRequirementPlan::one(
305 requirement.capability_id,
306 requirement.descriptor_version,
307 ));
308 }
309 serde_json::to_value(descriptor)
310 .map_err(|error| BundleError::InvalidManifest(error.to_string()))
311}
312
313pub fn extract_plugin_descriptor(component: &[u8]) -> Result<Vec<u8>, BundleError> {
315 let mut descriptors = Vec::new();
316 collect_plugin_descriptors(component, &mut descriptors)?;
317 let [descriptor] = descriptors.as_slice() else {
318 return invalid_bundle(if descriptors.is_empty() {
319 "Plugin Component does not contain a source-derived descriptor"
320 } else {
321 "Plugin Component contains duplicate source-derived descriptors"
322 });
323 };
324 let value = strict_json::<Value>(descriptor)?;
325 let canonical = serde_json::to_vec(&value)
326 .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
327 if canonical != *descriptor {
328 return invalid_bundle("Plugin descriptor is not canonical JSON");
329 }
330 Ok(descriptor.clone())
331}
332
333fn collect_plugin_descriptors(
334 bytes: &[u8],
335 descriptors: &mut Vec<Vec<u8>>,
336) -> Result<(), BundleError> {
337 for payload in wasmparser::Parser::new(0).parse_all(bytes) {
338 match payload.map_err(|error| BundleError::Wasm(error.to_string()))? {
339 wasmparser::Payload::CustomSection(section)
340 if section.name() == PLUGIN_DESCRIPTOR_SECTION =>
341 {
342 if section.data().len() > MAX_PLUGIN_DESCRIPTOR_BYTES {
343 return invalid_bundle("Plugin descriptor exceeds the size limit");
344 }
345 descriptors.push(section.data().to_vec());
346 }
347 _ => {}
348 }
349 }
350 Ok(())
351}
352
353fn validate_source_manifest(manifest: &PluginManifestV2) -> Result<(), BundleError> {
354 if manifest.schema_version != 2 {
355 return invalid_manifest("unsupported schema version");
356 }
357 if manifest.plugin_id.is_empty() || semver::Version::parse(&manifest.release_version).is_err() {
358 return invalid_manifest("Plugin identity or Release version is invalid");
359 }
360 validate_relative_path(&manifest.artifact.path)?;
361 digest_component(&manifest.artifact.digest)?;
362 if manifest.artifact.size == 0
363 || manifest.artifact.media_type != "application/wasm"
364 || manifest.artifact.target != "wasm32-unknown-unknown"
365 {
366 return invalid_manifest("V2 Artifact size, Wasm media type, and target must be exact");
367 }
368 if !manifest.entry.descriptor.is_object() {
369 return invalid_manifest("V2 Plugin entry descriptor must be an object");
370 }
371 Ok(())
372}
373
374#[allow(clippy::too_many_lines)]
376pub fn sha256_digest(bytes: &[u8]) -> String {
378 format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
379}
380
381fn strict_json<T: DeserializeOwned>(input: &[u8]) -> Result<T, BundleError> {
382 let mut deserializer = serde_json::Deserializer::from_slice(input);
383 let strict = StrictValue::deserialize(&mut deserializer)
384 .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
385 deserializer
386 .end()
387 .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
388 validate_json_value(&strict.0)?;
389 serde_json::from_value(strict.0)
390 .map_err(|error| BundleError::InvalidManifest(error.to_string()))
391}
392
393#[derive(Clone, Debug)]
394struct StrictValue(Value);
395
396impl<'de> Deserialize<'de> for StrictValue {
397 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
398 where
399 D: serde::Deserializer<'de>,
400 {
401 deserializer.deserialize_any(StrictVisitor)
402 }
403}
404
405struct StrictVisitor;
406
407impl<'de> serde::de::Visitor<'de> for StrictVisitor {
408 type Value = StrictValue;
409
410 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
411 formatter.write_str("strict Plugin Manifest JSON")
412 }
413
414 fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> {
415 Ok(StrictValue(Value::Bool(value)))
416 }
417
418 fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
419 Ok(StrictValue(Value::Number(value.into())))
420 }
421
422 fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
423 where
424 E: serde::de::Error,
425 {
426 u64::try_from(value)
427 .map_err(|_| E::custom("negative integers are forbidden"))
428 .and_then(|value| self.visit_u64(value))
429 }
430
431 fn visit_f64<E>(self, _: f64) -> Result<Self::Value, E>
432 where
433 E: serde::de::Error,
434 {
435 Err(E::custom("floating-point values are forbidden"))
436 }
437
438 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> {
439 Ok(StrictValue(Value::String(value.to_owned())))
440 }
441
442 fn visit_string<E>(self, value: String) -> Result<Self::Value, E> {
443 Ok(StrictValue(Value::String(value)))
444 }
445
446 fn visit_none<E>(self) -> Result<Self::Value, E> {
447 Ok(StrictValue(Value::Null))
448 }
449
450 fn visit_unit<E>(self) -> Result<Self::Value, E> {
451 Ok(StrictValue(Value::Null))
452 }
453
454 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
455 where
456 A: serde::de::SeqAccess<'de>,
457 {
458 let mut values = Vec::new();
459 while let Some(value) = sequence.next_element::<StrictValue>()? {
460 values.push(value.0);
461 }
462 Ok(StrictValue(Value::Array(values)))
463 }
464
465 fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
466 where
467 A: serde::de::MapAccess<'de>,
468 {
469 let mut keys = BTreeSet::new();
470 let mut values = serde_json::Map::new();
471 while let Some(key) = map.next_key::<String>()? {
472 if !keys.insert(key.clone()) {
473 return Err(serde::de::Error::custom(format!("duplicate field `{key}`")));
474 }
475 values.insert(key, map.next_value::<StrictValue>()?.0);
476 }
477 Ok(StrictValue(Value::Object(values)))
478 }
479}
480
481fn validate_json_value(value: &Value) -> Result<(), BundleError> {
482 match value {
483 Value::Number(number) if !number.is_u64() => {
484 invalid_manifest("numbers must be non-negative integers")
485 }
486 Value::Array(values) => values.iter().try_for_each(validate_json_value),
487 Value::Object(values) => values.values().try_for_each(validate_json_value),
488 _ => Ok(()),
489 }
490}
491
492fn validate_relative_path(path: &str) -> Result<(), BundleError> {
493 if path.is_empty() || path.contains('\\') {
494 return invalid_manifest("Bundle path is empty or platform-ambiguous");
495 }
496 let path = Path::new(path);
497 if path.is_absolute()
498 || path
499 .components()
500 .any(|part| !matches!(part, Component::Normal(_)))
501 {
502 return invalid_manifest("Bundle path must contain only normalized relative segments");
503 }
504 Ok(())
505}
506
507fn digest_component(digest: &str) -> Result<&str, BundleError> {
508 let Some(value) = digest.strip_prefix("sha256:") else {
509 return invalid_manifest("digest does not use sha256 prefix");
510 };
511 if value.len() != 64
512 || !value
513 .bytes()
514 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
515 {
516 return invalid_manifest("digest is not 64 lowercase hexadecimal characters");
517 }
518 Ok(value)
519}
520
521fn read_regular_file(path: &Path, kind: &str) -> Result<Vec<u8>, BundleError> {
522 let metadata = fs::symlink_metadata(path)
523 .map_err(|error| BundleError::Io(format!("failed to inspect {kind}: {error}")))?;
524 if !metadata.is_file() || metadata.file_type().is_symlink() {
525 return invalid_bundle(format!("{kind} is not a regular file"));
526 }
527 fs::read(path).map_err(io_error)
528}
529
530fn write_bundle_file(root: &Path, relative: &str, bytes: &[u8]) -> Result<(), BundleError> {
531 let path = root.join(relative);
532 if let Some(parent) = path.parent() {
533 fs::create_dir_all(parent).map_err(io_error)?;
534 }
535 fs::write(path, bytes).map_err(io_error)
536}
537
538fn collect_bundle_files(
539 root: &Path,
540 directory: &Path,
541 files: &mut BTreeMap<String, Vec<u8>>,
542) -> Result<(), BundleError> {
543 let metadata = fs::symlink_metadata(directory).map_err(io_error)?;
544 if !metadata.is_dir() || metadata.file_type().is_symlink() {
545 return invalid_bundle("Bundle root contains a non-regular directory");
546 }
547 let mut entries = fs::read_dir(directory)
548 .map_err(io_error)?
549 .collect::<Result<Vec<_>, _>>()
550 .map_err(io_error)?;
551 entries.sort_by_key(std::fs::DirEntry::file_name);
552 for entry in entries {
553 let path = entry.path();
554 let metadata = fs::symlink_metadata(&path).map_err(io_error)?;
555 if metadata.file_type().is_symlink() {
556 return invalid_bundle("Bundle contains a symbolic link");
557 }
558 if metadata.is_dir() {
559 collect_bundle_files(root, &path, files)?;
560 continue;
561 }
562 if !metadata.is_file() {
563 return invalid_bundle("Bundle contains a non-regular file");
564 }
565 let relative = path
566 .strip_prefix(root)
567 .map_err(|_| BundleError::InvalidBundle("Bundle path escaped root".to_owned()))?
568 .to_str()
569 .ok_or_else(|| BundleError::InvalidBundle("Bundle path is not UTF-8".to_owned()))?
570 .replace(std::path::MAIN_SEPARATOR, "/");
571 validate_relative_path(&relative)?;
572 files.insert(relative, fs::read(path).map_err(io_error)?);
573 }
574 Ok(())
575}
576
577fn invalid_manifest<T>(detail: impl Into<String>) -> Result<T, BundleError> {
578 Err(BundleError::InvalidManifest(detail.into()))
579}
580
581fn invalid_bundle<T>(detail: impl Into<String>) -> Result<T, BundleError> {
582 Err(BundleError::InvalidBundle(detail.into()))
583}
584
585fn io_error(error: impl fmt::Display) -> BundleError {
586 BundleError::Io(error.to_string())
587}
588
589#[cfg(test)]
590mod tests {
591 use std::borrow::Cow;
592
593 use super::*;
594
595 fn wasm_with_descriptors(descriptors: &[&[u8]]) -> Vec<u8> {
596 let mut module = wasm_encoder::Module::new();
597 for descriptor in descriptors {
598 module.section(&wasm_encoder::CustomSection {
599 name: Cow::Borrowed(PLUGIN_DESCRIPTOR_SECTION),
600 data: Cow::Borrowed(descriptor),
601 });
602 }
603 module.finish()
604 }
605
606 #[test]
607 fn source_metadata_rejects_old_multi_entry_fields() {
608 let error = toml::from_str::<CargoManifest>(
609 r#"
610 [package]
611 version = "1.0.0"
612
613 [package.metadata.lenso]
614 plugin-id = "example.echo"
615 root-slot = "tools"
616 module-contributions = []
617 "#,
618 )
619 .unwrap_err();
620
621 assert!(error.to_string().contains("module-contributions"));
622 }
623
624 #[test]
625 fn descriptor_extraction_requires_one_canonical_descriptor() {
626 assert!(extract_plugin_descriptor(&wasm_with_descriptors(&[])).is_err());
627 let descriptor = br#"{"profile":"one"}"#;
628 assert!(
629 extract_plugin_descriptor(&wasm_with_descriptors(&[
630 descriptor.as_slice(),
631 descriptor.as_slice(),
632 ]))
633 .is_err()
634 );
635 assert!(extract_plugin_descriptor(&wasm_with_descriptors(&[b"{"])).is_err());
636 assert!(
637 extract_plugin_descriptor(&wasm_with_descriptors(&[br#"{ "profile": "one" }"#]))
638 .is_err()
639 );
640 }
641
642 #[test]
643 fn descriptor_extraction_rejects_oversized_evidence() {
644 let descriptor = vec![b' '; MAX_PLUGIN_DESCRIPTOR_BYTES + 1];
645 assert!(extract_plugin_descriptor(&wasm_with_descriptors(&[&descriptor])).is_err());
646 }
647
648 #[test]
649 fn strict_v2_manifest_rejects_duplicate_fields_and_path_escape() {
650 assert!(
651 SourceManifestDocument::parse(br#"{"schema_version":2,"schema_version":2}"#).is_err()
652 );
653 let manifest = PluginManifestV2 {
654 schema_version: 2,
655 plugin_id: "example.echo".to_owned(),
656 release_version: "1.0.0".to_owned(),
657 artifact: PluginArtifactV2 {
658 path: "../plugin.wasm".to_owned(),
659 digest: sha256_digest(b"plugin"),
660 size: 6,
661 media_type: "application/wasm".to_owned(),
662 target: "wasm32-unknown-unknown".to_owned(),
663 },
664 entry: PluginEntryV2 {
665 descriptor: serde_json::json!({"plugin_id":"example.echo"}),
666 },
667 };
668 assert!(SourceManifestDocument::from_value(manifest).is_err());
669 }
670}