1use serde_json::{Map, Value};
7
8use crate::Config;
9
10pub fn is_masked_api_key(value: &str) -> bool {
19 let v = value.trim();
20 !v.is_empty() && v.chars().all(|c| c == '*' || c == '.')
22}
23
24#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub struct ProviderApiKeyIntents {
29 pub providers: std::collections::BTreeSet<String>,
30 pub provider_instances: std::collections::BTreeSet<String>,
31}
32
33pub fn provider_api_key_intents(patch_obj: &Map<String, Value>) -> ProviderApiKeyIntents {
34 let mut intents = ProviderApiKeyIntents::default();
35
36 if let Some(root) = patch_obj.get("providers").and_then(|v| v.as_object()) {
37 for (provider_name, provider_patch) in root.iter() {
38 let Some(obj) = provider_patch.as_object() else {
39 continue;
40 };
41 let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) else {
42 continue;
43 };
44 if is_masked_api_key(api_key) {
45 continue;
46 }
47 intents.providers.insert(provider_name.clone());
48 }
49 }
50
51 if let Some(root) = patch_obj
52 .get("provider_instances")
53 .and_then(|v| v.as_object())
54 {
55 for (instance_id, instance_patch) in root.iter() {
56 let Some(obj) = instance_patch.as_object() else {
57 continue;
58 };
59 let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) else {
60 continue;
61 };
62 if is_masked_api_key(api_key) {
63 continue;
64 }
65 intents.provider_instances.insert(instance_id.clone());
66 }
67 }
68
69 intents
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum ReloadMode {
75 None,
76 BestEffort,
78 Strict,
80}
81
82#[derive(Debug, Clone, Copy)]
84pub struct PatchEffects {
85 pub reload_provider: ReloadMode,
86 pub reconcile_mcp: bool,
87}
88
89#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
91pub struct DomainChanges {
92 pub provider: bool,
93 pub proxy: bool,
94 pub setup: bool,
95 pub mcp: bool,
96 pub keyword_masking: bool,
97 pub hooks: bool,
98 pub model_mapping: bool,
99}
100
101pub fn domains_for_root_patch(patch_obj: &Map<String, Value>) -> DomainChanges {
103 let mut changes = DomainChanges::default();
104
105 for key in patch_obj.keys() {
106 match key.as_str() {
107 "provider"
109 | "providers"
110 | "provider_instances"
111 | "default_provider_instance"
112 | "model"
113 | "defaults"
114 | "features" => changes.provider = true,
115
116 "http_proxy"
118 | "https_proxy"
119 | "proxy_auth"
120 | "proxy_auth_encrypted"
121 | "http_proxy_auth_encrypted"
122 | "https_proxy_auth_encrypted" => changes.proxy = true,
123
124 "setup" => changes.setup = true,
126
127 "mcp" | "mcpServers" => changes.mcp = true,
129
130 "keyword_masking" => changes.keyword_masking = true,
132 "hooks" => changes.hooks = true,
133 "anthropic_model_mapping" | "gemini_model_mapping" => changes.model_mapping = true,
134
135 _ => {}
136 }
137 }
138
139 changes
140}
141
142pub fn effects_for_root_patch(patch_obj: &Map<String, Value>) -> PatchEffects {
144 let domains = domains_for_root_patch(patch_obj);
145
146 let touches_provider = domains.provider || domains.hooks || domains.keyword_masking;
147 let touches_proxy = domains.proxy;
148 let touches_mcp = domains.mcp;
149
150 PatchEffects {
151 reload_provider: if touches_provider || touches_proxy {
152 ReloadMode::BestEffort
153 } else {
154 ReloadMode::None
155 },
156 reconcile_mcp: touches_mcp || touches_proxy,
159 }
160}
161
162pub fn sanitize_root_patch(patch_obj: &mut Map<String, Value>) {
167 patch_obj.remove("proxy_auth");
169 patch_obj.remove("proxy_auth_encrypted");
170 patch_obj.remove("http_proxy_auth_encrypted");
172 patch_obj.remove("https_proxy_auth_encrypted");
173 patch_obj.remove("data_dir");
174
175 if let Some(providers) = patch_obj
177 .get_mut("providers")
178 .and_then(|v| v.as_object_mut())
179 {
180 for (_provider_name, provider_cfg) in providers.iter_mut() {
181 let Some(obj) = provider_cfg.as_object_mut() else {
182 continue;
183 };
184 obj.remove("api_key_encrypted");
185 }
186 }
187
188 if let Some(provider_instances) = patch_obj
189 .get_mut("provider_instances")
190 .and_then(|v| v.as_object_mut())
191 {
192 for (_instance_id, instance_cfg) in provider_instances.iter_mut() {
193 let Some(obj) = instance_cfg.as_object_mut() else {
194 continue;
195 };
196 obj.remove("api_key_encrypted");
197 }
198 }
199
200 if let Some(notifications) = patch_obj
202 .get_mut("notifications")
203 .and_then(|v| v.as_object_mut())
204 {
205 if let Some(ntfy) = notifications
206 .get_mut("ntfy")
207 .and_then(|v| v.as_object_mut())
208 {
209 ntfy.remove("token_encrypted");
210 }
211 if let Some(bark) = notifications
212 .get_mut("bark")
213 .and_then(|v| v.as_object_mut())
214 {
215 bark.remove("device_key_encrypted");
216 }
217 }
218
219 if let Some(mcp_servers) = patch_obj
224 .get_mut("mcpServers")
225 .and_then(|v| v.as_object_mut())
226 {
227 for (_id, server) in mcp_servers.iter_mut() {
228 let Some(server_obj) = server.as_object_mut() else {
229 continue;
230 };
231 server_obj.remove("env_encrypted");
232 if let Some(headers) = server_obj.get_mut("headers").and_then(|v| v.as_array_mut()) {
233 for header in headers.iter_mut() {
234 let Some(header_obj) = header.as_object_mut() else {
235 continue;
236 };
237 header_obj.remove("value_encrypted");
238 }
239 }
240 }
241 }
242
243 if let Some(servers) = patch_obj
246 .get_mut("mcp")
247 .and_then(|m| m.get_mut("servers"))
248 .and_then(|v| v.as_array_mut())
249 {
250 for server in servers.iter_mut() {
251 let Some(server_obj) = server.as_object_mut() else {
252 continue;
253 };
254 let Some(transport) = server_obj
255 .get_mut("transport")
256 .and_then(|v| v.as_object_mut())
257 else {
258 continue;
259 };
260
261 match transport.get("type").and_then(|v| v.as_str()) {
262 Some("stdio") => {
263 transport.remove("env_encrypted");
264 }
265 Some("sse") => {
266 if let Some(headers) =
267 transport.get_mut("headers").and_then(|v| v.as_array_mut())
268 {
269 for header in headers.iter_mut() {
270 let Some(header_obj) = header.as_object_mut() else {
271 continue;
272 };
273 header_obj.remove("value_encrypted");
274 }
275 }
276 }
277 _ => {}
278 }
279 }
280 }
281}
282
283pub fn preserve_masked_provider_api_keys(patch_obj: &mut Map<String, Value>, current: &Config) {
288 if let Some(patch_providers) = patch_obj
289 .get_mut("providers")
290 .and_then(|v| v.as_object_mut())
291 {
292 for (provider_name, provider_patch) in patch_providers.iter_mut() {
293 let Some(patch_cfg_obj) = provider_patch.as_object_mut() else {
294 continue;
295 };
296
297 let Some(api_key) = patch_cfg_obj.get("api_key").and_then(|v| v.as_str()) else {
298 continue;
299 };
300 if !is_masked_api_key(api_key) {
301 continue;
302 }
303
304 let existing_plain = match provider_name.as_str() {
305 "openai" => current.providers.openai.as_ref().map(|c| c.api_key.clone()),
306 "anthropic" => current
307 .providers
308 .anthropic
309 .as_ref()
310 .map(|c| c.api_key.clone()),
311 "gemini" => current.providers.gemini.as_ref().map(|c| c.api_key.clone()),
312 "bodhi" => current.providers.bodhi.as_ref().map(|c| c.api_key.clone()),
313 _ => None,
314 };
315
316 if let Some(existing_plain) = existing_plain {
317 if !existing_plain.trim().is_empty() {
318 patch_cfg_obj.insert("api_key".to_string(), Value::String(existing_plain));
319 } else {
320 patch_cfg_obj.remove("api_key");
321 }
322 } else {
323 patch_cfg_obj.remove("api_key");
324 }
325 }
326 }
327
328 if let Some(patch_instances) = patch_obj
329 .get_mut("provider_instances")
330 .and_then(|v| v.as_object_mut())
331 {
332 for (instance_id, instance_patch) in patch_instances.iter_mut() {
333 let Some(patch_cfg_obj) = instance_patch.as_object_mut() else {
334 continue;
335 };
336
337 let Some(api_key) = patch_cfg_obj.get("api_key").and_then(|v| v.as_str()) else {
338 continue;
339 };
340 if !is_masked_api_key(api_key) {
341 continue;
342 }
343
344 let existing_plain = current
345 .provider_instances
346 .get(instance_id)
347 .map(|instance| instance.api_key.clone());
348
349 if let Some(existing_plain) = existing_plain {
350 if !existing_plain.trim().is_empty() {
351 patch_cfg_obj.insert("api_key".to_string(), Value::String(existing_plain));
352 } else {
353 patch_cfg_obj.remove("api_key");
354 }
355 } else {
356 patch_cfg_obj.remove("api_key");
357 }
358 }
359 }
360}
361
362pub fn preserve_masked_notification_secrets(patch_obj: &mut Map<String, Value>, current: &Config) {
371 let Some(notifications) = patch_obj
372 .get_mut("notifications")
373 .and_then(|v| v.as_object_mut())
374 else {
375 return;
376 };
377
378 if let Some(ntfy) = notifications
379 .get_mut("ntfy")
380 .and_then(|v| v.as_object_mut())
381 {
382 preserve_masked_secret_field(ntfy, "token", current.notifications.ntfy.token.as_deref());
383 }
384
385 if let Some(bark) = notifications
386 .get_mut("bark")
387 .and_then(|v| v.as_object_mut())
388 {
389 preserve_masked_secret_field(
390 bark,
391 "device_key",
392 current.notifications.bark.device_key.as_deref(),
393 );
394 }
395}
396
397fn preserve_masked_secret_field(
402 obj: &mut Map<String, Value>,
403 field: &str,
404 existing_plain: Option<&str>,
405) {
406 let Some(value) = obj.get(field).and_then(|v| v.as_str()) else {
407 return;
408 };
409 if !is_masked_api_key(value) {
410 return;
411 }
412
413 match existing_plain {
414 Some(plain) if !plain.trim().is_empty() => {
415 obj.insert(field.to_string(), Value::String(plain.to_string()));
416 }
417 _ => {
418 obj.remove(field);
419 }
420 }
421}
422
423pub fn deep_merge_json(dst: &mut Value, src: Value) {
425 match (dst, src) {
426 (Value::Object(dst_map), Value::Object(src_map)) => {
427 for (key, value) in src_map {
428 match dst_map.get_mut(&key) {
429 Some(existing) => deep_merge_json(existing, value),
430 None => {
431 dst_map.insert(key, value);
432 }
433 }
434 }
435 }
436 (dst_slot, src_value) => {
437 *dst_slot = src_value;
438 }
439 }
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445 use serde_json::json;
446
447 #[test]
448 fn domains_for_root_patch_detects_proxy_and_provider() {
449 let patch = json!({
450 "provider": "openai",
451 "http_proxy": "http://proxy:8080",
452 "setup": { "completed": false },
453 "mcpServers": {}
454 });
455
456 let domains = domains_for_root_patch(patch.as_object().unwrap());
457 assert!(domains.provider);
458 assert!(domains.proxy);
459 assert!(domains.setup);
460 assert!(domains.mcp);
461 }
462
463 #[test]
464 fn domains_for_root_patch_detects_provider_instances() {
465 let patch = json!({
466 "provider_instances": {
467 "openai-work": { "provider_type": "openai" }
468 },
469 "default_provider_instance": "openai-work",
470 "defaults": {
471 "chat": { "provider": "openai-work", "model": "gpt-4o" }
472 },
473 "features": {
474 "provider_model_ref": true
475 }
476 });
477
478 let domains = domains_for_root_patch(patch.as_object().unwrap());
479 assert!(domains.provider);
480 }
481
482 #[test]
483 fn provider_api_key_intents_ignores_masked_placeholders() {
484 let patch = json!({
485 "providers": {
486 "openai": { "api_key": "****...****" },
487 "gemini": { "api_key": "sk-real" }
488 },
489 "provider_instances": {
490 "work-openai": { "api_key": "****...****" },
491 "personal-openai": { "api_key": "sk-live" }
492 }
493 });
494 let intents = provider_api_key_intents(patch.as_object().unwrap());
495 assert!(intents.providers.contains("gemini"));
496 assert!(!intents.providers.contains("openai"));
497 assert!(intents.provider_instances.contains("personal-openai"));
498 assert!(!intents.provider_instances.contains("work-openai"));
499 }
500
501 #[test]
502 fn is_masked_api_key_requires_placeholder_only_values() {
503 assert!(is_masked_api_key("****...****"));
505 assert!(is_masked_api_key("********"));
506 assert!(is_masked_api_key(" ****...**** "));
507
508 assert!(!is_masked_api_key(""));
510 assert!(!is_masked_api_key(" "));
511
512 assert!(!is_masked_api_key("****...****sk-newkey123"));
515 assert!(!is_masked_api_key("sk-newkey123****...****"));
516
517 assert!(!is_masked_api_key("id.secret...suffix"));
519 assert!(!is_masked_api_key("sk-live-abc"));
520 }
521
522 #[test]
523 fn sanitize_root_patch_strips_notification_encrypted_fields() {
524 let mut patch = json!({
525 "notifications": {
526 "ntfy": { "token": "new-token", "token_encrypted": "client-supplied-cipher" },
527 "bark": { "device_key": "new-key", "device_key_encrypted": "client-supplied-cipher" }
528 }
529 });
530 let obj = patch.as_object_mut().unwrap();
531 sanitize_root_patch(obj);
532
533 assert!(!obj["notifications"]["ntfy"]
534 .as_object()
535 .unwrap()
536 .contains_key("token_encrypted"));
537 assert!(!obj["notifications"]["bark"]
538 .as_object()
539 .unwrap()
540 .contains_key("device_key_encrypted"));
541 assert_eq!(obj["notifications"]["ntfy"]["token"], "new-token");
543 assert_eq!(obj["notifications"]["bark"]["device_key"], "new-key");
544 }
545
546 #[test]
547 fn preserve_masked_notification_secrets_keeps_existing_plaintext() {
548 let mut current = Config::default();
549 current.notifications.ntfy.token = Some("existing-ntfy-token".to_string());
550 current.notifications.bark.device_key = Some("existing-bark-key".to_string());
551
552 let mut patch: Map<String, Value> = serde_json::from_str(
553 r#"{"notifications":{"ntfy":{"token":"****...****"},"bark":{"device_key":"****...****"}}}"#,
554 )
555 .unwrap();
556
557 preserve_masked_notification_secrets(&mut patch, ¤t);
558
559 assert_eq!(
560 patch["notifications"]["ntfy"]["token"],
561 "existing-ntfy-token"
562 );
563 assert_eq!(
564 patch["notifications"]["bark"]["device_key"],
565 "existing-bark-key"
566 );
567 }
568
569 #[test]
570 fn preserve_masked_notification_secrets_drops_mask_when_nothing_configured() {
571 let current = Config::default();
572 let mut patch: Map<String, Value> =
573 serde_json::from_str(r#"{"notifications":{"ntfy":{"token":"****...****"}}}"#).unwrap();
574
575 preserve_masked_notification_secrets(&mut patch, ¤t);
576
577 assert!(!patch["notifications"]["ntfy"]
578 .as_object()
579 .unwrap()
580 .contains_key("token"));
581 }
582
583 #[test]
584 fn preserve_masked_notification_secrets_leaves_real_values_untouched() {
585 let current = Config::default();
586 let mut patch: Map<String, Value> =
587 serde_json::from_str(r#"{"notifications":{"ntfy":{"token":"tk-real-new-value"}}}"#)
588 .unwrap();
589
590 preserve_masked_notification_secrets(&mut patch, ¤t);
591
592 assert_eq!(patch["notifications"]["ntfy"]["token"], "tk-real-new-value");
593 }
594}