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