Skip to main content

lenso_plugin_bundle/
lib.rs

1//! Immutable Plugin Release manifests, source materialization, and Bundle verification.
2
3mod 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
16/// The only manifest filename accepted in a materialized Plugin Bundle.
17pub const MANIFEST_FILE: &str = "lenso-plugin.json";
18
19/// A source Artifact selected while materializing one Release.
20#[derive(Clone, Debug, Eq, PartialEq)]
21pub struct ArtifactSource {
22    pub artifact_id: String,
23    pub path: PathBuf,
24}
25
26/// Exact input to one fail-closed Bundle build.
27#[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/// Canonical Plugin Manifest plus its content identity.
35#[derive(Clone, Debug)]
36pub struct ManifestDocument {
37    value: PluginManifest,
38    bytes: Vec<u8>,
39    digest: String,
40}
41
42impl ManifestDocument {
43    /// Strictly parses and validates one publisher Manifest.
44    pub fn parse(input: &[u8]) -> Result<Self, BundleError> {
45        let value = strict_json::<PluginManifest>(input)?;
46        Self::from_value(value)
47    }
48
49    /// Validates and canonicalizes one typed publisher Manifest.
50    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/// Verified closure of one immutable Plugin Release.
79#[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/// A Plugin authoring or immutable Bundle invariant failed closed.
89#[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
115/// Builds an immutable Bundle directory without executing publisher code.
116pub 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
189/// Verifies an already materialized directory as an exact immutable Bundle closure.
190pub 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
199/// Verifies exact declared bytes without performing admission or granting authority.
200pub 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/// Validates publisher-owned Manifest semantics independently of Host policy.
272#[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
509/// Computes the canonical digest syntax used by Plugin Release documents and files.
510pub 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}