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
19#[derive(Clone, Debug, Eq, PartialEq)]
21pub struct ArtifactSource {
22 pub artifact_id: String,
23 pub path: PathBuf,
24}
25
26#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct BundleBuild {
29 pub template: PathBuf,
30 pub output: PathBuf,
31 pub artifact_sources: Vec<ArtifactSource>,
32}
33
34#[derive(Clone, Debug)]
36pub struct ManifestDocument {
37 value: PluginManifest,
38 bytes: Vec<u8>,
39 digest: String,
40}
41
42impl ManifestDocument {
43 pub fn parse(input: &[u8]) -> Result<Self, BundleError> {
45 let value = strict_json::<PluginManifest>(input)?;
46 Self::from_value(value)
47 }
48
49 pub fn from_value(value: PluginManifest) -> Result<Self, BundleError> {
51 validate_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 pub const fn value(&self) -> &PluginManifest {
66 &self.value
67 }
68
69 pub fn bytes(&self) -> &[u8] {
70 &self.bytes
71 }
72
73 pub fn digest(&self) -> &str {
74 &self.digest
75 }
76}
77
78#[derive(Clone, Debug, Eq, PartialEq)]
80pub struct VerifiedBundle {
81 pub plugin_id: String,
82 pub release_version: String,
83 pub manifest_digest: String,
84 pub artifact_digests: Vec<String>,
85 pub product_metadata_digests: Vec<String>,
86}
87
88#[derive(Clone, Debug, Eq, PartialEq)]
90pub enum BundleError {
91 InvalidManifest(String),
92 InvalidBundle(String),
93 DigestMismatch(String),
94 Io(String),
95 Wasm(String),
96}
97
98impl fmt::Display for BundleError {
99 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
100 match self {
101 Self::InvalidManifest(detail) => write!(formatter, "invalid Plugin Manifest: {detail}"),
102 Self::InvalidBundle(detail) => write!(formatter, "invalid Plugin Bundle: {detail}"),
103 Self::DigestMismatch(subject) => write!(formatter, "digest mismatch for {subject}"),
104 Self::Io(detail) => formatter.write_str(detail),
105 Self::Wasm(detail) => write!(
106 formatter,
107 "failed to encode WebAssembly Component: {detail}"
108 ),
109 }
110 }
111}
112
113impl std::error::Error for BundleError {}
114
115pub fn build_bundle(build: &BundleBuild) -> Result<VerifiedBundle, BundleError> {
117 if build.output.exists() {
118 return invalid_bundle(format!(
119 "output `{}` already exists",
120 build.output.display()
121 ));
122 }
123 let template = read_regular_file(&build.template, "Plugin Manifest template")?;
124 let mut manifest = ManifestDocument::parse(&template)?.value;
125 let template_root = build.template.parent().unwrap_or_else(|| Path::new("."));
126 let sources = build
127 .artifact_sources
128 .iter()
129 .map(|source| (source.artifact_id.as_str(), source.path.as_path()))
130 .collect::<BTreeMap<_, _>>();
131 if sources.len() != build.artifact_sources.len() {
132 return invalid_bundle("Artifact source IDs must be unique");
133 }
134 if sources
135 .keys()
136 .any(|id| !manifest.artifacts.iter().any(|artifact| artifact.id == *id))
137 {
138 return invalid_bundle("Artifact source references an unknown Artifact ID");
139 }
140
141 let output_parent = build.output.parent().unwrap_or_else(|| Path::new("."));
142 fs::create_dir_all(output_parent).map_err(io_error)?;
143 let staging = tempfile::Builder::new()
144 .prefix(".lenso-plugin-")
145 .tempdir_in(output_parent)
146 .map_err(io_error)?;
147 let mut files = BTreeMap::new();
148 for artifact in &mut manifest.artifacts {
149 validate_relative_path(&artifact.path)?;
150 let source = sources
151 .get(artifact.id.as_str())
152 .copied()
153 .map_or_else(|| template_root.join(&artifact.path), Path::to_path_buf);
154 let source_bytes = read_regular_file(&source, &format!("Artifact `{}`", artifact.id))?;
155 let bytes = if artifact.kind == ArtifactKind::WasmComponent {
156 wit_component::ComponentEncoder::default()
157 .module(&source_bytes)
158 .map_err(|error| BundleError::Wasm(error.to_string()))?
159 .validate(true)
160 .encode()
161 .map_err(|error| BundleError::Wasm(error.to_string()))?
162 } else {
163 source_bytes
164 };
165 artifact.digest = sha256_digest(&bytes);
166 artifact.size = u64::try_from(bytes.len())
167 .map_err(|_| BundleError::InvalidBundle("Artifact size exceeds u64".to_owned()))?;
168 write_bundle_file(staging.path(), &artifact.path, &bytes)?;
169 files.insert(artifact.path.clone(), bytes);
170 }
171 for metadata in &mut manifest.product_metadata {
172 validate_relative_path(&metadata.path)?;
173 let bytes = read_regular_file(
174 &template_root.join(&metadata.path),
175 &format!("Product Metadata `{}`", metadata.id),
176 )?;
177 metadata.digest = sha256_digest(&bytes);
178 write_bundle_file(staging.path(), &metadata.path, &bytes)?;
179 files.insert(metadata.path.clone(), bytes);
180 }
181
182 let document = ManifestDocument::from_value(manifest)?;
183 fs::write(staging.path().join(MANIFEST_FILE), document.bytes()).map_err(io_error)?;
184 let verified = verify_bundle_files(&document, &files)?;
185 fs::rename(staging.path(), &build.output).map_err(io_error)?;
186 Ok(verified)
187}
188
189pub fn verify_bundle_directory(root: &Path) -> Result<VerifiedBundle, BundleError> {
191 let manifest_path = root.join(MANIFEST_FILE);
192 let manifest = ManifestDocument::parse(&read_regular_file(&manifest_path, "Plugin Manifest")?)?;
193 let mut files = BTreeMap::new();
194 collect_bundle_files(root, root, &mut files)?;
195 files.remove(MANIFEST_FILE);
196 verify_bundle_files(&manifest, &files)
197}
198
199pub fn verify_bundle_files(
201 manifest: &ManifestDocument,
202 files: &BTreeMap<String, Vec<u8>>,
203) -> Result<VerifiedBundle, BundleError> {
204 let declared_paths = manifest
205 .value()
206 .artifacts
207 .iter()
208 .map(|artifact| artifact.path.as_str())
209 .chain(
210 manifest
211 .value()
212 .product_metadata
213 .iter()
214 .map(|metadata| metadata.path.as_str()),
215 )
216 .collect::<BTreeSet<_>>();
217 if declared_paths.len()
218 != manifest.value().artifacts.len() + manifest.value().product_metadata.len()
219 {
220 return invalid_bundle("Manifest declares a duplicate Bundle path");
221 }
222 if files.len() != declared_paths.len()
223 || files
224 .keys()
225 .any(|path| !declared_paths.contains(path.as_str()))
226 {
227 return invalid_bundle("Bundle files do not exactly close over declared paths");
228 }
229 let mut artifact_digests = Vec::new();
230 for artifact in &manifest.value().artifacts {
231 let bytes = files.get(&artifact.path).ok_or_else(|| {
232 BundleError::InvalidBundle(format!("missing Artifact `{}`", artifact.id))
233 })?;
234 if u64::try_from(bytes.len()).unwrap_or(u64::MAX) != artifact.size {
235 return invalid_bundle(format!("Artifact `{}` size does not match", artifact.id));
236 }
237 if sha256_digest(bytes) != artifact.digest {
238 return Err(BundleError::DigestMismatch(format!(
239 "Artifact `{}`",
240 artifact.id
241 )));
242 }
243 artifact_digests.push(artifact.digest.clone());
244 }
245 artifact_digests.sort();
246 artifact_digests.dedup();
247 let mut product_metadata_digests = Vec::new();
248 for metadata in &manifest.value().product_metadata {
249 let bytes = files.get(&metadata.path).ok_or_else(|| {
250 BundleError::InvalidBundle(format!("missing Product Metadata `{}`", metadata.id))
251 })?;
252 if sha256_digest(bytes) != metadata.digest {
253 return Err(BundleError::DigestMismatch(format!(
254 "Product Metadata `{}`",
255 metadata.id
256 )));
257 }
258 product_metadata_digests.push(metadata.digest.clone());
259 }
260 product_metadata_digests.sort();
261 product_metadata_digests.dedup();
262 Ok(VerifiedBundle {
263 plugin_id: manifest.value().plugin_id.clone(),
264 release_version: manifest.value().release_version.clone(),
265 manifest_digest: manifest.digest().to_owned(),
266 artifact_digests,
267 product_metadata_digests,
268 })
269}
270
271#[allow(clippy::too_many_lines)]
273pub fn validate_manifest(manifest: &PluginManifest) -> Result<(), BundleError> {
274 if manifest.schema_version != 1 {
275 return invalid_manifest("unsupported schema version");
276 }
277 if manifest.plugin_id.is_empty() || semver::Version::parse(&manifest.release_version).is_err() {
278 return invalid_manifest("Plugin identity or Release version is invalid");
279 }
280 let artifact_ids = manifest
281 .artifacts
282 .iter()
283 .map(|artifact| artifact.id.as_str())
284 .collect::<Vec<_>>();
285 let contribution_ids = manifest
286 .module_contributions
287 .iter()
288 .map(|contribution| contribution.id.as_str())
289 .collect::<Vec<_>>();
290 let data_ids = manifest
291 .data_contributions
292 .iter()
293 .map(|contribution| contribution.id.as_str())
294 .collect::<Vec<_>>();
295 let permission_ids = manifest
296 .permission_requests
297 .iter()
298 .map(|request| request.id.as_str())
299 .collect::<Vec<_>>();
300 let feature_ids = manifest
301 .features
302 .iter()
303 .map(|feature| feature.id.as_str())
304 .collect::<Vec<_>>();
305 let metadata_ids = manifest
306 .product_metadata
307 .iter()
308 .map(|metadata| metadata.id.as_str())
309 .collect::<Vec<_>>();
310 ensure_sorted_unique(&artifact_ids, "Artifact ID")?;
311 ensure_sorted_unique(&contribution_ids, "Module contribution ID")?;
312 ensure_sorted_unique(&data_ids, "Data contribution ID")?;
313 ensure_sorted_unique(&permission_ids, "permission request ID")?;
314 ensure_sorted_unique(&feature_ids, "Feature ID")?;
315 ensure_sorted_unique(&metadata_ids, "Product Metadata ID")?;
316 let artifact_set = artifact_ids.iter().copied().collect::<BTreeSet<_>>();
317 let contribution_set = contribution_ids.iter().copied().collect::<BTreeSet<_>>();
318 let data_set = data_ids.iter().copied().collect::<BTreeSet<_>>();
319 let permission_set = permission_ids.iter().copied().collect::<BTreeSet<_>>();
320 let metadata_set = metadata_ids.iter().copied().collect::<BTreeSet<_>>();
321 for artifact in &manifest.artifacts {
322 digest_component(&artifact.digest)?;
323 validate_relative_path(&artifact.path)?;
324 if artifact.id.is_empty() || artifact.media_type.is_empty() || artifact.targets.is_empty() {
325 return invalid_manifest("Artifact identity, media type, and targets must be explicit");
326 }
327 ensure_sorted_unique(&artifact.targets, "Artifact target")?;
328 }
329 for contribution in &manifest.module_contributions {
330 digest_component(&contribution.configuration_schema_digest)?;
331 if contribution.id.is_empty()
332 || contribution.package_id.is_empty()
333 || contribution.implementations.is_empty()
334 {
335 return invalid_manifest(
336 "Module contribution identity and implementations are required",
337 );
338 }
339 let provided = contribution
340 .provides
341 .iter()
342 .map(|capability| capability.capability_id.as_str())
343 .collect::<Vec<_>>();
344 ensure_sorted_unique(&provided, "provided Capability")?;
345 for capability in &contribution.provides {
346 digest_component(&capability.descriptor_digest)?;
347 validate_semver(
348 &capability.descriptor_version,
349 "Capability Descriptor version",
350 )?;
351 ensure_sorted_unique(&capability.request_operations, "Capability Operation")?;
352 if capability
353 .operation_kinds
354 .keys()
355 .any(|operation| !capability.request_operations.contains(operation))
356 {
357 return invalid_manifest(
358 "Capability interaction kind references an unknown Operation",
359 );
360 }
361 }
362 let required = contribution
363 .requires
364 .iter()
365 .map(|capability| capability.capability_id.as_str())
366 .collect::<Vec<_>>();
367 ensure_sorted_unique(&required, "required Capability")?;
368 for capability in &contribution.requires {
369 validate_semver(
370 &capability.descriptor_version,
371 "required Capability Descriptor version",
372 )?;
373 }
374 ensure_sorted_unique(
375 &contribution.permission_request_ids,
376 "Module permission request",
377 )?;
378 if contribution
379 .permission_request_ids
380 .iter()
381 .any(|request| !permission_set.contains(request.as_str()))
382 {
383 return invalid_manifest(
384 "Module contribution references an unknown permission request",
385 );
386 }
387 if let Some(state) = &contribution.state {
388 if state.state_schema_id.is_empty() {
389 return invalid_manifest("state Schema identity is empty");
390 }
391 digest_component(&state.state_schema_digest)?;
392 }
393 let variants = contribution
394 .implementations
395 .iter()
396 .map(|variant| variant.id.as_str())
397 .collect::<Vec<_>>();
398 ensure_sorted_unique(&variants, "implementation variant ID")?;
399 for variant in &contribution.implementations {
400 if variant.artifact.is_some() == variant.built_in_factory.is_some() {
401 return invalid_manifest("implementation must select exactly one execution input");
402 }
403 if variant.built_in_factory.is_some()
404 && variant.execution_class != "lenso.native-rust@1"
405 {
406 return invalid_manifest("only native Rust may select a built-in factory");
407 }
408 if variant
409 .artifact
410 .as_deref()
411 .is_some_and(|artifact| !artifact_set.contains(artifact))
412 {
413 return invalid_manifest("implementation references an unknown Artifact");
414 }
415 if variant.entrypoint.is_empty()
416 || variant.execution_class.is_empty()
417 || variant.targets.is_empty()
418 || variant.profiles.is_empty()
419 {
420 return invalid_manifest(
421 "implementation entrypoint, class, targets, and Profiles are required",
422 );
423 }
424 ensure_sorted_unique(&variant.targets, "implementation target")?;
425 ensure_sorted_unique(&variant.profiles, "implementation Profile")?;
426 }
427 }
428 for contribution in &manifest.data_contributions {
429 if !artifact_set.contains(contribution.artifact.as_str())
430 || contribution.media_type.is_empty()
431 || contribution.content_schema_id.is_empty()
432 {
433 return invalid_manifest("Data contribution references incomplete authority");
434 }
435 digest_component(&contribution.content_schema_digest)?;
436 digest_component(&contribution.product_metadata_digest)?;
437 }
438 for request in &manifest.permission_requests {
439 if request.id.is_empty()
440 || request.resource_kind.is_empty()
441 || request.explanation_key.is_empty()
442 {
443 return invalid_manifest("permission request identity is incomplete");
444 }
445 }
446 for metadata in &manifest.product_metadata {
447 if metadata.id.is_empty() || metadata.namespace.is_empty() || metadata.schema_id.is_empty()
448 {
449 return invalid_manifest("Product Metadata identity is incomplete");
450 }
451 validate_relative_path(&metadata.path)?;
452 digest_component(&metadata.digest)?;
453 }
454 for feature in &manifest.features {
455 validate_refs(
456 &feature.module_contribution_ids,
457 &contribution_set,
458 "Module contribution",
459 )?;
460 validate_refs(
461 &feature.data_contribution_ids,
462 &data_set,
463 "Data contribution",
464 )?;
465 validate_refs(&feature.artifact_ids, &artifact_set, "Artifact")?;
466 validate_refs(
467 &feature.permission_request_ids,
468 &permission_set,
469 "permission request",
470 )?;
471 validate_refs(
472 &feature.product_metadata_ids,
473 &metadata_set,
474 "Product Metadata",
475 )?;
476 }
477 for template in &manifest.binding_templates {
478 if !contribution_set.contains(template.consumer_contribution_id.as_str())
479 || !contribution_set.contains(template.provider_contribution_id.as_str())
480 || template.capability_id.is_empty()
481 {
482 return invalid_manifest("binding template references unknown contribution authority");
483 }
484 let consumer = manifest
485 .module_contributions
486 .iter()
487 .find(|value| value.id == template.consumer_contribution_id)
488 .expect("validated contribution");
489 let provider = manifest
490 .module_contributions
491 .iter()
492 .find(|value| value.id == template.provider_contribution_id)
493 .expect("validated contribution");
494 if !consumer
495 .requires
496 .iter()
497 .any(|value| value.capability_id == template.capability_id)
498 || !provider
499 .provides
500 .iter()
501 .any(|value| value.capability_id == template.capability_id)
502 {
503 return invalid_manifest("binding template Capability is not required and provided");
504 }
505 }
506 Ok(())
507}
508
509pub fn sha256_digest(bytes: &[u8]) -> String {
511 format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
512}
513
514fn strict_json<T: DeserializeOwned>(input: &[u8]) -> Result<T, BundleError> {
515 let mut deserializer = serde_json::Deserializer::from_slice(input);
516 let strict = StrictValue::deserialize(&mut deserializer)
517 .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
518 deserializer
519 .end()
520 .map_err(|error| BundleError::InvalidManifest(error.to_string()))?;
521 validate_json_value(&strict.0)?;
522 serde_json::from_value(strict.0)
523 .map_err(|error| BundleError::InvalidManifest(error.to_string()))
524}
525
526#[derive(Clone, Debug)]
527struct StrictValue(Value);
528
529impl<'de> Deserialize<'de> for StrictValue {
530 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
531 where
532 D: serde::Deserializer<'de>,
533 {
534 deserializer.deserialize_any(StrictVisitor)
535 }
536}
537
538struct StrictVisitor;
539
540impl<'de> serde::de::Visitor<'de> for StrictVisitor {
541 type Value = StrictValue;
542
543 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
544 formatter.write_str("strict Plugin Manifest JSON")
545 }
546
547 fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> {
548 Ok(StrictValue(Value::Bool(value)))
549 }
550
551 fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
552 Ok(StrictValue(Value::Number(value.into())))
553 }
554
555 fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
556 where
557 E: serde::de::Error,
558 {
559 u64::try_from(value)
560 .map_err(|_| E::custom("negative integers are forbidden"))
561 .and_then(|value| self.visit_u64(value))
562 }
563
564 fn visit_f64<E>(self, _: f64) -> Result<Self::Value, E>
565 where
566 E: serde::de::Error,
567 {
568 Err(E::custom("floating-point values are forbidden"))
569 }
570
571 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> {
572 Ok(StrictValue(Value::String(value.to_owned())))
573 }
574
575 fn visit_string<E>(self, value: String) -> Result<Self::Value, E> {
576 Ok(StrictValue(Value::String(value)))
577 }
578
579 fn visit_none<E>(self) -> Result<Self::Value, E> {
580 Ok(StrictValue(Value::Null))
581 }
582
583 fn visit_unit<E>(self) -> Result<Self::Value, E> {
584 Ok(StrictValue(Value::Null))
585 }
586
587 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
588 where
589 A: serde::de::SeqAccess<'de>,
590 {
591 let mut values = Vec::new();
592 while let Some(value) = sequence.next_element::<StrictValue>()? {
593 values.push(value.0);
594 }
595 Ok(StrictValue(Value::Array(values)))
596 }
597
598 fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
599 where
600 A: serde::de::MapAccess<'de>,
601 {
602 let mut keys = BTreeSet::new();
603 let mut values = serde_json::Map::new();
604 while let Some(key) = map.next_key::<String>()? {
605 if !keys.insert(key.clone()) {
606 return Err(serde::de::Error::custom(format!("duplicate field `{key}`")));
607 }
608 values.insert(key, map.next_value::<StrictValue>()?.0);
609 }
610 Ok(StrictValue(Value::Object(values)))
611 }
612}
613
614fn validate_json_value(value: &Value) -> Result<(), BundleError> {
615 match value {
616 Value::Number(number) if !number.is_u64() => {
617 invalid_manifest("numbers must be non-negative integers")
618 }
619 Value::Array(values) => values.iter().try_for_each(validate_json_value),
620 Value::Object(values) => values.values().try_for_each(validate_json_value),
621 _ => Ok(()),
622 }
623}
624
625fn validate_relative_path(path: &str) -> Result<(), BundleError> {
626 if path.is_empty() || path.contains('\\') {
627 return invalid_manifest("Bundle path is empty or platform-ambiguous");
628 }
629 let path = Path::new(path);
630 if path.is_absolute()
631 || path
632 .components()
633 .any(|part| !matches!(part, Component::Normal(_)))
634 {
635 return invalid_manifest("Bundle path must contain only normalized relative segments");
636 }
637 Ok(())
638}
639
640fn digest_component(digest: &str) -> Result<&str, BundleError> {
641 let Some(value) = digest.strip_prefix("sha256:") else {
642 return invalid_manifest("digest does not use sha256 prefix");
643 };
644 if value.len() != 64
645 || !value
646 .bytes()
647 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
648 {
649 return invalid_manifest("digest is not 64 lowercase hexadecimal characters");
650 }
651 Ok(value)
652}
653
654fn validate_semver(value: &str, kind: &str) -> Result<(), BundleError> {
655 semver::Version::parse(value)
656 .map(|_| ())
657 .map_err(|_| BundleError::InvalidManifest(format!("{kind} is not SemVer")))
658}
659
660fn ensure_sorted_unique<T: Ord + fmt::Display>(
661 values: &[T],
662 kind: &str,
663) -> Result<(), BundleError> {
664 if values.windows(2).any(|pair| pair[0] >= pair[1]) {
665 return invalid_manifest(format!("{kind} entries must be sorted and unique"));
666 }
667 Ok(())
668}
669
670fn validate_refs<'a>(
671 values: &'a [String],
672 known: &BTreeSet<&'a str>,
673 kind: &str,
674) -> Result<(), BundleError> {
675 ensure_sorted_unique(values, kind)?;
676 if values.iter().any(|value| !known.contains(value.as_str())) {
677 return invalid_manifest(format!("Feature references an unknown {kind}"));
678 }
679 Ok(())
680}
681
682fn read_regular_file(path: &Path, kind: &str) -> Result<Vec<u8>, BundleError> {
683 let metadata = fs::symlink_metadata(path)
684 .map_err(|error| BundleError::Io(format!("failed to inspect {kind}: {error}")))?;
685 if !metadata.is_file() || metadata.file_type().is_symlink() {
686 return invalid_bundle(format!("{kind} is not a regular file"));
687 }
688 fs::read(path).map_err(io_error)
689}
690
691fn write_bundle_file(root: &Path, relative: &str, bytes: &[u8]) -> Result<(), BundleError> {
692 let path = root.join(relative);
693 if let Some(parent) = path.parent() {
694 fs::create_dir_all(parent).map_err(io_error)?;
695 }
696 fs::write(path, bytes).map_err(io_error)
697}
698
699fn collect_bundle_files(
700 root: &Path,
701 directory: &Path,
702 files: &mut BTreeMap<String, Vec<u8>>,
703) -> Result<(), BundleError> {
704 let metadata = fs::symlink_metadata(directory).map_err(io_error)?;
705 if !metadata.is_dir() || metadata.file_type().is_symlink() {
706 return invalid_bundle("Bundle root contains a non-regular directory");
707 }
708 let mut entries = fs::read_dir(directory)
709 .map_err(io_error)?
710 .collect::<Result<Vec<_>, _>>()
711 .map_err(io_error)?;
712 entries.sort_by_key(std::fs::DirEntry::file_name);
713 for entry in entries {
714 let path = entry.path();
715 let metadata = fs::symlink_metadata(&path).map_err(io_error)?;
716 if metadata.file_type().is_symlink() {
717 return invalid_bundle("Bundle contains a symbolic link");
718 }
719 if metadata.is_dir() {
720 collect_bundle_files(root, &path, files)?;
721 continue;
722 }
723 if !metadata.is_file() {
724 return invalid_bundle("Bundle contains a non-regular file");
725 }
726 let relative = path
727 .strip_prefix(root)
728 .map_err(|_| BundleError::InvalidBundle("Bundle path escaped root".to_owned()))?
729 .to_str()
730 .ok_or_else(|| BundleError::InvalidBundle("Bundle path is not UTF-8".to_owned()))?
731 .replace(std::path::MAIN_SEPARATOR, "/");
732 validate_relative_path(&relative)?;
733 files.insert(relative, fs::read(path).map_err(io_error)?);
734 }
735 Ok(())
736}
737
738fn invalid_manifest<T>(detail: impl Into<String>) -> Result<T, BundleError> {
739 Err(BundleError::InvalidManifest(detail.into()))
740}
741
742fn invalid_bundle<T>(detail: impl Into<String>) -> Result<T, BundleError> {
743 Err(BundleError::InvalidBundle(detail.into()))
744}
745
746fn io_error(error: impl fmt::Display) -> BundleError {
747 BundleError::Io(error.to_string())
748}
749
750#[cfg(test)]
751mod tests {
752 use super::*;
753
754 const ZERO_DIGEST: &str =
755 "sha256:0000000000000000000000000000000000000000000000000000000000000000";
756
757 fn template() -> PluginManifest {
758 PluginManifest {
759 schema_version: 1,
760 plugin_id: "example.quickjs".to_owned(),
761 release_version: "1.0.0".to_owned(),
762 artifacts: vec![ArtifactDeclaration {
763 id: "script".to_owned(),
764 kind: ArtifactKind::QuickJsModule,
765 digest: ZERO_DIGEST.to_owned(),
766 size: 0,
767 media_type: "text/javascript".to_owned(),
768 path: "plugin.mjs".to_owned(),
769 targets: vec!["aarch64-macos".to_owned()],
770 }],
771 module_contributions: Vec::new(),
772 data_contributions: Vec::new(),
773 permission_requests: Vec::new(),
774 features: Vec::new(),
775 binding_templates: Vec::new(),
776 product_metadata: Vec::new(),
777 }
778 }
779
780 #[test]
781 fn build_materializes_and_verifies_an_exact_bundle() {
782 let source = tempfile::tempdir().unwrap();
783 let template_path = source.path().join("lenso-plugin.template.json");
784 fs::write(&template_path, serde_json::to_vec(&template()).unwrap()).unwrap();
785 fs::write(
786 source.path().join("source.mjs"),
787 b"export const ready = true;\n",
788 )
789 .unwrap();
790 let output = source.path().join("dist/plugin");
791 let built = build_bundle(&BundleBuild {
792 template: template_path,
793 output: output.clone(),
794 artifact_sources: vec![ArtifactSource {
795 artifact_id: "script".to_owned(),
796 path: source.path().join("source.mjs"),
797 }],
798 })
799 .unwrap();
800 assert_eq!(built, verify_bundle_directory(&output).unwrap());
801 assert_ne!(built.artifact_digests, [ZERO_DIGEST]);
802 }
803
804 #[test]
805 fn verification_rejects_tampering_and_undeclared_files() {
806 let mut manifest = template();
807 let bytes = b"ready".to_vec();
808 manifest.artifacts[0].digest = sha256_digest(&bytes);
809 manifest.artifacts[0].size = u64::try_from(bytes.len()).unwrap();
810 let document = ManifestDocument::from_value(manifest).unwrap();
811 let mut files = BTreeMap::from([("plugin.mjs".to_owned(), bytes)]);
812 verify_bundle_files(&document, &files).unwrap();
813 files.insert("extra".to_owned(), Vec::new());
814 assert!(verify_bundle_files(&document, &files).is_err());
815 }
816
817 #[test]
818 fn parsing_rejects_duplicate_fields_and_path_escape() {
819 assert!(ManifestDocument::parse(br#"{"schema_version":1,"schema_version":1}"#).is_err());
820 let mut manifest = template();
821 manifest.artifacts[0].path = "../plugin.mjs".to_owned();
822 assert!(ManifestDocument::from_value(manifest).is_err());
823 }
824}