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}