1#![forbid(unsafe_code)]
2
3use std::collections::{BTreeMap, HashMap};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result, anyhow, bail};
8use greentic_distributor_client::{DistClient, DistOptions};
9use greentic_flow::wizard_ops::{WizardMode, decode_component_qa_spec, fetch_wizard_spec};
10use greentic_pack::PackLoad;
11use greentic_pack::pack_lock::{LockedComponent, PackLockV1, read_pack_lock, validate_pack_lock};
12use greentic_types::cbor::canonical;
13use greentic_types::pack::extensions::component_sources::{
14 ArtifactLocationV1, ComponentSourceEntryV1, ComponentSourcesV1,
15};
16use greentic_types::schemas::common::schema_ir::{AdditionalProperties, SchemaIr};
17use greentic_types::schemas::component::v0_6_0::{ComponentDescribe, schema_hash};
18use greentic_types::validate::{Diagnostic, Severity};
19use serde_json::{Value, json};
20use sha2::{Digest, Sha256};
21use tokio::runtime::Handle;
22use wasmtime::Engine;
23use wasmtime::component::{Component as WasmtimeComponent, Linker};
24
25use crate::component_host_stubs::{
26 DescribeHostState, add_describe_host_imports, stub_remaining_imports,
27};
28use crate::runtime::{NetworkPolicy, RuntimeContext};
29
30pub struct PackLockDoctorInput<'a> {
31 pub load: &'a PackLoad,
32 pub pack_dir: Option<&'a Path>,
33 pub runtime: &'a RuntimeContext,
34 pub allow_oci_tags: bool,
35 pub use_describe_cache: bool,
36 pub online: bool,
37}
38
39pub struct PackLockDoctorOutput {
40 pub diagnostics: Vec<Diagnostic>,
41 pub has_errors: bool,
42}
43
44#[derive(Clone)]
45struct ComponentDiagnostic {
46 component_id: String,
47 diagnostic: Diagnostic,
48}
49
50struct WasmSource {
51 bytes: Vec<u8>,
52 source_path: Option<PathBuf>,
53 describe_bytes: Option<Vec<u8>>,
54}
55
56struct DescribeResolution {
57 describe: ComponentDescribe,
58 requires_typed_instance: bool,
59}
60
61pub fn run_pack_lock_doctor(input: PackLockDoctorInput<'_>) -> Result<PackLockDoctorOutput> {
62 let mut diagnostics: Vec<ComponentDiagnostic> = Vec::new();
63 let mut has_errors = false;
64
65 let pack_lock = match load_pack_lock(input.load, input.pack_dir) {
66 Ok(Some(lock)) => lock,
67 Ok(None) => {
68 diagnostics.push(ComponentDiagnostic {
69 component_id: String::new(),
70 diagnostic: Diagnostic {
71 severity: Severity::Warn,
72 code: "PACK_LOCK_MISSING".to_string(),
73 message: "pack.lock.cbor missing; skipping pack lock doctor checks".to_string(),
74 path: Some("pack.lock.cbor".to_string()),
75 hint: Some(
76 "run `greentic-pack resolve` to generate pack.lock.cbor".to_string(),
77 ),
78 data: Value::Null,
79 },
80 });
81 return Ok(finish_diagnostics(diagnostics));
82 }
83 Err(err) => {
84 diagnostics.push(ComponentDiagnostic {
85 component_id: String::new(),
86 diagnostic: Diagnostic {
87 severity: Severity::Error,
88 code: "PACK_LOCK_INVALID".to_string(),
89 message: format!("failed to load pack.lock.cbor: {err}"),
90 path: Some("pack.lock.cbor".to_string()),
91 hint: Some("regenerate the lock with `greentic-pack resolve`".to_string()),
92 data: Value::Null,
93 },
94 });
95 return Ok(finish_diagnostics(diagnostics));
96 }
97 };
98
99 let component_sources = match load_component_sources(input.load) {
100 Ok(sources) => sources,
101 Err(err) => {
102 diagnostics.push(ComponentDiagnostic {
103 component_id: String::new(),
104 diagnostic: Diagnostic {
105 severity: Severity::Warn,
106 code: "PACK_LOCK_COMPONENT_SOURCES_INVALID".to_string(),
107 message: format!("component sources extension invalid: {err}"),
108 path: Some("manifest.cbor".to_string()),
109 hint: Some("rebuild the pack to refresh component sources".to_string()),
110 data: Value::Null,
111 },
112 });
113 None
114 }
115 };
116
117 let component_sources_map = build_component_sources_map(component_sources.as_ref());
118 let manifest_map: HashMap<_, _> = input
119 .load
120 .manifest
121 .components
122 .iter()
123 .map(|entry| (entry.name.clone(), entry))
124 .collect();
125
126 let engine = Engine::default();
127
128 for (component_id, locked) in &pack_lock.components {
129 if locked.abi_version != "0.6.0" {
130 continue;
131 }
132
133 let wasm = match resolve_component_wasm(
134 &input,
135 &manifest_map,
136 &component_sources_map,
137 component_id,
138 locked,
139 ) {
140 Ok(wasm) => wasm,
141 Err(err) => {
142 has_errors = true;
143 diagnostics.push(component_diag(
144 component_id,
145 Severity::Error,
146 "PACK_LOCK_COMPONENT_WASM_MISSING",
147 format!("component wasm unavailable: {err}"),
148 Some(format!("components/{component_id}")),
149 Some(
150 "bundle artifacts into the pack or allow online resolution with --online"
151 .to_string(),
152 ),
153 Value::Null,
154 ));
155 continue;
156 }
157 };
158
159 let digest = format!("sha256:{}", hex::encode(Sha256::digest(&wasm.bytes)));
160 if digest != locked.resolved_digest {
161 has_errors = true;
162 diagnostics.push(component_diag(
163 component_id,
164 Severity::Error,
165 "PACK_LOCK_COMPONENT_DIGEST_MISMATCH",
166 "resolved_digest does not match component bytes".to_string(),
167 Some(format!("components/{component_id}")),
168 Some("re-run `greentic-pack resolve` after updating components".to_string()),
169 json!({ "expected": locked.resolved_digest, "actual": digest }),
170 ));
171 }
172
173 let describe_resolution = match describe_component_with_cache(
174 &engine,
175 &wasm,
176 input.use_describe_cache,
177 component_id,
178 ) {
179 Ok(describe) => describe,
180 Err(err) => {
181 has_errors = true;
182 diagnostics.push(component_diag(
183 component_id,
184 Severity::Error,
185 "PACK_LOCK_COMPONENT_DESCRIBE_FAILED",
186 format!("describe() failed: {err}"),
187 Some(format!("components/{component_id}")),
188 Some("ensure the component exports greentic:component@0.6.0".to_string()),
189 Value::Null,
190 ));
191 continue;
192 }
193 };
194 let describe = describe_resolution.describe;
195
196 if describe.info.id != locked.component_id {
197 has_errors = true;
198 diagnostics.push(component_diag(
199 component_id,
200 Severity::Error,
201 "PACK_LOCK_COMPONENT_ID_MISMATCH",
202 "describe id does not match pack.lock component_id".to_string(),
203 Some(format!("components/{component_id}")),
204 None,
205 json!({ "describe_id": describe.info.id, "component_id": locked.component_id }),
206 ));
207 }
208
209 let describe_hash = compute_describe_hash(&describe)?;
210 if describe_hash != locked.describe_hash {
211 has_errors = true;
212 diagnostics.push(component_diag(
213 component_id,
214 Severity::Error,
215 "PACK_LOCK_DESCRIBE_HASH_MISMATCH",
216 "describe_hash does not match describe() output".to_string(),
217 Some(format!("components/{component_id}")),
218 Some("re-run `greentic-pack resolve` after updating components".to_string()),
219 json!({ "expected": locked.describe_hash, "actual": describe_hash }),
220 ));
221 }
222
223 let mut describe_ops = BTreeMap::new();
224 for op in &describe.operations {
225 describe_ops.insert(op.id.clone(), op);
226 }
227
228 for op in &describe.operations {
229 let recomputed =
230 match schema_hash(&op.input.schema, &op.output.schema, &describe.config_schema) {
231 Ok(hash) => hash,
232 Err(err) => {
233 has_errors = true;
234 diagnostics.push(component_diag(
235 component_id,
236 Severity::Error,
237 "PACK_LOCK_SCHEMA_HASH_COMPUTE_FAILED",
238 format!("schema_hash failed for {}: {err}", op.id),
239 Some(format!(
240 "components/{component_id}/operations/{}/schema_hash",
241 op.id
242 )),
243 None,
244 Value::Null,
245 ));
246 continue;
247 }
248 };
249
250 if recomputed != op.schema_hash {
251 has_errors = true;
252 diagnostics.push(component_diag(
253 component_id,
254 Severity::Error,
255 "PACK_LOCK_SCHEMA_HASH_DESCRIBE_MISMATCH",
256 "schema_hash does not match describe() payload".to_string(),
257 Some(format!(
258 "components/{component_id}/operations/{}/schema_hash",
259 op.id
260 )),
261 None,
262 json!({ "expected": op.schema_hash, "actual": recomputed }),
263 ));
264 }
265
266 match locked
267 .operations
268 .iter()
269 .find(|entry| entry.operation_id == op.id)
270 {
271 Some(lock_op) => {
272 if recomputed != lock_op.schema_hash {
273 has_errors = true;
274 diagnostics.push(component_diag(
275 component_id,
276 Severity::Error,
277 "PACK_LOCK_SCHEMA_HASH_LOCK_MISMATCH",
278 "schema_hash does not match pack.lock entry".to_string(),
279 Some(format!(
280 "components/{component_id}/operations/{}/schema_hash",
281 op.id
282 )),
283 None,
284 json!({ "expected": lock_op.schema_hash, "actual": recomputed }),
285 ));
286 }
287 }
288 None => {
289 has_errors = true;
290 diagnostics.push(component_diag(
291 component_id,
292 Severity::Error,
293 "PACK_LOCK_OPERATION_MISSING",
294 "operation missing from pack.lock".to_string(),
295 Some(format!("components/{component_id}/operations/{}", op.id)),
296 Some(
297 "re-run `greentic-pack resolve` to refresh lock operations".to_string(),
298 ),
299 Value::Null,
300 ));
301 }
302 }
303
304 validate_schema_ir(
305 component_id,
306 &op.input.schema,
307 &format!(
308 "components/{component_id}/operations/{}/input.schema",
309 op.id
310 ),
311 &mut diagnostics,
312 &mut has_errors,
313 );
314 validate_schema_ir(
315 component_id,
316 &op.output.schema,
317 &format!(
318 "components/{component_id}/operations/{}/output.schema",
319 op.id
320 ),
321 &mut diagnostics,
322 &mut has_errors,
323 );
324 }
325
326 for lock_op in &locked.operations {
327 if !describe_ops.contains_key(&lock_op.operation_id) {
328 has_errors = true;
329 diagnostics.push(component_diag(
330 component_id,
331 Severity::Error,
332 "PACK_LOCK_OPERATION_STALE",
333 "pack.lock operation not present in describe()".to_string(),
334 Some(format!(
335 "components/{component_id}/operations/{}",
336 lock_op.operation_id
337 )),
338 Some("re-run `greentic-pack resolve` to refresh lock operations".to_string()),
339 Value::Null,
340 ));
341 }
342 }
343
344 validate_schema_ir(
345 component_id,
346 &describe.config_schema,
347 &format!("components/{component_id}/config_schema"),
348 &mut diagnostics,
349 &mut has_errors,
350 );
351
352 if let Err(err) = WasmtimeComponent::from_binary(&engine, &wasm.bytes) {
353 has_errors = true;
354 diagnostics.push(component_diag(
355 component_id,
356 Severity::Error,
357 "PACK_LOCK_COMPONENT_DECODE_FAILED",
358 format!("component bytes are not a valid component: {err}"),
359 Some(format!("components/{component_id}")),
360 Some("rebuild the pack with a valid component artifact".to_string()),
361 Value::Null,
362 ));
363 continue;
364 }
365
366 if !describe_resolution.requires_typed_instance {
367 diagnostics.push(component_diag(
368 component_id,
369 Severity::Warn,
370 "PACK_LOCK_COMPONENT_WORLD_FALLBACK",
371 "describe was resolved via fallback; qa/i18n contract checks were skipped"
372 .to_string(),
373 Some(format!("components/{component_id}")),
374 None,
375 Value::Null,
376 ));
377 }
378
379 for (mode, label) in [
380 (WizardMode::Default, "default"),
381 (WizardMode::Setup, "setup"),
382 (WizardMode::Update, "update"),
383 (WizardMode::Remove, "remove"),
384 ] {
385 let spec = match fetch_wizard_spec(&wasm.bytes, mode) {
386 Ok(spec) => spec,
387 Err(err) => {
388 has_errors = true;
389 diagnostics.push(component_diag(
390 component_id,
391 Severity::Error,
392 "PACK_LOCK_QA_SPEC_MISSING",
393 format!("wizard qa_spec fetch failed for {label}: {err}"),
394 Some(format!("components/{component_id}/qa/{label}")),
395 Some(
396 "ensure setup contract and setup.apply_answers are exported"
397 .to_string(),
398 ),
399 Value::Null,
400 ));
401 continue;
402 }
403 };
404 if let Err(err) = decode_component_qa_spec(&spec.qa_spec_cbor, mode) {
405 has_errors = true;
406 diagnostics.push(component_diag(
407 component_id,
408 Severity::Error,
409 "PACK_LOCK_QA_SPEC_DECODE_FAILED",
410 format!("qa_spec decode failed for {label}: {err}"),
411 Some(format!("components/{component_id}/qa/{label}")),
412 Some("ensure qa_spec is valid canonical CBOR/legacy JSON".to_string()),
413 Value::Null,
414 ));
415 }
416 }
417 }
418
419 Ok(finish_diagnostics(diagnostics))
420}
421
422fn finish_diagnostics(mut diagnostics: Vec<ComponentDiagnostic>) -> PackLockDoctorOutput {
423 diagnostics.sort_by(|a, b| {
424 let path_a = a.diagnostic.path.as_deref().unwrap_or_default();
425 let path_b = b.diagnostic.path.as_deref().unwrap_or_default();
426 a.component_id
427 .cmp(&b.component_id)
428 .then_with(|| a.diagnostic.code.cmp(&b.diagnostic.code))
429 .then_with(|| path_a.cmp(path_b))
430 });
431 let mut has_errors = false;
432 let diagnostics: Vec<Diagnostic> = diagnostics
433 .into_iter()
434 .map(|entry| {
435 if matches!(entry.diagnostic.severity, Severity::Error) {
436 has_errors = true;
437 }
438 entry.diagnostic
439 })
440 .collect();
441 PackLockDoctorOutput {
442 diagnostics,
443 has_errors,
444 }
445}
446
447fn load_pack_lock(load: &PackLoad, pack_dir: Option<&Path>) -> Result<Option<PackLockV1>> {
448 if let Some(bytes) = load.files.get("pack.lock.cbor") {
449 return read_pack_lock_from_bytes(bytes).map(Some);
450 }
451 let Some(pack_dir) = pack_dir else {
452 return Ok(None);
453 };
454 let path = pack_dir.join("pack.lock.cbor");
455 if !path.exists() {
456 return Ok(None);
457 }
458 read_pack_lock(&path).map(Some)
459}
460
461fn read_pack_lock_from_bytes(bytes: &[u8]) -> Result<PackLockV1> {
462 canonical::ensure_canonical(bytes).context("pack.lock.cbor must be canonical")?;
463 let lock: PackLockV1 = canonical::from_cbor(bytes).context("decode pack.lock.cbor")?;
464 validate_pack_lock(&lock)?;
465 Ok(lock)
466}
467
468fn load_component_sources(load: &PackLoad) -> Result<Option<ComponentSourcesV1>> {
469 let Some(manifest) = load.gpack_manifest.as_ref() else {
470 return Ok(None);
471 };
472 manifest
473 .get_component_sources_v1()
474 .map_err(|err| anyhow!(err.to_string()))
475}
476
477fn build_component_sources_map(
478 sources: Option<&ComponentSourcesV1>,
479) -> HashMap<String, ComponentSourceEntryV1> {
480 let mut map = HashMap::new();
481 let Some(sources) = sources else {
482 return map;
483 };
484 for entry in &sources.components {
485 let key = entry
486 .component_id
487 .as_ref()
488 .map(|id| id.to_string())
489 .unwrap_or_else(|| entry.name.clone());
490 map.insert(key, entry.clone());
491 }
492 map
493}
494
495fn resolve_component_wasm(
496 input: &PackLockDoctorInput<'_>,
497 manifest_map: &HashMap<String, &greentic_pack::builder::ComponentEntry>,
498 component_sources_map: &HashMap<String, ComponentSourceEntryV1>,
499 component_id: &str,
500 locked: &LockedComponent,
501) -> Result<WasmSource> {
502 if let Some(entry) = manifest_map.get(component_id) {
503 let logical = entry.file_wasm.clone();
504 if let Some(bytes) = input.load.files.get(&logical) {
505 return Ok(WasmSource {
506 bytes: bytes.clone(),
507 source_path: input
508 .pack_dir
509 .map(|dir| dir.join(&entry.file_wasm))
510 .filter(|path| path.exists()),
511 describe_bytes: load_describe_sidecar_from_pack(input.load, &logical),
512 });
513 }
514 if let Some(pack_dir) = input.pack_dir {
515 let disk_path = pack_dir.join(&entry.file_wasm);
516 if disk_path.exists() {
517 let bytes = fs::read(&disk_path)
518 .with_context(|| format!("read {}", disk_path.display()))?;
519 return Ok(WasmSource {
520 bytes,
521 source_path: Some(disk_path),
522 describe_bytes: None,
523 });
524 }
525 }
526 }
527
528 if let Some(entry) = component_sources_map.get(component_id)
529 && let ArtifactLocationV1::Inline { wasm_path, .. } = &entry.artifact
530 {
531 if let Some(bytes) = input.load.files.get(wasm_path) {
532 return Ok(WasmSource {
533 bytes: bytes.clone(),
534 source_path: input
535 .pack_dir
536 .map(|dir| dir.join(wasm_path))
537 .filter(|path| path.exists()),
538 describe_bytes: load_describe_sidecar_from_pack(input.load, wasm_path),
539 });
540 }
541 if let Some(pack_dir) = input.pack_dir {
542 let disk_path = pack_dir.join(wasm_path);
543 if disk_path.exists() {
544 let bytes = fs::read(&disk_path)
545 .with_context(|| format!("read {}", disk_path.display()))?;
546 return Ok(WasmSource {
547 bytes,
548 source_path: Some(disk_path),
549 describe_bytes: None,
550 });
551 }
552 }
553 }
554
555 if let Some(reference) = locked.r#ref.as_ref()
556 && reference.starts_with("file://")
557 {
558 let path = strip_file_uri_prefix(reference);
559 let bytes = fs::read(path).with_context(|| format!("read {}", path))?;
560 return Ok(WasmSource {
561 bytes,
562 source_path: Some(PathBuf::from(path)),
563 describe_bytes: None,
564 });
565 }
566
567 let reference = locked
568 .r#ref
569 .as_ref()
570 .ok_or_else(|| anyhow!("component {} missing ref", component_id))?;
571 if input.online {
572 input
573 .runtime
574 .require_online("pack lock doctor component download")?;
575 }
576 let offline = !input.online || input.runtime.network_policy() == NetworkPolicy::Offline;
577 let dist = DistClient::new(DistOptions {
578 cache_dir: input.runtime.cache_dir(),
579 allow_tags: input.allow_oci_tags,
580 offline,
581 allow_insecure_local_http: false,
582 ..DistOptions::default()
583 });
584
585 let handle = Handle::try_current().context("component resolution requires a Tokio runtime")?;
586 let resolved = if offline {
587 dist.open_cached(&locked.resolved_digest)
588 .map_err(|err| anyhow!("offline cache miss for {}: {}", reference, err))?
589 } else {
590 let source = dist
591 .parse_source(reference)
592 .map_err(|err| anyhow!("resolve {}: {}", reference, err))?;
593 let descriptor = block_on(
594 &handle,
595 dist.resolve(source, greentic_distributor_client::ResolvePolicy),
596 )
597 .map_err(|err| anyhow!("resolve {}: {}", reference, err))?;
598 block_on(
599 &handle,
600 dist.fetch(&descriptor, greentic_distributor_client::CachePolicy),
601 )
602 .map_err(|err| anyhow!("resolve {}: {}", reference, err))?
603 };
604 let path = resolved
605 .cache_path
606 .ok_or_else(|| anyhow!("resolved component missing path for {}", reference))?;
607 let bytes = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
608 Ok(WasmSource {
609 bytes,
610 source_path: Some(path),
611 describe_bytes: None,
612 })
613}
614
615fn block_on<F, T, E>(handle: &Handle, fut: F) -> std::result::Result<T, E>
616where
617 F: std::future::Future<Output = std::result::Result<T, E>>,
618{
619 tokio::task::block_in_place(|| handle.block_on(fut))
620}
621
622fn describe_component_with_cache(
623 engine: &Engine,
624 wasm: &WasmSource,
625 use_cache: bool,
626 component_id: &str,
627) -> Result<DescribeResolution> {
628 match describe_component(engine, &wasm.bytes) {
629 Ok(describe) => Ok(DescribeResolution {
630 describe,
631 requires_typed_instance: true,
632 }),
633 Err(err) => {
634 if should_fallback_to_untyped_describe(&err)
635 && let Ok(describe) = describe_component_untyped(engine, &wasm.bytes)
636 {
637 return Ok(DescribeResolution {
638 describe,
639 requires_typed_instance: false,
640 });
641 }
642 if use_cache || should_fallback_to_describe_cache(&err) {
643 if let Some(describe) = load_describe_from_cache(
644 wasm.describe_bytes.as_deref(),
645 wasm.source_path.as_deref(),
646 )? {
647 return Ok(DescribeResolution {
648 describe,
649 requires_typed_instance: false,
650 });
651 }
652 bail!("describe failed and no describe cache found for {component_id}: {err}");
653 }
654 Err(err)
655 }
656 }
657}
658
659fn describe_component_untyped(engine: &Engine, bytes: &[u8]) -> Result<ComponentDescribe> {
660 let component = WasmtimeComponent::from_binary(engine, bytes)
661 .map_err(|err| anyhow!("decode component bytes: {err}"))?;
662 let mut store = wasmtime::Store::new(engine, DescribeHostState::default());
663 let mut linker = Linker::new(engine);
664 add_describe_host_imports(&mut linker)?;
665 stub_remaining_imports(&mut linker, &component)?;
668 let instance = linker
669 .instantiate(&mut store, &component)
670 .map_err(|err| anyhow!("instantiate component root world: {err}"))?;
671
672 let descriptor = [
673 "component-descriptor",
674 "greentic:component/component-descriptor",
675 "greentic:component/component-descriptor@0.6.0",
676 ]
677 .iter()
678 .find_map(|name| instance.get_export_index(&mut store, None, name))
679 .ok_or_else(|| anyhow!("missing exported descriptor instance"))?;
680 let describe_export = [
681 "describe",
682 "greentic:component/component-descriptor@0.6.0#describe",
683 ]
684 .iter()
685 .find_map(|name| instance.get_export_index(&mut store, Some(&descriptor), name))
686 .ok_or_else(|| anyhow!("missing exported describe function"))?;
687 let describe_func = instance
688 .get_typed_func::<(), (Vec<u8>,)>(&mut store, &describe_export)
689 .map_err(|err| anyhow!("lookup component-descriptor.describe: {err}"))?;
690 let (describe_bytes,) = describe_func
691 .call(&mut store, ())
692 .map_err(|err| anyhow!("call component-descriptor.describe: {err}"))?;
693 canonical::from_cbor(&describe_bytes).context("decode ComponentDescribe")
694}
695
696fn should_fallback_to_describe_cache(err: &anyhow::Error) -> bool {
697 err.to_string().contains("instantiate component-v0-v6-v0")
698}
699
700fn should_fallback_to_untyped_describe(err: &anyhow::Error) -> bool {
701 err.to_string().contains("instantiate component-v0-v6-v0")
702}
703
704fn describe_component(engine: &Engine, bytes: &[u8]) -> Result<ComponentDescribe> {
705 describe_component_untyped(engine, bytes)
706}
707
708fn load_describe_from_cache(
709 inline_bytes: Option<&[u8]>,
710 source_path: Option<&Path>,
711) -> Result<Option<ComponentDescribe>> {
712 if let Some(bytes) = inline_bytes {
713 canonical::ensure_canonical(bytes).context("describe cache must be canonical")?;
714 let describe =
715 canonical::from_cbor(bytes).context("decode ComponentDescribe from cache")?;
716 return Ok(Some(describe));
717 }
718 if let Some(path) = source_path {
719 let describe_path = PathBuf::from(format!("{}.describe.cbor", path.display()));
720 if !describe_path.exists() {
721 return Ok(None);
722 }
723 let bytes = fs::read(&describe_path)
724 .with_context(|| format!("read {}", describe_path.display()))?;
725 canonical::ensure_canonical(&bytes).context("describe cache must be canonical")?;
726 let describe =
727 canonical::from_cbor(&bytes).context("decode ComponentDescribe from cache")?;
728 return Ok(Some(describe));
729 }
730 Ok(None)
731}
732
733fn load_describe_sidecar_from_pack(load: &PackLoad, logical_path: &str) -> Option<Vec<u8>> {
734 let describe_path = format!("{logical_path}.describe.cbor");
735 load.files.get(&describe_path).cloned()
736}
737
738fn compute_describe_hash(describe: &ComponentDescribe) -> Result<String> {
739 let bytes =
740 canonical::to_canonical_cbor_allow_floats(describe).context("canonicalize describe")?;
741 let digest = Sha256::digest(bytes.as_slice());
742 Ok(hex::encode(digest))
743}
744
745fn validate_schema_ir(
746 component_id: &str,
747 schema: &SchemaIr,
748 path: &str,
749 diagnostics: &mut Vec<ComponentDiagnostic>,
750 has_errors: &mut bool,
751) {
752 match schema {
753 SchemaIr::Object {
754 properties,
755 required,
756 additional,
757 } => {
758 if properties.is_empty()
759 && required.is_empty()
760 && matches!(additional, AdditionalProperties::Allow)
761 {
762 let inside_variant = path.contains("/variants/");
763 if !inside_variant {
764 *has_errors = true;
765 }
766 diagnostics.push(component_diag(
767 component_id,
768 if inside_variant {
769 Severity::Warn
770 } else {
771 Severity::Error
772 },
773 "PACK_LOCK_SCHEMA_UNCONSTRAINED_OBJECT",
774 "object schema is unconstrained".to_string(),
775 Some(path.to_string()),
776 Some("add properties or set additional=forbid".to_string()),
777 Value::Null,
778 ));
779 }
780 for req in required {
781 if !properties.contains_key(req) {
782 *has_errors = true;
783 diagnostics.push(component_diag(
784 component_id,
785 Severity::Error,
786 "PACK_LOCK_SCHEMA_REQUIRED_MISSING",
787 format!("required property `{req}` missing from properties"),
788 Some(path.to_string()),
789 None,
790 Value::Null,
791 ));
792 }
793 }
794 for (name, prop) in properties {
795 let child_path = format!("{path}/properties/{name}");
796 validate_schema_ir(component_id, prop, &child_path, diagnostics, has_errors);
797 }
798 if let AdditionalProperties::Schema(schema) = additional {
799 let child_path = format!("{path}/additional");
800 validate_schema_ir(component_id, schema, &child_path, diagnostics, has_errors);
801 }
802 }
803 SchemaIr::Array {
804 items,
805 min_items,
806 max_items,
807 } => {
808 if let (Some(min), Some(max)) = (min_items, max_items)
809 && min > max
810 {
811 *has_errors = true;
812 diagnostics.push(component_diag(
813 component_id,
814 Severity::Error,
815 "PACK_LOCK_SCHEMA_ARRAY_BOUNDS",
816 format!("min_items {min} exceeds max_items {max}"),
817 Some(path.to_string()),
818 None,
819 Value::Null,
820 ));
821 }
822 let child_path = format!("{path}/items");
823 validate_schema_ir(component_id, items, &child_path, diagnostics, has_errors);
824 }
825 SchemaIr::String {
826 min_len,
827 max_len,
828 regex,
829 format,
830 } => {
831 if let (Some(min), Some(max)) = (min_len, max_len)
832 && min > max
833 {
834 *has_errors = true;
835 diagnostics.push(component_diag(
836 component_id,
837 Severity::Error,
838 "PACK_LOCK_SCHEMA_STRING_BOUNDS",
839 format!("min_len {min} exceeds max_len {max}"),
840 Some(path.to_string()),
841 None,
842 Value::Null,
843 ));
844 }
845 if regex.is_some() || format.is_some() {
846 diagnostics.push(component_diag(
847 component_id,
848 Severity::Warn,
849 "PACK_LOCK_SCHEMA_STRING_CONSTRAINT",
850 "string regex/format constraints are not validated by pack doctor".to_string(),
851 Some(path.to_string()),
852 None,
853 Value::Null,
854 ));
855 }
856 }
857 SchemaIr::Int { min, max } => {
858 if let (Some(min), Some(max)) = (min, max)
859 && min > max
860 {
861 *has_errors = true;
862 diagnostics.push(component_diag(
863 component_id,
864 Severity::Error,
865 "PACK_LOCK_SCHEMA_INT_BOUNDS",
866 format!("min {min} exceeds max {max}"),
867 Some(path.to_string()),
868 None,
869 Value::Null,
870 ));
871 }
872 }
873 SchemaIr::Float { min, max } => {
874 if let (Some(min), Some(max)) = (min, max)
875 && min > max
876 {
877 *has_errors = true;
878 diagnostics.push(component_diag(
879 component_id,
880 Severity::Error,
881 "PACK_LOCK_SCHEMA_FLOAT_BOUNDS",
882 format!("min {min} exceeds max {max}"),
883 Some(path.to_string()),
884 None,
885 Value::Null,
886 ));
887 }
888 }
889 SchemaIr::Enum { values } => {
890 if values.is_empty() {
891 *has_errors = true;
892 diagnostics.push(component_diag(
893 component_id,
894 Severity::Error,
895 "PACK_LOCK_SCHEMA_ENUM_EMPTY",
896 "enum has no values".to_string(),
897 Some(path.to_string()),
898 None,
899 Value::Null,
900 ));
901 }
902 }
903 SchemaIr::OneOf { variants } => {
904 if variants.is_empty() {
905 *has_errors = true;
906 diagnostics.push(component_diag(
907 component_id,
908 Severity::Error,
909 "PACK_LOCK_SCHEMA_ONEOF_EMPTY",
910 "oneOf has no variants".to_string(),
911 Some(path.to_string()),
912 None,
913 Value::Null,
914 ));
915 }
916 for (idx, variant) in variants.iter().enumerate() {
917 let child_path = format!("{path}/variants/{idx}");
918 validate_schema_ir(component_id, variant, &child_path, diagnostics, has_errors);
919 }
920 }
921 SchemaIr::Bool | SchemaIr::Null | SchemaIr::Bytes | SchemaIr::Ref { .. } => {}
922 }
923}
924
925fn component_diag(
926 component_id: &str,
927 severity: Severity,
928 code: &str,
929 message: String,
930 path: Option<String>,
931 hint: Option<String>,
932 data: Value,
933) -> ComponentDiagnostic {
934 ComponentDiagnostic {
935 component_id: component_id.to_string(),
936 diagnostic: Diagnostic {
937 severity,
938 code: code.to_string(),
939 message,
940 path,
941 hint,
942 data,
943 },
944 }
945}
946
947fn strip_file_uri_prefix(reference: &str) -> &str {
948 reference.strip_prefix("file://").unwrap_or(reference)
949}
950
951#[cfg(test)]
952mod tests {
953 use super::*;
954 use greentic_pack::PackLoad;
955 use greentic_types::ComponentId;
956 use greentic_types::component_source::ComponentSourceRef;
957 use greentic_types::pack::extensions::component_sources::{
958 ComponentSourceEntryV1, ResolvedComponentV1,
959 };
960 use greentic_types::schemas::common::schema_ir::AdditionalProperties;
961 use tempfile::TempDir;
962
963 #[test]
964 fn finish_diagnostics_sorts_and_marks_errors() {
965 let output = finish_diagnostics(vec![
966 component_diag(
967 "b.component",
968 Severity::Warn,
969 "WARN_CODE",
970 "warn".to_string(),
971 Some("z".to_string()),
972 None,
973 Value::Null,
974 ),
975 component_diag(
976 "a.component",
977 Severity::Error,
978 "ERR_CODE",
979 "err".to_string(),
980 Some("a".to_string()),
981 None,
982 Value::Null,
983 ),
984 ]);
985
986 assert!(output.has_errors);
987 assert_eq!(output.diagnostics[0].code, "ERR_CODE");
988 assert_eq!(output.diagnostics[1].code, "WARN_CODE");
989 }
990
991 #[test]
992 fn build_component_sources_map_prefers_component_id_when_present() {
993 let sources = ComponentSourcesV1::new(vec![
994 ComponentSourceEntryV1 {
995 name: "friendly".to_string(),
996 component_id: Some(ComponentId::try_from("demo.component").expect("component id")),
997 source: ComponentSourceRef::File("components/demo.wasm".to_string()),
998 resolved: ResolvedComponentV1 {
999 digest: "sha256:abc".to_string(),
1000 signature: None,
1001 signed_by: None,
1002 },
1003 artifact: ArtifactLocationV1::Remote,
1004 licensing_hint: None,
1005 metering_hint: None,
1006 },
1007 ComponentSourceEntryV1 {
1008 name: "name.only".to_string(),
1009 component_id: None,
1010 source: ComponentSourceRef::File("components/name.wasm".to_string()),
1011 resolved: ResolvedComponentV1 {
1012 digest: "sha256:def".to_string(),
1013 signature: None,
1014 signed_by: None,
1015 },
1016 artifact: ArtifactLocationV1::Remote,
1017 licensing_hint: None,
1018 metering_hint: None,
1019 },
1020 ]);
1021
1022 let map = build_component_sources_map(Some(&sources));
1023 assert!(map.contains_key("demo.component"));
1024 assert!(map.contains_key("name.only"));
1025 assert!(!map.contains_key("friendly"));
1026 }
1027
1028 #[test]
1029 fn validate_schema_ir_reports_unconstrained_objects_and_bad_bounds() {
1030 let mut diagnostics = Vec::new();
1031 let mut has_errors = false;
1032
1033 validate_schema_ir(
1034 "demo.component",
1035 &SchemaIr::Object {
1036 properties: BTreeMap::new(),
1037 required: Vec::new(),
1038 additional: AdditionalProperties::Allow,
1039 },
1040 "config",
1041 &mut diagnostics,
1042 &mut has_errors,
1043 );
1044 validate_schema_ir(
1045 "demo.component",
1046 &SchemaIr::Array {
1047 items: Box::new(SchemaIr::Bool),
1048 min_items: Some(3),
1049 max_items: Some(1),
1050 },
1051 "config/list",
1052 &mut diagnostics,
1053 &mut has_errors,
1054 );
1055
1056 assert!(has_errors);
1057 assert!(diagnostics.iter().any(|diag| {
1058 diag.diagnostic.code == "PACK_LOCK_SCHEMA_UNCONSTRAINED_OBJECT"
1059 && diag.diagnostic.path.as_deref() == Some("config")
1060 }));
1061 assert!(diagnostics.iter().any(|diag| {
1062 diag.diagnostic.code == "PACK_LOCK_SCHEMA_ARRAY_BOUNDS"
1063 && diag.diagnostic.path.as_deref() == Some("config/list")
1064 }));
1065 }
1066
1067 #[test]
1068 fn validate_schema_ir_downgrades_variant_unconstrained_object_to_warning() {
1069 let mut diagnostics = Vec::new();
1070 let mut has_errors = false;
1071
1072 validate_schema_ir(
1073 "demo.component",
1074 &SchemaIr::Object {
1075 properties: BTreeMap::new(),
1076 required: Vec::new(),
1077 additional: AdditionalProperties::Allow,
1078 },
1079 "config/variants/0",
1080 &mut diagnostics,
1081 &mut has_errors,
1082 );
1083
1084 assert!(!has_errors);
1085 assert_eq!(diagnostics.len(), 1);
1086 assert!(matches!(diagnostics[0].diagnostic.severity, Severity::Warn));
1087 }
1088
1089 #[test]
1090 fn strip_file_uri_prefix_handles_prefixed_and_plain_paths() {
1091 assert_eq!(
1092 strip_file_uri_prefix("file:///tmp/demo.wasm"),
1093 "/tmp/demo.wasm"
1094 );
1095 assert_eq!(
1096 strip_file_uri_prefix("components/demo.wasm"),
1097 "components/demo.wasm"
1098 );
1099 }
1100
1101 #[test]
1102 fn validate_schema_ir_reports_string_constraints_as_warnings() {
1103 let mut diagnostics = Vec::new();
1104 let mut has_errors = false;
1105
1106 validate_schema_ir(
1107 "demo.component",
1108 &SchemaIr::String {
1109 min_len: Some(1),
1110 max_len: Some(8),
1111 regex: Some("^demo$".to_string()),
1112 format: None,
1113 },
1114 "config/name",
1115 &mut diagnostics,
1116 &mut has_errors,
1117 );
1118
1119 assert!(!has_errors);
1120 assert_eq!(diagnostics.len(), 1);
1121 assert_eq!(
1122 diagnostics[0].diagnostic.code,
1123 "PACK_LOCK_SCHEMA_STRING_CONSTRAINT"
1124 );
1125 }
1126
1127 #[test]
1128 fn load_describe_from_cache_reads_inline_and_sidecar_bytes() {
1129 let temp = TempDir::new().expect("temp dir");
1130 let describe = greentic_types::schemas::component::v0_6_0::ComponentDescribe {
1131 info: greentic_types::schemas::component::v0_6_0::ComponentInfo {
1132 id: "demo.component".to_string(),
1133 version: "0.1.0".to_string(),
1134 role: "tool".to_string(),
1135 display_name: None,
1136 },
1137 provided_capabilities: Vec::new(),
1138 required_capabilities: Vec::new(),
1139 metadata: BTreeMap::new(),
1140 operations: Vec::new(),
1141 config_schema: SchemaIr::Bool,
1142 };
1143 let bytes = canonical::to_canonical_cbor_allow_floats(&describe).expect("describe bytes");
1144 let wasm_path = temp.path().join("component.wasm");
1145 std::fs::write(&wasm_path, b"\0asm").expect("wasm");
1146 std::fs::write(format!("{}.describe.cbor", wasm_path.display()), &bytes).expect("sidecar");
1147
1148 let inline = load_describe_from_cache(Some(&bytes), None)
1149 .expect("inline load")
1150 .expect("inline describe");
1151 let sidecar = load_describe_from_cache(None, Some(&wasm_path))
1152 .expect("sidecar load")
1153 .expect("sidecar describe");
1154
1155 assert_eq!(inline.info.id, "demo.component");
1156 assert_eq!(sidecar.info.id, "demo.component");
1157 }
1158
1159 #[test]
1160 fn load_describe_sidecar_from_pack_uses_logical_suffix() {
1161 let manifest = greentic_pack::builder::PackManifest {
1162 meta: greentic_pack::builder::PackMeta {
1163 pack_version: 1,
1164 pack_id: "demo.pack".to_string(),
1165 version: semver::Version::parse("0.1.0").expect("version"),
1166 name: "Demo".to_string(),
1167 kind: None,
1168 description: None,
1169 authors: Vec::new(),
1170 license: None,
1171 homepage: None,
1172 support: None,
1173 vendor: None,
1174 imports: Vec::new(),
1175 entry_flows: Vec::new(),
1176 created_at_utc: "2026-01-01T00:00:00Z".to_string(),
1177 events: None,
1178 repo: None,
1179 messaging: None,
1180 interfaces: Vec::new(),
1181 annotations: serde_json::Map::new(),
1182 distribution: None,
1183 components: Vec::new(),
1184 },
1185 flows: Vec::new(),
1186 components: Vec::new(),
1187 distribution: None,
1188 component_descriptors: Vec::new(),
1189 };
1190 let mut load = PackLoad {
1191 manifest,
1192 report: Default::default(),
1193 sbom: Vec::new(),
1194 files: HashMap::new(),
1195 gpack_manifest: None,
1196 };
1197 load.files.insert(
1198 "components/demo.wasm.describe.cbor".to_string(),
1199 vec![1, 2, 3],
1200 );
1201
1202 assert_eq!(
1203 load_describe_sidecar_from_pack(&load, "components/demo.wasm"),
1204 Some(vec![1, 2, 3])
1205 );
1206 }
1207
1208 #[test]
1209 fn compute_describe_hash_is_stable_for_same_payload() {
1210 let describe = greentic_types::schemas::component::v0_6_0::ComponentDescribe {
1211 info: greentic_types::schemas::component::v0_6_0::ComponentInfo {
1212 id: "demo.component".to_string(),
1213 version: "0.1.0".to_string(),
1214 role: "tool".to_string(),
1215 display_name: None,
1216 },
1217 provided_capabilities: Vec::new(),
1218 required_capabilities: Vec::new(),
1219 metadata: BTreeMap::new(),
1220 operations: Vec::new(),
1221 config_schema: SchemaIr::Bool,
1222 };
1223
1224 let first = compute_describe_hash(&describe).expect("first hash");
1225 let second = compute_describe_hash(&describe).expect("second hash");
1226 assert_eq!(first, second);
1227 assert_eq!(first.len(), 64);
1228 }
1229
1230 #[test]
1231 fn fallback_heuristics_only_trigger_for_known_errors() {
1232 let fallback = anyhow!("failed to instantiate component-v0-v6-v0 during describe");
1233 let other = anyhow!("totally different error");
1234
1235 assert!(should_fallback_to_describe_cache(&fallback));
1236 assert!(should_fallback_to_untyped_describe(&fallback));
1237 assert!(!should_fallback_to_describe_cache(&other));
1238 assert!(!should_fallback_to_untyped_describe(&other));
1239 }
1240
1241 #[test]
1242 fn load_pack_lock_returns_none_when_no_bytes_or_disk_file_exist() {
1243 let manifest = greentic_pack::builder::PackManifest {
1244 meta: greentic_pack::builder::PackMeta {
1245 pack_version: 1,
1246 pack_id: "demo.pack".to_string(),
1247 version: semver::Version::parse("0.1.0").expect("version"),
1248 name: "Demo".to_string(),
1249 kind: None,
1250 description: None,
1251 authors: Vec::new(),
1252 license: None,
1253 homepage: None,
1254 support: None,
1255 vendor: None,
1256 imports: Vec::new(),
1257 entry_flows: Vec::new(),
1258 created_at_utc: "2026-01-01T00:00:00Z".to_string(),
1259 events: None,
1260 repo: None,
1261 messaging: None,
1262 interfaces: Vec::new(),
1263 annotations: serde_json::Map::new(),
1264 distribution: None,
1265 components: Vec::new(),
1266 },
1267 flows: Vec::new(),
1268 components: Vec::new(),
1269 distribution: None,
1270 component_descriptors: Vec::new(),
1271 };
1272 let load = PackLoad {
1273 manifest,
1274 report: Default::default(),
1275 sbom: Vec::new(),
1276 files: HashMap::new(),
1277 gpack_manifest: None,
1278 };
1279 let temp = TempDir::new().expect("temp dir");
1280
1281 assert!(load_pack_lock(&load, None).expect("none").is_none());
1282 assert!(
1283 load_pack_lock(&load, Some(temp.path()))
1284 .expect("none")
1285 .is_none()
1286 );
1287 }
1288
1289 #[test]
1290 fn read_pack_lock_from_bytes_rejects_invalid_lock_documents() {
1291 let invalid = PackLockV1::new(BTreeMap::from([(
1292 "demo.component".to_string(),
1293 LockedComponent {
1294 component_id: "demo.component".to_string(),
1295 r#ref: None,
1296 abi_version: "0.6.0".to_string(),
1297 resolved_digest: "sha256:abc".to_string(),
1298 describe_hash: "not-a-real-hash".to_string(),
1299 operations: Vec::new(),
1300 world: None,
1301 component_version: None,
1302 role: None,
1303 },
1304 )]));
1305 let bytes = canonical::to_canonical_cbor_allow_floats(&invalid).expect("cbor");
1306
1307 let err = read_pack_lock_from_bytes(&bytes).expect_err("invalid lock should fail");
1308 assert!(err.to_string().contains("describe_hash"));
1309 }
1310
1311 #[test]
1312 fn validate_schema_ir_reports_missing_required_and_empty_variants() {
1313 let mut diagnostics = Vec::new();
1314 let mut has_errors = false;
1315
1316 validate_schema_ir(
1317 "demo.component",
1318 &SchemaIr::Object {
1319 properties: BTreeMap::new(),
1320 required: vec!["missing".to_string()],
1321 additional: AdditionalProperties::Forbid,
1322 },
1323 "config",
1324 &mut diagnostics,
1325 &mut has_errors,
1326 );
1327 validate_schema_ir(
1328 "demo.component",
1329 &SchemaIr::OneOf {
1330 variants: Vec::new(),
1331 },
1332 "config/choice",
1333 &mut diagnostics,
1334 &mut has_errors,
1335 );
1336 validate_schema_ir(
1337 "demo.component",
1338 &SchemaIr::Enum { values: Vec::new() },
1339 "config/enum",
1340 &mut diagnostics,
1341 &mut has_errors,
1342 );
1343
1344 assert!(has_errors);
1345 assert!(
1346 diagnostics
1347 .iter()
1348 .any(|diag| { diag.diagnostic.code == "PACK_LOCK_SCHEMA_REQUIRED_MISSING" })
1349 );
1350 assert!(
1351 diagnostics
1352 .iter()
1353 .any(|diag| { diag.diagnostic.code == "PACK_LOCK_SCHEMA_ONEOF_EMPTY" })
1354 );
1355 assert!(
1356 diagnostics
1357 .iter()
1358 .any(|diag| { diag.diagnostic.code == "PACK_LOCK_SCHEMA_ENUM_EMPTY" })
1359 );
1360 }
1361
1362 #[test]
1363 fn validate_schema_ir_reports_numeric_bound_inversions() {
1364 let mut diagnostics = Vec::new();
1365 let mut has_errors = false;
1366
1367 validate_schema_ir(
1368 "demo.component",
1369 &SchemaIr::Int {
1370 min: Some(10),
1371 max: Some(1),
1372 },
1373 "config/int",
1374 &mut diagnostics,
1375 &mut has_errors,
1376 );
1377 validate_schema_ir(
1378 "demo.component",
1379 &SchemaIr::Float {
1380 min: Some(4.0),
1381 max: Some(2.0),
1382 },
1383 "config/float",
1384 &mut diagnostics,
1385 &mut has_errors,
1386 );
1387
1388 assert!(has_errors);
1389 assert!(
1390 diagnostics
1391 .iter()
1392 .any(|diag| { diag.diagnostic.code == "PACK_LOCK_SCHEMA_INT_BOUNDS" })
1393 );
1394 assert!(
1395 diagnostics
1396 .iter()
1397 .any(|diag| { diag.diagnostic.code == "PACK_LOCK_SCHEMA_FLOAT_BOUNDS" })
1398 );
1399 }
1400}