Skip to main content

greentic_component/cmd/
inspect.rs

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    /// Path or identifier resolvable by the loader
29    #[arg(value_name = "TARGET", required_unless_present = "describe")]
30    pub target: Option<String>,
31    /// Explicit path to component.manifest.json when it is not adjacent to the wasm
32    #[arg(long)]
33    pub manifest: Option<PathBuf>,
34    /// Inspect a pre-generated describe CBOR file (skip WASM execution)
35    #[arg(long)]
36    pub describe: Option<PathBuf>,
37    /// Emit structured JSON instead of human output
38    #[arg(long)]
39    pub json: bool,
40    /// Verify schema_hash values against typed SchemaIR
41    #[arg(long)]
42    pub verify: bool,
43    /// Treat warnings as errors
44    #[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}