1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use clap::{Args, Parser, ValueEnum};
6use serde::Serialize;
7use serde_json::Value as JsonValue;
8use wasmtime::component::{Component, Func, Linker, Val};
9use wasmtime::{Engine, Store};
10use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
11
12use super::path::strip_file_scheme;
13use crate::cmd::component_world::is_fallback_world;
14use crate::embedded_compare::{compare_embedded_with_describe, compare_embedded_with_manifest};
15use crate::embedded_descriptor::{
16 VerifiedEmbeddedDescriptorV1, read_and_verify_embedded_component_manifest_section_v1,
17};
18use crate::test_harness::{HarnessConfig, TestHarness};
19use crate::{ComponentError, abi, loader, parse_manifest};
20
21use greentic_types::cbor::canonical;
22use greentic_types::schemas::common::schema_ir::{AdditionalProperties, SchemaIr};
23use greentic_types::schemas::component::v0_6_0::{
24 ComponentDescribe, ComponentInfo, ComponentQaSpec, QaMode, schema_hash,
25};
26use greentic_types::{EnvId, TenantCtx, TenantId};
27
28const COMPONENT_WORLD_V0_6_0: &str = "greentic:component/component@0.6.0";
29const SELF_DESCRIBE_TAG: [u8; 3] = [0xd9, 0xd9, 0xf7];
30const EMPTY_CBOR_MAP: [u8; 1] = [0xa0];
31
32#[derive(Args, Debug, Clone)]
33#[command(about = "Run health checks against a Greentic component artifact")]
34pub struct DoctorArgs {
35 pub target: String,
37 #[arg(long)]
39 pub manifest: Option<PathBuf>,
40 #[arg(long, value_enum, default_value = "human")]
42 pub format: DoctorFormat,
43}
44
45#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
46pub enum DoctorFormat {
47 Human,
48 Json,
49}
50
51#[derive(Parser, Debug)]
52struct DoctorCli {
53 #[command(flatten)]
54 args: DoctorArgs,
55}
56
57pub fn parse_from_cli() -> DoctorArgs {
58 DoctorCli::parse().args
59}
60
61pub fn run(args: DoctorArgs) -> Result<(), ComponentError> {
62 let target_path = strip_file_scheme(Path::new(&args.target));
63 let wasm_path = resolve_wasm_path(&args.target, &target_path, args.manifest.as_deref())
64 .map_err(ComponentError::Doctor)?;
65 let manifest_path = discover_manifest_path(&wasm_path, &target_path, args.manifest.as_deref());
66
67 let report = DoctorReport::from_wasm(&wasm_path, manifest_path.as_deref())
68 .map_err(ComponentError::Doctor)?;
69 match args.format {
70 DoctorFormat::Human => report.emit_human(),
71 DoctorFormat::Json => report.emit_json()?,
72 }
73
74 if report.has_errors() {
75 return Err(ComponentError::Doctor("doctor checks failed".to_string()));
76 }
77 Ok(())
78}
79
80fn discover_manifest_path(
81 wasm_path: &Path,
82 target_path: &Path,
83 explicit: Option<&Path>,
84) -> Option<PathBuf> {
85 if let Some(path) = explicit {
86 return Some(path.to_path_buf());
87 }
88
89 let mut candidates = Vec::new();
90 if target_path.is_dir() {
91 candidates.push(target_path.join("component.manifest.json"));
92 }
93 if let Some(parent) = wasm_path.parent() {
94 candidates.push(parent.join("component.manifest.json"));
95 if let Some(grandparent) = parent.parent() {
96 candidates.push(grandparent.join("component.manifest.json"));
97 }
98 }
99
100 candidates.into_iter().find(|path| path.is_file())
101}
102
103fn resolve_wasm_path(
104 raw_target: &str,
105 target_path: &Path,
106 manifest: Option<&Path>,
107) -> Result<PathBuf, String> {
108 if let Some(manifest_path) = manifest {
109 let handle = loader::discover_with_manifest(raw_target, Some(manifest_path))
110 .map_err(|err| format!("failed to load manifest: {err}"))?;
111 return Ok(handle.wasm_path);
112 }
113
114 if target_path.is_file() {
115 if target_path.extension().and_then(|ext| ext.to_str()) == Some("wasm") {
116 return Ok(target_path.to_path_buf());
117 }
118 if target_path.extension().and_then(|ext| ext.to_str()) == Some("json") {
119 let handle = loader::discover_with_manifest(raw_target, Some(target_path))
120 .map_err(|err| format!("failed to load manifest: {err}"))?;
121 return Ok(handle.wasm_path);
122 }
123 }
124
125 if target_path.is_dir()
126 && let Some(found) = find_wasm_in_dir(target_path)?
127 {
128 return Ok(found);
129 }
130
131 Err(format!(
132 "doctor: unable to resolve wasm for '{}'; pass a .wasm file or --manifest",
133 raw_target
134 ))
135}
136
137fn find_wasm_in_dir(dir: &Path) -> Result<Option<PathBuf>, String> {
138 let mut candidates = Vec::new();
139 let dist = dir.join("dist");
140 if dist.is_dir() {
141 collect_wasm_files(&dist, &mut candidates)?;
142 }
143 let target = dir.join("target").join("wasm32-wasip2");
144 for profile in ["release", "debug"] {
145 let profile_dir = target.join(profile);
146 if profile_dir.is_dir() {
147 collect_wasm_files(&profile_dir, &mut candidates)?;
148 }
149 }
150
151 candidates.sort();
152 candidates.dedup();
153 match candidates.len() {
154 0 => Ok(None),
155 1 => Ok(Some(candidates.remove(0))),
156 _ => Err(format!(
157 "doctor: multiple wasm files found in {}; specify one explicitly",
158 dir.display()
159 )),
160 }
161}
162
163fn collect_wasm_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
164 for entry in
165 fs::read_dir(dir).map_err(|err| format!("failed to read {}: {err}", dir.display()))?
166 {
167 let entry = entry.map_err(|err| format!("failed to read {}: {err}", dir.display()))?;
168 let path = entry.path();
169 if path.extension().and_then(|ext| ext.to_str()) == Some("wasm") {
170 out.push(path);
171 }
172 }
173 Ok(())
174}
175
176#[derive(Default, Serialize)]
177struct DoctorReport {
178 diagnostics: Vec<DoctorDiagnostic>,
179}
180
181impl DoctorReport {
182 fn from_wasm(wasm_path: &Path, manifest_path: Option<&Path>) -> Result<Self, String> {
183 let mut report = DoctorReport::default();
184 report.validate_world(wasm_path);
185 let embedded = report.validate_embedded_metadata(wasm_path, manifest_path)?;
186
187 let mut caller = ComponentCaller::new(wasm_path)
188 .map_err(|err| format!("doctor: failed to load component: {err}"))?;
189
190 if !caller.has_interface("component-descriptor") && caller.has_interface("node") {
191 return report.validate_node_component(wasm_path, manifest_path, embedded.is_some());
192 }
193
194 let info_bytes = report.require_export_bytes(
195 &mut caller,
196 "component-descriptor",
197 "get-component-info",
198 &[],
199 );
200 let describe_bytes =
201 report.require_export_bytes(&mut caller, "component-descriptor", "describe", &[]);
202 let i18n_keys =
203 report.require_export_strings(&mut caller, "component-i18n", "i18n-keys", &[]);
204
205 report.require_export_call(
206 &mut caller,
207 "component-runtime",
208 "run",
209 &[
210 Val::List(bytes_to_vals(&EMPTY_CBOR_MAP)),
211 Val::List(bytes_to_vals(&EMPTY_CBOR_MAP)),
212 ],
213 );
214
215 let mut qa_specs = BTreeMap::new();
216 for (mode, mode_name) in qa_modes() {
217 let spec_bytes = report.require_export_bytes(
218 &mut caller,
219 "component-qa",
220 "qa-spec",
221 &[Val::Enum(mode_name.to_string())],
222 );
223 if let Some(bytes) = spec_bytes.as_deref() {
224 match decode_cbor::<ComponentQaSpec>(bytes) {
225 Ok(spec) => {
226 let compatible_default =
227 mode == QaMode::Default && spec.mode == QaMode::Setup;
228 if spec.mode != mode && !compatible_default {
229 report.error(
230 "doctor.qa.mode_mismatch",
231 format!("qa-spec returned {:?} for mode {mode_name}", spec.mode),
232 "qa-spec",
233 None,
234 );
235 }
236 qa_specs.insert(mode_name.to_string(), spec);
237 }
238 Err(err) => {
239 report.error(
240 "doctor.qa.decode_failed",
241 format!("qa-spec({mode_name}) decode failed: {err}"),
242 "qa-spec",
243 None,
244 );
245 }
246 }
247 }
248 }
249
250 if let Some(bytes) = info_bytes {
251 match decode_cbor::<ComponentInfo>(&bytes) {
252 Ok(info) => report.validate_info(&info, "get-component-info"),
253 Err(err) => report.error(
254 "doctor.describe.info_decode_failed",
255 format!("get-component-info decode failed: {err}"),
256 "get-component-info",
257 None,
258 ),
259 }
260 }
261
262 if let Some(bytes) = describe_bytes {
263 match decode_cbor::<ComponentDescribe>(&bytes) {
264 Ok(describe) => {
265 report.validate_info(&describe.info, "describe");
266 report.validate_describe(&describe, &bytes);
267 if let Some(embedded) = embedded.as_ref() {
268 report.validate_embedded_against_describe(&embedded.manifest, &describe);
269 } else {
270 report.warning(
271 "doctor.embedded.describe_unavailable",
272 "embedded metadata unavailable for compare with describe()".to_string(),
273 "embedded_manifest",
274 None,
275 );
276 }
277 report.validate_i18n(&i18n_keys, &qa_specs);
278 report.validate_apply_answers(&mut caller, &describe, &bytes);
279 }
280 Err(err) => report.error(
281 "doctor.describe.decode_failed",
282 format!("describe decode failed: {err}"),
283 "describe",
284 None,
285 ),
286 }
287 }
288
289 report.finalize();
290 Ok(report)
291 }
292
293 fn validate_node_component(
294 mut self,
295 wasm_path: &Path,
296 manifest_path: Option<&Path>,
297 _embedded_present: bool,
298 ) -> Result<Self, String> {
299 let Some(manifest_path) = manifest_path else {
300 self.error(
301 "doctor.node.manifest_required",
302 "node-interface doctor checks require a component.manifest.json path".to_string(),
303 "manifest",
304 Some("pass --manifest or run doctor from the component project root".to_string()),
305 );
306 self.finalize();
307 return Ok(self);
308 };
309
310 let raw_manifest = fs::read_to_string(manifest_path)
311 .map_err(|err| format!("failed to read {}: {err}", manifest_path.display()))?;
312 let manifest = parse_manifest(&raw_manifest)
313 .map_err(|err| format!("failed to parse {}: {err}", manifest_path.display()))?;
314 let harness = new_doctor_harness(wasm_path, &manifest)?;
315
316 let i18n_keys = match invoke_json(&harness, "i18n-keys", &serde_json::json!({})) {
317 Ok(value) => match json_array_to_string_set(&value) {
318 Ok(keys) => Some(keys),
319 Err(err) => {
320 self.error(
321 "doctor.export.invalid_strings",
322 format!("node.i18n-keys returned invalid strings: {err}"),
323 "node.i18n-keys",
324 None,
325 );
326 None
327 }
328 },
329 Err(err) => {
330 self.error(
331 "doctor.export.call_failed",
332 format!("node.i18n-keys failed: {err}"),
333 "node.i18n-keys",
334 None,
335 );
336 None
337 }
338 };
339
340 let mut qa_specs = BTreeMap::new();
341 for (mode, mode_name) in qa_modes() {
342 match invoke_json(
343 &harness,
344 "qa-spec",
345 &serde_json::json!({ "mode": mode_name }),
346 ) {
347 Ok(value) => match serde_json::from_value::<ComponentQaSpec>(value) {
348 Ok(spec) => {
349 let compatible_default =
350 mode == QaMode::Default && spec.mode == QaMode::Setup;
351 if spec.mode != mode && !compatible_default {
352 self.error(
353 "doctor.qa.mode_mismatch",
354 format!("qa-spec returned {:?} for mode {mode_name}", spec.mode),
355 "qa-spec",
356 None,
357 );
358 }
359 qa_specs.insert(mode_name.to_string(), spec);
360 }
361 Err(err) => self.error(
362 "doctor.qa.decode_failed",
363 format!("qa-spec({mode_name}) decode failed: {err}"),
364 "qa-spec",
365 None,
366 ),
367 },
368 Err(err) => self.error(
369 "doctor.export.call_failed",
370 format!("node.qa-spec failed: {err}"),
371 "node.qa-spec",
372 None,
373 ),
374 }
375 }
376 self.validate_i18n(&i18n_keys, &qa_specs);
377
378 for (_mode, mode_name) in qa_modes() {
379 let payload = sample_apply_answers_payload(mode_name);
380 match invoke_json(&harness, "apply-answers", &payload) {
381 Ok(value) => self.validate_apply_answers_value(mode_name, &value),
382 Err(err) => self.error(
383 "doctor.export.call_failed",
384 format!("node.apply-answers failed: {err}"),
385 "node.apply-answers",
386 None,
387 ),
388 }
389 }
390
391 if let Some(operation) = default_user_operation(&manifest) {
392 match invoke_json(
393 &harness,
394 operation,
395 &serde_json::json!({ "input": "doctor" }),
396 ) {
397 Ok(value) => {
398 if !value.is_object() {
399 self.error(
400 "doctor.runtime.invalid_output",
401 format!("{operation} returned non-object output"),
402 format!("node.invoke.{operation}"),
403 None,
404 );
405 }
406 }
407 Err(err) => self.error(
408 "doctor.export.call_failed",
409 format!("node.invoke({operation}) failed: {err}"),
410 format!("node.invoke.{operation}"),
411 None,
412 ),
413 }
414 }
415
416 self.finalize();
417 Ok(self)
418 }
419
420 fn validate_embedded_metadata(
421 &mut self,
422 wasm_path: &Path,
423 manifest_path: Option<&Path>,
424 ) -> Result<Option<VerifiedEmbeddedDescriptorV1>, String> {
425 let wasm_bytes = fs::read(wasm_path)
426 .map_err(|err| format!("failed to read {}: {err}", wasm_path.display()))?;
427 let embedded = read_and_verify_embedded_component_manifest_section_v1(&wasm_bytes)
428 .map_err(|err| format!("embedded manifest decode failed: {err}"))?;
429
430 let Some(embedded) = embedded else {
431 self.error(
432 "doctor.embedded.missing",
433 format!(
434 "missing embedded manifest section {}",
435 crate::EMBEDDED_COMPONENT_MANIFEST_SECTION_V1
436 ),
437 "embedded_manifest",
438 None,
439 );
440 return Ok(None);
441 };
442
443 if let Some(manifest_path) = manifest_path {
444 let raw_manifest = fs::read_to_string(manifest_path)
445 .map_err(|err| format!("failed to read {}: {err}", manifest_path.display()))?;
446 let manifest = parse_manifest(&raw_manifest)
447 .map_err(|err| format!("failed to parse {}: {err}", manifest_path.display()))?;
448 let comparison = compare_embedded_with_manifest(&embedded.manifest, &manifest);
449 for field in comparison
450 .fields
451 .into_iter()
452 .filter(|field| field.status != crate::ComparisonStatus::Match)
453 {
454 self.error(
455 "doctor.embedded.manifest_mismatch",
456 format!(
457 "embedded manifest differs from canonical manifest for {}{}",
458 field.field,
459 field
460 .detail
461 .as_deref()
462 .map(|detail| format!(": {detail}"))
463 .unwrap_or_default()
464 ),
465 format!("embedded_manifest.{}", field.field),
466 None,
467 );
468 }
469 } else {
470 self.warning(
471 "doctor.embedded.manifest_unavailable",
472 "external manifest unavailable; skipping embedded vs manifest comparison"
473 .to_string(),
474 "embedded_manifest",
475 None,
476 );
477 }
478
479 Ok(Some(embedded))
480 }
481
482 fn validate_embedded_against_describe(
483 &mut self,
484 embedded: &crate::embedded_descriptor::EmbeddedComponentManifestV1,
485 describe: &ComponentDescribe,
486 ) {
487 let comparison = compare_embedded_with_describe(embedded, describe);
488 for field in comparison
489 .fields
490 .into_iter()
491 .filter(|field| field.status != crate::ComparisonStatus::Match)
492 {
493 self.error(
494 "doctor.embedded.describe_mismatch",
495 format!(
496 "embedded manifest differs from describe() for {}{}",
497 field.field,
498 field
499 .detail
500 .as_deref()
501 .map(|detail| format!(": {detail}"))
502 .unwrap_or_default()
503 ),
504 format!("embedded_manifest.describe.{}", field.field),
505 None,
506 );
507 }
508 }
509
510 fn validate_world(&mut self, wasm_path: &Path) {
511 if let Err(err) = abi::check_world_base(wasm_path, COMPONENT_WORLD_V0_6_0) {
512 match err {
513 abi::AbiError::WorldMismatch { found, .. } if is_fallback_world(&found) => {}
514 other => self.error(
515 "doctor.world.mismatch",
516 format!("component world mismatch: {other}"),
517 "world",
518 Some("expected component@0.6.0 world".to_string()),
519 ),
520 }
521 }
522 }
523
524 fn validate_info(&mut self, info: &ComponentInfo, source: &str) {
525 if info.id.trim().is_empty() {
526 self.error(
527 "doctor.describe.info.id_empty",
528 format!("{source} info.id must be non-empty"),
529 "info.id",
530 None,
531 );
532 }
533 if info.version.trim().is_empty() {
534 self.error(
535 "doctor.describe.info.version_empty",
536 format!("{source} info.version must be non-empty"),
537 "info.version",
538 None,
539 );
540 }
541 if info.role.trim().is_empty() {
542 self.error(
543 "doctor.describe.info.role_empty",
544 format!("{source} info.role must be non-empty"),
545 "info.role",
546 None,
547 );
548 }
549 }
550
551 fn validate_describe(&mut self, describe: &ComponentDescribe, raw_bytes: &[u8]) {
552 if let Err(err) = ensure_canonical_allow_floats(raw_bytes) {
553 self.error(
554 "doctor.describe.non_canonical",
555 format!("describe CBOR is not canonical: {err}"),
556 "describe",
557 None,
558 );
559 }
560
561 if describe.operations.is_empty() {
562 self.error(
563 "doctor.describe.missing_operations",
564 "describe.operations must be non-empty".to_string(),
565 "operations",
566 None,
567 );
568 }
569
570 self.validate_schema_ir(&describe.config_schema, "config_schema");
571
572 for (idx, op) in describe.operations.iter().enumerate() {
573 if op.id.trim().is_empty() {
574 self.error(
575 "doctor.describe.operation.id_empty",
576 "operation id must be non-empty".to_string(),
577 format!("operations[{idx}].id"),
578 None,
579 );
580 }
581 self.validate_schema_ir(&op.input.schema, format!("operations[{idx}].input.schema"));
582 self.validate_schema_ir(
583 &op.output.schema,
584 format!("operations[{idx}].output.schema"),
585 );
586
587 match schema_hash(&op.input.schema, &op.output.schema, &describe.config_schema) {
588 Ok(expected) => {
589 if op.schema_hash.trim().is_empty() {
590 self.error(
591 "doctor.describe.schema_hash.empty",
592 "schema_hash must be non-empty".to_string(),
593 format!("operations[{idx}].schema_hash"),
594 None,
595 );
596 } else if op.schema_hash != expected {
597 self.error(
598 "doctor.describe.schema_hash.mismatch",
599 format!(
600 "schema_hash mismatch (expected {expected}, got {})",
601 op.schema_hash
602 ),
603 format!("operations[{idx}].schema_hash"),
604 None,
605 );
606 }
607 }
608 Err(err) => self.error(
609 "doctor.describe.schema_hash.failed",
610 format!("schema_hash computation failed: {err}"),
611 format!("operations[{idx}].schema_hash"),
612 None,
613 ),
614 }
615 }
616 }
617
618 fn validate_i18n(
619 &mut self,
620 i18n_keys: &Option<BTreeSet<String>>,
621 qa_specs: &BTreeMap<String, ComponentQaSpec>,
622 ) {
623 let Some(keys) = i18n_keys else {
624 self.error(
625 "doctor.i18n.missing_keys",
626 "i18n-keys export missing or failed".to_string(),
627 "component-i18n",
628 None,
629 );
630 return;
631 };
632
633 for (mode, spec) in qa_specs {
634 for key in spec.i18n_keys() {
635 if !keys.contains(&key) {
636 self.error(
637 "doctor.i18n.key_missing",
638 format!("missing i18n key {key} referenced in qa-spec({mode})"),
639 "component-i18n",
640 None,
641 );
642 }
643 }
644 }
645 }
646
647 fn validate_apply_answers(
648 &mut self,
649 caller: &mut ComponentCaller,
650 describe: &ComponentDescribe,
651 describe_bytes: &[u8],
652 ) {
653 let context = describe_hash_context(describe, describe_bytes);
654 for (_mode, mode_name) in qa_modes() {
655 let bytes = self.require_export_bytes(
656 caller,
657 "component-qa",
658 "apply-answers",
659 &[
660 Val::Enum(mode_name.to_string()),
661 Val::List(bytes_to_vals(&EMPTY_CBOR_MAP)),
662 Val::List(bytes_to_vals(&EMPTY_CBOR_MAP)),
663 ],
664 );
665 let Some(bytes) = bytes else {
666 continue;
667 };
668 if let Err(err) = ensure_canonical_allow_floats(&bytes) {
669 self.error(
670 "doctor.qa.apply_answers.non_canonical",
671 format!(
672 "apply-answers({mode_name}) returned non-canonical CBOR: {err}; {context}"
673 ),
674 format!("apply-answers.{mode_name}"),
675 None,
676 );
677 }
678 match decode_cbor::<JsonValue>(&bytes) {
679 Ok(value) => {
680 let mut issues = Vec::new();
681 validate_json_value(&describe.config_schema, &value, "$", &mut issues);
682 if !issues.is_empty() {
683 self.error(
684 "doctor.qa.apply_answers.schema_invalid",
685 format!(
686 "apply-answers({mode_name}) violates config_schema: {}; {context}",
687 format_validation_issues(&issues)
688 ),
689 format!("apply-answers.{mode_name}"),
690 None,
691 );
692 }
693 }
694 Err(err) => {
695 self.error(
696 "doctor.qa.apply_answers.decode_failed",
697 format!("apply-answers({mode_name}) decode failed: {err}; {context}"),
698 "apply-answers",
699 None,
700 );
701 }
702 }
703 }
704 }
705
706 fn validate_apply_answers_value(&mut self, mode_name: &str, value: &JsonValue) {
707 let Some(object) = value.as_object() else {
708 self.error(
709 "doctor.qa.apply_answers.invalid_shape",
710 format!("apply-answers({mode_name}) returned non-object JSON"),
711 format!("apply-answers.{mode_name}"),
712 None,
713 );
714 return;
715 };
716
717 if !object.get("ok").is_some_and(JsonValue::is_boolean) {
718 self.error(
719 "doctor.qa.apply_answers.invalid_shape",
720 format!("apply-answers({mode_name}) must include boolean `ok`"),
721 format!("apply-answers.{mode_name}.ok"),
722 None,
723 );
724 }
725 if !object.get("warnings").is_some_and(|value| value.is_array()) {
726 self.error(
727 "doctor.qa.apply_answers.invalid_shape",
728 format!("apply-answers({mode_name}) must include array `warnings`"),
729 format!("apply-answers.{mode_name}.warnings"),
730 None,
731 );
732 }
733 if !object.get("errors").is_some_and(|value| value.is_array()) {
734 self.error(
735 "doctor.qa.apply_answers.invalid_shape",
736 format!("apply-answers({mode_name}) must include array `errors`"),
737 format!("apply-answers.{mode_name}.errors"),
738 None,
739 );
740 }
741 }
742
743 fn validate_schema_ir<P: Into<String>>(&mut self, schema: &SchemaIr, path: P) {
744 let path = path.into();
745 let mut errors = Vec::new();
746 collect_schema_issues(schema, &path, &mut errors);
747 for error in errors {
748 self.error(error.code, error.message, error.path, error.hint);
749 }
750 }
751
752 fn require_export_bytes(
753 &mut self,
754 caller: &mut ComponentCaller,
755 interface: &str,
756 func: &str,
757 params: &[Val],
758 ) -> Option<Vec<u8>> {
759 match caller.call(interface, func, params) {
760 Ok(values) => {
761 if let Some(val) = values.first() {
762 match val_to_bytes(val) {
763 Ok(bytes) => Some(bytes),
764 Err(err) => {
765 self.error(
766 "doctor.export.invalid_bytes",
767 format!("{interface}.{func} returned invalid bytes: {err}"),
768 format!("{interface}.{func}"),
769 None,
770 );
771 None
772 }
773 }
774 } else {
775 self.error(
776 "doctor.export.missing_result",
777 format!("{interface}.{func} returned no value"),
778 format!("{interface}.{func}"),
779 None,
780 );
781 None
782 }
783 }
784 Err(err) => {
785 self.error(
786 "doctor.export.call_failed",
787 format!("{interface}.{func} failed: {err}"),
788 format!("{interface}.{func}"),
789 None,
790 );
791 None
792 }
793 }
794 }
795
796 fn require_export_strings(
797 &mut self,
798 caller: &mut ComponentCaller,
799 interface: &str,
800 func: &str,
801 params: &[Val],
802 ) -> Option<BTreeSet<String>> {
803 match caller.call(interface, func, params) {
804 Ok(values) => {
805 if let Some(val) = values.first() {
806 match val_to_strings(val) {
807 Ok(values) => Some(values.into_iter().collect()),
808 Err(err) => {
809 self.error(
810 "doctor.export.invalid_strings",
811 format!("{interface}.{func} returned invalid strings: {err}"),
812 format!("{interface}.{func}"),
813 None,
814 );
815 None
816 }
817 }
818 } else {
819 self.error(
820 "doctor.export.missing_result",
821 format!("{interface}.{func} returned no value"),
822 format!("{interface}.{func}"),
823 None,
824 );
825 None
826 }
827 }
828 Err(err) => {
829 self.error(
830 "doctor.export.call_failed",
831 format!("{interface}.{func} failed: {err}"),
832 format!("{interface}.{func}"),
833 None,
834 );
835 None
836 }
837 }
838 }
839
840 fn require_export_call(
841 &mut self,
842 caller: &mut ComponentCaller,
843 interface: &str,
844 func: &str,
845 params: &[Val],
846 ) {
847 if let Err(err) = caller.call(interface, func, params) {
848 self.error(
849 "doctor.export.call_failed",
850 format!("{interface}.{func} failed: {err}"),
851 format!("{interface}.{func}"),
852 None,
853 );
854 }
855 }
856
857 fn error(
858 &mut self,
859 code: impl Into<String>,
860 message: impl Into<String>,
861 path: impl Into<String>,
862 hint: Option<String>,
863 ) {
864 self.diagnostics.push(DoctorDiagnostic {
865 severity: Severity::Error,
866 code: code.into(),
867 message: message.into(),
868 path: path.into(),
869 hint,
870 });
871 }
872
873 fn warning(
874 &mut self,
875 code: impl Into<String>,
876 message: impl Into<String>,
877 path: impl Into<String>,
878 hint: Option<String>,
879 ) {
880 self.diagnostics.push(DoctorDiagnostic {
881 severity: Severity::Warning,
882 code: code.into(),
883 message: message.into(),
884 path: path.into(),
885 hint,
886 });
887 }
888
889 fn finalize(&mut self) {
890 self.diagnostics
891 .sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.code.cmp(&b.code)));
892 }
893
894 fn has_errors(&self) -> bool {
895 self.diagnostics
896 .iter()
897 .any(|diag| diag.severity == Severity::Error)
898 }
899
900 fn emit_human(&self) {
901 if self.diagnostics.is_empty() {
902 println!("doctor: ok");
903 return;
904 }
905 for diag in &self.diagnostics {
906 let hint = diag
907 .hint
908 .as_deref()
909 .map(|hint| format!(" (hint: {hint})"))
910 .unwrap_or_default();
911 println!(
912 "{severity}[{code}] {path}: {message}{hint}",
913 severity = diag.severity,
914 code = diag.code,
915 path = diag.path,
916 message = diag.message,
917 hint = hint
918 );
919 }
920 }
921
922 fn emit_json(&self) -> Result<(), ComponentError> {
923 let payload = serde_json::to_string_pretty(&self)
924 .map_err(|err| ComponentError::Doctor(format!("failed to encode json: {err}")))?;
925 println!("{payload}");
926 Ok(())
927 }
928}
929
930#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
931#[serde(rename_all = "lowercase")]
932enum Severity {
933 Error,
934 Warning,
935}
936
937impl std::fmt::Display for Severity {
938 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
939 match self {
940 Severity::Error => write!(f, "error"),
941 Severity::Warning => write!(f, "warning"),
942 }
943 }
944}
945
946#[derive(Debug, Clone, Serialize)]
947struct DoctorDiagnostic {
948 severity: Severity,
949 code: String,
950 message: String,
951 path: String,
952 #[serde(skip_serializing_if = "Option::is_none")]
953 hint: Option<String>,
954}
955
956struct ComponentCaller {
957 store: Store<DoctorWasi>,
958 instance: wasmtime::component::Instance,
959}
960
961impl ComponentCaller {
962 fn new(wasm_path: &Path) -> Result<Self, anyhow::Error> {
963 let mut config = wasmtime::Config::new();
964 config.wasm_component_model(true);
965 let engine =
966 Engine::new(&config).map_err(|err| anyhow::anyhow!("create engine failed: {err}"))?;
967
968 let component = Component::from_file(&engine, wasm_path).map_err(|err| {
969 anyhow::anyhow!("load component {} failed: {err}", wasm_path.display())
970 })?;
971 let mut linker = Linker::new(&engine);
972 wasmtime_wasi::p2::add_to_linker_sync(&mut linker)
973 .map_err(|err| anyhow::anyhow!("add wasi linker failed: {err}"))?;
974
975 let wasi = DoctorWasi::new()?;
976 let mut store = Store::new(&engine, wasi);
977 let instance = linker
978 .instantiate(&mut store, &component)
979 .map_err(|err| anyhow::anyhow!("instantiate component failed: {err}"))?;
980 Ok(Self { store, instance })
981 }
982
983 fn call(&mut self, interface: &str, func: &str, params: &[Val]) -> Result<Vec<Val>, String> {
984 let instance_index = resolve_interface_index(&self.instance, &mut self.store, interface)
985 .ok_or_else(|| format!("missing export interface {interface}"))?;
986 let func_index = self
987 .instance
988 .get_export_index(&mut self.store, Some(&instance_index), func)
989 .ok_or_else(|| format!("missing export {interface}.{func}"))?;
990 let func = self
991 .instance
992 .get_func(&mut self.store, func_index)
993 .ok_or_else(|| format!("export {interface}.{func} is not callable"))?;
994
995 call_component_func(&mut self.store, &func, params)
996 }
997
998 fn has_interface(&mut self, interface: &str) -> bool {
999 resolve_interface_index(&self.instance, &mut self.store, interface).is_some()
1000 }
1001}
1002
1003fn resolve_interface_index(
1004 instance: &wasmtime::component::Instance,
1005 store: &mut Store<DoctorWasi>,
1006 interface: &str,
1007) -> Option<wasmtime::component::ComponentExportIndex> {
1008 for candidate in interface_candidates(interface) {
1009 if let Some(index) = instance.get_export_index(&mut *store, None, &candidate) {
1010 return Some(index);
1011 }
1012 }
1013 None
1014}
1015
1016fn interface_candidates(interface: &str) -> [String; 3] {
1017 [
1018 interface.to_string(),
1019 format!("greentic:component/{interface}@0.6.0"),
1020 format!("greentic:component/{interface}"),
1021 ]
1022}
1023
1024fn call_component_func(
1025 store: &mut Store<DoctorWasi>,
1026 func: &Func,
1027 params: &[Val],
1028) -> Result<Vec<Val>, String> {
1029 let results_len = func.ty(&mut *store).results().len();
1030 let mut results = vec![Val::Bool(false); results_len];
1031 func.call(&mut *store, params, &mut results)
1032 .map_err(|err| format!("call failed: {err}"))?;
1033 Ok(results)
1034}
1035
1036fn qa_modes() -> [(QaMode, &'static str); 4] {
1037 [
1038 (QaMode::Default, "default"),
1039 (QaMode::Setup, "setup"),
1040 (QaMode::Update, "update"),
1041 (QaMode::Remove, "remove"),
1042 ]
1043}
1044
1045fn bytes_to_vals(bytes: &[u8]) -> Vec<Val> {
1046 bytes.iter().map(|b| Val::U8(*b)).collect()
1047}
1048
1049fn val_to_bytes(val: &Val) -> Result<Vec<u8>, String> {
1050 match val {
1051 Val::List(items) => {
1052 let mut out = Vec::with_capacity(items.len());
1053 for item in items {
1054 match item {
1055 Val::U8(byte) => out.push(*byte),
1056 _ => {
1057 return Err("expected list<u8>".to_string());
1058 }
1059 }
1060 }
1061 Ok(out)
1062 }
1063 _ => Err("expected list<u8>".to_string()),
1064 }
1065}
1066
1067fn val_to_strings(val: &Val) -> Result<Vec<String>, String> {
1068 match val {
1069 Val::List(items) => {
1070 let mut out = Vec::with_capacity(items.len());
1071 for item in items {
1072 match item {
1073 Val::String(value) => out.push(value.clone()),
1074 _ => return Err("expected list<string>".to_string()),
1075 }
1076 }
1077 Ok(out)
1078 }
1079 _ => Err("expected list<string>".to_string()),
1080 }
1081}
1082
1083fn decode_cbor<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, String> {
1084 let payload = strip_self_describe_tag(bytes);
1085 canonical::from_cbor(payload).map_err(|err| format!("CBOR decode failed: {err}"))
1086}
1087
1088fn new_doctor_harness(
1089 wasm_path: &Path,
1090 manifest: &crate::manifest::ComponentManifest,
1091) -> Result<TestHarness, String> {
1092 let env: EnvId = "dev"
1093 .to_string()
1094 .try_into()
1095 .map_err(|err| format!("invalid doctor env: {err}"))?;
1096 let tenant: TenantId = "doctor"
1097 .to_string()
1098 .try_into()
1099 .map_err(|err| format!("invalid doctor tenant: {err}"))?;
1100 let tenant_ctx = TenantCtx::new(env, tenant)
1101 .with_flow("doctor")
1102 .with_node("doctor");
1103 let allowed_secrets = manifest
1104 .secret_requirements
1105 .iter()
1106 .map(|req| req.key.to_string())
1107 .chain(
1108 manifest
1109 .capabilities
1110 .host
1111 .secrets
1112 .as_ref()
1113 .into_iter()
1114 .flat_map(|spec| spec.required.iter().map(|req| req.key.to_string())),
1115 )
1116 .collect();
1117
1118 TestHarness::new(HarnessConfig {
1119 wasm_bytes: fs::read(wasm_path)
1120 .map_err(|err| format!("failed to read {}: {err}", wasm_path.display()))?,
1121 tenant_ctx,
1122 flow_id: "doctor".to_string(),
1123 node_id: Some("doctor".to_string()),
1124 state_prefix: "doctor".to_string(),
1125 state_seeds: Vec::new(),
1126 allow_state_read: true,
1127 allow_state_write: true,
1128 allow_state_delete: true,
1129 allow_secrets: true,
1130 allowed_secrets,
1131 secrets: Default::default(),
1132 wasi_preopens: Vec::new(),
1133 config: Some(serde_json::json!({})),
1134 allow_http: true,
1135 timeout_ms: 5_000,
1136 max_memory_bytes: 64 * 1024 * 1024,
1137 })
1138 .map_err(|err| format!("failed to initialize doctor harness: {err}"))
1139}
1140
1141fn invoke_json(
1142 harness: &TestHarness,
1143 operation: &str,
1144 payload: &JsonValue,
1145) -> Result<JsonValue, String> {
1146 let outcome = harness
1147 .invoke(operation, payload)
1148 .map_err(|err| format!("invoke component: {err}"))?;
1149 serde_json::from_str(&outcome.output_json)
1150 .map_err(|err| format!("decode operation output json failed: {err}"))
1151}
1152
1153fn json_array_to_string_set(value: &JsonValue) -> Result<BTreeSet<String>, String> {
1154 let array = value
1155 .as_array()
1156 .ok_or_else(|| "expected array<string>".to_string())?;
1157 let mut out = BTreeSet::new();
1158 for item in array {
1159 let Some(string) = item.as_str() else {
1160 return Err("expected array<string>".to_string());
1161 };
1162 out.insert(string.to_string());
1163 }
1164 Ok(out)
1165}
1166
1167fn sample_apply_answers_payload(mode_name: &str) -> JsonValue {
1168 let answers = match mode_name {
1169 "setup" | "default" => serde_json::json!({
1170 "api_key": "demo-key",
1171 "region": "eu",
1172 "webhook_base_url": "https://example.invalid/webhook",
1173 "enabled": "true"
1174 }),
1175 "remove" => serde_json::json!({
1176 "confirm_remove": "true"
1177 }),
1178 _ => serde_json::json!({
1179 "enabled": "true"
1180 }),
1181 };
1182 serde_json::json!({
1183 "mode": mode_name,
1184 "answers": answers,
1185 "current_config": {}
1186 })
1187}
1188
1189fn default_user_operation(manifest: &crate::manifest::ComponentManifest) -> Option<&str> {
1190 if let Some(default) = manifest.default_operation.as_deref() {
1191 return Some(default);
1192 }
1193
1194 manifest
1195 .operations
1196 .iter()
1197 .map(|op| op.name.as_str())
1198 .find(|name| !matches!(*name, "qa-spec" | "apply-answers" | "i18n-keys"))
1199}
1200
1201fn strip_self_describe_tag(bytes: &[u8]) -> &[u8] {
1202 if bytes.starts_with(&SELF_DESCRIBE_TAG) {
1203 &bytes[SELF_DESCRIBE_TAG.len()..]
1204 } else {
1205 bytes
1206 }
1207}
1208
1209fn ensure_canonical_allow_floats(bytes: &[u8]) -> Result<(), String> {
1210 let payload = strip_self_describe_tag(bytes);
1211 let canonicalized = canonical::canonicalize_allow_floats(payload)
1212 .map_err(|err| format!("canonicalization failed: {err}"))?;
1213 if canonicalized.as_slice() != payload {
1214 return Err("payload is not canonical".to_string());
1215 }
1216 Ok(())
1217}
1218
1219#[derive(Debug, Clone)]
1220struct SchemaIssue {
1221 code: String,
1222 message: String,
1223 path: String,
1224 hint: Option<String>,
1225}
1226
1227fn collect_schema_issues(schema: &SchemaIr, path: &str, issues: &mut Vec<SchemaIssue>) {
1228 match schema {
1229 SchemaIr::Object {
1230 properties,
1231 required: _,
1232 additional,
1233 } => {
1234 if properties.is_empty() && matches!(additional, AdditionalProperties::Allow) {
1235 issues.push(SchemaIssue {
1236 code: "doctor.schema.object.unconstrained".to_string(),
1237 message: "object schema allows arbitrary additional properties without defined fields"
1238 .to_string(),
1239 path: path.to_string(),
1240 hint: None,
1241 });
1242 }
1243 for (name, subschema) in properties {
1244 collect_schema_issues(subschema, &format!("{path}.{name}"), issues);
1245 }
1246 if let AdditionalProperties::Schema(schema) = additional {
1247 collect_schema_issues(schema, &format!("{path}.additional"), issues);
1248 }
1249 }
1250 SchemaIr::Array {
1251 items,
1252 min_items,
1253 max_items,
1254 } => {
1255 if min_items.is_none() && max_items.is_none() && is_unconstrained(items) {
1256 issues.push(SchemaIssue {
1257 code: "doctor.schema.array.unconstrained".to_string(),
1258 message: "array schema has no constraints".to_string(),
1259 path: path.to_string(),
1260 hint: None,
1261 });
1262 }
1263 collect_schema_issues(items, &format!("{path}.items"), issues);
1264 }
1265 SchemaIr::String {
1266 min_len,
1267 max_len,
1268 regex,
1269 format,
1270 } => {
1271 if min_len.is_none() && max_len.is_none() && regex.is_none() && format.is_none() {
1272 issues.push(SchemaIssue {
1273 code: "doctor.schema.string.unconstrained".to_string(),
1274 message: "string schema has no constraints".to_string(),
1275 path: path.to_string(),
1276 hint: None,
1277 });
1278 }
1279 }
1280 SchemaIr::Int { min, max } => {
1281 if min.is_none() && max.is_none() {
1282 issues.push(SchemaIssue {
1283 code: "doctor.schema.int.unconstrained".to_string(),
1284 message: "int schema has no constraints".to_string(),
1285 path: path.to_string(),
1286 hint: None,
1287 });
1288 }
1289 }
1290 SchemaIr::Float { min, max } => {
1291 if min.is_none() && max.is_none() {
1292 issues.push(SchemaIssue {
1293 code: "doctor.schema.float.unconstrained".to_string(),
1294 message: "float schema has no constraints".to_string(),
1295 path: path.to_string(),
1296 hint: None,
1297 });
1298 }
1299 }
1300 SchemaIr::Enum { values } => {
1301 if values.is_empty() {
1302 issues.push(SchemaIssue {
1303 code: "doctor.schema.enum.empty".to_string(),
1304 message: "enum schema must define at least one value".to_string(),
1305 path: path.to_string(),
1306 hint: None,
1307 });
1308 }
1309 }
1310 SchemaIr::OneOf { variants } => {
1311 if variants.is_empty() {
1312 issues.push(SchemaIssue {
1313 code: "doctor.schema.oneof.empty".to_string(),
1314 message: "oneof schema must define at least one variant".to_string(),
1315 path: path.to_string(),
1316 hint: None,
1317 });
1318 }
1319 for (idx, variant) in variants.iter().enumerate() {
1320 collect_schema_issues(variant, &format!("{path}.variants[{idx}]"), issues);
1321 }
1322 }
1323 SchemaIr::Ref { .. } => {
1324 issues.push(SchemaIssue {
1325 code: "doctor.schema.ref.unsupported".to_string(),
1326 message: "schema ref is not supported in strict mode".to_string(),
1327 path: path.to_string(),
1328 hint: None,
1329 });
1330 }
1331 SchemaIr::Bool | SchemaIr::Null | SchemaIr::Bytes => {}
1332 }
1333}
1334
1335fn is_unconstrained(schema: &SchemaIr) -> bool {
1336 match schema {
1337 SchemaIr::Object {
1338 properties,
1339 additional,
1340 ..
1341 } => properties.is_empty() && matches!(additional, AdditionalProperties::Allow),
1342 SchemaIr::Array {
1343 min_items,
1344 max_items,
1345 items,
1346 } => min_items.is_none() && max_items.is_none() && is_unconstrained(items),
1347 SchemaIr::String {
1348 min_len,
1349 max_len,
1350 regex,
1351 format,
1352 } => min_len.is_none() && max_len.is_none() && regex.is_none() && format.is_none(),
1353 SchemaIr::Int { min, max } => min.is_none() && max.is_none(),
1354 SchemaIr::Float { min, max } => min.is_none() && max.is_none(),
1355 SchemaIr::Enum { values } => values.is_empty(),
1356 SchemaIr::OneOf { variants } => variants.is_empty(),
1357 SchemaIr::Ref { .. } => true,
1358 SchemaIr::Bool | SchemaIr::Null | SchemaIr::Bytes => false,
1359 }
1360}
1361
1362#[derive(Debug)]
1363struct ValueIssue {
1364 path: String,
1365 message: String,
1366}
1367
1368fn describe_hash_context(describe: &ComponentDescribe, describe_bytes: &[u8]) -> String {
1369 let describe_hash =
1370 compute_describe_hash(describe_bytes).unwrap_or_else(|err| format!("unavailable ({err})"));
1371 let schema_hashes = describe
1372 .operations
1373 .iter()
1374 .map(|op| format!("{}={}", op.id, op.schema_hash))
1375 .collect::<Vec<_>>();
1376 if schema_hashes.is_empty() {
1377 format!("describe_hash={describe_hash}")
1378 } else {
1379 format!(
1380 "describe_hash={describe_hash}; schema_hashes=[{}]",
1381 schema_hashes.join(", ")
1382 )
1383 }
1384}
1385
1386fn compute_describe_hash(raw_bytes: &[u8]) -> Result<String, String> {
1387 let payload = strip_self_describe_tag(raw_bytes);
1388 let canonicalized = canonical::canonicalize_allow_floats(payload)
1389 .map_err(|err| format!("canonicalization failed: {err}"))?;
1390 Ok(blake3::hash(&canonicalized).to_hex().to_string())
1391}
1392
1393fn format_validation_issues(issues: &[ValueIssue]) -> String {
1394 issues
1395 .iter()
1396 .take(8)
1397 .map(|issue| format!("{}: {}", issue.path, issue.message))
1398 .collect::<Vec<_>>()
1399 .join("; ")
1400}
1401
1402fn validate_json_value(
1403 schema: &SchemaIr,
1404 value: &JsonValue,
1405 path: &str,
1406 issues: &mut Vec<ValueIssue>,
1407) {
1408 match schema {
1409 SchemaIr::Object {
1410 properties,
1411 required,
1412 additional,
1413 } => {
1414 let Some(obj) = value.as_object() else {
1415 issues.push(ValueIssue {
1416 path: path.to_string(),
1417 message: "expected object".to_string(),
1418 });
1419 return;
1420 };
1421 for key in required {
1422 if !obj.contains_key(key) {
1423 issues.push(ValueIssue {
1424 path: format!("{path}/{key}"),
1425 message: "required field missing".to_string(),
1426 });
1427 }
1428 }
1429 for (key, subschema) in properties {
1430 if let Some(subvalue) = obj.get(key) {
1431 validate_json_value(subschema, subvalue, &format!("{path}/{key}"), issues);
1432 }
1433 }
1434 for (key, subvalue) in obj {
1435 if properties.contains_key(key) {
1436 continue;
1437 }
1438 match additional {
1439 AdditionalProperties::Allow => {}
1440 AdditionalProperties::Forbid => issues.push(ValueIssue {
1441 path: format!("{path}/{key}"),
1442 message: "additional property not allowed".to_string(),
1443 }),
1444 AdditionalProperties::Schema(extra_schema) => {
1445 validate_json_value(
1446 extra_schema,
1447 subvalue,
1448 &format!("{path}/{key}"),
1449 issues,
1450 );
1451 }
1452 }
1453 }
1454 }
1455 SchemaIr::Array {
1456 items,
1457 min_items,
1458 max_items,
1459 } => {
1460 let Some(arr) = value.as_array() else {
1461 issues.push(ValueIssue {
1462 path: path.to_string(),
1463 message: "expected array".to_string(),
1464 });
1465 return;
1466 };
1467 if let Some(min) = min_items
1468 && arr.len() < *min as usize
1469 {
1470 issues.push(ValueIssue {
1471 path: path.to_string(),
1472 message: format!("expected at least {min} items"),
1473 });
1474 }
1475 if let Some(max) = max_items
1476 && arr.len() > *max as usize
1477 {
1478 issues.push(ValueIssue {
1479 path: path.to_string(),
1480 message: format!("expected at most {max} items"),
1481 });
1482 }
1483 for (idx, item) in arr.iter().enumerate() {
1484 validate_json_value(items, item, &format!("{path}/{idx}"), issues);
1485 }
1486 }
1487 SchemaIr::String {
1488 min_len,
1489 max_len,
1490 regex,
1491 ..
1492 } => {
1493 let Some(s) = value.as_str() else {
1494 issues.push(ValueIssue {
1495 path: path.to_string(),
1496 message: "expected string".to_string(),
1497 });
1498 return;
1499 };
1500 if let Some(min) = min_len
1501 && s.chars().count() < *min as usize
1502 {
1503 issues.push(ValueIssue {
1504 path: path.to_string(),
1505 message: format!("expected minimum length {min}"),
1506 });
1507 }
1508 if let Some(max) = max_len
1509 && s.chars().count() > *max as usize
1510 {
1511 issues.push(ValueIssue {
1512 path: path.to_string(),
1513 message: format!("expected maximum length {max}"),
1514 });
1515 }
1516 if let Some(pattern) = regex {
1517 match regex::Regex::new(pattern) {
1518 Ok(re) => {
1519 if !re.is_match(s) {
1520 issues.push(ValueIssue {
1521 path: path.to_string(),
1522 message: format!("string does not match regex `{pattern}`"),
1523 });
1524 }
1525 }
1526 Err(err) => issues.push(ValueIssue {
1527 path: path.to_string(),
1528 message: format!("invalid schema regex `{pattern}`: {err}"),
1529 }),
1530 }
1531 }
1532 }
1533 SchemaIr::Int { min, max } => {
1534 let Some(i) = value.as_i64() else {
1535 issues.push(ValueIssue {
1536 path: path.to_string(),
1537 message: "expected integer".to_string(),
1538 });
1539 return;
1540 };
1541 if let Some(min) = min
1542 && i < *min
1543 {
1544 issues.push(ValueIssue {
1545 path: path.to_string(),
1546 message: format!("expected value >= {min}"),
1547 });
1548 }
1549 if let Some(max) = max
1550 && i > *max
1551 {
1552 issues.push(ValueIssue {
1553 path: path.to_string(),
1554 message: format!("expected value <= {max}"),
1555 });
1556 }
1557 }
1558 SchemaIr::Float { min, max } => {
1559 let Some(f) = value.as_f64() else {
1560 issues.push(ValueIssue {
1561 path: path.to_string(),
1562 message: "expected number".to_string(),
1563 });
1564 return;
1565 };
1566 if let Some(min) = min
1567 && f < *min
1568 {
1569 issues.push(ValueIssue {
1570 path: path.to_string(),
1571 message: format!("expected value >= {min}"),
1572 });
1573 }
1574 if let Some(max) = max
1575 && f > *max
1576 {
1577 issues.push(ValueIssue {
1578 path: path.to_string(),
1579 message: format!("expected value <= {max}"),
1580 });
1581 }
1582 }
1583 SchemaIr::Enum { values } => match json_to_cbor_value(value) {
1584 Ok(cbor_value) => {
1585 if !values.iter().any(|candidate| candidate == &cbor_value) {
1586 issues.push(ValueIssue {
1587 path: path.to_string(),
1588 message: "value not present in enum".to_string(),
1589 });
1590 }
1591 }
1592 Err(err) => {
1593 issues.push(ValueIssue {
1594 path: path.to_string(),
1595 message: format!("failed to normalize enum value: {err}"),
1596 });
1597 }
1598 },
1599 SchemaIr::OneOf { variants } => {
1600 let any_match = variants.iter().any(|variant| {
1601 let mut inner = Vec::new();
1602 validate_json_value(variant, value, path, &mut inner);
1603 inner.is_empty()
1604 });
1605 if !any_match {
1606 issues.push(ValueIssue {
1607 path: path.to_string(),
1608 message: "value does not match any oneOf variant".to_string(),
1609 });
1610 }
1611 }
1612 SchemaIr::Bool => {
1613 if !value.is_boolean() {
1614 issues.push(ValueIssue {
1615 path: path.to_string(),
1616 message: "expected boolean".to_string(),
1617 });
1618 }
1619 }
1620 SchemaIr::Null => {
1621 if !value.is_null() {
1622 issues.push(ValueIssue {
1623 path: path.to_string(),
1624 message: "expected null".to_string(),
1625 });
1626 }
1627 }
1628 SchemaIr::Bytes => {
1629 if !value.is_string() && !value.is_array() {
1630 issues.push(ValueIssue {
1631 path: path.to_string(),
1632 message: "expected bytes-like value".to_string(),
1633 });
1634 }
1635 }
1636 SchemaIr::Ref { id } => {
1637 issues.push(ValueIssue {
1638 path: path.to_string(),
1639 message: format!("schema ref `{id}` is unsupported for strict validation"),
1640 });
1641 }
1642 }
1643}
1644
1645fn json_to_cbor_value(value: &JsonValue) -> Result<ciborium::Value, String> {
1646 let bytes = canonical::to_canonical_cbor_allow_floats(value)
1647 .map_err(|err| format!("CBOR encode failed: {err}"))?;
1648 canonical::from_cbor(&bytes).map_err(|err| format!("CBOR decode failed: {err}"))
1649}
1650
1651struct DoctorWasi {
1652 ctx: WasiCtx,
1653 table: ResourceTable,
1654}
1655
1656impl DoctorWasi {
1657 fn new() -> Result<Self, anyhow::Error> {
1658 let ctx = WasiCtxBuilder::new().build();
1659 Ok(Self {
1660 ctx,
1661 table: ResourceTable::new(),
1662 })
1663 }
1664}
1665
1666impl WasiView for DoctorWasi {
1667 fn ctx(&mut self) -> WasiCtxView<'_> {
1668 WasiCtxView {
1669 ctx: &mut self.ctx,
1670 table: &mut self.table,
1671 }
1672 }
1673}
1674
1675#[cfg(test)]
1676mod tests {
1677 use super::*;
1678 use greentic_types::i18n_text::I18nText;
1679 use greentic_types::schemas::component::v0_6_0::{
1680 ComponentDescribe, ComponentInfo, ComponentOperation, ComponentQaSpec, ComponentRunInput,
1681 ComponentRunOutput, QaMode, RedactionKind, RedactionRule,
1682 };
1683 use serde_json::json;
1684
1685 fn fixture_path(name: &str) -> PathBuf {
1686 Path::new(env!("CARGO_MANIFEST_DIR"))
1687 .join("tests")
1688 .join("fixtures")
1689 .join("doctor")
1690 .join(name)
1691 }
1692
1693 fn load_or_update_fixture(name: &str, expected: &[u8]) -> Vec<u8> {
1694 let path = fixture_path(name);
1695 if std::env::var("UPDATE_DOCTOR_FIXTURES").is_ok() {
1696 if let Some(parent) = path.parent() {
1697 fs::create_dir_all(parent).expect("create fixture dir");
1698 }
1699 fs::write(&path, expected).expect("write fixture");
1700 }
1701 fs::read(&path).expect("fixture exists")
1702 }
1703
1704 fn object_schema(props: Vec<(&str, SchemaIr)>) -> SchemaIr {
1705 let mut properties = BTreeMap::new();
1706 let mut required = Vec::new();
1707 for (name, schema) in props {
1708 properties.insert(name.to_string(), schema);
1709 required.push(name.to_string());
1710 }
1711 SchemaIr::Object {
1712 properties,
1713 required,
1714 additional: AdditionalProperties::Forbid,
1715 }
1716 }
1717
1718 fn good_describe() -> ComponentDescribe {
1719 let info = ComponentInfo {
1720 id: "com.greentic.demo".to_string(),
1721 version: "0.1.0".to_string(),
1722 role: "tool".to_string(),
1723 display_name: None,
1724 };
1725 let input_schema = object_schema(vec![(
1726 "name",
1727 SchemaIr::String {
1728 min_len: Some(1),
1729 max_len: None,
1730 regex: None,
1731 format: None,
1732 },
1733 )]);
1734 let output_schema = object_schema(vec![("ok", SchemaIr::Bool)]);
1735 let config_schema = object_schema(vec![("enabled", SchemaIr::Bool)]);
1736 let schema_hash =
1737 schema_hash(&input_schema, &output_schema, &config_schema).expect("schema hash");
1738 let operation = ComponentOperation {
1739 id: "run".to_string(),
1740 display_name: None,
1741 input: ComponentRunInput {
1742 schema: input_schema,
1743 },
1744 output: ComponentRunOutput {
1745 schema: output_schema,
1746 },
1747 defaults: BTreeMap::new(),
1748 redactions: Vec::new(),
1749 constraints: BTreeMap::new(),
1750 schema_hash,
1751 };
1752 ComponentDescribe {
1753 info,
1754 provided_capabilities: Vec::new(),
1755 required_capabilities: Vec::new(),
1756 metadata: BTreeMap::new(),
1757 operations: vec![operation],
1758 config_schema,
1759 }
1760 }
1761
1762 fn bad_missing_ops_describe() -> ComponentDescribe {
1763 let mut describe = good_describe();
1764 describe.operations.clear();
1765 describe
1766 }
1767
1768 fn bad_unconstrained_describe() -> ComponentDescribe {
1769 let info = ComponentInfo {
1770 id: "com.greentic.demo".to_string(),
1771 version: "0.1.0".to_string(),
1772 role: "tool".to_string(),
1773 display_name: None,
1774 };
1775 let input_schema = SchemaIr::String {
1776 min_len: None,
1777 max_len: None,
1778 regex: None,
1779 format: None,
1780 };
1781 let output_schema = SchemaIr::Bool;
1782 let config_schema = SchemaIr::Object {
1783 properties: BTreeMap::new(),
1784 required: Vec::new(),
1785 additional: AdditionalProperties::Allow,
1786 };
1787 let schema_hash =
1788 schema_hash(&input_schema, &output_schema, &config_schema).expect("schema hash");
1789 let operation = ComponentOperation {
1790 id: "run".to_string(),
1791 display_name: None,
1792 input: ComponentRunInput {
1793 schema: input_schema,
1794 },
1795 output: ComponentRunOutput {
1796 schema: output_schema,
1797 },
1798 defaults: BTreeMap::new(),
1799 redactions: vec![RedactionRule {
1800 json_pointer: "/secret".to_string(),
1801 kind: RedactionKind::Secret,
1802 }],
1803 constraints: BTreeMap::new(),
1804 schema_hash,
1805 };
1806 ComponentDescribe {
1807 info,
1808 provided_capabilities: Vec::new(),
1809 required_capabilities: Vec::new(),
1810 metadata: BTreeMap::new(),
1811 operations: vec![operation],
1812 config_schema,
1813 }
1814 }
1815
1816 fn bad_hash_describe() -> ComponentDescribe {
1817 let mut describe = good_describe();
1818 if let Some(op) = describe.operations.first_mut() {
1819 op.schema_hash = "deadbeef".to_string();
1820 }
1821 describe
1822 }
1823
1824 fn encode_describe(describe: &ComponentDescribe) -> Vec<u8> {
1825 canonical::to_canonical_cbor_allow_floats(describe).expect("encode cbor")
1826 }
1827
1828 fn has_code(report: &DoctorReport, code: &str) -> bool {
1829 report.diagnostics.iter().any(|diag| diag.code == code)
1830 }
1831
1832 #[test]
1833 fn fixtures_match_expected_payloads() {
1834 let good_bytes = encode_describe(&good_describe());
1835 let fixture = load_or_update_fixture("good_component_describe.cbor", &good_bytes);
1836 assert_eq!(fixture, good_bytes);
1837
1838 let missing_ops_bytes = encode_describe(&bad_missing_ops_describe());
1839 let fixture = load_or_update_fixture(
1840 "bad_component_describe_missing_ops.cbor",
1841 &missing_ops_bytes,
1842 );
1843 assert_eq!(fixture, missing_ops_bytes);
1844
1845 let unconstrained_bytes = encode_describe(&bad_unconstrained_describe());
1846 let fixture = load_or_update_fixture(
1847 "bad_component_describe_unconstrained_schema.cbor",
1848 &unconstrained_bytes,
1849 );
1850 assert_eq!(fixture, unconstrained_bytes);
1851
1852 let hash_bytes = encode_describe(&bad_hash_describe());
1853 let fixture =
1854 load_or_update_fixture("bad_component_describe_hash_mismatch.cbor", &hash_bytes);
1855 assert_eq!(fixture, hash_bytes);
1856 }
1857
1858 #[test]
1859 fn doctor_accepts_good_describe_fixture() {
1860 let bytes = load_or_update_fixture(
1861 "good_component_describe.cbor",
1862 &encode_describe(&good_describe()),
1863 );
1864 let describe: ComponentDescribe = decode_cbor(&bytes).expect("decode describe");
1865 let mut report = DoctorReport::default();
1866 report.validate_info(&describe.info, "describe");
1867 report.validate_describe(&describe, &bytes);
1868 report.finalize();
1869 assert!(
1870 !report.has_errors(),
1871 "expected no diagnostics, got {:?}",
1872 report.diagnostics
1873 );
1874 }
1875
1876 #[test]
1877 fn doctor_rejects_missing_ops_fixture() {
1878 let bytes = load_or_update_fixture(
1879 "bad_component_describe_missing_ops.cbor",
1880 &encode_describe(&bad_missing_ops_describe()),
1881 );
1882 let describe: ComponentDescribe = decode_cbor(&bytes).expect("decode describe");
1883 let mut report = DoctorReport::default();
1884 report.validate_describe(&describe, &bytes);
1885 report.finalize();
1886 assert!(has_code(&report, "doctor.describe.missing_operations"));
1887 }
1888
1889 #[test]
1890 fn doctor_rejects_unconstrained_schema_fixture() {
1891 let bytes = load_or_update_fixture(
1892 "bad_component_describe_unconstrained_schema.cbor",
1893 &encode_describe(&bad_unconstrained_describe()),
1894 );
1895 let describe: ComponentDescribe = decode_cbor(&bytes).expect("decode describe");
1896 let mut report = DoctorReport::default();
1897 report.validate_describe(&describe, &bytes);
1898 report.finalize();
1899 assert!(
1900 has_code(&report, "doctor.schema.object.unconstrained")
1901 || has_code(&report, "doctor.schema.string.unconstrained"),
1902 "expected unconstrained schema diagnostics, got {:?}",
1903 report.diagnostics
1904 );
1905 }
1906
1907 #[test]
1908 fn doctor_rejects_hash_mismatch_fixture() {
1909 let bytes = load_or_update_fixture(
1910 "bad_component_describe_hash_mismatch.cbor",
1911 &encode_describe(&bad_hash_describe()),
1912 );
1913 let describe: ComponentDescribe = decode_cbor(&bytes).expect("decode describe");
1914 let mut report = DoctorReport::default();
1915 report.validate_describe(&describe, &bytes);
1916 report.finalize();
1917 assert!(has_code(&report, "doctor.describe.schema_hash.mismatch"));
1918 }
1919
1920 #[test]
1921 fn doctor_flags_missing_i18n_keys() {
1922 let qa_spec = ComponentQaSpec {
1923 mode: QaMode::Default,
1924 title: I18nText::new("qa.title", None),
1925 description: Some(I18nText::new("qa.desc", None)),
1926 questions: vec![
1927 serde_json::from_value(serde_json::json!({
1928 "id": "name",
1929 "label": I18nText::new("qa.question.name", None),
1930 "help": null,
1931 "error": null,
1932 "kind": {
1933 "type": "choice",
1934 "options": [{
1935 "value": "one",
1936 "label": I18nText::new("qa.option.one", None)
1937 }]
1938 },
1939 "required": true,
1940 "default": null
1941 }))
1942 .expect("question should deserialize"),
1943 ],
1944 defaults: BTreeMap::new(),
1945 };
1946 let mut qa_specs = BTreeMap::new();
1947 qa_specs.insert("default".to_string(), qa_spec);
1948
1949 let keys = BTreeSet::from_iter(["qa.title".to_string()]);
1950 let mut report = DoctorReport::default();
1951 report.validate_i18n(&Some(keys), &qa_specs);
1952 report.finalize();
1953 assert!(has_code(&report, "doctor.i18n.key_missing"));
1954 }
1955
1956 #[test]
1957 fn validation_issues_include_field_paths_and_hash_context() {
1958 let describe = good_describe();
1959 let describe_bytes = encode_describe(&describe);
1960 let context = describe_hash_context(&describe, &describe_bytes);
1961
1962 let mut issues = Vec::new();
1963 let invalid_config = json!({ "enabled": "true" });
1964 validate_json_value(&describe.config_schema, &invalid_config, "$", &mut issues);
1965 assert!(
1966 !issues.is_empty(),
1967 "expected at least one schema validation issue"
1968 );
1969
1970 let rendered = format_validation_issues(&issues);
1971 assert!(
1972 rendered.contains("$/enabled"),
1973 "issues should include field path"
1974 );
1975 assert!(
1976 rendered.contains("expected boolean"),
1977 "issues should include type mismatch message"
1978 );
1979 assert!(
1980 context.contains("describe_hash="),
1981 "context should include describe hash"
1982 );
1983 assert!(
1984 context.contains("schema_hashes=[run="),
1985 "context should include operation schema hash"
1986 );
1987 }
1988
1989 #[test]
1990 fn non_map_config_reports_object_error_with_hash_context() {
1991 let describe = good_describe();
1992 let describe_bytes = encode_describe(&describe);
1993 let context = describe_hash_context(&describe, &describe_bytes);
1994
1995 let mut issues = Vec::new();
1996 let non_map = json!(42);
1997 validate_json_value(&describe.config_schema, &non_map, "$", &mut issues);
1998
1999 let rendered = format_validation_issues(&issues);
2000 assert!(
2001 rendered.contains("$: expected object"),
2002 "non-map config should be rejected with object error"
2003 );
2004 let combined = format!(
2005 "apply-answers(update) violates config_schema: {}; {}",
2006 rendered, context
2007 );
2008 assert!(combined.contains("describe_hash="));
2009 assert!(combined.contains("schema_hashes=[run="));
2010 }
2011}