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 "/src/templates/component/Cargo.toml.in"
33));
34const TEMPLATE_SRC_LIB: &str = include_str!(concat!(
35 env!("CARGO_MANIFEST_DIR"),
36 "/src/templates/component/src/lib.rs"
37));
38const TEMPLATE_PROVIDER: &str = include_str!(concat!(
39 env!("CARGO_MANIFEST_DIR"),
40 "/src/templates/component/provider.toml"
41));
42const TEMPLATE_SCHEMA_CONFIG: &str = include_str!(concat!(
43 env!("CARGO_MANIFEST_DIR"),
44 "/src/templates/component/schemas/v1/config.schema.json"
45));
46const TEMPLATE_README: &str = include_str!(concat!(
47 env!("CARGO_MANIFEST_DIR"),
48 "/src/templates/component/README.md"
49));
50const TEMPLATE_WORLD: &str = include_str!(concat!(
51 env!("CARGO_MANIFEST_DIR"),
52 "/src/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 `{}` not provided; set environment variable `{}`",
465 key, 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 `{}` for {}", rest, 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 {:?} not satisfied by embedded packages {:?}",
868 expected_packages,
869 actual_greentic
870 );
871 }
872 }
873
874 let cache_root = component_dir.join("target/component-cache");
875 let store = ComponentStore::new(&cache_root)
876 .with_context(|| format!("failed to initialise cache at {}", cache_root.display()))?;
877 let policy = LoadPolicy::new(Arc::new(store)).with_host_policy(HostPolicy {
878 allow_http_fetch: false,
879 allow_telemetry: true,
880 });
881 let cref = ComponentRef {
882 name: provider.name.clone(),
883 locator: artifact_path
884 .canonicalize()
885 .unwrap_or(artifact_path.clone())
886 .display()
887 .to_string(),
888 };
889 let manifest = match component_runtime::load(&cref, &policy) {
890 Ok(handle) => {
891 let manifest = component_runtime::describe(&handle)
892 .context("failed to inspect component manifest")?;
893 validate_exports(&provider, &manifest)?;
894 validate_capabilities(&provider, &manifest)?;
895 Some(manifest)
896 }
897 Err(component_runtime::CompError::Wasmtime(wasmtime_err)) => {
898 let msg = wasmtime_err.to_string();
899 if msg.contains("wasi:") {
900 println!(
901 "warning: skipping runtime manifest validation due to missing WASI host support: {}",
902 msg
903 );
904 None
905 } else {
906 return Err(component_runtime::CompError::Wasmtime(wasmtime_err).into());
907 }
908 }
909 Err(other) => return Err(other.into()),
910 };
911
912 Ok(ValidationReport {
913 provider,
914 component_dir,
915 artifact_path,
916 sha256,
917 world,
918 packages,
919 manifest,
920 })
921}
922
923fn resolve_component_dir(path: &Path) -> Result<PathBuf> {
924 let dir = if path.is_absolute() {
925 path.to_path_buf()
926 } else {
927 env::current_dir()
928 .context("unable to determine current directory")?
929 .join(path)
930 };
931 dir.canonicalize()
932 .with_context(|| format!("failed to canonicalize {}", dir.display()))
933}
934
935fn resolve_path(base: &Path, raw: impl AsRef<Path>) -> PathBuf {
936 let raw_path = raw.as_ref();
937 if raw_path.is_absolute() {
938 raw_path.to_path_buf()
939 } else {
940 base.join(raw_path)
941 }
942}
943
944fn candidate_artifact_paths(original: &str) -> Vec<String> {
945 let mut paths = Vec::new();
946 paths.push(original.to_string());
947
948 for (from, to) in [
949 ("wasm32-wasip2", "wasm32-wasip1"),
950 ("wasm32-wasip2", "wasm32-wasi"),
951 ("wasm32-wasip1", "wasm32-wasip2"),
952 ("wasm32-wasip1", "wasm32-wasi"),
953 ("wasm32-wasi", "wasm32-wasip2"),
954 ("wasm32-wasi", "wasm32-wasip1"),
955 ] {
956 if original.contains(from) {
957 let candidate = original.replace(from, to);
958 if candidate != original && !paths.contains(&candidate) {
959 paths.push(candidate);
960 }
961 }
962 }
963
964 paths
965}
966
967fn ensure_cargo_component_installed() -> Result<()> {
968 let status = Command::new("cargo")
969 .arg("component")
970 .arg("--version")
971 .status();
972 match status {
973 Ok(status) if status.success() => Ok(()),
974 Ok(_) => bail!(
975 "cargo-component is required. Install with `cargo install cargo-component --locked`."
976 ),
977 Err(err) => Err(anyhow!(
978 "failed to execute `cargo component --version`: {err}. Install cargo-component with `cargo install cargo-component --locked`."
979 )),
980 }
981}
982
983fn run_cargo_component_build(component_dir: &Path) -> Result<()> {
984 let cache_dir = component_dir.join("target").join(".component-cache");
985 let status = Command::new("cargo")
986 .current_dir(component_dir)
987 .arg("component")
988 .arg("build")
989 .arg("--release")
990 .arg("--target")
991 .arg("wasm32-wasip2")
992 .env("CARGO_COMPONENT_CACHE_DIR", cache_dir.as_os_str())
993 .env("CARGO_NET_OFFLINE", "true")
994 .status()
995 .with_context(|| {
996 format!(
997 "failed to run `cargo component build` in {}",
998 component_dir.display()
999 )
1000 })?;
1001 if status.success() {
1002 Ok(())
1003 } else {
1004 bail!("cargo component build failed")
1005 }
1006}
1007
1008fn load_provider(path: &Path) -> Result<ProviderMetadata> {
1009 let contents = fs::read_to_string(path)
1010 .with_context(|| format!("failed to read provider metadata {}", path.display()))?;
1011 let provider: ProviderMetadata =
1012 toml::from_str(&contents).context("provider.toml is not valid TOML")?;
1013 if provider.artifact.format != "wasm-component" {
1014 bail!(
1015 "artifact.format must be `wasm-component`, found `{}`",
1016 provider.artifact.format
1017 );
1018 }
1019 Ok(provider)
1020}
1021
1022fn ensure_version_alignment(provider: &ProviderMetadata, versions: &Versions) -> Result<()> {
1023 if provider.abi.interfaces_version != versions.interfaces {
1024 bail!(
1025 "provider abi.interfaces_version `{}` does not match pinned `{}`",
1026 provider.abi.interfaces_version,
1027 versions.interfaces
1028 );
1029 }
1030 if provider.abi.types_version != versions.types {
1031 bail!(
1032 "provider abi.types_version `{}` does not match pinned `{}`",
1033 provider.abi.types_version,
1034 versions.types
1035 );
1036 }
1037 if provider.abi.component_runtime != versions.component_runtime {
1038 bail!(
1039 "provider abi.component_runtime `{}` does not match pinned `{}`",
1040 provider.abi.component_runtime,
1041 versions.component_runtime
1042 );
1043 }
1044 Ok(())
1045}
1046
1047fn extract_wit_metadata(
1048 resolve: &Resolve,
1049 world_id: WorldId,
1050) -> Result<(Vec<String>, String, Option<String>)> {
1051 let mut packages = Vec::new();
1052 for (_, package) in resolve.packages.iter() {
1053 let name = &package.name;
1054 if name.namespace == "root" {
1055 continue;
1056 }
1057 if let Some(version) = &name.version {
1058 packages.push(format!("{}:{}@{}", name.namespace, name.name, version));
1059 } else {
1060 packages.push(format!("{}:{}", name.namespace, name.name));
1061 }
1062 }
1063 packages.sort();
1064 packages.dedup();
1065
1066 let world = &resolve.worlds[world_id];
1067 let mut export_package = None;
1068 for item in world.exports.values() {
1069 if let WorldItem::Interface { id, .. } = item {
1070 let iface = &resolve.interfaces[*id];
1071 if let Some(pkg_id) = iface.package {
1072 let pkg = &resolve.packages[pkg_id].name;
1073 if pkg.namespace != "root" {
1074 let mut ident = format!("{}:{}", pkg.namespace, pkg.name);
1075 if let Some(version) = &pkg.version {
1076 ident.push('@');
1077 ident.push_str(&version.to_string());
1078 }
1079 export_package.get_or_insert(ident);
1080 }
1081 }
1082 }
1083 }
1084
1085 let world_string = if let Some(pkg_id) = world.package {
1086 let pkg = &resolve.packages[pkg_id];
1087 if let Some(version) = &pkg.name.version {
1088 format!(
1089 "{}:{}/{}@{}",
1090 pkg.name.namespace, pkg.name.name, world.name, version
1091 )
1092 } else {
1093 format!("{}:{}/{}", pkg.name.namespace, pkg.name.name, world.name)
1094 }
1095 } else {
1096 world.name.clone()
1097 };
1098
1099 Ok((packages, world_string, export_package))
1100}
1101
1102fn world_to_package_id(world: &str) -> Option<String> {
1103 let (pkg_part, rest) = world.split_once('/')?;
1104 let (_, version) = rest.rsplit_once('@')?;
1105 Some(format!("{}@{}", pkg_part, version))
1106}
1107
1108fn validate_exports(provider: &ProviderMetadata, manifest: &ComponentManifestInfo) -> Result<()> {
1109 let actual: BTreeSet<_> = manifest
1110 .exports
1111 .iter()
1112 .map(|export| export.operation.clone())
1113 .collect();
1114 for required in &provider.exports.provides {
1115 if !actual.contains(required) {
1116 bail!(
1117 "component manifest is missing required export `{}`",
1118 required
1119 );
1120 }
1121 }
1122 Ok(())
1123}
1124
1125fn validate_capabilities(
1126 provider: &ProviderMetadata,
1127 manifest: &ComponentManifestInfo,
1128) -> Result<()> {
1129 let actual: BTreeSet<_> = manifest
1130 .capabilities
1131 .iter()
1132 .map(|cap| cap.as_str().to_string())
1133 .collect();
1134 for (name, required) in [
1135 ("secrets", provider.capabilities.secrets),
1136 ("telemetry", provider.capabilities.telemetry),
1137 ("network", provider.capabilities.network),
1138 ("filesystem", provider.capabilities.filesystem),
1139 ] {
1140 if required && !actual.contains(name) {
1141 bail!(
1142 "provider declares capability `{}` but component manifest does not expose it",
1143 name
1144 );
1145 }
1146 }
1147 Ok(())
1148}