Skip to main content

packc/
validator.rs

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