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 #[arg(value_name = "PATH")]
36 pub path: Option<PathBuf>,
37
38 #[arg(long, value_name = "FILE", conflicts_with = "input")]
40 pub pack: Option<PathBuf>,
41
42 #[arg(long = "in", value_name = "DIR", conflicts_with = "pack")]
44 pub input: Option<PathBuf>,
45
46 #[arg(long)]
48 pub archive: bool,
49
50 #[arg(long)]
52 pub source: bool,
53
54 #[arg(long = "allow-oci-tags", default_value_t = false)]
56 pub allow_oci_tags: bool,
57
58 #[arg(long, value_enum, default_value = "human")]
60 pub format: InspectFormat,
61
62 #[arg(long, default_value_t = true)]
64 pub validate: bool,
65
66 #[arg(long = "no-validate", default_value_t = false)]
68 pub no_validate: bool,
69
70 #[arg(long, value_name = "DIR", default_value = ".greentic/validators")]
72 pub validators_root: PathBuf,
73
74 #[arg(long, value_name = "REF")]
76 pub validator_pack: Vec<String>,
77
78 #[arg(long, value_name = "PREFIX", default_value = DEFAULT_VALIDATOR_ALLOW)]
80 pub validator_allow: Vec<String>,
81
82 #[arg(long, value_name = "DIR", default_value = ".greentic/cache/validators")]
84 pub validator_cache_dir: PathBuf,
85
86 #[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"), 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 no_extra_dirs: false,
216 runtime: runtime.clone(),
217 skip_update: false,
218 };
219
220 build::run(&opts).await?;
221
222 inspect_pack_file(>pack_out)
223}
224
225fn print_human(load: &PackLoad, validation: Option<&ValidationOutput>) {
226 let manifest = &load.manifest;
227 let report = &load.report;
228 println!(
229 "Pack: {} ({})",
230 manifest.meta.pack_id, manifest.meta.version
231 );
232 println!("Name: {}", manifest.meta.name);
233 println!("Flows: {}", manifest.flows.len());
234 if manifest.flows.is_empty() {
235 println!("Flows list: none");
236 } else {
237 println!("Flows list:");
238 for flow in &manifest.flows {
239 println!(
240 " - {} (entry: {}, kind: {})",
241 flow.id, flow.entry, flow.kind
242 );
243 }
244 }
245 println!("Components: {}", manifest.components.len());
246 if manifest.components.is_empty() {
247 println!("Components list: none");
248 } else {
249 println!("Components list:");
250 for component in &manifest.components {
251 println!(" - {} ({})", component.name, component.version);
252 }
253 }
254 if let Some(gmanifest) = load.gpack_manifest.as_ref()
255 && let Some(value) = gmanifest
256 .extensions
257 .as_ref()
258 .and_then(|m| m.get(EXT_COMPONENT_SOURCES_V1))
259 .and_then(|ext| ext.inline.as_ref())
260 .and_then(|inline| match inline {
261 greentic_types::ExtensionInline::Other(v) => Some(v),
262 _ => None,
263 })
264 && let Ok(cs) = ComponentSourcesV1::from_extension_value(value)
265 {
266 let mut inline = 0usize;
267 let mut remote = 0usize;
268 let mut oci = 0usize;
269 let mut repo = 0usize;
270 let mut store = 0usize;
271 let mut file = 0usize;
272 for entry in &cs.components {
273 match entry.artifact {
274 ArtifactLocationV1::Inline { .. } => inline += 1,
275 ArtifactLocationV1::Remote => remote += 1,
276 }
277 match entry.source {
278 ComponentSourceRef::Oci(_) => oci += 1,
279 ComponentSourceRef::Repo(_) => repo += 1,
280 ComponentSourceRef::Store(_) => store += 1,
281 ComponentSourceRef::File(_) => file += 1,
282 }
283 }
284 println!(
285 "Component sources: {} total (origins: oci {}, repo {}, store {}, file {}; artifacts: inline {}, remote {})",
286 cs.components.len(),
287 oci,
288 repo,
289 store,
290 file,
291 inline,
292 remote
293 );
294 if cs.components.is_empty() {
295 println!("Component source entries: none");
296 } else {
297 println!("Component source entries:");
298 for entry in &cs.components {
299 println!(
300 " - {} source={} artifact={}",
301 entry.name,
302 format_component_source(&entry.source),
303 format_component_artifact(&entry.artifact)
304 );
305 }
306 }
307 } else {
308 println!("Component sources: none");
309 }
310
311 if let Some(gmanifest) = load.gpack_manifest.as_ref() {
312 let providers = providers_from_manifest(gmanifest);
313 if providers.is_empty() {
314 println!("Providers: none");
315 } else {
316 println!("Providers:");
317 for provider in providers {
318 println!(
319 " - {} ({}) {}",
320 provider.provider_type,
321 provider_kind(&provider),
322 summarize_provider(&provider)
323 );
324 }
325 }
326 } else {
327 println!("Providers: none");
328 }
329
330 if !report.warnings.is_empty() {
331 println!("Warnings:");
332 for warning in &report.warnings {
333 println!(" - {}", warning);
334 }
335 }
336
337 if let Some(report) = validation {
338 print_validation(report);
339 }
340}
341
342#[derive(Clone, Debug, Serialize)]
343struct ValidationOutput {
344 #[serde(flatten)]
345 report: ValidationReport,
346 has_errors: bool,
347 sources: Vec<crate::validator::ValidatorSourceReport>,
348}
349
350fn has_error_diagnostics(diagnostics: &[Diagnostic]) -> bool {
351 diagnostics
352 .iter()
353 .any(|diag| matches!(diag.severity, Severity::Error))
354}
355
356async fn run_pack_validation(
357 load: &PackLoad,
358 args: &InspectArgs,
359 runtime: &RuntimeContext,
360) -> Result<ValidationOutput> {
361 let ctx = ValidateCtx::from_pack_load(load);
362 let validators: Vec<Box<dyn greentic_types::validate::PackValidator>> = vec![
363 Box::new(ReferencedFilesExistValidator::new(ctx.clone())),
364 Box::new(SbomConsistencyValidator::new(ctx.clone())),
365 Box::new(ProviderReferencesExistValidator::new(ctx.clone())),
366 Box::new(ComponentReferencesExistValidator),
367 ];
368
369 let mut report = if let Some(manifest) = load.gpack_manifest.as_ref() {
370 run_validators(manifest, &ctx, &validators)
371 } else {
372 ValidationReport {
373 pack_id: None,
374 pack_version: None,
375 diagnostics: vec![Diagnostic {
376 severity: Severity::Warn,
377 code: "PACK_MANIFEST_UNSUPPORTED".to_string(),
378 message: "Pack manifest is not in the greentic-types format; skipping validation."
379 .to_string(),
380 path: Some("manifest.cbor".to_string()),
381 hint: Some(
382 "Rebuild the pack with greentic-pack build to enable validation.".to_string(),
383 ),
384 data: Value::Null,
385 }],
386 }
387 };
388
389 let config = ValidatorConfig {
390 validators_root: args.validators_root.clone(),
391 validator_packs: args.validator_pack.clone(),
392 validator_allow: args.validator_allow.clone(),
393 validator_cache_dir: args.validator_cache_dir.clone(),
394 policy: args.validator_policy,
395 };
396
397 let wasm_result = run_wasm_validators(load, &config, runtime).await?;
398 report.diagnostics.extend(wasm_result.diagnostics);
399
400 let has_errors = has_error_diagnostics(&report.diagnostics) || wasm_result.missing_required;
401
402 Ok(ValidationOutput {
403 report,
404 has_errors,
405 sources: wasm_result.sources,
406 })
407}
408
409fn print_validation(report: &ValidationOutput) {
410 let (info, warn, error) = validation_counts(&report.report);
411 println!("Validation:");
412 println!(" Info: {info} Warn: {warn} Error: {error}");
413 if report.report.diagnostics.is_empty() {
414 println!(" - none");
415 return;
416 }
417 for diag in &report.report.diagnostics {
418 let sev = match diag.severity {
419 Severity::Info => "INFO",
420 Severity::Warn => "WARN",
421 Severity::Error => "ERROR",
422 };
423 if let Some(path) = diag.path.as_deref() {
424 println!(" - [{sev}] {} {} - {}", diag.code, path, diag.message);
425 } else {
426 println!(" - [{sev}] {} - {}", diag.code, diag.message);
427 }
428 if let Some(hint) = diag.hint.as_deref() {
429 println!(" hint: {hint}");
430 }
431 }
432}
433
434fn validation_counts(report: &ValidationReport) -> (usize, usize, usize) {
435 let mut info = 0;
436 let mut warn = 0;
437 let mut error = 0;
438 for diag in &report.diagnostics {
439 match diag.severity {
440 Severity::Info => info += 1,
441 Severity::Warn => warn += 1,
442 Severity::Error => error += 1,
443 }
444 }
445 (info, warn, error)
446}
447
448#[derive(Debug, Clone, Copy, clap::ValueEnum)]
449pub enum InspectFormat {
450 Human,
451 Json,
452}
453
454fn resolve_format(args: &InspectArgs, json: bool) -> InspectFormat {
455 if json {
456 InspectFormat::Json
457 } else {
458 args.format
459 }
460}
461
462fn providers_from_manifest(manifest: &PackManifest) -> Vec<ProviderDecl> {
463 let mut providers = manifest
464 .provider_extension_inline()
465 .map(|inline| inline.providers.clone())
466 .unwrap_or_default();
467 providers.sort_by(|a, b| a.provider_type.cmp(&b.provider_type));
468 providers
469}
470
471fn provider_kind(provider: &ProviderDecl) -> String {
472 provider
473 .runtime
474 .world
475 .split('@')
476 .next()
477 .unwrap_or_default()
478 .to_string()
479}
480
481fn summarize_provider(provider: &ProviderDecl) -> String {
482 let caps = provider.capabilities.len();
483 let ops = provider.ops.len();
484 let mut parts = vec![format!("caps:{caps}"), format!("ops:{ops}")];
485 parts.push(format!("config:{}", provider.config_schema_ref));
486 if let Some(docs) = provider.docs_ref.as_deref() {
487 parts.push(format!("docs:{docs}"));
488 }
489 parts.join(" ")
490}
491
492fn format_component_source(source: &ComponentSourceRef) -> String {
493 match source {
494 ComponentSourceRef::Oci(value) => format_source_ref("oci", value),
495 ComponentSourceRef::Repo(value) => format_source_ref("repo", value),
496 ComponentSourceRef::Store(value) => format_source_ref("store", value),
497 ComponentSourceRef::File(value) => format_source_ref("file", value),
498 }
499}
500
501fn format_source_ref(scheme: &str, value: &str) -> String {
502 if value.contains("://") {
503 value.to_string()
504 } else {
505 format!("{scheme}://{value}")
506 }
507}
508
509fn format_component_artifact(artifact: &ArtifactLocationV1) -> String {
510 match artifact {
511 ArtifactLocationV1::Inline { wasm_path, .. } => format!("inline ({})", wasm_path),
512 ArtifactLocationV1::Remote => "remote".to_string(),
513 }
514}