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.starts_with("components/")
585 && (path.ends_with("/component.manifest.json") || path.ends_with(".manifest.json"))
586 {
587 return true;
588 }
589 false
590}
591
592enum InspectMode {
593 Archive(PathBuf),
594 Source(PathBuf),
595}
596
597fn resolve_mode(args: &InspectArgs) -> Result<InspectMode> {
598 if args.archive && args.source {
599 bail!("--archive and --source are mutually exclusive");
600 }
601 if args.pack.is_some() && args.input.is_some() {
602 bail!("exactly one of --pack or --in may be supplied");
603 }
604
605 if let Some(path) = &args.pack {
606 return Ok(InspectMode::Archive(path.clone()));
607 }
608 if let Some(path) = &args.input {
609 return Ok(InspectMode::Source(path.clone()));
610 }
611 if let Some(path) = &args.path {
612 let meta =
613 fs::metadata(path).with_context(|| format!("failed to stat {}", path.display()))?;
614 if args.archive || (path.extension() == Some(std::ffi::OsStr::new("gtpack"))) {
615 return Ok(InspectMode::Archive(path.clone()));
616 }
617 if args.source || meta.is_dir() {
618 return Ok(InspectMode::Source(path.clone()));
619 }
620 if meta.is_file() {
621 return Ok(InspectMode::Archive(path.clone()));
622 }
623 }
624 Ok(InspectMode::Source(
625 std::env::current_dir().context("determine current directory")?,
626 ))
627}
628
629fn source_mode_pack_dir(mode: &InspectMode) -> Option<&Path> {
630 match mode {
631 InspectMode::Source(path) => Some(path.as_path()),
632 InspectMode::Archive(_) => None,
633 }
634}
635
636async fn inspect_source_dir(
637 dir: &Path,
638 runtime: &RuntimeContext,
639 allow_oci_tags: bool,
640) -> Result<PackLoad> {
641 let pack_dir = dir
642 .canonicalize()
643 .with_context(|| format!("failed to resolve pack dir {}", dir.display()))?;
644
645 let temp = TempDir::new().context("failed to allocate temp dir for inspect")?;
646 let manifest_out = temp.path().join("manifest.cbor");
647 let gtpack_out = temp.path().join("pack.gtpack");
648
649 let opts = build::BuildOptions {
650 pack_dir,
651 component_out: None,
652 manifest_out,
653 sbom_out: None,
654 gtpack_out: Some(gtpack_out.clone()),
655 lock_path: gtpack_out.with_extension("lock.json"), bundle: build::BundleMode::Cache,
657 dry_run: false,
658 secrets_req: None,
659 default_secret_scope: None,
660 allow_oci_tags,
661 require_component_manifests: false,
662 no_extra_dirs: false,
663 dev: true,
664 runtime: runtime.clone(),
665 skip_update: false,
666 allow_pack_schema: false,
667 validate_extension_refs: false,
668 };
669
670 build::run(&opts).await?;
671
672 inspect_pack_file(>pack_out)
673}
674
675fn print_human(load: &PackLoad, validation: Option<&ValidationOutput>) {
676 let manifest = &load.manifest;
677 let report = &load.report;
678 println!(
679 "Pack: {} ({})",
680 manifest.meta.pack_id, manifest.meta.version
681 );
682 println!("Name: {}", manifest.meta.name);
683 println!("Flows: {}", manifest.flows.len());
684 if manifest.flows.is_empty() {
685 println!("Flows list: none");
686 } else {
687 println!("Flows list:");
688 for flow in &manifest.flows {
689 println!(
690 " - {} (entry: {}, kind: {})",
691 flow.id, flow.entry, flow.kind
692 );
693 }
694 }
695 println!("Components: {}", manifest.components.len());
696 if manifest.components.is_empty() {
697 println!("Components list: none");
698 } else {
699 println!("Components list:");
700 for component in &manifest.components {
701 println!(" - {} ({})", component.name, component.version);
702 }
703 }
704 if let Some(gmanifest) = load.gpack_manifest.as_ref()
705 && let Some(value) = gmanifest
706 .extensions
707 .as_ref()
708 .and_then(|m| m.get(EXT_COMPONENT_SOURCES_V1))
709 .and_then(|ext| ext.inline.as_ref())
710 .and_then(|inline| match inline {
711 greentic_types::ExtensionInline::Other(v) => Some(v),
712 _ => None,
713 })
714 && let Ok(cs) = ComponentSourcesV1::from_extension_value(value)
715 {
716 let mut inline = 0usize;
717 let mut remote = 0usize;
718 let mut oci = 0usize;
719 let mut repo = 0usize;
720 let mut store = 0usize;
721 let mut file = 0usize;
722 for entry in &cs.components {
723 match entry.artifact {
724 ArtifactLocationV1::Inline { .. } => inline += 1,
725 ArtifactLocationV1::Remote => remote += 1,
726 }
727 match entry.source {
728 ComponentSourceRef::Oci(_) => oci += 1,
729 ComponentSourceRef::Repo(_) => repo += 1,
730 ComponentSourceRef::Store(_) => store += 1,
731 ComponentSourceRef::File(_) => file += 1,
732 }
733 }
734 println!(
735 "Component sources: {} total (origins: oci {}, repo {}, store {}, file {}; artifacts: inline {}, remote {})",
736 cs.components.len(),
737 oci,
738 repo,
739 store,
740 file,
741 inline,
742 remote
743 );
744 if cs.components.is_empty() {
745 println!("Component source entries: none");
746 } else {
747 println!("Component source entries:");
748 for entry in &cs.components {
749 println!(
750 " - {} source={} artifact={}",
751 entry.name,
752 format_component_source(&entry.source),
753 format_component_artifact(&entry.artifact)
754 );
755 }
756 }
757 } else {
758 println!("Component sources: none");
759 }
760
761 if let Some(gmanifest) = load.gpack_manifest.as_ref() {
762 let providers = providers_from_manifest(gmanifest);
763 if providers.is_empty() {
764 println!("Providers: none");
765 } else {
766 println!("Providers:");
767 for provider in providers {
768 println!(
769 " - {} ({}) {}",
770 provider.provider_type,
771 provider_kind(&provider),
772 summarize_provider(&provider)
773 );
774 }
775 }
776 } else {
777 println!("Providers: none");
778 }
779
780 let static_routes = load_static_routes(load);
781 if static_routes.is_empty() {
782 println!("Static routes: none");
783 } else {
784 println!("Static routes:");
785 for route in &static_routes {
786 println!(
787 " - {} -> {} [{}]",
788 route.id, route.public_path, route.source_root
789 );
790 println!(
791 " scope: tenant={} team={}",
792 route.scope.tenant, route.scope.team
793 );
794 println!(
795 " index_file: {}",
796 route.index_file.as_deref().unwrap_or("none")
797 );
798 println!(
799 " spa_fallback: {}",
800 route.spa_fallback.as_deref().unwrap_or("none")
801 );
802 println!(
803 " cache: {}",
804 route
805 .cache
806 .as_ref()
807 .map(|cache| match cache.max_age_seconds {
808 Some(max_age) => format!("{} ({max_age}s)", cache.strategy),
809 None => cache.strategy.clone(),
810 })
811 .unwrap_or_else(|| "none".to_string())
812 );
813 if route.exports.is_empty() {
814 println!(" exports: none");
815 } else {
816 let exports = route
817 .exports
818 .iter()
819 .map(|(key, value)| format!("{key}={value}"))
820 .collect::<Vec<_>>()
821 .join(", ");
822 println!(" exports: {exports}");
823 }
824 }
825 }
826
827 if !report.warnings.is_empty() {
828 println!("Warnings:");
829 for warning in &report.warnings {
830 println!(" - {}", warning);
831 }
832 }
833
834 if let Some(report) = validation {
835 print_validation(report);
836 }
837}
838
839fn load_static_routes(load: &PackLoad) -> Vec<StaticRouteV1> {
840 load.gpack_manifest
841 .as_ref()
842 .and_then(|manifest| {
843 parse_static_routes_extension(&manifest.extensions)
844 .ok()
845 .flatten()
846 })
847 .map(|payload| payload.routes)
848 .unwrap_or_default()
849}
850
851#[derive(Clone, Debug, Serialize)]
852struct ValidationOutput {
853 #[serde(flatten)]
854 report: ValidationReport,
855 has_errors: bool,
856 sources: Vec<crate::validator::ValidatorSourceReport>,
857}
858
859fn has_error_diagnostics(diagnostics: &[Diagnostic]) -> bool {
860 diagnostics
861 .iter()
862 .any(|diag| matches!(diag.severity, Severity::Error))
863}
864
865async fn run_pack_validation(
866 load: &PackLoad,
867 source_pack_dir: Option<&Path>,
868 args: &InspectArgs,
869 runtime: &RuntimeContext,
870) -> Result<ValidationOutput> {
871 let ctx = ValidateCtx::from_pack_load(load);
872 let validators: Vec<Box<dyn greentic_types::validate::PackValidator>> = vec![
873 Box::new(ReferencedFilesExistValidator::new(ctx.clone())),
874 Box::new(SbomConsistencyValidator::new(ctx.clone())),
875 Box::new(ProviderReferencesExistValidator::new(ctx.clone())),
876 Box::new(SecretRequirementsValidator),
877 Box::new(StaticRoutesValidator::new(ctx.clone())),
878 Box::new(ComponentReferencesExistValidator),
879 Box::new(OauthCapabilityRequirementsValidator),
880 ];
881
882 let mut report = if let Some(manifest) = load.gpack_manifest.as_ref() {
883 run_validators(manifest, &ctx, &validators)
884 } else {
885 ValidationReport {
886 pack_id: None,
887 pack_version: None,
888 diagnostics: vec![Diagnostic {
889 severity: Severity::Warn,
890 code: "PACK_MANIFEST_UNSUPPORTED".to_string(),
891 message: "Pack manifest is not in the greentic-types format; skipping validation."
892 .to_string(),
893 path: Some("manifest.cbor".to_string()),
894 hint: Some(
895 "Rebuild the pack with greentic-pack build to enable validation.".to_string(),
896 ),
897 data: Value::Null,
898 }],
899 }
900 };
901
902 let config = ValidatorConfig {
903 validators_root: args.validators_root.clone(),
904 validator_packs: args.validator_pack.clone(),
905 validator_allow: args.validator_allow.clone(),
906 validator_cache_dir: args.validator_cache_dir.clone(),
907 policy: args.validator_policy,
908 local_validators: parse_validator_wasm_args(&args.validator_wasm)?,
909 };
910
911 let wasm_result = run_wasm_validators(load, &config, runtime).await?;
912 report.diagnostics.extend(wasm_result.diagnostics);
913 if let Some(pack_dir) = source_pack_dir {
914 report
915 .diagnostics
916 .extend(collect_extension_dependency_diagnostics(pack_dir));
917 }
918
919 let has_errors = has_error_diagnostics(&report.diagnostics) || wasm_result.missing_required;
920
921 Ok(ValidationOutput {
922 report,
923 has_errors,
924 sources: wasm_result.sources,
925 })
926}
927
928fn collect_extension_dependency_diagnostics(pack_dir: &Path) -> Vec<Diagnostic> {
929 let source_path = default_extensions_file_path(pack_dir);
930 let lock_path = default_extensions_lock_file_path(pack_dir);
931 let mut diagnostics = Vec::new();
932
933 let source = if source_path.exists() {
934 match read_extensions_file(&source_path) {
935 Ok(file) => Some(file),
936 Err(err) => {
937 diagnostics.push(Diagnostic {
938 severity: Severity::Error,
939 code: "PACK_EXTENSION_DEPENDENCY_SOURCE_INVALID".to_string(),
940 message: err.to_string(),
941 path: Some(path_display(pack_dir, &source_path)),
942 hint: Some("fix pack.extensions.json and rerun doctor".to_string()),
943 data: Value::Null,
944 });
945 None
946 }
947 }
948 } else {
949 None
950 };
951
952 let lock = if lock_path.exists() {
953 match read_extensions_lock_file(&lock_path) {
954 Ok(file) => Some(file),
955 Err(err) => {
956 diagnostics.push(Diagnostic {
957 severity: Severity::Error,
958 code: "PACK_EXTENSION_DEPENDENCY_LOCK_INVALID".to_string(),
959 message: err.to_string(),
960 path: Some(path_display(pack_dir, &lock_path)),
961 hint: Some("rerun `greentic-pack extensions-lock --in <DIR>`".to_string()),
962 data: Value::Null,
963 });
964 None
965 }
966 }
967 } else {
968 None
969 };
970
971 match (source.as_ref(), lock.as_ref()) {
972 (Some(_), None) => diagnostics.push(Diagnostic {
973 severity: Severity::Warn,
974 code: "PACK_EXTENSION_DEPENDENCY_LOCK_MISSING".to_string(),
975 message: "pack.extensions.json exists but pack.extensions.lock.json is missing"
976 .to_string(),
977 path: Some(path_display(pack_dir, &source_path)),
978 hint: Some("run `greentic-pack extensions-lock --in <DIR>`".to_string()),
979 data: Value::Null,
980 }),
981 (None, Some(_)) => diagnostics.push(Diagnostic {
982 severity: Severity::Warn,
983 code: "PACK_EXTENSION_DEPENDENCY_SOURCE_MISSING".to_string(),
984 message: "pack.extensions.lock.json exists but pack.extensions.json is missing"
985 .to_string(),
986 path: Some(path_display(pack_dir, &lock_path)),
987 hint: Some(
988 "restore pack.extensions.json or regenerate the lock from the intended source file"
989 .to_string(),
990 ),
991 data: Value::Null,
992 }),
993 (Some(source), Some(lock)) => {
994 if let Err(err) = validate_extensions_lock_alignment(source, lock) {
995 diagnostics.push(Diagnostic {
996 severity: Severity::Error,
997 code: "PACK_EXTENSION_DEPENDENCY_LOCK_STALE".to_string(),
998 message: err.to_string(),
999 path: Some(path_display(pack_dir, &lock_path)),
1000 hint: Some("rerun `greentic-pack extensions-lock --in <DIR>` after editing pack.extensions.json".to_string()),
1001 data: Value::Null,
1002 });
1003 }
1004 }
1005 (None, None) => {}
1006 }
1007
1008 if let Some(source) = source.as_ref() {
1009 for extension in &source.extensions {
1010 if extension.id == DEPLOYER_EXTENSION_KEY && extension.role != "deployer" {
1011 diagnostics.push(Diagnostic {
1012 severity: Severity::Error,
1013 code: "PACK_DEPLOYER_EXTENSION_ROLE_INVALID".to_string(),
1014 message: format!(
1015 "extension `{}` must use role `deployer`, found `{}`",
1016 extension.id, extension.role
1017 ),
1018 path: Some(path_display(pack_dir, &source_path)),
1019 hint: Some("set the dependency role to `deployer`".to_string()),
1020 data: Value::Null,
1021 });
1022 }
1023 }
1024 }
1025
1026 if let Some(lock) = lock.as_ref() {
1027 for extension in &lock.extensions {
1028 if extension.media_type.is_none() {
1029 diagnostics.push(Diagnostic {
1030 severity: Severity::Warn,
1031 code: "PACK_EXTENSION_DEPENDENCY_LOCK_MISSING_MEDIA_TYPE".to_string(),
1032 message: format!(
1033 "extension `{}` lock entry is missing media_type metadata",
1034 extension.id
1035 ),
1036 path: Some(path_display(pack_dir, &lock_path)),
1037 hint: Some("rerun `greentic-pack extensions-lock --in <DIR>` with a resolver that reports content type".to_string()),
1038 data: Value::Null,
1039 });
1040 }
1041 if extension.size_bytes.is_none() {
1042 diagnostics.push(Diagnostic {
1043 severity: Severity::Warn,
1044 code: "PACK_EXTENSION_DEPENDENCY_LOCK_MISSING_SIZE".to_string(),
1045 message: format!(
1046 "extension `{}` lock entry is missing size metadata",
1047 extension.id
1048 ),
1049 path: Some(path_display(pack_dir, &lock_path)),
1050 hint: Some("rerun `greentic-pack extensions-lock --in <DIR>` with a resolver that reports content length".to_string()),
1051 data: Value::Null,
1052 });
1053 }
1054 }
1055 }
1056
1057 diagnostics
1058}
1059
1060fn path_display(root: &Path, path: &Path) -> String {
1061 path.strip_prefix(root)
1062 .unwrap_or(path)
1063 .display()
1064 .to_string()
1065}
1066
1067fn print_validation(report: &ValidationOutput) {
1068 let (info, warn, error) = validation_counts(&report.report);
1069 println!("Validation:");
1070 println!(" Info: {info} Warn: {warn} Error: {error}");
1071 if report.report.diagnostics.is_empty() {
1072 println!(" - none");
1073 return;
1074 }
1075 for diag in &report.report.diagnostics {
1076 let sev = match diag.severity {
1077 Severity::Info => "INFO",
1078 Severity::Warn => "WARN",
1079 Severity::Error => "ERROR",
1080 };
1081 if let Some(path) = diag.path.as_deref() {
1082 println!(" - [{sev}] {} {} - {}", diag.code, path, diag.message);
1083 } else {
1084 println!(" - [{sev}] {} - {}", diag.code, diag.message);
1085 }
1086 if matches!(
1087 diag.code.as_str(),
1088 "PACK_FLOW_DOCTOR_FAILED" | "PACK_COMPONENT_DOCTOR_FAILED"
1089 ) {
1090 print_doctor_failure_details(&diag.data);
1091 }
1092 if let Some(hint) = diag.hint.as_deref() {
1093 println!(" hint: {hint}");
1094 }
1095 }
1096}
1097
1098fn parse_validator_wasm_args(args: &[String]) -> Result<Vec<LocalValidator>> {
1099 let mut local_validators = Vec::new();
1100 for entry in args {
1101 let mut segments = entry.splitn(2, '=');
1102 let component_id = segments.next().unwrap_or_default().trim().to_string();
1103 let path = segments
1104 .next()
1105 .map(|p| p.trim())
1106 .filter(|p| !p.is_empty())
1107 .ok_or_else(|| {
1108 anyhow!(
1109 "invalid --validator-wasm argument `{}` (expected format COMPONENT_ID=FILE)",
1110 entry
1111 )
1112 })?;
1113 if component_id.is_empty() {
1114 return Err(anyhow!(
1115 "validator component id must not be empty in `{}`",
1116 entry
1117 ));
1118 }
1119 local_validators.push(LocalValidator {
1120 component_id,
1121 path: PathBuf::from(path),
1122 });
1123 }
1124 Ok(local_validators)
1125}
1126
1127fn print_doctor_failure_details(data: &Value) {
1128 let Some(obj) = data.as_object() else {
1129 return;
1130 };
1131 let stdout = obj.get("stdout").and_then(|value| value.as_str());
1132 let stderr = obj.get("stderr").and_then(|value| value.as_str());
1133 let status = obj.get("status").and_then(|value| value.as_i64());
1134 if let Some(status) = status {
1135 println!(" status: {status}");
1136 }
1137 if let Some(stderr) = stderr {
1138 let trimmed = stderr.trim();
1139 if !trimmed.is_empty() {
1140 println!(" stderr: {trimmed}");
1141 }
1142 }
1143 if let Some(stdout) = stdout {
1144 let trimmed = stdout.trim();
1145 if !trimmed.is_empty() {
1146 println!(" stdout: {trimmed}");
1147 }
1148 }
1149}
1150
1151fn validation_counts(report: &ValidationReport) -> (usize, usize, usize) {
1152 let mut info = 0;
1153 let mut warn = 0;
1154 let mut error = 0;
1155 for diag in &report.diagnostics {
1156 match diag.severity {
1157 Severity::Info => info += 1,
1158 Severity::Warn => warn += 1,
1159 Severity::Error => error += 1,
1160 }
1161 }
1162 (info, warn, error)
1163}
1164
1165#[derive(Debug, Clone, Copy, clap::ValueEnum)]
1166pub enum InspectFormat {
1167 Human,
1168 Json,
1169}
1170
1171fn resolve_format(args: &InspectArgs, json: bool) -> InspectFormat {
1172 if json {
1173 InspectFormat::Json
1174 } else {
1175 args.format
1176 }
1177}
1178
1179fn providers_from_manifest(manifest: &PackManifest) -> Vec<ProviderDecl> {
1180 let mut providers = manifest
1181 .provider_extension_inline()
1182 .map(|inline| inline.providers.clone())
1183 .unwrap_or_default();
1184 providers.sort_by(|a, b| a.provider_type.cmp(&b.provider_type));
1185 providers
1186}
1187
1188fn provider_kind(provider: &ProviderDecl) -> String {
1189 provider
1190 .runtime
1191 .world
1192 .split('@')
1193 .next()
1194 .unwrap_or_default()
1195 .to_string()
1196}
1197
1198fn summarize_provider(provider: &ProviderDecl) -> String {
1199 let caps = provider.capabilities.len();
1200 let ops = provider.ops.len();
1201 let mut parts = vec![format!("caps:{caps}"), format!("ops:{ops}")];
1202 parts.push(format!("config:{}", provider.config_schema_ref));
1203 if let Some(docs) = provider.docs_ref.as_deref() {
1204 parts.push(format!("docs:{docs}"));
1205 }
1206 parts.join(" ")
1207}
1208
1209fn format_component_source(source: &ComponentSourceRef) -> String {
1210 match source {
1211 ComponentSourceRef::Oci(value) => format_source_ref("oci", value),
1212 ComponentSourceRef::Repo(value) => format_source_ref("repo", value),
1213 ComponentSourceRef::Store(value) => format_source_ref("store", value),
1214 ComponentSourceRef::File(value) => format_source_ref("file", value),
1215 }
1216}
1217
1218fn format_source_ref(scheme: &str, value: &str) -> String {
1219 if value.contains("://") {
1220 value.to_string()
1221 } else {
1222 format!("{scheme}://{value}")
1223 }
1224}
1225
1226fn format_component_artifact(artifact: &ArtifactLocationV1) -> String {
1227 match artifact {
1228 ArtifactLocationV1::Inline { wasm_path, .. } => format!("inline ({})", wasm_path),
1229 ArtifactLocationV1::Remote => "remote".to_string(),
1230 }
1231}
1232
1233#[cfg(test)]
1234mod tests {
1235 use super::*;
1236 use std::collections::HashMap;
1237 use std::os::unix::process::ExitStatusExt;
1238 use std::path::PathBuf;
1239
1240 fn sample_args() -> InspectArgs {
1241 InspectArgs {
1242 path: None,
1243 pack: None,
1244 input: None,
1245 archive: false,
1246 source: false,
1247 allow_oci_tags: false,
1248 flow_doctor: true,
1249 component_doctor: true,
1250 format: InspectFormat::Human,
1251 validate: true,
1252 no_validate: false,
1253 validators_root: PathBuf::from(".greentic/validators"),
1254 validator_pack: Vec::new(),
1255 validator_wasm: Vec::new(),
1256 validator_allow: vec![DEFAULT_VALIDATOR_ALLOW.to_string()],
1257 validator_cache_dir: PathBuf::from(".greentic/cache/validators"),
1258 validator_policy: ValidatorPolicy::Optional,
1259 online: false,
1260 use_describe_cache: false,
1261 }
1262 }
1263
1264 #[test]
1265 fn sort_json_orders_object_keys_recursively() {
1266 let value = serde_json::json!({
1267 "z": 1,
1268 "a": { "b": 2, "a": 1 },
1269 "list": [{ "d": 4, "c": 3 }]
1270 });
1271
1272 let sorted = to_sorted_json(value).expect("json serialization should succeed");
1273 let root_a = sorted.find("\"a\"").expect("root a key");
1274 let root_z = sorted.find("\"z\"").expect("root z key");
1275 let nested_a = sorted.find("\"a\": 1").expect("nested a key");
1276 let nested_b = sorted.find("\"b\": 2").expect("nested b key");
1277
1278 assert!(root_a < root_z, "root keys should be sorted: {sorted}");
1279 assert!(
1280 nested_a < nested_b,
1281 "nested keys should be sorted: {sorted}"
1282 );
1283 }
1284
1285 #[test]
1286 fn flow_doctor_unsupported_detects_common_cli_errors() {
1287 let output = std::process::Output {
1288 status: std::process::ExitStatus::from_raw(256),
1289 stdout: Vec::new(),
1290 stderr: b"error: unexpected argument '--stdin' found".to_vec(),
1291 };
1292
1293 assert!(flow_doctor_unsupported(&output));
1294 }
1295
1296 #[test]
1297 fn sanitize_component_id_replaces_path_like_characters() {
1298 assert_eq!(
1299 sanitize_component_id("demo/component:beta@1"),
1300 "demo_component_beta_1"
1301 );
1302 }
1303
1304 #[test]
1305 fn forbidden_source_paths_match_dev_only_inputs() {
1306 assert!(is_forbidden_source_path("pack.yaml"));
1307 assert!(is_forbidden_source_path("pack.manifest.json"));
1308 assert!(is_forbidden_source_path("flows/main.json"));
1309 assert!(is_forbidden_source_path("flows/main.ygtc"));
1310 assert!(is_forbidden_source_path("components/demo.manifest.json"));
1311 assert!(is_forbidden_source_path(
1312 "components/demo/component.manifest.json"
1313 ));
1314 assert!(!is_forbidden_source_path("gui/assets/index.html"));
1315 assert!(!is_forbidden_source_path("assets/i18n/_manifest.json"));
1317 assert!(!is_forbidden_source_path("assets/i18n/en/_manifest.json"));
1318 assert!(!is_forbidden_source_path("assets/cards/_manifest.json"));
1319 }
1320
1321 #[test]
1322 fn find_forbidden_source_paths_returns_only_matching_entries() {
1323 let files = HashMap::from([
1324 ("pack.yaml".to_string(), Vec::new()),
1325 ("flows/main.ygtc".to_string(), Vec::new()),
1326 ("gui/assets/index.html".to_string(), Vec::new()),
1327 ]);
1328
1329 let forbidden = find_forbidden_source_paths(&files);
1330 assert_eq!(forbidden.len(), 2);
1331 assert!(forbidden.contains(&"pack.yaml".to_string()));
1332 assert!(forbidden.contains(&"flows/main.ygtc".to_string()));
1333 }
1334
1335 #[test]
1336 fn resolve_mode_prefers_pack_and_input_flags() {
1337 let pack_args = InspectArgs {
1338 pack: Some(PathBuf::from("demo.gtpack")),
1339 ..sample_args()
1340 };
1341 let source_args = InspectArgs {
1342 input: Some(PathBuf::from("demo")),
1343 ..sample_args()
1344 };
1345
1346 assert!(matches!(
1347 resolve_mode(&pack_args).expect("pack mode"),
1348 InspectMode::Archive(path) if path.as_path() == std::path::Path::new("demo.gtpack")
1349 ));
1350 assert!(matches!(
1351 resolve_mode(&source_args).expect("source mode"),
1352 InspectMode::Source(path) if path.as_path() == std::path::Path::new("demo")
1353 ));
1354 }
1355
1356 #[test]
1357 fn resolve_mode_auto_detects_dir_and_gtpack_file() {
1358 let temp = tempfile::tempdir().expect("tempdir");
1359 let dir = temp.path().join("pack");
1360 let file = temp.path().join("pack.gtpack");
1361 std::fs::create_dir_all(&dir).expect("dir");
1362 std::fs::write(&file, b"stub").expect("file");
1363
1364 let dir_args = InspectArgs {
1365 path: Some(dir.clone()),
1366 ..sample_args()
1367 };
1368 let file_args = InspectArgs {
1369 path: Some(file.clone()),
1370 ..sample_args()
1371 };
1372
1373 assert!(matches!(
1374 resolve_mode(&dir_args).expect("dir mode"),
1375 InspectMode::Source(path) if path == dir
1376 ));
1377 assert!(matches!(
1378 resolve_mode(&file_args).expect("file mode"),
1379 InspectMode::Archive(path) if path == file
1380 ));
1381 }
1382
1383 #[test]
1384 fn parse_validator_wasm_args_rejects_missing_paths() {
1385 let err = parse_validator_wasm_args(&["demo.component=".to_string()])
1386 .expect_err("missing validator path should fail");
1387 assert!(
1388 err.to_string()
1389 .contains("expected format COMPONENT_ID=FILE")
1390 );
1391 }
1392
1393 #[test]
1394 fn parse_validator_wasm_args_parses_component_pairs() {
1395 let validators = parse_validator_wasm_args(&[
1396 "demo.component=validators/demo.wasm".to_string(),
1397 "other.component = validators/other.wasm".to_string(),
1398 ])
1399 .expect("validator args should parse");
1400
1401 assert_eq!(validators.len(), 2);
1402 assert_eq!(validators[0].component_id, "demo.component");
1403 assert_eq!(validators[1].path, PathBuf::from("validators/other.wasm"));
1404 }
1405
1406 #[test]
1407 fn format_helpers_preserve_existing_schemes_and_inline_paths() {
1408 assert_eq!(format_source_ref("oci", "oci://example"), "oci://example");
1409 assert_eq!(
1410 format_source_ref("file", "components/demo.wasm"),
1411 "file://components/demo.wasm"
1412 );
1413 assert_eq!(
1414 format_component_artifact(&ArtifactLocationV1::Inline {
1415 wasm_path: "components/demo.wasm".to_string(),
1416 manifest_path: None,
1417 }),
1418 "inline (components/demo.wasm)"
1419 );
1420 assert_eq!(
1421 format_component_artifact(&ArtifactLocationV1::Remote),
1422 "remote"
1423 );
1424 }
1425}