1use 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#[derive(Default)]
40pub struct RuntimeOptions {
41 pub key_file: Option<PathBuf>,
43 pub key_b64: Option<String>,
45 pub environment: Option<String>,
47}
48
49#[derive(Debug, Error)]
51pub enum RuntimeError {
52 #[error("failed to read config key file {path}: {source}")]
54 KeyFileRead {
55 path: PathBuf,
56 #[source]
57 source: std::io::Error,
58 },
59 #[error("SMOO_CONFIG_KEY is not valid base64: {0}")]
61 InvalidKeyBase64(#[from] base64::DecodeError),
62 #[error("SMOO_CONFIG_KEY must decode to 32 bytes (got {0})")]
64 InvalidKeyLength(usize),
65 #[error("smoo-config blob too short ({0} bytes)")]
67 BlobTooShort(usize),
68 #[error("aes-gcm decryption failed (wrong key or tampered blob)")]
71 Decrypt,
72 #[error("failed to parse decrypted config JSON: {0}")]
74 ParseJson(#[from] serde_json::Error),
75 #[error("failed to seed ConfigManager: {0}")]
77 Seed(String),
78}
79
80pub 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#[derive(Debug, Default, Clone)]
111pub struct BakedConfig {
112 pub public: HashMap<String, Value>,
113 pub secret: HashMap<String, Value>,
114}
115
116impl BakedConfig {
117 pub fn len(&self) -> usize {
119 self.public.len() + self.secret.len()
120 }
121
122 pub fn is_empty(&self) -> bool {
124 self.public.is_empty() && self.secret.is_empty()
125 }
126
127 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 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
171pub 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 }
200 }
201
202 Ok(manager)
203}
204
205pub 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 async fn bake_fixture(values: serde_json::Value, classify: Option<Classifier>) -> (String, Vec<u8>) {
232 let mock_server = MockServer::start().await;
233
234 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 #[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 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 #[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 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 #[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 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 #[tokio::test]
364 async fn missing_env_falls_back_gracefully() {
365 let prev_file = env::var_os("SMOO_CONFIG_KEY_FILE");
369 let prev_key = env::var_os("SMOO_CONFIG_KEY");
370 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 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 #[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 #[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 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 #[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 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 assert_eq!(manager.get_feature_flag("newFlow").unwrap(), None);
473 }
474
475 #[tokio::test]
477 async fn blob_too_short_errors() {
478 let dir = tempfile::tempdir().unwrap();
479 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 #[tokio::test]
497 async fn read_baked_config_returns_none_without_env() {
498 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}