Skip to main content

packc/
extensions.rs

1use anyhow::{Context, Result, bail};
2use greentic_pack::static_routes::{
3    STATIC_ROUTES_EXTENSION_KEY, StaticRoutesExtensionV1, parse_static_routes_extension,
4    validate_static_routes_payload,
5};
6use greentic_types::pack::extensions::capabilities::CapabilitiesExtensionV1;
7use greentic_types::pack_manifest::{ExtensionInline, ExtensionRef};
8use serde::{Deserialize, Serialize};
9use serde_json::{Map as JsonMap, Value as JsonValue};
10use std::collections::BTreeMap;
11use std::path::Path;
12
13pub const COMPONENTS_EXTENSION_KEY: &str = "greentic.components";
14pub const CAPABILITIES_EXTENSION_KEY: &str = "greentic.ext.capabilities.v1";
15pub const DEPLOYER_EXTENSION_KEY: &str = "greentic.deployer.v1";
16
17#[derive(Debug, Clone)]
18pub struct ComponentsExtension {
19    pub refs: Vec<String>,
20    pub mode: Option<String>,
21    pub allow_tags: Option<bool>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct DeployerExtension {
26    pub version: u64,
27    pub provides: Vec<DeployerProvide>,
28    #[serde(default)]
29    pub flow_refs: BTreeMap<String, String>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct DeployerProvide {
34    pub capability: String,
35    pub contract: String,
36    #[serde(default)]
37    pub ops: Vec<String>,
38}
39
40pub fn validate_capabilities_extension(
41    extensions: &Option<BTreeMap<String, ExtensionRef>>,
42    pack_root: &Path,
43    known_component_ids: &[String],
44) -> Result<Option<CapabilitiesExtensionV1>> {
45    let Some(ext) = extensions
46        .as_ref()
47        .and_then(|all| all.get(CAPABILITIES_EXTENSION_KEY))
48    else {
49        return Ok(None);
50    };
51
52    let inline = ext.inline.as_ref().ok_or_else(|| {
53        anyhow::anyhow!("extensions[{CAPABILITIES_EXTENSION_KEY}] inline is required")
54    })?;
55
56    let value = match inline {
57        ExtensionInline::Other(value) => value,
58        _ => {
59            bail!("extensions[{CAPABILITIES_EXTENSION_KEY}] inline must be an object");
60        }
61    };
62
63    let payload = CapabilitiesExtensionV1::from_extension_value(value)
64        .map_err(|err| anyhow::anyhow!("invalid capabilities extension payload: {err}"))?;
65    for offer in &payload.offers {
66        if offer.offer_id.trim().is_empty() {
67            bail!("extensions[{CAPABILITIES_EXTENSION_KEY}] offer_id must not be empty");
68        }
69        if offer.cap_id.trim().is_empty() {
70            bail!(
71                "extensions[{CAPABILITIES_EXTENSION_KEY}] offer `{}` cap_id must not be empty",
72                offer.offer_id
73            );
74        }
75        if offer.version.trim().is_empty() {
76            bail!(
77                "extensions[{CAPABILITIES_EXTENSION_KEY}] offer `{}` version must not be empty",
78                offer.offer_id
79            );
80        }
81        if offer.provider.component_ref.trim().is_empty() {
82            bail!(
83                "extensions[{CAPABILITIES_EXTENSION_KEY}] offer `{}` provider.component_ref must not be empty",
84                offer.offer_id
85            );
86        }
87        if offer.provider.op.trim().is_empty() {
88            bail!(
89                "extensions[{CAPABILITIES_EXTENSION_KEY}] offer `{}` provider.op must not be empty",
90                offer.offer_id
91            );
92        }
93        if !known_component_ids.contains(&offer.provider.component_ref) {
94            bail!(
95                "extensions[{CAPABILITIES_EXTENSION_KEY}] offer `{}` references unknown provider.component_ref `{}`",
96                offer.offer_id,
97                offer.provider.component_ref
98            );
99        }
100        if !offer.requires_setup {
101            continue;
102        }
103        let Some(setup) = offer.setup.as_ref() else {
104            bail!(
105                "extensions[{CAPABILITIES_EXTENSION_KEY}] offer `{}` requires setup but setup is missing",
106                offer.offer_id
107            );
108        };
109        let qa_path = pack_root.join(&setup.qa_ref);
110        if !qa_path.exists() {
111            bail!(
112                "extensions[{CAPABILITIES_EXTENSION_KEY}] offer `{}` references missing qa_ref {}",
113                offer.offer_id,
114                setup.qa_ref
115            );
116        }
117    }
118
119    Ok(Some(payload))
120}
121
122pub fn validate_components_extension(
123    extensions: &Option<BTreeMap<String, ExtensionRef>>,
124    allow_tags: bool,
125) -> Result<Option<ComponentsExtension>> {
126    let Some(ext) = extensions
127        .as_ref()
128        .and_then(|all| all.get(COMPONENTS_EXTENSION_KEY))
129    else {
130        return Ok(None);
131    };
132
133    let payload = ext.inline.as_ref().ok_or_else(|| {
134        anyhow::anyhow!("extensions[{COMPONENTS_EXTENSION_KEY}] inline is required")
135    })?;
136
137    let payload = match payload {
138        ExtensionInline::Other(value) => value.clone(),
139        other => serde_json::to_value(other).context("serialize inline extension")?,
140    };
141
142    let map = payload.as_object().cloned().ok_or_else(|| {
143        anyhow::anyhow!("extensions[{COMPONENTS_EXTENSION_KEY}] inline must be an object")
144    })?;
145
146    let refs = extract_refs(&map, allow_tags)?;
147    let mode = extract_mode(&map)?;
148    let allow_tags_inline = map.get("allow_tags").and_then(JsonValue::as_bool);
149
150    Ok(Some(ComponentsExtension {
151        refs,
152        mode,
153        allow_tags: allow_tags_inline,
154    }))
155}
156
157pub fn validate_deployer_extension(
158    extensions: &Option<BTreeMap<String, ExtensionRef>>,
159    pack_root: &Path,
160) -> Result<Option<DeployerExtension>> {
161    let Some(ext) = extensions
162        .as_ref()
163        .and_then(|all| all.get(DEPLOYER_EXTENSION_KEY))
164    else {
165        return Ok(None);
166    };
167
168    let inline = ext.inline.as_ref().ok_or_else(|| {
169        anyhow::anyhow!("extensions[{DEPLOYER_EXTENSION_KEY}] inline is required")
170    })?;
171
172    let value = match inline {
173        ExtensionInline::Other(value) => value,
174        _ => {
175            bail!("extensions[{DEPLOYER_EXTENSION_KEY}] inline must be an object");
176        }
177    };
178
179    let payload: DeployerExtension = serde_json::from_value(value.clone())
180        .map_err(|err| anyhow::anyhow!("invalid deployer extension payload: {err}"))?;
181    if payload.version == 0 {
182        bail!("extensions[{DEPLOYER_EXTENSION_KEY}] version must be >= 1");
183    }
184    if payload.provides.is_empty() {
185        bail!("extensions[{DEPLOYER_EXTENSION_KEY}] provides must not be empty");
186    }
187    for provide in &payload.provides {
188        if provide.capability.trim().is_empty() {
189            bail!("extensions[{DEPLOYER_EXTENSION_KEY}] provide.capability must not be empty");
190        }
191        if provide.contract.trim().is_empty() {
192            bail!("extensions[{DEPLOYER_EXTENSION_KEY}] provide.contract must not be empty");
193        }
194        if provide.ops.is_empty() {
195            bail!(
196                "extensions[{DEPLOYER_EXTENSION_KEY}] provide `{}` must declare at least one op",
197                provide.contract
198            );
199        }
200        for op in &provide.ops {
201            if op.trim().is_empty() {
202                bail!(
203                    "extensions[{DEPLOYER_EXTENSION_KEY}] provide `{}` contains an empty op",
204                    provide.contract
205                );
206            }
207            if let Some(flow_ref) = payload.flow_refs.get(op) {
208                let flow_path = pack_root.join(flow_ref);
209                if !flow_path.exists() {
210                    bail!(
211                        "extensions[{DEPLOYER_EXTENSION_KEY}] op `{}` references missing flow {}",
212                        op,
213                        flow_ref
214                    );
215                }
216            }
217        }
218    }
219
220    Ok(Some(payload))
221}
222
223pub fn validate_static_routes_extension(
224    extensions: &Option<BTreeMap<String, ExtensionRef>>,
225    pack_root: &Path,
226) -> Result<Option<StaticRoutesExtensionV1>> {
227    let Some(payload) = parse_static_routes_extension(extensions)? else {
228        return Ok(None);
229    };
230
231    validate_static_routes_payload(&payload, |logical| {
232        let path = pack_root.join(logical);
233        if path.is_file() || path.is_dir() {
234            return true;
235        }
236        std::fs::read_dir(&path).is_ok()
237    })
238    .map_err(|err| anyhow::anyhow!("extensions[{STATIC_ROUTES_EXTENSION_KEY}] invalid: {err}"))?;
239
240    Ok(Some(payload))
241}
242
243fn extract_refs(map: &JsonMap<String, JsonValue>, allow_tags: bool) -> Result<Vec<String>> {
244    let refs = map.get("refs").ok_or_else(|| {
245        anyhow::anyhow!("extensions[{COMPONENTS_EXTENSION_KEY}] inline.refs is required")
246    })?;
247    let arr = refs.as_array().ok_or_else(|| {
248        anyhow::anyhow!("extensions[{COMPONENTS_EXTENSION_KEY}] inline.refs must be an array")
249    })?;
250
251    let mut result = Vec::new();
252    for value in arr {
253        let reference = value.as_str().ok_or_else(|| {
254            anyhow::anyhow!(
255                "extensions[{COMPONENTS_EXTENSION_KEY}] inline.refs entries must be strings"
256            )
257        })?;
258        validate_oci_ref(reference, allow_tags)?;
259        result.push(reference.to_string());
260    }
261    Ok(result)
262}
263
264fn extract_mode(map: &JsonMap<String, JsonValue>) -> Result<Option<String>> {
265    let Some(mode) = map.get("mode") else {
266        return Ok(None);
267    };
268    let Some(mode_str) = mode.as_str() else {
269        bail!("extensions[{COMPONENTS_EXTENSION_KEY}] inline.mode must be a string when present");
270    };
271    match mode_str {
272        "eager" | "lazy" => Ok(Some(mode_str.to_string())),
273        other => bail!(
274            "extensions[{COMPONENTS_EXTENSION_KEY}] inline.mode must be one of [eager, lazy]; found `{other}`"
275        ),
276    }
277}
278
279fn validate_oci_ref(reference: &str, allow_tags: bool) -> Result<()> {
280    if let Some((repo, digest)) = reference.rsplit_once('@') {
281        if repo.trim().is_empty() {
282            bail!("OCI component ref is missing a repository before the digest: `{reference}`");
283        }
284        if !digest.starts_with("sha256:") {
285            bail!("OCI component ref digest must start with sha256: `{reference}`");
286        }
287        let hex = &digest["sha256:".len()..];
288        if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
289            bail!("OCI component ref must include a 64-character hex sha256 digest: `{reference}`");
290        }
291        if !repo.contains('/') {
292            bail!("OCI component ref must include a registry/repository path: `{reference}`");
293        }
294        return Ok(());
295    }
296
297    let last_slash = reference.rfind('/').ok_or_else(|| {
298        anyhow::anyhow!("OCI component ref must include a registry/repository path: `{reference}`")
299    })?;
300    let last_colon = reference.rfind(':').ok_or_else(|| {
301        anyhow::anyhow!(
302            "OCI component ref must be digest-pinned (...@sha256:...){}",
303            if allow_tags {
304                " or include a tag (:tag)"
305            } else {
306                ""
307            }
308        )
309    })?;
310
311    if last_colon <= last_slash {
312        bail!("OCI component ref must include a tag or digest: `{reference}`");
313    }
314
315    let tag = &reference[last_colon + 1..];
316    if tag.is_empty() {
317        bail!("OCI component ref tag must not be empty: `{reference}`");
318    }
319    if !allow_tags {
320        bail!(
321            "OCI component ref must be digest-pinned (...@sha256:...). Re-run with --allow-oci-tags to permit tags."
322        );
323    }
324    Ok(())
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use serde_json::json;
331    use std::path::Path;
332
333    fn ext_with_payload(payload: JsonValue) -> BTreeMap<String, ExtensionRef> {
334        let mut map = BTreeMap::new();
335        map.insert(
336            COMPONENTS_EXTENSION_KEY.to_string(),
337            ExtensionRef {
338                kind: COMPONENTS_EXTENSION_KEY.to_string(),
339                version: "v1".to_string(),
340                digest: None,
341                location: None,
342                inline: Some(ExtensionInline::Other(payload)),
343            },
344        );
345        map
346    }
347
348    #[test]
349    fn digest_refs_are_allowed_by_default() {
350        let extensions = ext_with_payload(json!({
351            "refs": ["ghcr.io/org/demo@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"]
352        }));
353        validate_components_extension(&Some(extensions), false).expect("digest ok");
354    }
355
356    #[test]
357    fn tag_refs_are_rejected_by_default() {
358        let extensions = ext_with_payload(json!({
359            "refs": ["ghcr.io/org/demo:latest"]
360        }));
361        let err = validate_components_extension(&Some(extensions), false).unwrap_err();
362        assert!(
363            err.to_string().contains("digest-pinned"),
364            "unexpected error: {err}"
365        );
366    }
367
368    #[test]
369    fn tag_refs_are_allowed_with_flag() {
370        let extensions = ext_with_payload(json!({
371            "refs": ["ghcr.io/org/demo:latest"]
372        }));
373        validate_components_extension(&Some(extensions), true).expect("tag allowed");
374    }
375
376    #[test]
377    fn invalid_refs_are_rejected() {
378        let extensions = ext_with_payload(json!({
379            "refs": ["not-an-oci-ref"]
380        }));
381        assert!(validate_components_extension(&Some(extensions), true).is_err());
382    }
383
384    fn capability_ext_with_payload(payload: JsonValue) -> BTreeMap<String, ExtensionRef> {
385        let mut map = BTreeMap::new();
386        map.insert(
387            CAPABILITIES_EXTENSION_KEY.to_string(),
388            ExtensionRef {
389                kind: CAPABILITIES_EXTENSION_KEY.to_string(),
390                version: "v1".to_string(),
391                digest: None,
392                location: None,
393                inline: Some(ExtensionInline::Other(payload)),
394            },
395        );
396        map
397    }
398
399    fn deployer_ext_with_payload(payload: JsonValue) -> BTreeMap<String, ExtensionRef> {
400        let mut map = BTreeMap::new();
401        map.insert(
402            DEPLOYER_EXTENSION_KEY.to_string(),
403            ExtensionRef {
404                kind: DEPLOYER_EXTENSION_KEY.to_string(),
405                version: "1.0.0".to_string(),
406                digest: None,
407                location: None,
408                inline: Some(ExtensionInline::Other(payload)),
409            },
410        );
411        map
412    }
413
414    #[test]
415    fn capabilities_requires_setup_must_include_setup_block() {
416        let extensions = capability_ext_with_payload(json!({
417            "schema_version": 1,
418            "offers": [{
419                "offer_id": "o1",
420                "cap_id": "greentic.cap.memory.shortterm",
421                "version": "v1",
422                "provider": { "component_ref": "memory.provider", "op": "cap.invoke" },
423                "requires_setup": true
424            }]
425        }));
426        let err = validate_capabilities_extension(
427            &Some(extensions),
428            Path::new("."),
429            &["memory.provider".to_string()],
430        )
431        .expect_err("missing setup should fail");
432        assert!(
433            err.to_string()
434                .contains("requires setup but setup is missing"),
435            "unexpected error: {err}"
436        );
437    }
438
439    #[test]
440    fn capabilities_provider_component_must_exist() {
441        let extensions = capability_ext_with_payload(json!({
442            "schema_version": 1,
443            "offers": [{
444                "offer_id": "o1",
445                "cap_id": "greentic.cap.memory.shortterm",
446                "version": "v1",
447                "provider": { "component_ref": "missing.component", "op": "cap.invoke" },
448                "requires_setup": false
449            }]
450        }));
451        let err = validate_capabilities_extension(&Some(extensions), Path::new("."), &[])
452            .expect_err("unknown provider component must fail");
453        assert!(
454            err.to_string()
455                .contains("references unknown provider.component_ref"),
456            "unexpected error: {err}"
457        );
458    }
459
460    #[test]
461    fn capabilities_provider_op_is_required() {
462        let extensions = capability_ext_with_payload(json!({
463            "schema_version": 1,
464            "offers": [{
465                "offer_id": "o1",
466                "cap_id": "greentic.cap.memory.shortterm",
467                "version": "v1",
468                "provider": { "component_ref": "memory.provider" },
469                "requires_setup": false
470            }]
471        }));
472        let err = validate_capabilities_extension(
473            &Some(extensions),
474            Path::new("."),
475            &["memory.provider".to_string()],
476        )
477        .expect_err("missing provider.op must fail");
478        assert!(
479            err.to_string()
480                .contains("invalid capabilities extension payload"),
481            "unexpected error: {err}"
482        );
483    }
484
485    #[test]
486    fn deployer_extension_accepts_generic_payload() {
487        let temp = tempfile::tempdir().expect("tempdir");
488        std::fs::create_dir_all(temp.path().join("flows")).expect("flows dir");
489        std::fs::write(temp.path().join("flows/generate.ygtc"), "id: generate\n")
490            .expect("write flow");
491        let extensions = deployer_ext_with_payload(json!({
492            "version": 1,
493            "provides": [{
494                "capability": "greentic.deployer.v1",
495                "contract": "greentic.deployer.v1",
496                "ops": ["generate"]
497            }],
498            "flow_refs": {
499                "generate": "flows/generate.ygtc"
500            }
501        }));
502
503        let payload = validate_deployer_extension(&Some(extensions), temp.path())
504            .expect("payload should validate")
505            .expect("payload should exist");
506        assert_eq!(payload.version, 1);
507        assert_eq!(payload.provides[0].ops, vec!["generate".to_string()]);
508    }
509
510    #[test]
511    fn deployer_extension_rejects_missing_declared_flow() {
512        let temp = tempfile::tempdir().expect("tempdir");
513        let extensions = deployer_ext_with_payload(json!({
514            "version": 1,
515            "provides": [{
516                "capability": "greentic.deployer.v1",
517                "contract": "greentic.deployer.v1",
518                "ops": ["generate"]
519            }],
520            "flow_refs": {
521                "generate": "flows/generate.ygtc"
522            }
523        }));
524
525        let err = validate_deployer_extension(&Some(extensions), temp.path())
526            .expect_err("missing flow should fail");
527        assert!(
528            err.to_string()
529                .contains("references missing flow flows/generate.ygtc")
530        );
531    }
532
533    fn static_routes_ext_with_payload(payload: JsonValue) -> BTreeMap<String, ExtensionRef> {
534        let mut map = BTreeMap::new();
535        map.insert(
536            STATIC_ROUTES_EXTENSION_KEY.to_string(),
537            ExtensionRef {
538                kind: STATIC_ROUTES_EXTENSION_KEY.to_string(),
539                version: "1.0.0".to_string(),
540                digest: None,
541                location: None,
542                inline: Some(ExtensionInline::Other(payload)),
543            },
544        );
545        map
546    }
547
548    #[test]
549    fn static_routes_extension_validates() {
550        let temp = tempfile::tempdir().expect("tempdir");
551        let assets_dir = temp.path().join("assets").join("webchat-gui");
552        std::fs::create_dir_all(&assets_dir).expect("assets dir");
553        std::fs::write(assets_dir.join("index.html"), "<html/>").expect("write index");
554
555        let extensions = static_routes_ext_with_payload(json!({
556            "version": 1,
557            "routes": [{
558                "id": "webchat-gui",
559                "public_path": "/v1/web/webchat/{tenant}",
560                "source_root": "assets/webchat-gui",
561                "scope": { "tenant": true, "team": false },
562                "index_file": "index.html",
563                "spa_fallback": "index.html",
564                "cache": {
565                    "strategy": "public-max-age",
566                    "max_age_seconds": 3600
567                },
568                "exports": {
569                    "base_url": "webchat_gui_base_url",
570                    "entry_url": "webchat_gui_entry_url"
571                }
572            }]
573        }));
574
575        validate_static_routes_extension(&Some(extensions), temp.path())
576            .expect("static routes extension should validate");
577    }
578
579    #[test]
580    fn static_routes_extension_rejects_invalid_scope() {
581        let temp = tempfile::tempdir().expect("tempdir");
582        let assets_dir = temp.path().join("assets").join("webchat-gui");
583        std::fs::create_dir_all(&assets_dir).expect("assets dir");
584
585        let extensions = static_routes_ext_with_payload(json!({
586            "version": 1,
587            "routes": [{
588                "id": "webchat-gui",
589                "public_path": "/v1/web/webchat/{team}",
590                "source_root": "assets/webchat-gui",
591                "scope": { "tenant": false, "team": true }
592            }]
593        }));
594
595        let err = validate_static_routes_extension(&Some(extensions), temp.path()).unwrap_err();
596        assert!(err.to_string().contains("scope.team=true"));
597    }
598}