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