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        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
285/// Atomically write bytes to a file (write to temp, then rename).
286pub 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}