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}