1use std::collections::{BTreeSet, HashMap};
2use std::env;
3use std::fs;
4use std::io::Write;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7use std::sync::Arc;
8
9use anyhow::{Context, Result, anyhow, bail};
10use clap::{Args, Subcommand};
11use component_runtime::{
12 self, Bindings, ComponentManifestInfo, ComponentRef, HostPolicy, LoadPolicy,
13};
14use component_store::ComponentStore;
15use convert_case::{Case, Casing};
16use greentic_types::{EnvId, TenantCtx as FlowTenantCtx, TenantId};
17use greentic_types_compat::TenantCtx as RuntimeTenantCtx;
18use once_cell::sync::Lazy;
19use semver::Version;
20use serde::{Deserialize, Serialize};
21use serde_json::{Value as JsonValue, json};
22use sha2::{Digest, Sha256};
23use time::OffsetDateTime;
24use time::format_description::well_known::Rfc3339;
25use wit_component::{DecodedWasm, decode as decode_component};
26use wit_parser::{Resolve, WorldId, WorldItem};
27
28static WORKSPACE_ROOT: Lazy<PathBuf> = Lazy::new(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")));
29
30const TEMPLATE_COMPONENT_CARGO: &str = include_str!(concat!(
31 env!("CARGO_MANIFEST_DIR"),
32 "/templates/component/Cargo.toml.in"
33));
34const TEMPLATE_SRC_LIB: &str = include_str!(concat!(
35 env!("CARGO_MANIFEST_DIR"),
36 "/templates/component/src/lib.rs"
37));
38const TEMPLATE_PROVIDER: &str = include_str!(concat!(
39 env!("CARGO_MANIFEST_DIR"),
40 "/templates/component/provider.toml"
41));
42const TEMPLATE_SCHEMA_CONFIG: &str = include_str!(concat!(
43 env!("CARGO_MANIFEST_DIR"),
44 "/templates/component/schemas/v1/config.schema.json"
45));
46const TEMPLATE_README: &str = include_str!(concat!(
47 env!("CARGO_MANIFEST_DIR"),
48 "/templates/component/README.md"
49));
50const TEMPLATE_WORLD: &str = include_str!(concat!(
51 env!("CARGO_MANIFEST_DIR"),
52 "/templates/component/wit/world.wit"
53));
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56struct ProviderMetadata {
57 name: String,
58 version: String,
59 #[serde(default)]
60 description: Option<String>,
61 #[serde(default)]
62 license: Option<String>,
63 #[serde(default)]
64 homepage: Option<String>,
65 abi: AbiSection,
66 capabilities: CapabilitiesSection,
67 exports: ExportsSection,
68 #[serde(default)]
69 imports: ImportsSection,
70 artifact: ArtifactSection,
71 #[serde(default)]
72 docs: Option<DocsSection>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76struct AbiSection {
77 interfaces_version: String,
78 types_version: String,
79 component_runtime: String,
80 world: String,
81 #[serde(default)]
82 wit_packages: Vec<String>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86struct CapabilitiesSection {
87 #[serde(default)]
88 secrets: bool,
89 #[serde(default)]
90 telemetry: bool,
91 #[serde(default)]
92 network: bool,
93 #[serde(default)]
94 filesystem: bool,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
98struct ExportsSection {
99 #[serde(default)]
100 provides: Vec<String>,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, Default)]
104struct ImportsSection {
105 #[serde(default)]
106 requires: Vec<String>,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110struct ArtifactSection {
111 format: String,
112 path: String,
113 #[serde(default)]
114 sha256: String,
115 #[serde(default)]
116 created: String,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize, Default)]
120struct DocsSection {
121 #[serde(default)]
122 readme: Option<String>,
123 #[serde(default)]
124 schemas: Vec<String>,
125}
126
127#[derive(Debug)]
128struct ValidationReport {
129 provider: ProviderMetadata,
130 component_dir: PathBuf,
131 artifact_path: PathBuf,
132 sha256: String,
133 world: String,
134 packages: Vec<String>,
135 manifest: Option<ComponentManifestInfo>,
136}
137
138#[derive(Debug, Clone)]
139struct WitInfo {
140 version: String,
141 dir: PathBuf,
142}
143
144#[derive(Debug, Clone)]
145struct Versions {
146 interfaces: String,
147 types: String,
148 component_runtime: String,
149 component_wit: WitInfo,
150 host_import_wit: WitInfo,
151 types_core_wit: WitInfo,
152}
153
154impl Versions {
155 fn load() -> Result<Self> {
156 let interfaces_version = resolved_version("greentic-interfaces")?;
157 let types_version = resolved_version("greentic-types")?;
158 let component_runtime_version = resolved_version("component-runtime")?;
159
160 let interfaces_root = find_crate_source("greentic-interfaces", &interfaces_version)?;
161 let component_wit = detect_wit_package(&interfaces_root, "component")?;
162 let host_import_wit = detect_wit_package(&interfaces_root, "host-import")?;
163 let types_core_wit = detect_wit_package(&interfaces_root, "types-core")?;
164
165 Ok(Self {
166 interfaces: interfaces_version,
167 types: types_version,
168 component_runtime: component_runtime_version,
169 component_wit,
170 host_import_wit,
171 types_core_wit,
172 })
173 }
174}
175
176static VERSIONS: Lazy<Versions> =
177 Lazy::new(|| Versions::load().expect("load greentic crate versions"));
178
179pub fn run_component_command(command: ComponentCommands) -> Result<()> {
180 match command {
181 ComponentCommands::New(args) => new_component(args),
182 ComponentCommands::Validate(args) => validate_command(args),
183 ComponentCommands::Pack(args) => pack_command(args),
184 ComponentCommands::DemoRun(args) => demo_run_command(args),
185 }
186}
187
188#[derive(Subcommand, Debug, Clone)]
189pub enum ComponentCommands {
190 New(NewComponentArgs),
192 Validate(ValidateArgs),
194 Pack(PackArgs),
196 DemoRun(DemoRunArgs),
198}
199
200#[derive(Args, Debug, Clone)]
201pub struct NewComponentArgs {
202 name: String,
204 #[arg(long, value_name = "DIR")]
206 dir: Option<PathBuf>,
207}
208
209#[derive(Args, Debug, Clone)]
210pub struct ValidateArgs {
211 #[arg(long, value_name = "PATH", default_value = ".")]
213 path: PathBuf,
214 #[arg(long)]
216 skip_build: bool,
217}
218
219#[derive(Args, Debug, Clone)]
220pub struct PackArgs {
221 #[arg(long, value_name = "PATH", default_value = ".")]
223 path: PathBuf,
224 #[arg(long, value_name = "DIR")]
226 out_dir: Option<PathBuf>,
227 #[arg(long)]
229 skip_build: bool,
230}
231
232#[derive(Args, Debug, Clone)]
233pub struct DemoRunArgs {
234 #[arg(long, value_name = "PATH", default_value = ".")]
236 path: PathBuf,
237 #[arg(long, value_name = "FILE")]
239 artifact: Option<PathBuf>,
240 #[arg(long, value_name = "NAME")]
242 operation: Option<String>,
243 #[arg(long, value_name = "JSON")]
245 input: Option<String>,
246 #[arg(long, value_name = "FILE")]
248 config: Option<PathBuf>,
249 #[arg(long)]
251 skip_build: bool,
252}
253
254pub fn new_component(args: NewComponentArgs) -> Result<()> {
255 let context = TemplateContext::new(&args.name)?;
256 let base_dir = match args.dir {
257 Some(ref dir) if dir.is_absolute() => dir.clone(),
258 Some(dir) => env::current_dir()
259 .with_context(|| "failed to resolve current directory")?
260 .join(dir),
261 None => env::current_dir().with_context(|| "failed to resolve current directory")?,
262 };
263 fs::create_dir_all(&base_dir)
264 .with_context(|| format!("failed to prepare base directory {}", base_dir.display()))?;
265 let component_dir = base_dir.join(context.component_dir());
266
267 if component_dir.exists() {
268 bail!(
269 "component directory `{}` already exists",
270 component_dir.display()
271 );
272 }
273
274 println!(
275 "Creating new component scaffold at `{}`",
276 component_dir.display()
277 );
278
279 create_dir(component_dir.join("src"))?;
280 create_dir(component_dir.join("schemas/v1"))?;
281 create_dir(component_dir.join("wit/deps"))?;
282
283 write_template(
284 &component_dir.join("Cargo.toml"),
285 TEMPLATE_COMPONENT_CARGO,
286 &context,
287 )?;
288 write_template(&component_dir.join("README.md"), TEMPLATE_README, &context)?;
289 write_template(
290 &component_dir.join("provider.toml"),
291 TEMPLATE_PROVIDER,
292 &context,
293 )?;
294 write_template(
295 &component_dir.join("src/lib.rs"),
296 TEMPLATE_SRC_LIB,
297 &context,
298 )?;
299 write_template(
300 &component_dir.join("schemas/v1/config.schema.json"),
301 TEMPLATE_SCHEMA_CONFIG,
302 &context,
303 )?;
304 write_template(
305 &component_dir.join("wit/world.wit"),
306 TEMPLATE_WORLD,
307 &context,
308 )?;
309
310 vendor_wit_packages(&component_dir, &context.versions)?;
311
312 println!(
313 "Component `{}` scaffolded successfully.",
314 context.component_name
315 );
316
317 Ok(())
318}
319
320pub fn validate_command(args: ValidateArgs) -> Result<()> {
321 let report = validate_component(&args.path, !args.skip_build)?;
322 print_validation_summary(&report);
323 Ok(())
324}
325
326pub fn pack_command(args: PackArgs) -> Result<()> {
327 let report = validate_component(&args.path, !args.skip_build)?;
328 let base_out = match args.out_dir {
329 Some(ref dir) if dir.is_absolute() => dir.clone(),
330 Some(ref dir) => report.component_dir.join(dir),
331 None => report.component_dir.join("packs"),
332 };
333 fs::create_dir_all(&base_out)
334 .with_context(|| format!("failed to create {}", base_out.display()))?;
335
336 let dest_dir = base_out
337 .join(&report.provider.name)
338 .join(&report.provider.version);
339 if dest_dir.exists() {
340 fs::remove_dir_all(&dest_dir)
341 .with_context(|| format!("failed to clear {}", dest_dir.display()))?;
342 }
343 fs::create_dir_all(&dest_dir)
344 .with_context(|| format!("failed to create {}", dest_dir.display()))?;
345
346 let artifact_file = format!("{}-{}.wasm", report.provider.name, report.provider.version);
347 let dest_wasm = dest_dir.join(&artifact_file);
348 fs::copy(&report.artifact_path, &dest_wasm).with_context(|| {
349 format!(
350 "failed to copy {} to {}",
351 report.artifact_path.display(),
352 dest_wasm.display()
353 )
354 })?;
355
356 let mut meta = report.provider.clone();
357 meta.artifact.path = artifact_file.clone();
358 meta.artifact.sha256 = report.sha256.clone();
359 meta.artifact.created = OffsetDateTime::now_utc()
360 .format(&Rfc3339)
361 .context("unable to format timestamp")?;
362 meta.abi.wit_packages = report.packages.clone();
363
364 let meta_path = dest_dir.join("meta.json");
365 let meta_file = fs::File::create(&meta_path)
366 .with_context(|| format!("failed to create {}", meta_path.display()))?;
367 serde_json::to_writer_pretty(meta_file, &meta)
368 .with_context(|| format!("failed to write {}", meta_path.display()))?;
369
370 let mut sums =
371 fs::File::create(dest_dir.join("SHA256SUMS")).context("failed to create SHA256SUMS")?;
372 writeln!(sums, "{} {}", report.sha256, artifact_file).context("failed to write SHA256SUMS")?;
373
374 println!("✓ Packed component at {}", dest_dir.display());
375 Ok(())
376}
377
378pub fn demo_run_command(args: DemoRunArgs) -> Result<()> {
379 let report = validate_component(&args.path, !args.skip_build)?;
380 let artifact_path = match args.artifact {
381 Some(ref path) => resolve_path(&report.component_dir, path),
382 None => report.artifact_path.clone(),
383 };
384
385 let cache_root = report.component_dir.join("target/demo-cache");
386 let store = ComponentStore::new(&cache_root)
387 .with_context(|| format!("failed to initialise cache at {}", cache_root.display()))?;
388 let policy = LoadPolicy::new(Arc::new(store)).with_host_policy(HostPolicy {
389 allow_http_fetch: false,
390 allow_telemetry: true,
391 });
392 let cref = ComponentRef {
393 name: report.provider.name.clone(),
394 locator: artifact_path
395 .canonicalize()
396 .unwrap_or(artifact_path.clone())
397 .display()
398 .to_string(),
399 };
400 let handle =
401 component_runtime::load(&cref, &policy).context("failed to load component into runtime")?;
402 let manifest = component_runtime::describe(&handle).context("failed to describe component")?;
403
404 let operation = args
405 .operation
406 .clone()
407 .unwrap_or_else(|| "invoke".to_string());
408 let available_ops: BTreeSet<_> = manifest
409 .exports
410 .iter()
411 .map(|export| export.operation.clone())
412 .collect();
413 if !available_ops.contains(&operation) {
414 bail!(
415 "component does not export required operation `{}`. Available: {}",
416 operation,
417 available_ops.iter().cloned().collect::<Vec<_>>().join(", ")
418 );
419 }
420
421 let input_value: JsonValue = if let Some(ref input) = args.input {
422 serde_json::from_str(input).context("failed to parse --input JSON")?
423 } else {
424 json!({})
425 };
426
427 let config_value: JsonValue = if let Some(ref cfg) = args.config {
428 let cfg_path = resolve_path(&report.component_dir, cfg);
429 let contents = fs::read_to_string(&cfg_path)
430 .with_context(|| format!("failed to read config {}", cfg_path.display()))?;
431 serde_json::from_str(&contents)
432 .with_context(|| format!("invalid JSON in {}", cfg_path.display()))?
433 } else {
434 json!({})
435 };
436
437 let mut missing_secrets = Vec::new();
438 let mut provided_secrets = Vec::new();
439 for secret in &manifest.secrets {
440 if env::var(secret).is_ok() {
441 provided_secrets.push(secret.clone());
442 } else {
443 missing_secrets.push(secret.clone());
444 }
445 }
446 if !missing_secrets.is_empty() {
447 println!(
448 "warning: secrets not provided via environment variables: {}",
449 missing_secrets.join(", ")
450 );
451 }
452
453 let bindings = Bindings::new(config_value.clone(), provided_secrets);
454 let env = EnvId::new("dev").context("invalid default environment id")?;
455 let tenant_id = TenantId::new("demo").context("invalid default tenant id")?;
456 let tenant = FlowTenantCtx::new(env, tenant_id);
457 let runtime_tenant = flow_ctx_to_runtime_ctx(&tenant)?;
458
459 let mut secret_resolver =
460 |key: &str, _ctx: &RuntimeTenantCtx| -> Result<String, component_runtime::CompError> {
461 match env::var(key) {
462 Ok(value) => Ok(value),
463 Err(_) => Err(component_runtime::CompError::Runtime(format!(
464 "secret `{key}` not provided; set environment variable `{key}`"
465 ))),
466 }
467 };
468
469 component_runtime::bind(&handle, &runtime_tenant, &bindings, &mut secret_resolver)
470 .context("failed to bind component configuration")?;
471 let output = component_runtime::invoke(&handle, &operation, &input_value, &runtime_tenant)
472 .context("component invocation failed")?;
473
474 println!(
475 "{}",
476 serde_json::to_string_pretty(&output)
477 .context("failed to format invocation result as JSON")?
478 );
479
480 Ok(())
481}
482
483fn flow_ctx_to_runtime_ctx(ctx: &FlowTenantCtx) -> Result<RuntimeTenantCtx> {
484 let serialized = serde_json::to_value(ctx)
485 .context("failed to serialize tenant context for runtime mapping")?;
486 serde_json::from_value(serialized)
487 .context("failed to convert tenant context to runtime-compatible version")
488}
489
490fn create_dir(path: PathBuf) -> Result<()> {
491 fs::create_dir_all(&path)
492 .with_context(|| format!("failed to create directory `{}`", path.display()))
493}
494
495fn write_template(path: &Path, template: &str, context: &TemplateContext) -> Result<()> {
496 if path.exists() {
497 bail!("file `{}` already exists", path.display());
498 }
499
500 let rendered = render_template(template, context);
501 fs::write(path, rendered).with_context(|| format!("failed to write `{}`", path.display()))
502}
503
504fn render_template(template: &str, context: &TemplateContext) -> String {
505 let mut output = template.to_owned();
506 for (key, value) in &context.placeholders {
507 let token = format!("{{{{{key}}}}}");
508 output = output.replace(&token, value);
509 }
510 output
511}
512
513fn vendor_wit_packages(component_dir: &Path, versions: &Versions) -> Result<()> {
514 let deps_dir = component_dir.join("wit/deps");
515 create_dir(deps_dir.clone())?;
516
517 for info in [
518 &versions.component_wit,
519 &versions.host_import_wit,
520 &versions.types_core_wit,
521 ] {
522 let package_name = info
523 .dir
524 .file_name()
525 .ok_or_else(|| anyhow!("invalid wit directory {}", info.dir.display()))?
526 .to_string_lossy()
527 .replace('@', "-");
528 let namespace = info
529 .dir
530 .parent()
531 .and_then(|path| path.file_name())
532 .ok_or_else(|| anyhow!("invalid wit namespace for {}", info.dir.display()))?
533 .to_string_lossy()
534 .into_owned();
535 let dest = deps_dir.join(format!("{namespace}-{package_name}"));
536 copy_dir_recursive(&info.dir, &dest)?;
537 }
538
539 Ok(())
540}
541
542fn detect_wit_package(crate_root: &Path, prefix: &str) -> Result<WitInfo> {
543 let wit_dir = crate_root.join("wit");
544 let namespace_dir = wit_dir.join("greentic");
545 let prefix = format!("{prefix}@");
546
547 let mut best: Option<(Version, PathBuf)> = None;
548 for entry in fs::read_dir(&namespace_dir).with_context(|| {
549 format!(
550 "failed to read namespace directory {}",
551 namespace_dir.display()
552 )
553 })? {
554 let entry = entry?;
555 let path = entry.path();
556 if !path.is_dir() {
557 continue;
558 }
559 let name = entry
560 .file_name()
561 .into_string()
562 .map_err(|_| anyhow!("non-unicode filename under {}", namespace_dir.display()))?;
563 if let Some(rest) = name.strip_prefix(&prefix) {
564 let version = Version::parse(rest)
565 .with_context(|| format!("invalid semver `{rest}` for {prefix}"))?;
566 if best.as_ref().is_none_or(|(current, _)| &version > current) {
567 best = Some((version, path));
568 }
569 }
570 }
571
572 match best {
573 Some((version, dir)) => Ok(WitInfo {
574 version: version.to_string(),
575 dir,
576 }),
577 None => Err(anyhow!(
578 "unable to locate WIT package `{}` under {}",
579 prefix,
580 namespace_dir.display()
581 )),
582 }
583}
584
585#[derive(Deserialize)]
586struct LockPackage {
587 name: String,
588 version: String,
589}
590
591#[derive(Deserialize)]
592struct LockFile {
593 package: Vec<LockPackage>,
594}
595
596fn resolved_version(crate_name: &str) -> Result<String> {
597 let lock_path = WORKSPACE_ROOT.join("Cargo.lock");
598 let contents = fs::read_to_string(&lock_path)
599 .with_context(|| format!("failed to read {}", lock_path.display()))?;
600 let lock: LockFile =
601 toml::from_str(&contents).with_context(|| format!("invalid {}", lock_path.display()))?;
602
603 let mut best: Option<(Version, String)> = None;
604 for pkg in lock
605 .package
606 .into_iter()
607 .filter(|pkg| pkg.name == crate_name)
608 {
609 let version = Version::parse(&pkg.version)
610 .with_context(|| format!("invalid semver `{}` for {}", pkg.version, crate_name))?;
611 if best.as_ref().is_none_or(|(current, _)| &version > current) {
612 best = Some((version, pkg.version));
613 }
614 }
615
616 match best {
617 Some((_, version)) => Ok(version),
618 None => Err(anyhow!(
619 "crate `{}` not found in {}",
620 crate_name,
621 lock_path.display()
622 )),
623 }
624}
625
626fn cargo_home() -> Result<PathBuf> {
627 if let Ok(path) = env::var("CARGO_HOME") {
628 return Ok(PathBuf::from(path));
629 }
630 if let Ok(home) = env::var("HOME") {
631 return Ok(PathBuf::from(home).join(".cargo"));
632 }
633 Err(anyhow!(
634 "unable to determine CARGO_HOME; set the environment variable explicitly"
635 ))
636}
637
638fn find_crate_source(crate_name: &str, version: &str) -> Result<PathBuf> {
639 let home = cargo_home()?;
640 let registry_src = home.join("registry/src");
641 if !registry_src.exists() {
642 return Err(anyhow!(
643 "cargo registry src directory not found at {}",
644 registry_src.display()
645 ));
646 }
647
648 for index in fs::read_dir(®istry_src)? {
649 let index_path = index?.path();
650 if !index_path.is_dir() {
651 continue;
652 }
653 let candidate = index_path.join(format!("{crate_name}-{version}"));
654 if candidate.exists() {
655 return Ok(candidate);
656 }
657 }
658
659 Err(anyhow!(
660 "crate `{}` version `{}` not found under {}",
661 crate_name,
662 version,
663 registry_src.display()
664 ))
665}
666
667fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> {
668 if dest.exists() {
669 fs::remove_dir_all(dest).with_context(|| format!("failed to remove {}", dest.display()))?;
670 }
671 fs::create_dir_all(dest).with_context(|| format!("failed to create {}", dest.display()))?;
672 for entry in
673 fs::read_dir(src).with_context(|| format!("failed to read directory {}", src.display()))?
674 {
675 let entry = entry?;
676 let src_path = entry.path();
677 let dest_path = dest.join(entry.file_name());
678 if src_path.is_dir() {
679 copy_dir_recursive(&src_path, &dest_path)?;
680 } else {
681 fs::copy(&src_path, &dest_path).with_context(|| {
682 format!(
683 "failed to copy {} to {}",
684 src_path.display(),
685 dest_path.display()
686 )
687 })?;
688 }
689 }
690 Ok(())
691}
692
693struct TemplateContext {
694 component_name: String,
695 component_kebab: String,
696 versions: Versions,
697 placeholders: HashMap<String, String>,
698}
699
700impl TemplateContext {
701 fn new(raw: &str) -> Result<Self> {
702 let trimmed = raw.trim();
703 if trimmed.is_empty() {
704 bail!("component name cannot be empty");
705 }
706
707 let component_kebab = trimmed.to_case(Case::Kebab);
708 let component_snake = trimmed.to_case(Case::Snake);
709 let component_pascal = trimmed.to_case(Case::Pascal);
710 let component_name = component_kebab.clone();
711 let versions = VERSIONS.clone();
712
713 let mut placeholders = HashMap::new();
714 placeholders.insert("component_name".into(), component_name.clone());
715 placeholders.insert("component_kebab".into(), component_kebab.clone());
716 placeholders.insert("component_snake".into(), component_snake.clone());
717 placeholders.insert("component_pascal".into(), component_pascal.clone());
718 placeholders.insert("component_crate".into(), component_kebab.clone());
719 placeholders.insert(
720 "component_dir".into(),
721 format!("component-{component_kebab}"),
722 );
723 placeholders.insert("interfaces_version".into(), versions.interfaces.clone());
724 placeholders.insert("types_version".into(), versions.types.clone());
725 placeholders.insert(
726 "component_runtime_version".into(),
727 versions.component_runtime.clone(),
728 );
729 placeholders.insert(
730 "component_world_version".into(),
731 versions.component_wit.version.clone(),
732 );
733 placeholders.insert(
734 "host_import_version".into(),
735 versions.host_import_wit.version.clone(),
736 );
737 placeholders.insert(
738 "types_core_version".into(),
739 versions.types_core_wit.version.clone(),
740 );
741
742 Ok(Self {
743 component_name,
744 component_kebab,
745 versions,
746 placeholders,
747 })
748 }
749
750 fn component_dir(&self) -> String {
751 format!("component-{}", self.component_kebab)
752 }
753}
754
755fn print_validation_summary(report: &ValidationReport) {
756 println!(
757 "✓ Validated {} {}",
758 report.provider.name, report.provider.version
759 );
760 println!(" artifact: {}", report.artifact_path.display());
761 println!(" sha256 : {}", report.sha256);
762 println!(" world : {}", report.world);
763 println!(" packages:");
764 for pkg in &report.packages {
765 println!(" - {pkg}");
766 }
767 if let Some(manifest) = &report.manifest {
768 println!(" exports:");
769 for export in &manifest.exports {
770 println!(" - {}", export.operation);
771 }
772 } else {
773 println!(" exports: <skipped - missing WASI host support>");
774 }
775}
776
777fn validate_component(path: &Path, build: bool) -> Result<ValidationReport> {
778 let component_dir = resolve_component_dir(path)?;
779
780 if build {
781 ensure_cargo_component_installed()?;
782 run_cargo_component_build(&component_dir)?;
783 }
784
785 let provider_path = component_dir.join("provider.toml");
786 let provider = load_provider(&provider_path)?;
787
788 let versions = Versions::load()?;
789 ensure_version_alignment(&provider, &versions)?;
790
791 let mut attempted = Vec::new();
792 let mut artifact_path = None;
793 for candidate in candidate_artifact_paths(&provider.artifact.path) {
794 let resolved = resolve_path(&component_dir, Path::new(&candidate));
795 attempted.push(resolved.clone());
796 if resolved.exists() {
797 artifact_path = Some(resolved);
798 break;
799 }
800 }
801 let artifact_path = match artifact_path {
802 Some(path) => path,
803 None => {
804 let paths = attempted
805 .into_iter()
806 .map(|p| p.display().to_string())
807 .collect::<Vec<_>>()
808 .join(", ");
809 bail!("artifact path not found; checked {paths}");
810 }
811 };
812
813 let wasm_bytes = fs::read(&artifact_path)
814 .with_context(|| format!("failed to read {}", artifact_path.display()))?;
815 let sha256 = format!("{:x}", Sha256::digest(&wasm_bytes));
816
817 let decoded = decode_component(&wasm_bytes).context("failed to decode component")?;
818 let (resolve, world_id) = match decoded {
819 DecodedWasm::Component(resolve, world) => (resolve, world),
820 DecodedWasm::WitPackage(_, _) => {
821 bail!("expected a component artifact but found a WIT package bundle")
822 }
823 };
824 let (packages, world, export_package) = extract_wit_metadata(&resolve, world_id)?;
825
826 if packages.is_empty() {
827 bail!("no WIT packages embedded in component artifact");
828 }
829
830 if provider.abi.world != world {
831 if let Some(expected_pkg) = world_to_package_id(&provider.abi.world) {
832 if let Some(actual_pkg) = export_package {
833 if actual_pkg != expected_pkg {
834 bail!(
835 "provider world `{}` expects package '{}', but embedded exports use '{}'",
836 provider.abi.world,
837 expected_pkg,
838 actual_pkg
839 );
840 }
841 } else if !packages.iter().any(|pkg| pkg == &expected_pkg) {
842 bail!(
843 "provider world `{}` expects package '{}', which was not embedded (found {:?})",
844 provider.abi.world,
845 expected_pkg,
846 packages
847 );
848 }
849 } else {
850 bail!(
851 "provider world `{}` is not formatted as <namespace>:<package>/<world>@<version>",
852 provider.abi.world
853 );
854 }
855 }
856
857 let expected_packages: BTreeSet<_> = provider.abi.wit_packages.iter().cloned().collect();
858 if !expected_packages.is_empty() {
859 let actual_greentic: BTreeSet<_> = packages
860 .iter()
861 .filter(|pkg| pkg.starts_with("greentic:"))
862 .cloned()
863 .collect();
864 if !expected_packages.is_subset(&actual_greentic) {
865 bail!(
866 "provider wit_packages {expected_packages:?} not satisfied by embedded packages \
867 {actual_greentic:?}"
868 );
869 }
870 }
871
872 let cache_root = component_dir.join("target/component-cache");
873 let store = ComponentStore::new(&cache_root)
874 .with_context(|| format!("failed to initialise cache at {}", cache_root.display()))?;
875 let policy = LoadPolicy::new(Arc::new(store)).with_host_policy(HostPolicy {
876 allow_http_fetch: false,
877 allow_telemetry: true,
878 });
879 let cref = ComponentRef {
880 name: provider.name.clone(),
881 locator: artifact_path
882 .canonicalize()
883 .unwrap_or(artifact_path.clone())
884 .display()
885 .to_string(),
886 };
887 let manifest = match component_runtime::load(&cref, &policy) {
888 Ok(handle) => {
889 let manifest = component_runtime::describe(&handle)
890 .context("failed to inspect component manifest")?;
891 validate_exports(&provider, &manifest)?;
892 validate_capabilities(&provider, &manifest)?;
893 Some(manifest)
894 }
895 Err(component_runtime::CompError::Wasmtime(wasmtime_err)) => {
896 let msg = wasmtime_err.to_string();
897 if msg.contains("wasi:") {
898 println!(
899 "warning: skipping runtime manifest validation due to missing WASI host support: {msg}"
900 );
901 None
902 } else {
903 return Err(component_runtime::CompError::Wasmtime(wasmtime_err).into());
904 }
905 }
906 Err(other) => return Err(other.into()),
907 };
908
909 Ok(ValidationReport {
910 provider,
911 component_dir,
912 artifact_path,
913 sha256,
914 world,
915 packages,
916 manifest,
917 })
918}
919
920fn resolve_component_dir(path: &Path) -> Result<PathBuf> {
921 let dir = if path.is_absolute() {
922 path.to_path_buf()
923 } else {
924 env::current_dir()
925 .context("unable to determine current directory")?
926 .join(path)
927 };
928 dir.canonicalize()
929 .with_context(|| format!("failed to canonicalize {}", dir.display()))
930}
931
932fn resolve_path(base: &Path, raw: impl AsRef<Path>) -> PathBuf {
933 let raw_path = raw.as_ref();
934 if raw_path.is_absolute() {
935 raw_path.to_path_buf()
936 } else {
937 base.join(raw_path)
938 }
939}
940
941fn candidate_artifact_paths(original: &str) -> Vec<String> {
942 let mut paths = Vec::new();
943 paths.push(original.to_string());
944
945 for (from, to) in [
946 ("wasm32-wasip2", "wasm32-wasip1"),
947 ("wasm32-wasip2", "wasm32-wasi"),
948 ("wasm32-wasip1", "wasm32-wasip2"),
949 ("wasm32-wasip1", "wasm32-wasi"),
950 ("wasm32-wasi", "wasm32-wasip2"),
951 ("wasm32-wasi", "wasm32-wasip1"),
952 ] {
953 if original.contains(from) {
954 let candidate = original.replace(from, to);
955 if candidate != original && !paths.contains(&candidate) {
956 paths.push(candidate);
957 }
958 }
959 }
960
961 paths
962}
963
964fn ensure_cargo_component_installed() -> Result<()> {
965 let status = Command::new("cargo")
966 .arg("component")
967 .arg("--version")
968 .status();
969 match status {
970 Ok(status) if status.success() => Ok(()),
971 Ok(_) => bail!(
972 "cargo-component is required. Install with `cargo install cargo-component --locked`."
973 ),
974 Err(err) => Err(anyhow!(
975 "failed to execute `cargo component --version`: {err}. Install cargo-component with `cargo install cargo-component --locked`."
976 )),
977 }
978}
979
980fn run_cargo_component_build(component_dir: &Path) -> Result<()> {
981 let cache_dir = component_dir.join("target").join(".component-cache");
982 let status = Command::new("cargo")
983 .current_dir(component_dir)
984 .arg("component")
985 .arg("build")
986 .arg("--release")
987 .arg("--target")
988 .arg("wasm32-wasip2")
989 .env("CARGO_COMPONENT_CACHE_DIR", cache_dir.as_os_str())
990 .env("CARGO_NET_OFFLINE", "true")
991 .status()
992 .with_context(|| {
993 format!(
994 "failed to run `cargo component build` in {}",
995 component_dir.display()
996 )
997 })?;
998 if status.success() {
999 Ok(())
1000 } else {
1001 bail!("cargo component build failed")
1002 }
1003}
1004
1005fn load_provider(path: &Path) -> Result<ProviderMetadata> {
1006 let contents = fs::read_to_string(path)
1007 .with_context(|| format!("failed to read provider metadata {}", path.display()))?;
1008 let provider: ProviderMetadata =
1009 toml::from_str(&contents).context("provider.toml is not valid TOML")?;
1010 if provider.artifact.format != "wasm-component" {
1011 bail!(
1012 "artifact.format must be `wasm-component`, found `{}`",
1013 provider.artifact.format
1014 );
1015 }
1016 Ok(provider)
1017}
1018
1019fn ensure_version_alignment(provider: &ProviderMetadata, versions: &Versions) -> Result<()> {
1020 if provider.abi.interfaces_version != versions.interfaces {
1021 bail!(
1022 "provider abi.interfaces_version `{}` does not match pinned `{}`",
1023 provider.abi.interfaces_version,
1024 versions.interfaces
1025 );
1026 }
1027 if provider.abi.types_version != versions.types {
1028 bail!(
1029 "provider abi.types_version `{}` does not match pinned `{}`",
1030 provider.abi.types_version,
1031 versions.types
1032 );
1033 }
1034 if provider.abi.component_runtime != versions.component_runtime {
1035 bail!(
1036 "provider abi.component_runtime `{}` does not match pinned `{}`",
1037 provider.abi.component_runtime,
1038 versions.component_runtime
1039 );
1040 }
1041 Ok(())
1042}
1043
1044fn extract_wit_metadata(
1045 resolve: &Resolve,
1046 world_id: WorldId,
1047) -> Result<(Vec<String>, String, Option<String>)> {
1048 let mut packages = Vec::new();
1049 for (_, package) in resolve.packages.iter() {
1050 let name = &package.name;
1051 if name.namespace == "root" {
1052 continue;
1053 }
1054 if let Some(version) = &name.version {
1055 packages.push(format!("{}:{}@{}", name.namespace, name.name, version));
1056 } else {
1057 packages.push(format!("{}:{}", name.namespace, name.name));
1058 }
1059 }
1060 packages.sort();
1061 packages.dedup();
1062
1063 let world = &resolve.worlds[world_id];
1064 let mut export_package = None;
1065 for item in world.exports.values() {
1066 if let WorldItem::Interface { id, .. } = item {
1067 let iface = &resolve.interfaces[*id];
1068 if let Some(pkg_id) = iface.package {
1069 let pkg = &resolve.packages[pkg_id].name;
1070 if pkg.namespace != "root" {
1071 let mut ident = format!("{}:{}", pkg.namespace, pkg.name);
1072 if let Some(version) = &pkg.version {
1073 ident.push('@');
1074 ident.push_str(&version.to_string());
1075 }
1076 export_package.get_or_insert(ident);
1077 }
1078 }
1079 }
1080 }
1081
1082 let world_string = if let Some(pkg_id) = world.package {
1083 let pkg = &resolve.packages[pkg_id];
1084 if let Some(version) = &pkg.name.version {
1085 format!(
1086 "{}:{}/{}@{}",
1087 pkg.name.namespace, pkg.name.name, world.name, version
1088 )
1089 } else {
1090 format!("{}:{}/{}", pkg.name.namespace, pkg.name.name, world.name)
1091 }
1092 } else {
1093 world.name.clone()
1094 };
1095
1096 Ok((packages, world_string, export_package))
1097}
1098
1099fn world_to_package_id(world: &str) -> Option<String> {
1100 let (pkg_part, rest) = world.split_once('/')?;
1101 let (_, version) = rest.rsplit_once('@')?;
1102 Some(format!("{pkg_part}@{version}"))
1103}
1104
1105fn validate_exports(provider: &ProviderMetadata, manifest: &ComponentManifestInfo) -> Result<()> {
1106 let actual: BTreeSet<_> = manifest
1107 .exports
1108 .iter()
1109 .map(|export| export.operation.clone())
1110 .collect();
1111 for required in &provider.exports.provides {
1112 if !actual.contains(required) {
1113 bail!("component manifest is missing required export `{required}`");
1114 }
1115 }
1116 Ok(())
1117}
1118
1119fn validate_capabilities(
1120 provider: &ProviderMetadata,
1121 manifest: &ComponentManifestInfo,
1122) -> Result<()> {
1123 let actual: BTreeSet<_> = manifest
1124 .capabilities
1125 .iter()
1126 .map(|cap| cap.as_str().to_string())
1127 .collect();
1128 for (name, required) in [
1129 ("secrets", provider.capabilities.secrets),
1130 ("telemetry", provider.capabilities.telemetry),
1131 ("network", provider.capabilities.network),
1132 ("filesystem", provider.capabilities.filesystem),
1133 ] {
1134 if required && !actual.contains(name) {
1135 bail!(
1136 "provider declares capability `{name}` but component manifest does not expose it"
1137 );
1138 }
1139 }
1140 Ok(())
1141}