use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{Aes256Gcm, Key, Nonce};
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine as _;
use serde_json::Value;
use thiserror::Error;
use crate::config_manager::ConfigManager;
#[derive(Default)]
pub struct RuntimeOptions {
pub key_file: Option<PathBuf>,
pub key_b64: Option<String>,
pub environment: Option<String>,
}
#[derive(Debug, Error)]
pub enum RuntimeError {
#[error("failed to read config key file {path}: {source}")]
KeyFileRead {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("SMOO_CONFIG_KEY is not valid base64: {0}")]
InvalidKeyBase64(#[from] base64::DecodeError),
#[error("SMOO_CONFIG_KEY must decode to 32 bytes (got {0})")]
InvalidKeyLength(usize),
#[error("smoo-config blob too short ({0} bytes)")]
BlobTooShort(usize),
#[error("aes-gcm decryption failed (wrong key or tampered blob)")]
Decrypt,
#[error("failed to parse decrypted config JSON: {0}")]
ParseJson(#[from] serde_json::Error),
#[error("failed to seed ConfigManager: {0}")]
Seed(String),
}
pub fn read_baked_config(opts: &RuntimeOptions) -> Result<Option<BakedConfig>, RuntimeError> {
let key_file = opts
.key_file
.clone()
.or_else(|| env::var_os("SMOO_CONFIG_KEY_FILE").map(PathBuf::from));
let key_b64 = opts.key_b64.clone().or_else(|| env::var("SMOO_CONFIG_KEY").ok());
let (Some(key_file), Some(key_b64)) = (key_file, key_b64) else {
return Ok(None);
};
let key = B64.decode(key_b64.as_bytes())?;
if key.len() != 32 {
return Err(RuntimeError::InvalidKeyLength(key.len()));
}
let blob = fs::read(&key_file).map_err(|source| RuntimeError::KeyFileRead {
path: key_file.clone(),
source,
})?;
decrypt_blob(&key, &blob).map(Some)
}
#[derive(Debug, Default, Clone)]
pub struct BakedConfig {
pub public: HashMap<String, Value>,
pub secret: HashMap<String, Value>,
}
impl BakedConfig {
pub fn len(&self) -> usize {
self.public.len() + self.secret.len()
}
pub fn is_empty(&self) -> bool {
self.public.is_empty() && self.secret.is_empty()
}
pub fn into_merged(self) -> HashMap<String, Value> {
let mut merged = self.public;
for (k, v) in self.secret {
merged.insert(k, v);
}
merged
}
}
fn decrypt_blob(key: &[u8], blob: &[u8]) -> Result<BakedConfig, RuntimeError> {
if blob.len() < 12 + 16 {
return Err(RuntimeError::BlobTooShort(blob.len()));
}
let (nonce_bytes, ciphertext_and_tag) = blob.split_at(12);
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
let nonce = Nonce::from_slice(nonce_bytes);
let plaintext = cipher
.decrypt(
nonce,
Payload {
msg: ciphertext_and_tag,
aad: &[],
},
)
.map_err(|_| RuntimeError::Decrypt)?;
#[derive(serde::Deserialize, Default)]
struct Partitioned {
#[serde(default)]
public: HashMap<String, Value>,
#[serde(default)]
secret: HashMap<String, Value>,
}
let parsed: Partitioned = serde_json::from_slice(&plaintext)?;
Ok(BakedConfig {
public: parsed.public,
secret: parsed.secret,
})
}
pub async fn build_config_runtime(opts: RuntimeOptions) -> Result<ConfigManager, RuntimeError> {
let mut manager = ConfigManager::new();
if let Some(env) = opts.environment.as_deref() {
manager = manager.with_environment(env);
}
match read_baked_config(&opts)? {
Some(baked) => {
let merged = baked.into_merged();
manager
.seed_from_baked(merged)
.map_err(|e| RuntimeError::Seed(e.to_string()))?;
}
None => {
}
}
Ok(manager)
}
pub fn read_baked_config_from(path: &Path, key_b64: &str) -> Result<BakedConfig, RuntimeError> {
let key = B64.decode(key_b64.as_bytes())?;
if key.len() != 32 {
return Err(RuntimeError::InvalidKeyLength(key.len()));
}
let blob = fs::read(path).map_err(|source| RuntimeError::KeyFileRead {
path: path.to_path_buf(),
source,
})?;
decrypt_blob(&key, &blob)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::build::{build_bundle, BuildBundleOptions, Classification, Classifier};
use std::io::Write;
use wiremock::matchers::{header, method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn bake_fixture(values: serde_json::Value, classify: Option<Classifier>) -> (String, Vec<u8>) {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(r"^/token$"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "baked-jwt",
"expires_in": 3600
})))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path_regex(r"/organizations/.+/config/values"))
.and(header("Authorization", "Bearer baked-jwt"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": values
})))
.mount(&mock_server)
.await;
let result = build_bundle(BuildBundleOptions {
base_url: mock_server.uri(),
auth_url: Some(mock_server.uri()),
client_id: Some("test-api-key".to_string()),
api_key: "test-api-key".to_string(),
org_id: "test-org".to_string(),
environment: Some("test".to_string()),
classify,
})
.await
.unwrap();
(result.key_b64, result.blob)
}
fn write_blob(dir: &tempfile::TempDir, blob: &[u8]) -> PathBuf {
let path = dir.path().join("smoo-config.enc");
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(blob).unwrap();
path
}
#[tokio::test]
async fn round_trip_bake_hydrate() {
let classify: Classifier = Box::new(|key, _v| match key {
"tavilyApiKey" => Classification::Secret,
_ => Classification::Public,
});
let (key_b64, blob) = bake_fixture(
serde_json::json!({
"apiUrl": "https://api.example.com",
"tavilyApiKey": "tvly-abc",
}),
Some(classify),
)
.await;
let dir = tempfile::tempdir().unwrap();
let blob_path = write_blob(&dir, &blob);
let manager = build_config_runtime(RuntimeOptions {
key_file: Some(blob_path),
key_b64: Some(key_b64),
environment: Some("test".to_string()),
})
.await
.unwrap();
assert_eq!(
manager.get_public_config("apiUrl").unwrap(),
Some(serde_json::json!("https://api.example.com"))
);
assert_eq!(
manager.get_secret_config("tavilyApiKey").unwrap(),
Some(serde_json::json!("tvly-abc"))
);
}
#[tokio::test]
async fn wrong_key_rejects() {
let (_key_b64, blob) = bake_fixture(serde_json::json!({"apiUrl": "https://api.example.com"}), None).await;
let dir = tempfile::tempdir().unwrap();
let blob_path = write_blob(&dir, &blob);
let wrong_key = B64.encode([0xFFu8; 32]);
let result = build_config_runtime(RuntimeOptions {
key_file: Some(blob_path),
key_b64: Some(wrong_key),
environment: None,
})
.await;
match result {
Err(RuntimeError::Decrypt) => {}
other => panic!("expected Decrypt error, got: {:?}", other.err()),
}
}
#[tokio::test]
async fn tampered_blob_rejects() {
let (key_b64, mut blob) = bake_fixture(serde_json::json!({"apiUrl": "https://api.example.com"}), None).await;
blob[20] ^= 0x01;
let dir = tempfile::tempdir().unwrap();
let blob_path = write_blob(&dir, &blob);
let result = build_config_runtime(RuntimeOptions {
key_file: Some(blob_path),
key_b64: Some(key_b64),
environment: None,
})
.await;
match result {
Err(RuntimeError::Decrypt) => {}
other => panic!("expected Decrypt error, got: {:?}", other.err()),
}
}
#[tokio::test]
async fn missing_env_falls_back_gracefully() {
let prev_file = env::var_os("SMOO_CONFIG_KEY_FILE");
let prev_key = env::var_os("SMOO_CONFIG_KEY");
unsafe {
env::remove_var("SMOO_CONFIG_KEY_FILE");
env::remove_var("SMOO_CONFIG_KEY");
}
let result = build_config_runtime(RuntimeOptions::default()).await;
unsafe {
if let Some(v) = prev_file {
env::set_var("SMOO_CONFIG_KEY_FILE", v);
}
if let Some(v) = prev_key {
env::set_var("SMOO_CONFIG_KEY", v);
}
}
let _manager = result.expect("should return a live-fetch manager with no error");
}
#[tokio::test]
async fn missing_key_file_path_errors() {
let dir = tempfile::tempdir().unwrap();
let nonexistent = dir.path().join("does-not-exist.enc");
let result = build_config_runtime(RuntimeOptions {
key_file: Some(nonexistent),
key_b64: Some(B64.encode([0u8; 32])),
environment: None,
})
.await;
match result {
Err(RuntimeError::KeyFileRead { .. }) => {}
other => panic!("expected KeyFileRead error, got: {:?}", other.err()),
}
}
#[tokio::test]
async fn invalid_key_length_errors() {
let dir = tempfile::tempdir().unwrap();
let blob_path = write_blob(&dir, &[0u8; 64]);
let result = build_config_runtime(RuntimeOptions {
key_file: Some(blob_path),
key_b64: Some(B64.encode([0u8; 16])),
environment: None,
})
.await;
match result {
Err(RuntimeError::InvalidKeyLength(16)) => {}
other => panic!("expected InvalidKeyLength(16), got: {:?}", other.err()),
}
}
#[tokio::test]
async fn classifier_skip_drops_feature_flags() {
let classify: Classifier = Box::new(|key, _v| match key {
"apiUrl" => Classification::Public,
"dbPassword" => Classification::Secret,
"newFlow" => Classification::Skip,
_ => Classification::Public,
});
let (key_b64, blob) = bake_fixture(
serde_json::json!({
"apiUrl": "https://api.example.com",
"dbPassword": "super-secret",
"newFlow": true,
}),
Some(classify),
)
.await;
let dir = tempfile::tempdir().unwrap();
let blob_path = write_blob(&dir, &blob);
let manager = build_config_runtime(RuntimeOptions {
key_file: Some(blob_path),
key_b64: Some(key_b64),
environment: Some("test".to_string()),
})
.await
.unwrap();
assert_eq!(
manager.get_public_config("apiUrl").unwrap(),
Some(serde_json::json!("https://api.example.com"))
);
assert_eq!(
manager.get_secret_config("dbPassword").unwrap(),
Some(serde_json::json!("super-secret"))
);
assert_eq!(manager.get_feature_flag("newFlow").unwrap(), None);
}
#[tokio::test]
async fn blob_too_short_errors() {
let dir = tempfile::tempdir().unwrap();
let blob_path = write_blob(&dir, &[0u8; 10]);
let result = build_config_runtime(RuntimeOptions {
key_file: Some(blob_path),
key_b64: Some(B64.encode([0u8; 32])),
environment: None,
})
.await;
match result {
Err(RuntimeError::BlobTooShort(10)) => {}
other => panic!("expected BlobTooShort(10), got: {:?}", other.err()),
}
}
#[tokio::test]
async fn read_baked_config_returns_none_without_env() {
let prev_file = env::var_os("SMOO_CONFIG_KEY_FILE");
let prev_key = env::var_os("SMOO_CONFIG_KEY");
unsafe {
env::remove_var("SMOO_CONFIG_KEY_FILE");
env::remove_var("SMOO_CONFIG_KEY");
}
let result = read_baked_config(&RuntimeOptions::default());
unsafe {
if let Some(v) = prev_file {
env::set_var("SMOO_CONFIG_KEY_FILE", v);
}
if let Some(v) = prev_key {
env::set_var("SMOO_CONFIG_KEY", v);
}
}
assert!(result.unwrap().is_none());
}
}