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::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 let describe: ComponentDescribe = canonical::from_cbor(payload)
618 .map_err(|err| ComponentError::Doctor(format!("describe decode failed: {err}")))?;
619
620 let mut report = DescribeReport::from(describe, args.verify)?;
621 report.wasm_path = wasm_path;
622
623 if args.json {
624 let json = serde_json::to_string_pretty(&report)
625 .map_err(|err| ComponentError::Doctor(format!("failed to encode json: {err}")))?;
626 println!("{json}");
627 } else {
628 emit_describe_human(&report);
629 }
630
631 let verify_failed = args.verify
632 && report
633 .operations
634 .iter()
635 .any(|op| matches!(op.schema_hash_valid, Some(false)));
636 if verify_failed {
637 return Err(ComponentError::Doctor(
638 "schema_hash verification failed".to_string(),
639 ));
640 }
641
642 Ok(InspectResult { warnings })
643}
644
645fn emit_describe_human(report: &DescribeReport) {
646 println!("component: {}", report.info.id);
647 println!(" version: {}", report.info.version);
648 println!(" role: {}", report.info.role);
649 println!(" operations: {}", report.operations.len());
650 for op in &report.operations {
651 println!(" - {} ({})", op.id, op.schema_hash);
652 println!(" input: {}", op.input.summary);
653 println!(" output: {}", op.output.summary);
654 if let Some(status) = op.schema_hash_valid {
655 println!(" schema_hash ok: {status}");
656 }
657 }
658 println!(" config: {}", report.config.summary);
659}
660
661#[derive(Debug, Serialize)]
662struct DescribeReport {
663 info: ComponentInfoSummary,
664 operations: Vec<OperationSummary>,
665 config: SchemaSummary,
666 #[serde(skip_serializing_if = "Option::is_none")]
667 wasm_path: Option<PathBuf>,
668}
669
670impl DescribeReport {
671 fn from(describe: ComponentDescribe, verify: bool) -> Result<Self, ComponentError> {
672 let info = ComponentInfoSummary {
673 id: describe.info.id,
674 version: describe.info.version,
675 role: describe.info.role,
676 };
677 let config = SchemaSummary::from_schema(&describe.config_schema);
678 let mut operations = Vec::new();
679 for op in describe.operations {
680 let input = SchemaSummary::from_schema(&op.input.schema);
681 let output = SchemaSummary::from_schema(&op.output.schema);
682 let schema_hash_valid = if verify {
683 let expected =
684 schema_hash(&op.input.schema, &op.output.schema, &describe.config_schema)
685 .map_err(|err| {
686 ComponentError::Doctor(format!("schema_hash failed: {err}"))
687 })?;
688 Some(expected == op.schema_hash)
689 } else {
690 None
691 };
692 operations.push(OperationSummary {
693 id: op.id,
694 schema_hash: op.schema_hash,
695 schema_hash_valid,
696 input,
697 output,
698 });
699 }
700 Ok(Self {
701 info,
702 operations,
703 config,
704 wasm_path: None,
705 })
706 }
707}
708
709#[derive(Debug, Serialize)]
710struct ComponentInfoSummary {
711 id: String,
712 version: String,
713 role: String,
714}
715
716#[derive(Debug, Serialize)]
717struct OperationSummary {
718 id: String,
719 schema_hash: String,
720 #[serde(skip_serializing_if = "Option::is_none")]
721 schema_hash_valid: Option<bool>,
722 input: SchemaSummary,
723 output: SchemaSummary,
724}
725
726#[derive(Debug, Serialize)]
727struct SchemaSummary {
728 kind: String,
729 summary: String,
730}
731
732impl SchemaSummary {
733 fn from_schema(schema: &SchemaIr) -> Self {
734 let (kind, summary) = summarize_schema(schema);
735 Self { kind, summary }
736 }
737}
738
739fn summarize_schema(schema: &SchemaIr) -> (String, String) {
740 match schema {
741 SchemaIr::Object {
742 properties,
743 required,
744 additional,
745 } => {
746 let add = match additional {
747 AdditionalProperties::Allow => "allow",
748 AdditionalProperties::Forbid => "forbid",
749 AdditionalProperties::Schema(_) => "schema",
750 };
751 let summary = format!(
752 "object{{fields={}, required={}, additional={add}}}",
753 properties.len(),
754 required.len()
755 );
756 ("object".to_string(), summary)
757 }
758 SchemaIr::Array {
759 min_items,
760 max_items,
761 ..
762 } => (
763 "array".to_string(),
764 format!("array{{min={:?}, max={:?}}}", min_items, max_items),
765 ),
766 SchemaIr::String {
767 min_len,
768 max_len,
769 format,
770 ..
771 } => (
772 "string".to_string(),
773 format!(
774 "string{{min={:?}, max={:?}, format={:?}}}",
775 min_len, max_len, format
776 ),
777 ),
778 SchemaIr::Int { min, max } => (
779 "int".to_string(),
780 format!("int{{min={:?}, max={:?}}}", min, max),
781 ),
782 SchemaIr::Float { min, max } => (
783 "float".to_string(),
784 format!("float{{min={:?}, max={:?}}}", min, max),
785 ),
786 SchemaIr::Enum { values } => (
787 "enum".to_string(),
788 format!("enum{{values={}}}", values.len()),
789 ),
790 SchemaIr::OneOf { variants } => (
791 "oneof".to_string(),
792 format!("oneof{{variants={}}}", variants.len()),
793 ),
794 SchemaIr::Bool => ("bool".to_string(), "bool".to_string()),
795 SchemaIr::Null => ("null".to_string(), "null".to_string()),
796 SchemaIr::Bytes => ("bytes".to_string(), "bytes".to_string()),
797 SchemaIr::Ref { id } => ("ref".to_string(), format!("ref{{id={id}}}")),
798 }
799}
800
801fn resolve_wasm_path(target: &str) -> Result<PathBuf, String> {
802 let target_path = strip_file_scheme(Path::new(target));
803 if target_path.is_file() {
804 return Ok(target_path.to_path_buf());
805 }
806 if target_path.is_dir()
807 && let Some(found) = find_wasm_in_dir(&target_path)?
808 {
809 return Ok(found);
810 }
811 Err(format!("inspect: unable to resolve wasm for '{target}'"))
812}
813
814fn find_wasm_in_dir(dir: &Path) -> Result<Option<PathBuf>, String> {
815 let mut candidates = Vec::new();
816 let dist = dir.join("dist");
817 if dist.is_dir() {
818 collect_wasm_files(&dist, &mut candidates)?;
819 }
820 let target = dir.join("target").join("wasm32-wasip2");
821 for profile in ["release", "debug"] {
822 let profile_dir = target.join(profile);
823 if profile_dir.is_dir() {
824 collect_wasm_files(&profile_dir, &mut candidates)?;
825 }
826 }
827 candidates.sort();
828 candidates.dedup();
829 match candidates.len() {
830 0 => Ok(None),
831 1 => Ok(Some(candidates.remove(0))),
832 _ => Err(format!(
833 "inspect: multiple wasm files found in {}; specify one explicitly",
834 dir.display()
835 )),
836 }
837}
838
839fn collect_wasm_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
840 for entry in
841 fs::read_dir(dir).map_err(|err| format!("failed to read {}: {err}", dir.display()))?
842 {
843 let entry = entry.map_err(|err| format!("failed to read {}: {err}", dir.display()))?;
844 let path = entry.path();
845 if path.extension().and_then(|ext| ext.to_str()) == Some("wasm") {
846 out.push(path);
847 }
848 }
849 Ok(())
850}
851
852fn call_describe(wasm_path: &Path) -> Result<Vec<u8>, String> {
853 let mut config = wasmtime::Config::new();
854 config.wasm_component_model(true);
855 let engine = Engine::new(&config).map_err(|err| format!("engine init failed: {err}"))?;
856 let component = Component::from_file(&engine, wasm_path)
857 .map_err(|err| format!("failed to load component: {err}"))?;
858 let mut linker = Linker::new(&engine);
859 wasmtime_wasi::p2::add_to_linker_sync(&mut linker)
860 .map_err(|err| format!("failed to add wasi: {err}"))?;
861 let mut store = Store::new(&engine, InspectWasi::new().map_err(|e| e.to_string())?);
862 let instance = linker
863 .instantiate(&mut store, &component)
864 .map_err(|err| format!("failed to instantiate: {err}"))?;
865 let instance_index = resolve_interface_index(&instance, &mut store, "component-descriptor")
866 .ok_or_else(|| "missing export interface component-descriptor".to_string())?;
867 let func_index = instance
868 .get_export_index(&mut store, Some(&instance_index), "describe")
869 .ok_or_else(|| "missing export component-descriptor.describe".to_string())?;
870 let func = instance
871 .get_func(&mut store, func_index)
872 .ok_or_else(|| "describe export is not callable".to_string())?;
873 let mut results = vec![Val::Bool(false); func.ty(&mut store).results().len()];
874 func.call(&mut store, &[], &mut results)
875 .map_err(|err| format!("describe call failed: {err}"))?;
876 let val = results
877 .first()
878 .ok_or_else(|| "describe returned no value".to_string())?;
879 val_to_bytes(val)
880}
881
882fn resolve_interface_index(
883 instance: &wasmtime::component::Instance,
884 store: &mut Store<InspectWasi>,
885 interface: &str,
886) -> Option<wasmtime::component::ComponentExportIndex> {
887 for candidate in interface_candidates(interface) {
888 if let Some(index) = instance.get_export_index(&mut *store, None, &candidate) {
889 return Some(index);
890 }
891 }
892 None
893}
894
895fn interface_candidates(interface: &str) -> [String; 3] {
896 [
897 interface.to_string(),
898 format!("greentic:component/{interface}@0.6.0"),
899 format!("greentic:component/{interface}"),
900 ]
901}
902
903fn val_to_bytes(val: &Val) -> Result<Vec<u8>, String> {
904 match val {
905 Val::List(items) => {
906 let mut out = Vec::with_capacity(items.len());
907 for item in items {
908 match item {
909 Val::U8(byte) => out.push(*byte),
910 _ => return Err("expected list<u8>".to_string()),
911 }
912 }
913 Ok(out)
914 }
915 _ => Err("expected list<u8>".to_string()),
916 }
917}
918
919fn strip_self_describe_tag(bytes: &[u8]) -> &[u8] {
920 const SELF_DESCRIBE_TAG: [u8; 3] = [0xd9, 0xd9, 0xf7];
921 if bytes.starts_with(&SELF_DESCRIBE_TAG) {
922 &bytes[SELF_DESCRIBE_TAG.len()..]
923 } else {
924 bytes
925 }
926}
927
928fn ensure_canonical_allow_floats(bytes: &[u8]) -> Result<(), String> {
929 let canonicalized = canonical::canonicalize_allow_floats(bytes)
930 .map_err(|err| format!("canonicalization failed: {err}"))?;
931 if canonicalized.as_slice() != bytes {
932 return Err("payload is not canonical".to_string());
933 }
934 Ok(())
935}
936
937struct InspectWasi {
938 ctx: WasiCtx,
939 table: ResourceTable,
940}
941
942impl InspectWasi {
943 fn new() -> Result<Self, anyhow::Error> {
944 let ctx = WasiCtxBuilder::new().build();
945 Ok(Self {
946 ctx,
947 table: ResourceTable::new(),
948 })
949 }
950}
951
952impl WasiView for InspectWasi {
953 fn ctx(&mut self) -> WasiCtxView<'_> {
954 WasiCtxView {
955 ctx: &mut self.ctx,
956 table: &mut self.table,
957 }
958 }
959}