Skip to main content

greentic_start/
provider_config_envelope.rs

1//! Provider configuration envelope reader/writer for greentic-start.
2//!
3//! After B12a, `config.envelope.cbor` carries **non-secret** provider config
4//! only. Secret material is fetched from `SecretsBackend` keyed by the
5//! canonical `secrets://<env>/<tenant>/<team>/<provider>/<key>` URI; the
6//! envelope's `config` JSON no longer ships plaintext secret values.
7//! The envelope itself remains a transitional sink for non-secret runtime
8//! config until `pack-config.v1` ships.
9
10#![allow(dead_code)]
11
12use std::fs::File;
13
14use std::io::Read;
15use std::path::{Path, PathBuf};
16
17use anyhow::{Context, anyhow};
18use chrono::Utc;
19use greentic_types::cbor::canonical;
20use greentic_types::decode_pack_manifest;
21use greentic_types::schemas::common::schema_ir::SchemaIr;
22use greentic_types::schemas::component::v0_6_0::ComponentDescribe;
23use greentic_types::schemas::component::v0_6_0::{
24    ComponentOperation, ComponentRunInput, ComponentRunOutput,
25};
26use serde::{Deserialize, Serialize};
27use serde_json::{Value as JsonValue, json};
28use zip::ZipArchive;
29
30use crate::runtime_state::atomic_write;
31
32pub const ABI_VERSION: &str = "greentic:component@0.6.0";
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct ConfigEnvelope {
36    pub config: JsonValue,
37    pub component_id: String,
38    pub abi_version: String,
39    pub resolved_digest: String,
40    pub describe_hash: String,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub schema_hash: Option<String>,
43    pub operation_id: String,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub updated_at: Option<String>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct ContractCacheEntry {
50    pub component_id: String,
51    pub abi_version: String,
52    pub resolved_digest: String,
53    pub describe_hash: String,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub schema_hash: Option<String>,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub config_schema: Option<JsonValue>,
58}
59
60struct PackProvenance {
61    component_id: String,
62    resolved_digest: String,
63    describe_hash: String,
64    schema_hash: Option<String>,
65    config_schema: Option<JsonValue>,
66}
67
68pub fn write_provider_config_envelope(
69    providers_root: &Path,
70    provider_id: &str,
71    operation_id: &str,
72    config: &JsonValue,
73    pack_path: &Path,
74    backup: bool,
75) -> anyhow::Result<PathBuf> {
76    let provenance = read_pack_provenance(pack_path, provider_id)?;
77    let _ = write_contract_cache_entry(providers_root, &provenance);
78    let envelope = ConfigEnvelope {
79        config: config.clone(),
80        component_id: provenance.component_id,
81        abi_version: ABI_VERSION.to_string(),
82        resolved_digest: provenance.resolved_digest,
83        describe_hash: provenance.describe_hash,
84        schema_hash: provenance.schema_hash,
85        operation_id: operation_id.to_string(),
86        // Useful for audit/debug; exclude from deterministic comparisons.
87        updated_at: Some(Utc::now().to_rfc3339()),
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(
105    providers_root: &Path,
106    provider_id: &str,
107) -> anyhow::Result<Option<ConfigEnvelope>> {
108    let path = providers_root
109        .join(provider_id)
110        .join("config.envelope.cbor");
111    if !path.exists() {
112        return Ok(None);
113    }
114    let bytes =
115        std::fs::read(&path).with_context(|| format!("failed to read {}", path.display()))?;
116    let envelope: ConfigEnvelope = canonical::from_cbor(&bytes)
117        .map_err(|err| anyhow!("decode envelope at {}: {err}", path.display()))?;
118    Ok(Some(envelope))
119}
120
121/// Read the `ConfigEnvelope` for a single provider, decoding from canonical CBOR.
122///
123/// Returns `Err` when the envelope file is absent (unlike `read_provider_config_envelope`
124/// which returns `Ok(None)`).  Use this when the provider config is expected to exist.
125pub fn require_provider_config_envelope(
126    providers_root: &Path,
127    provider_id: &str,
128) -> anyhow::Result<ConfigEnvelope> {
129    let path = providers_root
130        .join(provider_id)
131        .join("config.envelope.cbor");
132    let bytes = std::fs::read(&path).with_context(|| {
133        format!(
134            "provider config envelope not found for {provider_id} at {}",
135            path.display()
136        )
137    })?;
138    let envelope: ConfigEnvelope = canonical::from_cbor(&bytes)
139        .map_err(|err| anyhow!("decode envelope at {}: {err}", path.display()))?;
140    Ok(envelope)
141}
142
143pub fn resolved_describe_hash(
144    pack_path: &Path,
145    fallback_component_id: &str,
146) -> anyhow::Result<String> {
147    Ok(read_pack_provenance(pack_path, fallback_component_id)?.describe_hash)
148}
149
150pub fn ensure_contract_compatible(
151    providers_root: &Path,
152    provider_id: &str,
153    flow_id: &str,
154    pack_path: &Path,
155    allow_contract_change: bool,
156) -> anyhow::Result<()> {
157    let Some(stored) = read_provider_config_envelope(providers_root, provider_id)? else {
158        return Ok(());
159    };
160    let resolved = resolved_describe_hash(pack_path, provider_id)?;
161    if stored.describe_hash != resolved && !allow_contract_change {
162        return Err(anyhow!(
163            "OP_CONTRACT_DRIFT: provider={} flow={} stored_describe_hash={} resolved_describe_hash={} (pass --allow-contract-change to override)",
164            provider_id,
165            flow_id,
166            stored.describe_hash,
167            resolved
168        ));
169    }
170    Ok(())
171}
172
173fn write_contract_cache_entry(
174    providers_root: &Path,
175    provenance: &PackProvenance,
176) -> anyhow::Result<PathBuf> {
177    let cache_dir = providers_root.join("_contracts");
178    let path = cache_dir.join(format!("{}.contract.cbor", provenance.resolved_digest));
179    let entry = ContractCacheEntry {
180        component_id: provenance.component_id.clone(),
181        abi_version: ABI_VERSION.to_string(),
182        resolved_digest: provenance.resolved_digest.clone(),
183        describe_hash: provenance.describe_hash.clone(),
184        schema_hash: provenance.schema_hash.clone(),
185        config_schema: provenance.config_schema.clone(),
186    };
187    let bytes = canonical::to_canonical_cbor(&entry).map_err(|err| anyhow!("{err}"))?;
188    atomic_write(&path, &bytes)?;
189    Ok(path)
190}
191
192fn read_pack_provenance(
193    pack_path: &Path,
194    fallback_component_id: &str,
195) -> anyhow::Result<PackProvenance> {
196    let pack_bytes = std::fs::read(pack_path).unwrap_or_default();
197    let resolved_digest = digest_hex(&pack_bytes);
198    let manifest_bytes = read_manifest_cbor_bytes(pack_path).ok();
199    let manifest = manifest_bytes
200        .as_ref()
201        .and_then(|bytes| decode_pack_manifest(bytes).ok());
202
203    let Some(manifest) = manifest else {
204        return Ok(PackProvenance {
205            component_id: fallback_component_id.to_string(),
206            resolved_digest,
207            describe_hash: digest_hex(fallback_component_id.as_bytes()),
208            schema_hash: None,
209            config_schema: None,
210        });
211    };
212
213    let component = manifest.components.first();
214    let component_id = component
215        .map(|value| value.id.to_string())
216        .unwrap_or_else(|| fallback_component_id.to_string());
217
218    let describe = ComponentDescribe {
219        info: greentic_types::schemas::component::v0_6_0::ComponentInfo {
220            id: component_id.clone(),
221            version: component
222                .map(|value| value.version.to_string())
223                .unwrap_or_else(|| "0.0.0".to_string()),
224            role: "provider".to_string(),
225            display_name: None,
226        },
227        provided_capabilities: Vec::new(),
228        required_capabilities: Vec::new(),
229        metadata: Default::default(),
230        operations: component
231            .map(|value| {
232                value
233                    .operations
234                    .iter()
235                    .map(|op| ComponentOperation {
236                        id: op.name.clone(),
237                        display_name: None,
238                        input: ComponentRunInput {
239                            schema: SchemaIr::Null,
240                        },
241                        output: ComponentRunOutput {
242                            schema: SchemaIr::Null,
243                        },
244                        defaults: Default::default(),
245                        redactions: Vec::new(),
246                        constraints: Default::default(),
247                        schema_hash: digest_hex(op.name.as_bytes()),
248                    })
249                    .collect::<Vec<_>>()
250            })
251            .unwrap_or_default(),
252        config_schema: SchemaIr::Null,
253    };
254    let describe_hash = hash_canonical(&describe)?;
255
256    let schema_hash = component
257        .map(|value| {
258            let schema_payload = json!({
259                "input": JsonValue::Null,
260                "output": JsonValue::Null,
261                "config": value.config_schema.clone().unwrap_or(JsonValue::Null),
262            });
263            hash_canonical(&schema_payload)
264        })
265        .transpose()?;
266
267    Ok(PackProvenance {
268        component_id,
269        resolved_digest,
270        describe_hash,
271        schema_hash,
272        config_schema: component.and_then(|value| value.config_schema.clone()),
273    })
274}
275
276fn hash_canonical<T: Serialize>(value: &T) -> anyhow::Result<String> {
277    let cbor = canonical::to_canonical_cbor(value).map_err(|err| anyhow!("{err}"))?;
278    Ok(digest_hex(&cbor))
279}
280
281fn digest_hex(bytes: &[u8]) -> String {
282    let digest = canonical::blake3_128(bytes);
283    let mut out = String::with_capacity(digest.len() * 2);
284    for byte in digest {
285        out.push(hex_nibble(byte >> 4));
286        out.push(hex_nibble(byte & 0x0f));
287    }
288    out
289}
290
291fn hex_nibble(value: u8) -> char {
292    match value {
293        0..=9 => (b'0' + value) as char,
294        10..=15 => (b'a' + (value - 10)) as char,
295        _ => '0',
296    }
297}
298
299fn read_manifest_cbor_bytes(pack_path: &Path) -> anyhow::Result<Vec<u8>> {
300    let file = File::open(pack_path)?;
301    let mut archive = ZipArchive::new(file)?;
302    let mut manifest = archive
303        .by_name("manifest.cbor")
304        .with_context(|| format!("manifest.cbor missing in {}", pack_path.display()))?;
305    let mut bytes = Vec::new();
306    manifest.read_to_end(&mut bytes)?;
307    Ok(bytes)
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use std::io::Write;
314    use tempfile::tempdir;
315    use zip::write::FileOptions;
316
317    #[test]
318    fn writes_cbor_envelope() {
319        let temp = tempdir().unwrap();
320        let pack = temp.path().join("provider.gtpack");
321        write_test_pack(&pack).unwrap();
322
323        let providers_root = temp
324            .path()
325            .join("state")
326            .join("runtime")
327            .join("demo")
328            .join("providers");
329        let path = write_provider_config_envelope(
330            &providers_root,
331            "messaging-telegram",
332            "setup_default",
333            &json!({"token":"abc"}),
334            &pack,
335            false,
336        )
337        .unwrap();
338
339        assert!(path.ends_with("messaging-telegram/config.envelope.cbor"));
340        let bytes = std::fs::read(path).unwrap();
341        let decoded: ConfigEnvelope = greentic_types::cbor::canonical::from_cbor(&bytes).unwrap();
342        assert_eq!(decoded.component_id, "messaging-telegram");
343        assert_eq!(decoded.operation_id, "setup_default");
344        assert_eq!(decoded.abi_version, ABI_VERSION);
345        assert!(decoded.updated_at.is_some());
346        assert_eq!(decoded.config, json!({"token":"abc"}));
347        assert!(!decoded.describe_hash.is_empty());
348        assert!(!decoded.resolved_digest.is_empty());
349        let contracts = providers_root.join("_contracts");
350        assert!(contracts.exists());
351    }
352
353    #[test]
354    fn reports_contract_drift_without_override() {
355        let temp = tempdir().unwrap();
356        let pack = temp.path().join("provider.gtpack");
357        write_test_pack(&pack).unwrap();
358        let providers_root = temp
359            .path()
360            .join("state")
361            .join("runtime")
362            .join("demo")
363            .join("providers");
364        let provider_id = "messaging-telegram";
365        let provider_dir = providers_root.join(provider_id);
366        std::fs::create_dir_all(&provider_dir).unwrap();
367        let envelope = ConfigEnvelope {
368            config: json!({"token":"abc"}),
369            component_id: provider_id.to_string(),
370            abi_version: ABI_VERSION.to_string(),
371            resolved_digest: "digest".to_string(),
372            describe_hash: "different".to_string(),
373            schema_hash: None,
374            operation_id: "setup_default".to_string(),
375            updated_at: None,
376        };
377        let bytes = canonical::to_canonical_cbor(&envelope).unwrap();
378        std::fs::write(provider_dir.join("config.envelope.cbor"), bytes).unwrap();
379
380        let err =
381            ensure_contract_compatible(&providers_root, provider_id, "setup_default", &pack, false)
382                .unwrap_err();
383        assert!(err.to_string().contains("OP_CONTRACT_DRIFT"));
384    }
385
386    fn write_test_pack(path: &Path) -> anyhow::Result<()> {
387        let file = File::create(path)?;
388        let mut zip = zip::ZipWriter::new(file);
389        zip.start_file("manifest.cbor", FileOptions::<()>::default())?;
390        let manifest = json!({
391            "schema_version": "1.0.0",
392            "pack_id": "messaging-telegram",
393            "name": "messaging-telegram",
394            "version": "1.0.0",
395            "kind": "provider",
396            "publisher": "tests",
397            "components": [{
398                "id": "messaging-telegram",
399                "version": "1.0.0",
400                "supports": ["provider"],
401                "world": "greentic:component/component-v0-v6-v0@0.6.0",
402                "profiles": {},
403                "capabilities": { "provides": ["messaging"], "requires": [] },
404                "configurators": null,
405                "operations": [],
406                "config_schema": {"type":"object"},
407                "resources": {},
408                "dev_flows": {}
409            }],
410            "flows": [],
411            "dependencies": [],
412            "capabilities": [],
413            "secret_requirements": [],
414            "signatures": [],
415            "extensions": {}
416        });
417        let bytes = greentic_types::cbor::canonical::to_canonical_cbor(&manifest)
418            .map_err(|err| anyhow!("{err}"))?;
419        zip.write_all(&bytes)?;
420        zip.finish()?;
421        Ok(())
422    }
423}
424
425#[cfg(test)]
426mod read_envelope_tests {
427    use super::*;
428    use serde_json::json;
429    use tempfile::tempdir;
430
431    #[test]
432    fn read_envelope_roundtrip() {
433        let dir = tempdir().unwrap();
434        let providers_root = dir.path().join("providers");
435        std::fs::create_dir_all(&providers_root).unwrap();
436
437        // Write a minimal envelope by hand using canonical CBOR.
438        let envelope = ConfigEnvelope {
439            config: json!({"url": "redis://example:6379"}),
440            component_id: "state-redis".into(),
441            abi_version: ABI_VERSION.to_string(),
442            resolved_digest: "sha256:0".into(),
443            describe_hash: "h".into(),
444            schema_hash: None,
445            operation_id: "configure".into(),
446            updated_at: None,
447        };
448        let bytes = greentic_types::cbor::canonical::to_canonical_cbor(&envelope).unwrap();
449        // Canonical on-disk path: <providers_root>/<provider_id>/config.envelope.cbor
450        let path = providers_root
451            .join("state-redis")
452            .join("config.envelope.cbor");
453        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
454        std::fs::write(&path, bytes).unwrap();
455
456        let read = require_provider_config_envelope(&providers_root, "state-redis").expect("read");
457        assert_eq!(
458            read.config.get("url").and_then(|v| v.as_str()),
459            Some("redis://example:6379")
460        );
461    }
462
463    #[test]
464    fn read_envelope_missing_provider_errors() {
465        let dir = tempdir().unwrap();
466        let providers_root = dir.path().join("providers");
467        std::fs::create_dir_all(&providers_root).unwrap();
468        let err = require_provider_config_envelope(&providers_root, "state-redis").unwrap_err();
469        assert!(format!("{err:#}").contains("state-redis"));
470    }
471}