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_path = format!(
497                "components/{}@{}/component.wasm",
498                component.id.as_str(),
499                component.version
500            );
501            let Some(wasm) = load.files.get(&wasm_path).cloned() else {
502                return Err(anyhow!(
503                    "validator pack missing {} for component {}",
504                    wasm_path,
505                    component.id.as_str()
506                ));
507            };
508            components.push(ValidatorComponent {
509                component_id: component.id.as_str().to_string(),
510                wasm,
511            });
512        }
513    } else {
514        for component in &load.manifest.components {
515            let Some(world) = component.world.as_deref() else {
516                continue;
517            };
518            if !PACK_VALIDATOR_WORLDS.iter().any(|item| item == &world) {
519                continue;
520            }
521            let Some(wasm) = load.files.get(&component.file_wasm).cloned() else {
522                return Err(anyhow!(
523                    "validator pack missing {} for component {}",
524                    component.file_wasm,
525                    component.name
526                ));
527            };
528            components.push(ValidatorComponent {
529                component_id: component.name.clone(),
530                wasm,
531            });
532        }
533    }
534
535    if components.is_empty() {
536        return Err(anyhow!(
537            "validator pack {} contains no pack-validator components",
538            path.display()
539        ));
540    }
541
542    Ok(components)
543}
544
545async fn load_validator_components_from_oci(
546    validator_ref: &ValidatorRef,
547    config: &ValidatorConfig,
548    runtime: &RuntimeContext,
549) -> Result<Vec<ValidatorComponent>> {
550    let allowed = if config.validator_allow.is_empty() {
551        vec![DEFAULT_VALIDATOR_ALLOW.to_string()]
552    } else {
553        config.validator_allow.clone()
554    };
555    if !allowed
556        .iter()
557        .any(|prefix| validator_ref.reference.starts_with(prefix))
558    {
559        return Err(anyhow!(
560            "validator ref {} is not in allowlist",
561            validator_ref.reference
562        ));
563    }
564
565    let dist = DistClient::new(DistOptions {
566        cache_dir: config.validator_cache_dir.clone(),
567        allow_tags: true,
568        offline: runtime.network_policy() == NetworkPolicy::Offline,
569        allow_insecure_local_http: false,
570    });
571
572    let resolved = if runtime.network_policy() == NetworkPolicy::Offline {
573        dist.ensure_cached(&validator_ref.reference)
574            .await
575            .context("validator ref not cached")?
576    } else {
577        dist.resolve_ref(&validator_ref.reference)
578            .await
579            .context("failed to fetch validator ref")?
580    };
581
582    if let Some(expected) = validator_ref.digest.as_ref()
583        && resolved.digest != *expected
584    {
585        return Err(anyhow!(
586            "validator digest mismatch (expected {}, got {})",
587            expected,
588            resolved.digest
589        ));
590    }
591
592    let cache_path = resolved
593        .cache_path
594        .as_ref()
595        .ok_or_else(|| anyhow!("validator ref resolved without cache path"))?;
596    let bytes = std::fs::read(cache_path)
597        .with_context(|| format!("failed to read validator cache {}", cache_path.display()))?;
598
599    if is_zip_archive(&bytes) {
600        let temp = tempfile::NamedTempFile::new()?;
601        std::fs::write(temp.path(), &bytes)?;
602        return load_validator_components_from_pack(temp.path());
603    }
604
605    Ok(vec![ValidatorComponent {
606        component_id: "validator".to_string(),
607        wasm: bytes,
608    }])
609}
610
611fn is_zip_archive(bytes: &[u8]) -> bool {
612    bytes.len() >= 4 && bytes[0] == 0x50 && bytes[1] == 0x4b && bytes[2] == 0x03 && bytes[3] == 0x04
613}
614
615struct ValidatorCtx {
616    table: ResourceTable,
617    wasi: WasiCtx,
618    limits: wasmtime::StoreLimits,
619}
620
621impl ValidatorCtx {
622    fn new() -> Self {
623        let limits = wasmtime::StoreLimitsBuilder::new()
624            .memory_size(DEFAULT_MAX_MEMORY_BYTES)
625            .build();
626        let wasi = WasiCtxBuilder::new()
627            .inherit_stdout()
628            .inherit_stderr()
629            .build();
630        Self {
631            table: ResourceTable::new(),
632            wasi,
633            limits,
634        }
635    }
636}
637
638impl WasiView for ValidatorCtx {
639    fn ctx(&mut self) -> WasiCtxView<'_> {
640        WasiCtxView {
641            table: &mut self.table,
642            ctx: &mut self.wasi,
643        }
644    }
645}