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