Skip to main content

greentic_setup/
config_envelope.rs

1//! Provider configuration envelope — CBOR-serialized config with provenance.
2//!
3//! Writes provider configuration to disk as a CBOR envelope containing the
4//! config payload, component metadata, and contract hashes for drift detection.
5
6use 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/// A CBOR-encoded configuration envelope written alongside a provider.
24#[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/// Cached contract entry for a component version.
39#[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
59/// Write a provider config envelope to `{providers_root}/{provider_id}/config.envelope.cbor`.
60///
61/// Reads pack provenance (component ID, digest, hashes) from the pack manifest,
62/// then serializes the config + metadata as canonical CBOR.
63pub 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
98/// Read a provider config envelope from disk.
99pub 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
114/// Resolve the describe hash for a pack's component.
115pub 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
122/// Verify that the stored config envelope is compatible with the current pack.
123///
124/// Returns an error if the `describe_hash` has changed (contract drift)
125/// unless `allow_contract_change` is set.
126pub 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
146// ── Internal helpers ─────────────────────────────────────────────────
147
148fn 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        // greentic-types 1.2.0-research added `outcomes` to ComponentDescribe
228        // (flow-builder component outcomes). This synthesised provider describe
229        // declares none — outcomes are only meaningful for flow-node components.
230        outcomes: Default::default(),
231        config_schema: SchemaIr::Null,
232    };
233    let describe_hash = hash_canonical(&describe)?;
234
235    let schema_hash = component
236        .map(|value| {
237            let schema_payload = json!({
238                "input": JsonValue::Null,
239                "output": JsonValue::Null,
240                "config": value.config_schema.clone().unwrap_or(JsonValue::Null),
241            });
242            hash_canonical(&schema_payload)
243        })
244        .transpose()?;
245
246    Ok(PackProvenance {
247        component_id,
248        resolved_digest,
249        describe_hash,
250        schema_hash,
251        config_schema: component.and_then(|value| value.config_schema.clone()),
252    })
253}
254
255fn hash_canonical<T: Serialize>(value: &T) -> anyhow::Result<String> {
256    let cbor = canonical::to_canonical_cbor(value).map_err(|err| anyhow!("{err}"))?;
257    Ok(digest_hex(&cbor))
258}
259
260fn digest_hex(bytes: &[u8]) -> String {
261    let digest = canonical::blake3_128(bytes);
262    let mut out = String::with_capacity(digest.len() * 2);
263    for byte in digest {
264        out.push(hex_nibble(byte >> 4));
265        out.push(hex_nibble(byte & 0x0f));
266    }
267    out
268}
269
270fn hex_nibble(value: u8) -> char {
271    match value {
272        0..=9 => (b'0' + value) as char,
273        10..=15 => (b'a' + (value - 10)) as char,
274        _ => '0',
275    }
276}
277
278fn read_manifest_cbor_bytes(pack_path: &Path) -> anyhow::Result<Vec<u8>> {
279    let file = File::open(pack_path)?;
280    let mut archive = ZipArchive::new(file)?;
281    let mut manifest = archive
282        .by_name("manifest.cbor")
283        .with_context(|| format!("manifest.cbor missing in {}", pack_path.display()))?;
284    let mut bytes = Vec::new();
285    manifest.read_to_end(&mut bytes)?;
286    Ok(bytes)
287}
288
289/// Atomically write bytes to a file (write to temp, then rename).
290pub fn atomic_write(path: &Path, bytes: &[u8]) -> anyhow::Result<()> {
291    if let Some(parent) = path.parent() {
292        std::fs::create_dir_all(parent)?;
293    }
294    let tmp = path.with_extension("tmp");
295    std::fs::write(&tmp, bytes)?;
296    std::fs::rename(&tmp, path)?;
297    Ok(())
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use std::io::Write;
304    use zip::write::FileOptions;
305
306    #[test]
307    fn writes_and_reads_cbor_envelope() {
308        let temp = tempfile::tempdir().unwrap();
309        let pack = temp.path().join("provider.gtpack");
310        write_test_pack(&pack).unwrap();
311
312        let providers_root = temp.path().join("providers");
313        let path = write_provider_config_envelope(
314            &providers_root,
315            "messaging-telegram",
316            "setup_default",
317            &json!({"token": "abc"}),
318            &pack,
319            false,
320        )
321        .unwrap();
322
323        assert!(path.ends_with("messaging-telegram/config.envelope.cbor"));
324        let envelope = read_provider_config_envelope(&providers_root, "messaging-telegram")
325            .unwrap()
326            .unwrap();
327        assert_eq!(envelope.component_id, "messaging-telegram");
328        assert_eq!(envelope.operation_id, "setup_default");
329        assert_eq!(envelope.config, json!({"token": "abc"}));
330    }
331
332    #[test]
333    fn contract_drift_detected() {
334        let temp = tempfile::tempdir().unwrap();
335        let pack = temp.path().join("provider.gtpack");
336        write_test_pack(&pack).unwrap();
337        let providers_root = temp.path().join("providers");
338        let provider_dir = providers_root.join("messaging-telegram");
339        std::fs::create_dir_all(&provider_dir).unwrap();
340
341        let envelope = ConfigEnvelope {
342            config: json!({}),
343            component_id: "messaging-telegram".into(),
344            abi_version: ABI_VERSION.into(),
345            resolved_digest: "digest".into(),
346            describe_hash: "different".into(),
347            schema_hash: None,
348            operation_id: "setup_default".into(),
349            updated_at: None,
350        };
351        let bytes = canonical::to_canonical_cbor(&envelope).unwrap();
352        std::fs::write(provider_dir.join("config.envelope.cbor"), bytes).unwrap();
353
354        let err = ensure_contract_compatible(
355            &providers_root,
356            "messaging-telegram",
357            "setup_default",
358            &pack,
359            false,
360        )
361        .unwrap_err();
362        assert!(err.to_string().contains("OP_CONTRACT_DRIFT"));
363    }
364
365    fn write_test_pack(path: &Path) -> anyhow::Result<()> {
366        let file = File::create(path)?;
367        let mut zip = zip::ZipWriter::new(file);
368        zip.start_file("manifest.cbor", FileOptions::<()>::default())?;
369        let manifest = json!({
370            "schema_version": "1.0.0",
371            "pack_id": "messaging-telegram",
372            "name": "messaging-telegram",
373            "version": "1.0.0",
374            "kind": "provider",
375            "publisher": "tests",
376            "components": [{
377                "id": "messaging-telegram",
378                "version": "1.0.0",
379                "supports": ["provider"],
380                "world": "greentic:component/component-v0-v6-v0@0.6.0",
381                "profiles": {},
382                "capabilities": { "provides": ["messaging"], "requires": [] },
383                "configurators": null,
384                "operations": [],
385                "config_schema": {"type":"object"},
386                "resources": {},
387                "dev_flows": {}
388            }],
389            "flows": [],
390            "dependencies": [],
391            "capabilities": [],
392            "secret_requirements": [],
393            "signatures": [],
394            "extensions": {}
395        });
396        let bytes = canonical::to_canonical_cbor(&manifest).map_err(|err| anyhow!("{err}"))?;
397        zip.write_all(&bytes)?;
398        zip.finish()?;
399        Ok(())
400    }
401}