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