1use std::fs::File;
7use std::io::Read as _;
8use std::path::{Path, PathBuf};
9
10use anyhow::{Context, anyhow};
11use greentic_types::cbor::canonical;
12use greentic_types::decode_pack_manifest;
13use greentic_types::schemas::common::schema_ir::SchemaIr;
14use greentic_types::schemas::component::v0_6_0::{
15 ComponentDescribe, ComponentInfo, ComponentOperation, ComponentRunInput, ComponentRunOutput,
16};
17use serde::{Deserialize, Serialize};
18use serde_json::{Value as JsonValue, json};
19use zip::ZipArchive;
20
21const ABI_VERSION: &str = "greentic:component@0.6.0";
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ConfigEnvelope {
26 pub config: JsonValue,
27 pub component_id: String,
28 pub abi_version: String,
29 pub resolved_digest: String,
30 pub describe_hash: String,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub schema_hash: Option<String>,
33 pub operation_id: String,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub updated_at: Option<String>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct ContractCacheEntry {
41 pub component_id: String,
42 pub abi_version: String,
43 pub resolved_digest: String,
44 pub describe_hash: String,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub schema_hash: Option<String>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub config_schema: Option<JsonValue>,
49}
50
51struct PackProvenance {
52 component_id: String,
53 resolved_digest: String,
54 describe_hash: String,
55 schema_hash: Option<String>,
56 config_schema: Option<JsonValue>,
57}
58
59pub fn write_provider_config_envelope(
64 providers_root: &Path,
65 provider_id: &str,
66 operation_id: &str,
67 config: &JsonValue,
68 pack_path: &Path,
69 backup: bool,
70) -> anyhow::Result<PathBuf> {
71 let provenance = read_pack_provenance(pack_path, provider_id)?;
72 let _ = write_contract_cache_entry(providers_root, &provenance);
73 let envelope = ConfigEnvelope {
74 config: config.clone(),
75 component_id: provenance.component_id,
76 abi_version: ABI_VERSION.to_string(),
77 resolved_digest: provenance.resolved_digest,
78 describe_hash: provenance.describe_hash,
79 schema_hash: provenance.schema_hash,
80 operation_id: operation_id.to_string(),
81 updated_at: None,
82 };
83 let bytes = canonical::to_canonical_cbor(&envelope).map_err(|err| anyhow!("{err}"))?;
84 let path = providers_root
85 .join(provider_id)
86 .join("config.envelope.cbor");
87 if backup && path.exists() {
88 let backup_path = path.with_extension("cbor.bak");
89 if let Some(parent) = backup_path.parent() {
90 std::fs::create_dir_all(parent)?;
91 }
92 std::fs::copy(&path, &backup_path)?;
93 }
94 atomic_write(&path, &bytes)?;
95 Ok(path)
96}
97
98pub fn read_provider_config_envelope(
100 providers_root: &Path,
101 provider_id: &str,
102) -> anyhow::Result<Option<ConfigEnvelope>> {
103 let path = providers_root
104 .join(provider_id)
105 .join("config.envelope.cbor");
106 if !path.exists() {
107 return Ok(None);
108 }
109 let bytes = std::fs::read(&path)?;
110 let envelope: ConfigEnvelope = serde_cbor::from_slice(&bytes)?;
111 Ok(Some(envelope))
112}
113
114pub fn resolved_describe_hash(
116 pack_path: &Path,
117 fallback_component_id: &str,
118) -> anyhow::Result<String> {
119 Ok(read_pack_provenance(pack_path, fallback_component_id)?.describe_hash)
120}
121
122pub fn ensure_contract_compatible(
127 providers_root: &Path,
128 provider_id: &str,
129 flow_id: &str,
130 pack_path: &Path,
131 allow_contract_change: bool,
132) -> anyhow::Result<()> {
133 let Some(stored) = read_provider_config_envelope(providers_root, provider_id)? else {
134 return Ok(());
135 };
136 let resolved = resolved_describe_hash(pack_path, provider_id)?;
137 if stored.describe_hash != resolved && !allow_contract_change {
138 return Err(anyhow!(
139 "OP_CONTRACT_DRIFT: provider={provider_id} flow={flow_id} stored_describe_hash={} resolved_describe_hash={resolved} (pass --allow-contract-change to override)",
140 stored.describe_hash,
141 ));
142 }
143 Ok(())
144}
145
146fn write_contract_cache_entry(
149 providers_root: &Path,
150 provenance: &PackProvenance,
151) -> anyhow::Result<PathBuf> {
152 let cache_dir = providers_root.join("_contracts");
153 let path = cache_dir.join(format!("{}.contract.cbor", provenance.resolved_digest));
154 let entry = ContractCacheEntry {
155 component_id: provenance.component_id.clone(),
156 abi_version: ABI_VERSION.to_string(),
157 resolved_digest: provenance.resolved_digest.clone(),
158 describe_hash: provenance.describe_hash.clone(),
159 schema_hash: provenance.schema_hash.clone(),
160 config_schema: provenance.config_schema.clone(),
161 };
162 let bytes = canonical::to_canonical_cbor(&entry).map_err(|err| anyhow!("{err}"))?;
163 atomic_write(&path, &bytes)?;
164 Ok(path)
165}
166
167fn read_pack_provenance(
168 pack_path: &Path,
169 fallback_component_id: &str,
170) -> anyhow::Result<PackProvenance> {
171 let pack_bytes = std::fs::read(pack_path).unwrap_or_default();
172 let resolved_digest = digest_hex(&pack_bytes);
173 let manifest_bytes = read_manifest_cbor_bytes(pack_path).ok();
174 let manifest = manifest_bytes
175 .as_ref()
176 .and_then(|bytes| decode_pack_manifest(bytes).ok());
177
178 let Some(manifest) = manifest else {
179 return Ok(PackProvenance {
180 component_id: fallback_component_id.to_string(),
181 resolved_digest,
182 describe_hash: digest_hex(fallback_component_id.as_bytes()),
183 schema_hash: None,
184 config_schema: None,
185 });
186 };
187
188 let component = manifest.components.first();
189 let component_id = component
190 .map(|value| value.id.to_string())
191 .unwrap_or_else(|| fallback_component_id.to_string());
192
193 let describe = ComponentDescribe {
194 info: ComponentInfo {
195 id: component_id.clone(),
196 version: component
197 .map(|value| value.version.to_string())
198 .unwrap_or_else(|| "0.0.0".to_string()),
199 role: "provider".to_string(),
200 display_name: None,
201 },
202 provided_capabilities: Vec::new(),
203 required_capabilities: Vec::new(),
204 metadata: Default::default(),
205 operations: component
206 .map(|value| {
207 value
208 .operations
209 .iter()
210 .map(|op| ComponentOperation {
211 id: op.name.clone(),
212 display_name: None,
213 input: ComponentRunInput {
214 schema: SchemaIr::Null,
215 },
216 output: ComponentRunOutput {
217 schema: SchemaIr::Null,
218 },
219 defaults: Default::default(),
220 redactions: Vec::new(),
221 constraints: Default::default(),
222 schema_hash: digest_hex(op.name.as_bytes()),
223 })
224 .collect::<Vec<_>>()
225 })
226 .unwrap_or_default(),
227 config_schema: SchemaIr::Null,
228 };
229 let describe_hash = hash_canonical(&describe)?;
230
231 let schema_hash = component
232 .map(|value| {
233 let schema_payload = json!({
234 "input": JsonValue::Null,
235 "output": JsonValue::Null,
236 "config": value.config_schema.clone().unwrap_or(JsonValue::Null),
237 });
238 hash_canonical(&schema_payload)
239 })
240 .transpose()?;
241
242 Ok(PackProvenance {
243 component_id,
244 resolved_digest,
245 describe_hash,
246 schema_hash,
247 config_schema: component.and_then(|value| value.config_schema.clone()),
248 })
249}
250
251fn hash_canonical<T: Serialize>(value: &T) -> anyhow::Result<String> {
252 let cbor = canonical::to_canonical_cbor(value).map_err(|err| anyhow!("{err}"))?;
253 Ok(digest_hex(&cbor))
254}
255
256fn digest_hex(bytes: &[u8]) -> String {
257 let digest = canonical::blake3_128(bytes);
258 let mut out = String::with_capacity(digest.len() * 2);
259 for byte in digest {
260 out.push(hex_nibble(byte >> 4));
261 out.push(hex_nibble(byte & 0x0f));
262 }
263 out
264}
265
266fn hex_nibble(value: u8) -> char {
267 match value {
268 0..=9 => (b'0' + value) as char,
269 10..=15 => (b'a' + (value - 10)) as char,
270 _ => '0',
271 }
272}
273
274fn read_manifest_cbor_bytes(pack_path: &Path) -> anyhow::Result<Vec<u8>> {
275 let file = File::open(pack_path)?;
276 let mut archive = ZipArchive::new(file)?;
277 let mut manifest = archive
278 .by_name("manifest.cbor")
279 .with_context(|| format!("manifest.cbor missing in {}", pack_path.display()))?;
280 let mut bytes = Vec::new();
281 manifest.read_to_end(&mut bytes)?;
282 Ok(bytes)
283}
284
285pub fn atomic_write(path: &Path, bytes: &[u8]) -> anyhow::Result<()> {
287 if let Some(parent) = path.parent() {
288 std::fs::create_dir_all(parent)?;
289 }
290 let tmp = path.with_extension("tmp");
291 std::fs::write(&tmp, bytes)?;
292 std::fs::rename(&tmp, path)?;
293 Ok(())
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use std::io::Write;
300 use zip::write::FileOptions;
301
302 #[test]
303 fn writes_and_reads_cbor_envelope() {
304 let temp = tempfile::tempdir().unwrap();
305 let pack = temp.path().join("provider.gtpack");
306 write_test_pack(&pack).unwrap();
307
308 let providers_root = temp.path().join("providers");
309 let path = write_provider_config_envelope(
310 &providers_root,
311 "messaging-telegram",
312 "setup_default",
313 &json!({"token": "abc"}),
314 &pack,
315 false,
316 )
317 .unwrap();
318
319 assert!(path.ends_with("messaging-telegram/config.envelope.cbor"));
320 let envelope = read_provider_config_envelope(&providers_root, "messaging-telegram")
321 .unwrap()
322 .unwrap();
323 assert_eq!(envelope.component_id, "messaging-telegram");
324 assert_eq!(envelope.operation_id, "setup_default");
325 assert_eq!(envelope.config, json!({"token": "abc"}));
326 }
327
328 #[test]
329 fn contract_drift_detected() {
330 let temp = tempfile::tempdir().unwrap();
331 let pack = temp.path().join("provider.gtpack");
332 write_test_pack(&pack).unwrap();
333 let providers_root = temp.path().join("providers");
334 let provider_dir = providers_root.join("messaging-telegram");
335 std::fs::create_dir_all(&provider_dir).unwrap();
336
337 let envelope = ConfigEnvelope {
338 config: json!({}),
339 component_id: "messaging-telegram".into(),
340 abi_version: ABI_VERSION.into(),
341 resolved_digest: "digest".into(),
342 describe_hash: "different".into(),
343 schema_hash: None,
344 operation_id: "setup_default".into(),
345 updated_at: None,
346 };
347 let bytes = canonical::to_canonical_cbor(&envelope).unwrap();
348 std::fs::write(provider_dir.join("config.envelope.cbor"), bytes).unwrap();
349
350 let err = ensure_contract_compatible(
351 &providers_root,
352 "messaging-telegram",
353 "setup_default",
354 &pack,
355 false,
356 )
357 .unwrap_err();
358 assert!(err.to_string().contains("OP_CONTRACT_DRIFT"));
359 }
360
361 fn write_test_pack(path: &Path) -> anyhow::Result<()> {
362 let file = File::create(path)?;
363 let mut zip = zip::ZipWriter::new(file);
364 zip.start_file("manifest.cbor", FileOptions::<()>::default())?;
365 let manifest = json!({
366 "schema_version": "1.0.0",
367 "pack_id": "messaging-telegram",
368 "name": "messaging-telegram",
369 "version": "1.0.0",
370 "kind": "provider",
371 "publisher": "tests",
372 "components": [{
373 "id": "messaging-telegram",
374 "version": "1.0.0",
375 "supports": ["provider"],
376 "world": "greentic:component/component-v0-v6-v0@0.6.0",
377 "profiles": {},
378 "capabilities": { "provides": ["messaging"], "requires": [] },
379 "configurators": null,
380 "operations": [],
381 "config_schema": {"type":"object"},
382 "resources": {},
383 "dev_flows": {}
384 }],
385 "flows": [],
386 "dependencies": [],
387 "capabilities": [],
388 "secret_requirements": [],
389 "signatures": [],
390 "extensions": {}
391 });
392 let bytes = canonical::to_canonical_cbor(&manifest).map_err(|err| anyhow!("{err}"))?;
393 zip.write_all(&bytes)?;
394 zip.finish()?;
395 Ok(())
396 }
397}