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 rel = strip_file_uri_prefix(reference);
559 let path = input
562 .pack_dir
563 .map(|dir| dir.join(rel))
564 .unwrap_or_else(|| PathBuf::from(rel));
565 let bytes = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
566 return Ok(WasmSource {
567 bytes,
568 source_path: Some(path),
569 describe_bytes: None,
570 });
571 }
572
573 let reference = locked
574 .r#ref
575 .as_ref()
576 .ok_or_else(|| anyhow!("component {} missing ref", component_id))?;
577 if input.online {
578 input
579 .runtime
580 .require_online("pack lock doctor component download")?;
581 }
582 let offline = !input.online || input.runtime.network_policy() == NetworkPolicy::Offline;
583 let dist = DistClient::new(DistOptions {
584 cache_dir: input.runtime.cache_dir(),
585 allow_tags: input.allow_oci_tags,
586 offline,
587 allow_insecure_local_http: false,
588 ..DistOptions::default()
589 });
590
591 let handle = Handle::try_current().context("component resolution requires a Tokio runtime")?;
592 let resolved = if offline {
593 dist.open_cached(&locked.resolved_digest)
594 .map_err(|err| anyhow!("offline cache miss for {}: {}", reference, err))?
595 } else {
596 let source = dist
597 .parse_source(reference)
598 .map_err(|err| anyhow!("resolve {}: {}", reference, err))?;
599 let descriptor = block_on(
600 &handle,
601 dist.resolve(source, greentic_distributor_client::ResolvePolicy),
602 )
603 .map_err(|err| anyhow!("resolve {}: {}", reference, err))?;
604 block_on(
605 &handle,
606 dist.fetch(&descriptor, greentic_distributor_client::CachePolicy),
607 )
608 .map_err(|err| anyhow!("resolve {}: {}", reference, err))?
609 };
610 let path = resolved
611 .cache_path
612 .ok_or_else(|| anyhow!("resolved component missing path for {}", reference))?;
613 let bytes = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
614 Ok(WasmSource {
615 bytes,
616 source_path: Some(path),
617 describe_bytes: None,
618 })
619}
620
621fn block_on<F, T, E>(handle: &Handle, fut: F) -> std::result::Result<T, E>
622where
623 F: std::future::Future<Output = std::result::Result<T, E>>,
624{
625 tokio::task::block_in_place(|| handle.block_on(fut))
626}
627
628fn describe_component_with_cache(
629 engine: &Engine,
630 wasm: &WasmSource,
631 use_cache: bool,
632 component_id: &str,
633) -> Result<DescribeResolution> {
634 match describe_component(engine, &wasm.bytes) {
635 Ok(describe) => Ok(DescribeResolution {
636 describe,
637 requires_typed_instance: true,
638 }),
639 Err(err) => {
640 if should_fallback_to_untyped_describe(&err)
641 && let Ok(describe) = describe_component_untyped(engine, &wasm.bytes)
642 {
643 return Ok(DescribeResolution {
644 describe,
645 requires_typed_instance: false,
646 });
647 }
648 if use_cache || should_fallback_to_describe_cache(&err) {
649 if let Some(describe) = load_describe_from_cache(
650 wasm.describe_bytes.as_deref(),
651 wasm.source_path.as_deref(),
652 )? {
653 return Ok(DescribeResolution {
654 describe,
655 requires_typed_instance: false,
656 });
657 }
658 bail!("describe failed and no describe cache found for {component_id}: {err}");
659 }
660 Err(err)
661 }
662 }
663}
664
665fn describe_component_untyped(engine: &Engine, bytes: &[u8]) -> Result<ComponentDescribe> {
666 let component = WasmtimeComponent::from_binary(engine, bytes)
667 .map_err(|err| anyhow!("decode component bytes: {err}"))?;
668 let mut store = wasmtime::Store::new(engine, DescribeHostState::default());
669 let mut linker = Linker::new(engine);
670 add_describe_host_imports(&mut linker)?;
671 stub_remaining_imports(&mut linker, &component)?;
674 let instance = linker
675 .instantiate(&mut store, &component)
676 .map_err(|err| anyhow!("instantiate component root world: {err}"))?;
677
678 let descriptor = [
679 "component-descriptor",
680 "greentic:component/component-descriptor",
681 "greentic:component/component-descriptor@0.6.0",
682 ]
683 .iter()
684 .find_map(|name| instance.get_export_index(&mut store, None, name))
685 .ok_or_else(|| anyhow!("missing exported descriptor instance"))?;
686 let describe_export = [
687 "describe",
688 "greentic:component/component-descriptor@0.6.0#describe",
689 ]
690 .iter()
691 .find_map(|name| instance.get_export_index(&mut store, Some(&descriptor), name))
692 .ok_or_else(|| anyhow!("missing exported describe function"))?;
693 let describe_func = instance
694 .get_typed_func::<(), (Vec<u8>,)>(&mut store, &describe_export)
695 .map_err(|err| anyhow!("lookup component-descriptor.describe: {err}"))?;
696 let (describe_bytes,) = describe_func
697 .call(&mut store, ())
698 .map_err(|err| anyhow!("call component-descriptor.describe: {err}"))?;
699 canonical::from_cbor(&describe_bytes).context("decode ComponentDescribe")
700}
701
702fn should_fallback_to_describe_cache(err: &anyhow::Error) -> bool {
703 err.to_string().contains("instantiate component-v0-v6-v0")
704}
705
706fn should_fallback_to_untyped_describe(err: &anyhow::Error) -> bool {
707 err.to_string().contains("instantiate component-v0-v6-v0")
708}
709
710fn describe_component(engine: &Engine, bytes: &[u8]) -> Result<ComponentDescribe> {
711 describe_component_untyped(engine, bytes)
712}
713
714fn load_describe_from_cache(
715 inline_bytes: Option<&[u8]>,
716 source_path: Option<&Path>,
717) -> Result<Option<ComponentDescribe>> {
718 if let Some(bytes) = inline_bytes {
719 canonical::ensure_canonical(bytes).context("describe cache must be canonical")?;
720 let describe =
721 canonical::from_cbor(bytes).context("decode ComponentDescribe from cache")?;
722 return Ok(Some(describe));
723 }
724 if let Some(path) = source_path {
725 let describe_path = PathBuf::from(format!("{}.describe.cbor", path.display()));
726 if !describe_path.exists() {
727 return Ok(None);
728 }
729 let bytes = fs::read(&describe_path)
730 .with_context(|| format!("read {}", describe_path.display()))?;
731 canonical::ensure_canonical(&bytes).context("describe cache must be canonical")?;
732 let describe =
733 canonical::from_cbor(&bytes).context("decode ComponentDescribe from cache")?;
734 return Ok(Some(describe));
735 }
736 Ok(None)
737}
738
739fn load_describe_sidecar_from_pack(load: &PackLoad, logical_path: &str) -> Option<Vec<u8>> {
740 let describe_path = format!("{logical_path}.describe.cbor");
741 load.files.get(&describe_path).cloned()
742}
743
744fn compute_describe_hash(describe: &ComponentDescribe) -> Result<String> {
745 let bytes =
746 canonical::to_canonical_cbor_allow_floats(describe).context("canonicalize describe")?;
747 let digest = Sha256::digest(bytes.as_slice());
748 Ok(hex::encode(digest))
749}
750
751fn validate_schema_ir(
752 component_id: &str,
753 schema: &SchemaIr,
754 path: &str,
755 diagnostics: &mut Vec<ComponentDiagnostic>,
756 has_errors: &mut bool,
757) {
758 match schema {
759 SchemaIr::Object {
760 properties,
761 required,
762 additional,
763 } => {
764 if properties.is_empty()
765 && required.is_empty()
766 && matches!(additional, AdditionalProperties::Allow)
767 {
768 let inside_variant = path.contains("/variants/");
769 if !inside_variant {
770 *has_errors = true;
771 }
772 diagnostics.push(component_diag(
773 component_id,
774 if inside_variant {
775 Severity::Warn
776 } else {
777 Severity::Error
778 },
779 "PACK_LOCK_SCHEMA_UNCONSTRAINED_OBJECT",
780 "object schema is unconstrained".to_string(),
781 Some(path.to_string()),
782 Some("add properties or set additional=forbid".to_string()),
783 Value::Null,
784 ));
785 }
786 for req in required {
787 if !properties.contains_key(req) {
788 *has_errors = true;
789 diagnostics.push(component_diag(
790 component_id,
791 Severity::Error,
792 "PACK_LOCK_SCHEMA_REQUIRED_MISSING",
793 format!("required property `{req}` missing from properties"),
794 Some(path.to_string()),
795 None,
796 Value::Null,
797 ));
798 }
799 }
800 for (name, prop) in properties {
801 let child_path = format!("{path}/properties/{name}");
802 validate_schema_ir(component_id, prop, &child_path, diagnostics, has_errors);
803 }
804 if let AdditionalProperties::Schema(schema) = additional {
805 let child_path = format!("{path}/additional");
806 validate_schema_ir(component_id, schema, &child_path, diagnostics, has_errors);
807 }
808 }
809 SchemaIr::Array {
810 items,
811 min_items,
812 max_items,
813 } => {
814 if let (Some(min), Some(max)) = (min_items, max_items)
815 && min > max
816 {
817 *has_errors = true;
818 diagnostics.push(component_diag(
819 component_id,
820 Severity::Error,
821 "PACK_LOCK_SCHEMA_ARRAY_BOUNDS",
822 format!("min_items {min} exceeds max_items {max}"),
823 Some(path.to_string()),
824 None,
825 Value::Null,
826 ));
827 }
828 let child_path = format!("{path}/items");
829 validate_schema_ir(component_id, items, &child_path, diagnostics, has_errors);
830 }
831 SchemaIr::String {
832 min_len,
833 max_len,
834 regex,
835 format,
836 } => {
837 if let (Some(min), Some(max)) = (min_len, max_len)
838 && min > max
839 {
840 *has_errors = true;
841 diagnostics.push(component_diag(
842 component_id,
843 Severity::Error,
844 "PACK_LOCK_SCHEMA_STRING_BOUNDS",
845 format!("min_len {min} exceeds max_len {max}"),
846 Some(path.to_string()),
847 None,
848 Value::Null,
849 ));
850 }
851 if regex.is_some() || format.is_some() {
852 diagnostics.push(component_diag(
853 component_id,
854 Severity::Warn,
855 "PACK_LOCK_SCHEMA_STRING_CONSTRAINT",
856 "string regex/format constraints are not validated by pack doctor".to_string(),
857 Some(path.to_string()),
858 None,
859 Value::Null,
860 ));
861 }
862 }
863 SchemaIr::Int { min, max } => {
864 if let (Some(min), Some(max)) = (min, max)
865 && min > max
866 {
867 *has_errors = true;
868 diagnostics.push(component_diag(
869 component_id,
870 Severity::Error,
871 "PACK_LOCK_SCHEMA_INT_BOUNDS",
872 format!("min {min} exceeds max {max}"),
873 Some(path.to_string()),
874 None,
875 Value::Null,
876 ));
877 }
878 }
879 SchemaIr::Float { min, max } => {
880 if let (Some(min), Some(max)) = (min, max)
881 && min > max
882 {
883 *has_errors = true;
884 diagnostics.push(component_diag(
885 component_id,
886 Severity::Error,
887 "PACK_LOCK_SCHEMA_FLOAT_BOUNDS",
888 format!("min {min} exceeds max {max}"),
889 Some(path.to_string()),
890 None,
891 Value::Null,
892 ));
893 }
894 }
895 SchemaIr::Enum { values } => {
896 if values.is_empty() {
897 *has_errors = true;
898 diagnostics.push(component_diag(
899 component_id,
900 Severity::Error,
901 "PACK_LOCK_SCHEMA_ENUM_EMPTY",
902 "enum has no values".to_string(),
903 Some(path.to_string()),
904 None,
905 Value::Null,
906 ));
907 }
908 }
909 SchemaIr::OneOf { variants } => {
910 if variants.is_empty() {
911 *has_errors = true;
912 diagnostics.push(component_diag(
913 component_id,
914 Severity::Error,
915 "PACK_LOCK_SCHEMA_ONEOF_EMPTY",
916 "oneOf has no variants".to_string(),
917 Some(path.to_string()),
918 None,
919 Value::Null,
920 ));
921 }
922 for (idx, variant) in variants.iter().enumerate() {
923 let child_path = format!("{path}/variants/{idx}");
924 validate_schema_ir(component_id, variant, &child_path, diagnostics, has_errors);
925 }
926 }
927 SchemaIr::Bool | SchemaIr::Null | SchemaIr::Bytes | SchemaIr::Ref { .. } => {}
928 }
929}
930
931fn component_diag(
932 component_id: &str,
933 severity: Severity,
934 code: &str,
935 message: String,
936 path: Option<String>,
937 hint: Option<String>,
938 data: Value,
939) -> ComponentDiagnostic {
940 ComponentDiagnostic {
941 component_id: component_id.to_string(),
942 diagnostic: Diagnostic {
943 severity,
944 code: code.to_string(),
945 message,
946 path,
947 hint,
948 data,
949 },
950 }
951}
952
953fn strip_file_uri_prefix(reference: &str) -> &str {
954 reference.strip_prefix("file://").unwrap_or(reference)
955}
956
957#[cfg(test)]
958mod tests {
959 use super::*;
960 use greentic_pack::PackLoad;
961 use greentic_types::ComponentId;
962 use greentic_types::component_source::ComponentSourceRef;
963 use greentic_types::pack::extensions::component_sources::{
964 ComponentSourceEntryV1, ResolvedComponentV1,
965 };
966 use greentic_types::schemas::common::schema_ir::AdditionalProperties;
967 use tempfile::TempDir;
968
969 #[test]
970 fn finish_diagnostics_sorts_and_marks_errors() {
971 let output = finish_diagnostics(vec![
972 component_diag(
973 "b.component",
974 Severity::Warn,
975 "WARN_CODE",
976 "warn".to_string(),
977 Some("z".to_string()),
978 None,
979 Value::Null,
980 ),
981 component_diag(
982 "a.component",
983 Severity::Error,
984 "ERR_CODE",
985 "err".to_string(),
986 Some("a".to_string()),
987 None,
988 Value::Null,
989 ),
990 ]);
991
992 assert!(output.has_errors);
993 assert_eq!(output.diagnostics[0].code, "ERR_CODE");
994 assert_eq!(output.diagnostics[1].code, "WARN_CODE");
995 }
996
997 #[test]
998 fn build_component_sources_map_prefers_component_id_when_present() {
999 let sources = ComponentSourcesV1::new(vec![
1000 ComponentSourceEntryV1 {
1001 name: "friendly".to_string(),
1002 component_id: Some(ComponentId::try_from("demo.component").expect("component id")),
1003 source: ComponentSourceRef::File("components/demo.wasm".to_string()),
1004 resolved: ResolvedComponentV1 {
1005 digest: "sha256:abc".to_string(),
1006 signature: None,
1007 signed_by: None,
1008 },
1009 artifact: ArtifactLocationV1::Remote,
1010 licensing_hint: None,
1011 metering_hint: None,
1012 },
1013 ComponentSourceEntryV1 {
1014 name: "name.only".to_string(),
1015 component_id: None,
1016 source: ComponentSourceRef::File("components/name.wasm".to_string()),
1017 resolved: ResolvedComponentV1 {
1018 digest: "sha256:def".to_string(),
1019 signature: None,
1020 signed_by: None,
1021 },
1022 artifact: ArtifactLocationV1::Remote,
1023 licensing_hint: None,
1024 metering_hint: None,
1025 },
1026 ]);
1027
1028 let map = build_component_sources_map(Some(&sources));
1029 assert!(map.contains_key("demo.component"));
1030 assert!(map.contains_key("name.only"));
1031 assert!(!map.contains_key("friendly"));
1032 }
1033
1034 #[test]
1035 fn validate_schema_ir_reports_unconstrained_objects_and_bad_bounds() {
1036 let mut diagnostics = Vec::new();
1037 let mut has_errors = false;
1038
1039 validate_schema_ir(
1040 "demo.component",
1041 &SchemaIr::Object {
1042 properties: BTreeMap::new(),
1043 required: Vec::new(),
1044 additional: AdditionalProperties::Allow,
1045 },
1046 "config",
1047 &mut diagnostics,
1048 &mut has_errors,
1049 );
1050 validate_schema_ir(
1051 "demo.component",
1052 &SchemaIr::Array {
1053 items: Box::new(SchemaIr::Bool),
1054 min_items: Some(3),
1055 max_items: Some(1),
1056 },
1057 "config/list",
1058 &mut diagnostics,
1059 &mut has_errors,
1060 );
1061
1062 assert!(has_errors);
1063 assert!(diagnostics.iter().any(|diag| {
1064 diag.diagnostic.code == "PACK_LOCK_SCHEMA_UNCONSTRAINED_OBJECT"
1065 && diag.diagnostic.path.as_deref() == Some("config")
1066 }));
1067 assert!(diagnostics.iter().any(|diag| {
1068 diag.diagnostic.code == "PACK_LOCK_SCHEMA_ARRAY_BOUNDS"
1069 && diag.diagnostic.path.as_deref() == Some("config/list")
1070 }));
1071 }
1072
1073 #[test]
1074 fn validate_schema_ir_downgrades_variant_unconstrained_object_to_warning() {
1075 let mut diagnostics = Vec::new();
1076 let mut has_errors = false;
1077
1078 validate_schema_ir(
1079 "demo.component",
1080 &SchemaIr::Object {
1081 properties: BTreeMap::new(),
1082 required: Vec::new(),
1083 additional: AdditionalProperties::Allow,
1084 },
1085 "config/variants/0",
1086 &mut diagnostics,
1087 &mut has_errors,
1088 );
1089
1090 assert!(!has_errors);
1091 assert_eq!(diagnostics.len(), 1);
1092 assert!(matches!(diagnostics[0].diagnostic.severity, Severity::Warn));
1093 }
1094
1095 #[test]
1096 fn strip_file_uri_prefix_handles_prefixed_and_plain_paths() {
1097 assert_eq!(
1098 strip_file_uri_prefix("file:///tmp/demo.wasm"),
1099 "/tmp/demo.wasm"
1100 );
1101 assert_eq!(
1102 strip_file_uri_prefix("components/demo.wasm"),
1103 "components/demo.wasm"
1104 );
1105 }
1106
1107 #[test]
1108 fn validate_schema_ir_reports_string_constraints_as_warnings() {
1109 let mut diagnostics = Vec::new();
1110 let mut has_errors = false;
1111
1112 validate_schema_ir(
1113 "demo.component",
1114 &SchemaIr::String {
1115 min_len: Some(1),
1116 max_len: Some(8),
1117 regex: Some("^demo$".to_string()),
1118 format: None,
1119 },
1120 "config/name",
1121 &mut diagnostics,
1122 &mut has_errors,
1123 );
1124
1125 assert!(!has_errors);
1126 assert_eq!(diagnostics.len(), 1);
1127 assert_eq!(
1128 diagnostics[0].diagnostic.code,
1129 "PACK_LOCK_SCHEMA_STRING_CONSTRAINT"
1130 );
1131 }
1132
1133 #[test]
1134 fn load_describe_from_cache_reads_inline_and_sidecar_bytes() {
1135 let temp = TempDir::new().expect("temp dir");
1136 let describe = greentic_types::schemas::component::v0_6_0::ComponentDescribe {
1137 info: greentic_types::schemas::component::v0_6_0::ComponentInfo {
1138 id: "demo.component".to_string(),
1139 version: "0.1.0".to_string(),
1140 role: "tool".to_string(),
1141 display_name: None,
1142 },
1143 provided_capabilities: Vec::new(),
1144 required_capabilities: Vec::new(),
1145 metadata: BTreeMap::new(),
1146 operations: Vec::new(),
1147 config_schema: SchemaIr::Bool,
1148 outcomes: Vec::new(),
1149 };
1150 let bytes = canonical::to_canonical_cbor_allow_floats(&describe).expect("describe bytes");
1151 let wasm_path = temp.path().join("component.wasm");
1152 std::fs::write(&wasm_path, b"\0asm").expect("wasm");
1153 std::fs::write(format!("{}.describe.cbor", wasm_path.display()), &bytes).expect("sidecar");
1154
1155 let inline = load_describe_from_cache(Some(&bytes), None)
1156 .expect("inline load")
1157 .expect("inline describe");
1158 let sidecar = load_describe_from_cache(None, Some(&wasm_path))
1159 .expect("sidecar load")
1160 .expect("sidecar describe");
1161
1162 assert_eq!(inline.info.id, "demo.component");
1163 assert_eq!(sidecar.info.id, "demo.component");
1164 }
1165
1166 #[test]
1167 fn load_describe_sidecar_from_pack_uses_logical_suffix() {
1168 let manifest = greentic_pack::builder::PackManifest {
1169 meta: greentic_pack::builder::PackMeta {
1170 pack_version: 1,
1171 pack_id: "demo.pack".to_string(),
1172 version: semver::Version::parse("0.1.0").expect("version"),
1173 name: "Demo".to_string(),
1174 kind: None,
1175 description: None,
1176 authors: Vec::new(),
1177 license: None,
1178 homepage: None,
1179 support: None,
1180 vendor: None,
1181 imports: Vec::new(),
1182 entry_flows: Vec::new(),
1183 created_at_utc: "2026-01-01T00:00:00Z".to_string(),
1184 events: None,
1185 repo: None,
1186 messaging: None,
1187 interfaces: Vec::new(),
1188 annotations: serde_json::Map::new(),
1189 distribution: None,
1190 components: Vec::new(),
1191 },
1192 flows: Vec::new(),
1193 components: Vec::new(),
1194 distribution: None,
1195 component_descriptors: Vec::new(),
1196 };
1197 let mut load = PackLoad {
1198 manifest,
1199 report: Default::default(),
1200 sbom: Vec::new(),
1201 files: HashMap::new(),
1202 gpack_manifest: None,
1203 };
1204 load.files.insert(
1205 "components/demo.wasm.describe.cbor".to_string(),
1206 vec![1, 2, 3],
1207 );
1208
1209 assert_eq!(
1210 load_describe_sidecar_from_pack(&load, "components/demo.wasm"),
1211 Some(vec![1, 2, 3])
1212 );
1213 }
1214
1215 #[test]
1216 fn compute_describe_hash_is_stable_for_same_payload() {
1217 let describe = greentic_types::schemas::component::v0_6_0::ComponentDescribe {
1218 info: greentic_types::schemas::component::v0_6_0::ComponentInfo {
1219 id: "demo.component".to_string(),
1220 version: "0.1.0".to_string(),
1221 role: "tool".to_string(),
1222 display_name: None,
1223 },
1224 provided_capabilities: Vec::new(),
1225 required_capabilities: Vec::new(),
1226 metadata: BTreeMap::new(),
1227 operations: Vec::new(),
1228 config_schema: SchemaIr::Bool,
1229 outcomes: Vec::new(),
1230 };
1231
1232 let first = compute_describe_hash(&describe).expect("first hash");
1233 let second = compute_describe_hash(&describe).expect("second hash");
1234 assert_eq!(first, second);
1235 assert_eq!(first.len(), 64);
1236 }
1237
1238 #[test]
1239 fn fallback_heuristics_only_trigger_for_known_errors() {
1240 let fallback = anyhow!("failed to instantiate component-v0-v6-v0 during describe");
1241 let other = anyhow!("totally different error");
1242
1243 assert!(should_fallback_to_describe_cache(&fallback));
1244 assert!(should_fallback_to_untyped_describe(&fallback));
1245 assert!(!should_fallback_to_describe_cache(&other));
1246 assert!(!should_fallback_to_untyped_describe(&other));
1247 }
1248
1249 #[test]
1250 fn load_pack_lock_returns_none_when_no_bytes_or_disk_file_exist() {
1251 let manifest = greentic_pack::builder::PackManifest {
1252 meta: greentic_pack::builder::PackMeta {
1253 pack_version: 1,
1254 pack_id: "demo.pack".to_string(),
1255 version: semver::Version::parse("0.1.0").expect("version"),
1256 name: "Demo".to_string(),
1257 kind: None,
1258 description: None,
1259 authors: Vec::new(),
1260 license: None,
1261 homepage: None,
1262 support: None,
1263 vendor: None,
1264 imports: Vec::new(),
1265 entry_flows: Vec::new(),
1266 created_at_utc: "2026-01-01T00:00:00Z".to_string(),
1267 events: None,
1268 repo: None,
1269 messaging: None,
1270 interfaces: Vec::new(),
1271 annotations: serde_json::Map::new(),
1272 distribution: None,
1273 components: Vec::new(),
1274 },
1275 flows: Vec::new(),
1276 components: Vec::new(),
1277 distribution: None,
1278 component_descriptors: Vec::new(),
1279 };
1280 let load = PackLoad {
1281 manifest,
1282 report: Default::default(),
1283 sbom: Vec::new(),
1284 files: HashMap::new(),
1285 gpack_manifest: None,
1286 };
1287 let temp = TempDir::new().expect("temp dir");
1288
1289 assert!(load_pack_lock(&load, None).expect("none").is_none());
1290 assert!(
1291 load_pack_lock(&load, Some(temp.path()))
1292 .expect("none")
1293 .is_none()
1294 );
1295 }
1296
1297 #[test]
1298 fn read_pack_lock_from_bytes_rejects_invalid_lock_documents() {
1299 let invalid = PackLockV1::new(BTreeMap::from([(
1300 "demo.component".to_string(),
1301 LockedComponent {
1302 component_id: "demo.component".to_string(),
1303 r#ref: None,
1304 abi_version: "0.6.0".to_string(),
1305 resolved_digest: "sha256:abc".to_string(),
1306 describe_hash: "not-a-real-hash".to_string(),
1307 operations: Vec::new(),
1308 world: None,
1309 component_version: None,
1310 role: None,
1311 },
1312 )]));
1313 let bytes = canonical::to_canonical_cbor_allow_floats(&invalid).expect("cbor");
1314
1315 let err = read_pack_lock_from_bytes(&bytes).expect_err("invalid lock should fail");
1316 assert!(err.to_string().contains("describe_hash"));
1317 }
1318
1319 #[test]
1320 fn validate_schema_ir_reports_missing_required_and_empty_variants() {
1321 let mut diagnostics = Vec::new();
1322 let mut has_errors = false;
1323
1324 validate_schema_ir(
1325 "demo.component",
1326 &SchemaIr::Object {
1327 properties: BTreeMap::new(),
1328 required: vec!["missing".to_string()],
1329 additional: AdditionalProperties::Forbid,
1330 },
1331 "config",
1332 &mut diagnostics,
1333 &mut has_errors,
1334 );
1335 validate_schema_ir(
1336 "demo.component",
1337 &SchemaIr::OneOf {
1338 variants: Vec::new(),
1339 },
1340 "config/choice",
1341 &mut diagnostics,
1342 &mut has_errors,
1343 );
1344 validate_schema_ir(
1345 "demo.component",
1346 &SchemaIr::Enum { values: Vec::new() },
1347 "config/enum",
1348 &mut diagnostics,
1349 &mut has_errors,
1350 );
1351
1352 assert!(has_errors);
1353 assert!(
1354 diagnostics
1355 .iter()
1356 .any(|diag| { diag.diagnostic.code == "PACK_LOCK_SCHEMA_REQUIRED_MISSING" })
1357 );
1358 assert!(
1359 diagnostics
1360 .iter()
1361 .any(|diag| { diag.diagnostic.code == "PACK_LOCK_SCHEMA_ONEOF_EMPTY" })
1362 );
1363 assert!(
1364 diagnostics
1365 .iter()
1366 .any(|diag| { diag.diagnostic.code == "PACK_LOCK_SCHEMA_ENUM_EMPTY" })
1367 );
1368 }
1369
1370 #[test]
1371 fn validate_schema_ir_reports_numeric_bound_inversions() {
1372 let mut diagnostics = Vec::new();
1373 let mut has_errors = false;
1374
1375 validate_schema_ir(
1376 "demo.component",
1377 &SchemaIr::Int {
1378 min: Some(10),
1379 max: Some(1),
1380 },
1381 "config/int",
1382 &mut diagnostics,
1383 &mut has_errors,
1384 );
1385 validate_schema_ir(
1386 "demo.component",
1387 &SchemaIr::Float {
1388 min: Some(4.0),
1389 max: Some(2.0),
1390 },
1391 "config/float",
1392 &mut diagnostics,
1393 &mut has_errors,
1394 );
1395
1396 assert!(has_errors);
1397 assert!(
1398 diagnostics
1399 .iter()
1400 .any(|diag| { diag.diagnostic.code == "PACK_LOCK_SCHEMA_INT_BOUNDS" })
1401 );
1402 assert!(
1403 diagnostics
1404 .iter()
1405 .any(|diag| { diag.diagnostic.code == "PACK_LOCK_SCHEMA_FLOAT_BOUNDS" })
1406 );
1407 }
1408}