1use std::fs;
2use std::path::{Path, PathBuf};
3
4use clap::{Args, Parser};
5use serde::Serialize;
6use serde_json::Value;
7use wasmtime::component::{Component, Linker, Val};
8use wasmtime::{Engine, Store};
9use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
10
11use super::path::strip_file_scheme;
12use crate::describe::{DescribePayload, from_wit_world};
13use crate::embedded_compare::{
14 EmbeddedManifestComparisonReport, compare_embedded_with_describe,
15 compare_embedded_with_manifest,
16};
17use crate::embedded_descriptor::{
18 EMBEDDED_COMPONENT_MANIFEST_SECTION_V1, read_and_verify_embedded_component_manifest_section_v1,
19};
20use crate::{ComponentError, PreparedComponent, parse_manifest, prepare_component_with_manifest};
21use greentic_types::cbor::canonical;
22use greentic_types::schemas::common::schema_ir::{AdditionalProperties, SchemaIr};
23use greentic_types::schemas::component::v0_6_0::{ComponentDescribe, schema_hash};
24
25#[derive(Args, Debug, Clone)]
26#[command(about = "Inspect a Greentic component artifact")]
27pub struct InspectArgs {
28 #[arg(value_name = "TARGET", required_unless_present = "describe")]
30 pub target: Option<String>,
31 #[arg(long)]
33 pub manifest: Option<PathBuf>,
34 #[arg(long)]
36 pub describe: Option<PathBuf>,
37 #[arg(long)]
39 pub json: bool,
40 #[arg(long)]
42 pub verify: bool,
43 #[arg(long)]
45 pub strict: bool,
46}
47
48#[derive(Parser, Debug)]
49struct InspectCli {
50 #[command(flatten)]
51 args: InspectArgs,
52}
53
54pub fn parse_from_cli() -> InspectArgs {
55 InspectCli::parse().args
56}
57
58#[derive(Default)]
59pub struct InspectResult {
60 pub warnings: Vec<String>,
61}
62
63pub fn run(args: &InspectArgs) -> Result<InspectResult, ComponentError> {
64 if args.describe.is_some() {
65 return inspect_describe(args);
66 }
67
68 if should_inspect_wasm_artifact(args) {
69 return inspect_artifact(args);
70 }
71
72 let target = args
73 .target
74 .as_ref()
75 .ok_or_else(|| ComponentError::Doctor("inspect target is required".to_string()))?;
76 let manifest_override = args.manifest.as_deref().map(strip_file_scheme);
77 let prepared = prepare_component_with_manifest(target, manifest_override.as_deref())?;
78 if args.json {
79 let json = serde_json::to_string_pretty(&build_report(&prepared))
80 .expect("serializing inspect report");
81 println!("{json}");
82 } else {
83 println!("component: {}", prepared.manifest.id.as_str());
84 println!(" wasm: {}", prepared.wasm_path.display());
85 println!(" world ok: {}", prepared.world_ok);
86 println!(" hash: {}", prepared.wasm_hash);
87 println!(" supports: {:?}", prepared.manifest.supports);
88 println!(
89 " profiles: default={:?} supported={:?}",
90 prepared.manifest.profiles.default, prepared.manifest.profiles.supported
91 );
92 println!(
93 " lifecycle: init={} health={} shutdown={}",
94 prepared.lifecycle.init, prepared.lifecycle.health, prepared.lifecycle.shutdown
95 );
96 let caps = &prepared.manifest.capabilities;
97 println!(
98 " capabilities: wasi(fs={}, env={}, random={}, clocks={}) host(secrets={}, state={}, messaging={}, events={}, http={}, telemetry={}, iac={})",
99 caps.wasi.filesystem.is_some(),
100 caps.wasi.env.is_some(),
101 caps.wasi.random,
102 caps.wasi.clocks,
103 caps.host.secrets.is_some(),
104 caps.host.state.is_some(),
105 caps.host.messaging.is_some(),
106 caps.host.events.is_some(),
107 caps.host.http.is_some(),
108 caps.host.telemetry.is_some(),
109 caps.host.iac.is_some(),
110 );
111 println!(
112 " limits: {}",
113 prepared
114 .manifest
115 .limits
116 .as_ref()
117 .map(|l| format!("{} MB / {} ms", l.memory_mb, l.wall_time_ms))
118 .unwrap_or_else(|| "default".into())
119 );
120 println!(
121 " telemetry prefix: {}",
122 prepared
123 .manifest
124 .telemetry
125 .as_ref()
126 .map(|t| t.span_prefix.as_str())
127 .unwrap_or("<none>")
128 );
129 println!(" describe versions: {}", prepared.describe.versions.len());
130 println!(" redaction paths: {}", prepared.redaction_paths().len());
131 println!(" defaults applied: {}", prepared.defaults_applied().len());
132 }
133 Ok(InspectResult::default())
134}
135
136#[derive(Debug, Serialize)]
137struct EmbeddedInspectStatus {
138 present: bool,
139 section_name: String,
140 #[serde(skip_serializing_if = "Option::is_none")]
141 envelope_version: Option<u32>,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 envelope_kind: Option<String>,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 payload_hash_blake3: Option<String>,
146 hash_verified: bool,
147 #[serde(skip_serializing_if = "Option::is_none")]
148 manifest: Option<crate::embedded_descriptor::EmbeddedComponentManifestV1>,
149 #[serde(skip_serializing_if = "Option::is_none")]
150 compare_manifest: Option<EmbeddedManifestComparisonReport>,
151 #[serde(skip_serializing_if = "Option::is_none")]
152 compare_describe: Option<EmbeddedManifestComparisonReport>,
153 #[serde(skip_serializing_if = "Vec::is_empty")]
154 warnings: Vec<String>,
155}
156
157#[derive(Debug, Serialize)]
158struct ArtifactInspectReport {
159 wasm_path: PathBuf,
160 #[serde(skip_serializing_if = "Option::is_none")]
161 manifest: Option<ArtifactManifestStatus>,
162 #[serde(skip_serializing_if = "Option::is_none")]
163 describe: Option<ArtifactDescribeStatus>,
164 embedded: EmbeddedInspectStatus,
165}
166
167#[derive(Debug, Serialize)]
168struct ArtifactManifestStatus {
169 path: PathBuf,
170 component_id: String,
171 version: String,
172 #[serde(skip_serializing_if = "Option::is_none")]
173 compare_embedded: Option<EmbeddedManifestComparisonReport>,
174}
175
176#[derive(Debug, Serialize)]
177struct ArtifactDescribeStatus {
178 status: String,
179 #[serde(skip_serializing_if = "Option::is_none")]
180 source: Option<String>,
181 #[serde(skip_serializing_if = "Option::is_none")]
182 name: Option<String>,
183 #[serde(skip_serializing_if = "Option::is_none")]
184 schema_id: Option<String>,
185 #[serde(skip_serializing_if = "Option::is_none")]
186 world: Option<String>,
187 #[serde(skip_serializing_if = "Option::is_none")]
188 versions: Option<Vec<String>>,
189 #[serde(skip_serializing_if = "Option::is_none")]
190 version_count: Option<usize>,
191 #[serde(skip_serializing_if = "Option::is_none")]
192 function_count: Option<usize>,
193 #[serde(skip_serializing_if = "Option::is_none")]
194 operation_count: Option<usize>,
195 #[serde(skip_serializing_if = "Option::is_none")]
196 compare_embedded: Option<EmbeddedManifestComparisonReport>,
197 #[serde(skip_serializing_if = "Option::is_none")]
198 reason: Option<String>,
199}
200
201fn inspect_artifact(args: &InspectArgs) -> Result<InspectResult, ComponentError> {
202 let target = args
203 .target
204 .as_ref()
205 .ok_or_else(|| ComponentError::Doctor("inspect target is required".to_string()))?;
206 let wasm_path = resolve_wasm_path(target).map_err(ComponentError::Doctor)?;
207 let manifest_path = args
208 .manifest
209 .clone()
210 .or_else(|| discover_manifest_path(&wasm_path, Path::new(target)));
211 let wasm_bytes = fs::read(&wasm_path)
212 .map_err(|err| ComponentError::Doctor(format!("failed to read wasm: {err}")))?;
213 let mut warnings = Vec::new();
214 let verified =
215 read_and_verify_embedded_component_manifest_section_v1(&wasm_bytes).map_err(|err| {
216 ComponentError::Doctor(format!("failed to read embedded manifest: {err}"))
217 })?;
218
219 let mut compare_manifest = None;
220 let mut compare_describe = None;
221 let mut envelope_version = None;
222 let mut envelope_kind = None;
223 let mut payload_hash_blake3 = None;
224 let mut manifest = None;
225 let mut external_manifest_summary = None;
226 let mut describe_status = None;
227 let present = verified.is_some();
228 let hash_verified = verified.is_some();
229
230 if let Some(manifest_path) = manifest_path.as_ref() {
231 let raw = fs::read_to_string(manifest_path).map_err(|err| {
232 ComponentError::Doctor(format!(
233 "failed to read manifest {}: {err}",
234 manifest_path.display()
235 ))
236 })?;
237 let parsed = parse_manifest(&raw).map_err(|err| {
238 ComponentError::Doctor(format!(
239 "failed to parse manifest {}: {err}",
240 manifest_path.display()
241 ))
242 })?;
243 external_manifest_summary =
244 Some((parsed.id.as_str().to_string(), parsed.version.to_string()));
245 if let Some(verified) = verified.as_ref() {
246 compare_manifest = Some(compare_embedded_with_manifest(&verified.manifest, &parsed));
247 }
248 }
249
250 if let Some(verified) = verified {
251 envelope_version = Some(verified.envelope.version);
252 envelope_kind = Some(verified.envelope.kind.clone());
253 payload_hash_blake3 = Some(verified.envelope.payload_hash_blake3.clone());
254 manifest = Some(verified.manifest.clone());
255 match call_describe(&wasm_path) {
256 Ok(bytes) => {
257 let payload = strip_self_describe_tag(&bytes);
258 match canonical::from_cbor::<ComponentDescribe>(payload) {
259 Ok(describe) => {
260 let operation_count = describe.operations.len();
261 let describe_id = describe.info.id.clone();
262 describe_status = Some(ArtifactDescribeStatus {
263 status: "available".to_string(),
264 source: Some("export".to_string()),
265 name: Some(describe_id),
266 schema_id: None,
267 world: None,
268 versions: None,
269 version_count: None,
270 function_count: None,
271 operation_count: Some(operation_count),
272 compare_embedded: None,
273 reason: None,
274 });
275 compare_describe = Some(compare_embedded_with_describe(
276 &verified.manifest,
277 &describe,
278 ));
279 }
280 Err(err) => {
281 let reason = format!("decode failed: {err}");
282 warnings.push(format!("describe {reason}"));
283 describe_status = Some(ArtifactDescribeStatus {
284 status: "unavailable".to_string(),
285 source: Some("export".to_string()),
286 name: None,
287 schema_id: None,
288 world: None,
289 versions: None,
290 version_count: None,
291 function_count: None,
292 operation_count: None,
293 compare_embedded: None,
294 reason: Some(reason),
295 });
296 }
297 }
298 }
299 Err(err) => {
300 if err.contains("missing export interface component-descriptor") {
301 match from_wit_world(&wasm_path, "greentic:component/component@0.6.0") {
302 Ok(payload) => {
303 let function_count = payload
304 .versions
305 .first()
306 .and_then(|version| version.schema.get("functions"))
307 .and_then(|functions| functions.as_array())
308 .map(|functions| functions.len());
309 let world = payload
310 .versions
311 .first()
312 .and_then(|version| version.schema.get("world"))
313 .and_then(|world| world.as_str())
314 .map(str::to_string);
315 let versions = payload
316 .versions
317 .iter()
318 .map(|version| version.version.to_string())
319 .collect::<Vec<_>>();
320 describe_status = Some(ArtifactDescribeStatus {
321 status: "available".to_string(),
322 source: Some("wit-world".to_string()),
323 name: Some(payload.name),
324 schema_id: payload.schema_id,
325 world,
326 versions: Some(versions),
327 version_count: Some(payload.versions.len()),
328 function_count,
329 operation_count: None,
330 compare_embedded: None,
331 reason: Some("derived from exported WIT world".to_string()),
332 });
333 }
334 Err(fallback_err) => {
335 describe_status = Some(ArtifactDescribeStatus {
336 status: "unavailable".to_string(),
337 source: Some("wit-world".to_string()),
338 name: None,
339 schema_id: None,
340 world: None,
341 versions: None,
342 version_count: None,
343 function_count: None,
344 operation_count: None,
345 compare_embedded: None,
346 reason: Some(format!(
347 "missing export interface component-descriptor; WIT fallback failed: {fallback_err}"
348 )),
349 });
350 }
351 }
352 } else {
353 warnings.push(format!("describe unavailable: {err}"));
354 describe_status = Some(ArtifactDescribeStatus {
355 status: "unavailable".to_string(),
356 source: Some("export".to_string()),
357 name: None,
358 schema_id: None,
359 world: None,
360 versions: None,
361 version_count: None,
362 function_count: None,
363 operation_count: None,
364 compare_embedded: None,
365 reason: Some(err),
366 });
367 }
368 }
369 }
370 }
371
372 if let (Some(compare), Some(status)) = (compare_describe.clone(), describe_status.as_mut()) {
373 status.compare_embedded = Some(compare);
374 }
375
376 let report = ArtifactInspectReport {
377 wasm_path,
378 manifest: manifest_path.as_ref().and_then(|path| {
379 external_manifest_summary
380 .as_ref()
381 .map(|(id, version)| ArtifactManifestStatus {
382 path: path.clone(),
383 component_id: id.clone(),
384 version: version.clone(),
385 compare_embedded: compare_manifest.clone(),
386 })
387 }),
388 describe: describe_status,
389 embedded: EmbeddedInspectStatus {
390 present,
391 section_name: EMBEDDED_COMPONENT_MANIFEST_SECTION_V1.to_string(),
392 envelope_version,
393 envelope_kind,
394 payload_hash_blake3,
395 hash_verified,
396 manifest,
397 compare_manifest,
398 compare_describe,
399 warnings: warnings.clone(),
400 },
401 };
402
403 if args.json {
404 let json = serde_json::to_string_pretty(&report)
405 .map_err(|err| ComponentError::Doctor(format!("failed to encode json: {err}")))?;
406 println!("{json}");
407 } else {
408 println!("wasm: {}", report.wasm_path.display());
409 if let Some(manifest) = &report.manifest {
410 println!("manifest: {}", manifest.path.display());
411 println!(" component: {}", manifest.component_id);
412 println!(" version: {}", manifest.version);
413 if let Some(compare) = &manifest.compare_embedded {
414 println!(" embedded vs manifest: {:?}", compare.overall);
415 }
416 }
417 println!(
418 "embedded manifest: {}",
419 if report.embedded.present {
420 "present"
421 } else {
422 "missing"
423 }
424 );
425 println!(" section: {}", report.embedded.section_name);
426 if let Some(version) = report.embedded.envelope_version {
427 println!(" envelope version: {version}");
428 }
429 if let Some(kind) = &report.embedded.envelope_kind {
430 println!(" kind: {kind}");
431 }
432 if let Some(hash) = &report.embedded.payload_hash_blake3 {
433 println!(" payload hash: {hash}");
434 }
435 println!(" hash verified: {}", report.embedded.hash_verified);
436 if let Some(manifest) = &report.embedded.manifest {
437 println!(" component: {}", manifest.id);
438 println!(" name: {}", manifest.name);
439 println!(" version: {}", manifest.version);
440 println!(" world: {}", manifest.world);
441 println!(" operations: {}", manifest.operations.len());
442 let operation_names = manifest
443 .operations
444 .iter()
445 .map(|op| op.name.as_str())
446 .collect::<Vec<_>>();
447 if !operation_names.is_empty() {
448 println!(" operation names: {}", operation_names.join(", "));
449 }
450 if let Some(default_operation) = &manifest.default_operation {
451 println!(" default operation: {default_operation}");
452 }
453 if !manifest.supports.is_empty() {
454 println!(" supports: {:?}", manifest.supports);
455 }
456 println!(" capabilities: {:?}", manifest.capabilities);
457 println!(
458 " secret requirements: {}",
459 manifest.secret_requirements.len()
460 );
461 println!(" profiles: {:?}", manifest.profiles);
462 if let Some(limits) = &manifest.limits {
463 println!(
464 " limits: memory_mb={} wall_time_ms={} fuel={:?} files={:?}",
465 limits.memory_mb, limits.wall_time_ms, limits.fuel, limits.files
466 );
467 }
468 if let Some(telemetry) = &manifest.telemetry {
469 println!(" telemetry span prefix: {}", telemetry.span_prefix);
470 println!(" telemetry attributes: {:?}", telemetry.attributes);
471 println!(" telemetry emit node spans: {}", telemetry.emit_node_spans);
472 }
473 }
474 if let Some(describe) = &report.describe {
475 println!("describe: {}", describe.status);
476 if let Some(source) = &describe.source {
477 println!(" source: {source}");
478 }
479 if let Some(name) = &describe.name {
480 println!(" name: {name}");
481 }
482 if let Some(schema_id) = &describe.schema_id {
483 println!(" schema id: {schema_id}");
484 }
485 if let Some(world) = &describe.world {
486 println!(" world: {world}");
487 }
488 if let Some(versions) = &describe.versions {
489 println!(" versions: {}", versions.join(", "));
490 }
491 if let Some(version_count) = describe.version_count {
492 println!(" version count: {version_count}");
493 }
494 if let Some(function_count) = describe.function_count {
495 println!(" functions: {function_count}");
496 }
497 if let Some(operation_count) = describe.operation_count {
498 println!(" operations: {operation_count}");
499 }
500 if let Some(compare) = &describe.compare_embedded {
501 println!(" embedded vs describe: {:?}", compare.overall);
502 }
503 if let Some(reason) = &describe.reason {
504 println!(" reason: {reason}");
505 }
506 }
507 }
508
509 Ok(InspectResult { warnings })
510}
511
512pub fn emit_warnings(warnings: &[String]) {
513 for warning in warnings {
514 eprintln!("warning: {warning}");
515 }
516}
517
518pub fn build_report(prepared: &PreparedComponent) -> Value {
519 let caps = &prepared.manifest.capabilities;
520 serde_json::json!({
521 "manifest": &prepared.manifest,
522 "manifest_path": prepared.manifest_path,
523 "wasm_path": prepared.wasm_path,
524 "wasm_hash": prepared.wasm_hash,
525 "hash_verified": prepared.hash_verified,
526 "world": {
527 "expected": prepared.manifest.world.as_str(),
528 "ok": prepared.world_ok,
529 },
530 "lifecycle": {
531 "init": prepared.lifecycle.init,
532 "health": prepared.lifecycle.health,
533 "shutdown": prepared.lifecycle.shutdown,
534 },
535 "describe": prepared.describe,
536 "capabilities": prepared.manifest.capabilities,
537 "limits": prepared.manifest.limits,
538 "telemetry": prepared.manifest.telemetry,
539 "redactions": prepared
540 .redaction_paths()
541 .iter()
542 .map(|p| p.as_str().to_string())
543 .collect::<Vec<_>>(),
544 "defaults_applied": prepared.defaults_applied(),
545 "summary": {
546 "supports": prepared.manifest.supports,
547 "profiles": prepared.manifest.profiles,
548 "capabilities": {
549 "wasi": {
550 "filesystem": caps.wasi.filesystem.is_some(),
551 "env": caps.wasi.env.is_some(),
552 "random": caps.wasi.random,
553 "clocks": caps.wasi.clocks
554 },
555 "host": {
556 "secrets": caps.host.secrets.is_some(),
557 "state": caps.host.state.is_some(),
558 "messaging": caps.host.messaging.is_some(),
559 "events": caps.host.events.is_some(),
560 "http": caps.host.http.is_some(),
561 "telemetry": caps.host.telemetry.is_some(),
562 "iac": caps.host.iac.is_some()
563 }
564 },
565 }
566 })
567}
568
569fn should_inspect_wasm_artifact(args: &InspectArgs) -> bool {
570 let Some(target) = args.target.as_ref() else {
571 return false;
572 };
573 let target = strip_file_scheme(Path::new(target));
574 target.is_dir()
575 || target
576 .extension()
577 .and_then(|ext| ext.to_str())
578 .map(|ext| ext.eq_ignore_ascii_case("wasm"))
579 .unwrap_or(false)
580}
581
582fn discover_manifest_path(wasm_path: &Path, target_path: &Path) -> Option<PathBuf> {
583 let mut candidates = Vec::new();
584 if target_path.is_dir() {
585 candidates.push(target_path.join("component.manifest.json"));
586 }
587 if let Some(parent) = wasm_path.parent() {
588 candidates.push(parent.join("component.manifest.json"));
589 if let Some(grandparent) = parent.parent() {
590 candidates.push(grandparent.join("component.manifest.json"));
591 }
592 }
593 candidates.into_iter().find(|path| path.is_file())
594}
595
596fn inspect_describe(args: &InspectArgs) -> Result<InspectResult, ComponentError> {
597 let mut warnings = Vec::new();
598 let mut wasm_path = None;
599 let bytes = if let Some(path) = args.describe.as_ref() {
600 let path = strip_file_scheme(path);
601 fs::read(path)
602 .map_err(|err| ComponentError::Doctor(format!("failed to read describe file: {err}")))?
603 } else {
604 let target = args
605 .target
606 .as_ref()
607 .ok_or_else(|| ComponentError::Doctor("inspect target is required".to_string()))?;
608 let path = resolve_wasm_path(target).map_err(ComponentError::Doctor)?;
609 wasm_path = Some(path.clone());
610 call_describe(&path).map_err(ComponentError::Doctor)?
611 };
612
613 let payload = strip_self_describe_tag(&bytes);
614 if let Err(err) = ensure_canonical_allow_floats(payload) {
615 warnings.push(format!("describe payload not canonical: {err}"));
616 }
617 if let Ok(describe) = canonical::from_cbor::<ComponentDescribe>(payload) {
618 let mut report = DescribeReport::from(describe, args.verify)?;
619 report.wasm_path = wasm_path;
620
621 if args.json {
622 let json = serde_json::to_string_pretty(&report)
623 .map_err(|err| ComponentError::Doctor(format!("failed to encode json: {err}")))?;
624 println!("{json}");
625 } else {
626 emit_describe_human(&report);
627 }
628
629 let verify_failed = args.verify
630 && report
631 .operations
632 .iter()
633 .any(|op| matches!(op.schema_hash_valid, Some(false)));
634 if verify_failed {
635 return Err(ComponentError::Doctor(
636 "schema_hash verification failed".to_string(),
637 ));
638 }
639 return Ok(InspectResult { warnings });
640 }
641
642 let derived: DescribePayload = canonical::from_cbor(payload)
643 .map_err(|err| ComponentError::Doctor(format!("describe decode failed: {err}")))?;
644 if args.verify {
645 warnings.push("verify skipped for WIT-derived describe payload".to_string());
646 }
647 let mut report = DerivedDescribeReport::from(derived);
648 report.wasm_path = wasm_path;
649
650 if args.json {
651 let json = serde_json::to_string_pretty(&report)
652 .map_err(|err| ComponentError::Doctor(format!("failed to encode json: {err}")))?;
653 println!("{json}");
654 } else {
655 emit_derived_describe_human(&report);
656 }
657
658 Ok(InspectResult { warnings })
659}
660
661fn emit_describe_human(report: &DescribeReport) {
662 println!("component: {}", report.info.id);
663 println!(" version: {}", report.info.version);
664 println!(" role: {}", report.info.role);
665 println!(" operations: {}", report.operations.len());
666 for op in &report.operations {
667 println!(" - {} ({})", op.id, op.schema_hash);
668 println!(" input: {}", op.input.summary);
669 println!(" output: {}", op.output.summary);
670 if let Some(status) = op.schema_hash_valid {
671 println!(" schema_hash ok: {status}");
672 }
673 }
674 println!(" config: {}", report.config.summary);
675}
676
677#[derive(Debug, Serialize)]
678struct DerivedDescribeReport {
679 kind: &'static str,
680 name: String,
681 #[serde(skip_serializing_if = "Option::is_none")]
682 schema_id: Option<String>,
683 versions: Vec<DerivedDescribeVersionReport>,
684 #[serde(skip_serializing_if = "Option::is_none")]
685 wasm_path: Option<PathBuf>,
686}
687
688#[derive(Debug, Serialize)]
689struct DerivedDescribeVersionReport {
690 version: String,
691 #[serde(skip_serializing_if = "Option::is_none")]
692 world: Option<String>,
693 function_count: usize,
694}
695
696impl DerivedDescribeReport {
697 fn from(payload: DescribePayload) -> Self {
698 Self {
699 kind: "wit-derived",
700 name: payload.name,
701 schema_id: payload.schema_id,
702 versions: payload
703 .versions
704 .into_iter()
705 .map(|version| DerivedDescribeVersionReport {
706 version: version.version.to_string(),
707 world: version
708 .schema
709 .get("world")
710 .and_then(|world| world.as_str())
711 .map(str::to_string),
712 function_count: version
713 .schema
714 .get("functions")
715 .and_then(|functions| functions.as_array())
716 .map(|functions| functions.len())
717 .unwrap_or(0),
718 })
719 .collect(),
720 wasm_path: None,
721 }
722 }
723}
724
725fn emit_derived_describe_human(report: &DerivedDescribeReport) {
726 println!("describe: wit-derived");
727 println!(" name: {}", report.name);
728 if let Some(schema_id) = &report.schema_id {
729 println!(" schema id: {schema_id}");
730 }
731 for version in &report.versions {
732 println!(" - version: {}", version.version);
733 if let Some(world) = &version.world {
734 println!(" world: {world}");
735 }
736 println!(" functions: {}", version.function_count);
737 }
738}
739
740#[derive(Debug, Serialize)]
741struct DescribeReport {
742 info: ComponentInfoSummary,
743 operations: Vec<OperationSummary>,
744 config: SchemaSummary,
745 #[serde(skip_serializing_if = "Option::is_none")]
746 wasm_path: Option<PathBuf>,
747}
748
749impl DescribeReport {
750 fn from(describe: ComponentDescribe, verify: bool) -> Result<Self, ComponentError> {
751 let info = ComponentInfoSummary {
752 id: describe.info.id,
753 version: describe.info.version,
754 role: describe.info.role,
755 };
756 let config = SchemaSummary::from_schema(&describe.config_schema);
757 let mut operations = Vec::new();
758 for op in describe.operations {
759 let input = SchemaSummary::from_schema(&op.input.schema);
760 let output = SchemaSummary::from_schema(&op.output.schema);
761 let schema_hash_valid = if verify {
762 let expected =
763 schema_hash(&op.input.schema, &op.output.schema, &describe.config_schema)
764 .map_err(|err| {
765 ComponentError::Doctor(format!("schema_hash failed: {err}"))
766 })?;
767 Some(expected == op.schema_hash)
768 } else {
769 None
770 };
771 operations.push(OperationSummary {
772 id: op.id,
773 schema_hash: op.schema_hash,
774 schema_hash_valid,
775 input,
776 output,
777 });
778 }
779 Ok(Self {
780 info,
781 operations,
782 config,
783 wasm_path: None,
784 })
785 }
786}
787
788#[derive(Debug, Serialize)]
789struct ComponentInfoSummary {
790 id: String,
791 version: String,
792 role: String,
793}
794
795#[derive(Debug, Serialize)]
796struct OperationSummary {
797 id: String,
798 schema_hash: String,
799 #[serde(skip_serializing_if = "Option::is_none")]
800 schema_hash_valid: Option<bool>,
801 input: SchemaSummary,
802 output: SchemaSummary,
803}
804
805#[derive(Debug, Serialize)]
806struct SchemaSummary {
807 kind: String,
808 summary: String,
809}
810
811impl SchemaSummary {
812 fn from_schema(schema: &SchemaIr) -> Self {
813 let (kind, summary) = summarize_schema(schema);
814 Self { kind, summary }
815 }
816}
817
818fn summarize_schema(schema: &SchemaIr) -> (String, String) {
819 match schema {
820 SchemaIr::Object {
821 properties,
822 required,
823 additional,
824 } => {
825 let add = match additional {
826 AdditionalProperties::Allow => "allow",
827 AdditionalProperties::Forbid => "forbid",
828 AdditionalProperties::Schema(_) => "schema",
829 };
830 let summary = format!(
831 "object{{fields={}, required={}, additional={add}}}",
832 properties.len(),
833 required.len()
834 );
835 ("object".to_string(), summary)
836 }
837 SchemaIr::Array {
838 min_items,
839 max_items,
840 ..
841 } => (
842 "array".to_string(),
843 format!("array{{min={:?}, max={:?}}}", min_items, max_items),
844 ),
845 SchemaIr::String {
846 min_len,
847 max_len,
848 format,
849 ..
850 } => (
851 "string".to_string(),
852 format!(
853 "string{{min={:?}, max={:?}, format={:?}}}",
854 min_len, max_len, format
855 ),
856 ),
857 SchemaIr::Int { min, max } => (
858 "int".to_string(),
859 format!("int{{min={:?}, max={:?}}}", min, max),
860 ),
861 SchemaIr::Float { min, max } => (
862 "float".to_string(),
863 format!("float{{min={:?}, max={:?}}}", min, max),
864 ),
865 SchemaIr::Enum { values } => (
866 "enum".to_string(),
867 format!("enum{{values={}}}", values.len()),
868 ),
869 SchemaIr::OneOf { variants } => (
870 "oneof".to_string(),
871 format!("oneof{{variants={}}}", variants.len()),
872 ),
873 SchemaIr::Bool => ("bool".to_string(), "bool".to_string()),
874 SchemaIr::Null => ("null".to_string(), "null".to_string()),
875 SchemaIr::Bytes => ("bytes".to_string(), "bytes".to_string()),
876 SchemaIr::Ref { id } => ("ref".to_string(), format!("ref{{id={id}}}")),
877 }
878}
879
880fn resolve_wasm_path(target: &str) -> Result<PathBuf, String> {
881 let target_path = strip_file_scheme(Path::new(target));
882 if target_path.is_file() {
883 return Ok(target_path.to_path_buf());
884 }
885 if target_path.is_dir()
886 && let Some(found) = find_wasm_in_dir(&target_path)?
887 {
888 return Ok(found);
889 }
890 Err(format!("inspect: unable to resolve wasm for '{target}'"))
891}
892
893fn find_wasm_in_dir(dir: &Path) -> Result<Option<PathBuf>, String> {
894 let mut candidates = Vec::new();
895 let dist = dir.join("dist");
896 if dist.is_dir() {
897 collect_wasm_files(&dist, &mut candidates)?;
898 }
899 let target = dir.join("target").join("wasm32-wasip2");
900 for profile in ["release", "debug"] {
901 let profile_dir = target.join(profile);
902 if profile_dir.is_dir() {
903 collect_wasm_files(&profile_dir, &mut candidates)?;
904 }
905 }
906 candidates.sort();
907 candidates.dedup();
908 match candidates.len() {
909 0 => Ok(None),
910 1 => Ok(Some(candidates.remove(0))),
911 _ => Err(format!(
912 "inspect: multiple wasm files found in {}; specify one explicitly",
913 dir.display()
914 )),
915 }
916}
917
918fn collect_wasm_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
919 for entry in
920 fs::read_dir(dir).map_err(|err| format!("failed to read {}: {err}", dir.display()))?
921 {
922 let entry = entry.map_err(|err| format!("failed to read {}: {err}", dir.display()))?;
923 let path = entry.path();
924 if path.extension().and_then(|ext| ext.to_str()) == Some("wasm") {
925 out.push(path);
926 }
927 }
928 Ok(())
929}
930
931fn call_describe(wasm_path: &Path) -> Result<Vec<u8>, String> {
932 let mut config = wasmtime::Config::new();
933 config.wasm_component_model(true);
934 let engine = Engine::new(&config).map_err(|err| format!("engine init failed: {err}"))?;
935 let component = Component::from_file(&engine, wasm_path)
936 .map_err(|err| format!("failed to load component: {err}"))?;
937 let mut linker = Linker::new(&engine);
938 wasmtime_wasi::p2::add_to_linker_sync(&mut linker)
939 .map_err(|err| format!("failed to add wasi: {err}"))?;
940 let mut store = Store::new(&engine, InspectWasi::new().map_err(|e| e.to_string())?);
941 let instance = linker
942 .instantiate(&mut store, &component)
943 .map_err(|err| format!("failed to instantiate: {err}"))?;
944 let instance_index = resolve_interface_index(&instance, &mut store, "component-descriptor")
945 .ok_or_else(|| "missing export interface component-descriptor".to_string())?;
946 let func_index = instance
947 .get_export_index(&mut store, Some(&instance_index), "describe")
948 .ok_or_else(|| "missing export component-descriptor.describe".to_string())?;
949 let func = instance
950 .get_func(&mut store, func_index)
951 .ok_or_else(|| "describe export is not callable".to_string())?;
952 let mut results = vec![Val::Bool(false); func.ty(&mut store).results().len()];
953 func.call(&mut store, &[], &mut results)
954 .map_err(|err| format!("describe call failed: {err}"))?;
955 let val = results
956 .first()
957 .ok_or_else(|| "describe returned no value".to_string())?;
958 val_to_bytes(val)
959}
960
961fn resolve_interface_index(
962 instance: &wasmtime::component::Instance,
963 store: &mut Store<InspectWasi>,
964 interface: &str,
965) -> Option<wasmtime::component::ComponentExportIndex> {
966 for candidate in interface_candidates(interface) {
967 if let Some(index) = instance.get_export_index(&mut *store, None, &candidate) {
968 return Some(index);
969 }
970 }
971 None
972}
973
974fn interface_candidates(interface: &str) -> [String; 3] {
975 [
976 interface.to_string(),
977 format!("greentic:component/{interface}@0.6.0"),
978 format!("greentic:component/{interface}"),
979 ]
980}
981
982fn val_to_bytes(val: &Val) -> Result<Vec<u8>, String> {
983 match val {
984 Val::List(items) => {
985 let mut out = Vec::with_capacity(items.len());
986 for item in items {
987 match item {
988 Val::U8(byte) => out.push(*byte),
989 _ => return Err("expected list<u8>".to_string()),
990 }
991 }
992 Ok(out)
993 }
994 _ => Err("expected list<u8>".to_string()),
995 }
996}
997
998fn strip_self_describe_tag(bytes: &[u8]) -> &[u8] {
999 const SELF_DESCRIBE_TAG: [u8; 3] = [0xd9, 0xd9, 0xf7];
1000 if bytes.starts_with(&SELF_DESCRIBE_TAG) {
1001 &bytes[SELF_DESCRIBE_TAG.len()..]
1002 } else {
1003 bytes
1004 }
1005}
1006
1007fn ensure_canonical_allow_floats(bytes: &[u8]) -> Result<(), String> {
1008 let canonicalized = canonical::canonicalize_allow_floats(bytes)
1009 .map_err(|err| format!("canonicalization failed: {err}"))?;
1010 if canonicalized.as_slice() != bytes {
1011 return Err("payload is not canonical".to_string());
1012 }
1013 Ok(())
1014}
1015
1016struct InspectWasi {
1017 ctx: WasiCtx,
1018 table: ResourceTable,
1019}
1020
1021impl InspectWasi {
1022 fn new() -> Result<Self, anyhow::Error> {
1023 let ctx = WasiCtxBuilder::new().build();
1024 Ok(Self {
1025 ctx,
1026 table: ResourceTable::new(),
1027 })
1028 }
1029}
1030
1031impl WasiView for InspectWasi {
1032 fn ctx(&mut self) -> WasiCtxView<'_> {
1033 WasiCtxView {
1034 ctx: &mut self.ctx,
1035 table: &mut self.table,
1036 }
1037 }
1038}