1#![forbid(unsafe_code)]
2
3use std::io::Write;
4use std::{
5 collections::HashMap,
6 fs, io,
7 path::{Path, PathBuf},
8 process::{Command, Stdio},
9};
10
11use anyhow::{Context, Result, anyhow, bail};
12use clap::Parser;
13use greentic_pack::validate::{
14 ComponentReferencesExistValidator, ProviderReferencesExistValidator,
15 ReferencedFilesExistValidator, SbomConsistencyValidator, SecretRequirementsValidator,
16 ValidateCtx, run_validators,
17};
18use greentic_pack::{PackLoad, SigningPolicy, open_pack};
19use greentic_types::component_source::ComponentSourceRef;
20use greentic_types::pack::extensions::component_manifests::{
21 ComponentManifestIndexV1, EXT_COMPONENT_MANIFEST_INDEX_V1,
22};
23use greentic_types::pack::extensions::component_sources::{
24 ArtifactLocationV1, ComponentSourcesV1, EXT_COMPONENT_SOURCES_V1,
25};
26use greentic_types::pack_manifest::{ExtensionInline as PackManifestExtensionInline, PackManifest};
27use greentic_types::provider::ProviderDecl;
28use greentic_types::validate::{Diagnostic, Severity, ValidationReport};
29use serde::Serialize;
30use serde_cbor;
31use serde_json::Value;
32use tempfile::TempDir;
33
34use crate::build;
35use crate::runtime::RuntimeContext;
36use crate::validator::{
37 DEFAULT_VALIDATOR_ALLOW, LocalValidator, ValidatorConfig, ValidatorPolicy, run_wasm_validators,
38};
39
40const EXT_BUILD_MODE_ID: &str = "greentic.pack-mode.v1";
41
42#[derive(Clone, Copy, PartialEq, Eq)]
43enum PackBuildMode {
44 Prod,
45 Dev,
46}
47
48#[derive(Debug, Parser)]
49pub struct InspectArgs {
50 #[arg(value_name = "PATH")]
52 pub path: Option<PathBuf>,
53
54 #[arg(long, value_name = "FILE", conflicts_with = "input")]
56 pub pack: Option<PathBuf>,
57
58 #[arg(long = "in", value_name = "DIR", conflicts_with = "pack")]
60 pub input: Option<PathBuf>,
61
62 #[arg(long)]
64 pub archive: bool,
65
66 #[arg(long)]
68 pub source: bool,
69
70 #[arg(long = "allow-oci-tags", default_value_t = false)]
72 pub allow_oci_tags: bool,
73
74 #[arg(long = "no-flow-doctor", default_value_t = true, action = clap::ArgAction::SetFalse)]
76 pub flow_doctor: bool,
77
78 #[arg(long = "no-component-doctor", default_value_t = true, action = clap::ArgAction::SetFalse)]
80 pub component_doctor: bool,
81
82 #[arg(long, value_enum, default_value = "human")]
84 pub format: InspectFormat,
85
86 #[arg(long, default_value_t = true)]
88 pub validate: bool,
89
90 #[arg(long = "no-validate", default_value_t = false)]
92 pub no_validate: bool,
93
94 #[arg(long, value_name = "DIR", default_value = ".greentic/validators")]
96 pub validators_root: PathBuf,
97
98 #[arg(long, value_name = "REF")]
100 pub validator_pack: Vec<String>,
101
102 #[arg(long, value_name = "COMPONENT=FILE")]
104 pub validator_wasm: Vec<String>,
105
106 #[arg(long, value_name = "PREFIX", default_value = DEFAULT_VALIDATOR_ALLOW)]
108 pub validator_allow: Vec<String>,
109
110 #[arg(long, value_name = "DIR", default_value = ".greentic/cache/validators")]
112 pub validator_cache_dir: PathBuf,
113
114 #[arg(long, value_enum, default_value = "optional")]
116 pub validator_policy: ValidatorPolicy,
117}
118
119pub async fn handle(args: InspectArgs, json: bool, runtime: &RuntimeContext) -> Result<()> {
120 let mode = resolve_mode(&args)?;
121 let format = resolve_format(&args, json);
122 let validate_enabled = if args.no_validate {
123 false
124 } else {
125 args.validate
126 };
127
128 let load = match &mode {
129 InspectMode::Archive(path) => inspect_pack_file(path)?,
130 InspectMode::Source(path) => inspect_source_dir(path, runtime, args.allow_oci_tags).await?,
131 };
132 let build_mode = detect_pack_build_mode(&load);
133 if matches!(mode, InspectMode::Archive(_)) && build_mode == PackBuildMode::Prod {
134 let forbidden = find_forbidden_source_paths(&load.files);
135 if !forbidden.is_empty() {
136 bail!(
137 "production pack contains forbidden source files: {}",
138 forbidden.join(", ")
139 );
140 }
141 }
142 let validation = if validate_enabled {
143 let mut output = run_pack_validation(&load, &args, runtime).await?;
144 let mut doctor_diagnostics = Vec::new();
145 let mut doctor_errors = false;
146 if args.flow_doctor {
147 doctor_errors |= run_flow_doctors(&load, &mut doctor_diagnostics, build_mode)?;
148 }
149 if args.component_doctor {
150 doctor_errors |= run_component_doctors(&load, &mut doctor_diagnostics)?;
151 }
152 output.report.diagnostics.extend(doctor_diagnostics);
153 output.has_errors |= doctor_errors;
154 Some(output)
155 } else {
156 None
157 };
158
159 match format {
160 InspectFormat::Json => {
161 let mut payload = serde_json::json!({
162 "manifest": load.manifest,
163 "report": {
164 "signature_ok": load.report.signature_ok,
165 "sbom_ok": load.report.sbom_ok,
166 "warnings": load.report.warnings,
167 },
168 "sbom": load.sbom,
169 });
170 if let Some(report) = validation.as_ref() {
171 payload["validation"] = serde_json::to_value(report)?;
172 }
173 println!("{}", serde_json::to_string_pretty(&payload)?);
174 }
175 InspectFormat::Human => {
176 print_human(&load, validation.as_ref());
177 }
178 }
179
180 if validate_enabled
181 && validation
182 .as_ref()
183 .map(|report| report.has_errors)
184 .unwrap_or(false)
185 {
186 bail!("pack validation failed");
187 }
188
189 Ok(())
190}
191
192fn run_flow_doctors(
193 load: &PackLoad,
194 diagnostics: &mut Vec<Diagnostic>,
195 build_mode: PackBuildMode,
196) -> Result<bool> {
197 if load.manifest.flows.is_empty() {
198 return Ok(false);
199 }
200
201 let mut has_errors = false;
202
203 for flow in &load.manifest.flows {
204 let Some(bytes) = load.files.get(&flow.file_yaml) else {
205 if build_mode == PackBuildMode::Prod {
206 continue;
207 }
208 diagnostics.push(Diagnostic {
209 severity: Severity::Error,
210 code: "PACK_FLOW_DOCTOR_MISSING_FLOW".to_string(),
211 message: "flow file missing from pack".to_string(),
212 path: Some(flow.file_yaml.clone()),
213 hint: Some("rebuild the pack to include flow sources".to_string()),
214 data: Value::Null,
215 });
216 has_errors = true;
217 continue;
218 };
219
220 let mut command = Command::new("greentic-flow");
221 command
222 .args(["doctor", "--json", "--stdin"])
223 .stdin(Stdio::piped())
224 .stdout(Stdio::piped())
225 .stderr(Stdio::piped());
226 let mut child = match command.spawn() {
227 Ok(child) => child,
228 Err(err) if err.kind() == io::ErrorKind::NotFound => {
229 diagnostics.push(Diagnostic {
230 severity: Severity::Warn,
231 code: "PACK_FLOW_DOCTOR_UNAVAILABLE".to_string(),
232 message: "greentic-flow not available; skipping flow doctor checks".to_string(),
233 path: None,
234 hint: Some("install greentic-flow or pass --no-flow-doctor".to_string()),
235 data: Value::Null,
236 });
237 return Ok(false);
238 }
239 Err(err) => return Err(err).context("run greentic-flow doctor"),
240 };
241 if let Some(mut stdin) = child.stdin.take() {
242 stdin
243 .write_all(bytes)
244 .context("write flow content to greentic-flow stdin")?;
245 }
246 let output = child
247 .wait_with_output()
248 .context("wait for greentic-flow doctor")?;
249
250 if !output.status.success() {
251 if flow_doctor_unsupported(&output) {
252 diagnostics.push(Diagnostic {
253 severity: Severity::Warn,
254 code: "PACK_FLOW_DOCTOR_UNAVAILABLE".to_string(),
255 message: "greentic-flow does not support --stdin; skipping flow doctor checks"
256 .to_string(),
257 path: None,
258 hint: Some("upgrade greentic-flow or pass --no-flow-doctor".to_string()),
259 data: json_diagnostic_data(&output),
260 });
261 return Ok(false);
262 }
263 has_errors = true;
264 diagnostics.push(Diagnostic {
265 severity: Severity::Error,
266 code: "PACK_FLOW_DOCTOR_FAILED".to_string(),
267 message: "flow doctor failed".to_string(),
268 path: Some(flow.file_yaml.clone()),
269 hint: Some("run `greentic-flow doctor` for details".to_string()),
270 data: json_diagnostic_data(&output),
271 });
272 }
273 }
274
275 Ok(has_errors)
276}
277
278fn flow_doctor_unsupported(output: &std::process::Output) -> bool {
279 let mut combined = String::new();
280 combined.push_str(&String::from_utf8_lossy(&output.stdout));
281 combined.push_str(&String::from_utf8_lossy(&output.stderr));
282 let combined = combined.to_lowercase();
283 combined.contains("--stdin") && combined.contains("unknown")
284 || combined.contains("found argument '--stdin'")
285 || combined.contains("unexpected argument '--stdin'")
286 || combined.contains("unrecognized option '--stdin'")
287}
288
289fn run_component_doctors(load: &PackLoad, diagnostics: &mut Vec<Diagnostic>) -> Result<bool> {
290 if load.manifest.components.is_empty() {
291 return Ok(false);
292 }
293
294 let temp = TempDir::new().context("allocate temp dir for component doctor")?;
295 let mut has_errors = false;
296
297 let mut manifest_paths = std::collections::HashMap::new();
298 if let Some(gpack_manifest) = load.gpack_manifest.as_ref()
299 && let Some(manifest_extension) = gpack_manifest
300 .extensions
301 .as_ref()
302 .and_then(|map| map.get(EXT_COMPONENT_MANIFEST_INDEX_V1))
303 .and_then(|entry| entry.inline.as_ref())
304 .and_then(|inline| match inline {
305 PackManifestExtensionInline::Other(value) => Some(value),
306 _ => None,
307 })
308 .and_then(|value| ComponentManifestIndexV1::from_extension_value(value).ok())
309 {
310 for entry in manifest_extension.entries {
311 manifest_paths.insert(entry.component_id, entry.manifest_file);
312 }
313 }
314
315 for component in &load.manifest.components {
316 let Some(wasm_bytes) = load.files.get(&component.file_wasm) else {
317 diagnostics.push(Diagnostic {
318 severity: Severity::Warn,
319 code: "PACK_COMPONENT_DOCTOR_MISSING_WASM".to_string(),
320 message: "component wasm missing from pack; skipping component doctor".to_string(),
321 path: Some(component.file_wasm.clone()),
322 hint: Some("rebuild with --bundle=cache or supply cached artifacts".to_string()),
323 data: Value::Null,
324 });
325 continue;
326 };
327
328 if component.manifest_file.is_none() {
329 if manifest_paths.contains_key(&component.name) {
330 continue;
331 }
332 diagnostics.push(component_manifest_missing_diag(&component.manifest_file));
333 continue;
334 }
335
336 let manifest_bytes = if let Some(path) = component.manifest_file.as_deref()
337 && let Some(bytes) = load.files.get(path)
338 {
339 bytes.clone()
340 } else {
341 diagnostics.push(component_manifest_missing_diag(&component.manifest_file));
342 continue;
343 };
344
345 let component_dir = temp.path().join(sanitize_component_id(&component.name));
346 fs::create_dir_all(&component_dir)
347 .with_context(|| format!("create temp dir for {}", component.name))?;
348 let wasm_path = component_dir.join("component.wasm");
349 let manifest_value = match serde_json::from_slice::<Value>(&manifest_bytes) {
350 Ok(value) => value,
351 Err(_) => match serde_cbor::from_slice::<Value>(&manifest_bytes) {
352 Ok(value) => value,
353 Err(err) => {
354 diagnostics.push(component_manifest_missing_diag(&component.manifest_file));
355 tracing::debug!(
356 manifest = %component.name,
357 "failed to parse component manifest for doctor: {err}"
358 );
359 continue;
360 }
361 },
362 };
363
364 if !component_manifest_has_required_fields(&manifest_value) {
365 diagnostics.push(component_manifest_missing_diag(&component.manifest_file));
366 continue;
367 }
368
369 let manifest_bytes =
370 serde_json::to_vec_pretty(&manifest_value).context("serialize component manifest")?;
371
372 let manifest_path = component_dir.join("component.manifest.json");
373 fs::write(&wasm_path, wasm_bytes)?;
374 fs::write(&manifest_path, manifest_bytes)?;
375
376 let output = match Command::new("greentic-component")
377 .args(["doctor"])
378 .arg(&wasm_path)
379 .args(["--manifest"])
380 .arg(&manifest_path)
381 .output()
382 {
383 Ok(output) => output,
384 Err(err) if err.kind() == io::ErrorKind::NotFound => {
385 diagnostics.push(Diagnostic {
386 severity: Severity::Warn,
387 code: "PACK_COMPONENT_DOCTOR_UNAVAILABLE".to_string(),
388 message: "greentic-component not available; skipping component doctor checks"
389 .to_string(),
390 path: None,
391 hint: Some(
392 "install greentic-component or pass --no-component-doctor".to_string(),
393 ),
394 data: Value::Null,
395 });
396 return Ok(false);
397 }
398 Err(err) => return Err(err).context("run greentic-component doctor"),
399 };
400
401 if !output.status.success() {
402 has_errors = true;
403 diagnostics.push(Diagnostic {
404 severity: Severity::Error,
405 code: "PACK_COMPONENT_DOCTOR_FAILED".to_string(),
406 message: "component doctor failed".to_string(),
407 path: Some(component.name.clone()),
408 hint: Some("run `greentic-component doctor` for details".to_string()),
409 data: json_diagnostic_data(&output),
410 });
411 }
412 }
413
414 Ok(has_errors)
415}
416
417fn json_diagnostic_data(output: &std::process::Output) -> Value {
418 serde_json::json!({
419 "status": output.status.code(),
420 "stdout": String::from_utf8_lossy(&output.stdout).trim_end(),
421 "stderr": String::from_utf8_lossy(&output.stderr).trim_end(),
422 })
423}
424
425fn component_manifest_missing_diag(manifest_file: &Option<String>) -> Diagnostic {
426 Diagnostic {
427 severity: Severity::Warn,
428 code: "PACK_COMPONENT_DOCTOR_MISSING_MANIFEST".to_string(),
429 message: "component manifest missing or incomplete; skipping component doctor".to_string(),
430 path: manifest_file.clone(),
431 hint: Some("rebuild the pack to include component manifests".to_string()),
432 data: Value::Null,
433 }
434}
435
436fn component_manifest_has_required_fields(manifest: &Value) -> bool {
437 manifest.get("name").is_some()
438 && manifest.get("artifacts").is_some()
439 && manifest.get("hashes").is_some()
440 && manifest.get("describe_export").is_some()
441 && manifest.get("config_schema").is_some()
442}
443
444fn sanitize_component_id(value: &str) -> String {
445 value
446 .chars()
447 .map(|ch| {
448 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
449 ch
450 } else {
451 '_'
452 }
453 })
454 .collect()
455}
456
457fn inspect_pack_file(path: &Path) -> Result<PackLoad> {
458 let load = open_pack(path, SigningPolicy::DevOk)
459 .map_err(|err| anyhow!(err.message))
460 .with_context(|| format!("failed to open pack {}", path.display()))?;
461 Ok(load)
462}
463
464fn detect_pack_build_mode(load: &PackLoad) -> PackBuildMode {
465 if let Some(manifest) = load.gpack_manifest.as_ref()
466 && let Some(mode) = manifest_build_mode(manifest)
467 {
468 return mode;
469 }
470 if load.files.keys().any(|path| path.ends_with(".ygtc")) {
471 return PackBuildMode::Dev;
472 }
473 PackBuildMode::Prod
474}
475
476fn manifest_build_mode(manifest: &PackManifest) -> Option<PackBuildMode> {
477 let extensions = manifest.extensions.as_ref()?;
478 let entry = extensions.get(EXT_BUILD_MODE_ID)?;
479 let inline = entry.inline.as_ref()?;
480 if let PackManifestExtensionInline::Other(value) = inline
481 && let Some(mode) = value.get("mode").and_then(|value| value.as_str())
482 {
483 if mode.eq_ignore_ascii_case("dev") {
484 return Some(PackBuildMode::Dev);
485 }
486 return Some(PackBuildMode::Prod);
487 }
488 None
489}
490
491fn find_forbidden_source_paths(files: &HashMap<String, Vec<u8>>) -> Vec<String> {
492 files
493 .keys()
494 .filter(|path| is_forbidden_source_path(path))
495 .cloned()
496 .collect()
497}
498
499fn is_forbidden_source_path(path: &str) -> bool {
500 if matches!(path, "pack.yaml" | "pack.manifest.json" | "pack.lock.json") {
501 return true;
502 }
503 if matches!(
504 path,
505 "secret-requirements.json" | "secrets_requirements.json"
506 ) {
507 return true;
508 }
509 if path.ends_with(".ygtc") {
510 return true;
511 }
512 if path.starts_with("flows/") && path.ends_with(".json") {
513 return true;
514 }
515 if path.ends_with("manifest.json") {
516 return true;
517 }
518 false
519}
520
521enum InspectMode {
522 Archive(PathBuf),
523 Source(PathBuf),
524}
525
526fn resolve_mode(args: &InspectArgs) -> Result<InspectMode> {
527 if args.archive && args.source {
528 bail!("--archive and --source are mutually exclusive");
529 }
530 if args.pack.is_some() && args.input.is_some() {
531 bail!("exactly one of --pack or --in may be supplied");
532 }
533
534 if let Some(path) = &args.pack {
535 return Ok(InspectMode::Archive(path.clone()));
536 }
537 if let Some(path) = &args.input {
538 return Ok(InspectMode::Source(path.clone()));
539 }
540 if let Some(path) = &args.path {
541 let meta =
542 fs::metadata(path).with_context(|| format!("failed to stat {}", path.display()))?;
543 if args.archive || (path.extension() == Some(std::ffi::OsStr::new("gtpack"))) {
544 return Ok(InspectMode::Archive(path.clone()));
545 }
546 if args.source || meta.is_dir() {
547 return Ok(InspectMode::Source(path.clone()));
548 }
549 if meta.is_file() {
550 return Ok(InspectMode::Archive(path.clone()));
551 }
552 }
553 Ok(InspectMode::Source(
554 std::env::current_dir().context("determine current directory")?,
555 ))
556}
557
558async fn inspect_source_dir(
559 dir: &Path,
560 runtime: &RuntimeContext,
561 allow_oci_tags: bool,
562) -> Result<PackLoad> {
563 let pack_dir = dir
564 .canonicalize()
565 .with_context(|| format!("failed to resolve pack dir {}", dir.display()))?;
566
567 let temp = TempDir::new().context("failed to allocate temp dir for inspect")?;
568 let manifest_out = temp.path().join("manifest.cbor");
569 let gtpack_out = temp.path().join("pack.gtpack");
570
571 let opts = build::BuildOptions {
572 pack_dir,
573 component_out: None,
574 manifest_out,
575 sbom_out: None,
576 gtpack_out: Some(gtpack_out.clone()),
577 lock_path: gtpack_out.with_extension("lock.json"), bundle: build::BundleMode::Cache,
579 dry_run: false,
580 secrets_req: None,
581 default_secret_scope: None,
582 allow_oci_tags,
583 require_component_manifests: false,
584 no_extra_dirs: false,
585 dev: true,
586 runtime: runtime.clone(),
587 skip_update: false,
588 };
589
590 build::run(&opts).await?;
591
592 inspect_pack_file(>pack_out)
593}
594
595fn print_human(load: &PackLoad, validation: Option<&ValidationOutput>) {
596 let manifest = &load.manifest;
597 let report = &load.report;
598 println!(
599 "Pack: {} ({})",
600 manifest.meta.pack_id, manifest.meta.version
601 );
602 println!("Name: {}", manifest.meta.name);
603 println!("Flows: {}", manifest.flows.len());
604 if manifest.flows.is_empty() {
605 println!("Flows list: none");
606 } else {
607 println!("Flows list:");
608 for flow in &manifest.flows {
609 println!(
610 " - {} (entry: {}, kind: {})",
611 flow.id, flow.entry, flow.kind
612 );
613 }
614 }
615 println!("Components: {}", manifest.components.len());
616 if manifest.components.is_empty() {
617 println!("Components list: none");
618 } else {
619 println!("Components list:");
620 for component in &manifest.components {
621 println!(" - {} ({})", component.name, component.version);
622 }
623 }
624 if let Some(gmanifest) = load.gpack_manifest.as_ref()
625 && let Some(value) = gmanifest
626 .extensions
627 .as_ref()
628 .and_then(|m| m.get(EXT_COMPONENT_SOURCES_V1))
629 .and_then(|ext| ext.inline.as_ref())
630 .and_then(|inline| match inline {
631 greentic_types::ExtensionInline::Other(v) => Some(v),
632 _ => None,
633 })
634 && let Ok(cs) = ComponentSourcesV1::from_extension_value(value)
635 {
636 let mut inline = 0usize;
637 let mut remote = 0usize;
638 let mut oci = 0usize;
639 let mut repo = 0usize;
640 let mut store = 0usize;
641 let mut file = 0usize;
642 for entry in &cs.components {
643 match entry.artifact {
644 ArtifactLocationV1::Inline { .. } => inline += 1,
645 ArtifactLocationV1::Remote => remote += 1,
646 }
647 match entry.source {
648 ComponentSourceRef::Oci(_) => oci += 1,
649 ComponentSourceRef::Repo(_) => repo += 1,
650 ComponentSourceRef::Store(_) => store += 1,
651 ComponentSourceRef::File(_) => file += 1,
652 }
653 }
654 println!(
655 "Component sources: {} total (origins: oci {}, repo {}, store {}, file {}; artifacts: inline {}, remote {})",
656 cs.components.len(),
657 oci,
658 repo,
659 store,
660 file,
661 inline,
662 remote
663 );
664 if cs.components.is_empty() {
665 println!("Component source entries: none");
666 } else {
667 println!("Component source entries:");
668 for entry in &cs.components {
669 println!(
670 " - {} source={} artifact={}",
671 entry.name,
672 format_component_source(&entry.source),
673 format_component_artifact(&entry.artifact)
674 );
675 }
676 }
677 } else {
678 println!("Component sources: none");
679 }
680
681 if let Some(gmanifest) = load.gpack_manifest.as_ref() {
682 let providers = providers_from_manifest(gmanifest);
683 if providers.is_empty() {
684 println!("Providers: none");
685 } else {
686 println!("Providers:");
687 for provider in providers {
688 println!(
689 " - {} ({}) {}",
690 provider.provider_type,
691 provider_kind(&provider),
692 summarize_provider(&provider)
693 );
694 }
695 }
696 } else {
697 println!("Providers: none");
698 }
699
700 if !report.warnings.is_empty() {
701 println!("Warnings:");
702 for warning in &report.warnings {
703 println!(" - {}", warning);
704 }
705 }
706
707 if let Some(report) = validation {
708 print_validation(report);
709 }
710}
711
712#[derive(Clone, Debug, Serialize)]
713struct ValidationOutput {
714 #[serde(flatten)]
715 report: ValidationReport,
716 has_errors: bool,
717 sources: Vec<crate::validator::ValidatorSourceReport>,
718}
719
720fn has_error_diagnostics(diagnostics: &[Diagnostic]) -> bool {
721 diagnostics
722 .iter()
723 .any(|diag| matches!(diag.severity, Severity::Error))
724}
725
726async fn run_pack_validation(
727 load: &PackLoad,
728 args: &InspectArgs,
729 runtime: &RuntimeContext,
730) -> Result<ValidationOutput> {
731 let ctx = ValidateCtx::from_pack_load(load);
732 let validators: Vec<Box<dyn greentic_types::validate::PackValidator>> = vec![
733 Box::new(ReferencedFilesExistValidator::new(ctx.clone())),
734 Box::new(SbomConsistencyValidator::new(ctx.clone())),
735 Box::new(ProviderReferencesExistValidator::new(ctx.clone())),
736 Box::new(SecretRequirementsValidator),
737 Box::new(ComponentReferencesExistValidator),
738 ];
739
740 let mut report = if let Some(manifest) = load.gpack_manifest.as_ref() {
741 run_validators(manifest, &ctx, &validators)
742 } else {
743 ValidationReport {
744 pack_id: None,
745 pack_version: None,
746 diagnostics: vec![Diagnostic {
747 severity: Severity::Warn,
748 code: "PACK_MANIFEST_UNSUPPORTED".to_string(),
749 message: "Pack manifest is not in the greentic-types format; skipping validation."
750 .to_string(),
751 path: Some("manifest.cbor".to_string()),
752 hint: Some(
753 "Rebuild the pack with greentic-pack build to enable validation.".to_string(),
754 ),
755 data: Value::Null,
756 }],
757 }
758 };
759
760 let config = ValidatorConfig {
761 validators_root: args.validators_root.clone(),
762 validator_packs: args.validator_pack.clone(),
763 validator_allow: args.validator_allow.clone(),
764 validator_cache_dir: args.validator_cache_dir.clone(),
765 policy: args.validator_policy,
766 local_validators: parse_validator_wasm_args(&args.validator_wasm)?,
767 };
768
769 let wasm_result = run_wasm_validators(load, &config, runtime).await?;
770 report.diagnostics.extend(wasm_result.diagnostics);
771
772 let has_errors = has_error_diagnostics(&report.diagnostics) || wasm_result.missing_required;
773
774 Ok(ValidationOutput {
775 report,
776 has_errors,
777 sources: wasm_result.sources,
778 })
779}
780
781fn print_validation(report: &ValidationOutput) {
782 let (info, warn, error) = validation_counts(&report.report);
783 println!("Validation:");
784 println!(" Info: {info} Warn: {warn} Error: {error}");
785 if report.report.diagnostics.is_empty() {
786 println!(" - none");
787 return;
788 }
789 for diag in &report.report.diagnostics {
790 let sev = match diag.severity {
791 Severity::Info => "INFO",
792 Severity::Warn => "WARN",
793 Severity::Error => "ERROR",
794 };
795 if let Some(path) = diag.path.as_deref() {
796 println!(" - [{sev}] {} {} - {}", diag.code, path, diag.message);
797 } else {
798 println!(" - [{sev}] {} - {}", diag.code, diag.message);
799 }
800 if matches!(
801 diag.code.as_str(),
802 "PACK_FLOW_DOCTOR_FAILED" | "PACK_COMPONENT_DOCTOR_FAILED"
803 ) {
804 print_doctor_failure_details(&diag.data);
805 }
806 if let Some(hint) = diag.hint.as_deref() {
807 println!(" hint: {hint}");
808 }
809 }
810}
811
812fn parse_validator_wasm_args(args: &[String]) -> Result<Vec<LocalValidator>> {
813 let mut local_validators = Vec::new();
814 for entry in args {
815 let mut segments = entry.splitn(2, '=');
816 let component_id = segments.next().unwrap_or_default().trim().to_string();
817 let path = segments
818 .next()
819 .map(|p| p.trim())
820 .filter(|p| !p.is_empty())
821 .ok_or_else(|| {
822 anyhow!(
823 "invalid --validator-wasm argument `{}` (expected format COMPONENT_ID=FILE)",
824 entry
825 )
826 })?;
827 if component_id.is_empty() {
828 return Err(anyhow!(
829 "validator component id must not be empty in `{}`",
830 entry
831 ));
832 }
833 local_validators.push(LocalValidator {
834 component_id,
835 path: PathBuf::from(path),
836 });
837 }
838 Ok(local_validators)
839}
840
841fn print_doctor_failure_details(data: &Value) {
842 let Some(obj) = data.as_object() else {
843 return;
844 };
845 let stdout = obj.get("stdout").and_then(|value| value.as_str());
846 let stderr = obj.get("stderr").and_then(|value| value.as_str());
847 let status = obj.get("status").and_then(|value| value.as_i64());
848 if let Some(status) = status {
849 println!(" status: {status}");
850 }
851 if let Some(stderr) = stderr {
852 let trimmed = stderr.trim();
853 if !trimmed.is_empty() {
854 println!(" stderr: {trimmed}");
855 }
856 }
857 if let Some(stdout) = stdout {
858 let trimmed = stdout.trim();
859 if !trimmed.is_empty() {
860 println!(" stdout: {trimmed}");
861 }
862 }
863}
864
865fn validation_counts(report: &ValidationReport) -> (usize, usize, usize) {
866 let mut info = 0;
867 let mut warn = 0;
868 let mut error = 0;
869 for diag in &report.diagnostics {
870 match diag.severity {
871 Severity::Info => info += 1,
872 Severity::Warn => warn += 1,
873 Severity::Error => error += 1,
874 }
875 }
876 (info, warn, error)
877}
878
879#[derive(Debug, Clone, Copy, clap::ValueEnum)]
880pub enum InspectFormat {
881 Human,
882 Json,
883}
884
885fn resolve_format(args: &InspectArgs, json: bool) -> InspectFormat {
886 if json {
887 InspectFormat::Json
888 } else {
889 args.format
890 }
891}
892
893fn providers_from_manifest(manifest: &PackManifest) -> Vec<ProviderDecl> {
894 let mut providers = manifest
895 .provider_extension_inline()
896 .map(|inline| inline.providers.clone())
897 .unwrap_or_default();
898 providers.sort_by(|a, b| a.provider_type.cmp(&b.provider_type));
899 providers
900}
901
902fn provider_kind(provider: &ProviderDecl) -> String {
903 provider
904 .runtime
905 .world
906 .split('@')
907 .next()
908 .unwrap_or_default()
909 .to_string()
910}
911
912fn summarize_provider(provider: &ProviderDecl) -> String {
913 let caps = provider.capabilities.len();
914 let ops = provider.ops.len();
915 let mut parts = vec![format!("caps:{caps}"), format!("ops:{ops}")];
916 parts.push(format!("config:{}", provider.config_schema_ref));
917 if let Some(docs) = provider.docs_ref.as_deref() {
918 parts.push(format!("docs:{docs}"));
919 }
920 parts.join(" ")
921}
922
923fn format_component_source(source: &ComponentSourceRef) -> String {
924 match source {
925 ComponentSourceRef::Oci(value) => format_source_ref("oci", value),
926 ComponentSourceRef::Repo(value) => format_source_ref("repo", value),
927 ComponentSourceRef::Store(value) => format_source_ref("store", value),
928 ComponentSourceRef::File(value) => format_source_ref("file", value),
929 }
930}
931
932fn format_source_ref(scheme: &str, value: &str) -> String {
933 if value.contains("://") {
934 value.to_string()
935 } else {
936 format!("{scheme}://{value}")
937 }
938}
939
940fn format_component_artifact(artifact: &ArtifactLocationV1) -> String {
941 match artifact {
942 ArtifactLocationV1::Inline { wasm_path, .. } => format!("inline ({})", wasm_path),
943 ArtifactLocationV1::Remote => "remote".to_string(),
944 }
945}