1use std::collections::BTreeMap;
2use std::fs;
3use std::io::Read;
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result};
7use greentic_types::pack_manifest::{ExtensionInline, ExtensionRef, PackManifest};
8use serde::{Deserialize, Serialize};
9
10use crate::path_safety::normalize_under_root;
11
12pub const INGRESS_EXTENSION_ID: &str = "messaging.provider_ingress.v1";
13pub const OAUTH_EXTENSION_ID: &str = "messaging.oauth.v1";
14pub const SUBSCRIPTIONS_EXTENSION_ID: &str = "messaging.subscriptions.v1";
15
16#[derive(Clone, Debug, Deserialize, Serialize)]
17pub struct RuntimeRef {
18 pub component_ref: String,
19 pub export: String,
20 pub world: String,
21}
22
23#[derive(Clone, Debug, Default, Deserialize, Serialize)]
24pub struct IngressCapabilities {
25 #[serde(default)]
26 pub supports_webhook_validation: bool,
27 #[serde(default)]
28 pub content_types: Vec<String>,
29}
30
31#[derive(Clone, Debug, Deserialize, Serialize)]
32pub struct IngressProviderDecl {
33 pub runtime: RuntimeRef,
34 #[serde(default)]
35 pub capabilities: IngressCapabilities,
36}
37
38#[derive(Clone, Debug, Deserialize, Serialize)]
39pub struct OAuthProviderDecl {
40 pub provider: String,
41 #[serde(default)]
42 pub scopes: Vec<String>,
43 #[serde(default)]
44 pub resource: Option<String>,
45 #[serde(default)]
46 pub prompt: Option<String>,
47 #[serde(default)]
48 pub redirect_path: Option<String>,
49}
50
51#[derive(Clone, Debug, Deserialize, Serialize)]
52pub struct SubscriptionsProviderDecl {
53 pub runtime: RuntimeRef,
54 #[serde(default)]
55 pub resources: Vec<String>,
56 #[serde(default)]
57 pub renewal_window_hours: Option<u32>,
58}
59
60#[derive(Clone, Debug, Default)]
61pub struct ProviderExtensionsRegistry {
62 pub ingress: BTreeMap<String, IngressProviderDecl>,
63 pub oauth: BTreeMap<String, OAuthProviderDecl>,
64 pub subscriptions: BTreeMap<String, SubscriptionsProviderDecl>,
65}
66
67impl ProviderExtensionsRegistry {
68 pub fn is_empty(&self) -> bool {
69 self.ingress.is_empty() && self.oauth.is_empty() && self.subscriptions.is_empty()
70 }
71}
72
73#[derive(Debug, Deserialize)]
74struct PackSpecExtensions {
75 #[allow(dead_code)]
76 id: String,
77 #[allow(dead_code)]
78 version: String,
79 #[serde(default)]
80 extensions: Option<BTreeMap<String, ExtensionRef>>,
81}
82
83#[derive(Debug, Deserialize)]
84struct IngressPayload {
85 #[serde(flatten)]
86 providers: BTreeMap<String, IngressProviderDecl>,
87}
88
89#[derive(Debug, Deserialize)]
90struct OAuthPayload {
91 #[serde(flatten)]
92 providers: BTreeMap<String, OAuthProviderDecl>,
93}
94
95#[derive(Debug, Deserialize)]
96struct SubscriptionsPayload {
97 #[serde(flatten)]
98 providers: BTreeMap<String, SubscriptionsProviderDecl>,
99}
100
101pub fn load_provider_extensions_from_pack_files(
102 root: &Path,
103 paths: &[PathBuf],
104) -> Result<ProviderExtensionsRegistry> {
105 let root = root
106 .canonicalize()
107 .with_context(|| format!("failed to canonicalize packs root {}", root.display()))?;
108 let mut registry = ProviderExtensionsRegistry::default();
109 for path in paths {
110 let extensions = extensions_from_pack_file(&root, path)
111 .with_context(|| format!("failed to read pack extensions from {}", path.display()))?;
112 merge_registry(&mut registry, extensions);
113 }
114 Ok(registry)
115}
116
117fn merge_registry(target: &mut ProviderExtensionsRegistry, incoming: ProviderExtensionsRegistry) {
118 target.ingress.extend(incoming.ingress);
119 target.oauth.extend(incoming.oauth);
120 target.subscriptions.extend(incoming.subscriptions);
121}
122
123fn extensions_from_pack_file(root: &Path, path: &Path) -> Result<ProviderExtensionsRegistry> {
124 let safe_path = if path.is_absolute() {
125 path.canonicalize()
126 .with_context(|| format!("failed to canonicalize {}", path.display()))?
127 } else {
128 normalize_under_root(root, path)?
129 };
130 let ext = safe_path
131 .extension()
132 .and_then(|s| s.to_str())
133 .map(|s| s.to_ascii_lowercase());
134 match ext.as_deref() {
135 Some("gtpack") => extensions_from_gtpack(&safe_path),
136 _ => extensions_from_pack_yaml(&safe_path),
137 }
138}
139
140fn extensions_from_pack_yaml(path: &Path) -> Result<ProviderExtensionsRegistry> {
141 let raw = fs::read_to_string(path)
142 .with_context(|| format!("failed to read pack file {}", path.display()))?;
143 let spec: PackSpecExtensions = serde_yaml_bw::from_str(&raw)
144 .with_context(|| format!("{} is not a valid pack spec", path.display()))?;
145 let mut registry = ProviderExtensionsRegistry::default();
146 apply_extensions(&mut registry, spec.extensions.as_ref());
147 Ok(registry)
148}
149
150fn extensions_from_gtpack(path: &Path) -> Result<ProviderExtensionsRegistry> {
151 let manifest = decode_pack_manifest(path).with_context(|| {
152 format!(
153 "failed to decode manifest.cbor (extensions are required) from {}",
154 path.display()
155 )
156 })?;
157 let mut registry = ProviderExtensionsRegistry::default();
158 apply_extensions(&mut registry, manifest.extensions.as_ref());
159 Ok(registry)
160}
161
162fn decode_pack_manifest(path: &Path) -> Result<PackManifest> {
163 let file = std::fs::File::open(path)?;
164 let mut archive = zip::ZipArchive::new(file)?;
165 let mut buf = Vec::new();
166 archive.by_name("manifest.cbor")?.read_to_end(&mut buf)?;
167 greentic_types::decode_pack_manifest(&buf).context("invalid pack manifest")
168}
169
170fn apply_extensions(
171 registry: &mut ProviderExtensionsRegistry,
172 extensions: Option<&BTreeMap<String, ExtensionRef>>,
173) {
174 let Some(extensions) = extensions else {
175 return;
176 };
177 if let Some(payload) = extract_ingress(extensions) {
178 registry.ingress.extend(payload);
179 }
180 if let Some(payload) = extract_oauth(extensions) {
181 registry.oauth.extend(payload);
182 }
183 if let Some(payload) = extract_subscriptions(extensions) {
184 registry.subscriptions.extend(payload);
185 }
186}
187
188fn extract_ingress(
189 extensions: &BTreeMap<String, ExtensionRef>,
190) -> Option<BTreeMap<String, IngressProviderDecl>> {
191 let entry = extensions.get(INGRESS_EXTENSION_ID)?;
192 let inline = entry.inline.as_ref()?;
193 let ExtensionInline::Other(value) = inline else {
194 return None;
195 };
196 let payload: IngressPayload = serde_json::from_value(value.clone()).ok()?;
197 Some(payload.providers)
198}
199
200fn extract_oauth(
201 extensions: &BTreeMap<String, ExtensionRef>,
202) -> Option<BTreeMap<String, OAuthProviderDecl>> {
203 let entry = extensions.get(OAUTH_EXTENSION_ID)?;
204 let inline = entry.inline.as_ref()?;
205 let ExtensionInline::Other(value) = inline else {
206 return None;
207 };
208 let payload: OAuthPayload = serde_json::from_value(value.clone()).ok()?;
209 Some(payload.providers)
210}
211
212fn extract_subscriptions(
213 extensions: &BTreeMap<String, ExtensionRef>,
214) -> Option<BTreeMap<String, SubscriptionsProviderDecl>> {
215 let entry = extensions.get(SUBSCRIPTIONS_EXTENSION_ID)?;
216 let inline = entry.inline.as_ref()?;
217 let ExtensionInline::Other(value) = inline else {
218 return None;
219 };
220 let payload: SubscriptionsPayload = serde_json::from_value(value.clone()).ok()?;
221 Some(payload.providers)
222}