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