use anyhow::{Context, Result, bail};
use greentic_pack::static_routes::{
STATIC_ROUTES_EXTENSION_KEY, StaticRoutesExtensionV1, parse_static_routes_extension,
validate_static_routes_payload,
};
use greentic_types::pack::extensions::capabilities::CapabilitiesExtensionV1;
use greentic_types::pack_manifest::{ExtensionInline, ExtensionRef};
use serde::{Deserialize, Serialize};
use serde_json::{Map as JsonMap, Value as JsonValue};
use std::collections::BTreeMap;
use std::path::Path;
pub const COMPONENTS_EXTENSION_KEY: &str = "greentic.components";
pub const CAPABILITIES_EXTENSION_KEY: &str = "greentic.ext.capabilities.v1";
pub const DEPLOYER_EXTENSION_KEY: &str = "greentic.deployer.v1";
#[derive(Debug, Clone)]
pub struct ComponentsExtension {
pub refs: Vec<String>,
pub mode: Option<String>,
pub allow_tags: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeployerExtension {
pub version: u64,
pub provides: Vec<DeployerProvide>,
#[serde(default)]
pub flow_refs: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeployerProvide {
pub capability: String,
pub contract: String,
#[serde(default)]
pub ops: Vec<String>,
}
pub fn validate_capabilities_extension(
extensions: &Option<BTreeMap<String, ExtensionRef>>,
pack_root: &Path,
known_component_ids: &[String],
) -> Result<Option<CapabilitiesExtensionV1>> {
let Some(ext) = extensions
.as_ref()
.and_then(|all| all.get(CAPABILITIES_EXTENSION_KEY))
else {
return Ok(None);
};
let inline = ext.inline.as_ref().ok_or_else(|| {
anyhow::anyhow!("extensions[{CAPABILITIES_EXTENSION_KEY}] inline is required")
})?;
let value = match inline {
ExtensionInline::Other(value) => value,
_ => {
bail!("extensions[{CAPABILITIES_EXTENSION_KEY}] inline must be an object");
}
};
let payload = CapabilitiesExtensionV1::from_extension_value(value)
.map_err(|err| anyhow::anyhow!("invalid capabilities extension payload: {err}"))?;
for offer in &payload.offers {
if offer.offer_id.trim().is_empty() {
bail!("extensions[{CAPABILITIES_EXTENSION_KEY}] offer_id must not be empty");
}
if offer.cap_id.trim().is_empty() {
bail!(
"extensions[{CAPABILITIES_EXTENSION_KEY}] offer `{}` cap_id must not be empty",
offer.offer_id
);
}
if offer.version.trim().is_empty() {
bail!(
"extensions[{CAPABILITIES_EXTENSION_KEY}] offer `{}` version must not be empty",
offer.offer_id
);
}
if offer.provider.component_ref.trim().is_empty() {
bail!(
"extensions[{CAPABILITIES_EXTENSION_KEY}] offer `{}` provider.component_ref must not be empty",
offer.offer_id
);
}
if offer.provider.op.trim().is_empty() {
bail!(
"extensions[{CAPABILITIES_EXTENSION_KEY}] offer `{}` provider.op must not be empty",
offer.offer_id
);
}
if !known_component_ids.contains(&offer.provider.component_ref) {
bail!(
"extensions[{CAPABILITIES_EXTENSION_KEY}] offer `{}` references unknown provider.component_ref `{}`",
offer.offer_id,
offer.provider.component_ref
);
}
if !offer.requires_setup {
continue;
}
let Some(setup) = offer.setup.as_ref() else {
bail!(
"extensions[{CAPABILITIES_EXTENSION_KEY}] offer `{}` requires setup but setup is missing",
offer.offer_id
);
};
let qa_path = pack_root.join(&setup.qa_ref);
if !qa_path.exists() {
bail!(
"extensions[{CAPABILITIES_EXTENSION_KEY}] offer `{}` references missing qa_ref {}",
offer.offer_id,
setup.qa_ref
);
}
}
Ok(Some(payload))
}
pub fn validate_components_extension(
extensions: &Option<BTreeMap<String, ExtensionRef>>,
allow_tags: bool,
) -> Result<Option<ComponentsExtension>> {
let Some(ext) = extensions
.as_ref()
.and_then(|all| all.get(COMPONENTS_EXTENSION_KEY))
else {
return Ok(None);
};
let payload = ext.inline.as_ref().ok_or_else(|| {
anyhow::anyhow!("extensions[{COMPONENTS_EXTENSION_KEY}] inline is required")
})?;
let payload = match payload {
ExtensionInline::Other(value) => value.clone(),
other => serde_json::to_value(other).context("serialize inline extension")?,
};
let map = payload.as_object().cloned().ok_or_else(|| {
anyhow::anyhow!("extensions[{COMPONENTS_EXTENSION_KEY}] inline must be an object")
})?;
let refs = extract_refs(&map, allow_tags)?;
let mode = extract_mode(&map)?;
let allow_tags_inline = map.get("allow_tags").and_then(JsonValue::as_bool);
Ok(Some(ComponentsExtension {
refs,
mode,
allow_tags: allow_tags_inline,
}))
}
pub fn validate_deployer_extension(
extensions: &Option<BTreeMap<String, ExtensionRef>>,
pack_root: &Path,
) -> Result<Option<DeployerExtension>> {
let Some(ext) = extensions
.as_ref()
.and_then(|all| all.get(DEPLOYER_EXTENSION_KEY))
else {
return Ok(None);
};
let inline = ext.inline.as_ref().ok_or_else(|| {
anyhow::anyhow!("extensions[{DEPLOYER_EXTENSION_KEY}] inline is required")
})?;
let value = match inline {
ExtensionInline::Other(value) => value,
_ => {
bail!("extensions[{DEPLOYER_EXTENSION_KEY}] inline must be an object");
}
};
let payload: DeployerExtension = serde_json::from_value(value.clone())
.map_err(|err| anyhow::anyhow!("invalid deployer extension payload: {err}"))?;
if payload.version == 0 {
bail!("extensions[{DEPLOYER_EXTENSION_KEY}] version must be >= 1");
}
if payload.provides.is_empty() {
bail!("extensions[{DEPLOYER_EXTENSION_KEY}] provides must not be empty");
}
for provide in &payload.provides {
if provide.capability.trim().is_empty() {
bail!("extensions[{DEPLOYER_EXTENSION_KEY}] provide.capability must not be empty");
}
if provide.contract.trim().is_empty() {
bail!("extensions[{DEPLOYER_EXTENSION_KEY}] provide.contract must not be empty");
}
if provide.ops.is_empty() {
bail!(
"extensions[{DEPLOYER_EXTENSION_KEY}] provide `{}` must declare at least one op",
provide.contract
);
}
for op in &provide.ops {
if op.trim().is_empty() {
bail!(
"extensions[{DEPLOYER_EXTENSION_KEY}] provide `{}` contains an empty op",
provide.contract
);
}
if let Some(flow_ref) = payload.flow_refs.get(op) {
let flow_path = pack_root.join(flow_ref);
if !flow_path.exists() {
bail!(
"extensions[{DEPLOYER_EXTENSION_KEY}] op `{}` references missing flow {}",
op,
flow_ref
);
}
}
}
}
Ok(Some(payload))
}
pub fn validate_static_routes_extension(
extensions: &Option<BTreeMap<String, ExtensionRef>>,
pack_root: &Path,
) -> Result<Option<StaticRoutesExtensionV1>> {
let Some(payload) = parse_static_routes_extension(extensions)? else {
return Ok(None);
};
validate_static_routes_payload(&payload, |logical| {
let path = pack_root.join(logical);
if path.is_file() || path.is_dir() {
return true;
}
std::fs::read_dir(&path).is_ok()
})
.map_err(|err| anyhow::anyhow!("extensions[{STATIC_ROUTES_EXTENSION_KEY}] invalid: {err}"))?;
Ok(Some(payload))
}
fn extract_refs(map: &JsonMap<String, JsonValue>, allow_tags: bool) -> Result<Vec<String>> {
let refs = map.get("refs").ok_or_else(|| {
anyhow::anyhow!("extensions[{COMPONENTS_EXTENSION_KEY}] inline.refs is required")
})?;
let arr = refs.as_array().ok_or_else(|| {
anyhow::anyhow!("extensions[{COMPONENTS_EXTENSION_KEY}] inline.refs must be an array")
})?;
let mut result = Vec::new();
for value in arr {
let reference = value.as_str().ok_or_else(|| {
anyhow::anyhow!(
"extensions[{COMPONENTS_EXTENSION_KEY}] inline.refs entries must be strings"
)
})?;
validate_oci_ref(reference, allow_tags)?;
result.push(reference.to_string());
}
Ok(result)
}
fn extract_mode(map: &JsonMap<String, JsonValue>) -> Result<Option<String>> {
let Some(mode) = map.get("mode") else {
return Ok(None);
};
let Some(mode_str) = mode.as_str() else {
bail!("extensions[{COMPONENTS_EXTENSION_KEY}] inline.mode must be a string when present");
};
match mode_str {
"eager" | "lazy" => Ok(Some(mode_str.to_string())),
other => bail!(
"extensions[{COMPONENTS_EXTENSION_KEY}] inline.mode must be one of [eager, lazy]; found `{other}`"
),
}
}
fn validate_oci_ref(reference: &str, allow_tags: bool) -> Result<()> {
if let Some((repo, digest)) = reference.rsplit_once('@') {
if repo.trim().is_empty() {
bail!("OCI component ref is missing a repository before the digest: `{reference}`");
}
if !digest.starts_with("sha256:") {
bail!("OCI component ref digest must start with sha256: `{reference}`");
}
let hex = &digest["sha256:".len()..];
if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
bail!("OCI component ref must include a 64-character hex sha256 digest: `{reference}`");
}
if !repo.contains('/') {
bail!("OCI component ref must include a registry/repository path: `{reference}`");
}
return Ok(());
}
let last_slash = reference.rfind('/').ok_or_else(|| {
anyhow::anyhow!("OCI component ref must include a registry/repository path: `{reference}`")
})?;
let last_colon = reference.rfind(':').ok_or_else(|| {
anyhow::anyhow!(
"OCI component ref must be digest-pinned (...@sha256:...){}",
if allow_tags {
" or include a tag (:tag)"
} else {
""
}
)
})?;
if last_colon <= last_slash {
bail!("OCI component ref must include a tag or digest: `{reference}`");
}
let tag = &reference[last_colon + 1..];
if tag.is_empty() {
bail!("OCI component ref tag must not be empty: `{reference}`");
}
if !allow_tags {
bail!(
"OCI component ref must be digest-pinned (...@sha256:...). Re-run with --allow-oci-tags to permit tags."
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::path::Path;
fn ext_with_payload(payload: JsonValue) -> BTreeMap<String, ExtensionRef> {
let mut map = BTreeMap::new();
map.insert(
COMPONENTS_EXTENSION_KEY.to_string(),
ExtensionRef {
kind: COMPONENTS_EXTENSION_KEY.to_string(),
version: "v1".to_string(),
digest: None,
location: None,
inline: Some(ExtensionInline::Other(payload)),
},
);
map
}
#[test]
fn digest_refs_are_allowed_by_default() {
let extensions = ext_with_payload(json!({
"refs": ["ghcr.io/org/demo@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"]
}));
validate_components_extension(&Some(extensions), false).expect("digest ok");
}
#[test]
fn tag_refs_are_rejected_by_default() {
let extensions = ext_with_payload(json!({
"refs": ["ghcr.io/org/demo:latest"]
}));
let err = validate_components_extension(&Some(extensions), false).unwrap_err();
assert!(
err.to_string().contains("digest-pinned"),
"unexpected error: {err}"
);
}
#[test]
fn tag_refs_are_allowed_with_flag() {
let extensions = ext_with_payload(json!({
"refs": ["ghcr.io/org/demo:latest"]
}));
validate_components_extension(&Some(extensions), true).expect("tag allowed");
}
#[test]
fn invalid_refs_are_rejected() {
let extensions = ext_with_payload(json!({
"refs": ["not-an-oci-ref"]
}));
assert!(validate_components_extension(&Some(extensions), true).is_err());
}
fn capability_ext_with_payload(payload: JsonValue) -> BTreeMap<String, ExtensionRef> {
let mut map = BTreeMap::new();
map.insert(
CAPABILITIES_EXTENSION_KEY.to_string(),
ExtensionRef {
kind: CAPABILITIES_EXTENSION_KEY.to_string(),
version: "v1".to_string(),
digest: None,
location: None,
inline: Some(ExtensionInline::Other(payload)),
},
);
map
}
fn deployer_ext_with_payload(payload: JsonValue) -> BTreeMap<String, ExtensionRef> {
let mut map = BTreeMap::new();
map.insert(
DEPLOYER_EXTENSION_KEY.to_string(),
ExtensionRef {
kind: DEPLOYER_EXTENSION_KEY.to_string(),
version: "1.0.0".to_string(),
digest: None,
location: None,
inline: Some(ExtensionInline::Other(payload)),
},
);
map
}
#[test]
fn capabilities_requires_setup_must_include_setup_block() {
let extensions = capability_ext_with_payload(json!({
"schema_version": 1,
"offers": [{
"offer_id": "o1",
"cap_id": "greentic.cap.memory.shortterm",
"version": "v1",
"provider": { "component_ref": "memory.provider", "op": "cap.invoke" },
"requires_setup": true
}]
}));
let err = validate_capabilities_extension(
&Some(extensions),
Path::new("."),
&["memory.provider".to_string()],
)
.expect_err("missing setup should fail");
assert!(
err.to_string()
.contains("requires setup but setup is missing"),
"unexpected error: {err}"
);
}
#[test]
fn capabilities_provider_component_must_exist() {
let extensions = capability_ext_with_payload(json!({
"schema_version": 1,
"offers": [{
"offer_id": "o1",
"cap_id": "greentic.cap.memory.shortterm",
"version": "v1",
"provider": { "component_ref": "missing.component", "op": "cap.invoke" },
"requires_setup": false
}]
}));
let err = validate_capabilities_extension(&Some(extensions), Path::new("."), &[])
.expect_err("unknown provider component must fail");
assert!(
err.to_string()
.contains("references unknown provider.component_ref"),
"unexpected error: {err}"
);
}
#[test]
fn capabilities_provider_op_is_required() {
let extensions = capability_ext_with_payload(json!({
"schema_version": 1,
"offers": [{
"offer_id": "o1",
"cap_id": "greentic.cap.memory.shortterm",
"version": "v1",
"provider": { "component_ref": "memory.provider" },
"requires_setup": false
}]
}));
let err = validate_capabilities_extension(
&Some(extensions),
Path::new("."),
&["memory.provider".to_string()],
)
.expect_err("missing provider.op must fail");
assert!(
err.to_string()
.contains("invalid capabilities extension payload"),
"unexpected error: {err}"
);
}
#[test]
fn deployer_extension_accepts_generic_payload() {
let temp = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(temp.path().join("flows")).expect("flows dir");
std::fs::write(temp.path().join("flows/generate.ygtc"), "id: generate\n")
.expect("write flow");
let extensions = deployer_ext_with_payload(json!({
"version": 1,
"provides": [{
"capability": "greentic.deployer.v1",
"contract": "greentic.deployer.v1",
"ops": ["generate"]
}],
"flow_refs": {
"generate": "flows/generate.ygtc"
}
}));
let payload = validate_deployer_extension(&Some(extensions), temp.path())
.expect("payload should validate")
.expect("payload should exist");
assert_eq!(payload.version, 1);
assert_eq!(payload.provides[0].ops, vec!["generate".to_string()]);
}
#[test]
fn deployer_extension_rejects_missing_declared_flow() {
let temp = tempfile::tempdir().expect("tempdir");
let extensions = deployer_ext_with_payload(json!({
"version": 1,
"provides": [{
"capability": "greentic.deployer.v1",
"contract": "greentic.deployer.v1",
"ops": ["generate"]
}],
"flow_refs": {
"generate": "flows/generate.ygtc"
}
}));
let err = validate_deployer_extension(&Some(extensions), temp.path())
.expect_err("missing flow should fail");
assert!(
err.to_string()
.contains("references missing flow flows/generate.ygtc")
);
}
fn static_routes_ext_with_payload(payload: JsonValue) -> BTreeMap<String, ExtensionRef> {
let mut map = BTreeMap::new();
map.insert(
STATIC_ROUTES_EXTENSION_KEY.to_string(),
ExtensionRef {
kind: STATIC_ROUTES_EXTENSION_KEY.to_string(),
version: "1.0.0".to_string(),
digest: None,
location: None,
inline: Some(ExtensionInline::Other(payload)),
},
);
map
}
#[test]
fn static_routes_extension_validates() {
let temp = tempfile::tempdir().expect("tempdir");
let assets_dir = temp.path().join("assets").join("webchat-gui");
std::fs::create_dir_all(&assets_dir).expect("assets dir");
std::fs::write(assets_dir.join("index.html"), "<html/>").expect("write index");
let extensions = static_routes_ext_with_payload(json!({
"version": 1,
"routes": [{
"id": "webchat-gui",
"public_path": "/v1/web/webchat/{tenant}",
"source_root": "assets/webchat-gui",
"scope": { "tenant": true, "team": false },
"index_file": "index.html",
"spa_fallback": "index.html",
"cache": {
"strategy": "public-max-age",
"max_age_seconds": 3600
},
"exports": {
"base_url": "webchat_gui_base_url",
"entry_url": "webchat_gui_entry_url"
}
}]
}));
validate_static_routes_extension(&Some(extensions), temp.path())
.expect("static routes extension should validate");
}
#[test]
fn static_routes_extension_rejects_invalid_scope() {
let temp = tempfile::tempdir().expect("tempdir");
let assets_dir = temp.path().join("assets").join("webchat-gui");
std::fs::create_dir_all(&assets_dir).expect("assets dir");
let extensions = static_routes_ext_with_payload(json!({
"version": 1,
"routes": [{
"id": "webchat-gui",
"public_path": "/v1/web/webchat/{team}",
"source_root": "assets/webchat-gui",
"scope": { "tenant": false, "team": true }
}]
}));
let err = validate_static_routes_extension(&Some(extensions), temp.path()).unwrap_err();
assert!(err.to_string().contains("scope.team=true"));
}
}