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