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 outcomes: Vec::new(),
235 };
236 let describe_hash = hash_canonical(&describe)?;
237
238 let schema_hash = component
239 .map(|value| {
240 let schema_payload = json!({
241 "input": JsonValue::Null,
242 "output": JsonValue::Null,
243 "config": value.config_schema.clone().unwrap_or(JsonValue::Null),
244 });
245 hash_canonical(&schema_payload)
246 })
247 .transpose()?;
248
249 Ok(PackProvenance {
250 component_id,
251 resolved_digest,
252 describe_hash,
253 schema_hash,
254 config_schema: component.and_then(|value| value.config_schema.clone()),
255 })
256}
257
258fn hash_canonical<T: Serialize>(value: &T) -> anyhow::Result<String> {
259 let cbor = canonical::to_canonical_cbor(value).map_err(|err| anyhow!("{err}"))?;
260 Ok(digest_hex(&cbor))
261}
262
263fn digest_hex(bytes: &[u8]) -> String {
264 let digest = canonical::blake3_128(bytes);
265 let mut out = String::with_capacity(digest.len() * 2);
266 for byte in digest {
267 out.push(hex_nibble(byte >> 4));
268 out.push(hex_nibble(byte & 0x0f));
269 }
270 out
271}
272
273fn hex_nibble(value: u8) -> char {
274 match value {
275 0..=9 => (b'0' + value) as char,
276 10..=15 => (b'a' + (value - 10)) as char,
277 _ => '0',
278 }
279}
280
281fn read_manifest_cbor_bytes(pack_path: &Path) -> anyhow::Result<Vec<u8>> {
282 let file = File::open(pack_path)?;
283 let mut archive = ZipArchive::new(file)?;
284 let mut manifest = archive
285 .by_name("manifest.cbor")
286 .with_context(|| format!("manifest.cbor missing in {}", pack_path.display()))?;
287 let mut bytes = Vec::new();
288 manifest.read_to_end(&mut bytes)?;
289 Ok(bytes)
290}
291
292pub fn atomic_write(path: &Path, bytes: &[u8]) -> anyhow::Result<()> {
294 if let Some(parent) = path.parent() {
295 std::fs::create_dir_all(parent)?;
296 }
297 let tmp = path.with_extension("tmp");
298 std::fs::write(&tmp, bytes)?;
299 std::fs::rename(&tmp, path)?;
300 Ok(())
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306 use std::io::Write;
307 use zip::write::FileOptions;
308
309 #[test]
310 fn writes_and_reads_cbor_envelope() {
311 let temp = tempfile::tempdir().unwrap();
312 let pack = temp.path().join("provider.gtpack");
313 write_test_pack(&pack).unwrap();
314
315 let providers_root = temp.path().join("providers");
316 let path = write_provider_config_envelope(
317 &providers_root,
318 "messaging-telegram",
319 "setup_default",
320 &json!({"token": "abc"}),
321 &pack,
322 false,
323 )
324 .unwrap();
325
326 assert!(path.ends_with("messaging-telegram/config.envelope.cbor"));
327 let envelope = read_provider_config_envelope(&providers_root, "messaging-telegram")
328 .unwrap()
329 .unwrap();
330 assert_eq!(envelope.component_id, "messaging-telegram");
331 assert_eq!(envelope.operation_id, "setup_default");
332 assert_eq!(envelope.config, json!({"token": "abc"}));
333 }
334
335 #[test]
336 fn contract_drift_detected() {
337 let temp = tempfile::tempdir().unwrap();
338 let pack = temp.path().join("provider.gtpack");
339 write_test_pack(&pack).unwrap();
340 let providers_root = temp.path().join("providers");
341 let provider_dir = providers_root.join("messaging-telegram");
342 std::fs::create_dir_all(&provider_dir).unwrap();
343
344 let envelope = ConfigEnvelope {
345 config: json!({}),
346 component_id: "messaging-telegram".into(),
347 abi_version: ABI_VERSION.into(),
348 resolved_digest: "digest".into(),
349 describe_hash: "different".into(),
350 schema_hash: None,
351 operation_id: "setup_default".into(),
352 updated_at: None,
353 };
354 let bytes = canonical::to_canonical_cbor(&envelope).unwrap();
355 std::fs::write(provider_dir.join("config.envelope.cbor"), bytes).unwrap();
356
357 let err = ensure_contract_compatible(
358 &providers_root,
359 "messaging-telegram",
360 "setup_default",
361 &pack,
362 false,
363 )
364 .unwrap_err();
365 assert!(err.to_string().contains("OP_CONTRACT_DRIFT"));
366 }
367
368 fn write_test_pack(path: &Path) -> anyhow::Result<()> {
369 let file = File::create(path)?;
370 let mut zip = zip::ZipWriter::new(file);
371 zip.start_file("manifest.cbor", FileOptions::<()>::default())?;
372 let manifest = json!({
373 "schema_version": "1.0.0",
374 "pack_id": "messaging-telegram",
375 "name": "messaging-telegram",
376 "version": "1.0.0",
377 "kind": "provider",
378 "publisher": "tests",
379 "components": [{
380 "id": "messaging-telegram",
381 "version": "1.0.0",
382 "supports": ["provider"],
383 "world": "greentic:component/component-v0-v6-v0@0.6.0",
384 "profiles": {},
385 "capabilities": { "provides": ["messaging"], "requires": [] },
386 "configurators": null,
387 "operations": [],
388 "config_schema": {"type":"object"},
389 "resources": {},
390 "dev_flows": {}
391 }],
392 "flows": [],
393 "dependencies": [],
394 "capabilities": [],
395 "secret_requirements": [],
396 "signatures": [],
397 "extensions": {}
398 });
399 let bytes = canonical::to_canonical_cbor(&manifest).map_err(|err| anyhow!("{err}"))?;
400 zip.write_all(&bytes)?;
401 zip.finish()?;
402 Ok(())
403 }
404}