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
24fn is_secret_field_intent(obj: &Map<String, Value>, field: &str) -> bool {
43 match obj.get(field) {
44 None => false,
45 Some(Value::Null) => true,
46 Some(value) => match value.as_str() {
47 Some(s) => !is_masked_api_key(s),
48 None => false,
51 },
52 }
53}
54
55#[derive(Debug, Clone, Default, PartialEq, Eq)]
61pub struct ProviderApiKeyIntents {
62 pub providers: std::collections::BTreeSet<String>,
63 pub provider_instances: std::collections::BTreeSet<String>,
64}
65
66pub fn provider_api_key_intents(patch_obj: &Map<String, Value>) -> ProviderApiKeyIntents {
67 let mut intents = ProviderApiKeyIntents::default();
68
69 if let Some(root) = patch_obj.get("providers").and_then(|v| v.as_object()) {
70 for (provider_name, provider_patch) in root.iter() {
71 let Some(obj) = provider_patch.as_object() else {
72 continue;
73 };
74 if is_secret_field_intent(obj, "api_key") {
75 intents.providers.insert(provider_name.clone());
76 }
77 }
78 }
79
80 if let Some(root) = patch_obj
81 .get("provider_instances")
82 .and_then(|v| v.as_object())
83 {
84 for (instance_id, instance_patch) in root.iter() {
85 if instance_patch.is_null() {
86 intents.provider_instances.insert(instance_id.clone());
87 continue;
88 }
89 let Some(obj) = instance_patch.as_object() else {
90 continue;
91 };
92 if is_secret_field_intent(obj, "api_key") {
93 intents.provider_instances.insert(instance_id.clone());
94 }
95 }
96 }
97
98 intents
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum ReloadMode {
104 None,
105 BestEffort,
107 Strict,
109}
110
111#[derive(Debug, Clone, Copy)]
113pub struct PatchEffects {
114 pub reload_provider: ReloadMode,
115 pub reconcile_mcp: bool,
116}
117
118#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
120pub struct DomainChanges {
121 pub provider: bool,
122 pub proxy: bool,
123 pub setup: bool,
124 pub mcp: bool,
125 pub keyword_masking: bool,
126 pub hooks: bool,
127 pub lifecycle_hooks: bool,
128 pub model_mapping: bool,
129}
130
131pub fn domains_for_root_patch(patch_obj: &Map<String, Value>) -> DomainChanges {
133 let mut changes = DomainChanges::default();
134
135 for key in patch_obj.keys() {
136 match key.as_str() {
137 "provider"
139 | "providers"
140 | "provider_instances"
141 | "default_provider_instance"
142 | "model"
143 | "defaults"
144 | "features" => changes.provider = true,
145
146 "http_proxy"
148 | "https_proxy"
149 | "proxy_auth"
150 | "proxy_auth_encrypted"
151 | "http_proxy_auth_encrypted"
152 | "https_proxy_auth_encrypted" => changes.proxy = true,
153
154 "setup" => changes.setup = true,
156
157 "mcp" | "mcpServers" => changes.mcp = true,
159
160 "keyword_masking" => changes.keyword_masking = true,
162 "hooks" => changes.hooks = true,
163 "lifecycle_hooks" => changes.lifecycle_hooks = true,
164 "anthropic_model_mapping" | "gemini_model_mapping" => changes.model_mapping = true,
165
166 _ => {}
167 }
168 }
169
170 changes
171}
172
173pub fn effects_for_root_patch(patch_obj: &Map<String, Value>) -> PatchEffects {
175 let domains = domains_for_root_patch(patch_obj);
176
177 let touches_provider = domains.provider || domains.hooks || domains.keyword_masking;
178 let touches_proxy = domains.proxy;
179 let touches_mcp = domains.mcp;
180
181 PatchEffects {
182 reload_provider: if touches_provider || touches_proxy {
183 ReloadMode::BestEffort
184 } else {
185 ReloadMode::None
186 },
187 reconcile_mcp: touches_mcp || touches_proxy,
190 }
191}
192
193pub fn sanitize_root_patch(patch_obj: &mut Map<String, Value>) {
198 patch_obj.remove("proxy_auth");
200 patch_obj.remove("proxy_auth_encrypted");
201 patch_obj.remove("proxy_auth_credential_ref");
202 patch_obj.remove("http_proxy_auth_encrypted");
204 patch_obj.remove("https_proxy_auth_encrypted");
205 patch_obj.remove("data_dir");
206 patch_obj.remove("env_vars");
210
211 if let Some(cluster_fabric) = patch_obj
215 .get_mut("cluster_fabric")
216 .and_then(|value| value.as_object_mut())
217 {
218 cluster_fabric.remove("credential_refs");
219 if let Some(nodes) = cluster_fabric
220 .get_mut("nodes")
221 .and_then(|value| value.as_array_mut())
222 {
223 for node in nodes {
224 let Some(auth) = node
225 .get_mut("placement")
226 .and_then(|value| value.as_object_mut())
227 .and_then(|placement| placement.get_mut("auth"))
228 .and_then(|value| value.as_object_mut())
229 else {
230 continue;
231 };
232 auth.remove("password_encrypted");
233 auth.remove("private_key_encrypted");
234 auth.remove("passphrase_encrypted");
235 }
236 }
237 }
238
239 if let Some(providers) = patch_obj
241 .get_mut("providers")
242 .and_then(|v| v.as_object_mut())
243 {
244 for (_provider_name, provider_cfg) in providers.iter_mut() {
245 let Some(obj) = provider_cfg.as_object_mut() else {
246 continue;
247 };
248 obj.remove("api_key_encrypted");
249 }
250 }
251
252 if let Some(provider_instances) = patch_obj
253 .get_mut("provider_instances")
254 .and_then(|v| v.as_object_mut())
255 {
256 for (_instance_id, instance_cfg) in provider_instances.iter_mut() {
257 let Some(obj) = instance_cfg.as_object_mut() else {
258 continue;
259 };
260 obj.remove("api_key_encrypted");
261 obj.remove("credential_ref");
262 }
263 }
264
265 if let Some(notifications) = patch_obj
267 .get_mut("notifications")
268 .and_then(|v| v.as_object_mut())
269 {
270 if let Some(ntfy) = notifications
271 .get_mut("ntfy")
272 .and_then(|v| v.as_object_mut())
273 {
274 ntfy.remove("token_encrypted");
275 }
276 if let Some(bark) = notifications
277 .get_mut("bark")
278 .and_then(|v| v.as_object_mut())
279 {
280 bark.remove("device_key_encrypted");
281 }
282 }
283
284 if let Some(platforms) = patch_obj
287 .get_mut("connect")
288 .and_then(|c| c.get_mut("platforms"))
289 .and_then(|v| v.as_array_mut())
290 {
291 for platform in platforms.iter_mut() {
292 if let Some(obj) = platform.as_object_mut() {
293 obj.remove("token_encrypted");
294 obj.remove("app_secret_encrypted");
295 }
296 }
297 }
298
299 if let Some(mcp_servers) = patch_obj
304 .get_mut("mcpServers")
305 .and_then(|v| v.as_object_mut())
306 {
307 for (_id, server) in mcp_servers.iter_mut() {
308 let Some(server_obj) = server.as_object_mut() else {
309 continue;
310 };
311 server_obj.remove("env_encrypted");
312 if let Some(headers) = server_obj.get_mut("headers").and_then(|v| v.as_array_mut()) {
313 for header in headers.iter_mut() {
314 let Some(header_obj) = header.as_object_mut() else {
315 continue;
316 };
317 header_obj.remove("value_encrypted");
318 }
319 }
320 }
321 }
322
323 if let Some(servers) = patch_obj
326 .get_mut("mcp")
327 .and_then(|m| m.get_mut("servers"))
328 .and_then(|v| v.as_array_mut())
329 {
330 for server in servers.iter_mut() {
331 let Some(server_obj) = server.as_object_mut() else {
332 continue;
333 };
334 let Some(transport) = server_obj
335 .get_mut("transport")
336 .and_then(|v| v.as_object_mut())
337 else {
338 continue;
339 };
340
341 match transport.get("type").and_then(|v| v.as_str()) {
342 Some("stdio") => {
343 transport.remove("env_encrypted");
344 }
345 Some("sse") => {
346 if let Some(headers) =
347 transport.get_mut("headers").and_then(|v| v.as_array_mut())
348 {
349 for header in headers.iter_mut() {
350 let Some(header_obj) = header.as_object_mut() else {
351 continue;
352 };
353 header_obj.remove("value_encrypted");
354 }
355 }
356 }
357 _ => {}
358 }
359 }
360 }
361}
362
363pub fn preserve_masked_provider_api_keys(patch_obj: &mut Map<String, Value>, current: &Config) {
368 if let Some(patch_providers) = patch_obj
369 .get_mut("providers")
370 .and_then(|v| v.as_object_mut())
371 {
372 for (provider_name, provider_patch) in patch_providers.iter_mut() {
373 let Some(patch_cfg_obj) = provider_patch.as_object_mut() else {
374 continue;
375 };
376
377 let Some(api_key) = patch_cfg_obj.get("api_key").and_then(|v| v.as_str()) else {
378 continue;
379 };
380 if !is_masked_api_key(api_key) {
381 continue;
382 }
383
384 let existing_plain = match provider_name.as_str() {
385 "openai" => current.providers.openai.as_ref().map(|c| c.api_key.clone()),
386 "anthropic" => current
387 .providers
388 .anthropic
389 .as_ref()
390 .map(|c| c.api_key.clone()),
391 "gemini" => current.providers.gemini.as_ref().map(|c| c.api_key.clone()),
392 "bodhi" => current.providers.bodhi.as_ref().map(|c| c.api_key.clone()),
393 _ => None,
394 };
395
396 if let Some(existing_plain) = existing_plain {
397 if !existing_plain.trim().is_empty() {
398 patch_cfg_obj.insert("api_key".to_string(), Value::String(existing_plain));
399 } else {
400 patch_cfg_obj.remove("api_key");
401 }
402 } else {
403 patch_cfg_obj.remove("api_key");
404 }
405 }
406 }
407
408 if let Some(patch_instances) = patch_obj
409 .get_mut("provider_instances")
410 .and_then(|v| v.as_object_mut())
411 {
412 for (instance_id, instance_patch) in patch_instances.iter_mut() {
413 let Some(patch_cfg_obj) = instance_patch.as_object_mut() else {
414 continue;
415 };
416
417 let Some(api_key) = patch_cfg_obj.get("api_key").and_then(|v| v.as_str()) else {
418 continue;
419 };
420 if !is_masked_api_key(api_key) {
421 continue;
422 }
423
424 let existing_plain = current
425 .provider_instances
426 .get(instance_id)
427 .map(|instance| instance.api_key.clone());
428
429 if let Some(existing_plain) = existing_plain {
430 if !existing_plain.trim().is_empty() {
431 patch_cfg_obj.insert("api_key".to_string(), Value::String(existing_plain));
432 } else {
433 patch_cfg_obj.remove("api_key");
434 }
435 } else {
436 patch_cfg_obj.remove("api_key");
437 }
438 }
439 }
440}
441
442pub fn preserve_unpatched_provider_secrets(
462 merged: &mut Config,
463 current: &Config,
464 intents: &ProviderApiKeyIntents,
465) {
466 macro_rules! carry_forward {
467 ($field:ident) => {
468 if !intents.providers.contains(stringify!($field)) {
469 if let (Some(new_cfg), Some(prev)) = (
470 merged.providers.$field.as_mut(),
471 current.providers.$field.as_ref(),
472 ) {
473 if new_cfg.api_key.trim().is_empty()
474 && new_cfg.api_key_encrypted.is_none()
475 && (!prev.api_key.trim().is_empty() || prev.api_key_encrypted.is_some())
476 {
477 new_cfg.api_key = prev.api_key.clone();
478 new_cfg.api_key_encrypted = prev.api_key_encrypted.clone();
479 }
480 }
481 }
482 };
483 }
484 carry_forward!(openai);
485 carry_forward!(anthropic);
486 carry_forward!(gemini);
487 carry_forward!(bodhi);
488
489 for (id, instance) in merged.provider_instances.iter_mut() {
490 if intents.provider_instances.contains(id) {
491 continue;
492 }
493 if !instance.api_key.trim().is_empty() || instance.api_key_encrypted.is_some() {
494 continue;
495 }
496 if let Some(prev) = current.provider_instances.get(id) {
497 if !prev.api_key.trim().is_empty() || prev.api_key_encrypted.is_some() {
498 instance.api_key = prev.api_key.clone();
499 instance.api_key_encrypted = prev.api_key_encrypted.clone();
500 }
501 }
502 }
503}
504
505pub fn clear_provider_ciphertext_for_explicit_clears(
514 merged: &mut Config,
515 intents: &ProviderApiKeyIntents,
516) {
517 macro_rules! clear_ciphertext {
518 ($field:ident) => {
519 if intents.providers.contains(stringify!($field)) {
520 if let Some(cfg) = merged.providers.$field.as_mut() {
521 if cfg.api_key.trim().is_empty() {
522 cfg.api_key_encrypted = None;
523 }
524 }
525 }
526 };
527 }
528 clear_ciphertext!(openai);
529 clear_ciphertext!(anthropic);
530 clear_ciphertext!(gemini);
531 clear_ciphertext!(bodhi);
532
533 for id in intents.provider_instances.iter() {
534 if let Some(instance) = merged.provider_instances.get_mut(id) {
535 if instance.api_key.trim().is_empty() {
536 instance.api_key_encrypted = None;
537 }
538 }
539 }
540}
541
542#[derive(Debug, Clone, Default, PartialEq, Eq)]
553pub struct NotificationSecretIntents {
554 pub ntfy_token: bool,
555 pub bark_device_key: bool,
556}
557
558pub fn notification_secret_intents(patch_obj: &Map<String, Value>) -> NotificationSecretIntents {
559 let mut intents = NotificationSecretIntents::default();
560
561 let Some(notifications) = patch_obj.get("notifications").and_then(|v| v.as_object()) else {
562 return intents;
563 };
564
565 if let Some(ntfy) = notifications.get("ntfy").and_then(|v| v.as_object()) {
566 intents.ntfy_token = is_secret_field_intent(ntfy, "token");
567 }
568
569 if let Some(bark) = notifications.get("bark").and_then(|v| v.as_object()) {
570 intents.bark_device_key = is_secret_field_intent(bark, "device_key");
571 }
572
573 intents
574}
575
576pub fn clear_notification_ciphertext_for_explicit_clears(
581 merged: &mut Config,
582 intents: &NotificationSecretIntents,
583) {
584 if intents.ntfy_token
585 && merged
586 .notifications
587 .ntfy
588 .token
589 .as_deref()
590 .unwrap_or("")
591 .trim()
592 .is_empty()
593 {
594 merged.notifications.ntfy.token_encrypted = None;
595 }
596
597 if intents.bark_device_key
598 && merged
599 .notifications
600 .bark
601 .device_key
602 .as_deref()
603 .unwrap_or("")
604 .trim()
605 .is_empty()
606 {
607 merged.notifications.bark.device_key_encrypted = None;
608 }
609}
610
611pub fn preserve_unpatched_notification_secrets(
616 merged: &mut Config,
617 current: &Config,
618 intents: &NotificationSecretIntents,
619) {
620 if !intents.ntfy_token {
621 merged.notifications.ntfy.token = current.notifications.ntfy.token.clone();
622 }
623 if !intents.bark_device_key {
624 merged.notifications.bark.device_key = current.notifications.bark.device_key.clone();
625 }
626}
627
628#[derive(Debug, Clone, Default, PartialEq, Eq)]
647pub struct ConnectSecretIntents {
648 pub token: std::collections::BTreeSet<usize>,
649 pub app_secret: std::collections::BTreeSet<usize>,
650}
651
652pub fn connect_secret_intents(patch_obj: &Map<String, Value>) -> ConnectSecretIntents {
653 let mut intents = ConnectSecretIntents::default();
654
655 let Some(platforms) = patch_obj
656 .get("connect")
657 .and_then(|c| c.get("platforms"))
658 .and_then(|v| v.as_array())
659 else {
660 return intents;
661 };
662
663 for (index, platform) in platforms.iter().enumerate() {
664 let Some(obj) = platform.as_object() else {
665 continue;
666 };
667
668 if is_secret_field_intent(obj, "token") {
669 intents.token.insert(index);
670 }
671
672 if is_secret_field_intent(obj, "app_secret") {
673 intents.app_secret.insert(index);
674 }
675 }
676
677 intents
678}
679
680pub fn clear_connect_ciphertext_for_explicit_clears(
689 merged: &mut Config,
690 intents: &ConnectSecretIntents,
691) {
692 for &index in intents.token.iter() {
693 if let Some(platform) = merged.connect.platforms.get_mut(index) {
694 if platform.token.as_deref().unwrap_or("").trim().is_empty() {
695 platform.token_encrypted = None;
696 }
697 }
698 }
699
700 for &index in intents.app_secret.iter() {
701 if let Some(platform) = merged.connect.platforms.get_mut(index) {
702 if platform
703 .app_secret
704 .as_deref()
705 .unwrap_or("")
706 .trim()
707 .is_empty()
708 {
709 platform.app_secret_encrypted = None;
710 }
711 }
712 }
713}
714
715pub fn preserve_masked_notification_secrets(patch_obj: &mut Map<String, Value>, current: &Config) {
724 let Some(notifications) = patch_obj
725 .get_mut("notifications")
726 .and_then(|v| v.as_object_mut())
727 else {
728 return;
729 };
730
731 if let Some(ntfy) = notifications
732 .get_mut("ntfy")
733 .and_then(|v| v.as_object_mut())
734 {
735 preserve_masked_secret_field(ntfy, "token", current.notifications.ntfy.token.as_deref());
736 }
737
738 if let Some(bark) = notifications
739 .get_mut("bark")
740 .and_then(|v| v.as_object_mut())
741 {
742 preserve_masked_secret_field(
743 bark,
744 "device_key",
745 current.notifications.bark.device_key.as_deref(),
746 );
747 }
748}
749
750pub fn preserve_masked_connect_secrets(patch_obj: &mut Map<String, Value>, current: &Config) {
794 let Some(platforms) = patch_obj
795 .get_mut("connect")
796 .and_then(|c| c.get_mut("platforms"))
797 .and_then(|v| v.as_array_mut())
798 else {
799 return;
800 };
801
802 for (index, platform) in platforms.iter_mut().enumerate() {
803 let Some(obj) = platform.as_object_mut() else {
804 continue;
805 };
806 let patch_type = obj.get("type").and_then(|v| v.as_str());
807 let patch_id = obj.get("id").and_then(|v| v.as_str());
808
809 let by_id = patch_id.and_then(|patch_id| {
816 current.connect.platforms.iter().find(|p| {
817 p.id.as_deref() == Some(patch_id)
818 && match patch_type {
819 Some(patch_type) => patch_type == p.platform_type,
820 None => true,
821 }
822 })
823 });
824
825 let existing = current.connect.platforms.get(index);
831 let guarded = by_id.or_else(|| {
841 existing
842 .filter(|p| match patch_type {
843 Some(patch_type) => patch_type == p.platform_type,
844 None => true,
845 })
846 .or_else(|| {
847 patch_type.and_then(|patch_type| {
848 current
849 .connect
850 .platforms
851 .iter()
852 .find(|p| p.platform_type == patch_type)
853 })
854 })
855 });
856 preserve_masked_secret_field(obj, "token", guarded.and_then(|p| p.token.as_deref()));
857 preserve_masked_secret_field(
858 obj,
859 "app_secret",
860 guarded.and_then(|p| p.app_secret.as_deref()),
861 );
862 }
863}
864
865fn preserve_masked_secret_field(
870 obj: &mut Map<String, Value>,
871 field: &str,
872 existing_plain: Option<&str>,
873) {
874 let Some(value) = obj.get(field).and_then(|v| v.as_str()) else {
875 return;
876 };
877 if !is_masked_api_key(value) {
878 return;
879 }
880
881 match existing_plain {
882 Some(plain) if !plain.trim().is_empty() => {
883 obj.insert(field.to_string(), Value::String(plain.to_string()));
884 }
885 _ => {
886 obj.remove(field);
887 }
888 }
889}
890
891pub fn deep_merge_json(dst: &mut Value, src: Value) {
967 match (dst, src) {
968 (Value::Object(dst_map), Value::Object(src_map)) => {
969 for (key, value) in src_map {
970 if value.is_null() {
971 dst_map.remove(&key);
980 continue;
981 }
982 match dst_map.get_mut(&key) {
983 Some(existing) if existing.is_object() && value.is_object() => {
984 deep_merge_json(existing, value);
985 }
986 _ => {
987 dst_map.insert(key, value);
988 }
989 }
990 }
991 }
992 (dst_slot, src_value) => {
993 *dst_slot = src_value;
994 }
995 }
996}
997
998#[cfg(test)]
999mod tests {
1000 use super::*;
1001 use serde_json::json;
1002
1003 #[test]
1004 fn domains_for_root_patch_detects_proxy_and_provider() {
1005 let patch = json!({
1006 "provider": "openai",
1007 "http_proxy": "http://proxy:8080",
1008 "setup": { "completed": false },
1009 "mcpServers": {}
1010 });
1011
1012 let domains = domains_for_root_patch(patch.as_object().unwrap());
1013 assert!(domains.provider);
1014 assert!(domains.proxy);
1015 assert!(domains.setup);
1016 assert!(domains.mcp);
1017 }
1018
1019 #[test]
1020 fn domains_for_root_patch_detects_provider_instances() {
1021 let patch = json!({
1022 "provider_instances": {
1023 "openai-work": { "provider_type": "openai" }
1024 },
1025 "default_provider_instance": "openai-work",
1026 "defaults": {
1027 "chat": { "provider": "openai-work", "model": "gpt-4o" }
1028 },
1029 "features": {
1030 "provider_model_ref": true
1031 }
1032 });
1033
1034 let domains = domains_for_root_patch(patch.as_object().unwrap());
1035 assert!(domains.provider);
1036 }
1037
1038 #[test]
1039 fn domains_for_root_patch_detects_lifecycle_hooks() {
1040 let patch = json!({"lifecycle_hooks": {"enabled": true}});
1041
1042 let domains = domains_for_root_patch(patch.as_object().unwrap());
1043
1044 assert!(domains.lifecycle_hooks);
1045 assert!(
1046 !domains.hooks,
1047 "request hooks and lifecycle hooks are distinct"
1048 );
1049 }
1050
1051 #[test]
1052 fn provider_api_key_intents_ignores_masked_placeholders() {
1053 let patch = json!({
1054 "providers": {
1055 "openai": { "api_key": "****...****" },
1056 "gemini": { "api_key": "sk-real" }
1057 },
1058 "provider_instances": {
1059 "work-openai": { "api_key": "****...****" },
1060 "personal-openai": { "api_key": "sk-live" }
1061 }
1062 });
1063 let intents = provider_api_key_intents(patch.as_object().unwrap());
1064 assert!(intents.providers.contains("gemini"));
1065 assert!(!intents.providers.contains("openai"));
1066 assert!(intents.provider_instances.contains("personal-openai"));
1067 assert!(!intents.provider_instances.contains("work-openai"));
1068 }
1069
1070 fn instance(api_key: &str, encrypted: Option<&str>) -> crate::ProviderInstanceConfig {
1073 serde_json::from_value(json!({
1074 "provider_type": "openai",
1075 "api_key": api_key,
1076 "api_key_encrypted": encrypted,
1077 }))
1078 .expect("valid instance")
1079 }
1080
1081 #[test]
1082 fn preserve_unpatched_provider_secrets_restores_roundtrip_dropped_keys() {
1083 let mut current = Config::default();
1087 current
1088 .provider_instances
1089 .insert("uuid-1".to_string(), instance("sk-instance-live", None));
1090 current.providers.openai = Some(crate::OpenAIConfig {
1091 api_key: "sk-legacy-live".to_string(),
1092 ..Default::default()
1093 });
1094
1095 let mut merged = Config::default();
1097 merged
1098 .provider_instances
1099 .insert("uuid-1".to_string(), instance("", None));
1100 merged.providers.openai = Some(crate::OpenAIConfig::default());
1101
1102 preserve_unpatched_provider_secrets(
1103 &mut merged,
1104 ¤t,
1105 &ProviderApiKeyIntents::default(),
1106 );
1107
1108 assert_eq!(
1109 merged.provider_instances["uuid-1"].api_key,
1110 "sk-instance-live"
1111 );
1112 assert_eq!(
1113 merged.providers.openai.as_ref().unwrap().api_key,
1114 "sk-legacy-live"
1115 );
1116 }
1117
1118 #[test]
1119 fn preserve_unpatched_provider_secrets_carries_ciphertext_only_keys() {
1120 let mut current = Config::default();
1123 current
1124 .provider_instances
1125 .insert("uuid-1".to_string(), instance("", Some("preexisting-ct")));
1126
1127 let mut merged = Config::default();
1128 merged
1129 .provider_instances
1130 .insert("uuid-1".to_string(), instance("", None));
1131
1132 preserve_unpatched_provider_secrets(
1133 &mut merged,
1134 ¤t,
1135 &ProviderApiKeyIntents::default(),
1136 );
1137
1138 assert_eq!(
1139 merged.provider_instances["uuid-1"]
1140 .api_key_encrypted
1141 .as_deref(),
1142 Some("preexisting-ct")
1143 );
1144 }
1145
1146 #[test]
1147 fn preserve_unpatched_provider_secrets_respects_explicit_intents() {
1148 let mut current = Config::default();
1151 current
1152 .provider_instances
1153 .insert("uuid-1".to_string(), instance("sk-old", None));
1154
1155 let mut merged = Config::default();
1156 merged
1157 .provider_instances
1158 .insert("uuid-1".to_string(), instance("", None));
1159
1160 let mut intents = ProviderApiKeyIntents::default();
1161 intents.provider_instances.insert("uuid-1".to_string());
1162
1163 preserve_unpatched_provider_secrets(&mut merged, ¤t, &intents);
1164
1165 let cleared = &merged.provider_instances["uuid-1"];
1166 assert!(cleared.api_key.is_empty(), "explicit clear must win");
1167 assert!(cleared.api_key_encrypted.is_none());
1168 }
1169
1170 #[test]
1171 fn clear_provider_ciphertext_drops_roundtripped_ciphertext_on_clear_intents() {
1172 let mut merged = Config::default();
1176 merged
1177 .provider_instances
1178 .insert("uuid-1".to_string(), instance("", Some("roundtripped-ct")));
1179 merged.providers.openai = Some(crate::OpenAIConfig {
1180 api_key_encrypted: Some("legacy-ct".to_string()),
1181 ..Default::default()
1182 });
1183
1184 let mut intents = ProviderApiKeyIntents::default();
1185 intents.provider_instances.insert("uuid-1".to_string());
1186 intents.providers.insert("openai".to_string());
1187
1188 clear_provider_ciphertext_for_explicit_clears(&mut merged, &intents);
1189
1190 assert!(merged.provider_instances["uuid-1"]
1191 .api_key_encrypted
1192 .is_none());
1193 assert!(merged
1194 .providers
1195 .openai
1196 .as_ref()
1197 .unwrap()
1198 .api_key_encrypted
1199 .is_none());
1200 }
1201
1202 #[test]
1203 fn clear_provider_ciphertext_leaves_set_intents_and_unpatched_alone() {
1204 let mut merged = Config::default();
1208 merged
1209 .provider_instances
1210 .insert("uuid-1".to_string(), instance("sk-new", Some("stale-ct")));
1211 merged
1212 .provider_instances
1213 .insert("uuid-2".to_string(), instance("", Some("kept-ct")));
1214
1215 let mut intents = ProviderApiKeyIntents::default();
1216 intents.provider_instances.insert("uuid-1".to_string());
1217
1218 clear_provider_ciphertext_for_explicit_clears(&mut merged, &intents);
1219
1220 assert!(merged.provider_instances["uuid-1"]
1221 .api_key_encrypted
1222 .is_some());
1223 assert_eq!(
1224 merged.provider_instances["uuid-2"]
1225 .api_key_encrypted
1226 .as_deref(),
1227 Some("kept-ct")
1228 );
1229 }
1230
1231 #[test]
1234 fn notification_secret_intents_ignores_masked_placeholders() {
1235 let patch = json!({
1236 "notifications": {
1237 "ntfy": { "token": "****...****" },
1238 "bark": { "device_key": "tk-real-new-value" }
1239 }
1240 });
1241 let intents = notification_secret_intents(patch.as_object().unwrap());
1242 assert!(!intents.ntfy_token, "masked placeholder is not an intent");
1243 assert!(intents.bark_device_key, "a real value is an intent");
1244 }
1245
1246 #[test]
1247 fn notification_secret_intents_detects_explicit_clear() {
1248 let patch = json!({
1249 "notifications": {
1250 "ntfy": { "token": "" },
1251 "bark": { "device_key": "" }
1252 }
1253 });
1254 let intents = notification_secret_intents(patch.as_object().unwrap());
1255 assert!(intents.ntfy_token, "empty string is a clear intent");
1256 assert!(intents.bark_device_key, "empty string is a clear intent");
1257 }
1258
1259 #[test]
1260 fn notification_secret_intents_empty_when_untouched() {
1261 let patch = json!({ "notifications": { "ntfy": { "enabled": true } } });
1262 let intents = notification_secret_intents(patch.as_object().unwrap());
1263 assert!(!intents.ntfy_token);
1264 assert!(!intents.bark_device_key);
1265 }
1266
1267 #[test]
1268 fn clear_notification_ciphertext_drops_roundtripped_ciphertext_on_clear_intents() {
1269 let mut merged = Config::default();
1273 merged.notifications.ntfy.token = None;
1274 merged.notifications.ntfy.token_encrypted = Some("roundtripped-ntfy-ct".to_string());
1275 merged.notifications.bark.device_key = None;
1276 merged.notifications.bark.device_key_encrypted = Some("roundtripped-bark-ct".to_string());
1277
1278 let intents = NotificationSecretIntents {
1279 ntfy_token: true,
1280 bark_device_key: true,
1281 };
1282 clear_notification_ciphertext_for_explicit_clears(&mut merged, &intents);
1283
1284 assert!(merged.notifications.ntfy.token_encrypted.is_none());
1285 assert!(merged.notifications.bark.device_key_encrypted.is_none());
1286 }
1287
1288 #[test]
1289 fn clear_notification_ciphertext_leaves_set_intents_and_unpatched_alone() {
1290 let mut merged = Config::default();
1291 merged.notifications.ntfy.token = Some("brand-new-token".to_string());
1294 merged.notifications.ntfy.token_encrypted = Some("stale-ct".to_string());
1295 merged.notifications.bark.device_key = None;
1297 merged.notifications.bark.device_key_encrypted = Some("kept-ct".to_string());
1298
1299 let intents = NotificationSecretIntents {
1300 ntfy_token: true,
1301 bark_device_key: false,
1302 };
1303 clear_notification_ciphertext_for_explicit_clears(&mut merged, &intents);
1304
1305 assert_eq!(
1306 merged.notifications.ntfy.token_encrypted.as_deref(),
1307 Some("stale-ct")
1308 );
1309 assert_eq!(
1310 merged.notifications.bark.device_key_encrypted.as_deref(),
1311 Some("kept-ct")
1312 );
1313 }
1314
1315 #[test]
1318 fn connect_secret_intents_ignores_masked_and_detects_clear_by_position() {
1319 let patch = json!({
1320 "connect": {
1321 "platforms": [
1322 { "type": "telegram", "token": "****...****" },
1323 { "type": "telegram", "token": "" },
1324 { "type": "feishu", "app_secret": "real-new-secret" }
1325 ]
1326 }
1327 });
1328 let intents = connect_secret_intents(patch.as_object().unwrap());
1329 assert!(
1330 !intents.token.contains(&0),
1331 "masked placeholder is not an intent"
1332 );
1333 assert!(intents.token.contains(&1), "empty string is a clear intent");
1334 assert!(intents.app_secret.contains(&2), "a real value is an intent");
1335 }
1336
1337 #[test]
1338 fn connect_secret_intents_empty_when_no_platforms_patched() {
1339 let patch = json!({ "http_proxy": "http://example.invalid:8080" });
1340 let intents = connect_secret_intents(patch.as_object().unwrap());
1341 assert!(intents.token.is_empty());
1342 assert!(intents.app_secret.is_empty());
1343 }
1344
1345 #[test]
1346 fn clear_connect_ciphertext_drops_roundtripped_ciphertext_on_clear_intents() {
1347 let mut merged = Config::default();
1352 merged.connect.platforms = vec![
1353 connect_platform("telegram", ""), feishu_platform(""),
1355 ];
1356 merged.connect.platforms[0].token = None;
1357 merged.connect.platforms[0].token_encrypted = Some("roundtripped-token-ct".to_string());
1358 merged.connect.platforms[1].app_secret = None;
1359 merged.connect.platforms[1].app_secret_encrypted =
1360 Some("roundtripped-secret-ct".to_string());
1361
1362 let mut intents = ConnectSecretIntents::default();
1363 intents.token.insert(0);
1364 intents.app_secret.insert(1);
1365
1366 clear_connect_ciphertext_for_explicit_clears(&mut merged, &intents);
1367
1368 assert!(merged.connect.platforms[0].token_encrypted.is_none());
1369 assert!(merged.connect.platforms[1].app_secret_encrypted.is_none());
1370 }
1371
1372 #[test]
1373 fn clear_connect_ciphertext_leaves_set_intents_and_unpatched_alone() {
1374 let mut merged = Config::default();
1375 merged.connect.platforms = vec![connect_platform("telegram", "brand-new-token")];
1376 merged.connect.platforms[0].token_encrypted = Some("stale-ct".to_string());
1377 merged
1380 .connect
1381 .platforms
1382 .push(connect_platform("feishu", ""));
1383 merged.connect.platforms[1].token = None;
1384 merged.connect.platforms[1].token_encrypted = Some("kept-ct".to_string());
1385
1386 let mut intents = ConnectSecretIntents::default();
1387 intents.token.insert(0);
1388
1389 clear_connect_ciphertext_for_explicit_clears(&mut merged, &intents);
1390
1391 assert_eq!(
1392 merged.connect.platforms[0].token_encrypted.as_deref(),
1393 Some("stale-ct")
1394 );
1395 assert_eq!(
1396 merged.connect.platforms[1].token_encrypted.as_deref(),
1397 Some("kept-ct")
1398 );
1399 }
1400
1401 #[test]
1402 fn is_masked_api_key_requires_placeholder_only_values() {
1403 assert!(is_masked_api_key("****...****"));
1405 assert!(is_masked_api_key("********"));
1406 assert!(is_masked_api_key(" ****...**** "));
1407
1408 assert!(!is_masked_api_key(""));
1410 assert!(!is_masked_api_key(" "));
1411
1412 assert!(!is_masked_api_key("****...****sk-newkey123"));
1415 assert!(!is_masked_api_key("sk-newkey123****...****"));
1416
1417 assert!(!is_masked_api_key("id.secret...suffix"));
1419 assert!(!is_masked_api_key("sk-live-abc"));
1420 }
1421
1422 #[test]
1423 fn sanitize_root_patch_strips_notification_encrypted_fields() {
1424 let mut patch = json!({
1425 "notifications": {
1426 "ntfy": { "token": "new-token", "token_encrypted": "client-supplied-cipher" },
1427 "bark": { "device_key": "new-key", "device_key_encrypted": "client-supplied-cipher" }
1428 }
1429 });
1430 let obj = patch.as_object_mut().unwrap();
1431 sanitize_root_patch(obj);
1432
1433 assert!(!obj["notifications"]["ntfy"]
1434 .as_object()
1435 .unwrap()
1436 .contains_key("token_encrypted"));
1437 assert!(!obj["notifications"]["bark"]
1438 .as_object()
1439 .unwrap()
1440 .contains_key("device_key_encrypted"));
1441 assert_eq!(obj["notifications"]["ntfy"]["token"], "new-token");
1443 assert_eq!(obj["notifications"]["bark"]["device_key"], "new-key");
1444 }
1445
1446 #[test]
1447 fn sanitize_root_patch_strips_provider_instance_storage_metadata() {
1448 let mut patch = json!({
1449 "provider_instances": {
1450 "work": {
1451 "provider_type": "openai",
1452 "api_key": "sk-user-value",
1453 "api_key_encrypted": "client-ciphertext",
1454 "credential_ref": "attacker.chosen.ref"
1455 }
1456 }
1457 });
1458 let obj = patch.as_object_mut().unwrap();
1459 sanitize_root_patch(obj);
1460
1461 let instance = obj["provider_instances"]["work"].as_object().unwrap();
1462 assert_eq!(instance["api_key"], "sk-user-value");
1463 assert!(!instance.contains_key("api_key_encrypted"));
1464 assert!(!instance.contains_key("credential_ref"));
1465 }
1466
1467 #[test]
1468 fn sanitize_root_patch_strips_proxy_credential_metadata() {
1469 let mut patch = json!({
1470 "http_proxy": "http://proxy.example:8080",
1471 "proxy_auth": {"username": "attacker", "password": "secret"},
1472 "proxy_auth_encrypted": "client-ciphertext",
1473 "proxy_auth_credential_ref": "attacker.chosen.ref"
1474 });
1475 let obj = patch.as_object_mut().unwrap();
1476 sanitize_root_patch(obj);
1477 assert_eq!(obj["http_proxy"], "http://proxy.example:8080");
1478 assert!(!obj.contains_key("proxy_auth"));
1479 assert!(!obj.contains_key("proxy_auth_encrypted"));
1480 assert!(!obj.contains_key("proxy_auth_credential_ref"));
1481 }
1482
1483 #[test]
1484 fn sanitize_root_patch_strips_cluster_storage_metadata_and_ciphertext() {
1485 let mut patch = json!({
1486 "cluster_fabric": {
1487 "credential_refs": {
1488 "node-a": {
1489 "password_credential_ref": "attacker.chosen.ref",
1490 "password_configured": true
1491 }
1492 },
1493 "nodes": [{
1494 "id": "node-a",
1495 "placement": {
1496 "type": "ssh",
1497 "auth": {
1498 "method": "private_key",
1499 "private_key": "new-user-value",
1500 "private_key_encrypted": "client-ciphertext",
1501 "passphrase": "new-passphrase",
1502 "passphrase_encrypted": "client-ciphertext"
1503 }
1504 }
1505 }]
1506 }
1507 });
1508 let obj = patch.as_object_mut().unwrap();
1509 sanitize_root_patch(obj);
1510
1511 let cluster = obj["cluster_fabric"].as_object().unwrap();
1512 assert!(!cluster.contains_key("credential_refs"));
1513 let auth = cluster["nodes"][0]["placement"]["auth"]
1514 .as_object()
1515 .unwrap();
1516 assert_eq!(auth["private_key"], "new-user-value");
1517 assert_eq!(auth["passphrase"], "new-passphrase");
1518 assert!(!auth.contains_key("private_key_encrypted"));
1519 assert!(!auth.contains_key("passphrase_encrypted"));
1520 }
1521
1522 #[test]
1523 fn preserve_masked_notification_secrets_keeps_existing_plaintext() {
1524 let mut current = Config::default();
1525 current.notifications.ntfy.token = Some("existing-ntfy-token".to_string());
1526 current.notifications.bark.device_key = Some("existing-bark-key".to_string());
1527
1528 let mut patch: Map<String, Value> = serde_json::from_str(
1529 r#"{"notifications":{"ntfy":{"token":"****...****"},"bark":{"device_key":"****...****"}}}"#,
1530 )
1531 .unwrap();
1532
1533 preserve_masked_notification_secrets(&mut patch, ¤t);
1534
1535 assert_eq!(
1536 patch["notifications"]["ntfy"]["token"],
1537 "existing-ntfy-token"
1538 );
1539 assert_eq!(
1540 patch["notifications"]["bark"]["device_key"],
1541 "existing-bark-key"
1542 );
1543 }
1544
1545 #[test]
1546 fn preserve_masked_notification_secrets_drops_mask_when_nothing_configured() {
1547 let current = Config::default();
1548 let mut patch: Map<String, Value> =
1549 serde_json::from_str(r#"{"notifications":{"ntfy":{"token":"****...****"}}}"#).unwrap();
1550
1551 preserve_masked_notification_secrets(&mut patch, ¤t);
1552
1553 assert!(!patch["notifications"]["ntfy"]
1554 .as_object()
1555 .unwrap()
1556 .contains_key("token"));
1557 }
1558
1559 #[test]
1560 fn preserve_masked_notification_secrets_leaves_real_values_untouched() {
1561 let current = Config::default();
1562 let mut patch: Map<String, Value> =
1563 serde_json::from_str(r#"{"notifications":{"ntfy":{"token":"tk-real-new-value"}}}"#)
1564 .unwrap();
1565
1566 preserve_masked_notification_secrets(&mut patch, ¤t);
1567
1568 assert_eq!(patch["notifications"]["ntfy"]["token"], "tk-real-new-value");
1569 }
1570
1571 fn connect_platform(platform_type: &str, token: &str) -> crate::ConnectPlatformConfig {
1572 crate::ConnectPlatformConfig {
1573 id: None,
1574 platform_type: platform_type.to_string(),
1575 token: Some(token.to_string()),
1576 token_encrypted: None,
1577 token_credential_ref: None,
1578 token_configured: false,
1579 app_id: None,
1580 app_secret: None,
1581 app_secret_encrypted: None,
1582 app_secret_credential_ref: None,
1583 app_secret_configured: false,
1584 domain: None,
1585 allow_from: Vec::new(),
1586 admin_from: Vec::new(),
1587 }
1588 }
1589
1590 fn connect_platform_with_id(
1591 id: &str,
1592 platform_type: &str,
1593 token: &str,
1594 ) -> crate::ConnectPlatformConfig {
1595 crate::ConnectPlatformConfig {
1596 id: Some(id.to_string()),
1597 ..connect_platform(platform_type, token)
1598 }
1599 }
1600
1601 #[test]
1602 fn sanitize_root_patch_strips_connect_platform_encrypted_field() {
1603 let mut patch = json!({
1604 "connect": {
1605 "platforms": [
1606 { "type": "telegram", "token": "new-token", "token_encrypted": "client-supplied-cipher" }
1607 ]
1608 }
1609 });
1610 let obj = patch.as_object_mut().unwrap();
1611 sanitize_root_patch(obj);
1612
1613 let platform = &obj["connect"]["platforms"][0];
1614 assert!(!platform
1615 .as_object()
1616 .unwrap()
1617 .contains_key("token_encrypted"));
1618 assert_eq!(platform["token"], "new-token");
1619 }
1620
1621 #[test]
1622 fn sanitize_root_patch_strips_connect_platform_app_secret_encrypted_field() {
1623 let mut patch = json!({
1624 "connect": {
1625 "platforms": [
1626 {
1627 "type": "feishu",
1628 "app_id": "cli_x",
1629 "app_secret": "new-secret",
1630 "app_secret_encrypted": "client-supplied-cipher",
1631 "domain": "feishu"
1632 }
1633 ]
1634 }
1635 });
1636 let obj = patch.as_object_mut().unwrap();
1637 sanitize_root_patch(obj);
1638
1639 let platform = &obj["connect"]["platforms"][0];
1640 assert!(!platform
1641 .as_object()
1642 .unwrap()
1643 .contains_key("app_secret_encrypted"));
1644 assert_eq!(platform["app_secret"], "new-secret");
1645 assert_eq!(platform["app_id"], "cli_x");
1646 }
1647
1648 #[test]
1649 fn preserve_masked_connect_secrets_keeps_existing_plaintext_by_position() {
1650 let mut current = Config::default();
1651 current.connect.platforms = vec![connect_platform("telegram", "existing-bot-token")];
1652
1653 let mut patch: Map<String, Value> = serde_json::from_str(
1654 r#"{"connect":{"platforms":[{"type":"telegram","token":"****...****"}]}}"#,
1655 )
1656 .unwrap();
1657
1658 preserve_masked_connect_secrets(&mut patch, ¤t);
1659
1660 assert_eq!(
1661 patch["connect"]["platforms"][0]["token"],
1662 "existing-bot-token"
1663 );
1664 }
1665
1666 #[test]
1667 fn preserve_masked_connect_secrets_drops_mask_when_nothing_configured() {
1668 let current = Config::default();
1669 let mut patch: Map<String, Value> = serde_json::from_str(
1670 r#"{"connect":{"platforms":[{"type":"telegram","token":"****...****"}]}}"#,
1671 )
1672 .unwrap();
1673
1674 preserve_masked_connect_secrets(&mut patch, ¤t);
1675
1676 assert!(!patch["connect"]["platforms"][0]
1677 .as_object()
1678 .unwrap()
1679 .contains_key("token"));
1680 }
1681
1682 #[test]
1683 fn preserve_masked_connect_secrets_leaves_real_values_untouched() {
1684 let current = Config::default();
1685 let mut patch: Map<String, Value> = serde_json::from_str(
1686 r#"{"connect":{"platforms":[{"type":"telegram","token":"tg-real-new-value"}]}}"#,
1687 )
1688 .unwrap();
1689
1690 preserve_masked_connect_secrets(&mut patch, ¤t);
1691
1692 assert_eq!(
1693 patch["connect"]["platforms"][0]["token"],
1694 "tg-real-new-value"
1695 );
1696 }
1697
1698 #[test]
1704 fn preserve_masked_connect_secrets_drops_mask_when_type_at_index_disagrees() {
1705 let mut current = Config::default();
1706 current.connect.platforms = vec![connect_platform("telegram", "telegram-secret-token")];
1707
1708 let mut patch: Map<String, Value> = serde_json::from_str(
1712 r#"{"connect":{"platforms":[{"type":"feishu","token":"****...****"}]}}"#,
1713 )
1714 .unwrap();
1715
1716 preserve_masked_connect_secrets(&mut patch, ¤t);
1717
1718 assert!(
1719 !patch["connect"]["platforms"][0]
1720 .as_object()
1721 .unwrap()
1722 .contains_key("token"),
1723 "masked token must not be resolved against a different platform's secret"
1724 );
1725 }
1726
1727 #[test]
1737 fn preserve_masked_connect_secrets_type_mismatch_falls_back_to_type_lookup() {
1738 let mut current = Config::default();
1739 current.connect.platforms = vec![
1740 connect_platform("telegram", "bot-a-token"),
1741 connect_platform("feishu", "feishu-token"),
1742 ];
1743
1744 let mut patch: Map<String, Value> = serde_json::from_str(
1745 r#"{"connect":{"platforms":[
1746 {"type":"telegram","token":"tg-real-value"},
1747 {"type":"telegram","token":"****...****"}
1748 ]}}"#,
1749 )
1750 .unwrap();
1751
1752 preserve_masked_connect_secrets(&mut patch, ¤t);
1753
1754 assert_eq!(patch["connect"]["platforms"][0]["token"], "tg-real-value");
1755 assert_eq!(
1756 patch["connect"]["platforms"][1]["token"], "bot-a-token",
1757 "index 1's mismatched type must fall back to the same-typed entry found elsewhere in current"
1758 );
1759 }
1760
1761 #[test]
1765 fn preserve_masked_connect_secrets_keeps_plaintext_when_type_at_index_matches() {
1766 let mut current = Config::default();
1767 current.connect.platforms = vec![
1768 connect_platform("telegram", "bot-a-token"),
1769 connect_platform("telegram", "bot-b-token"),
1770 ];
1771
1772 let mut patch: Map<String, Value> = serde_json::from_str(
1773 r#"{"connect":{"platforms":[
1774 {"type":"telegram","token":"****...****"},
1775 {"type":"telegram","token":"****...****"}
1776 ]}}"#,
1777 )
1778 .unwrap();
1779
1780 preserve_masked_connect_secrets(&mut patch, ¤t);
1781
1782 assert_eq!(patch["connect"]["platforms"][0]["token"], "bot-a-token");
1783 assert_eq!(patch["connect"]["platforms"][1]["token"], "bot-b-token");
1784 }
1785
1786 #[test]
1790 fn preserve_masked_connect_secrets_falls_back_to_positional_when_type_is_absent() {
1791 let mut current = Config::default();
1792 current.connect.platforms = vec![connect_platform("telegram", "existing-bot-token")];
1793
1794 let mut patch: Map<String, Value> =
1795 serde_json::from_str(r#"{"connect":{"platforms":[{"token":"****...****"}]}}"#).unwrap();
1796
1797 preserve_masked_connect_secrets(&mut patch, ¤t);
1798
1799 assert_eq!(
1800 patch["connect"]["platforms"][0]["token"],
1801 "existing-bot-token"
1802 );
1803 }
1804
1805 #[test]
1813 fn preserve_masked_connect_secrets_resolves_duplicate_type_by_id_even_when_reordered() {
1814 let mut current = Config::default();
1815 current.connect.platforms = vec![
1816 connect_platform_with_id("id-a", "telegram", "bot-a-token"),
1817 connect_platform_with_id("id-b", "telegram", "bot-b-token"),
1818 ];
1819
1820 let mut patch: Map<String, Value> = serde_json::from_str(
1825 r#"{"connect":{"platforms":[
1826 {"id":"id-b","type":"telegram","token":"****...****"},
1827 {"id":"id-a","type":"telegram","token":"****...****"}
1828 ]}}"#,
1829 )
1830 .unwrap();
1831
1832 preserve_masked_connect_secrets(&mut patch, ¤t);
1833
1834 assert_eq!(
1835 patch["connect"]["platforms"][0]["token"], "bot-b-token",
1836 "index 0 (id-b) must resolve to id-b's own token, not the positionally-co-located id-a"
1837 );
1838 assert_eq!(
1839 patch["connect"]["platforms"][1]["token"], "bot-a-token",
1840 "index 1 (id-a) must resolve to id-a's own token"
1841 );
1842 }
1843
1844 #[test]
1850 fn preserve_masked_connect_secrets_falls_back_when_id_not_found_in_current() {
1851 let mut current = Config::default();
1852 current.connect.platforms = vec![connect_platform("telegram", "existing-bot-token")];
1853
1854 let mut patch: Map<String, Value> = serde_json::from_str(
1855 r#"{"connect":{"platforms":[{"id":"unknown-id","type":"telegram","token":"****...****"}]}}"#,
1856 )
1857 .unwrap();
1858
1859 preserve_masked_connect_secrets(&mut patch, ¤t);
1860
1861 assert_eq!(
1862 patch["connect"]["platforms"][0]["token"], "existing-bot-token",
1863 "an id absent from `current` falls back to the positional+type guard"
1864 );
1865 }
1866
1867 #[test]
1874 fn preserve_masked_connect_secrets_rejects_id_match_when_type_differs() {
1875 let mut current = Config::default();
1876 current.connect.platforms = vec![connect_platform_with_id(
1877 "id-a",
1878 "telegram",
1879 "telegram-secret-token",
1880 )];
1881
1882 let mut patch: Map<String, Value> = serde_json::from_str(
1886 r#"{"connect":{"platforms":[{"id":"id-a","type":"feishu","token":"****...****"}]}}"#,
1887 )
1888 .unwrap();
1889
1890 preserve_masked_connect_secrets(&mut patch, ¤t);
1891
1892 assert!(
1893 !patch["connect"]["platforms"][0]
1894 .as_object()
1895 .unwrap()
1896 .contains_key("token"),
1897 "an id match with a disagreeing type must not resolve the mask from that entry"
1898 );
1899 }
1900
1901 #[test]
1906 fn preserve_masked_connect_secrets_type_mismatched_id_still_falls_through_to_type_lookup() {
1907 let mut current = Config::default();
1908 current.connect.platforms = vec![
1909 connect_platform_with_id("id-a", "telegram", "telegram-secret-token"),
1910 connect_platform_with_id("id-b", "feishu", "feishu-secret-token"),
1911 ];
1912
1913 let mut patch: Map<String, Value> = serde_json::from_str(
1918 r#"{"connect":{"platforms":[{"id":"id-a","type":"feishu","token":"****...****"}]}}"#,
1919 )
1920 .unwrap();
1921
1922 preserve_masked_connect_secrets(&mut patch, ¤t);
1923
1924 assert_eq!(
1925 patch["connect"]["platforms"][0]["token"], "feishu-secret-token",
1926 "the fall-through must resolve via the type lookup, never via the mismatched id"
1927 );
1928 }
1929
1930 #[test]
1936 fn preserve_masked_connect_secrets_id_without_type_resolves_on_id_alone() {
1937 let mut current = Config::default();
1938 current.connect.platforms = vec![
1939 connect_platform_with_id("id-a", "telegram", "bot-a-token"),
1940 connect_platform_with_id("id-b", "telegram", "bot-b-token"),
1941 ];
1942
1943 let mut patch: Map<String, Value> = serde_json::from_str(
1944 r#"{"connect":{"platforms":[{"id":"id-b","token":"****...****"}]}}"#,
1945 )
1946 .unwrap();
1947
1948 preserve_masked_connect_secrets(&mut patch, ¤t);
1949
1950 assert_eq!(
1951 patch["connect"]["platforms"][0]["token"], "bot-b-token",
1952 "with no type to check, the id match resolves to its own entry, not the positional one"
1953 );
1954 }
1955
1956 #[test]
1963 fn preserve_masked_connect_secrets_ignores_id_branch_when_patch_omits_id() {
1964 let mut current = Config::default();
1965 current.connect.platforms = vec![connect_platform_with_id(
1966 "id-a",
1967 "telegram",
1968 "existing-bot-token",
1969 )];
1970
1971 let mut patch: Map<String, Value> = serde_json::from_str(
1972 r#"{"connect":{"platforms":[{"type":"telegram","token":"****...****"}]}}"#,
1973 )
1974 .unwrap();
1975
1976 preserve_masked_connect_secrets(&mut patch, ¤t);
1977
1978 assert_eq!(
1979 patch["connect"]["platforms"][0]["token"],
1980 "existing-bot-token"
1981 );
1982 }
1983
1984 fn feishu_platform(app_secret: &str) -> crate::ConnectPlatformConfig {
1985 crate::ConnectPlatformConfig {
1986 id: None,
1987 platform_type: "feishu".to_string(),
1988 token: None,
1989 token_encrypted: None,
1990 token_credential_ref: None,
1991 token_configured: false,
1992 app_id: Some("cli_x".to_string()),
1993 app_secret: Some(app_secret.to_string()),
1994 app_secret_encrypted: None,
1995 app_secret_credential_ref: None,
1996 app_secret_configured: false,
1997 domain: Some("lark".to_string()),
1998 allow_from: Vec::new(),
1999 admin_from: Vec::new(),
2000 }
2001 }
2002
2003 #[test]
2004 fn preserve_masked_connect_secrets_keeps_existing_app_secret_by_position() {
2005 let mut current = Config::default();
2006 current.connect.platforms = vec![feishu_platform("existing-app-secret")];
2007
2008 let mut patch: Map<String, Value> = serde_json::from_str(
2009 r#"{"connect":{"platforms":[{"type":"feishu","app_id":"cli_x","app_secret":"****...****","domain":"lark"}]}}"#,
2010 )
2011 .unwrap();
2012
2013 preserve_masked_connect_secrets(&mut patch, ¤t);
2014
2015 assert_eq!(
2016 patch["connect"]["platforms"][0]["app_secret"],
2017 "existing-app-secret"
2018 );
2019 assert_eq!(patch["connect"]["platforms"][0]["app_id"], "cli_x");
2021 assert_eq!(patch["connect"]["platforms"][0]["domain"], "lark");
2022 }
2023
2024 #[test]
2025 fn preserve_masked_connect_secrets_drops_app_secret_mask_when_nothing_configured() {
2026 let current = Config::default();
2027 let mut patch: Map<String, Value> = serde_json::from_str(
2028 r#"{"connect":{"platforms":[{"type":"feishu","app_secret":"****...****"}]}}"#,
2029 )
2030 .unwrap();
2031
2032 preserve_masked_connect_secrets(&mut patch, ¤t);
2033
2034 assert!(!patch["connect"]["platforms"][0]
2035 .as_object()
2036 .unwrap()
2037 .contains_key("app_secret"));
2038 }
2039
2040 #[test]
2041 fn preserve_masked_connect_secrets_leaves_real_app_secret_untouched() {
2042 let current = Config::default();
2043 let mut patch: Map<String, Value> = serde_json::from_str(
2044 r#"{"connect":{"platforms":[{"type":"feishu","app_secret":"feishu-real-new-value"}]}}"#,
2045 )
2046 .unwrap();
2047
2048 preserve_masked_connect_secrets(&mut patch, ¤t);
2049
2050 assert_eq!(
2051 patch["connect"]["platforms"][0]["app_secret"],
2052 "feishu-real-new-value"
2053 );
2054 }
2055
2056 #[test]
2060 fn preserve_masked_connect_secrets_drops_app_secret_mask_when_type_at_index_disagrees() {
2061 let mut current = Config::default();
2062 current.connect.platforms = vec![feishu_platform("feishu-secret")];
2063
2064 let mut patch: Map<String, Value> = serde_json::from_str(
2065 r#"{"connect":{"platforms":[{"type":"telegram","app_secret":"****...****"}]}}"#,
2066 )
2067 .unwrap();
2068
2069 preserve_masked_connect_secrets(&mut patch, ¤t);
2070
2071 assert!(
2072 !patch["connect"]["platforms"][0]
2073 .as_object()
2074 .unwrap()
2075 .contains_key("app_secret"),
2076 "masked app_secret must not be resolved against a different platform's secret"
2077 );
2078 }
2079
2080 #[test]
2088 fn preserve_masked_connect_secrets_resolves_by_type_when_preceding_entry_removed() {
2089 let mut current = Config::default();
2090 current.connect.platforms = vec![
2091 connect_platform("telegram", "telegram-token"),
2092 feishu_platform("existing-app-secret"),
2093 ];
2094
2095 let mut patch: Map<String, Value> = serde_json::from_str(
2098 r#"{"connect":{"platforms":[
2099 {"type":"feishu","app_id":"cli_x","app_secret":"****...****","domain":"lark"}
2100 ]}}"#,
2101 )
2102 .unwrap();
2103
2104 preserve_masked_connect_secrets(&mut patch, ¤t);
2105
2106 assert_eq!(
2107 patch["connect"]["platforms"][0]["app_secret"], "existing-app-secret",
2108 "masked app_secret must resolve via type fallback after a preceding entry was removed"
2109 );
2110 }
2111
2112 #[test]
2117 fn preserve_masked_connect_secrets_resolves_by_type_when_index_out_of_range() {
2118 let mut current = Config::default();
2119 current.connect.platforms = vec![
2120 connect_platform("telegram", "telegram-token"),
2121 feishu_platform("existing-app-secret"),
2122 ];
2123
2124 let mut patch: Map<String, Value> = serde_json::from_str(
2128 r#"{"connect":{"platforms":[
2129 {"type":"telegram","token":"tg-real-value"},
2130 {"type":"telegram","token":"new-bot-token"},
2131 {"type":"feishu","app_id":"cli_x","app_secret":"****...****","domain":"lark"}
2132 ]}}"#,
2133 )
2134 .unwrap();
2135
2136 preserve_masked_connect_secrets(&mut patch, ¤t);
2137
2138 assert_eq!(
2139 patch["connect"]["platforms"][2]["app_secret"], "existing-app-secret",
2140 "masked app_secret at an out-of-range index must resolve via type fallback"
2141 );
2142 }
2143
2144 #[test]
2149 fn preserve_masked_connect_secrets_drops_mask_when_type_absent_from_current_entirely() {
2150 let mut current = Config::default();
2151 current.connect.platforms = vec![connect_platform("telegram", "telegram-token")];
2152
2153 let mut patch: Map<String, Value> = serde_json::from_str(
2156 r#"{"connect":{"platforms":[
2157 {"type":"feishu","app_id":"cli_x","app_secret":"****...****","domain":"lark"}
2158 ]}}"#,
2159 )
2160 .unwrap();
2161
2162 preserve_masked_connect_secrets(&mut patch, ¤t);
2163
2164 assert!(
2165 !patch["connect"]["platforms"][0]
2166 .as_object()
2167 .unwrap()
2168 .contains_key("app_secret"),
2169 "mask must still drop when no entry of that type exists anywhere in current"
2170 );
2171 }
2172
2173 fn merge_and_deserialize(current: &Config, patch: Value) -> Config {
2180 let mut merged = current.to_compatibility_value().unwrap();
2181 deep_merge_json(&mut merged, patch);
2182 serde_json::from_value(merged).expect("merged config should deserialize")
2183 }
2184
2185 #[test]
2186 fn null_deletes_option_scalar_leaf() {
2187 let mut current = Config::default();
2191 current.subagents.claude_code_binary = Some("/usr/local/bin/claude".to_string());
2192 current.subagents.claude_code_model = Some("claude-sonnet".to_string());
2193
2194 let merged = merge_and_deserialize(
2195 ¤t,
2196 json!({ "subagents": { "claude_code_binary": null } }),
2197 );
2198
2199 assert_eq!(merged.subagents.claude_code_binary, None);
2200 assert_eq!(
2203 merged.subagents.claude_code_model,
2204 Some("claude-sonnet".to_string())
2205 );
2206 }
2207
2208 #[test]
2209 fn absent_key_leaves_value_unchanged_back_compat() {
2210 let mut current = Config::default();
2214 current.subagents.claude_code_binary = Some("/usr/local/bin/claude".to_string());
2215 current.subagents.max_concurrent = Some(4);
2216
2217 let merged =
2220 merge_and_deserialize(¤t, json!({ "subagents": { "max_concurrent": 16 } }));
2221
2222 assert_eq!(
2223 merged.subagents.claude_code_binary,
2224 Some("/usr/local/bin/claude".to_string()),
2225 "an omitted key must leave the existing value untouched"
2226 );
2227 assert_eq!(merged.subagents.max_concurrent, Some(16));
2228 }
2229
2230 #[test]
2231 fn null_on_whole_object_subtree_resets_it_to_defaults() {
2232 let mut current = Config::default();
2238 current.notifications.ntfy.token = Some("existing-token".to_string());
2239 current.notifications.ntfy.enabled = true;
2240
2241 let merged = merge_and_deserialize(¤t, json!({ "notifications": null }));
2242
2243 assert_eq!(merged.notifications, crate::NotificationsConfig::default());
2244 }
2245
2246 #[test]
2247 fn null_deletes_one_hashmap_entry_keeps_siblings() {
2248 fn labeled_instance(label: &str) -> crate::ProviderInstanceConfig {
2261 serde_json::from_value(json!({
2262 "provider_type": "openai",
2263 "label": label,
2264 }))
2265 .expect("valid instance")
2266 }
2267
2268 let mut current = Config::default();
2269 current
2270 .provider_instances
2271 .insert("uuid-1".to_string(), labeled_instance("Work"));
2272 current
2273 .provider_instances
2274 .insert("uuid-2".to_string(), labeled_instance("Personal"));
2275
2276 let merged = merge_and_deserialize(
2277 ¤t,
2278 json!({ "provider_instances": { "uuid-1": null } }),
2279 );
2280
2281 assert!(!merged.provider_instances.contains_key("uuid-1"));
2282 assert_eq!(
2283 merged
2284 .provider_instances
2285 .get("uuid-2")
2286 .and_then(|i| i.label.as_deref()),
2287 Some("Personal"),
2288 "sibling map entries the patch didn't touch must survive"
2289 );
2290 }
2291
2292 #[test]
2293 fn null_on_whole_array_field_resets_it_to_empty() {
2294 let mut current = Config::default();
2301 current.connect.platforms = vec![connect_platform("telegram", "tok")];
2302
2303 let merged = merge_and_deserialize(¤t, json!({ "connect": { "platforms": null } }));
2304
2305 assert!(merged.connect.platforms.is_empty());
2306 }
2307
2308 #[test]
2309 fn null_inside_a_surviving_array_is_a_literal_element_not_a_delete_marker() {
2310 let mut current = Config::default();
2319 current.subagents.worker_args = Some(vec!["subagent-worker".to_string()]);
2320
2321 let mut merged = serde_json::to_value(¤t).unwrap();
2322 deep_merge_json(
2323 &mut merged,
2324 json!({ "subagents": { "worker_args": ["a", null, "b"] } }),
2325 );
2326
2327 assert_eq!(
2330 merged["subagents"]["worker_args"],
2331 json!(["a", null, "b"]),
2332 "arrays are leaf-replaced verbatim; a null element is not deleted"
2333 );
2334 let result: Result<Config, _> = serde_json::from_value(merged);
2335 assert!(
2336 result.is_err(),
2337 "a literal null inside a Vec<String> is a type error, not an element delete"
2338 );
2339 }
2340
2341 #[test]
2342 fn sentinel_string_values_still_work_after_null_delete_support() {
2343 let current = Config::default();
2347
2348 let merged = merge_and_deserialize(
2349 ¤t,
2350 json!({ "subagents": { "executor": "bamboo_runtime" } }),
2351 );
2352
2353 assert_eq!(
2354 merged.subagents.executor,
2355 Some("bamboo_runtime".to_string())
2356 );
2357 }
2358
2359 #[test]
2360 fn subagents_max_concurrent_null_clears_it_like_todays_lotus_ui() {
2361 let mut current = Config::default();
2371 current.subagents.max_concurrent = Some(4);
2372
2373 let merged =
2374 merge_and_deserialize(¤t, json!({ "subagents": { "max_concurrent": null } }));
2375
2376 assert_eq!(merged.subagents.max_concurrent, None);
2377 }
2378
2379 #[test]
2382 fn provider_api_key_intents_treats_null_as_clear_intent() {
2383 let patch = json!({
2384 "providers": { "openai": { "api_key": null } },
2385 "provider_instances": { "uuid-1": { "api_key": null } }
2386 });
2387 let intents = provider_api_key_intents(patch.as_object().unwrap());
2388 assert!(
2389 intents.providers.contains("openai"),
2390 "a null api_key must register as an explicit clear intent, \
2391 same as an empty string — otherwise preserve_unpatched_provider_secrets \
2392 would resurrect the deleted key"
2393 );
2394 assert!(intents.provider_instances.contains("uuid-1"));
2395 }
2396
2397 #[test]
2398 fn provider_api_key_intents_treats_whole_instance_null_as_delete_intent() {
2399 let patch = json!({ "provider_instances": { "uuid-1": null } });
2400 let intents = provider_api_key_intents(patch.as_object().unwrap());
2401 assert!(intents.provider_instances.contains("uuid-1"));
2402 }
2403
2404 #[test]
2405 fn provider_api_key_intents_null_and_empty_string_are_equivalent_intents() {
2406 let null_patch = json!({ "providers": { "openai": { "api_key": null } } });
2407 let empty_patch = json!({ "providers": { "openai": { "api_key": "" } } });
2408 assert_eq!(
2409 provider_api_key_intents(null_patch.as_object().unwrap()),
2410 provider_api_key_intents(empty_patch.as_object().unwrap()),
2411 "null and \"\" must be recognized as the same clear intent"
2412 );
2413 }
2414
2415 #[test]
2416 fn null_api_key_does_not_get_resurrected_by_preserve_unpatched_secrets() {
2417 let mut current = Config::default();
2423 current
2424 .provider_instances
2425 .insert("uuid-1".to_string(), instance("sk-old", None));
2426
2427 let patch = json!({ "provider_instances": { "uuid-1": { "api_key": null } } });
2428 let intents = provider_api_key_intents(patch.as_object().unwrap());
2429 assert!(intents.provider_instances.contains("uuid-1"));
2430
2431 let mut merged = merge_and_deserialize(¤t, patch);
2432 preserve_unpatched_provider_secrets(&mut merged, ¤t, &intents);
2433
2434 assert_eq!(
2435 merged.provider_instances["uuid-1"].api_key, "",
2436 "a null-delete of api_key must stick, not get resurrected from `current`"
2437 );
2438 }
2439
2440 #[test]
2441 fn notification_secret_intents_treats_null_as_clear_intent() {
2442 let patch = json!({
2443 "notifications": { "ntfy": { "token": null }, "bark": { "device_key": null } }
2444 });
2445 let intents = notification_secret_intents(patch.as_object().unwrap());
2446 assert!(intents.ntfy_token);
2447 assert!(intents.bark_device_key);
2448 }
2449
2450 #[test]
2451 fn connect_secret_intents_treats_null_as_clear_intent() {
2452 let patch = json!({
2453 "connect": { "platforms": [ { "type": "telegram", "token": null } ] }
2454 });
2455 let intents = connect_secret_intents(patch.as_object().unwrap());
2456 assert!(intents.token.contains(&0));
2457 }
2458
2459 #[test]
2460 fn null_ntfy_token_clears_roundtripped_ciphertext_via_clear_intents() {
2461 let mut current = Config::default();
2465 current.notifications.ntfy.token = Some("existing-token".to_string());
2466 current.notifications.ntfy.token_encrypted = Some("existing-ct".to_string());
2467
2468 let patch = json!({ "notifications": { "ntfy": { "token": null } } });
2469 let intents = notification_secret_intents(patch.as_object().unwrap());
2470 assert!(intents.ntfy_token);
2471
2472 let mut merged = merge_and_deserialize(¤t, patch);
2473 assert!(
2474 merged.notifications.ntfy.token_encrypted.is_none(),
2475 "legacy notification ciphertext is no longer serialized through the merge"
2476 );
2477
2478 clear_notification_ciphertext_for_explicit_clears(&mut merged, &intents);
2479
2480 assert_eq!(merged.notifications.ntfy.token, None);
2481 assert!(
2482 merged.notifications.ntfy.token_encrypted.is_none(),
2483 "the clear-intent pass must drop the stale ciphertext so hydration can't refill it"
2484 );
2485 }
2486
2487 #[test]
2488 fn whole_providers_null_wipes_everything_without_resurrecting_secrets() {
2489 let mut current = Config::default();
2498 current.providers.openai = Some(crate::OpenAIConfig {
2499 api_key: "sk-legacy-live".to_string(),
2500 ..Default::default()
2501 });
2502
2503 let patch = json!({ "providers": null });
2504 let intents = provider_api_key_intents(patch.as_object().unwrap());
2505 assert!(intents.providers.is_empty());
2506
2507 let mut merged = merge_and_deserialize(¤t, patch);
2508 assert!(
2509 merged.providers.openai.is_none(),
2510 "the whole providers subtree must reset to default"
2511 );
2512
2513 preserve_unpatched_provider_secrets(&mut merged, ¤t, &intents);
2514
2515 assert!(
2516 merged.providers.openai.is_none(),
2517 "carry-forward must not resurrect a provider the patch wiped out entirely"
2518 );
2519 }
2520}