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