packc/cli/
inspect.rs

1#![forbid(unsafe_code)]
2
3use std::{
4    fs,
5    path::{Path, PathBuf},
6};
7
8use anyhow::{Context, Result, anyhow, bail};
9use clap::Parser;
10use greentic_pack::validate::{
11    ComponentReferencesExistValidator, ProviderReferencesExistValidator,
12    ReferencedFilesExistValidator, SbomConsistencyValidator, ValidateCtx, run_validators,
13};
14use greentic_pack::{PackLoad, SigningPolicy, open_pack};
15use greentic_types::component_source::ComponentSourceRef;
16use greentic_types::pack::extensions::component_sources::{
17    ArtifactLocationV1, ComponentSourcesV1, EXT_COMPONENT_SOURCES_V1,
18};
19use greentic_types::pack_manifest::PackManifest;
20use greentic_types::provider::ProviderDecl;
21use greentic_types::validate::{Diagnostic, Severity, ValidationReport};
22use serde::Serialize;
23use serde_json::Value;
24use tempfile::TempDir;
25
26use crate::build;
27use crate::runtime::RuntimeContext;
28use crate::validator::{
29    DEFAULT_VALIDATOR_ALLOW, ValidatorConfig, ValidatorPolicy, run_wasm_validators,
30};
31
32#[derive(Debug, Parser)]
33pub struct InspectArgs {
34    /// Path to a pack (.gtpack or source dir). Defaults to current directory.
35    #[arg(value_name = "PATH")]
36    pub path: Option<PathBuf>,
37
38    /// Path to a compiled .gtpack archive
39    #[arg(long, value_name = "FILE", conflicts_with = "input")]
40    pub pack: Option<PathBuf>,
41
42    /// Path to a pack source directory containing pack.yaml
43    #[arg(long = "in", value_name = "DIR", conflicts_with = "pack")]
44    pub input: Option<PathBuf>,
45
46    /// Force archive inspection (disables auto-detection)
47    #[arg(long)]
48    pub archive: bool,
49
50    /// Force source inspection (disables auto-detection)
51    #[arg(long)]
52    pub source: bool,
53
54    /// Allow OCI component refs in extensions to be tag-based (default requires sha256 digest)
55    #[arg(long = "allow-oci-tags", default_value_t = false)]
56    pub allow_oci_tags: bool,
57
58    /// Output format
59    #[arg(long, value_enum, default_value = "human")]
60    pub format: InspectFormat,
61
62    /// Enable validation (default)
63    #[arg(long, default_value_t = true)]
64    pub validate: bool,
65
66    /// Disable validation
67    #[arg(long = "no-validate", default_value_t = false)]
68    pub no_validate: bool,
69
70    /// Directory containing validator packs (.gtpack)
71    #[arg(long, value_name = "DIR", default_value = ".greentic/validators")]
72    pub validators_root: PathBuf,
73
74    /// Validator pack or component reference (path or oci://...)
75    #[arg(long, value_name = "REF")]
76    pub validator_pack: Vec<String>,
77
78    /// Allowed OCI prefixes for validator refs
79    #[arg(long, value_name = "PREFIX", default_value = DEFAULT_VALIDATOR_ALLOW)]
80    pub validator_allow: Vec<String>,
81
82    /// Validator cache directory
83    #[arg(long, value_name = "DIR", default_value = ".greentic/cache/validators")]
84    pub validator_cache_dir: PathBuf,
85
86    /// Validator loading policy
87    #[arg(long, value_enum, default_value = "optional")]
88    pub validator_policy: ValidatorPolicy,
89}
90
91pub async fn handle(args: InspectArgs, json: bool, runtime: &RuntimeContext) -> Result<()> {
92    let mode = resolve_mode(&args)?;
93    let format = resolve_format(&args, json);
94    let validate_enabled = if args.no_validate {
95        false
96    } else {
97        args.validate
98    };
99
100    let load = match mode {
101        InspectMode::Archive(path) => inspect_pack_file(&path)?,
102        InspectMode::Source(path) => {
103            inspect_source_dir(&path, runtime, args.allow_oci_tags).await?
104        }
105    };
106    let validation = if validate_enabled {
107        Some(run_pack_validation(&load, &args, runtime).await?)
108    } else {
109        None
110    };
111
112    match format {
113        InspectFormat::Json => {
114            let mut payload = serde_json::json!({
115                "manifest": load.manifest,
116                "report": {
117                    "signature_ok": load.report.signature_ok,
118                    "sbom_ok": load.report.sbom_ok,
119                    "warnings": load.report.warnings,
120                },
121                "sbom": load.sbom,
122            });
123            if let Some(report) = validation.as_ref() {
124                payload["validation"] = serde_json::to_value(report)?;
125            }
126            println!("{}", serde_json::to_string_pretty(&payload)?);
127        }
128        InspectFormat::Human => {
129            print_human(&load, validation.as_ref());
130        }
131    }
132
133    if validate_enabled
134        && validation
135            .as_ref()
136            .map(|report| report.has_errors)
137            .unwrap_or(false)
138    {
139        bail!("pack validation failed");
140    }
141
142    Ok(())
143}
144
145fn inspect_pack_file(path: &Path) -> Result<PackLoad> {
146    let load = open_pack(path, SigningPolicy::DevOk)
147        .map_err(|err| anyhow!(err.message))
148        .with_context(|| format!("failed to open pack {}", path.display()))?;
149    Ok(load)
150}
151
152enum InspectMode {
153    Archive(PathBuf),
154    Source(PathBuf),
155}
156
157fn resolve_mode(args: &InspectArgs) -> Result<InspectMode> {
158    if args.archive && args.source {
159        bail!("--archive and --source are mutually exclusive");
160    }
161    if args.pack.is_some() && args.input.is_some() {
162        bail!("exactly one of --pack or --in may be supplied");
163    }
164
165    if let Some(path) = &args.pack {
166        return Ok(InspectMode::Archive(path.clone()));
167    }
168    if let Some(path) = &args.input {
169        return Ok(InspectMode::Source(path.clone()));
170    }
171    if let Some(path) = &args.path {
172        let meta =
173            fs::metadata(path).with_context(|| format!("failed to stat {}", path.display()))?;
174        if args.archive || (path.extension() == Some(std::ffi::OsStr::new("gtpack"))) {
175            return Ok(InspectMode::Archive(path.clone()));
176        }
177        if args.source || meta.is_dir() {
178            return Ok(InspectMode::Source(path.clone()));
179        }
180        if meta.is_file() {
181            return Ok(InspectMode::Archive(path.clone()));
182        }
183    }
184    Ok(InspectMode::Source(
185        std::env::current_dir().context("determine current directory")?,
186    ))
187}
188
189async fn inspect_source_dir(
190    dir: &Path,
191    runtime: &RuntimeContext,
192    allow_oci_tags: bool,
193) -> Result<PackLoad> {
194    let pack_dir = dir
195        .canonicalize()
196        .with_context(|| format!("failed to resolve pack dir {}", dir.display()))?;
197
198    let temp = TempDir::new().context("failed to allocate temp dir for inspect")?;
199    let manifest_out = temp.path().join("manifest.cbor");
200    let gtpack_out = temp.path().join("pack.gtpack");
201
202    let opts = build::BuildOptions {
203        pack_dir,
204        component_out: None,
205        manifest_out,
206        sbom_out: None,
207        gtpack_out: Some(gtpack_out.clone()),
208        lock_path: gtpack_out.with_extension("lock.json"), // use temp lock path under temp dir
209        bundle: build::BundleMode::Cache,
210        dry_run: false,
211        secrets_req: None,
212        default_secret_scope: None,
213        allow_oci_tags,
214        require_component_manifests: false,
215        runtime: runtime.clone(),
216        skip_update: false,
217    };
218
219    build::run(&opts).await?;
220
221    inspect_pack_file(&gtpack_out)
222}
223
224fn print_human(load: &PackLoad, validation: Option<&ValidationOutput>) {
225    let manifest = &load.manifest;
226    let report = &load.report;
227    println!(
228        "Pack: {} ({})",
229        manifest.meta.pack_id, manifest.meta.version
230    );
231    println!("Name: {}", manifest.meta.name);
232    println!("Flows: {}", manifest.flows.len());
233    if manifest.flows.is_empty() {
234        println!("Flows list: none");
235    } else {
236        println!("Flows list:");
237        for flow in &manifest.flows {
238            println!(
239                "  - {} (entry: {}, kind: {})",
240                flow.id, flow.entry, flow.kind
241            );
242        }
243    }
244    println!("Components: {}", manifest.components.len());
245    if manifest.components.is_empty() {
246        println!("Components list: none");
247    } else {
248        println!("Components list:");
249        for component in &manifest.components {
250            println!("  - {} ({})", component.name, component.version);
251        }
252    }
253    if let Some(gmanifest) = load.gpack_manifest.as_ref()
254        && let Some(value) = gmanifest
255            .extensions
256            .as_ref()
257            .and_then(|m| m.get(EXT_COMPONENT_SOURCES_V1))
258            .and_then(|ext| ext.inline.as_ref())
259            .and_then(|inline| match inline {
260                greentic_types::ExtensionInline::Other(v) => Some(v),
261                _ => None,
262            })
263        && let Ok(cs) = ComponentSourcesV1::from_extension_value(value)
264    {
265        let mut inline = 0usize;
266        let mut remote = 0usize;
267        let mut oci = 0usize;
268        let mut repo = 0usize;
269        let mut store = 0usize;
270        let mut file = 0usize;
271        for entry in &cs.components {
272            match entry.artifact {
273                ArtifactLocationV1::Inline { .. } => inline += 1,
274                ArtifactLocationV1::Remote => remote += 1,
275            }
276            match entry.source {
277                ComponentSourceRef::Oci(_) => oci += 1,
278                ComponentSourceRef::Repo(_) => repo += 1,
279                ComponentSourceRef::Store(_) => store += 1,
280                ComponentSourceRef::File(_) => file += 1,
281            }
282        }
283        println!(
284            "Component sources: {} total (origins: oci {}, repo {}, store {}, file {}; artifacts: inline {}, remote {})",
285            cs.components.len(),
286            oci,
287            repo,
288            store,
289            file,
290            inline,
291            remote
292        );
293        if cs.components.is_empty() {
294            println!("Component source entries: none");
295        } else {
296            println!("Component source entries:");
297            for entry in &cs.components {
298                println!(
299                    "  - {} source={} artifact={}",
300                    entry.name,
301                    format_component_source(&entry.source),
302                    format_component_artifact(&entry.artifact)
303                );
304            }
305        }
306    } else {
307        println!("Component sources: none");
308    }
309
310    if let Some(gmanifest) = load.gpack_manifest.as_ref() {
311        let providers = providers_from_manifest(gmanifest);
312        if providers.is_empty() {
313            println!("Providers: none");
314        } else {
315            println!("Providers:");
316            for provider in providers {
317                println!(
318                    "  - {} ({}) {}",
319                    provider.provider_type,
320                    provider_kind(&provider),
321                    summarize_provider(&provider)
322                );
323            }
324        }
325    } else {
326        println!("Providers: none");
327    }
328
329    if !report.warnings.is_empty() {
330        println!("Warnings:");
331        for warning in &report.warnings {
332            println!("  - {}", warning);
333        }
334    }
335
336    if let Some(report) = validation {
337        print_validation(report);
338    }
339}
340
341#[derive(Clone, Debug, Serialize)]
342struct ValidationOutput {
343    #[serde(flatten)]
344    report: ValidationReport,
345    has_errors: bool,
346    sources: Vec<crate::validator::ValidatorSourceReport>,
347}
348
349fn has_error_diagnostics(diagnostics: &[Diagnostic]) -> bool {
350    diagnostics
351        .iter()
352        .any(|diag| matches!(diag.severity, Severity::Error))
353}
354
355async fn run_pack_validation(
356    load: &PackLoad,
357    args: &InspectArgs,
358    runtime: &RuntimeContext,
359) -> Result<ValidationOutput> {
360    let ctx = ValidateCtx::from_pack_load(load);
361    let validators: Vec<Box<dyn greentic_types::validate::PackValidator>> = vec![
362        Box::new(ReferencedFilesExistValidator::new(ctx.clone())),
363        Box::new(SbomConsistencyValidator::new(ctx.clone())),
364        Box::new(ProviderReferencesExistValidator::new(ctx.clone())),
365        Box::new(ComponentReferencesExistValidator),
366    ];
367
368    let mut report = if let Some(manifest) = load.gpack_manifest.as_ref() {
369        run_validators(manifest, &ctx, &validators)
370    } else {
371        ValidationReport {
372            pack_id: None,
373            pack_version: None,
374            diagnostics: vec![Diagnostic {
375                severity: Severity::Warn,
376                code: "PACK_MANIFEST_UNSUPPORTED".to_string(),
377                message: "Pack manifest is not in the greentic-types format; skipping validation."
378                    .to_string(),
379                path: Some("manifest.cbor".to_string()),
380                hint: Some(
381                    "Rebuild the pack with greentic-pack build to enable validation.".to_string(),
382                ),
383                data: Value::Null,
384            }],
385        }
386    };
387
388    let config = ValidatorConfig {
389        validators_root: args.validators_root.clone(),
390        validator_packs: args.validator_pack.clone(),
391        validator_allow: args.validator_allow.clone(),
392        validator_cache_dir: args.validator_cache_dir.clone(),
393        policy: args.validator_policy,
394    };
395
396    let wasm_result = run_wasm_validators(load, &config, runtime).await?;
397    report.diagnostics.extend(wasm_result.diagnostics);
398
399    let has_errors = has_error_diagnostics(&report.diagnostics) || wasm_result.missing_required;
400
401    Ok(ValidationOutput {
402        report,
403        has_errors,
404        sources: wasm_result.sources,
405    })
406}
407
408fn print_validation(report: &ValidationOutput) {
409    let (info, warn, error) = validation_counts(&report.report);
410    println!("Validation:");
411    println!("  Info: {info} Warn: {warn} Error: {error}");
412    if report.report.diagnostics.is_empty() {
413        println!("  - none");
414        return;
415    }
416    for diag in &report.report.diagnostics {
417        let sev = match diag.severity {
418            Severity::Info => "INFO",
419            Severity::Warn => "WARN",
420            Severity::Error => "ERROR",
421        };
422        if let Some(path) = diag.path.as_deref() {
423            println!("  - [{sev}] {} {} - {}", diag.code, path, diag.message);
424        } else {
425            println!("  - [{sev}] {} - {}", diag.code, diag.message);
426        }
427        if let Some(hint) = diag.hint.as_deref() {
428            println!("    hint: {hint}");
429        }
430    }
431}
432
433fn validation_counts(report: &ValidationReport) -> (usize, usize, usize) {
434    let mut info = 0;
435    let mut warn = 0;
436    let mut error = 0;
437    for diag in &report.diagnostics {
438        match diag.severity {
439            Severity::Info => info += 1,
440            Severity::Warn => warn += 1,
441            Severity::Error => error += 1,
442        }
443    }
444    (info, warn, error)
445}
446
447#[derive(Debug, Clone, Copy, clap::ValueEnum)]
448pub enum InspectFormat {
449    Human,
450    Json,
451}
452
453fn resolve_format(args: &InspectArgs, json: bool) -> InspectFormat {
454    if json {
455        InspectFormat::Json
456    } else {
457        args.format
458    }
459}
460
461fn providers_from_manifest(manifest: &PackManifest) -> Vec<ProviderDecl> {
462    let mut providers = manifest
463        .provider_extension_inline()
464        .map(|inline| inline.providers.clone())
465        .unwrap_or_default();
466    providers.sort_by(|a, b| a.provider_type.cmp(&b.provider_type));
467    providers
468}
469
470fn provider_kind(provider: &ProviderDecl) -> String {
471    provider
472        .runtime
473        .world
474        .split('@')
475        .next()
476        .unwrap_or_default()
477        .to_string()
478}
479
480fn summarize_provider(provider: &ProviderDecl) -> String {
481    let caps = provider.capabilities.len();
482    let ops = provider.ops.len();
483    let mut parts = vec![format!("caps:{caps}"), format!("ops:{ops}")];
484    parts.push(format!("config:{}", provider.config_schema_ref));
485    if let Some(docs) = provider.docs_ref.as_deref() {
486        parts.push(format!("docs:{docs}"));
487    }
488    parts.join(" ")
489}
490
491fn format_component_source(source: &ComponentSourceRef) -> String {
492    match source {
493        ComponentSourceRef::Oci(value) => format_source_ref("oci", value),
494        ComponentSourceRef::Repo(value) => format_source_ref("repo", value),
495        ComponentSourceRef::Store(value) => format_source_ref("store", value),
496        ComponentSourceRef::File(value) => format_source_ref("file", value),
497    }
498}
499
500fn format_source_ref(scheme: &str, value: &str) -> String {
501    if value.contains("://") {
502        value.to_string()
503    } else {
504        format!("{scheme}://{value}")
505    }
506}
507
508fn format_component_artifact(artifact: &ArtifactLocationV1) -> String {
509    match artifact {
510        ArtifactLocationV1::Inline { wasm_path, .. } => format!("inline ({})", wasm_path),
511        ArtifactLocationV1::Remote => "remote".to_string(),
512    }
513}