1use crate::path_safety::normalize_under_root;
2use anyhow::{Context, Result, bail};
3use greentic_types::pack_manifest::ExtensionInline;
4use greentic_types::provider::{PROVIDER_EXTENSION_ID, ProviderDecl, ProviderExtensionInline};
5use greentic_types::{
6 ComponentCapabilities, ComponentProfiles, ExtensionRef, FlowKind, ResourceHints,
7};
8use serde::{Deserialize, Serialize};
9use serde_json::Value as JsonValue;
10use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12
13const PROVIDER_RUNTIME_WORLD: &str = "greentic:provider/schema-core@1.0.0";
14const LEGACY_PROVIDER_EXTENSION_KIND: &str = "greentic.ext.provider";
15
16#[derive(Debug, Clone, Deserialize, Serialize)]
17pub struct PackConfig {
18 pub pack_id: String,
19 pub version: String,
20 pub kind: String,
21 pub publisher: String,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub name: Option<String>,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub bootstrap: Option<BootstrapConfig>,
26 #[serde(default)]
27 pub components: Vec<ComponentConfig>,
28 #[serde(default)]
29 pub dependencies: Vec<DependencyConfig>,
30 #[serde(default)]
31 pub flows: Vec<FlowConfig>,
32 #[serde(default)]
33 pub assets: Vec<AssetConfig>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub extensions: Option<BTreeMap<String, ExtensionRef>>,
36}
37
38#[derive(Debug, Clone, Deserialize, Serialize)]
39pub struct ComponentConfig {
40 pub id: String,
41 pub version: String,
42 pub world: String,
43 #[serde(default)]
44 pub supports: Vec<FlowKindLabel>,
45 pub profiles: ComponentProfiles,
46 pub capabilities: ComponentCapabilities,
47 pub wasm: PathBuf,
48 #[serde(default, skip_serializing_if = "Vec::is_empty")]
49 pub operations: Vec<ComponentOperationConfig>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub config_schema: Option<JsonValue>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub resources: Option<ResourceHints>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub configurators: Option<ComponentConfiguratorConfig>,
56}
57
58#[derive(Debug, Clone, Deserialize, Serialize)]
59pub struct ComponentOperationConfig {
60 pub name: String,
61 pub input_schema: JsonValue,
62 pub output_schema: JsonValue,
63}
64
65#[derive(Debug, Clone, Deserialize, Serialize)]
66pub struct FlowConfig {
67 pub id: String,
68 pub file: PathBuf,
69 #[serde(default)]
70 pub tags: Vec<String>,
71 #[serde(default)]
72 pub entrypoints: Vec<String>,
73}
74
75#[derive(Debug, Clone, Deserialize, Serialize)]
76pub struct DependencyConfig {
77 pub alias: String,
78 pub pack_id: String,
79 pub version_req: String,
80 #[serde(default)]
81 pub required_capabilities: Vec<String>,
82}
83
84#[derive(Debug, Clone, Deserialize, Serialize)]
85pub struct AssetConfig {
86 pub path: PathBuf,
87}
88
89#[derive(Debug, Clone, Deserialize, Serialize)]
90pub struct BootstrapConfig {
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub install_flow: Option<String>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub upgrade_flow: Option<String>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub installer_component: Option<String>,
97}
98
99#[derive(Debug, Clone, Deserialize, Serialize)]
100pub struct ComponentConfiguratorConfig {
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub basic: Option<String>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub full: Option<String>,
105}
106
107#[derive(Debug, Clone, Deserialize, Serialize)]
108#[serde(rename_all = "lowercase")]
109pub enum FlowKindLabel {
110 Messaging,
111 Event,
112 #[serde(
113 rename = "componentconfig",
114 alias = "component-config",
115 alias = "component_config"
116 )]
117 ComponentConfig,
118 Job,
119 Http,
120}
121
122impl FlowKindLabel {
123 pub fn to_kind(&self) -> FlowKind {
124 match self {
125 FlowKindLabel::Messaging => FlowKind::Messaging,
126 FlowKindLabel::Event => FlowKind::Event,
127 FlowKindLabel::ComponentConfig => FlowKind::ComponentConfig,
128 FlowKindLabel::Job => FlowKind::Job,
129 FlowKindLabel::Http => FlowKind::Http,
130 }
131 }
132}
133
134pub fn load_pack_config(root: &Path) -> Result<PackConfig> {
135 let manifest_path = normalize_under_root(root, Path::new("pack.yaml"))?;
136 let contents = std::fs::read_to_string(&manifest_path)
137 .with_context(|| format!("failed to read {}", manifest_path.display()))?;
138 let mut cfg: PackConfig = serde_yaml_bw::from_str(&contents)
139 .with_context(|| format!("{} is not a valid pack.yaml", manifest_path.display()))?;
140
141 for component in cfg.components.iter_mut() {
143 component.wasm = normalize_under_root(root, &component.wasm)?;
144 }
145 for flow in cfg.flows.iter_mut() {
146 flow.file = normalize_under_root(root, &flow.file)?;
147 }
148 for asset in cfg.assets.iter_mut() {
149 asset.path = normalize_under_root(root, &asset.path)?;
150 }
151
152 validate_extensions(cfg.extensions.as_ref(), strict_extensions())?;
153
154 Ok(cfg)
155}
156
157fn strict_extensions() -> bool {
158 matches!(
159 std::env::var("GREENTIC_PACK_STRICT_EXTENSIONS")
160 .unwrap_or_default()
161 .as_str(),
162 "1" | "true" | "TRUE"
163 )
164}
165
166fn validate_extensions(
167 extensions: Option<&BTreeMap<String, ExtensionRef>>,
168 strict: bool,
169) -> Result<()> {
170 let Some(exts) = extensions else {
171 return Ok(());
172 };
173
174 for (key, ext) in exts {
175 if ext.kind.trim().is_empty() {
176 bail!("extensions[{key}] kind must not be empty");
177 }
178 if ext.version.trim().is_empty() {
179 bail!("extensions[{key}] version must not be empty");
180 }
181 if ext.kind != *key {
182 bail!(
183 "extensions[{key}] kind `{}` must match the extension key",
184 ext.kind
185 );
186 }
187 if strict && let Some(location) = ext.location.as_deref() {
188 let digest_missing = ext
189 .digest
190 .as_ref()
191 .map(|d| d.trim().is_empty())
192 .unwrap_or(true);
193 if digest_missing {
194 bail!("extensions[{key}] location requires digest in strict mode");
195 }
196 let allowed = location.starts_with("oci://")
197 || location.starts_with("file://")
198 || location.starts_with("https://");
199 if !allowed {
200 bail!(
201 "extensions[{key}] location `{location}` uses an unsupported scheme; allowed: oci://, file://, https://"
202 );
203 }
204 }
205
206 if ext.kind == PROVIDER_EXTENSION_ID || ext.kind == LEGACY_PROVIDER_EXTENSION_KIND {
207 validate_provider_extension(key, ext)?;
208 }
209 }
210
211 Ok(())
212}
213
214fn validate_provider_extension(key: &str, ext: &ExtensionRef) -> Result<()> {
215 let inline = ext
216 .inline
217 .as_ref()
218 .ok_or_else(|| anyhow::anyhow!("extensions[{key}] inline payload is required"))?;
219 let providers = match inline {
220 ExtensionInline::Provider(value) => value.providers.clone(),
221 ExtensionInline::Other(value) => {
222 serde_json::from_value::<ProviderExtensionInline>(value.clone())
223 .with_context(|| {
224 format!("extensions[{key}].inline is not a valid provider extension")
225 })?
226 .providers
227 }
228 };
229 if providers.is_empty() {
230 bail!("extensions[{key}].inline.providers must not be empty");
231 }
232
233 for (idx, provider) in providers.iter().enumerate() {
234 validate_provider_decl(provider, key, idx)?;
235 }
236
237 Ok(())
238}
239
240fn validate_provider_decl(provider: &ProviderDecl, key: &str, idx: usize) -> Result<()> {
241 if provider.provider_type.trim().is_empty() {
242 bail!("extensions[{key}].inline.providers[{idx}].provider_type must not be empty");
243 }
244 if provider.config_schema_ref.trim().is_empty() {
245 bail!("extensions[{key}].inline.providers[{idx}].config_schema_ref must not be empty");
246 }
247 if provider.runtime.world != PROVIDER_RUNTIME_WORLD {
248 bail!(
249 "extensions[{key}].inline.providers[{idx}].runtime.world must be `{}`",
250 PROVIDER_RUNTIME_WORLD
251 );
252 }
253 if provider.runtime.component_ref.trim().is_empty() || provider.runtime.export.trim().is_empty()
254 {
255 bail!(
256 "extensions[{key}].inline.providers[{idx}].runtime component_ref/export must not be empty"
257 );
258 }
259 validate_string_vec(&provider.capabilities, "capabilities", key, idx)?;
260 validate_string_vec(&provider.ops, "ops", key, idx)?;
261 Ok(())
262}
263
264fn validate_string_vec(entries: &[String], field: &str, key: &str, idx: usize) -> Result<()> {
265 if entries.is_empty() {
266 bail!("extensions[{key}].inline.providers[{idx}].{field} must not be empty");
267 }
268 for (entry_idx, entry) in entries.iter().enumerate() {
269 if entry.trim().is_empty() {
270 bail!(
271 "extensions[{key}].inline.providers[{idx}].{field}[{entry_idx}] must be a non-empty string"
272 );
273 }
274 }
275 Ok(())
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use serde_json::json;
282
283 fn provider_extension_inline() -> JsonValue {
284 json!({
285 "providers": [
286 {
287 "provider_type": "messaging.telegram.bot",
288 "capabilities": ["send", "receive"],
289 "ops": ["send", "reply"],
290 "config_schema_ref": "schemas/messaging/telegram/config.schema.json",
291 "state_schema_ref": "schemas/messaging/telegram/state.schema.json",
292 "runtime": {
293 "component_ref": "telegram-provider",
294 "export": "provider",
295 "world": PROVIDER_RUNTIME_WORLD
296 },
297 "docs_ref": "schemas/messaging/telegram/README.md"
298 }
299 ]
300 })
301 }
302
303 #[test]
304 fn provider_extension_validates() {
305 let mut extensions = BTreeMap::new();
306 extensions.insert(
307 PROVIDER_EXTENSION_ID.to_string(),
308 ExtensionRef {
309 kind: PROVIDER_EXTENSION_ID.into(),
310 version: "1.0.0".into(),
311 digest: Some("sha256:abc123".into()),
312 location: None,
313 inline: Some(
314 serde_json::from_value(provider_extension_inline()).expect("inline parse"),
315 ),
316 },
317 );
318 validate_extensions(Some(&extensions), false).expect("provider extension should validate");
319 }
320
321 #[test]
322 fn provider_extension_missing_required_fields_fails() {
323 let mut extensions = BTreeMap::new();
324 extensions.insert(
325 PROVIDER_EXTENSION_ID.to_string(),
326 ExtensionRef {
327 kind: PROVIDER_EXTENSION_ID.into(),
328 version: "1.0.0".into(),
329 digest: None,
330 location: None,
331 inline: Some(
332 serde_json::from_value(json!({
333 "providers": [{
334 "provider_type": "",
335 "capabilities": [],
336 "ops": ["send"],
337 "config_schema_ref": "",
338 "state_schema_ref": "schemas/state.json",
339 "runtime": {
340 "component_ref": "",
341 "export": "",
342 "world": "greentic:provider/schema-core@1.0.0"
343 }
344 }]
345 }))
346 .expect("inline parse"),
347 ),
348 },
349 );
350 assert!(
351 validate_extensions(Some(&extensions), false).is_err(),
352 "missing fields should fail validation"
353 );
354 }
355
356 #[test]
357 fn strict_mode_requires_digest_for_remote_extension() {
358 let mut extensions = BTreeMap::new();
359 extensions.insert(
360 "greentic.ext.provider".to_string(),
361 ExtensionRef {
362 kind: PROVIDER_EXTENSION_ID.into(),
363 version: "1.0.0".into(),
364 digest: None,
365 location: Some("oci://registry/extensions/provider".into()),
366 inline: None,
367 },
368 );
369 assert!(
370 validate_extensions(Some(&extensions), true).is_err(),
371 "strict mode should require digest when location is set"
372 );
373 }
374
375 #[test]
376 fn unknown_extensions_are_allowed() {
377 let mut extensions = BTreeMap::new();
378 extensions.insert(
379 "acme.ext.logging".to_string(),
380 ExtensionRef {
381 kind: "acme.ext.logging".into(),
382 version: "0.1.0".into(),
383 digest: None,
384 location: None,
385 inline: None,
386 },
387 );
388 validate_extensions(Some(&extensions), false).expect("unknown extensions should pass");
389 }
390}