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