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//!
6//! After B12a, the envelope's `config` carries **non-secret** values and
7//! `secrets://<env>/<tenant>/<team>/<provider>/<key>` URI references for
8//! secret-marked keys (no plaintext). Consumers dereference the references via
9//! `SecretsManager`. The envelope itself remains a transitional config sink
10//! until `pack-config.v1` ships.
11
12use 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/// A CBOR-encoded configuration envelope written alongside a provider.
30#[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/// Cached contract entry for a component version.
45#[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
65/// Write a provider config envelope to `{providers_root}/{provider_id}/config.envelope.cbor`.
66///
67/// Reads pack provenance (component ID, digest, hashes) from the pack manifest,
68/// then serializes the config + metadata as canonical CBOR.
69pub 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
104/// Read a provider config envelope from disk.
105pub 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
120/// Resolve the describe hash for a pack's component.
121pub 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
128/// Verify that the stored config envelope is compatible with the current pack.
129///
130/// Returns an error if the `describe_hash` has changed (contract drift)
131/// unless `allow_contract_change` is set.
132pub 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
152// ── Internal helpers ─────────────────────────────────────────────────
153
154fn 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
291/// Atomically write bytes to a file (write to temp, then rename).
292pub 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}