Skip to main content

packc/
validator.rs

1#![forbid(unsafe_code)]
2
3use std::collections::BTreeSet;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::time::Duration;
7
8use anyhow::{Context, Result, anyhow};
9use clap::ValueEnum;
10use greentic_distributor_client::{DistClient, DistOptions};
11use greentic_pack::{PackLoad, SigningPolicy, open_pack};
12use greentic_types::pack_manifest::{ExtensionInline, PackManifest};
13use greentic_types::provider::PROVIDER_EXTENSION_ID;
14use greentic_types::validate::{Diagnostic, Severity};
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17use wasmtime::component::{Component, Linker};
18use wasmtime::{Config, Engine, Store};
19use wasmtime_wasi::p2::add_to_linker_sync;
20use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
21
22use crate::runtime::{NetworkPolicy, RuntimeContext};
23
24const PACK_VALIDATOR_WORLDS: [&str; 2] = [
25    "greentic:pack-validate@0.1.0/pack-validator",
26    "greentic:pack-validate/pack-validator@0.1.0",
27];
28pub const DEFAULT_VALIDATOR_ALLOW: &str = "oci://ghcr.io/greentic-ai/validators/";
29const DEFAULT_TIMEOUT_SECS: u64 = 2;
30const DEFAULT_MAX_MEMORY_BYTES: usize = 64 * 1024 * 1024;
31
32mod bindings {
33    wasmtime::component::bindgen!({
34        inline: r#"
35        package greentic:pack-validate@0.1.0;
36
37        interface validator {
38          record diagnostic {
39            severity: string,
40            code: string,
41            message: string,
42            path: option<string>,
43            hint: option<string>,
44          }
45
46          record pack-inputs {
47            manifest-cbor: list<u8>,
48            sbom-json: string,
49            file-index: list<string>,
50          }
51
52          applies: func(inputs: pack-inputs) -> bool;
53          validate: func(inputs: pack-inputs) -> list<diagnostic>;
54        }
55
56        world pack-validator {
57          export validator;
58        }
59        "#,
60    });
61}
62
63use bindings::PackValidator;
64use bindings::exports::greentic::pack_validate::validator::{
65    Diagnostic as WasmDiagnostic, PackInputs,
66};
67
68#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
69pub enum ValidatorPolicy {
70    Required,
71    Optional,
72}
73
74impl ValidatorPolicy {
75    pub fn is_required(self) -> bool {
76        matches!(self, ValidatorPolicy::Required)
77    }
78}
79
80#[derive(Clone, Debug)]
81pub struct ValidatorConfig {
82    pub validators_root: PathBuf,
83    pub validator_packs: Vec<String>,
84    pub validator_allow: Vec<String>,
85    pub validator_cache_dir: PathBuf,
86    pub policy: ValidatorPolicy,
87    pub local_validators: Vec<LocalValidator>,
88}
89
90#[derive(Clone, Debug)]
91pub struct LocalValidator {
92    pub component_id: String,
93    pub path: PathBuf,
94}
95
96#[derive(Clone, Debug, Serialize)]
97pub struct ValidatorSourceReport {
98    pub reference: String,
99    pub origin: String,
100    pub status: String,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub message: Option<String>,
103}
104
105#[derive(Clone, Debug)]
106struct ValidatorRef {
107    reference: String,
108    digest: Option<String>,
109    origin: String,
110}
111
112#[derive(Clone, Debug)]
113struct ValidatorComponent {
114    component_id: String,
115    wasm: Vec<u8>,
116}
117
118#[derive(Clone, Debug, Default)]
119pub struct ValidatorRunResult {
120    pub diagnostics: Vec<Diagnostic>,
121    pub sources: Vec<ValidatorSourceReport>,
122    pub missing_required: bool,
123}
124
125pub async fn run_wasm_validators(
126    load: &PackLoad,
127    config: &ValidatorConfig,
128    runtime: &RuntimeContext,
129) -> Result<ValidatorRunResult> {
130    let inputs = build_pack_inputs(load)?;
131
132    let mut result = ValidatorRunResult::default();
133    let mut components = Vec::new();
134
135    for local in &config.local_validators {
136        let reference = format!("local:{}", local.path.display());
137        match fs::read(&local.path) {
138            Ok(bytes) => {
139                components.push(ValidatorComponent {
140                    component_id: local.component_id.clone(),
141                    wasm: bytes,
142                });
143                result.sources.push(ValidatorSourceReport {
144                    reference: reference.clone(),
145                    origin: "local".to_string(),
146                    status: "loaded".to_string(),
147                    message: None,
148                });
149            }
150            Err(err) => {
151                let is_required = config.policy.is_required();
152                if is_required {
153                    result.missing_required = true;
154                    result.diagnostics.push(Diagnostic {
155                        severity: Severity::Error,
156                        code: "PACK_VALIDATOR_REQUIRED".to_string(),
157                        message: format!(
158                            "Validator {} is required but could not be loaded.",
159                            reference
160                        ),
161                        path: None,
162                        hint: Some(err.to_string()),
163                        data: Value::Null,
164                    });
165                } else {
166                    result.diagnostics.push(Diagnostic {
167                        severity: Severity::Warn,
168                        code: "PACK_VALIDATOR_UNAVAILABLE".to_string(),
169                        message: format!("Validator {} could not be loaded; skipping.", reference),
170                        path: None,
171                        hint: Some(err.to_string()),
172                        data: Value::Null,
173                    });
174                }
175                result.sources.push(ValidatorSourceReport {
176                    reference,
177                    origin: "local".to_string(),
178                    status: "failed".to_string(),
179                    message: Some(err.to_string()),
180                });
181            }
182        }
183    }
184
185    let refs = collect_validator_refs(load, config);
186    if refs.is_empty() && components.is_empty() {
187        return Ok(result);
188    }
189
190    for validator_ref in refs {
191        match load_validator_components(&validator_ref, config, runtime).await {
192            Ok(mut loaded) => {
193                components.append(&mut loaded);
194                result.sources.push(ValidatorSourceReport {
195                    reference: validator_ref.reference.clone(),
196                    origin: validator_ref.origin.clone(),
197                    status: "loaded".to_string(),
198                    message: None,
199                });
200            }
201            Err(err) => {
202                let is_required = config.policy.is_required();
203                if is_required {
204                    result.missing_required = true;
205                    result.diagnostics.push(Diagnostic {
206                        severity: Severity::Error,
207                        code: "PACK_VALIDATOR_REQUIRED".to_string(),
208                        message: format!(
209                            "Validator {} is required but could not be loaded.",
210                            validator_ref.reference
211                        ),
212                        path: None,
213                        hint: Some(err.to_string()),
214                        data: Value::Null,
215                    });
216                }
217                result.sources.push(ValidatorSourceReport {
218                    reference: validator_ref.reference.clone(),
219                    origin: validator_ref.origin.clone(),
220                    status: "failed".to_string(),
221                    message: Some(err.to_string()),
222                });
223                if !is_required {
224                    result.diagnostics.push(Diagnostic {
225                        severity: Severity::Warn,
226                        code: "PACK_VALIDATOR_UNAVAILABLE".to_string(),
227                        message: format!(
228                            "Validator {} could not be loaded; skipping.",
229                            validator_ref.reference
230                        ),
231                        path: None,
232                        hint: Some(err.to_string()),
233                        data: Value::Null,
234                    });
235                }
236            }
237        }
238    }
239
240    if components.is_empty() {
241        return Ok(result);
242    }
243
244    let engine = build_engine()?;
245    let mut linker = Linker::new(&engine);
246    add_to_linker_sync(&mut linker)?;
247
248    for component in components {
249        let validator_result = run_component_validator(&engine, &mut linker, &component, &inputs);
250        match validator_result {
251            Ok(mut diags) => result.diagnostics.append(&mut diags),
252            Err(err) => {
253                result.diagnostics.push(Diagnostic {
254                    severity: Severity::Warn,
255                    code: "PACK_VALIDATOR_FAILED".to_string(),
256                    message: format!(
257                        "Validator component {} failed to execute.",
258                        component.component_id
259                    ),
260                    path: None,
261                    hint: Some(err.to_string()),
262                    data: Value::Null,
263                });
264            }
265        }
266    }
267
268    Ok(result)
269}
270
271fn build_engine() -> Result<Engine> {
272    let mut config = Config::new();
273    config.wasm_component_model(true);
274    config.epoch_interruption(true);
275    Engine::new(&config)
276}
277
278fn run_component_validator(
279    engine: &Engine,
280    linker: &mut Linker<ValidatorCtx>,
281    component: &ValidatorComponent,
282    inputs: &PackInputs,
283) -> Result<Vec<Diagnostic>> {
284    let component = Component::from_binary(engine, &component.wasm)
285        .context("failed to load validator component")?;
286
287    let mut store = Store::new(engine, ValidatorCtx::new());
288    store.limiter(|ctx| &mut ctx.limits);
289    store.set_epoch_deadline(1);
290
291    let validator = PackValidator::instantiate(&mut store, &component, linker)
292        .context("failed to instantiate validator component")?;
293    let guest = validator.greentic_pack_validate_validator();
294
295    let engine = engine.clone();
296    let timeout = Duration::from_secs(DEFAULT_TIMEOUT_SECS);
297    std::thread::spawn(move || {
298        std::thread::sleep(timeout);
299        engine.increment_epoch();
300    });
301
302    let applies = guest
303        .call_applies(&mut store, inputs)
304        .context("validator applies call failed")?;
305    if !applies {
306        return Ok(Vec::new());
307    }
308
309    let diags = guest
310        .call_validate(&mut store, inputs)
311        .context("validator validate call failed")?;
312    Ok(convert_diagnostics(diags))
313}
314
315fn convert_diagnostics(diags: Vec<WasmDiagnostic>) -> Vec<Diagnostic> {
316    diags
317        .into_iter()
318        .map(|diag| Diagnostic {
319            severity: match diag.severity.as_str() {
320                "info" => Severity::Info,
321                "warn" => Severity::Warn,
322                "error" => Severity::Error,
323                _ => Severity::Warn,
324            },
325            code: diag.code,
326            message: diag.message,
327            path: diag.path,
328            hint: diag.hint,
329            data: Value::Null,
330        })
331        .collect()
332}
333
334fn build_pack_inputs(load: &PackLoad) -> Result<PackInputs> {
335    let manifest_bytes = load.files.get("manifest.cbor").cloned().unwrap_or_default();
336
337    let sbom_json = if let Some(bytes) = load.files.get("sbom.json") {
338        String::from_utf8_lossy(bytes).to_string()
339    } else if let Some(bytes) = load.files.get("sbom.cbor") {
340        let value: Value = serde_cbor::from_slice(bytes).context("sbom.cbor is not valid CBOR")?;
341        serde_json::to_string(&value).context("failed to serialize sbom json")?
342    } else {
343        serde_json::to_string(&serde_json::json!({"files": load.sbom}))
344            .context("failed to serialize sbom json")?
345    };
346
347    let file_index = load.files.keys().cloned().collect();
348
349    Ok(PackInputs {
350        manifest_cbor: manifest_bytes,
351        sbom_json,
352        file_index,
353    })
354}
355
356fn collect_validator_refs(load: &PackLoad, config: &ValidatorConfig) -> Vec<ValidatorRef> {
357    let mut refs = Vec::new();
358
359    for reference in &config.validator_packs {
360        refs.push(ValidatorRef {
361            reference: reference.clone(),
362            digest: None,
363            origin: "cli".to_string(),
364        });
365    }
366
367    if config.validators_root.exists()
368        && let Ok(entries) = std::fs::read_dir(&config.validators_root)
369    {
370        for entry in entries.flatten() {
371            let path = entry.path();
372            if path.extension().and_then(|ext| ext.to_str()) == Some("gtpack") {
373                refs.push(ValidatorRef {
374                    reference: path.to_string_lossy().to_string(),
375                    digest: None,
376                    origin: "validators-root".to_string(),
377                });
378            }
379        }
380    }
381
382    if let Some(manifest) = load.gpack_manifest.as_ref() {
383        refs.extend(validator_refs_from_manifest(manifest));
384    }
385    refs.extend(validator_refs_from_annotations(load));
386
387    let mut seen = BTreeSet::new();
388    refs.retain(|r| seen.insert((r.reference.clone(), r.digest.clone())));
389    refs
390}
391
392fn validator_refs_from_manifest(manifest: &PackManifest) -> Vec<ValidatorRef> {
393    let mut refs = Vec::new();
394    let Some(extensions) = manifest.extensions.as_ref() else {
395        return refs;
396    };
397    let Some(extension) = extensions.get(PROVIDER_EXTENSION_ID) else {
398        return refs;
399    };
400    let Some(inline) = extension.inline.as_ref() else {
401        return refs;
402    };
403
404    let value = match inline {
405        ExtensionInline::Other(value) => value.clone(),
406        _ => serde_json::to_value(inline).unwrap_or(Value::Null),
407    };
408
409    if let Some(reference) = value.get("validator_ref").and_then(Value::as_str) {
410        let digest = value
411            .get("validator_digest")
412            .and_then(Value::as_str)
413            .map(|s| s.to_string());
414        refs.push(ValidatorRef {
415            reference: reference.to_string(),
416            digest,
417            origin: "provider-extension".to_string(),
418        });
419    }
420
421    if let Some(values) = value.get("validator_refs").and_then(Value::as_array) {
422        for entry in values {
423            if let Some(reference) = entry.as_str() {
424                refs.push(ValidatorRef {
425                    reference: reference.to_string(),
426                    digest: None,
427                    origin: "provider-extension".to_string(),
428                });
429            }
430        }
431    }
432
433    if let Some(providers) = value.get("providers").and_then(Value::as_array) {
434        for provider in providers {
435            if let Some(reference) = provider.get("validator_ref").and_then(Value::as_str) {
436                let digest = provider
437                    .get("validator_digest")
438                    .and_then(Value::as_str)
439                    .map(|s| s.to_string());
440                refs.push(ValidatorRef {
441                    reference: reference.to_string(),
442                    digest,
443                    origin: "provider-extension".to_string(),
444                });
445            }
446        }
447    }
448
449    refs
450}
451
452fn validator_refs_from_annotations(load: &PackLoad) -> Vec<ValidatorRef> {
453    let mut refs = Vec::new();
454    if let Some(value) = load.manifest.meta.annotations.get("greentic.validators") {
455        match value {
456            Value::String(reference) => refs.push(ValidatorRef {
457                reference: reference.clone(),
458                digest: None,
459                origin: "annotations".to_string(),
460            }),
461            Value::Array(items) => {
462                for item in items {
463                    if let Some(reference) = item.as_str() {
464                        refs.push(ValidatorRef {
465                            reference: reference.to_string(),
466                            digest: None,
467                            origin: "annotations".to_string(),
468                        });
469                    } else if let Some(reference) = item.get("ref").and_then(Value::as_str) {
470                        let digest = item
471                            .get("digest")
472                            .and_then(Value::as_str)
473                            .map(|s| s.to_string());
474                        refs.push(ValidatorRef {
475                            reference: reference.to_string(),
476                            digest,
477                            origin: "annotations".to_string(),
478                        });
479                    }
480                }
481            }
482            _ => {}
483        }
484    }
485    refs
486}
487
488async fn load_validator_components(
489    validator_ref: &ValidatorRef,
490    config: &ValidatorConfig,
491    runtime: &RuntimeContext,
492) -> Result<Vec<ValidatorComponent>> {
493    let reference = validator_ref.reference.as_str();
494    if reference.starts_with("oci://") {
495        return load_validator_components_from_oci(validator_ref, config, runtime).await;
496    }
497
498    let path = Path::new(reference);
499    if path.exists() {
500        if path.is_dir() {
501            return load_validator_components_from_dir(path);
502        }
503        if path.extension().and_then(|ext| ext.to_str()) == Some("gtpack") {
504            return load_validator_components_from_pack(path);
505        }
506        if path.extension().and_then(|ext| ext.to_str()) == Some("wasm") {
507            let wasm = std::fs::read(path).with_context(|| {
508                format!("failed to read validator component {}", path.display())
509            })?;
510            return Ok(vec![ValidatorComponent {
511                component_id: path
512                    .file_stem()
513                    .and_then(|name| name.to_str())
514                    .unwrap_or("validator")
515                    .to_string(),
516                wasm,
517            }]);
518        }
519    }
520
521    Err(anyhow!(
522        "validator reference {} could not be resolved",
523        reference
524    ))
525}
526
527fn load_validator_components_from_dir(path: &Path) -> Result<Vec<ValidatorComponent>> {
528    let mut components = Vec::new();
529    for entry in std::fs::read_dir(path)
530        .with_context(|| format!("failed to read validators root {}", path.display()))?
531    {
532        let entry = entry?;
533        let path = entry.path();
534        if path.extension().and_then(|ext| ext.to_str()) == Some("gtpack") {
535            components.extend(load_validator_components_from_pack(&path)?);
536        }
537    }
538    Ok(components)
539}
540
541fn load_validator_components_from_pack(path: &Path) -> Result<Vec<ValidatorComponent>> {
542    let load = open_pack(path, SigningPolicy::DevOk)
543        .map_err(|err| anyhow!(err.message))
544        .with_context(|| format!("failed to open validator pack {}", path.display()))?;
545    let mut components = Vec::new();
546
547    if let Some(manifest) = load.gpack_manifest.as_ref() {
548        for component in &manifest.components {
549            if !PACK_VALIDATOR_WORLDS
550                .iter()
551                .any(|world| world == &component.world)
552            {
553                continue;
554            }
555            let wasm_paths = [
556                format!(
557                    "components/{}@{}/component.wasm",
558                    component.id.as_str(),
559                    component.version
560                ),
561                format!("components/{}.wasm", component.id.as_str()),
562            ];
563            let wasm = wasm_paths
564                .iter()
565                .find_map(|path| load.files.get(path).cloned())
566                .ok_or_else(|| {
567                    anyhow!(
568                        "validator pack missing {} for component {}",
569                        wasm_paths.join(" or "),
570                        component.id.as_str()
571                    )
572                })?;
573            components.push(ValidatorComponent {
574                component_id: component.id.as_str().to_string(),
575                wasm,
576            });
577        }
578    } else {
579        for component in &load.manifest.components {
580            let Some(world) = component.world.as_deref() else {
581                continue;
582            };
583            if !PACK_VALIDATOR_WORLDS.iter().any(|item| item == &world) {
584                continue;
585            }
586            let Some(wasm) = load.files.get(&component.file_wasm).cloned() else {
587                return Err(anyhow!(
588                    "validator pack missing {} for component {}",
589                    component.file_wasm,
590                    component.name
591                ));
592            };
593            components.push(ValidatorComponent {
594                component_id: component.name.clone(),
595                wasm,
596            });
597        }
598    }
599
600    if components.is_empty() {
601        return Err(anyhow!(
602            "validator pack {} contains no pack-validator components",
603            path.display()
604        ));
605    }
606
607    Ok(components)
608}
609
610async fn load_validator_components_from_oci(
611    validator_ref: &ValidatorRef,
612    config: &ValidatorConfig,
613    runtime: &RuntimeContext,
614) -> Result<Vec<ValidatorComponent>> {
615    let allowed = if config.validator_allow.is_empty() {
616        vec![DEFAULT_VALIDATOR_ALLOW.to_string()]
617    } else {
618        config.validator_allow.clone()
619    };
620    if !allowed
621        .iter()
622        .any(|prefix| validator_ref.reference.starts_with(prefix))
623    {
624        return Err(anyhow!(
625            "validator ref {} is not in allowlist",
626            validator_ref.reference
627        ));
628    }
629
630    let dist = DistClient::new(DistOptions {
631        cache_dir: config.validator_cache_dir.clone(),
632        allow_tags: true,
633        offline: runtime.network_policy() == NetworkPolicy::Offline,
634        allow_insecure_local_http: false,
635    });
636
637    let resolved = if runtime.network_policy() == NetworkPolicy::Offline {
638        dist.ensure_cached(&validator_ref.reference)
639            .await
640            .context("validator ref not cached")?
641    } else {
642        dist.resolve_ref(&validator_ref.reference)
643            .await
644            .context("failed to fetch validator ref")?
645    };
646
647    if let Some(expected) = validator_ref.digest.as_ref()
648        && resolved.digest != *expected
649    {
650        return Err(anyhow!(
651            "validator digest mismatch (expected {}, got {})",
652            expected,
653            resolved.digest
654        ));
655    }
656
657    let cache_path = resolved
658        .cache_path
659        .as_ref()
660        .ok_or_else(|| anyhow!("validator ref resolved without cache path"))?;
661    let bytes = std::fs::read(cache_path)
662        .with_context(|| format!("failed to read validator cache {}", cache_path.display()))?;
663
664    if is_zip_archive(&bytes) {
665        let temp = tempfile::NamedTempFile::new()?;
666        std::fs::write(temp.path(), &bytes)?;
667        return load_validator_components_from_pack(temp.path());
668    }
669
670    Ok(vec![ValidatorComponent {
671        component_id: "validator".to_string(),
672        wasm: bytes,
673    }])
674}
675
676fn is_zip_archive(bytes: &[u8]) -> bool {
677    bytes.len() >= 4 && bytes[0] == 0x50 && bytes[1] == 0x4b && bytes[2] == 0x03 && bytes[3] == 0x04
678}
679
680struct ValidatorCtx {
681    table: ResourceTable,
682    wasi: WasiCtx,
683    limits: wasmtime::StoreLimits,
684}
685
686impl ValidatorCtx {
687    fn new() -> Self {
688        let limits = wasmtime::StoreLimitsBuilder::new()
689            .memory_size(DEFAULT_MAX_MEMORY_BYTES)
690            .build();
691        let wasi = WasiCtxBuilder::new()
692            .inherit_stdout()
693            .inherit_stderr()
694            .build();
695        Self {
696            table: ResourceTable::new(),
697            wasi,
698            limits,
699        }
700    }
701}
702
703impl WasiView for ValidatorCtx {
704    fn ctx(&mut self) -> WasiCtxView<'_> {
705        WasiCtxView {
706            table: &mut self.table,
707            ctx: &mut self.wasi,
708        }
709    }
710}
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715    use greentic_pack::builder::SbomEntry;
716    use greentic_types::{
717        ComponentCapabilities, ComponentId, ComponentManifest, ComponentProfiles, PackId, PackKind,
718        PackManifest, PackSignatures, ResourceHints, encode_pack_manifest,
719    };
720    use semver::Version;
721    use serde::Serialize;
722    use std::collections::BTreeMap;
723    use std::fs::File;
724    use std::io::Write;
725    use tempfile::tempdir;
726    use zip::write::FileOptions;
727    use zip::{CompressionMethod, ZipWriter};
728
729    #[derive(Serialize)]
730    struct SbomDocument {
731        format: String,
732        files: Vec<SbomEntry>,
733    }
734
735    #[test]
736    fn validator_pack_accepts_id_wasm_from_pack_manifest() {
737        let temp = tempdir().expect("temp dir");
738        let pack_path = temp.path().join("validator.gtpack");
739        let component_id = ComponentId::new("messaging-validator").expect("component id");
740        let component_version = Version::parse("0.1.0").expect("component version");
741        let component = ComponentManifest {
742            id: component_id.clone(),
743            version: component_version.clone(),
744            supports: Vec::new(),
745            world: PACK_VALIDATOR_WORLDS[0].to_string(),
746            profiles: ComponentProfiles::default(),
747            capabilities: ComponentCapabilities::default(),
748            configurators: None,
749            operations: Vec::new(),
750            config_schema: None,
751            resources: ResourceHints::default(),
752            dev_flows: BTreeMap::new(),
753        };
754        let manifest = PackManifest {
755            schema_version: "pack-v1".to_string(),
756            pack_id: PackId::new("dev.local.validator").expect("pack id"),
757            name: None,
758            version: component_version,
759            kind: PackKind::Provider,
760            publisher: "test".to_string(),
761            components: vec![component],
762            flows: Vec::new(),
763            dependencies: Vec::new(),
764            capabilities: Vec::new(),
765            secret_requirements: Vec::new(),
766            signatures: PackSignatures::default(),
767            bootstrap: None,
768            extensions: None,
769        };
770        let manifest_cbor = encode_pack_manifest(&manifest).expect("encode manifest");
771        let wasm_bytes = b"validator wasm";
772        let wasm_path = format!("components/{}.wasm", component_id.as_str());
773        let sbom_entries = vec![
774            SbomEntry {
775                path: "manifest.cbor".to_string(),
776                size: manifest_cbor.len() as u64,
777                hash_blake3: blake3::hash(&manifest_cbor).to_hex().to_string(),
778                media_type: "application/cbor".to_string(),
779            },
780            SbomEntry {
781                path: wasm_path.clone(),
782                size: wasm_bytes.len() as u64,
783                hash_blake3: blake3::hash(wasm_bytes).to_hex().to_string(),
784                media_type: "application/wasm".to_string(),
785            },
786        ];
787        let sbom = SbomDocument {
788            format: "greentic-sbom-v1".to_string(),
789            files: sbom_entries,
790        };
791        let sbom_cbor = serde_cbor::to_vec(&sbom).expect("encode sbom");
792
793        let file = File::create(&pack_path).expect("create pack");
794        let mut writer = ZipWriter::new(file);
795        let options = FileOptions::<()>::default().compression_method(CompressionMethod::Stored);
796        writer
797            .start_file("manifest.cbor", options)
798            .expect("start manifest");
799        writer.write_all(&manifest_cbor).expect("write manifest");
800        writer.start_file(&wasm_path, options).expect("start wasm");
801        writer.write_all(wasm_bytes).expect("write wasm");
802        writer.start_file("sbom.cbor", options).expect("start sbom");
803        writer.write_all(&sbom_cbor).expect("write sbom");
804        writer.finish().expect("finish pack");
805
806        let components =
807            load_validator_components_from_pack(&pack_path).expect("load validator components");
808        assert_eq!(components.len(), 1);
809        assert_eq!(components[0].component_id, "messaging-validator");
810        assert_eq!(components[0].wasm, wasm_bytes);
811    }
812}