Skip to main content

smooai_config/
runtime.rs

1//! Bake-aware runtime hydrator for `smooai-config` (Rust parity with TypeScript/Python).
2//!
3//! Reads a pre-encrypted blob produced by [`crate::build::build_bundle`] and
4//! exposes sync accessors by seeding a [`ConfigManager`]'s merged-config map.
5//! The library API stays uniform — consumers always call
6//! `manager.get_public_config(key)` / `manager.get_secret_config(key)`
7//! regardless of whether the data came from the baked blob or a live fetch.
8//!
9//! - Public + secret values hydrate from the blob (sync, no network)
10//! - Feature flags are never baked — they stay live-fetched through the
11//!   normal [`ConfigManager`] merge pipeline when env vars are absent.
12//!
13//! Environment variables (set by the deploy pipeline):
14//!
15//! ```text
16//! SMOO_CONFIG_KEY_FILE  — absolute path to the encrypted blob on disk
17//! SMOO_CONFIG_KEY       — base64-encoded 32-byte AES-256 key
18//! ```
19//!
20//! Blob layout (matches TypeScript + Python):
21//! `nonce (12 bytes) || ciphertext || authTag (16 bytes)`.
22
23use std::collections::HashMap;
24use std::env;
25use std::fs;
26use std::path::{Path, PathBuf};
27
28use aes_gcm::aead::{Aead, KeyInit, Payload};
29use aes_gcm::{Aes256Gcm, Key, Nonce};
30use base64::engine::general_purpose::STANDARD as B64;
31use base64::Engine as _;
32use serde_json::Value;
33use thiserror::Error;
34
35use crate::config_manager::ConfigManager;
36
37/// Options for [`build_config_runtime`]. All fields optional — the function
38/// falls back to environment variables / defaults for anything left unset.
39#[derive(Default)]
40pub struct RuntimeOptions {
41    /// Override `SMOO_CONFIG_KEY_FILE` (blob path on disk).
42    pub key_file: Option<PathBuf>,
43    /// Override `SMOO_CONFIG_KEY` (base64 AES-256 key).
44    pub key_b64: Option<String>,
45    /// Override the `ConfigManager`'s environment name (e.g. `production`).
46    pub environment: Option<String>,
47}
48
49/// Errors produced by [`build_config_runtime`] and related helpers.
50#[derive(Debug, Error)]
51pub enum RuntimeError {
52    /// The key file pointed to by `SMOO_CONFIG_KEY_FILE` could not be read.
53    #[error("failed to read config key file {path}: {source}")]
54    KeyFileRead {
55        path: PathBuf,
56        #[source]
57        source: std::io::Error,
58    },
59    /// `SMOO_CONFIG_KEY` was not valid base64.
60    #[error("SMOO_CONFIG_KEY is not valid base64: {0}")]
61    InvalidKeyBase64(#[from] base64::DecodeError),
62    /// `SMOO_CONFIG_KEY` decoded to something other than 32 bytes.
63    #[error("SMOO_CONFIG_KEY must decode to 32 bytes (got {0})")]
64    InvalidKeyLength(usize),
65    /// The blob is shorter than the minimum possible layout.
66    #[error("smoo-config blob too short ({0} bytes)")]
67    BlobTooShort(usize),
68    /// AES-GCM authentication / decryption failed. Either the key is wrong or
69    /// the blob has been tampered with.
70    #[error("aes-gcm decryption failed (wrong key or tampered blob)")]
71    Decrypt,
72    /// The decrypted plaintext was not valid JSON with the expected shape.
73    #[error("failed to parse decrypted config JSON: {0}")]
74    ParseJson(#[from] serde_json::Error),
75    /// Seeding the [`ConfigManager`] failed (lock poisoning).
76    #[error("failed to seed ConfigManager: {0}")]
77    Seed(String),
78}
79
80/// Decrypt a baked blob if the required env vars / overrides are present.
81///
82/// Returns `Ok(None)` when no blob is configured — the caller should fall back
83/// to a live-fetch [`ConfigManager`]. Returns `Ok(Some({public, secret}))` on
84/// success, where each inner map is the decrypted JSON section.
85pub fn read_baked_config(opts: &RuntimeOptions) -> Result<Option<BakedConfig>, RuntimeError> {
86    let key_file = opts
87        .key_file
88        .clone()
89        .or_else(|| env::var_os("SMOO_CONFIG_KEY_FILE").map(PathBuf::from));
90    let key_b64 = opts.key_b64.clone().or_else(|| env::var("SMOO_CONFIG_KEY").ok());
91
92    let (Some(key_file), Some(key_b64)) = (key_file, key_b64) else {
93        return Ok(None);
94    };
95
96    let key = B64.decode(key_b64.as_bytes())?;
97    if key.len() != 32 {
98        return Err(RuntimeError::InvalidKeyLength(key.len()));
99    }
100
101    let blob = fs::read(&key_file).map_err(|source| RuntimeError::KeyFileRead {
102        path: key_file.clone(),
103        source,
104    })?;
105
106    decrypt_blob(&key, &blob).map(Some)
107}
108
109/// Decrypted `{public, secret}` partition from a baked blob.
110#[derive(Debug, Default, Clone)]
111pub struct BakedConfig {
112    pub public: HashMap<String, Value>,
113    pub secret: HashMap<String, Value>,
114}
115
116impl BakedConfig {
117    /// Total number of baked entries (public + secret).
118    pub fn len(&self) -> usize {
119        self.public.len() + self.secret.len()
120    }
121
122    /// Whether the baked config contains zero entries.
123    pub fn is_empty(&self) -> bool {
124        self.public.is_empty() && self.secret.is_empty()
125    }
126
127    /// Merge public + secret into a single flat map. Secret keys win on
128    /// collisions, matching the TS/Python hydrator semantics.
129    pub fn into_merged(self) -> HashMap<String, Value> {
130        let mut merged = self.public;
131        for (k, v) in self.secret {
132            merged.insert(k, v);
133        }
134        merged
135    }
136}
137
138fn decrypt_blob(key: &[u8], blob: &[u8]) -> Result<BakedConfig, RuntimeError> {
139    // Minimum layout: 12-byte nonce + 16-byte tag = 28 bytes before any ciphertext.
140    if blob.len() < 12 + 16 {
141        return Err(RuntimeError::BlobTooShort(blob.len()));
142    }
143    let (nonce_bytes, ciphertext_and_tag) = blob.split_at(12);
144
145    let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
146    let nonce = Nonce::from_slice(nonce_bytes);
147    let plaintext = cipher
148        .decrypt(
149            nonce,
150            Payload {
151                msg: ciphertext_and_tag,
152                aad: &[],
153            },
154        )
155        .map_err(|_| RuntimeError::Decrypt)?;
156
157    #[derive(serde::Deserialize, Default)]
158    struct Partitioned {
159        #[serde(default)]
160        public: HashMap<String, Value>,
161        #[serde(default)]
162        secret: HashMap<String, Value>,
163    }
164    let parsed: Partitioned = serde_json::from_slice(&plaintext)?;
165    Ok(BakedConfig {
166        public: parsed.public,
167        secret: parsed.secret,
168    })
169}
170
171/// Build a bake-aware [`ConfigManager`].
172///
173/// Reads `SMOO_CONFIG_KEY_FILE` + `SMOO_CONFIG_KEY` at cold start and seeds a
174/// fresh [`ConfigManager`] with the decrypted public + secret values. If
175/// either env var is missing, returns a plain [`ConfigManager`] that lazily
176/// loads from file/env/remote on first access — preserving a graceful fallback
177/// path for local development.
178///
179/// Feature flags are never baked; the [`ConfigManager`] falls through to the
180/// live-fetch pipeline for `get_feature_flag` calls when no seeded entry
181/// exists.
182pub async fn build_config_runtime(opts: RuntimeOptions) -> Result<ConfigManager, RuntimeError> {
183    let mut manager = ConfigManager::new();
184    if let Some(env) = opts.environment.as_deref() {
185        manager = manager.with_environment(env);
186    }
187
188    match read_baked_config(&opts)? {
189        Some(baked) => {
190            let merged = baked.into_merged();
191            manager
192                .seed_from_baked(merged)
193                .map_err(|e| RuntimeError::Seed(e.to_string()))?;
194        }
195        None => {
196            // No blob configured — caller gets a live-fetch manager. Nothing
197            // else to do here; lazy init will pull from file/env/remote on
198            // first access.
199        }
200    }
201
202    Ok(manager)
203}
204
205/// Convenience: decrypt a blob from an explicit path + key, bypassing env vars.
206///
207/// Mostly useful for tests and one-off scripts. Prefer
208/// [`build_config_runtime`] in production code.
209pub fn read_baked_config_from(path: &Path, key_b64: &str) -> Result<BakedConfig, RuntimeError> {
210    let key = B64.decode(key_b64.as_bytes())?;
211    if key.len() != 32 {
212        return Err(RuntimeError::InvalidKeyLength(key.len()));
213    }
214    let blob = fs::read(path).map_err(|source| RuntimeError::KeyFileRead {
215        path: path.to_path_buf(),
216        source,
217    })?;
218    decrypt_blob(&key, &blob)
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use crate::build::{build_bundle, BuildBundleOptions, Classification, Classifier};
225    use std::io::Write;
226    use wiremock::matchers::{header, method, path_regex};
227    use wiremock::{Mock, MockServer, ResponseTemplate};
228
229    // --- Helpers ---
230
231    async fn bake_fixture(values: serde_json::Value, classify: Option<Classifier>) -> (String, Vec<u8>) {
232        let mock_server = MockServer::start().await;
233
234        // SMOODEV-975: stub the OAuth handshake — mints "baked-jwt"
235        // which the values endpoint validates against below.
236        Mock::given(method("POST"))
237            .and(path_regex(r"^/token$"))
238            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
239                "access_token": "baked-jwt",
240                "expires_in": 3600
241            })))
242            .mount(&mock_server)
243            .await;
244        Mock::given(method("GET"))
245            .and(path_regex(r"/organizations/.+/config/values"))
246            .and(header("Authorization", "Bearer baked-jwt"))
247            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
248                "values": values
249            })))
250            .mount(&mock_server)
251            .await;
252
253        let result = build_bundle(BuildBundleOptions {
254            base_url: mock_server.uri(),
255            auth_url: Some(mock_server.uri()),
256            client_id: Some("test-api-key".to_string()),
257            api_key: "test-api-key".to_string(),
258            org_id: "test-org".to_string(),
259            environment: Some("test".to_string()),
260            classify,
261        })
262        .await
263        .unwrap();
264
265        (result.key_b64, result.blob)
266    }
267
268    fn write_blob(dir: &tempfile::TempDir, blob: &[u8]) -> PathBuf {
269        let path = dir.path().join("smoo-config.enc");
270        let mut f = std::fs::File::create(&path).unwrap();
271        f.write_all(blob).unwrap();
272        path
273    }
274
275    // --- Test: round-trip bake → hydrate → retrieve ---
276    #[tokio::test]
277    async fn round_trip_bake_hydrate() {
278        let classify: Classifier = Box::new(|key, _v| match key {
279            "tavilyApiKey" => Classification::Secret,
280            _ => Classification::Public,
281        });
282
283        let (key_b64, blob) = bake_fixture(
284            serde_json::json!({
285                "apiUrl": "https://api.example.com",
286                "tavilyApiKey": "tvly-abc",
287            }),
288            Some(classify),
289        )
290        .await;
291
292        let dir = tempfile::tempdir().unwrap();
293        let blob_path = write_blob(&dir, &blob);
294
295        let manager = build_config_runtime(RuntimeOptions {
296            key_file: Some(blob_path),
297            key_b64: Some(key_b64),
298            environment: Some("test".to_string()),
299        })
300        .await
301        .unwrap();
302
303        // Public + secret both retrievable via sync accessors (no network).
304        assert_eq!(
305            manager.get_public_config("apiUrl").unwrap(),
306            Some(serde_json::json!("https://api.example.com"))
307        );
308        assert_eq!(
309            manager.get_secret_config("tavilyApiKey").unwrap(),
310            Some(serde_json::json!("tvly-abc"))
311        );
312    }
313
314    // --- Test: wrong key rejects via AES-GCM tag verification ---
315    #[tokio::test]
316    async fn wrong_key_rejects() {
317        let (_key_b64, blob) = bake_fixture(serde_json::json!({"apiUrl": "https://api.example.com"}), None).await;
318
319        let dir = tempfile::tempdir().unwrap();
320        let blob_path = write_blob(&dir, &blob);
321
322        // Random wrong key of the correct length (32 bytes).
323        let wrong_key = B64.encode([0xFFu8; 32]);
324
325        let result = build_config_runtime(RuntimeOptions {
326            key_file: Some(blob_path),
327            key_b64: Some(wrong_key),
328            environment: None,
329        })
330        .await;
331
332        match result {
333            Err(RuntimeError::Decrypt) => {}
334            other => panic!("expected Decrypt error, got: {:?}", other.err()),
335        }
336    }
337
338    // --- Test: tampered blob rejects ---
339    #[tokio::test]
340    async fn tampered_blob_rejects() {
341        let (key_b64, mut blob) = bake_fixture(serde_json::json!({"apiUrl": "https://api.example.com"}), None).await;
342
343        // Flip a byte in the ciphertext region (past the 12-byte nonce).
344        blob[20] ^= 0x01;
345
346        let dir = tempfile::tempdir().unwrap();
347        let blob_path = write_blob(&dir, &blob);
348
349        let result = build_config_runtime(RuntimeOptions {
350            key_file: Some(blob_path),
351            key_b64: Some(key_b64),
352            environment: None,
353        })
354        .await;
355
356        match result {
357            Err(RuntimeError::Decrypt) => {}
358            other => panic!("expected Decrypt error, got: {:?}", other.err()),
359        }
360    }
361
362    // --- Test: missing key file falls back gracefully (no blob loaded) ---
363    #[tokio::test]
364    async fn missing_env_falls_back_gracefully() {
365        // Both env vars unset — no override either — should succeed and
366        // return a live-fetch ConfigManager with no seeded state.
367        // Guard the real env to avoid interference from the caller's shell.
368        let prev_file = env::var_os("SMOO_CONFIG_KEY_FILE");
369        let prev_key = env::var_os("SMOO_CONFIG_KEY");
370        // SAFETY: tests in this module run single-threaded relative to these
371        // env vars. We restore the prior values at the end.
372        unsafe {
373            env::remove_var("SMOO_CONFIG_KEY_FILE");
374            env::remove_var("SMOO_CONFIG_KEY");
375        }
376
377        let result = build_config_runtime(RuntimeOptions::default()).await;
378
379        // Restore before asserting to keep failure output clean.
380        unsafe {
381            if let Some(v) = prev_file {
382                env::set_var("SMOO_CONFIG_KEY_FILE", v);
383            }
384            if let Some(v) = prev_key {
385                env::set_var("SMOO_CONFIG_KEY", v);
386            }
387        }
388
389        let _manager = result.expect("should return a live-fetch manager with no error");
390    }
391
392    // --- Test: missing key file (path does not exist) is a hard error ---
393    #[tokio::test]
394    async fn missing_key_file_path_errors() {
395        let dir = tempfile::tempdir().unwrap();
396        let nonexistent = dir.path().join("does-not-exist.enc");
397
398        let result = build_config_runtime(RuntimeOptions {
399            key_file: Some(nonexistent),
400            key_b64: Some(B64.encode([0u8; 32])),
401            environment: None,
402        })
403        .await;
404
405        match result {
406            Err(RuntimeError::KeyFileRead { .. }) => {}
407            other => panic!("expected KeyFileRead error, got: {:?}", other.err()),
408        }
409    }
410
411    // --- Test: invalid key length ---
412    #[tokio::test]
413    async fn invalid_key_length_errors() {
414        let dir = tempfile::tempdir().unwrap();
415        let blob_path = write_blob(&dir, &[0u8; 64]);
416
417        let result = build_config_runtime(RuntimeOptions {
418            key_file: Some(blob_path),
419            // 16-byte key, not 32.
420            key_b64: Some(B64.encode([0u8; 16])),
421            environment: None,
422        })
423        .await;
424
425        match result {
426            Err(RuntimeError::InvalidKeyLength(16)) => {}
427            other => panic!("expected InvalidKeyLength(16), got: {:?}", other.err()),
428        }
429    }
430
431    // --- Test: classifier skip logic — feature flags dropped from blob ---
432    #[tokio::test]
433    async fn classifier_skip_drops_feature_flags() {
434        let classify: Classifier = Box::new(|key, _v| match key {
435            "apiUrl" => Classification::Public,
436            "dbPassword" => Classification::Secret,
437            "newFlow" => Classification::Skip,
438            _ => Classification::Public,
439        });
440
441        let (key_b64, blob) = bake_fixture(
442            serde_json::json!({
443                "apiUrl": "https://api.example.com",
444                "dbPassword": "super-secret",
445                "newFlow": true,
446            }),
447            Some(classify),
448        )
449        .await;
450
451        let dir = tempfile::tempdir().unwrap();
452        let blob_path = write_blob(&dir, &blob);
453
454        let manager = build_config_runtime(RuntimeOptions {
455            key_file: Some(blob_path),
456            key_b64: Some(key_b64),
457            environment: Some("test".to_string()),
458        })
459        .await
460        .unwrap();
461
462        // Public + secret are in the seeded map.
463        assert_eq!(
464            manager.get_public_config("apiUrl").unwrap(),
465            Some(serde_json::json!("https://api.example.com"))
466        );
467        assert_eq!(
468            manager.get_secret_config("dbPassword").unwrap(),
469            Some(serde_json::json!("super-secret"))
470        );
471        // Feature flag was dropped — not in the seeded config.
472        assert_eq!(manager.get_feature_flag("newFlow").unwrap(), None);
473    }
474
475    // --- Test: blob too short ---
476    #[tokio::test]
477    async fn blob_too_short_errors() {
478        let dir = tempfile::tempdir().unwrap();
479        // Only 10 bytes — below the 28-byte minimum (12 nonce + 16 tag).
480        let blob_path = write_blob(&dir, &[0u8; 10]);
481
482        let result = build_config_runtime(RuntimeOptions {
483            key_file: Some(blob_path),
484            key_b64: Some(B64.encode([0u8; 32])),
485            environment: None,
486        })
487        .await;
488
489        match result {
490            Err(RuntimeError::BlobTooShort(10)) => {}
491            other => panic!("expected BlobTooShort(10), got: {:?}", other.err()),
492        }
493    }
494
495    // --- Test: read_baked_config returns None when opts/env both absent ---
496    #[tokio::test]
497    async fn read_baked_config_returns_none_without_env() {
498        // Guard real env to keep the test hermetic.
499        let prev_file = env::var_os("SMOO_CONFIG_KEY_FILE");
500        let prev_key = env::var_os("SMOO_CONFIG_KEY");
501        unsafe {
502            env::remove_var("SMOO_CONFIG_KEY_FILE");
503            env::remove_var("SMOO_CONFIG_KEY");
504        }
505
506        let result = read_baked_config(&RuntimeOptions::default());
507
508        unsafe {
509            if let Some(v) = prev_file {
510                env::set_var("SMOO_CONFIG_KEY_FILE", v);
511            }
512            if let Some(v) = prev_key {
513                env::set_var("SMOO_CONFIG_KEY", v);
514            }
515        }
516
517        assert!(result.unwrap().is_none());
518    }
519}