use serde_json::{Map, Value};
use crate::Config;
pub fn is_masked_api_key(value: &str) -> bool {
let v = value.trim();
!v.is_empty() && v.chars().all(|c| c == '*' || c == '.')
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProviderApiKeyIntents {
pub providers: std::collections::BTreeSet<String>,
pub provider_instances: std::collections::BTreeSet<String>,
}
pub fn provider_api_key_intents(patch_obj: &Map<String, Value>) -> ProviderApiKeyIntents {
let mut intents = ProviderApiKeyIntents::default();
if let Some(root) = patch_obj.get("providers").and_then(|v| v.as_object()) {
for (provider_name, provider_patch) in root.iter() {
let Some(obj) = provider_patch.as_object() else {
continue;
};
let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) else {
continue;
};
if is_masked_api_key(api_key) {
continue;
}
intents.providers.insert(provider_name.clone());
}
}
if let Some(root) = patch_obj
.get("provider_instances")
.and_then(|v| v.as_object())
{
for (instance_id, instance_patch) in root.iter() {
let Some(obj) = instance_patch.as_object() else {
continue;
};
let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) else {
continue;
};
if is_masked_api_key(api_key) {
continue;
}
intents.provider_instances.insert(instance_id.clone());
}
}
intents
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReloadMode {
None,
BestEffort,
Strict,
}
#[derive(Debug, Clone, Copy)]
pub struct PatchEffects {
pub reload_provider: ReloadMode,
pub reconcile_mcp: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct DomainChanges {
pub provider: bool,
pub proxy: bool,
pub setup: bool,
pub mcp: bool,
pub keyword_masking: bool,
pub hooks: bool,
pub model_mapping: bool,
}
pub fn domains_for_root_patch(patch_obj: &Map<String, Value>) -> DomainChanges {
let mut changes = DomainChanges::default();
for key in patch_obj.keys() {
match key.as_str() {
"provider"
| "providers"
| "provider_instances"
| "default_provider_instance"
| "model"
| "defaults"
| "features" => changes.provider = true,
"http_proxy"
| "https_proxy"
| "proxy_auth"
| "proxy_auth_encrypted"
| "http_proxy_auth_encrypted"
| "https_proxy_auth_encrypted" => changes.proxy = true,
"setup" => changes.setup = true,
"mcp" | "mcpServers" => changes.mcp = true,
"keyword_masking" => changes.keyword_masking = true,
"hooks" => changes.hooks = true,
"anthropic_model_mapping" | "gemini_model_mapping" => changes.model_mapping = true,
_ => {}
}
}
changes
}
pub fn effects_for_root_patch(patch_obj: &Map<String, Value>) -> PatchEffects {
let domains = domains_for_root_patch(patch_obj);
let touches_provider = domains.provider || domains.hooks || domains.keyword_masking;
let touches_proxy = domains.proxy;
let touches_mcp = domains.mcp;
PatchEffects {
reload_provider: if touches_provider || touches_proxy {
ReloadMode::BestEffort
} else {
ReloadMode::None
},
reconcile_mcp: touches_mcp || touches_proxy,
}
}
pub fn sanitize_root_patch(patch_obj: &mut Map<String, Value>) {
patch_obj.remove("proxy_auth");
patch_obj.remove("proxy_auth_encrypted");
patch_obj.remove("http_proxy_auth_encrypted");
patch_obj.remove("https_proxy_auth_encrypted");
patch_obj.remove("data_dir");
if let Some(providers) = patch_obj
.get_mut("providers")
.and_then(|v| v.as_object_mut())
{
for (_provider_name, provider_cfg) in providers.iter_mut() {
let Some(obj) = provider_cfg.as_object_mut() else {
continue;
};
obj.remove("api_key_encrypted");
}
}
if let Some(provider_instances) = patch_obj
.get_mut("provider_instances")
.and_then(|v| v.as_object_mut())
{
for (_instance_id, instance_cfg) in provider_instances.iter_mut() {
let Some(obj) = instance_cfg.as_object_mut() else {
continue;
};
obj.remove("api_key_encrypted");
}
}
if let Some(notifications) = patch_obj
.get_mut("notifications")
.and_then(|v| v.as_object_mut())
{
if let Some(ntfy) = notifications
.get_mut("ntfy")
.and_then(|v| v.as_object_mut())
{
ntfy.remove("token_encrypted");
}
if let Some(bark) = notifications
.get_mut("bark")
.and_then(|v| v.as_object_mut())
{
bark.remove("device_key_encrypted");
}
}
if let Some(mcp_servers) = patch_obj
.get_mut("mcpServers")
.and_then(|v| v.as_object_mut())
{
for (_id, server) in mcp_servers.iter_mut() {
let Some(server_obj) = server.as_object_mut() else {
continue;
};
server_obj.remove("env_encrypted");
if let Some(headers) = server_obj.get_mut("headers").and_then(|v| v.as_array_mut()) {
for header in headers.iter_mut() {
let Some(header_obj) = header.as_object_mut() else {
continue;
};
header_obj.remove("value_encrypted");
}
}
}
}
if let Some(servers) = patch_obj
.get_mut("mcp")
.and_then(|m| m.get_mut("servers"))
.and_then(|v| v.as_array_mut())
{
for server in servers.iter_mut() {
let Some(server_obj) = server.as_object_mut() else {
continue;
};
let Some(transport) = server_obj
.get_mut("transport")
.and_then(|v| v.as_object_mut())
else {
continue;
};
match transport.get("type").and_then(|v| v.as_str()) {
Some("stdio") => {
transport.remove("env_encrypted");
}
Some("sse") => {
if let Some(headers) =
transport.get_mut("headers").and_then(|v| v.as_array_mut())
{
for header in headers.iter_mut() {
let Some(header_obj) = header.as_object_mut() else {
continue;
};
header_obj.remove("value_encrypted");
}
}
}
_ => {}
}
}
}
}
pub fn preserve_masked_provider_api_keys(patch_obj: &mut Map<String, Value>, current: &Config) {
if let Some(patch_providers) = patch_obj
.get_mut("providers")
.and_then(|v| v.as_object_mut())
{
for (provider_name, provider_patch) in patch_providers.iter_mut() {
let Some(patch_cfg_obj) = provider_patch.as_object_mut() else {
continue;
};
let Some(api_key) = patch_cfg_obj.get("api_key").and_then(|v| v.as_str()) else {
continue;
};
if !is_masked_api_key(api_key) {
continue;
}
let existing_plain = match provider_name.as_str() {
"openai" => current.providers.openai.as_ref().map(|c| c.api_key.clone()),
"anthropic" => current
.providers
.anthropic
.as_ref()
.map(|c| c.api_key.clone()),
"gemini" => current.providers.gemini.as_ref().map(|c| c.api_key.clone()),
"bodhi" => current.providers.bodhi.as_ref().map(|c| c.api_key.clone()),
_ => None,
};
if let Some(existing_plain) = existing_plain {
if !existing_plain.trim().is_empty() {
patch_cfg_obj.insert("api_key".to_string(), Value::String(existing_plain));
} else {
patch_cfg_obj.remove("api_key");
}
} else {
patch_cfg_obj.remove("api_key");
}
}
}
if let Some(patch_instances) = patch_obj
.get_mut("provider_instances")
.and_then(|v| v.as_object_mut())
{
for (instance_id, instance_patch) in patch_instances.iter_mut() {
let Some(patch_cfg_obj) = instance_patch.as_object_mut() else {
continue;
};
let Some(api_key) = patch_cfg_obj.get("api_key").and_then(|v| v.as_str()) else {
continue;
};
if !is_masked_api_key(api_key) {
continue;
}
let existing_plain = current
.provider_instances
.get(instance_id)
.map(|instance| instance.api_key.clone());
if let Some(existing_plain) = existing_plain {
if !existing_plain.trim().is_empty() {
patch_cfg_obj.insert("api_key".to_string(), Value::String(existing_plain));
} else {
patch_cfg_obj.remove("api_key");
}
} else {
patch_cfg_obj.remove("api_key");
}
}
}
}
pub fn preserve_masked_notification_secrets(patch_obj: &mut Map<String, Value>, current: &Config) {
let Some(notifications) = patch_obj
.get_mut("notifications")
.and_then(|v| v.as_object_mut())
else {
return;
};
if let Some(ntfy) = notifications
.get_mut("ntfy")
.and_then(|v| v.as_object_mut())
{
preserve_masked_secret_field(ntfy, "token", current.notifications.ntfy.token.as_deref());
}
if let Some(bark) = notifications
.get_mut("bark")
.and_then(|v| v.as_object_mut())
{
preserve_masked_secret_field(
bark,
"device_key",
current.notifications.bark.device_key.as_deref(),
);
}
}
fn preserve_masked_secret_field(
obj: &mut Map<String, Value>,
field: &str,
existing_plain: Option<&str>,
) {
let Some(value) = obj.get(field).and_then(|v| v.as_str()) else {
return;
};
if !is_masked_api_key(value) {
return;
}
match existing_plain {
Some(plain) if !plain.trim().is_empty() => {
obj.insert(field.to_string(), Value::String(plain.to_string()));
}
_ => {
obj.remove(field);
}
}
}
pub fn deep_merge_json(dst: &mut Value, src: Value) {
match (dst, src) {
(Value::Object(dst_map), Value::Object(src_map)) => {
for (key, value) in src_map {
match dst_map.get_mut(&key) {
Some(existing) => deep_merge_json(existing, value),
None => {
dst_map.insert(key, value);
}
}
}
}
(dst_slot, src_value) => {
*dst_slot = src_value;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn domains_for_root_patch_detects_proxy_and_provider() {
let patch = json!({
"provider": "openai",
"http_proxy": "http://proxy:8080",
"setup": { "completed": false },
"mcpServers": {}
});
let domains = domains_for_root_patch(patch.as_object().unwrap());
assert!(domains.provider);
assert!(domains.proxy);
assert!(domains.setup);
assert!(domains.mcp);
}
#[test]
fn domains_for_root_patch_detects_provider_instances() {
let patch = json!({
"provider_instances": {
"openai-work": { "provider_type": "openai" }
},
"default_provider_instance": "openai-work",
"defaults": {
"chat": { "provider": "openai-work", "model": "gpt-4o" }
},
"features": {
"provider_model_ref": true
}
});
let domains = domains_for_root_patch(patch.as_object().unwrap());
assert!(domains.provider);
}
#[test]
fn provider_api_key_intents_ignores_masked_placeholders() {
let patch = json!({
"providers": {
"openai": { "api_key": "****...****" },
"gemini": { "api_key": "sk-real" }
},
"provider_instances": {
"work-openai": { "api_key": "****...****" },
"personal-openai": { "api_key": "sk-live" }
}
});
let intents = provider_api_key_intents(patch.as_object().unwrap());
assert!(intents.providers.contains("gemini"));
assert!(!intents.providers.contains("openai"));
assert!(intents.provider_instances.contains("personal-openai"));
assert!(!intents.provider_instances.contains("work-openai"));
}
#[test]
fn is_masked_api_key_requires_placeholder_only_values() {
assert!(is_masked_api_key("****...****"));
assert!(is_masked_api_key("********"));
assert!(is_masked_api_key(" ****...**** "));
assert!(!is_masked_api_key(""));
assert!(!is_masked_api_key(" "));
assert!(!is_masked_api_key("****...****sk-newkey123"));
assert!(!is_masked_api_key("sk-newkey123****...****"));
assert!(!is_masked_api_key("id.secret...suffix"));
assert!(!is_masked_api_key("sk-live-abc"));
}
#[test]
fn sanitize_root_patch_strips_notification_encrypted_fields() {
let mut patch = json!({
"notifications": {
"ntfy": { "token": "new-token", "token_encrypted": "client-supplied-cipher" },
"bark": { "device_key": "new-key", "device_key_encrypted": "client-supplied-cipher" }
}
});
let obj = patch.as_object_mut().unwrap();
sanitize_root_patch(obj);
assert!(!obj["notifications"]["ntfy"]
.as_object()
.unwrap()
.contains_key("token_encrypted"));
assert!(!obj["notifications"]["bark"]
.as_object()
.unwrap()
.contains_key("device_key_encrypted"));
assert_eq!(obj["notifications"]["ntfy"]["token"], "new-token");
assert_eq!(obj["notifications"]["bark"]["device_key"], "new-key");
}
#[test]
fn preserve_masked_notification_secrets_keeps_existing_plaintext() {
let mut current = Config::default();
current.notifications.ntfy.token = Some("existing-ntfy-token".to_string());
current.notifications.bark.device_key = Some("existing-bark-key".to_string());
let mut patch: Map<String, Value> = serde_json::from_str(
r#"{"notifications":{"ntfy":{"token":"****...****"},"bark":{"device_key":"****...****"}}}"#,
)
.unwrap();
preserve_masked_notification_secrets(&mut patch, ¤t);
assert_eq!(
patch["notifications"]["ntfy"]["token"],
"existing-ntfy-token"
);
assert_eq!(
patch["notifications"]["bark"]["device_key"],
"existing-bark-key"
);
}
#[test]
fn preserve_masked_notification_secrets_drops_mask_when_nothing_configured() {
let current = Config::default();
let mut patch: Map<String, Value> =
serde_json::from_str(r#"{"notifications":{"ntfy":{"token":"****...****"}}}"#).unwrap();
preserve_masked_notification_secrets(&mut patch, ¤t);
assert!(!patch["notifications"]["ntfy"]
.as_object()
.unwrap()
.contains_key("token"));
}
#[test]
fn preserve_masked_notification_secrets_leaves_real_values_untouched() {
let current = Config::default();
let mut patch: Map<String, Value> =
serde_json::from_str(r#"{"notifications":{"ntfy":{"token":"tk-real-new-value"}}}"#)
.unwrap();
preserve_masked_notification_secrets(&mut patch, ¤t);
assert_eq!(patch["notifications"]["ntfy"]["token"], "tk-real-new-value");
}
}