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!(" secret requirements: [redacted]");
458 println!(" profiles: {:?}", manifest.profiles);
459 if let Some(limits) = &manifest.limits {
460 println!(
461 " limits: memory_mb={} wall_time_ms={} fuel={:?} files={:?}",
462 limits.memory_mb, limits.wall_time_ms, limits.fuel, limits.files
463 );
464 }
465 if let Some(telemetry) = &manifest.telemetry {
466 println!(" telemetry span prefix: {}", telemetry.span_prefix);
467 println!(" telemetry attributes: {:?}", telemetry.attributes);
468 println!(" telemetry emit node spans: {}", telemetry.emit_node_spans);
469 }
470 }
471 if let Some(describe) = &report.describe {
472 println!("describe: {}", describe.status);
473 if let Some(source) = &describe.source {
474 println!(" source: {source}");
475 }
476 if let Some(name) = &describe.name {
477 println!(" name: {name}");
478 }
479 if let Some(schema_id) = &describe.schema_id {
480 println!(" schema id: {schema_id}");
481 }
482 if let Some(world) = &describe.world {
483 println!(" world: {world}");
484 }
485 if let Some(versions) = &describe.versions {
486 println!(" versions: {}", versions.join(", "));
487 }
488 if let Some(version_count) = describe.version_count {
489 println!(" version count: {version_count}");
490 }
491 if let Some(function_count) = describe.function_count {
492 println!(" functions: {function_count}");
493 }
494 if let Some(operation_count) = describe.operation_count {
495 println!(" operations: {operation_count}");
496 }
497 if let Some(compare) = &describe.compare_embedded {
498 println!(" embedded vs describe: {:?}", compare.overall);
499 }
500 if let Some(reason) = &describe.reason {
501 println!(" reason: {reason}");
502 }
503 }
504 }
505
506 Ok(InspectResult { warnings })
507}
508
509pub fn emit_warnings(warnings: &[String]) {
510 for warning in warnings {
511 eprintln!("warning: {warning}");
512 }
513}
514
515pub fn build_report(prepared: &PreparedComponent) -> Value {
516 let caps = &prepared.manifest.capabilities;
517 serde_json::json!({
518 "manifest": &prepared.manifest,
519 "manifest_path": prepared.manifest_path,
520 "wasm_path": prepared.wasm_path,
521 "wasm_hash": prepared.wasm_hash,
522 "hash_verified": prepared.hash_verified,
523 "world": {
524 "expected": prepared.manifest.world.as_str(),
525 "ok": prepared.world_ok,
526 },
527 "lifecycle": {
528 "init": prepared.lifecycle.init,
529 "health": prepared.lifecycle.health,
530 "shutdown": prepared.lifecycle.shutdown,
531 },
532 "describe": prepared.describe,
533 "capabilities": prepared.manifest.capabilities,
534 "limits": prepared.manifest.limits,
535 "telemetry": prepared.manifest.telemetry,
536 "redactions": prepared
537 .redaction_paths()
538 .iter()
539 .map(|p| p.as_str().to_string())
540 .collect::<Vec<_>>(),
541 "defaults_applied": prepared.defaults_applied(),
542 "summary": {
543 "supports": prepared.manifest.supports,
544 "profiles": prepared.manifest.profiles,
545 "capabilities": {
546 "wasi": {
547 "filesystem": caps.wasi.filesystem.is_some(),
548 "env": caps.wasi.env.is_some(),
549 "random": caps.wasi.random,
550 "clocks": caps.wasi.clocks
551 },
552 "host": {
553 "secrets": caps.host.secrets.is_some(),
554 "state": caps.host.state.is_some(),
555 "messaging": caps.host.messaging.is_some(),
556 "events": caps.host.events.is_some(),
557 "http": caps.host.http.is_some(),
558 "telemetry": caps.host.telemetry.is_some(),
559 "iac": caps.host.iac.is_some()
560 }
561 },
562 }
563 })
564}
565
566fn should_inspect_wasm_artifact(args: &InspectArgs) -> bool {
567 let Some(target) = args.target.as_ref() else {
568 return false;
569 };
570 let target = strip_file_scheme(Path::new(target));
571 target.is_dir()
572 || target
573 .extension()
574 .and_then(|ext| ext.to_str())
575 .map(|ext| ext.eq_ignore_ascii_case("wasm"))
576 .unwrap_or(false)
577}
578
579fn discover_manifest_path(wasm_path: &Path, target_path: &Path) -> Option<PathBuf> {
580 let mut candidates = Vec::new();
581 if target_path.is_dir() {
582 candidates.push(target_path.join("component.manifest.json"));
583 }
584 if let Some(parent) = wasm_path.parent() {
585 candidates.push(parent.join("component.manifest.json"));
586 if let Some(grandparent) = parent.parent() {
587 candidates.push(grandparent.join("component.manifest.json"));
588 }
589 }
590 candidates.into_iter().find(|path| path.is_file())
591}
592
593fn inspect_describe(args: &InspectArgs) -> Result<InspectResult, ComponentError> {
594 let mut warnings = Vec::new();
595 let mut wasm_path = None;
596 let bytes = if let Some(path) = args.describe.as_ref() {
597 let path = strip_file_scheme(path);
598 fs::read(path)
599 .map_err(|err| ComponentError::Doctor(format!("failed to read describe file: {err}")))?
600 } else {
601 let target = args
602 .target
603 .as_ref()
604 .ok_or_else(|| ComponentError::Doctor("inspect target is required".to_string()))?;
605 let path = resolve_wasm_path(target).map_err(ComponentError::Doctor)?;
606 wasm_path = Some(path.clone());
607 call_describe(&path).map_err(ComponentError::Doctor)?
608 };
609
610 let payload = strip_self_describe_tag(&bytes);
611 if let Err(err) = ensure_canonical_allow_floats(payload) {
612 warnings.push(format!("describe payload not canonical: {err}"));
613 }
614 if let Ok(describe) = canonical::from_cbor::<ComponentDescribe>(payload) {
615 let mut report = DescribeReport::from(describe, args.verify)?;
616 report.wasm_path = wasm_path;
617
618 if args.json {
619 let json = serde_json::to_string_pretty(&report)
620 .map_err(|err| ComponentError::Doctor(format!("failed to encode json: {err}")))?;
621 println!("{json}");
622 } else {
623 emit_describe_human(&report);
624 }
625
626 let verify_failed = args.verify
627 && report
628 .operations
629 .iter()
630 .any(|op| matches!(op.schema_hash_valid, Some(false)));
631 if verify_failed {
632 return Err(ComponentError::Doctor(
633 "schema_hash verification failed".to_string(),
634 ));
635 }
636 return Ok(InspectResult { warnings });
637 }
638
639 let derived: DescribePayload = canonical::from_cbor(payload)
640 .map_err(|err| ComponentError::Doctor(format!("describe decode failed: {err}")))?;
641 if args.verify {
642 warnings.push("verify skipped for WIT-derived describe payload".to_string());
643 }
644 let mut report = DerivedDescribeReport::from(derived);
645 report.wasm_path = wasm_path;
646
647 if args.json {
648 let json = serde_json::to_string_pretty(&report)
649 .map_err(|err| ComponentError::Doctor(format!("failed to encode json: {err}")))?;
650 println!("{json}");
651 } else {
652 emit_derived_describe_human(&report);
653 }
654
655 Ok(InspectResult { warnings })
656}
657
658fn emit_describe_human(report: &DescribeReport) {
659 println!("component: {}", report.info.id);
660 println!(" version: {}", report.info.version);
661 println!(" role: {}", report.info.role);
662 println!(" operations: {}", report.operations.len());
663 for op in &report.operations {
664 println!(" - {} ({})", op.id, op.schema_hash);
665 println!(" input: {}", op.input.summary);
666 println!(" output: {}", op.output.summary);
667 if let Some(status) = op.schema_hash_valid {
668 println!(" schema_hash ok: {status}");
669 }
670 }
671 println!(" config: {}", report.config.summary);
672}
673
674#[derive(Debug, Serialize)]
675struct DerivedDescribeReport {
676 kind: &'static str,
677 name: String,
678 #[serde(skip_serializing_if = "Option::is_none")]
679 schema_id: Option<String>,
680 versions: Vec<DerivedDescribeVersionReport>,
681 #[serde(skip_serializing_if = "Option::is_none")]
682 wasm_path: Option<PathBuf>,
683}
684
685#[derive(Debug, Serialize)]
686struct DerivedDescribeVersionReport {
687 version: String,
688 #[serde(skip_serializing_if = "Option::is_none")]
689 world: Option<String>,
690 function_count: usize,
691}
692
693impl DerivedDescribeReport {
694 fn from(payload: DescribePayload) -> Self {
695 Self {
696 kind: "wit-derived",
697 name: payload.name,
698 schema_id: payload.schema_id,
699 versions: payload
700 .versions
701 .into_iter()
702 .map(|version| DerivedDescribeVersionReport {
703 version: version.version.to_string(),
704 world: version
705 .schema
706 .get("world")
707 .and_then(|world| world.as_str())
708 .map(str::to_string),
709 function_count: version
710 .schema
711 .get("functions")
712 .and_then(|functions| functions.as_array())
713 .map(|functions| functions.len())
714 .unwrap_or(0),
715 })
716 .collect(),
717 wasm_path: None,
718 }
719 }
720}
721
722fn emit_derived_describe_human(report: &DerivedDescribeReport) {
723 println!("describe: wit-derived");
724 println!(" name: {}", report.name);
725 if let Some(schema_id) = &report.schema_id {
726 println!(" schema id: {schema_id}");
727 }
728 for version in &report.versions {
729 println!(" - version: {}", version.version);
730 if let Some(world) = &version.world {
731 println!(" world: {world}");
732 }
733 println!(" functions: {}", version.function_count);
734 }
735}
736
737#[derive(Debug, Serialize)]
738struct DescribeReport {
739 info: ComponentInfoSummary,
740 operations: Vec<OperationSummary>,
741 config: SchemaSummary,
742 #[serde(skip_serializing_if = "Option::is_none")]
743 wasm_path: Option<PathBuf>,
744}
745
746impl DescribeReport {
747 fn from(describe: ComponentDescribe, verify: bool) -> Result<Self, ComponentError> {
748 let info = ComponentInfoSummary {
749 id: describe.info.id,
750 version: describe.info.version,
751 role: describe.info.role,
752 };
753 let config = SchemaSummary::from_schema(&describe.config_schema);
754 let mut operations = Vec::new();
755 for op in describe.operations {
756 let input = SchemaSummary::from_schema(&op.input.schema);
757 let output = SchemaSummary::from_schema(&op.output.schema);
758 let schema_hash_valid = if verify {
759 let expected =
760 schema_hash(&op.input.schema, &op.output.schema, &describe.config_schema)
761 .map_err(|err| {
762 ComponentError::Doctor(format!("schema_hash failed: {err}"))
763 })?;
764 Some(expected == op.schema_hash)
765 } else {
766 None
767 };
768 operations.push(OperationSummary {
769 id: op.id,
770 schema_hash: op.schema_hash,
771 schema_hash_valid,
772 input,
773 output,
774 });
775 }
776 Ok(Self {
777 info,
778 operations,
779 config,
780 wasm_path: None,
781 })
782 }
783}
784
785#[derive(Debug, Serialize)]
786struct ComponentInfoSummary {
787 id: String,
788 version: String,
789 role: String,
790}
791
792#[derive(Debug, Serialize)]
793struct OperationSummary {
794 id: String,
795 schema_hash: String,
796 #[serde(skip_serializing_if = "Option::is_none")]
797 schema_hash_valid: Option<bool>,
798 input: SchemaSummary,
799 output: SchemaSummary,
800}
801
802#[derive(Debug, Serialize)]
803struct SchemaSummary {
804 kind: String,
805 summary: String,
806}
807
808impl SchemaSummary {
809 fn from_schema(schema: &SchemaIr) -> Self {
810 let (kind, summary) = summarize_schema(schema);
811 Self { kind, summary }
812 }
813}
814
815fn summarize_schema(schema: &SchemaIr) -> (String, String) {
816 match schema {
817 SchemaIr::Object {
818 properties,
819 required,
820 additional,
821 } => {
822 let add = match additional {
823 AdditionalProperties::Allow => "allow",
824 AdditionalProperties::Forbid => "forbid",
825 AdditionalProperties::Schema(_) => "schema",
826 };
827 let summary = format!(
828 "object{{fields={}, required={}, additional={add}}}",
829 properties.len(),
830 required.len()
831 );
832 ("object".to_string(), summary)
833 }
834 SchemaIr::Array {
835 min_items,
836 max_items,
837 ..
838 } => (
839 "array".to_string(),
840 format!("array{{min={:?}, max={:?}}}", min_items, max_items),
841 ),
842 SchemaIr::String {
843 min_len,
844 max_len,
845 format,
846 ..
847 } => (
848 "string".to_string(),
849 format!(
850 "string{{min={:?}, max={:?}, format={:?}}}",
851 min_len, max_len, format
852 ),
853 ),
854 SchemaIr::Int { min, max } => (
855 "int".to_string(),
856 format!("int{{min={:?}, max={:?}}}", min, max),
857 ),
858 SchemaIr::Float { min, max } => (
859 "float".to_string(),
860 format!("float{{min={:?}, max={:?}}}", min, max),
861 ),
862 SchemaIr::Enum { values } => (
863 "enum".to_string(),
864 format!("enum{{values={}}}", values.len()),
865 ),
866 SchemaIr::OneOf { variants } => (
867 "oneof".to_string(),
868 format!("oneof{{variants={}}}", variants.len()),
869 ),
870 SchemaIr::Bool => ("bool".to_string(), "bool".to_string()),
871 SchemaIr::Null => ("null".to_string(), "null".to_string()),
872 SchemaIr::Bytes => ("bytes".to_string(), "bytes".to_string()),
873 SchemaIr::Ref { id } => ("ref".to_string(), format!("ref{{id={id}}}")),
874 }
875}
876
877fn resolve_wasm_path(target: &str) -> Result<PathBuf, String> {
878 let target_path = strip_file_scheme(Path::new(target));
879 if target_path.is_file() {
880 return Ok(target_path.to_path_buf());
881 }
882 if target_path.is_dir()
883 && let Some(found) = find_wasm_in_dir(&target_path)?
884 {
885 return Ok(found);
886 }
887 Err(format!("inspect: unable to resolve wasm for '{target}'"))
888}
889
890fn find_wasm_in_dir(dir: &Path) -> Result<Option<PathBuf>, String> {
891 let mut candidates = Vec::new();
892 let dist = dir.join("dist");
893 if dist.is_dir() {
894 collect_wasm_files(&dist, &mut candidates)?;
895 }
896 let target = dir.join("target").join("wasm32-wasip2");
897 for profile in ["release", "debug"] {
898 let profile_dir = target.join(profile);
899 if profile_dir.is_dir() {
900 collect_wasm_files(&profile_dir, &mut candidates)?;
901 }
902 }
903 candidates.sort();
904 candidates.dedup();
905 match candidates.len() {
906 0 => Ok(None),
907 1 => Ok(Some(candidates.remove(0))),
908 _ => Err(format!(
909 "inspect: multiple wasm files found in {}; specify one explicitly",
910 dir.display()
911 )),
912 }
913}
914
915fn collect_wasm_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
916 for entry in
917 fs::read_dir(dir).map_err(|err| format!("failed to read {}: {err}", dir.display()))?
918 {
919 let entry = entry.map_err(|err| format!("failed to read {}: {err}", dir.display()))?;
920 let path = entry.path();
921 if path.extension().and_then(|ext| ext.to_str()) == Some("wasm") {
922 out.push(path);
923 }
924 }
925 Ok(())
926}
927
928fn call_describe(wasm_path: &Path) -> Result<Vec<u8>, String> {
929 let mut config = wasmtime::Config::new();
930 config.wasm_component_model(true);
931 let engine = Engine::new(&config).map_err(|err| format!("engine init failed: {err}"))?;
932 let component = Component::from_file(&engine, wasm_path)
933 .map_err(|err| format!("failed to load component: {err}"))?;
934 let mut linker = Linker::new(&engine);
935 wasmtime_wasi::p2::add_to_linker_sync(&mut linker)
936 .map_err(|err| format!("failed to add wasi: {err}"))?;
937 let mut store = Store::new(&engine, InspectWasi::new().map_err(|e| e.to_string())?);
938 let instance = linker
939 .instantiate(&mut store, &component)
940 .map_err(|err| format!("failed to instantiate: {err}"))?;
941 let instance_index = resolve_interface_index(&instance, &mut store, "component-descriptor")
942 .ok_or_else(|| "missing export interface component-descriptor".to_string())?;
943 let func_index = instance
944 .get_export_index(&mut store, Some(&instance_index), "describe")
945 .ok_or_else(|| "missing export component-descriptor.describe".to_string())?;
946 let func = instance
947 .get_func(&mut store, func_index)
948 .ok_or_else(|| "describe export is not callable".to_string())?;
949 let mut results = vec![Val::Bool(false); func.ty(&mut store).results().len()];
950 func.call(&mut store, &[], &mut results)
951 .map_err(|err| format!("describe call failed: {err}"))?;
952 let val = results
953 .first()
954 .ok_or_else(|| "describe returned no value".to_string())?;
955 val_to_bytes(val)
956}
957
958fn resolve_interface_index(
959 instance: &wasmtime::component::Instance,
960 store: &mut Store<InspectWasi>,
961 interface: &str,
962) -> Option<wasmtime::component::ComponentExportIndex> {
963 for candidate in interface_candidates(interface) {
964 if let Some(index) = instance.get_export_index(&mut *store, None, &candidate) {
965 return Some(index);
966 }
967 }
968 None
969}
970
971fn interface_candidates(interface: &str) -> [String; 3] {
972 [
973 interface.to_string(),
974 format!("greentic:component/{interface}@0.6.0"),
975 format!("greentic:component/{interface}"),
976 ]
977}
978
979fn val_to_bytes(val: &Val) -> Result<Vec<u8>, String> {
980 match val {
981 Val::List(items) => {
982 let mut out = Vec::with_capacity(items.len());
983 for item in items {
984 match item {
985 Val::U8(byte) => out.push(*byte),
986 _ => return Err("expected list<u8>".to_string()),
987 }
988 }
989 Ok(out)
990 }
991 _ => Err("expected list<u8>".to_string()),
992 }
993}
994
995fn strip_self_describe_tag(bytes: &[u8]) -> &[u8] {
996 const SELF_DESCRIBE_TAG: [u8; 3] = [0xd9, 0xd9, 0xf7];
997 if bytes.starts_with(&SELF_DESCRIBE_TAG) {
998 &bytes[SELF_DESCRIBE_TAG.len()..]
999 } else {
1000 bytes
1001 }
1002}
1003
1004fn ensure_canonical_allow_floats(bytes: &[u8]) -> Result<(), String> {
1005 let canonicalized = canonical::canonicalize_allow_floats(bytes)
1006 .map_err(|err| format!("canonicalization failed: {err}"))?;
1007 if canonicalized.as_slice() != bytes {
1008 return Err("payload is not canonical".to_string());
1009 }
1010 Ok(())
1011}
1012
1013struct InspectWasi {
1014 ctx: WasiCtx,
1015 table: ResourceTable,
1016}
1017
1018impl InspectWasi {
1019 fn new() -> Result<Self, anyhow::Error> {
1020 let ctx = WasiCtxBuilder::new().build();
1021 Ok(Self {
1022 ctx,
1023 table: ResourceTable::new(),
1024 })
1025 }
1026}
1027
1028impl WasiView for InspectWasi {
1029 fn ctx(&mut self) -> WasiCtxView<'_> {
1030 WasiCtxView {
1031 ctx: &mut self.ctx,
1032 table: &mut self.table,
1033 }
1034 }
1035}
1036
1037#[cfg(test)]
1038mod tests {
1039 use super::*;
1040 use wasmtime::component::Val;
1041
1042 #[test]
1043 fn should_inspect_wasm_artifact_detects_wasm_and_dirs_only() {
1044 let dir = tempfile::tempdir().expect("tempdir");
1045
1046 assert!(should_inspect_wasm_artifact(&InspectArgs {
1047 target: Some("component.wasm".into()),
1048 manifest: None,
1049 describe: None,
1050 json: false,
1051 verify: false,
1052 strict: false,
1053 }));
1054 assert!(should_inspect_wasm_artifact(&InspectArgs {
1055 target: Some(dir.path().display().to_string()),
1056 manifest: None,
1057 describe: None,
1058 json: false,
1059 verify: false,
1060 strict: false,
1061 }));
1062 assert!(!should_inspect_wasm_artifact(&InspectArgs {
1063 target: Some("component.manifest.json".into()),
1064 manifest: None,
1065 describe: None,
1066 json: false,
1067 verify: false,
1068 strict: false,
1069 }));
1070 }
1071
1072 #[test]
1073 fn discover_manifest_path_checks_target_and_parent_paths() {
1074 let dir = tempfile::tempdir().expect("tempdir");
1075 let manifest = dir.path().join("component.manifest.json");
1076 fs::write(&manifest, "{}").expect("write manifest");
1077
1078 let from_dir = discover_manifest_path(&dir.path().join("component.wasm"), dir.path());
1079 assert_eq!(from_dir, Some(manifest.clone()));
1080
1081 let nested = dir.path().join("dist/component.wasm");
1082 fs::create_dir_all(nested.parent().expect("parent")).expect("create dist");
1083 let from_parent = discover_manifest_path(&nested, nested.parent().expect("parent"));
1084 assert_eq!(from_parent, Some(manifest));
1085 }
1086
1087 #[test]
1088 fn resolve_wasm_path_reports_multiple_candidates_in_directory() {
1089 let dir = tempfile::tempdir().expect("tempdir");
1090 let dist = dir.path().join("dist");
1091 fs::create_dir_all(&dist).expect("create dist");
1092 fs::write(dist.join("one.wasm"), b"1").expect("write first wasm");
1093 fs::write(dist.join("two.wasm"), b"2").expect("write second wasm");
1094
1095 let err = resolve_wasm_path(dir.path().to_str().expect("utf-8"))
1096 .expect_err("multiple wasm files should fail");
1097
1098 assert!(err.contains("multiple wasm files found"));
1099 }
1100
1101 #[test]
1102 fn val_to_bytes_rejects_non_byte_lists() {
1103 let err = val_to_bytes(&Val::List(vec![Val::String("oops".to_string())]))
1104 .expect_err("non-u8 list should fail");
1105 assert_eq!(err, "expected list<u8>");
1106 }
1107
1108 #[test]
1109 fn strip_self_describe_tag_removes_only_known_prefix() {
1110 let tagged = [0xd9, 0xd9, 0xf7, 0x01, 0x02];
1111 assert_eq!(strip_self_describe_tag(&tagged), &[0x01, 0x02]);
1112 assert_eq!(strip_self_describe_tag(&[0x01, 0x02]), &[0x01, 0x02]);
1113 }
1114}