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(
35 default,
36 skip_serializing_if = "Option::is_none",
37 deserialize_with = "deserialize_extensions"
38 )]
39 pub extensions: Option<BTreeMap<String, ExtensionRef>>,
40}
41
42#[derive(Debug, Clone, Deserialize, Serialize)]
43struct RawExtensionRef {
44 pub kind: String,
45 pub version: String,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub digest: Option<String>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub location: Option<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub inline: Option<JsonValue>,
52}
53
54#[derive(Debug, Clone, Deserialize, Serialize)]
55pub struct ComponentConfig {
56 pub id: String,
57 pub version: String,
58 pub world: String,
59 #[serde(default)]
60 pub supports: Vec<FlowKindLabel>,
61 pub profiles: ComponentProfiles,
62 pub capabilities: ComponentCapabilities,
63 pub wasm: PathBuf,
64 #[serde(default, skip_serializing_if = "Vec::is_empty")]
65 pub operations: Vec<ComponentOperationConfig>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub config_schema: Option<JsonValue>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub resources: Option<ResourceHints>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub configurators: Option<ComponentConfiguratorConfig>,
72}
73
74#[derive(Debug, Clone, Deserialize, Serialize)]
75pub struct ComponentOperationConfig {
76 pub name: String,
77 pub input_schema: JsonValue,
78 pub output_schema: JsonValue,
79}
80
81#[derive(Debug, Clone, Deserialize, Serialize)]
82pub struct FlowConfig {
83 pub id: String,
84 pub file: PathBuf,
85 #[serde(default)]
86 pub tags: Vec<String>,
87 #[serde(default)]
88 pub entrypoints: Vec<String>,
89}
90
91#[derive(Debug, Clone, Deserialize, Serialize)]
92pub struct DependencyConfig {
93 pub alias: String,
94 pub pack_id: String,
95 pub version_req: String,
96 #[serde(default)]
97 pub required_capabilities: Vec<String>,
98}
99
100#[derive(Debug, Clone, Deserialize, Serialize)]
101pub struct AssetConfig {
102 pub path: PathBuf,
103}
104
105#[derive(Debug, Clone, Deserialize, Serialize)]
106pub struct BootstrapConfig {
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub install_flow: Option<String>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub upgrade_flow: Option<String>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub installer_component: Option<String>,
113}
114
115#[derive(Debug, Clone, Deserialize, Serialize)]
116pub struct ComponentConfiguratorConfig {
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub basic: Option<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub full: Option<String>,
121}
122
123#[derive(Debug, Clone, Deserialize, Serialize)]
124#[serde(rename_all = "lowercase")]
125pub enum FlowKindLabel {
126 Messaging,
127 Event,
128 #[serde(
129 rename = "componentconfig",
130 alias = "component-config",
131 alias = "component_config"
132 )]
133 ComponentConfig,
134 Job,
135 Http,
136}
137
138impl FlowKindLabel {
139 pub fn to_kind(&self) -> FlowKind {
140 match self {
141 FlowKindLabel::Messaging => FlowKind::Messaging,
142 FlowKindLabel::Event => FlowKind::Event,
143 FlowKindLabel::ComponentConfig => FlowKind::ComponentConfig,
144 FlowKindLabel::Job => FlowKind::Job,
145 FlowKindLabel::Http => FlowKind::Http,
146 }
147 }
148}
149
150fn deserialize_extensions<'de, D>(
151 deserializer: D,
152) -> std::result::Result<Option<BTreeMap<String, ExtensionRef>>, D::Error>
153where
154 D: serde::Deserializer<'de>,
155{
156 let raw = Option::<BTreeMap<String, RawExtensionRef>>::deserialize(deserializer)?;
157 raw.map(convert_extensions)
158 .transpose()
159 .map_err(serde::de::Error::custom)
160}
161
162fn convert_extensions(
163 raw: BTreeMap<String, RawExtensionRef>,
164) -> Result<BTreeMap<String, ExtensionRef>> {
165 raw.into_iter()
166 .map(|(key, value)| Ok((key, convert_extension_ref(value)?)))
167 .collect()
168}
169
170fn convert_extension_ref(raw: RawExtensionRef) -> Result<ExtensionRef> {
171 let inline = raw
172 .inline
173 .map(|value| convert_extension_inline(&raw.kind, value))
174 .transpose()?;
175 Ok(ExtensionRef {
176 kind: raw.kind,
177 version: raw.version,
178 digest: raw.digest,
179 location: raw.location,
180 inline,
181 })
182}
183
184fn convert_extension_inline(kind: &str, value: JsonValue) -> Result<ExtensionInline> {
185 if kind == PROVIDER_EXTENSION_ID || kind == LEGACY_PROVIDER_EXTENSION_KIND {
186 let provider = serde_json::from_value::<ProviderExtensionInline>(value.clone())
187 .with_context(|| {
188 format!("extensions[{kind}].inline is not a valid provider extension")
189 })?;
190 return Ok(ExtensionInline::Provider(provider));
191 }
192 Ok(ExtensionInline::Other(value))
193}
194
195pub fn load_pack_config(root: &Path) -> Result<PackConfig> {
196 let manifest_path = normalize_under_root(root, Path::new("pack.yaml"))?;
197 let contents = std::fs::read_to_string(&manifest_path)
198 .with_context(|| format!("failed to read {}", manifest_path.display()))?;
199 let mut cfg: PackConfig = serde_yaml_bw::from_str(&contents)
200 .with_context(|| format!("{} is not a valid pack.yaml", manifest_path.display()))?;
201
202 for component in cfg.components.iter_mut() {
204 component.wasm = normalize_under_root(root, &component.wasm)?;
205 }
206 for flow in cfg.flows.iter_mut() {
207 flow.file = normalize_under_root(root, &flow.file)?;
208 }
209 for asset in cfg.assets.iter_mut() {
210 asset.path = normalize_under_root(root, &asset.path)?;
211 }
212
213 validate_extensions(cfg.extensions.as_ref(), strict_extensions())?;
214
215 Ok(cfg)
216}
217
218fn strict_extensions() -> bool {
219 matches!(
220 std::env::var("GREENTIC_PACK_STRICT_EXTENSIONS")
221 .unwrap_or_default()
222 .as_str(),
223 "1" | "true" | "TRUE"
224 )
225}
226
227fn validate_extensions(
228 extensions: Option<&BTreeMap<String, ExtensionRef>>,
229 strict: bool,
230) -> Result<()> {
231 let Some(exts) = extensions else {
232 return Ok(());
233 };
234
235 for (key, ext) in exts {
236 if ext.kind.trim().is_empty() {
237 bail!("extensions[{key}] kind must not be empty");
238 }
239 if ext.version.trim().is_empty() {
240 bail!("extensions[{key}] version must not be empty");
241 }
242 if ext.kind != *key {
243 bail!(
244 "extensions[{key}] kind `{}` must match the extension key",
245 ext.kind
246 );
247 }
248 if strict && let Some(location) = ext.location.as_deref() {
249 let digest_missing = ext
250 .digest
251 .as_ref()
252 .map(|d| d.trim().is_empty())
253 .unwrap_or(true);
254 if digest_missing {
255 bail!("extensions[{key}] location requires digest in strict mode");
256 }
257 let allowed = location.starts_with("oci://")
258 || location.starts_with("file://")
259 || location.starts_with("https://");
260 if !allowed {
261 bail!(
262 "extensions[{key}] location `{location}` uses an unsupported scheme; allowed: oci://, file://, https://"
263 );
264 }
265 }
266
267 if ext.kind == PROVIDER_EXTENSION_ID || ext.kind == LEGACY_PROVIDER_EXTENSION_KIND {
268 validate_provider_extension(key, ext)?;
269 }
270 }
271
272 Ok(())
273}
274
275fn validate_provider_extension(key: &str, ext: &ExtensionRef) -> Result<()> {
276 let inline = ext
277 .inline
278 .as_ref()
279 .ok_or_else(|| anyhow::anyhow!("extensions[{key}] inline payload is required"))?;
280 let providers = match inline {
281 ExtensionInline::Provider(value) => value.providers.clone(),
282 ExtensionInline::Other(value) => {
283 serde_json::from_value::<ProviderExtensionInline>(value.clone())
284 .with_context(|| {
285 format!("extensions[{key}].inline is not a valid provider extension")
286 })?
287 .providers
288 }
289 };
290 if providers.is_empty() {
291 bail!("extensions[{key}].inline.providers must not be empty");
292 }
293
294 for (idx, provider) in providers.iter().enumerate() {
295 validate_provider_decl(provider, key, idx)?;
296 }
297
298 Ok(())
299}
300
301fn validate_provider_decl(provider: &ProviderDecl, key: &str, idx: usize) -> Result<()> {
302 if provider.provider_type.trim().is_empty() {
303 bail!("extensions[{key}].inline.providers[{idx}].provider_type must not be empty");
304 }
305 if provider.config_schema_ref.trim().is_empty() {
306 bail!("extensions[{key}].inline.providers[{idx}].config_schema_ref must not be empty");
307 }
308 if provider.runtime.world != PROVIDER_RUNTIME_WORLD {
309 bail!(
310 "extensions[{key}].inline.providers[{idx}].runtime.world must be `{}`",
311 PROVIDER_RUNTIME_WORLD
312 );
313 }
314 if provider.runtime.component_ref.trim().is_empty() || provider.runtime.export.trim().is_empty()
315 {
316 bail!(
317 "extensions[{key}].inline.providers[{idx}].runtime component_ref/export must not be empty"
318 );
319 }
320 validate_string_vec(&provider.capabilities, "capabilities", key, idx)?;
321 validate_string_vec(&provider.ops, "ops", key, idx)?;
322 Ok(())
323}
324
325fn validate_string_vec(entries: &[String], field: &str, key: &str, idx: usize) -> Result<()> {
326 if entries.is_empty() {
327 bail!("extensions[{key}].inline.providers[{idx}].{field} must not be empty");
328 }
329 for (entry_idx, entry) in entries.iter().enumerate() {
330 if entry.trim().is_empty() {
331 bail!(
332 "extensions[{key}].inline.providers[{idx}].{field}[{entry_idx}] must be a non-empty string"
333 );
334 }
335 }
336 Ok(())
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use serde_json::json;
343
344 fn provider_extension_inline() -> JsonValue {
345 json!({
346 "providers": [
347 {
348 "provider_type": "messaging.telegram.bot",
349 "capabilities": ["send", "receive"],
350 "ops": ["send", "reply"],
351 "config_schema_ref": "schemas/messaging/telegram/config.schema.json",
352 "state_schema_ref": "schemas/messaging/telegram/state.schema.json",
353 "runtime": {
354 "component_ref": "telegram-provider",
355 "export": "provider",
356 "world": PROVIDER_RUNTIME_WORLD
357 },
358 "docs_ref": "schemas/messaging/telegram/README.md"
359 }
360 ]
361 })
362 }
363
364 #[test]
365 fn provider_extension_validates() {
366 let mut extensions = BTreeMap::new();
367 extensions.insert(
368 PROVIDER_EXTENSION_ID.to_string(),
369 ExtensionRef {
370 kind: PROVIDER_EXTENSION_ID.into(),
371 version: "1.0.0".into(),
372 digest: Some("sha256:abc123".into()),
373 location: None,
374 inline: Some(
375 serde_json::from_value(provider_extension_inline()).expect("inline parse"),
376 ),
377 },
378 );
379 validate_extensions(Some(&extensions), false).expect("provider extension should validate");
380 }
381
382 #[test]
383 fn provider_extension_missing_required_fields_fails() {
384 let mut extensions = BTreeMap::new();
385 extensions.insert(
386 PROVIDER_EXTENSION_ID.to_string(),
387 ExtensionRef {
388 kind: PROVIDER_EXTENSION_ID.into(),
389 version: "1.0.0".into(),
390 digest: None,
391 location: None,
392 inline: Some(
393 serde_json::from_value(json!({
394 "providers": [{
395 "provider_type": "",
396 "capabilities": [],
397 "ops": ["send"],
398 "config_schema_ref": "",
399 "state_schema_ref": "schemas/state.json",
400 "runtime": {
401 "component_ref": "",
402 "export": "",
403 "world": "greentic:provider/schema-core@1.0.0"
404 }
405 }]
406 }))
407 .expect("inline parse"),
408 ),
409 },
410 );
411 assert!(
412 validate_extensions(Some(&extensions), false).is_err(),
413 "missing fields should fail validation"
414 );
415 }
416
417 #[test]
418 fn strict_mode_requires_digest_for_remote_extension() {
419 let mut extensions = BTreeMap::new();
420 extensions.insert(
421 "greentic.ext.provider".to_string(),
422 ExtensionRef {
423 kind: PROVIDER_EXTENSION_ID.into(),
424 version: "1.0.0".into(),
425 digest: None,
426 location: Some("oci://registry/extensions/provider".into()),
427 inline: None,
428 },
429 );
430 assert!(
431 validate_extensions(Some(&extensions), true).is_err(),
432 "strict mode should require digest when location is set"
433 );
434 }
435
436 #[test]
437 fn unknown_extensions_are_allowed() {
438 let mut extensions = BTreeMap::new();
439 extensions.insert(
440 "acme.ext.logging".to_string(),
441 ExtensionRef {
442 kind: "acme.ext.logging".into(),
443 version: "0.1.0".into(),
444 digest: None,
445 location: None,
446 inline: None,
447 },
448 );
449 validate_extensions(Some(&extensions), false).expect("unknown extensions should pass");
450 }
451
452 #[test]
453 fn pack_config_preserves_unknown_inline_extension_payload() {
454 let cfg: PackConfig = serde_yaml_bw::from_str(
455 r#"pack_id: dev.local.static-routes
456version: 0.1.0
457kind: application
458publisher: Test
459extensions:
460 greentic.static-routes.v1:
461 kind: greentic.static-routes.v1
462 version: 0.4.37
463 inline:
464 version: 1
465 routes:
466 - id: webchat-gui
467 public_path: /v1/web/webchat/{tenant}
468 source_root: assets/webchat-gui
469 scope:
470 tenant: true
471 team: false
472 index_file: index.html
473 spa_fallback: index.html
474"#,
475 )
476 .expect("deserialize pack config");
477
478 let ext = cfg
479 .extensions
480 .as_ref()
481 .and_then(|extensions| extensions.get("greentic.static-routes.v1"))
482 .expect("static routes extension present");
483 assert_eq!(ext.version, "0.4.37");
484
485 let inline = match ext.inline.as_ref() {
486 Some(ExtensionInline::Other(value)) => value,
487 other => panic!("unexpected inline payload: {other:?}"),
488 };
489 assert_eq!(inline.get("version"), Some(&json!(1)));
490 assert_eq!(
491 inline
492 .get("routes")
493 .and_then(JsonValue::as_array)
494 .map(Vec::len),
495 Some(1)
496 );
497 }
498}