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::static_routes::{StaticRouteV1, parse_static_routes_extension};
14use greentic_pack::validate::{
15 ComponentReferencesExistValidator, OauthCapabilityRequirementsValidator,
16 ProviderReferencesExistValidator, ReferencedFilesExistValidator, SbomConsistencyValidator,
17 SecretRequirementsValidator, StaticRoutesValidator, ValidateCtx, run_validators,
18};
19use greentic_pack::{PackLoad, SigningPolicy, open_pack};
20use greentic_types::component_source::ComponentSourceRef;
21use greentic_types::pack::extensions::component_manifests::{
22 ComponentManifestIndexV1, EXT_COMPONENT_MANIFEST_INDEX_V1,
23};
24use greentic_types::pack::extensions::component_sources::{
25 ArtifactLocationV1, ComponentSourcesV1, EXT_COMPONENT_SOURCES_V1,
26};
27use greentic_types::pack_manifest::{ExtensionInline as PackManifestExtensionInline, PackManifest};
28use greentic_types::provider::ProviderDecl;
29use greentic_types::validate::{Diagnostic, Severity, ValidationReport};
30use serde::Serialize;
31use serde_cbor;
32use serde_json::Value;
33use tempfile::TempDir;
34
35use crate::build;
36use crate::extension_refs::{
37 default_extensions_file_path, default_extensions_lock_file_path, read_extensions_file,
38 read_extensions_lock_file, validate_extensions_lock_alignment,
39};
40use crate::extensions::DEPLOYER_EXTENSION_KEY;
41use crate::pack_lock_doctor::{PackLockDoctorInput, run_pack_lock_doctor};
42use crate::runtime::RuntimeContext;
43use crate::validator::{
44 DEFAULT_VALIDATOR_ALLOW, LocalValidator, ValidatorConfig, ValidatorPolicy, run_wasm_validators,
45};
46
47const EXT_BUILD_MODE_ID: &str = "greentic.pack-mode.v1";
48
49#[derive(Clone, Copy, PartialEq, Eq)]
50enum PackBuildMode {
51 Prod,
52 Dev,
53}
54
55#[derive(Debug, Parser)]
56pub struct InspectArgs {
57 #[arg(value_name = "PATH")]
59 pub path: Option<PathBuf>,
60
61 #[arg(long, value_name = "FILE", conflicts_with = "input")]
63 pub pack: Option<PathBuf>,
64
65 #[arg(long = "in", value_name = "DIR", conflicts_with = "pack")]
67 pub input: Option<PathBuf>,
68
69 #[arg(long)]
71 pub archive: bool,
72
73 #[arg(long)]
75 pub source: bool,
76
77 #[arg(long = "allow-oci-tags", default_value_t = false)]
79 pub allow_oci_tags: bool,
80
81 #[arg(long = "no-flow-doctor", default_value_t = true, action = clap::ArgAction::SetFalse)]
83 pub flow_doctor: bool,
84
85 #[arg(long = "no-component-doctor", default_value_t = true, action = clap::ArgAction::SetFalse)]
87 pub component_doctor: bool,
88
89 #[arg(long, value_enum, default_value = "human")]
91 pub format: InspectFormat,
92
93 #[arg(long, default_value_t = true)]
95 pub validate: bool,
96
97 #[arg(long = "no-validate", default_value_t = false)]
99 pub no_validate: bool,
100
101 #[arg(long, value_name = "DIR", default_value = ".greentic/validators")]
103 pub validators_root: PathBuf,
104
105 #[arg(long, value_name = "REF")]
107 pub validator_pack: Vec<String>,
108
109 #[arg(long, value_name = "COMPONENT=FILE")]
111 pub validator_wasm: Vec<String>,
112
113 #[arg(long, value_name = "PREFIX", default_value = DEFAULT_VALIDATOR_ALLOW)]
115 pub validator_allow: Vec<String>,
116
117 #[arg(long, value_name = "DIR", default_value = ".greentic/cache/validators")]
119 pub validator_cache_dir: PathBuf,
120
121 #[arg(long, value_enum, default_value = "optional")]
123 pub validator_policy: ValidatorPolicy,
124
125 #[arg(long, default_value_t = false)]
127 pub online: bool,
128
129 #[arg(long = "use-describe-cache", default_value_t = false)]
131 pub use_describe_cache: bool,
132}
133
134pub async fn handle(args: InspectArgs, json: bool, runtime: &RuntimeContext) -> Result<()> {
135 let mode = resolve_mode(&args)?;
136 let format = resolve_format(&args, json);
137 let validate_enabled = if args.no_validate {
138 false
139 } else {
140 args.validate
141 };
142
143 let load = match &mode {
144 InspectMode::Archive(path) => inspect_pack_file(path)?,
145 InspectMode::Source(path) => inspect_source_dir(path, runtime, args.allow_oci_tags).await?,
146 };
147 let build_mode = detect_pack_build_mode(&load);
148 if matches!(mode, InspectMode::Archive(_)) && build_mode == PackBuildMode::Prod {
149 let forbidden = find_forbidden_source_paths(&load.files);
150 if !forbidden.is_empty() {
151 bail!(
152 "production pack contains forbidden source files: {}",
153 forbidden.join(", ")
154 );
155 }
156 }
157 let validation = if validate_enabled {
158 let mut output =
159 run_pack_validation(&load, source_mode_pack_dir(&mode), &args, runtime).await?;
160 let mut doctor_diagnostics = Vec::new();
161 let mut doctor_errors = false;
162 if args.component_doctor {
163 let use_describe_cache = args.use_describe_cache
164 || std::env::var("GREENTIC_PACK_USE_DESCRIBE_CACHE").is_ok()
165 || cfg!(test);
166 let pack_dir = match &mode {
167 InspectMode::Source(path) => Some(path.as_path()),
168 InspectMode::Archive(_) => None,
169 };
170 let pack_lock_output = run_pack_lock_doctor(PackLockDoctorInput {
171 load: &load,
172 pack_dir,
173 runtime,
174 allow_oci_tags: args.allow_oci_tags,
175 use_describe_cache,
176 online: args.online,
177 })?;
178 doctor_errors |= pack_lock_output.has_errors;
179 doctor_diagnostics.extend(pack_lock_output.diagnostics);
180 }
181 if args.flow_doctor {
182 doctor_errors |= run_flow_doctors(&load, &mut doctor_diagnostics, build_mode)?;
183 }
184 if args.component_doctor {
185 doctor_errors |= run_component_doctors(&load, &mut doctor_diagnostics)?;
186 }
187 output.report.diagnostics.extend(doctor_diagnostics);
188 output.has_errors |= doctor_errors;
189 Some(output)
190 } else {
191 None
192 };
193
194 match format {
195 InspectFormat::Json => {
196 let mut payload = serde_json::json!({
197 "manifest": load.manifest,
198 "report": {
199 "signature_ok": load.report.signature_ok,
200 "sbom_ok": load.report.sbom_ok,
201 "warnings": load.report.warnings,
202 },
203 "sbom": load.sbom,
204 "static_routes": load_static_routes(&load),
205 });
206 if let Some(report) = validation.as_ref() {
207 payload["validation"] = serde_json::to_value(report)?;
208 }
209 println!("{}", to_sorted_json(payload)?);
210 }
211 InspectFormat::Human => {
212 print_human(&load, validation.as_ref());
213 }
214 }
215
216 if validate_enabled
217 && validation
218 .as_ref()
219 .map(|report| report.has_errors)
220 .unwrap_or(false)
221 {
222 bail!("pack validation failed");
223 }
224
225 Ok(())
226}
227
228fn to_sorted_json(value: Value) -> Result<String> {
229 let sorted = sort_json(value);
230 Ok(serde_json::to_string_pretty(&sorted)?)
231}
232
233pub(crate) fn sort_json(value: Value) -> Value {
234 match value {
235 Value::Object(map) => {
236 let mut entries: Vec<(String, Value)> = map.into_iter().collect();
237 entries.sort_by(|a, b| a.0.cmp(&b.0));
238 let mut sorted = serde_json::Map::new();
239 for (key, value) in entries {
240 sorted.insert(key, sort_json(value));
241 }
242 Value::Object(sorted)
243 }
244 Value::Array(values) => Value::Array(values.into_iter().map(sort_json).collect()),
245 other => other,
246 }
247}
248
249fn run_flow_doctors(
250 load: &PackLoad,
251 diagnostics: &mut Vec<Diagnostic>,
252 build_mode: PackBuildMode,
253) -> Result<bool> {
254 if load.manifest.flows.is_empty() {
255 return Ok(false);
256 }
257
258 let mut has_errors = false;
259
260 for flow in &load.manifest.flows {
261 let Some(bytes) = load.files.get(&flow.file_yaml) else {
262 if build_mode == PackBuildMode::Prod {
263 continue;
264 }
265 diagnostics.push(Diagnostic {
266 severity: Severity::Error,
267 code: "PACK_FLOW_DOCTOR_MISSING_FLOW".to_string(),
268 message: "flow file missing from pack".to_string(),
269 path: Some(flow.file_yaml.clone()),
270 hint: Some("rebuild the pack to include flow sources".to_string()),
271 data: Value::Null,
272 });
273 has_errors = true;
274 continue;
275 };
276
277 let flow_bin = crate::external_tools::resolve("greentic-flow")
278 .unwrap_or_else(|| PathBuf::from("greentic-flow"));
279 let mut command = Command::new(&flow_bin);
280 command
281 .args(["doctor", "--json", "--stdin"])
282 .stdin(Stdio::piped())
283 .stdout(Stdio::piped())
284 .stderr(Stdio::piped());
285 let mut child = match command.spawn() {
286 Ok(child) => child,
287 Err(err) if err.kind() == io::ErrorKind::NotFound => {
288 diagnostics.push(Diagnostic {
289 severity: Severity::Warn,
290 code: "PACK_FLOW_DOCTOR_UNAVAILABLE".to_string(),
291 message: "greentic-flow not available; skipping flow doctor checks".to_string(),
292 path: None,
293 hint: Some("install greentic-flow or pass --no-flow-doctor".to_string()),
294 data: Value::Null,
295 });
296 return Ok(false);
297 }
298 Err(err) => {
299 return Err(err).with_context(|| format!("run {} doctor", flow_bin.display()));
300 }
301 };
302 if let Some(mut stdin) = child.stdin.take() {
303 stdin
304 .write_all(bytes)
305 .context("write flow content to greentic-flow stdin")?;
306 }
307 let output = child
308 .wait_with_output()
309 .context("wait for greentic-flow doctor")?;
310
311 if !output.status.success() {
312 if flow_doctor_unsupported(&output) {
313 diagnostics.push(Diagnostic {
314 severity: Severity::Warn,
315 code: "PACK_FLOW_DOCTOR_UNAVAILABLE".to_string(),
316 message: "greentic-flow does not support --stdin; skipping flow doctor checks"
317 .to_string(),
318 path: None,
319 hint: Some("update greentic-flow or pass --no-flow-doctor".to_string()),
320 data: json_diagnostic_data(&output),
321 });
322 return Ok(false);
323 }
324 has_errors = true;
325 diagnostics.push(Diagnostic {
326 severity: Severity::Error,
327 code: "PACK_FLOW_DOCTOR_FAILED".to_string(),
328 message: "flow doctor failed".to_string(),
329 path: Some(flow.file_yaml.clone()),
330 hint: Some("run `greentic-flow doctor` for details".to_string()),
331 data: json_diagnostic_data(&output),
332 });
333 }
334 }
335
336 Ok(has_errors)
337}
338
339fn flow_doctor_unsupported(output: &std::process::Output) -> bool {
340 let mut combined = String::new();
341 combined.push_str(&String::from_utf8_lossy(&output.stdout));
342 combined.push_str(&String::from_utf8_lossy(&output.stderr));
343 let combined = combined.to_lowercase();
344 combined.contains("--stdin") && combined.contains("unknown")
345 || combined.contains("found argument '--stdin'")
346 || combined.contains("unexpected argument '--stdin'")
347 || combined.contains("unrecognized option '--stdin'")
348}
349
350fn run_component_doctors(load: &PackLoad, diagnostics: &mut Vec<Diagnostic>) -> Result<bool> {
351 if load.manifest.components.is_empty() {
352 return Ok(false);
353 }
354
355 let temp = TempDir::new().context("allocate temp dir for component doctor")?;
356 let mut has_errors = false;
357
358 let mut manifest_paths = std::collections::HashMap::new();
359 if let Some(gpack_manifest) = load.gpack_manifest.as_ref()
360 && let Some(manifest_extension) = gpack_manifest
361 .extensions
362 .as_ref()
363 .and_then(|map| map.get(EXT_COMPONENT_MANIFEST_INDEX_V1))
364 .and_then(|entry| entry.inline.as_ref())
365 .and_then(|inline| match inline {
366 PackManifestExtensionInline::Other(value) => Some(value),
367 _ => None,
368 })
369 .and_then(|value| ComponentManifestIndexV1::from_extension_value(value).ok())
370 {
371 for entry in manifest_extension.entries {
372 manifest_paths.insert(entry.component_id, entry.manifest_file);
373 }
374 }
375
376 for component in &load.manifest.components {
377 let Some(wasm_bytes) = load.files.get(&component.file_wasm) else {
378 diagnostics.push(Diagnostic {
379 severity: Severity::Warn,
380 code: "PACK_COMPONENT_DOCTOR_MISSING_WASM".to_string(),
381 message: "component wasm missing from pack; skipping component doctor".to_string(),
382 path: Some(component.file_wasm.clone()),
383 hint: Some("rebuild with --bundle=cache or supply cached artifacts".to_string()),
384 data: Value::Null,
385 });
386 continue;
387 };
388
389 if component.manifest_file.is_none() {
390 if manifest_paths.contains_key(&component.name) {
391 continue;
392 }
393 diagnostics.push(component_manifest_missing_diag(&component.manifest_file));
394 continue;
395 }
396
397 let manifest_bytes = if let Some(path) = component.manifest_file.as_deref()
398 && let Some(bytes) = load.files.get(path)
399 {
400 bytes.clone()
401 } else {
402 diagnostics.push(component_manifest_missing_diag(&component.manifest_file));
403 continue;
404 };
405
406 let component_dir = temp.path().join(sanitize_component_id(&component.name));
407 fs::create_dir_all(&component_dir)
408 .with_context(|| format!("create temp dir for {}", component.name))?;
409 let wasm_path = component_dir.join("component.wasm");
410 let manifest_value = match serde_json::from_slice::<Value>(&manifest_bytes) {
411 Ok(value) => value,
412 Err(_) => match serde_cbor::from_slice::<Value>(&manifest_bytes) {
413 Ok(value) => value,
414 Err(err) => {
415 diagnostics.push(component_manifest_missing_diag(&component.manifest_file));
416 tracing::debug!(
417 manifest = %component.name,
418 "failed to parse component manifest for doctor: {err}"
419 );
420 continue;
421 }
422 },
423 };
424
425 if !component_manifest_has_required_fields(&manifest_value) {
426 diagnostics.push(component_manifest_missing_diag(&component.manifest_file));
427 continue;
428 }
429
430 let manifest_bytes =
431 serde_json::to_vec_pretty(&manifest_value).context("serialize component manifest")?;
432
433 let manifest_path = component_dir.join("component.manifest.json");
434 fs::write(&wasm_path, wasm_bytes)?;
435 fs::write(&manifest_path, manifest_bytes)?;
436
437 let component_bin = crate::external_tools::resolve("greentic-component")
438 .unwrap_or_else(|| PathBuf::from("greentic-component"));
439 let output = match Command::new(&component_bin)
440 .args(["doctor"])
441 .arg(&wasm_path)
442 .args(["--manifest"])
443 .arg(&manifest_path)
444 .output()
445 {
446 Ok(output) => output,
447 Err(err) if err.kind() == io::ErrorKind::NotFound => {
448 diagnostics.push(Diagnostic {
449 severity: Severity::Warn,
450 code: "PACK_COMPONENT_DOCTOR_UNAVAILABLE".to_string(),
451 message: "greentic-component not available; skipping component doctor checks"
452 .to_string(),
453 path: None,
454 hint: Some(
455 "install greentic-component or pass --no-component-doctor".to_string(),
456 ),
457 data: Value::Null,
458 });
459 return Ok(false);
460 }
461 Err(err) => {
462 return Err(err).with_context(|| format!("run {} doctor", component_bin.display()));
463 }
464 };
465
466 if !output.status.success() {
467 has_errors = true;
468 diagnostics.push(Diagnostic {
469 severity: Severity::Error,
470 code: "PACK_COMPONENT_DOCTOR_FAILED".to_string(),
471 message: "component doctor failed".to_string(),
472 path: Some(component.name.clone()),
473 hint: Some("run `greentic-component doctor` for details".to_string()),
474 data: json_diagnostic_data(&output),
475 });
476 }
477 }
478
479 Ok(has_errors)
480}
481
482fn json_diagnostic_data(output: &std::process::Output) -> Value {
483 serde_json::json!({
484 "status": output.status.code(),
485 "stdout": String::from_utf8_lossy(&output.stdout).trim_end(),
486 "stderr": String::from_utf8_lossy(&output.stderr).trim_end(),
487 })
488}
489
490fn component_manifest_missing_diag(manifest_file: &Option<String>) -> Diagnostic {
491 Diagnostic {
492 severity: Severity::Warn,
493 code: "PACK_COMPONENT_DOCTOR_MISSING_MANIFEST".to_string(),
494 message: "component manifest missing or incomplete; skipping component doctor".to_string(),
495 path: manifest_file.clone(),
496 hint: Some("rebuild the pack to include component manifests".to_string()),
497 data: Value::Null,
498 }
499}
500
501fn component_manifest_has_required_fields(manifest: &Value) -> bool {
502 manifest.get("name").is_some()
503 && manifest.get("artifacts").is_some()
504 && manifest.get("hashes").is_some()
505 && manifest.get("describe_export").is_some()
506 && manifest.get("config_schema").is_some()
507}
508
509fn sanitize_component_id(value: &str) -> String {
510 value
511 .chars()
512 .map(|ch| {
513 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
514 ch
515 } else {
516 '_'
517 }
518 })
519 .collect()
520}
521
522fn inspect_pack_file(path: &Path) -> Result<PackLoad> {
523 let load = open_pack(path, SigningPolicy::DevOk)
524 .map_err(|err| anyhow!(err.message))
525 .with_context(|| format!("failed to open pack {}", path.display()))?;
526 Ok(load)
527}
528
529fn detect_pack_build_mode(load: &PackLoad) -> PackBuildMode {
530 if let Some(manifest) = load.gpack_manifest.as_ref()
531 && let Some(mode) = manifest_build_mode(manifest)
532 {
533 return mode;
534 }
535 if load.files.keys().any(|path| path.ends_with(".ygtc")) {
536 return PackBuildMode::Dev;
537 }
538 PackBuildMode::Prod
539}
540
541fn manifest_build_mode(manifest: &PackManifest) -> Option<PackBuildMode> {
542 let extensions = manifest.extensions.as_ref()?;
543 let entry = extensions.get(EXT_BUILD_MODE_ID)?;
544 let inline = entry.inline.as_ref()?;
545 if let PackManifestExtensionInline::Other(value) = inline
546 && let Some(mode) = value.get("mode").and_then(|value| value.as_str())
547 {
548 if mode.eq_ignore_ascii_case("dev") {
549 return Some(PackBuildMode::Dev);
550 }
551 return Some(PackBuildMode::Prod);
552 }
553 None
554}
555
556fn find_forbidden_source_paths(files: &HashMap<String, Vec<u8>>) -> Vec<String> {
557 files
558 .keys()
559 .filter(|path| is_forbidden_source_path(path))
560 .cloned()
561 .collect()
562}
563
564fn is_forbidden_source_path(path: &str) -> bool {
565 if matches!(path, "pack.yaml" | "pack.manifest.json") {
566 return true;
567 }
568 if matches!(
569 path,
570 "secret-requirements.json" | "secrets_requirements.json"
571 ) {
572 return true;
573 }
574 if path.ends_with(".ygtc") {
575 return true;
576 }
577 if path.starts_with("flows/") && path.ends_with(".json") {
578 return true;
579 }
580 if path.ends_with("manifest.json") {
581 return true;
582 }
583 false
584}
585
586enum InspectMode {
587 Archive(PathBuf),
588 Source(PathBuf),
589}
590
591fn resolve_mode(args: &InspectArgs) -> Result<InspectMode> {
592 if args.archive && args.source {
593 bail!("--archive and --source are mutually exclusive");
594 }
595 if args.pack.is_some() && args.input.is_some() {
596 bail!("exactly one of --pack or --in may be supplied");
597 }
598
599 if let Some(path) = &args.pack {
600 return Ok(InspectMode::Archive(path.clone()));
601 }
602 if let Some(path) = &args.input {
603 return Ok(InspectMode::Source(path.clone()));
604 }
605 if let Some(path) = &args.path {
606 let meta =
607 fs::metadata(path).with_context(|| format!("failed to stat {}", path.display()))?;
608 if args.archive || (path.extension() == Some(std::ffi::OsStr::new("gtpack"))) {
609 return Ok(InspectMode::Archive(path.clone()));
610 }
611 if args.source || meta.is_dir() {
612 return Ok(InspectMode::Source(path.clone()));
613 }
614 if meta.is_file() {
615 return Ok(InspectMode::Archive(path.clone()));
616 }
617 }
618 Ok(InspectMode::Source(
619 std::env::current_dir().context("determine current directory")?,
620 ))
621}
622
623fn source_mode_pack_dir(mode: &InspectMode) -> Option<&Path> {
624 match mode {
625 InspectMode::Source(path) => Some(path.as_path()),
626 InspectMode::Archive(_) => None,
627 }
628}
629
630async fn inspect_source_dir(
631 dir: &Path,
632 runtime: &RuntimeContext,
633 allow_oci_tags: bool,
634) -> Result<PackLoad> {
635 let pack_dir = dir
636 .canonicalize()
637 .with_context(|| format!("failed to resolve pack dir {}", dir.display()))?;
638
639 let temp = TempDir::new().context("failed to allocate temp dir for inspect")?;
640 let manifest_out = temp.path().join("manifest.cbor");
641 let gtpack_out = temp.path().join("pack.gtpack");
642
643 let opts = build::BuildOptions {
644 pack_dir,
645 component_out: None,
646 manifest_out,
647 sbom_out: None,
648 gtpack_out: Some(gtpack_out.clone()),
649 lock_path: gtpack_out.with_extension("lock.json"), bundle: build::BundleMode::Cache,
651 dry_run: false,
652 secrets_req: None,
653 default_secret_scope: None,
654 allow_oci_tags,
655 require_component_manifests: false,
656 no_extra_dirs: false,
657 dev: true,
658 runtime: runtime.clone(),
659 skip_update: false,
660 allow_pack_schema: false,
661 validate_extension_refs: false,
662 };
663
664 build::run(&opts).await?;
665
666 inspect_pack_file(>pack_out)
667}
668
669fn print_human(load: &PackLoad, validation: Option<&ValidationOutput>) {
670 let manifest = &load.manifest;
671 let report = &load.report;
672 println!(
673 "Pack: {} ({})",
674 manifest.meta.pack_id, manifest.meta.version
675 );
676 println!("Name: {}", manifest.meta.name);
677 println!("Flows: {}", manifest.flows.len());
678 if manifest.flows.is_empty() {
679 println!("Flows list: none");
680 } else {
681 println!("Flows list:");
682 for flow in &manifest.flows {
683 println!(
684 " - {} (entry: {}, kind: {})",
685 flow.id, flow.entry, flow.kind
686 );
687 }
688 }
689 println!("Components: {}", manifest.components.len());
690 if manifest.components.is_empty() {
691 println!("Components list: none");
692 } else {
693 println!("Components list:");
694 for component in &manifest.components {
695 println!(" - {} ({})", component.name, component.version);
696 }
697 }
698 if let Some(gmanifest) = load.gpack_manifest.as_ref()
699 && let Some(value) = gmanifest
700 .extensions
701 .as_ref()
702 .and_then(|m| m.get(EXT_COMPONENT_SOURCES_V1))
703 .and_then(|ext| ext.inline.as_ref())
704 .and_then(|inline| match inline {
705 greentic_types::ExtensionInline::Other(v) => Some(v),
706 _ => None,
707 })
708 && let Ok(cs) = ComponentSourcesV1::from_extension_value(value)
709 {
710 let mut inline = 0usize;
711 let mut remote = 0usize;
712 let mut oci = 0usize;
713 let mut repo = 0usize;
714 let mut store = 0usize;
715 let mut file = 0usize;
716 for entry in &cs.components {
717 match entry.artifact {
718 ArtifactLocationV1::Inline { .. } => inline += 1,
719 ArtifactLocationV1::Remote => remote += 1,
720 }
721 match entry.source {
722 ComponentSourceRef::Oci(_) => oci += 1,
723 ComponentSourceRef::Repo(_) => repo += 1,
724 ComponentSourceRef::Store(_) => store += 1,
725 ComponentSourceRef::File(_) => file += 1,
726 }
727 }
728 println!(
729 "Component sources: {} total (origins: oci {}, repo {}, store {}, file {}; artifacts: inline {}, remote {})",
730 cs.components.len(),
731 oci,
732 repo,
733 store,
734 file,
735 inline,
736 remote
737 );
738 if cs.components.is_empty() {
739 println!("Component source entries: none");
740 } else {
741 println!("Component source entries:");
742 for entry in &cs.components {
743 println!(
744 " - {} source={} artifact={}",
745 entry.name,
746 format_component_source(&entry.source),
747 format_component_artifact(&entry.artifact)
748 );
749 }
750 }
751 } else {
752 println!("Component sources: none");
753 }
754
755 if let Some(gmanifest) = load.gpack_manifest.as_ref() {
756 let providers = providers_from_manifest(gmanifest);
757 if providers.is_empty() {
758 println!("Providers: none");
759 } else {
760 println!("Providers:");
761 for provider in providers {
762 println!(
763 " - {} ({}) {}",
764 provider.provider_type,
765 provider_kind(&provider),
766 summarize_provider(&provider)
767 );
768 }
769 }
770 } else {
771 println!("Providers: none");
772 }
773
774 let static_routes = load_static_routes(load);
775 if static_routes.is_empty() {
776 println!("Static routes: none");
777 } else {
778 println!("Static routes:");
779 for route in &static_routes {
780 println!(
781 " - {} -> {} [{}]",
782 route.id, route.public_path, route.source_root
783 );
784 println!(
785 " scope: tenant={} team={}",
786 route.scope.tenant, route.scope.team
787 );
788 println!(
789 " index_file: {}",
790 route.index_file.as_deref().unwrap_or("none")
791 );
792 println!(
793 " spa_fallback: {}",
794 route.spa_fallback.as_deref().unwrap_or("none")
795 );
796 println!(
797 " cache: {}",
798 route
799 .cache
800 .as_ref()
801 .map(|cache| match cache.max_age_seconds {
802 Some(max_age) => format!("{} ({max_age}s)", cache.strategy),
803 None => cache.strategy.clone(),
804 })
805 .unwrap_or_else(|| "none".to_string())
806 );
807 if route.exports.is_empty() {
808 println!(" exports: none");
809 } else {
810 let exports = route
811 .exports
812 .iter()
813 .map(|(key, value)| format!("{key}={value}"))
814 .collect::<Vec<_>>()
815 .join(", ");
816 println!(" exports: {exports}");
817 }
818 }
819 }
820
821 if !report.warnings.is_empty() {
822 println!("Warnings:");
823 for warning in &report.warnings {
824 println!(" - {}", warning);
825 }
826 }
827
828 if let Some(report) = validation {
829 print_validation(report);
830 }
831}
832
833fn load_static_routes(load: &PackLoad) -> Vec<StaticRouteV1> {
834 load.gpack_manifest
835 .as_ref()
836 .and_then(|manifest| {
837 parse_static_routes_extension(&manifest.extensions)
838 .ok()
839 .flatten()
840 })
841 .map(|payload| payload.routes)
842 .unwrap_or_default()
843}
844
845#[derive(Clone, Debug, Serialize)]
846struct ValidationOutput {
847 #[serde(flatten)]
848 report: ValidationReport,
849 has_errors: bool,
850 sources: Vec<crate::validator::ValidatorSourceReport>,
851}
852
853fn has_error_diagnostics(diagnostics: &[Diagnostic]) -> bool {
854 diagnostics
855 .iter()
856 .any(|diag| matches!(diag.severity, Severity::Error))
857}
858
859async fn run_pack_validation(
860 load: &PackLoad,
861 source_pack_dir: Option<&Path>,
862 args: &InspectArgs,
863 runtime: &RuntimeContext,
864) -> Result<ValidationOutput> {
865 let ctx = ValidateCtx::from_pack_load(load);
866 let validators: Vec<Box<dyn greentic_types::validate::PackValidator>> = vec![
867 Box::new(ReferencedFilesExistValidator::new(ctx.clone())),
868 Box::new(SbomConsistencyValidator::new(ctx.clone())),
869 Box::new(ProviderReferencesExistValidator::new(ctx.clone())),
870 Box::new(SecretRequirementsValidator),
871 Box::new(StaticRoutesValidator::new(ctx.clone())),
872 Box::new(ComponentReferencesExistValidator),
873 Box::new(OauthCapabilityRequirementsValidator),
874 ];
875
876 let mut report = if let Some(manifest) = load.gpack_manifest.as_ref() {
877 run_validators(manifest, &ctx, &validators)
878 } else {
879 ValidationReport {
880 pack_id: None,
881 pack_version: None,
882 diagnostics: vec![Diagnostic {
883 severity: Severity::Warn,
884 code: "PACK_MANIFEST_UNSUPPORTED".to_string(),
885 message: "Pack manifest is not in the greentic-types format; skipping validation."
886 .to_string(),
887 path: Some("manifest.cbor".to_string()),
888 hint: Some(
889 "Rebuild the pack with greentic-pack build to enable validation.".to_string(),
890 ),
891 data: Value::Null,
892 }],
893 }
894 };
895
896 let config = ValidatorConfig {
897 validators_root: args.validators_root.clone(),
898 validator_packs: args.validator_pack.clone(),
899 validator_allow: args.validator_allow.clone(),
900 validator_cache_dir: args.validator_cache_dir.clone(),
901 policy: args.validator_policy,
902 local_validators: parse_validator_wasm_args(&args.validator_wasm)?,
903 };
904
905 let wasm_result = run_wasm_validators(load, &config, runtime).await?;
906 report.diagnostics.extend(wasm_result.diagnostics);
907 if let Some(pack_dir) = source_pack_dir {
908 report
909 .diagnostics
910 .extend(collect_extension_dependency_diagnostics(pack_dir));
911 }
912
913 let has_errors = has_error_diagnostics(&report.diagnostics) || wasm_result.missing_required;
914
915 Ok(ValidationOutput {
916 report,
917 has_errors,
918 sources: wasm_result.sources,
919 })
920}
921
922fn collect_extension_dependency_diagnostics(pack_dir: &Path) -> Vec<Diagnostic> {
923 let source_path = default_extensions_file_path(pack_dir);
924 let lock_path = default_extensions_lock_file_path(pack_dir);
925 let mut diagnostics = Vec::new();
926
927 let source = if source_path.exists() {
928 match read_extensions_file(&source_path) {
929 Ok(file) => Some(file),
930 Err(err) => {
931 diagnostics.push(Diagnostic {
932 severity: Severity::Error,
933 code: "PACK_EXTENSION_DEPENDENCY_SOURCE_INVALID".to_string(),
934 message: err.to_string(),
935 path: Some(path_display(pack_dir, &source_path)),
936 hint: Some("fix pack.extensions.json and rerun doctor".to_string()),
937 data: Value::Null,
938 });
939 None
940 }
941 }
942 } else {
943 None
944 };
945
946 let lock = if lock_path.exists() {
947 match read_extensions_lock_file(&lock_path) {
948 Ok(file) => Some(file),
949 Err(err) => {
950 diagnostics.push(Diagnostic {
951 severity: Severity::Error,
952 code: "PACK_EXTENSION_DEPENDENCY_LOCK_INVALID".to_string(),
953 message: err.to_string(),
954 path: Some(path_display(pack_dir, &lock_path)),
955 hint: Some("rerun `greentic-pack extensions-lock --in <DIR>`".to_string()),
956 data: Value::Null,
957 });
958 None
959 }
960 }
961 } else {
962 None
963 };
964
965 match (source.as_ref(), lock.as_ref()) {
966 (Some(_), None) => diagnostics.push(Diagnostic {
967 severity: Severity::Warn,
968 code: "PACK_EXTENSION_DEPENDENCY_LOCK_MISSING".to_string(),
969 message: "pack.extensions.json exists but pack.extensions.lock.json is missing"
970 .to_string(),
971 path: Some(path_display(pack_dir, &source_path)),
972 hint: Some("run `greentic-pack extensions-lock --in <DIR>`".to_string()),
973 data: Value::Null,
974 }),
975 (None, Some(_)) => diagnostics.push(Diagnostic {
976 severity: Severity::Warn,
977 code: "PACK_EXTENSION_DEPENDENCY_SOURCE_MISSING".to_string(),
978 message: "pack.extensions.lock.json exists but pack.extensions.json is missing"
979 .to_string(),
980 path: Some(path_display(pack_dir, &lock_path)),
981 hint: Some(
982 "restore pack.extensions.json or regenerate the lock from the intended source file"
983 .to_string(),
984 ),
985 data: Value::Null,
986 }),
987 (Some(source), Some(lock)) => {
988 if let Err(err) = validate_extensions_lock_alignment(source, lock) {
989 diagnostics.push(Diagnostic {
990 severity: Severity::Error,
991 code: "PACK_EXTENSION_DEPENDENCY_LOCK_STALE".to_string(),
992 message: err.to_string(),
993 path: Some(path_display(pack_dir, &lock_path)),
994 hint: Some("rerun `greentic-pack extensions-lock --in <DIR>` after editing pack.extensions.json".to_string()),
995 data: Value::Null,
996 });
997 }
998 }
999 (None, None) => {}
1000 }
1001
1002 if let Some(source) = source.as_ref() {
1003 for extension in &source.extensions {
1004 if extension.id == DEPLOYER_EXTENSION_KEY && extension.role != "deployer" {
1005 diagnostics.push(Diagnostic {
1006 severity: Severity::Error,
1007 code: "PACK_DEPLOYER_EXTENSION_ROLE_INVALID".to_string(),
1008 message: format!(
1009 "extension `{}` must use role `deployer`, found `{}`",
1010 extension.id, extension.role
1011 ),
1012 path: Some(path_display(pack_dir, &source_path)),
1013 hint: Some("set the dependency role to `deployer`".to_string()),
1014 data: Value::Null,
1015 });
1016 }
1017 }
1018 }
1019
1020 if let Some(lock) = lock.as_ref() {
1021 for extension in &lock.extensions {
1022 if extension.media_type.is_none() {
1023 diagnostics.push(Diagnostic {
1024 severity: Severity::Warn,
1025 code: "PACK_EXTENSION_DEPENDENCY_LOCK_MISSING_MEDIA_TYPE".to_string(),
1026 message: format!(
1027 "extension `{}` lock entry is missing media_type metadata",
1028 extension.id
1029 ),
1030 path: Some(path_display(pack_dir, &lock_path)),
1031 hint: Some("rerun `greentic-pack extensions-lock --in <DIR>` with a resolver that reports content type".to_string()),
1032 data: Value::Null,
1033 });
1034 }
1035 if extension.size_bytes.is_none() {
1036 diagnostics.push(Diagnostic {
1037 severity: Severity::Warn,
1038 code: "PACK_EXTENSION_DEPENDENCY_LOCK_MISSING_SIZE".to_string(),
1039 message: format!(
1040 "extension `{}` lock entry is missing size metadata",
1041 extension.id
1042 ),
1043 path: Some(path_display(pack_dir, &lock_path)),
1044 hint: Some("rerun `greentic-pack extensions-lock --in <DIR>` with a resolver that reports content length".to_string()),
1045 data: Value::Null,
1046 });
1047 }
1048 }
1049 }
1050
1051 diagnostics
1052}
1053
1054fn path_display(root: &Path, path: &Path) -> String {
1055 path.strip_prefix(root)
1056 .unwrap_or(path)
1057 .display()
1058 .to_string()
1059}
1060
1061fn print_validation(report: &ValidationOutput) {
1062 let (info, warn, error) = validation_counts(&report.report);
1063 println!("Validation:");
1064 println!(" Info: {info} Warn: {warn} Error: {error}");
1065 if report.report.diagnostics.is_empty() {
1066 println!(" - none");
1067 return;
1068 }
1069 for diag in &report.report.diagnostics {
1070 let sev = match diag.severity {
1071 Severity::Info => "INFO",
1072 Severity::Warn => "WARN",
1073 Severity::Error => "ERROR",
1074 };
1075 if let Some(path) = diag.path.as_deref() {
1076 println!(" - [{sev}] {} {} - {}", diag.code, path, diag.message);
1077 } else {
1078 println!(" - [{sev}] {} - {}", diag.code, diag.message);
1079 }
1080 if matches!(
1081 diag.code.as_str(),
1082 "PACK_FLOW_DOCTOR_FAILED" | "PACK_COMPONENT_DOCTOR_FAILED"
1083 ) {
1084 print_doctor_failure_details(&diag.data);
1085 }
1086 if let Some(hint) = diag.hint.as_deref() {
1087 println!(" hint: {hint}");
1088 }
1089 }
1090}
1091
1092fn parse_validator_wasm_args(args: &[String]) -> Result<Vec<LocalValidator>> {
1093 let mut local_validators = Vec::new();
1094 for entry in args {
1095 let mut segments = entry.splitn(2, '=');
1096 let component_id = segments.next().unwrap_or_default().trim().to_string();
1097 let path = segments
1098 .next()
1099 .map(|p| p.trim())
1100 .filter(|p| !p.is_empty())
1101 .ok_or_else(|| {
1102 anyhow!(
1103 "invalid --validator-wasm argument `{}` (expected format COMPONENT_ID=FILE)",
1104 entry
1105 )
1106 })?;
1107 if component_id.is_empty() {
1108 return Err(anyhow!(
1109 "validator component id must not be empty in `{}`",
1110 entry
1111 ));
1112 }
1113 local_validators.push(LocalValidator {
1114 component_id,
1115 path: PathBuf::from(path),
1116 });
1117 }
1118 Ok(local_validators)
1119}
1120
1121fn print_doctor_failure_details(data: &Value) {
1122 let Some(obj) = data.as_object() else {
1123 return;
1124 };
1125 let stdout = obj.get("stdout").and_then(|value| value.as_str());
1126 let stderr = obj.get("stderr").and_then(|value| value.as_str());
1127 let status = obj.get("status").and_then(|value| value.as_i64());
1128 if let Some(status) = status {
1129 println!(" status: {status}");
1130 }
1131 if let Some(stderr) = stderr {
1132 let trimmed = stderr.trim();
1133 if !trimmed.is_empty() {
1134 println!(" stderr: {trimmed}");
1135 }
1136 }
1137 if let Some(stdout) = stdout {
1138 let trimmed = stdout.trim();
1139 if !trimmed.is_empty() {
1140 println!(" stdout: {trimmed}");
1141 }
1142 }
1143}
1144
1145fn validation_counts(report: &ValidationReport) -> (usize, usize, usize) {
1146 let mut info = 0;
1147 let mut warn = 0;
1148 let mut error = 0;
1149 for diag in &report.diagnostics {
1150 match diag.severity {
1151 Severity::Info => info += 1,
1152 Severity::Warn => warn += 1,
1153 Severity::Error => error += 1,
1154 }
1155 }
1156 (info, warn, error)
1157}
1158
1159#[derive(Debug, Clone, Copy, clap::ValueEnum)]
1160pub enum InspectFormat {
1161 Human,
1162 Json,
1163}
1164
1165fn resolve_format(args: &InspectArgs, json: bool) -> InspectFormat {
1166 if json {
1167 InspectFormat::Json
1168 } else {
1169 args.format
1170 }
1171}
1172
1173fn providers_from_manifest(manifest: &PackManifest) -> Vec<ProviderDecl> {
1174 let mut providers = manifest
1175 .provider_extension_inline()
1176 .map(|inline| inline.providers.clone())
1177 .unwrap_or_default();
1178 providers.sort_by(|a, b| a.provider_type.cmp(&b.provider_type));
1179 providers
1180}
1181
1182fn provider_kind(provider: &ProviderDecl) -> String {
1183 provider
1184 .runtime
1185 .world
1186 .split('@')
1187 .next()
1188 .unwrap_or_default()
1189 .to_string()
1190}
1191
1192fn summarize_provider(provider: &ProviderDecl) -> String {
1193 let caps = provider.capabilities.len();
1194 let ops = provider.ops.len();
1195 let mut parts = vec![format!("caps:{caps}"), format!("ops:{ops}")];
1196 parts.push(format!("config:{}", provider.config_schema_ref));
1197 if let Some(docs) = provider.docs_ref.as_deref() {
1198 parts.push(format!("docs:{docs}"));
1199 }
1200 parts.join(" ")
1201}
1202
1203fn format_component_source(source: &ComponentSourceRef) -> String {
1204 match source {
1205 ComponentSourceRef::Oci(value) => format_source_ref("oci", value),
1206 ComponentSourceRef::Repo(value) => format_source_ref("repo", value),
1207 ComponentSourceRef::Store(value) => format_source_ref("store", value),
1208 ComponentSourceRef::File(value) => format_source_ref("file", value),
1209 }
1210}
1211
1212fn format_source_ref(scheme: &str, value: &str) -> String {
1213 if value.contains("://") {
1214 value.to_string()
1215 } else {
1216 format!("{scheme}://{value}")
1217 }
1218}
1219
1220fn format_component_artifact(artifact: &ArtifactLocationV1) -> String {
1221 match artifact {
1222 ArtifactLocationV1::Inline { wasm_path, .. } => format!("inline ({})", wasm_path),
1223 ArtifactLocationV1::Remote => "remote".to_string(),
1224 }
1225}
1226
1227#[cfg(test)]
1228mod tests {
1229 use super::*;
1230 use std::collections::HashMap;
1231 use std::os::unix::process::ExitStatusExt;
1232 use std::path::PathBuf;
1233
1234 fn sample_args() -> InspectArgs {
1235 InspectArgs {
1236 path: None,
1237 pack: None,
1238 input: None,
1239 archive: false,
1240 source: false,
1241 allow_oci_tags: false,
1242 flow_doctor: true,
1243 component_doctor: true,
1244 format: InspectFormat::Human,
1245 validate: true,
1246 no_validate: false,
1247 validators_root: PathBuf::from(".greentic/validators"),
1248 validator_pack: Vec::new(),
1249 validator_wasm: Vec::new(),
1250 validator_allow: vec![DEFAULT_VALIDATOR_ALLOW.to_string()],
1251 validator_cache_dir: PathBuf::from(".greentic/cache/validators"),
1252 validator_policy: ValidatorPolicy::Optional,
1253 online: false,
1254 use_describe_cache: false,
1255 }
1256 }
1257
1258 #[test]
1259 fn sort_json_orders_object_keys_recursively() {
1260 let value = serde_json::json!({
1261 "z": 1,
1262 "a": { "b": 2, "a": 1 },
1263 "list": [{ "d": 4, "c": 3 }]
1264 });
1265
1266 let sorted = to_sorted_json(value).expect("json serialization should succeed");
1267 let root_a = sorted.find("\"a\"").expect("root a key");
1268 let root_z = sorted.find("\"z\"").expect("root z key");
1269 let nested_a = sorted.find("\"a\": 1").expect("nested a key");
1270 let nested_b = sorted.find("\"b\": 2").expect("nested b key");
1271
1272 assert!(root_a < root_z, "root keys should be sorted: {sorted}");
1273 assert!(
1274 nested_a < nested_b,
1275 "nested keys should be sorted: {sorted}"
1276 );
1277 }
1278
1279 #[test]
1280 fn flow_doctor_unsupported_detects_common_cli_errors() {
1281 let output = std::process::Output {
1282 status: std::process::ExitStatus::from_raw(256),
1283 stdout: Vec::new(),
1284 stderr: b"error: unexpected argument '--stdin' found".to_vec(),
1285 };
1286
1287 assert!(flow_doctor_unsupported(&output));
1288 }
1289
1290 #[test]
1291 fn sanitize_component_id_replaces_path_like_characters() {
1292 assert_eq!(
1293 sanitize_component_id("demo/component:beta@1"),
1294 "demo_component_beta_1"
1295 );
1296 }
1297
1298 #[test]
1299 fn forbidden_source_paths_match_dev_only_inputs() {
1300 assert!(is_forbidden_source_path("pack.yaml"));
1301 assert!(is_forbidden_source_path("flows/main.json"));
1302 assert!(is_forbidden_source_path("flows/main.ygtc"));
1303 assert!(is_forbidden_source_path("components/demo.manifest.json"));
1304 assert!(!is_forbidden_source_path("gui/assets/index.html"));
1305 }
1306
1307 #[test]
1308 fn find_forbidden_source_paths_returns_only_matching_entries() {
1309 let files = HashMap::from([
1310 ("pack.yaml".to_string(), Vec::new()),
1311 ("flows/main.ygtc".to_string(), Vec::new()),
1312 ("gui/assets/index.html".to_string(), Vec::new()),
1313 ]);
1314
1315 let forbidden = find_forbidden_source_paths(&files);
1316 assert_eq!(forbidden.len(), 2);
1317 assert!(forbidden.contains(&"pack.yaml".to_string()));
1318 assert!(forbidden.contains(&"flows/main.ygtc".to_string()));
1319 }
1320
1321 #[test]
1322 fn resolve_mode_prefers_pack_and_input_flags() {
1323 let pack_args = InspectArgs {
1324 pack: Some(PathBuf::from("demo.gtpack")),
1325 ..sample_args()
1326 };
1327 let source_args = InspectArgs {
1328 input: Some(PathBuf::from("demo")),
1329 ..sample_args()
1330 };
1331
1332 assert!(matches!(
1333 resolve_mode(&pack_args).expect("pack mode"),
1334 InspectMode::Archive(path) if path.as_path() == std::path::Path::new("demo.gtpack")
1335 ));
1336 assert!(matches!(
1337 resolve_mode(&source_args).expect("source mode"),
1338 InspectMode::Source(path) if path.as_path() == std::path::Path::new("demo")
1339 ));
1340 }
1341
1342 #[test]
1343 fn resolve_mode_auto_detects_dir_and_gtpack_file() {
1344 let temp = tempfile::tempdir().expect("tempdir");
1345 let dir = temp.path().join("pack");
1346 let file = temp.path().join("pack.gtpack");
1347 std::fs::create_dir_all(&dir).expect("dir");
1348 std::fs::write(&file, b"stub").expect("file");
1349
1350 let dir_args = InspectArgs {
1351 path: Some(dir.clone()),
1352 ..sample_args()
1353 };
1354 let file_args = InspectArgs {
1355 path: Some(file.clone()),
1356 ..sample_args()
1357 };
1358
1359 assert!(matches!(
1360 resolve_mode(&dir_args).expect("dir mode"),
1361 InspectMode::Source(path) if path == dir
1362 ));
1363 assert!(matches!(
1364 resolve_mode(&file_args).expect("file mode"),
1365 InspectMode::Archive(path) if path == file
1366 ));
1367 }
1368
1369 #[test]
1370 fn parse_validator_wasm_args_rejects_missing_paths() {
1371 let err = parse_validator_wasm_args(&["demo.component=".to_string()])
1372 .expect_err("missing validator path should fail");
1373 assert!(
1374 err.to_string()
1375 .contains("expected format COMPONENT_ID=FILE")
1376 );
1377 }
1378
1379 #[test]
1380 fn parse_validator_wasm_args_parses_component_pairs() {
1381 let validators = parse_validator_wasm_args(&[
1382 "demo.component=validators/demo.wasm".to_string(),
1383 "other.component = validators/other.wasm".to_string(),
1384 ])
1385 .expect("validator args should parse");
1386
1387 assert_eq!(validators.len(), 2);
1388 assert_eq!(validators[0].component_id, "demo.component");
1389 assert_eq!(validators[1].path, PathBuf::from("validators/other.wasm"));
1390 }
1391
1392 #[test]
1393 fn format_helpers_preserve_existing_schemes_and_inline_paths() {
1394 assert_eq!(format_source_ref("oci", "oci://example"), "oci://example");
1395 assert_eq!(
1396 format_source_ref("file", "components/demo.wasm"),
1397 "file://components/demo.wasm"
1398 );
1399 assert_eq!(
1400 format_component_artifact(&ArtifactLocationV1::Inline {
1401 wasm_path: "components/demo.wasm".to_string(),
1402 manifest_path: None,
1403 }),
1404 "inline (components/demo.wasm)"
1405 );
1406 assert_eq!(
1407 format_component_artifact(&ArtifactLocationV1::Remote),
1408 "remote"
1409 );
1410 }
1411}