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 let Some(obj) = instance_patch.as_object() else {
86 continue;
87 };
88 if is_secret_field_intent(obj, "api_key") {
89 intents.provider_instances.insert(instance_id.clone());
90 }
91 }
92 }
93
94 intents
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum ReloadMode {
100 None,
101 BestEffort,
103 Strict,
105}
106
107#[derive(Debug, Clone, Copy)]
109pub struct PatchEffects {
110 pub reload_provider: ReloadMode,
111 pub reconcile_mcp: bool,
112}
113
114#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
116pub struct DomainChanges {
117 pub provider: bool,
118 pub proxy: bool,
119 pub setup: bool,
120 pub mcp: bool,
121 pub keyword_masking: bool,
122 pub hooks: bool,
123 pub model_mapping: bool,
124}
125
126pub fn domains_for_root_patch(patch_obj: &Map<String, Value>) -> DomainChanges {
128 let mut changes = DomainChanges::default();
129
130 for key in patch_obj.keys() {
131 match key.as_str() {
132 "provider"
134 | "providers"
135 | "provider_instances"
136 | "default_provider_instance"
137 | "model"
138 | "defaults"
139 | "features" => changes.provider = true,
140
141 "http_proxy"
143 | "https_proxy"
144 | "proxy_auth"
145 | "proxy_auth_encrypted"
146 | "http_proxy_auth_encrypted"
147 | "https_proxy_auth_encrypted" => changes.proxy = true,
148
149 "setup" => changes.setup = true,
151
152 "mcp" | "mcpServers" => changes.mcp = true,
154
155 "keyword_masking" => changes.keyword_masking = true,
157 "hooks" => changes.hooks = true,
158 "anthropic_model_mapping" | "gemini_model_mapping" => changes.model_mapping = true,
159
160 _ => {}
161 }
162 }
163
164 changes
165}
166
167pub fn effects_for_root_patch(patch_obj: &Map<String, Value>) -> PatchEffects {
169 let domains = domains_for_root_patch(patch_obj);
170
171 let touches_provider = domains.provider || domains.hooks || domains.keyword_masking;
172 let touches_proxy = domains.proxy;
173 let touches_mcp = domains.mcp;
174
175 PatchEffects {
176 reload_provider: if touches_provider || touches_proxy {
177 ReloadMode::BestEffort
178 } else {
179 ReloadMode::None
180 },
181 reconcile_mcp: touches_mcp || touches_proxy,
184 }
185}
186
187pub fn sanitize_root_patch(patch_obj: &mut Map<String, Value>) {
192 patch_obj.remove("proxy_auth");
194 patch_obj.remove("proxy_auth_encrypted");
195 patch_obj.remove("http_proxy_auth_encrypted");
197 patch_obj.remove("https_proxy_auth_encrypted");
198 patch_obj.remove("data_dir");
199
200 if let Some(providers) = patch_obj
202 .get_mut("providers")
203 .and_then(|v| v.as_object_mut())
204 {
205 for (_provider_name, provider_cfg) in providers.iter_mut() {
206 let Some(obj) = provider_cfg.as_object_mut() else {
207 continue;
208 };
209 obj.remove("api_key_encrypted");
210 }
211 }
212
213 if let Some(provider_instances) = patch_obj
214 .get_mut("provider_instances")
215 .and_then(|v| v.as_object_mut())
216 {
217 for (_instance_id, instance_cfg) in provider_instances.iter_mut() {
218 let Some(obj) = instance_cfg.as_object_mut() else {
219 continue;
220 };
221 obj.remove("api_key_encrypted");
222 }
223 }
224
225 if let Some(notifications) = patch_obj
227 .get_mut("notifications")
228 .and_then(|v| v.as_object_mut())
229 {
230 if let Some(ntfy) = notifications
231 .get_mut("ntfy")
232 .and_then(|v| v.as_object_mut())
233 {
234 ntfy.remove("token_encrypted");
235 }
236 if let Some(bark) = notifications
237 .get_mut("bark")
238 .and_then(|v| v.as_object_mut())
239 {
240 bark.remove("device_key_encrypted");
241 }
242 }
243
244 if let Some(platforms) = patch_obj
247 .get_mut("connect")
248 .and_then(|c| c.get_mut("platforms"))
249 .and_then(|v| v.as_array_mut())
250 {
251 for platform in platforms.iter_mut() {
252 if let Some(obj) = platform.as_object_mut() {
253 obj.remove("token_encrypted");
254 obj.remove("app_secret_encrypted");
255 }
256 }
257 }
258
259 if let Some(mcp_servers) = patch_obj
264 .get_mut("mcpServers")
265 .and_then(|v| v.as_object_mut())
266 {
267 for (_id, server) in mcp_servers.iter_mut() {
268 let Some(server_obj) = server.as_object_mut() else {
269 continue;
270 };
271 server_obj.remove("env_encrypted");
272 if let Some(headers) = server_obj.get_mut("headers").and_then(|v| v.as_array_mut()) {
273 for header in headers.iter_mut() {
274 let Some(header_obj) = header.as_object_mut() else {
275 continue;
276 };
277 header_obj.remove("value_encrypted");
278 }
279 }
280 }
281 }
282
283 if let Some(servers) = patch_obj
286 .get_mut("mcp")
287 .and_then(|m| m.get_mut("servers"))
288 .and_then(|v| v.as_array_mut())
289 {
290 for server in servers.iter_mut() {
291 let Some(server_obj) = server.as_object_mut() else {
292 continue;
293 };
294 let Some(transport) = server_obj
295 .get_mut("transport")
296 .and_then(|v| v.as_object_mut())
297 else {
298 continue;
299 };
300
301 match transport.get("type").and_then(|v| v.as_str()) {
302 Some("stdio") => {
303 transport.remove("env_encrypted");
304 }
305 Some("sse") => {
306 if let Some(headers) =
307 transport.get_mut("headers").and_then(|v| v.as_array_mut())
308 {
309 for header in headers.iter_mut() {
310 let Some(header_obj) = header.as_object_mut() else {
311 continue;
312 };
313 header_obj.remove("value_encrypted");
314 }
315 }
316 }
317 _ => {}
318 }
319 }
320 }
321}
322
323pub fn preserve_masked_provider_api_keys(patch_obj: &mut Map<String, Value>, current: &Config) {
328 if let Some(patch_providers) = patch_obj
329 .get_mut("providers")
330 .and_then(|v| v.as_object_mut())
331 {
332 for (provider_name, provider_patch) in patch_providers.iter_mut() {
333 let Some(patch_cfg_obj) = provider_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 = match provider_name.as_str() {
345 "openai" => current.providers.openai.as_ref().map(|c| c.api_key.clone()),
346 "anthropic" => current
347 .providers
348 .anthropic
349 .as_ref()
350 .map(|c| c.api_key.clone()),
351 "gemini" => current.providers.gemini.as_ref().map(|c| c.api_key.clone()),
352 "bodhi" => current.providers.bodhi.as_ref().map(|c| c.api_key.clone()),
353 _ => None,
354 };
355
356 if let Some(existing_plain) = existing_plain {
357 if !existing_plain.trim().is_empty() {
358 patch_cfg_obj.insert("api_key".to_string(), Value::String(existing_plain));
359 } else {
360 patch_cfg_obj.remove("api_key");
361 }
362 } else {
363 patch_cfg_obj.remove("api_key");
364 }
365 }
366 }
367
368 if let Some(patch_instances) = patch_obj
369 .get_mut("provider_instances")
370 .and_then(|v| v.as_object_mut())
371 {
372 for (instance_id, instance_patch) in patch_instances.iter_mut() {
373 let Some(patch_cfg_obj) = instance_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 = current
385 .provider_instances
386 .get(instance_id)
387 .map(|instance| instance.api_key.clone());
388
389 if let Some(existing_plain) = existing_plain {
390 if !existing_plain.trim().is_empty() {
391 patch_cfg_obj.insert("api_key".to_string(), Value::String(existing_plain));
392 } else {
393 patch_cfg_obj.remove("api_key");
394 }
395 } else {
396 patch_cfg_obj.remove("api_key");
397 }
398 }
399 }
400}
401
402pub fn preserve_unpatched_provider_secrets(
422 merged: &mut Config,
423 current: &Config,
424 intents: &ProviderApiKeyIntents,
425) {
426 macro_rules! carry_forward {
427 ($field:ident) => {
428 if !intents.providers.contains(stringify!($field)) {
429 if let (Some(new_cfg), Some(prev)) = (
430 merged.providers.$field.as_mut(),
431 current.providers.$field.as_ref(),
432 ) {
433 if new_cfg.api_key.trim().is_empty()
434 && new_cfg.api_key_encrypted.is_none()
435 && (!prev.api_key.trim().is_empty() || prev.api_key_encrypted.is_some())
436 {
437 new_cfg.api_key = prev.api_key.clone();
438 new_cfg.api_key_encrypted = prev.api_key_encrypted.clone();
439 }
440 }
441 }
442 };
443 }
444 carry_forward!(openai);
445 carry_forward!(anthropic);
446 carry_forward!(gemini);
447 carry_forward!(bodhi);
448
449 for (id, instance) in merged.provider_instances.iter_mut() {
450 if intents.provider_instances.contains(id) {
451 continue;
452 }
453 if !instance.api_key.trim().is_empty() || instance.api_key_encrypted.is_some() {
454 continue;
455 }
456 if let Some(prev) = current.provider_instances.get(id) {
457 if !prev.api_key.trim().is_empty() || prev.api_key_encrypted.is_some() {
458 instance.api_key = prev.api_key.clone();
459 instance.api_key_encrypted = prev.api_key_encrypted.clone();
460 }
461 }
462 }
463}
464
465pub fn clear_provider_ciphertext_for_explicit_clears(
474 merged: &mut Config,
475 intents: &ProviderApiKeyIntents,
476) {
477 macro_rules! clear_ciphertext {
478 ($field:ident) => {
479 if intents.providers.contains(stringify!($field)) {
480 if let Some(cfg) = merged.providers.$field.as_mut() {
481 if cfg.api_key.trim().is_empty() {
482 cfg.api_key_encrypted = None;
483 }
484 }
485 }
486 };
487 }
488 clear_ciphertext!(openai);
489 clear_ciphertext!(anthropic);
490 clear_ciphertext!(gemini);
491 clear_ciphertext!(bodhi);
492
493 for id in intents.provider_instances.iter() {
494 if let Some(instance) = merged.provider_instances.get_mut(id) {
495 if instance.api_key.trim().is_empty() {
496 instance.api_key_encrypted = None;
497 }
498 }
499 }
500}
501
502#[derive(Debug, Clone, Default, PartialEq, Eq)]
513pub struct NotificationSecretIntents {
514 pub ntfy_token: bool,
515 pub bark_device_key: bool,
516}
517
518pub fn notification_secret_intents(patch_obj: &Map<String, Value>) -> NotificationSecretIntents {
519 let mut intents = NotificationSecretIntents::default();
520
521 let Some(notifications) = patch_obj.get("notifications").and_then(|v| v.as_object()) else {
522 return intents;
523 };
524
525 if let Some(ntfy) = notifications.get("ntfy").and_then(|v| v.as_object()) {
526 intents.ntfy_token = is_secret_field_intent(ntfy, "token");
527 }
528
529 if let Some(bark) = notifications.get("bark").and_then(|v| v.as_object()) {
530 intents.bark_device_key = is_secret_field_intent(bark, "device_key");
531 }
532
533 intents
534}
535
536pub fn clear_notification_ciphertext_for_explicit_clears(
552 merged: &mut Config,
553 intents: &NotificationSecretIntents,
554) {
555 if intents.ntfy_token
556 && merged
557 .notifications
558 .ntfy
559 .token
560 .as_deref()
561 .unwrap_or("")
562 .trim()
563 .is_empty()
564 {
565 merged.notifications.ntfy.token_encrypted = None;
566 }
567
568 if intents.bark_device_key
569 && merged
570 .notifications
571 .bark
572 .device_key
573 .as_deref()
574 .unwrap_or("")
575 .trim()
576 .is_empty()
577 {
578 merged.notifications.bark.device_key_encrypted = None;
579 }
580}
581
582#[derive(Debug, Clone, Default, PartialEq, Eq)]
601pub struct ConnectSecretIntents {
602 pub token: std::collections::BTreeSet<usize>,
603 pub app_secret: std::collections::BTreeSet<usize>,
604}
605
606pub fn connect_secret_intents(patch_obj: &Map<String, Value>) -> ConnectSecretIntents {
607 let mut intents = ConnectSecretIntents::default();
608
609 let Some(platforms) = patch_obj
610 .get("connect")
611 .and_then(|c| c.get("platforms"))
612 .and_then(|v| v.as_array())
613 else {
614 return intents;
615 };
616
617 for (index, platform) in platforms.iter().enumerate() {
618 let Some(obj) = platform.as_object() else {
619 continue;
620 };
621
622 if is_secret_field_intent(obj, "token") {
623 intents.token.insert(index);
624 }
625
626 if is_secret_field_intent(obj, "app_secret") {
627 intents.app_secret.insert(index);
628 }
629 }
630
631 intents
632}
633
634pub fn clear_connect_ciphertext_for_explicit_clears(
643 merged: &mut Config,
644 intents: &ConnectSecretIntents,
645) {
646 for &index in intents.token.iter() {
647 if let Some(platform) = merged.connect.platforms.get_mut(index) {
648 if platform.token.as_deref().unwrap_or("").trim().is_empty() {
649 platform.token_encrypted = None;
650 }
651 }
652 }
653
654 for &index in intents.app_secret.iter() {
655 if let Some(platform) = merged.connect.platforms.get_mut(index) {
656 if platform
657 .app_secret
658 .as_deref()
659 .unwrap_or("")
660 .trim()
661 .is_empty()
662 {
663 platform.app_secret_encrypted = None;
664 }
665 }
666 }
667}
668
669pub fn preserve_masked_notification_secrets(patch_obj: &mut Map<String, Value>, current: &Config) {
678 let Some(notifications) = patch_obj
679 .get_mut("notifications")
680 .and_then(|v| v.as_object_mut())
681 else {
682 return;
683 };
684
685 if let Some(ntfy) = notifications
686 .get_mut("ntfy")
687 .and_then(|v| v.as_object_mut())
688 {
689 preserve_masked_secret_field(ntfy, "token", current.notifications.ntfy.token.as_deref());
690 }
691
692 if let Some(bark) = notifications
693 .get_mut("bark")
694 .and_then(|v| v.as_object_mut())
695 {
696 preserve_masked_secret_field(
697 bark,
698 "device_key",
699 current.notifications.bark.device_key.as_deref(),
700 );
701 }
702}
703
704pub fn preserve_masked_connect_secrets(patch_obj: &mut Map<String, Value>, current: &Config) {
748 let Some(platforms) = patch_obj
749 .get_mut("connect")
750 .and_then(|c| c.get_mut("platforms"))
751 .and_then(|v| v.as_array_mut())
752 else {
753 return;
754 };
755
756 for (index, platform) in platforms.iter_mut().enumerate() {
757 let Some(obj) = platform.as_object_mut() else {
758 continue;
759 };
760 let patch_type = obj.get("type").and_then(|v| v.as_str());
761 let patch_id = obj.get("id").and_then(|v| v.as_str());
762
763 let by_id = patch_id.and_then(|patch_id| {
770 current.connect.platforms.iter().find(|p| {
771 p.id.as_deref() == Some(patch_id)
772 && match patch_type {
773 Some(patch_type) => patch_type == p.platform_type,
774 None => true,
775 }
776 })
777 });
778
779 let existing = current.connect.platforms.get(index);
785 let guarded = by_id.or_else(|| {
795 existing
796 .filter(|p| match patch_type {
797 Some(patch_type) => patch_type == p.platform_type,
798 None => true,
799 })
800 .or_else(|| {
801 patch_type.and_then(|patch_type| {
802 current
803 .connect
804 .platforms
805 .iter()
806 .find(|p| p.platform_type == patch_type)
807 })
808 })
809 });
810 preserve_masked_secret_field(obj, "token", guarded.and_then(|p| p.token.as_deref()));
811 preserve_masked_secret_field(
812 obj,
813 "app_secret",
814 guarded.and_then(|p| p.app_secret.as_deref()),
815 );
816 }
817}
818
819fn preserve_masked_secret_field(
824 obj: &mut Map<String, Value>,
825 field: &str,
826 existing_plain: Option<&str>,
827) {
828 let Some(value) = obj.get(field).and_then(|v| v.as_str()) else {
829 return;
830 };
831 if !is_masked_api_key(value) {
832 return;
833 }
834
835 match existing_plain {
836 Some(plain) if !plain.trim().is_empty() => {
837 obj.insert(field.to_string(), Value::String(plain.to_string()));
838 }
839 _ => {
840 obj.remove(field);
841 }
842 }
843}
844
845pub fn deep_merge_json(dst: &mut Value, src: Value) {
921 match (dst, src) {
922 (Value::Object(dst_map), Value::Object(src_map)) => {
923 for (key, value) in src_map {
924 if value.is_null() {
925 dst_map.remove(&key);
934 continue;
935 }
936 match dst_map.get_mut(&key) {
937 Some(existing) if existing.is_object() && value.is_object() => {
938 deep_merge_json(existing, value);
939 }
940 _ => {
941 dst_map.insert(key, value);
942 }
943 }
944 }
945 }
946 (dst_slot, src_value) => {
947 *dst_slot = src_value;
948 }
949 }
950}
951
952#[cfg(test)]
953mod tests {
954 use super::*;
955 use serde_json::json;
956
957 #[test]
958 fn domains_for_root_patch_detects_proxy_and_provider() {
959 let patch = json!({
960 "provider": "openai",
961 "http_proxy": "http://proxy:8080",
962 "setup": { "completed": false },
963 "mcpServers": {}
964 });
965
966 let domains = domains_for_root_patch(patch.as_object().unwrap());
967 assert!(domains.provider);
968 assert!(domains.proxy);
969 assert!(domains.setup);
970 assert!(domains.mcp);
971 }
972
973 #[test]
974 fn domains_for_root_patch_detects_provider_instances() {
975 let patch = json!({
976 "provider_instances": {
977 "openai-work": { "provider_type": "openai" }
978 },
979 "default_provider_instance": "openai-work",
980 "defaults": {
981 "chat": { "provider": "openai-work", "model": "gpt-4o" }
982 },
983 "features": {
984 "provider_model_ref": true
985 }
986 });
987
988 let domains = domains_for_root_patch(patch.as_object().unwrap());
989 assert!(domains.provider);
990 }
991
992 #[test]
993 fn provider_api_key_intents_ignores_masked_placeholders() {
994 let patch = json!({
995 "providers": {
996 "openai": { "api_key": "****...****" },
997 "gemini": { "api_key": "sk-real" }
998 },
999 "provider_instances": {
1000 "work-openai": { "api_key": "****...****" },
1001 "personal-openai": { "api_key": "sk-live" }
1002 }
1003 });
1004 let intents = provider_api_key_intents(patch.as_object().unwrap());
1005 assert!(intents.providers.contains("gemini"));
1006 assert!(!intents.providers.contains("openai"));
1007 assert!(intents.provider_instances.contains("personal-openai"));
1008 assert!(!intents.provider_instances.contains("work-openai"));
1009 }
1010
1011 fn instance(api_key: &str, encrypted: Option<&str>) -> crate::ProviderInstanceConfig {
1014 serde_json::from_value(json!({
1015 "provider_type": "openai",
1016 "api_key": api_key,
1017 "api_key_encrypted": encrypted,
1018 }))
1019 .expect("valid instance")
1020 }
1021
1022 #[test]
1023 fn preserve_unpatched_provider_secrets_restores_roundtrip_dropped_keys() {
1024 let mut current = Config::default();
1028 current
1029 .provider_instances
1030 .insert("uuid-1".to_string(), instance("sk-instance-live", None));
1031 current.providers.openai = Some(crate::OpenAIConfig {
1032 api_key: "sk-legacy-live".to_string(),
1033 ..Default::default()
1034 });
1035
1036 let mut merged = Config::default();
1038 merged
1039 .provider_instances
1040 .insert("uuid-1".to_string(), instance("", None));
1041 merged.providers.openai = Some(crate::OpenAIConfig::default());
1042
1043 preserve_unpatched_provider_secrets(
1044 &mut merged,
1045 ¤t,
1046 &ProviderApiKeyIntents::default(),
1047 );
1048
1049 assert_eq!(
1050 merged.provider_instances["uuid-1"].api_key,
1051 "sk-instance-live"
1052 );
1053 assert_eq!(
1054 merged.providers.openai.as_ref().unwrap().api_key,
1055 "sk-legacy-live"
1056 );
1057 }
1058
1059 #[test]
1060 fn preserve_unpatched_provider_secrets_carries_ciphertext_only_keys() {
1061 let mut current = Config::default();
1064 current
1065 .provider_instances
1066 .insert("uuid-1".to_string(), instance("", Some("preexisting-ct")));
1067
1068 let mut merged = Config::default();
1069 merged
1070 .provider_instances
1071 .insert("uuid-1".to_string(), instance("", None));
1072
1073 preserve_unpatched_provider_secrets(
1074 &mut merged,
1075 ¤t,
1076 &ProviderApiKeyIntents::default(),
1077 );
1078
1079 assert_eq!(
1080 merged.provider_instances["uuid-1"]
1081 .api_key_encrypted
1082 .as_deref(),
1083 Some("preexisting-ct")
1084 );
1085 }
1086
1087 #[test]
1088 fn preserve_unpatched_provider_secrets_respects_explicit_intents() {
1089 let mut current = Config::default();
1092 current
1093 .provider_instances
1094 .insert("uuid-1".to_string(), instance("sk-old", None));
1095
1096 let mut merged = Config::default();
1097 merged
1098 .provider_instances
1099 .insert("uuid-1".to_string(), instance("", None));
1100
1101 let mut intents = ProviderApiKeyIntents::default();
1102 intents.provider_instances.insert("uuid-1".to_string());
1103
1104 preserve_unpatched_provider_secrets(&mut merged, ¤t, &intents);
1105
1106 let cleared = &merged.provider_instances["uuid-1"];
1107 assert!(cleared.api_key.is_empty(), "explicit clear must win");
1108 assert!(cleared.api_key_encrypted.is_none());
1109 }
1110
1111 #[test]
1112 fn clear_provider_ciphertext_drops_roundtripped_ciphertext_on_clear_intents() {
1113 let mut merged = Config::default();
1117 merged
1118 .provider_instances
1119 .insert("uuid-1".to_string(), instance("", Some("roundtripped-ct")));
1120 merged.providers.openai = Some(crate::OpenAIConfig {
1121 api_key_encrypted: Some("legacy-ct".to_string()),
1122 ..Default::default()
1123 });
1124
1125 let mut intents = ProviderApiKeyIntents::default();
1126 intents.provider_instances.insert("uuid-1".to_string());
1127 intents.providers.insert("openai".to_string());
1128
1129 clear_provider_ciphertext_for_explicit_clears(&mut merged, &intents);
1130
1131 assert!(merged.provider_instances["uuid-1"]
1132 .api_key_encrypted
1133 .is_none());
1134 assert!(merged
1135 .providers
1136 .openai
1137 .as_ref()
1138 .unwrap()
1139 .api_key_encrypted
1140 .is_none());
1141 }
1142
1143 #[test]
1144 fn clear_provider_ciphertext_leaves_set_intents_and_unpatched_alone() {
1145 let mut merged = Config::default();
1149 merged
1150 .provider_instances
1151 .insert("uuid-1".to_string(), instance("sk-new", Some("stale-ct")));
1152 merged
1153 .provider_instances
1154 .insert("uuid-2".to_string(), instance("", Some("kept-ct")));
1155
1156 let mut intents = ProviderApiKeyIntents::default();
1157 intents.provider_instances.insert("uuid-1".to_string());
1158
1159 clear_provider_ciphertext_for_explicit_clears(&mut merged, &intents);
1160
1161 assert!(merged.provider_instances["uuid-1"]
1162 .api_key_encrypted
1163 .is_some());
1164 assert_eq!(
1165 merged.provider_instances["uuid-2"]
1166 .api_key_encrypted
1167 .as_deref(),
1168 Some("kept-ct")
1169 );
1170 }
1171
1172 #[test]
1175 fn notification_secret_intents_ignores_masked_placeholders() {
1176 let patch = json!({
1177 "notifications": {
1178 "ntfy": { "token": "****...****" },
1179 "bark": { "device_key": "tk-real-new-value" }
1180 }
1181 });
1182 let intents = notification_secret_intents(patch.as_object().unwrap());
1183 assert!(!intents.ntfy_token, "masked placeholder is not an intent");
1184 assert!(intents.bark_device_key, "a real value is an intent");
1185 }
1186
1187 #[test]
1188 fn notification_secret_intents_detects_explicit_clear() {
1189 let patch = json!({
1190 "notifications": {
1191 "ntfy": { "token": "" },
1192 "bark": { "device_key": "" }
1193 }
1194 });
1195 let intents = notification_secret_intents(patch.as_object().unwrap());
1196 assert!(intents.ntfy_token, "empty string is a clear intent");
1197 assert!(intents.bark_device_key, "empty string is a clear intent");
1198 }
1199
1200 #[test]
1201 fn notification_secret_intents_empty_when_untouched() {
1202 let patch = json!({ "notifications": { "ntfy": { "enabled": true } } });
1203 let intents = notification_secret_intents(patch.as_object().unwrap());
1204 assert!(!intents.ntfy_token);
1205 assert!(!intents.bark_device_key);
1206 }
1207
1208 #[test]
1209 fn clear_notification_ciphertext_drops_roundtripped_ciphertext_on_clear_intents() {
1210 let mut merged = Config::default();
1214 merged.notifications.ntfy.token = None;
1215 merged.notifications.ntfy.token_encrypted = Some("roundtripped-ntfy-ct".to_string());
1216 merged.notifications.bark.device_key = None;
1217 merged.notifications.bark.device_key_encrypted = Some("roundtripped-bark-ct".to_string());
1218
1219 let intents = NotificationSecretIntents {
1220 ntfy_token: true,
1221 bark_device_key: true,
1222 };
1223 clear_notification_ciphertext_for_explicit_clears(&mut merged, &intents);
1224
1225 assert!(merged.notifications.ntfy.token_encrypted.is_none());
1226 assert!(merged.notifications.bark.device_key_encrypted.is_none());
1227 }
1228
1229 #[test]
1230 fn clear_notification_ciphertext_leaves_set_intents_and_unpatched_alone() {
1231 let mut merged = Config::default();
1232 merged.notifications.ntfy.token = Some("brand-new-token".to_string());
1235 merged.notifications.ntfy.token_encrypted = Some("stale-ct".to_string());
1236 merged.notifications.bark.device_key = None;
1238 merged.notifications.bark.device_key_encrypted = Some("kept-ct".to_string());
1239
1240 let intents = NotificationSecretIntents {
1241 ntfy_token: true,
1242 bark_device_key: false,
1243 };
1244 clear_notification_ciphertext_for_explicit_clears(&mut merged, &intents);
1245
1246 assert_eq!(
1247 merged.notifications.ntfy.token_encrypted.as_deref(),
1248 Some("stale-ct")
1249 );
1250 assert_eq!(
1251 merged.notifications.bark.device_key_encrypted.as_deref(),
1252 Some("kept-ct")
1253 );
1254 }
1255
1256 #[test]
1259 fn connect_secret_intents_ignores_masked_and_detects_clear_by_position() {
1260 let patch = json!({
1261 "connect": {
1262 "platforms": [
1263 { "type": "telegram", "token": "****...****" },
1264 { "type": "telegram", "token": "" },
1265 { "type": "feishu", "app_secret": "real-new-secret" }
1266 ]
1267 }
1268 });
1269 let intents = connect_secret_intents(patch.as_object().unwrap());
1270 assert!(
1271 !intents.token.contains(&0),
1272 "masked placeholder is not an intent"
1273 );
1274 assert!(intents.token.contains(&1), "empty string is a clear intent");
1275 assert!(intents.app_secret.contains(&2), "a real value is an intent");
1276 }
1277
1278 #[test]
1279 fn connect_secret_intents_empty_when_no_platforms_patched() {
1280 let patch = json!({ "http_proxy": "http://example.invalid:8080" });
1281 let intents = connect_secret_intents(patch.as_object().unwrap());
1282 assert!(intents.token.is_empty());
1283 assert!(intents.app_secret.is_empty());
1284 }
1285
1286 #[test]
1287 fn clear_connect_ciphertext_drops_roundtripped_ciphertext_on_clear_intents() {
1288 let mut merged = Config::default();
1293 merged.connect.platforms = vec![
1294 connect_platform("telegram", ""), feishu_platform(""),
1296 ];
1297 merged.connect.platforms[0].token = None;
1298 merged.connect.platforms[0].token_encrypted = Some("roundtripped-token-ct".to_string());
1299 merged.connect.platforms[1].app_secret = None;
1300 merged.connect.platforms[1].app_secret_encrypted =
1301 Some("roundtripped-secret-ct".to_string());
1302
1303 let mut intents = ConnectSecretIntents::default();
1304 intents.token.insert(0);
1305 intents.app_secret.insert(1);
1306
1307 clear_connect_ciphertext_for_explicit_clears(&mut merged, &intents);
1308
1309 assert!(merged.connect.platforms[0].token_encrypted.is_none());
1310 assert!(merged.connect.platforms[1].app_secret_encrypted.is_none());
1311 }
1312
1313 #[test]
1314 fn clear_connect_ciphertext_leaves_set_intents_and_unpatched_alone() {
1315 let mut merged = Config::default();
1316 merged.connect.platforms = vec![connect_platform("telegram", "brand-new-token")];
1317 merged.connect.platforms[0].token_encrypted = Some("stale-ct".to_string());
1318 merged
1321 .connect
1322 .platforms
1323 .push(connect_platform("feishu", ""));
1324 merged.connect.platforms[1].token = None;
1325 merged.connect.platforms[1].token_encrypted = Some("kept-ct".to_string());
1326
1327 let mut intents = ConnectSecretIntents::default();
1328 intents.token.insert(0);
1329
1330 clear_connect_ciphertext_for_explicit_clears(&mut merged, &intents);
1331
1332 assert_eq!(
1333 merged.connect.platforms[0].token_encrypted.as_deref(),
1334 Some("stale-ct")
1335 );
1336 assert_eq!(
1337 merged.connect.platforms[1].token_encrypted.as_deref(),
1338 Some("kept-ct")
1339 );
1340 }
1341
1342 #[test]
1343 fn is_masked_api_key_requires_placeholder_only_values() {
1344 assert!(is_masked_api_key("****...****"));
1346 assert!(is_masked_api_key("********"));
1347 assert!(is_masked_api_key(" ****...**** "));
1348
1349 assert!(!is_masked_api_key(""));
1351 assert!(!is_masked_api_key(" "));
1352
1353 assert!(!is_masked_api_key("****...****sk-newkey123"));
1356 assert!(!is_masked_api_key("sk-newkey123****...****"));
1357
1358 assert!(!is_masked_api_key("id.secret...suffix"));
1360 assert!(!is_masked_api_key("sk-live-abc"));
1361 }
1362
1363 #[test]
1364 fn sanitize_root_patch_strips_notification_encrypted_fields() {
1365 let mut patch = json!({
1366 "notifications": {
1367 "ntfy": { "token": "new-token", "token_encrypted": "client-supplied-cipher" },
1368 "bark": { "device_key": "new-key", "device_key_encrypted": "client-supplied-cipher" }
1369 }
1370 });
1371 let obj = patch.as_object_mut().unwrap();
1372 sanitize_root_patch(obj);
1373
1374 assert!(!obj["notifications"]["ntfy"]
1375 .as_object()
1376 .unwrap()
1377 .contains_key("token_encrypted"));
1378 assert!(!obj["notifications"]["bark"]
1379 .as_object()
1380 .unwrap()
1381 .contains_key("device_key_encrypted"));
1382 assert_eq!(obj["notifications"]["ntfy"]["token"], "new-token");
1384 assert_eq!(obj["notifications"]["bark"]["device_key"], "new-key");
1385 }
1386
1387 #[test]
1388 fn preserve_masked_notification_secrets_keeps_existing_plaintext() {
1389 let mut current = Config::default();
1390 current.notifications.ntfy.token = Some("existing-ntfy-token".to_string());
1391 current.notifications.bark.device_key = Some("existing-bark-key".to_string());
1392
1393 let mut patch: Map<String, Value> = serde_json::from_str(
1394 r#"{"notifications":{"ntfy":{"token":"****...****"},"bark":{"device_key":"****...****"}}}"#,
1395 )
1396 .unwrap();
1397
1398 preserve_masked_notification_secrets(&mut patch, ¤t);
1399
1400 assert_eq!(
1401 patch["notifications"]["ntfy"]["token"],
1402 "existing-ntfy-token"
1403 );
1404 assert_eq!(
1405 patch["notifications"]["bark"]["device_key"],
1406 "existing-bark-key"
1407 );
1408 }
1409
1410 #[test]
1411 fn preserve_masked_notification_secrets_drops_mask_when_nothing_configured() {
1412 let current = Config::default();
1413 let mut patch: Map<String, Value> =
1414 serde_json::from_str(r#"{"notifications":{"ntfy":{"token":"****...****"}}}"#).unwrap();
1415
1416 preserve_masked_notification_secrets(&mut patch, ¤t);
1417
1418 assert!(!patch["notifications"]["ntfy"]
1419 .as_object()
1420 .unwrap()
1421 .contains_key("token"));
1422 }
1423
1424 #[test]
1425 fn preserve_masked_notification_secrets_leaves_real_values_untouched() {
1426 let current = Config::default();
1427 let mut patch: Map<String, Value> =
1428 serde_json::from_str(r#"{"notifications":{"ntfy":{"token":"tk-real-new-value"}}}"#)
1429 .unwrap();
1430
1431 preserve_masked_notification_secrets(&mut patch, ¤t);
1432
1433 assert_eq!(patch["notifications"]["ntfy"]["token"], "tk-real-new-value");
1434 }
1435
1436 fn connect_platform(platform_type: &str, token: &str) -> crate::ConnectPlatformConfig {
1437 crate::ConnectPlatformConfig {
1438 id: None,
1439 platform_type: platform_type.to_string(),
1440 token: Some(token.to_string()),
1441 token_encrypted: None,
1442 app_id: None,
1443 app_secret: None,
1444 app_secret_encrypted: None,
1445 domain: None,
1446 allow_from: Vec::new(),
1447 admin_from: Vec::new(),
1448 }
1449 }
1450
1451 fn connect_platform_with_id(
1452 id: &str,
1453 platform_type: &str,
1454 token: &str,
1455 ) -> crate::ConnectPlatformConfig {
1456 crate::ConnectPlatformConfig {
1457 id: Some(id.to_string()),
1458 ..connect_platform(platform_type, token)
1459 }
1460 }
1461
1462 #[test]
1463 fn sanitize_root_patch_strips_connect_platform_encrypted_field() {
1464 let mut patch = json!({
1465 "connect": {
1466 "platforms": [
1467 { "type": "telegram", "token": "new-token", "token_encrypted": "client-supplied-cipher" }
1468 ]
1469 }
1470 });
1471 let obj = patch.as_object_mut().unwrap();
1472 sanitize_root_patch(obj);
1473
1474 let platform = &obj["connect"]["platforms"][0];
1475 assert!(!platform
1476 .as_object()
1477 .unwrap()
1478 .contains_key("token_encrypted"));
1479 assert_eq!(platform["token"], "new-token");
1480 }
1481
1482 #[test]
1483 fn sanitize_root_patch_strips_connect_platform_app_secret_encrypted_field() {
1484 let mut patch = json!({
1485 "connect": {
1486 "platforms": [
1487 {
1488 "type": "feishu",
1489 "app_id": "cli_x",
1490 "app_secret": "new-secret",
1491 "app_secret_encrypted": "client-supplied-cipher",
1492 "domain": "feishu"
1493 }
1494 ]
1495 }
1496 });
1497 let obj = patch.as_object_mut().unwrap();
1498 sanitize_root_patch(obj);
1499
1500 let platform = &obj["connect"]["platforms"][0];
1501 assert!(!platform
1502 .as_object()
1503 .unwrap()
1504 .contains_key("app_secret_encrypted"));
1505 assert_eq!(platform["app_secret"], "new-secret");
1506 assert_eq!(platform["app_id"], "cli_x");
1507 }
1508
1509 #[test]
1510 fn preserve_masked_connect_secrets_keeps_existing_plaintext_by_position() {
1511 let mut current = Config::default();
1512 current.connect.platforms = vec![connect_platform("telegram", "existing-bot-token")];
1513
1514 let mut patch: Map<String, Value> = serde_json::from_str(
1515 r#"{"connect":{"platforms":[{"type":"telegram","token":"****...****"}]}}"#,
1516 )
1517 .unwrap();
1518
1519 preserve_masked_connect_secrets(&mut patch, ¤t);
1520
1521 assert_eq!(
1522 patch["connect"]["platforms"][0]["token"],
1523 "existing-bot-token"
1524 );
1525 }
1526
1527 #[test]
1528 fn preserve_masked_connect_secrets_drops_mask_when_nothing_configured() {
1529 let current = Config::default();
1530 let mut patch: Map<String, Value> = serde_json::from_str(
1531 r#"{"connect":{"platforms":[{"type":"telegram","token":"****...****"}]}}"#,
1532 )
1533 .unwrap();
1534
1535 preserve_masked_connect_secrets(&mut patch, ¤t);
1536
1537 assert!(!patch["connect"]["platforms"][0]
1538 .as_object()
1539 .unwrap()
1540 .contains_key("token"));
1541 }
1542
1543 #[test]
1544 fn preserve_masked_connect_secrets_leaves_real_values_untouched() {
1545 let current = Config::default();
1546 let mut patch: Map<String, Value> = serde_json::from_str(
1547 r#"{"connect":{"platforms":[{"type":"telegram","token":"tg-real-new-value"}]}}"#,
1548 )
1549 .unwrap();
1550
1551 preserve_masked_connect_secrets(&mut patch, ¤t);
1552
1553 assert_eq!(
1554 patch["connect"]["platforms"][0]["token"],
1555 "tg-real-new-value"
1556 );
1557 }
1558
1559 #[test]
1565 fn preserve_masked_connect_secrets_drops_mask_when_type_at_index_disagrees() {
1566 let mut current = Config::default();
1567 current.connect.platforms = vec![connect_platform("telegram", "telegram-secret-token")];
1568
1569 let mut patch: Map<String, Value> = serde_json::from_str(
1573 r#"{"connect":{"platforms":[{"type":"feishu","token":"****...****"}]}}"#,
1574 )
1575 .unwrap();
1576
1577 preserve_masked_connect_secrets(&mut patch, ¤t);
1578
1579 assert!(
1580 !patch["connect"]["platforms"][0]
1581 .as_object()
1582 .unwrap()
1583 .contains_key("token"),
1584 "masked token must not be resolved against a different platform's secret"
1585 );
1586 }
1587
1588 #[test]
1598 fn preserve_masked_connect_secrets_type_mismatch_falls_back_to_type_lookup() {
1599 let mut current = Config::default();
1600 current.connect.platforms = vec![
1601 connect_platform("telegram", "bot-a-token"),
1602 connect_platform("feishu", "feishu-token"),
1603 ];
1604
1605 let mut patch: Map<String, Value> = serde_json::from_str(
1606 r#"{"connect":{"platforms":[
1607 {"type":"telegram","token":"tg-real-value"},
1608 {"type":"telegram","token":"****...****"}
1609 ]}}"#,
1610 )
1611 .unwrap();
1612
1613 preserve_masked_connect_secrets(&mut patch, ¤t);
1614
1615 assert_eq!(patch["connect"]["platforms"][0]["token"], "tg-real-value");
1616 assert_eq!(
1617 patch["connect"]["platforms"][1]["token"], "bot-a-token",
1618 "index 1's mismatched type must fall back to the same-typed entry found elsewhere in current"
1619 );
1620 }
1621
1622 #[test]
1626 fn preserve_masked_connect_secrets_keeps_plaintext_when_type_at_index_matches() {
1627 let mut current = Config::default();
1628 current.connect.platforms = vec![
1629 connect_platform("telegram", "bot-a-token"),
1630 connect_platform("telegram", "bot-b-token"),
1631 ];
1632
1633 let mut patch: Map<String, Value> = serde_json::from_str(
1634 r#"{"connect":{"platforms":[
1635 {"type":"telegram","token":"****...****"},
1636 {"type":"telegram","token":"****...****"}
1637 ]}}"#,
1638 )
1639 .unwrap();
1640
1641 preserve_masked_connect_secrets(&mut patch, ¤t);
1642
1643 assert_eq!(patch["connect"]["platforms"][0]["token"], "bot-a-token");
1644 assert_eq!(patch["connect"]["platforms"][1]["token"], "bot-b-token");
1645 }
1646
1647 #[test]
1651 fn preserve_masked_connect_secrets_falls_back_to_positional_when_type_is_absent() {
1652 let mut current = Config::default();
1653 current.connect.platforms = vec![connect_platform("telegram", "existing-bot-token")];
1654
1655 let mut patch: Map<String, Value> =
1656 serde_json::from_str(r#"{"connect":{"platforms":[{"token":"****...****"}]}}"#).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]
1674 fn preserve_masked_connect_secrets_resolves_duplicate_type_by_id_even_when_reordered() {
1675 let mut current = Config::default();
1676 current.connect.platforms = vec![
1677 connect_platform_with_id("id-a", "telegram", "bot-a-token"),
1678 connect_platform_with_id("id-b", "telegram", "bot-b-token"),
1679 ];
1680
1681 let mut patch: Map<String, Value> = serde_json::from_str(
1686 r#"{"connect":{"platforms":[
1687 {"id":"id-b","type":"telegram","token":"****...****"},
1688 {"id":"id-a","type":"telegram","token":"****...****"}
1689 ]}}"#,
1690 )
1691 .unwrap();
1692
1693 preserve_masked_connect_secrets(&mut patch, ¤t);
1694
1695 assert_eq!(
1696 patch["connect"]["platforms"][0]["token"], "bot-b-token",
1697 "index 0 (id-b) must resolve to id-b's own token, not the positionally-co-located id-a"
1698 );
1699 assert_eq!(
1700 patch["connect"]["platforms"][1]["token"], "bot-a-token",
1701 "index 1 (id-a) must resolve to id-a's own token"
1702 );
1703 }
1704
1705 #[test]
1711 fn preserve_masked_connect_secrets_falls_back_when_id_not_found_in_current() {
1712 let mut current = Config::default();
1713 current.connect.platforms = vec![connect_platform("telegram", "existing-bot-token")];
1714
1715 let mut patch: Map<String, Value> = serde_json::from_str(
1716 r#"{"connect":{"platforms":[{"id":"unknown-id","type":"telegram","token":"****...****"}]}}"#,
1717 )
1718 .unwrap();
1719
1720 preserve_masked_connect_secrets(&mut patch, ¤t);
1721
1722 assert_eq!(
1723 patch["connect"]["platforms"][0]["token"], "existing-bot-token",
1724 "an id absent from `current` falls back to the positional+type guard"
1725 );
1726 }
1727
1728 #[test]
1735 fn preserve_masked_connect_secrets_rejects_id_match_when_type_differs() {
1736 let mut current = Config::default();
1737 current.connect.platforms = vec![connect_platform_with_id(
1738 "id-a",
1739 "telegram",
1740 "telegram-secret-token",
1741 )];
1742
1743 let mut patch: Map<String, Value> = serde_json::from_str(
1747 r#"{"connect":{"platforms":[{"id":"id-a","type":"feishu","token":"****...****"}]}}"#,
1748 )
1749 .unwrap();
1750
1751 preserve_masked_connect_secrets(&mut patch, ¤t);
1752
1753 assert!(
1754 !patch["connect"]["platforms"][0]
1755 .as_object()
1756 .unwrap()
1757 .contains_key("token"),
1758 "an id match with a disagreeing type must not resolve the mask from that entry"
1759 );
1760 }
1761
1762 #[test]
1767 fn preserve_masked_connect_secrets_type_mismatched_id_still_falls_through_to_type_lookup() {
1768 let mut current = Config::default();
1769 current.connect.platforms = vec![
1770 connect_platform_with_id("id-a", "telegram", "telegram-secret-token"),
1771 connect_platform_with_id("id-b", "feishu", "feishu-secret-token"),
1772 ];
1773
1774 let mut patch: Map<String, Value> = serde_json::from_str(
1779 r#"{"connect":{"platforms":[{"id":"id-a","type":"feishu","token":"****...****"}]}}"#,
1780 )
1781 .unwrap();
1782
1783 preserve_masked_connect_secrets(&mut patch, ¤t);
1784
1785 assert_eq!(
1786 patch["connect"]["platforms"][0]["token"], "feishu-secret-token",
1787 "the fall-through must resolve via the type lookup, never via the mismatched id"
1788 );
1789 }
1790
1791 #[test]
1797 fn preserve_masked_connect_secrets_id_without_type_resolves_on_id_alone() {
1798 let mut current = Config::default();
1799 current.connect.platforms = vec![
1800 connect_platform_with_id("id-a", "telegram", "bot-a-token"),
1801 connect_platform_with_id("id-b", "telegram", "bot-b-token"),
1802 ];
1803
1804 let mut patch: Map<String, Value> = serde_json::from_str(
1805 r#"{"connect":{"platforms":[{"id":"id-b","token":"****...****"}]}}"#,
1806 )
1807 .unwrap();
1808
1809 preserve_masked_connect_secrets(&mut patch, ¤t);
1810
1811 assert_eq!(
1812 patch["connect"]["platforms"][0]["token"], "bot-b-token",
1813 "with no type to check, the id match resolves to its own entry, not the positional one"
1814 );
1815 }
1816
1817 #[test]
1824 fn preserve_masked_connect_secrets_ignores_id_branch_when_patch_omits_id() {
1825 let mut current = Config::default();
1826 current.connect.platforms = vec![connect_platform_with_id(
1827 "id-a",
1828 "telegram",
1829 "existing-bot-token",
1830 )];
1831
1832 let mut patch: Map<String, Value> = serde_json::from_str(
1833 r#"{"connect":{"platforms":[{"type":"telegram","token":"****...****"}]}}"#,
1834 )
1835 .unwrap();
1836
1837 preserve_masked_connect_secrets(&mut patch, ¤t);
1838
1839 assert_eq!(
1840 patch["connect"]["platforms"][0]["token"],
1841 "existing-bot-token"
1842 );
1843 }
1844
1845 fn feishu_platform(app_secret: &str) -> crate::ConnectPlatformConfig {
1846 crate::ConnectPlatformConfig {
1847 id: None,
1848 platform_type: "feishu".to_string(),
1849 token: None,
1850 token_encrypted: None,
1851 app_id: Some("cli_x".to_string()),
1852 app_secret: Some(app_secret.to_string()),
1853 app_secret_encrypted: None,
1854 domain: Some("lark".to_string()),
1855 allow_from: Vec::new(),
1856 admin_from: Vec::new(),
1857 }
1858 }
1859
1860 #[test]
1861 fn preserve_masked_connect_secrets_keeps_existing_app_secret_by_position() {
1862 let mut current = Config::default();
1863 current.connect.platforms = vec![feishu_platform("existing-app-secret")];
1864
1865 let mut patch: Map<String, Value> = serde_json::from_str(
1866 r#"{"connect":{"platforms":[{"type":"feishu","app_id":"cli_x","app_secret":"****...****","domain":"lark"}]}}"#,
1867 )
1868 .unwrap();
1869
1870 preserve_masked_connect_secrets(&mut patch, ¤t);
1871
1872 assert_eq!(
1873 patch["connect"]["platforms"][0]["app_secret"],
1874 "existing-app-secret"
1875 );
1876 assert_eq!(patch["connect"]["platforms"][0]["app_id"], "cli_x");
1878 assert_eq!(patch["connect"]["platforms"][0]["domain"], "lark");
1879 }
1880
1881 #[test]
1882 fn preserve_masked_connect_secrets_drops_app_secret_mask_when_nothing_configured() {
1883 let current = Config::default();
1884 let mut patch: Map<String, Value> = serde_json::from_str(
1885 r#"{"connect":{"platforms":[{"type":"feishu","app_secret":"****...****"}]}}"#,
1886 )
1887 .unwrap();
1888
1889 preserve_masked_connect_secrets(&mut patch, ¤t);
1890
1891 assert!(!patch["connect"]["platforms"][0]
1892 .as_object()
1893 .unwrap()
1894 .contains_key("app_secret"));
1895 }
1896
1897 #[test]
1898 fn preserve_masked_connect_secrets_leaves_real_app_secret_untouched() {
1899 let current = Config::default();
1900 let mut patch: Map<String, Value> = serde_json::from_str(
1901 r#"{"connect":{"platforms":[{"type":"feishu","app_secret":"feishu-real-new-value"}]}}"#,
1902 )
1903 .unwrap();
1904
1905 preserve_masked_connect_secrets(&mut patch, ¤t);
1906
1907 assert_eq!(
1908 patch["connect"]["platforms"][0]["app_secret"],
1909 "feishu-real-new-value"
1910 );
1911 }
1912
1913 #[test]
1917 fn preserve_masked_connect_secrets_drops_app_secret_mask_when_type_at_index_disagrees() {
1918 let mut current = Config::default();
1919 current.connect.platforms = vec![feishu_platform("feishu-secret")];
1920
1921 let mut patch: Map<String, Value> = serde_json::from_str(
1922 r#"{"connect":{"platforms":[{"type":"telegram","app_secret":"****...****"}]}}"#,
1923 )
1924 .unwrap();
1925
1926 preserve_masked_connect_secrets(&mut patch, ¤t);
1927
1928 assert!(
1929 !patch["connect"]["platforms"][0]
1930 .as_object()
1931 .unwrap()
1932 .contains_key("app_secret"),
1933 "masked app_secret must not be resolved against a different platform's secret"
1934 );
1935 }
1936
1937 #[test]
1945 fn preserve_masked_connect_secrets_resolves_by_type_when_preceding_entry_removed() {
1946 let mut current = Config::default();
1947 current.connect.platforms = vec![
1948 connect_platform("telegram", "telegram-token"),
1949 feishu_platform("existing-app-secret"),
1950 ];
1951
1952 let mut patch: Map<String, Value> = serde_json::from_str(
1955 r#"{"connect":{"platforms":[
1956 {"type":"feishu","app_id":"cli_x","app_secret":"****...****","domain":"lark"}
1957 ]}}"#,
1958 )
1959 .unwrap();
1960
1961 preserve_masked_connect_secrets(&mut patch, ¤t);
1962
1963 assert_eq!(
1964 patch["connect"]["platforms"][0]["app_secret"], "existing-app-secret",
1965 "masked app_secret must resolve via type fallback after a preceding entry was removed"
1966 );
1967 }
1968
1969 #[test]
1974 fn preserve_masked_connect_secrets_resolves_by_type_when_index_out_of_range() {
1975 let mut current = Config::default();
1976 current.connect.platforms = vec![
1977 connect_platform("telegram", "telegram-token"),
1978 feishu_platform("existing-app-secret"),
1979 ];
1980
1981 let mut patch: Map<String, Value> = serde_json::from_str(
1985 r#"{"connect":{"platforms":[
1986 {"type":"telegram","token":"tg-real-value"},
1987 {"type":"telegram","token":"new-bot-token"},
1988 {"type":"feishu","app_id":"cli_x","app_secret":"****...****","domain":"lark"}
1989 ]}}"#,
1990 )
1991 .unwrap();
1992
1993 preserve_masked_connect_secrets(&mut patch, ¤t);
1994
1995 assert_eq!(
1996 patch["connect"]["platforms"][2]["app_secret"], "existing-app-secret",
1997 "masked app_secret at an out-of-range index must resolve via type fallback"
1998 );
1999 }
2000
2001 #[test]
2006 fn preserve_masked_connect_secrets_drops_mask_when_type_absent_from_current_entirely() {
2007 let mut current = Config::default();
2008 current.connect.platforms = vec![connect_platform("telegram", "telegram-token")];
2009
2010 let mut patch: Map<String, Value> = serde_json::from_str(
2013 r#"{"connect":{"platforms":[
2014 {"type":"feishu","app_id":"cli_x","app_secret":"****...****","domain":"lark"}
2015 ]}}"#,
2016 )
2017 .unwrap();
2018
2019 preserve_masked_connect_secrets(&mut patch, ¤t);
2020
2021 assert!(
2022 !patch["connect"]["platforms"][0]
2023 .as_object()
2024 .unwrap()
2025 .contains_key("app_secret"),
2026 "mask must still drop when no entry of that type exists anywhere in current"
2027 );
2028 }
2029
2030 fn merge_and_deserialize(current: &Config, patch: Value) -> Config {
2037 let mut merged = serde_json::to_value(current).unwrap();
2038 deep_merge_json(&mut merged, patch);
2039 serde_json::from_value(merged).expect("merged config should deserialize")
2040 }
2041
2042 #[test]
2043 fn null_deletes_option_scalar_leaf() {
2044 let mut current = Config::default();
2048 current.subagents.claude_code_binary = Some("/usr/local/bin/claude".to_string());
2049 current.subagents.claude_code_model = Some("claude-sonnet".to_string());
2050
2051 let merged = merge_and_deserialize(
2052 ¤t,
2053 json!({ "subagents": { "claude_code_binary": null } }),
2054 );
2055
2056 assert_eq!(merged.subagents.claude_code_binary, None);
2057 assert_eq!(
2060 merged.subagents.claude_code_model,
2061 Some("claude-sonnet".to_string())
2062 );
2063 }
2064
2065 #[test]
2066 fn absent_key_leaves_value_unchanged_back_compat() {
2067 let mut current = Config::default();
2071 current.subagents.claude_code_binary = Some("/usr/local/bin/claude".to_string());
2072 current.subagents.max_concurrent = Some(4);
2073
2074 let merged =
2077 merge_and_deserialize(¤t, json!({ "subagents": { "max_concurrent": 16 } }));
2078
2079 assert_eq!(
2080 merged.subagents.claude_code_binary,
2081 Some("/usr/local/bin/claude".to_string()),
2082 "an omitted key must leave the existing value untouched"
2083 );
2084 assert_eq!(merged.subagents.max_concurrent, Some(16));
2085 }
2086
2087 #[test]
2088 fn null_on_whole_object_subtree_resets_it_to_defaults() {
2089 let mut current = Config::default();
2095 current.notifications.ntfy.token = Some("existing-token".to_string());
2096 current.notifications.ntfy.enabled = true;
2097
2098 let merged = merge_and_deserialize(¤t, json!({ "notifications": null }));
2099
2100 assert_eq!(merged.notifications, crate::NotificationsConfig::default());
2101 }
2102
2103 #[test]
2104 fn null_deletes_one_hashmap_entry_keeps_siblings() {
2105 fn labeled_instance(label: &str) -> crate::ProviderInstanceConfig {
2118 serde_json::from_value(json!({
2119 "provider_type": "openai",
2120 "label": label,
2121 }))
2122 .expect("valid instance")
2123 }
2124
2125 let mut current = Config::default();
2126 current
2127 .provider_instances
2128 .insert("uuid-1".to_string(), labeled_instance("Work"));
2129 current
2130 .provider_instances
2131 .insert("uuid-2".to_string(), labeled_instance("Personal"));
2132
2133 let merged = merge_and_deserialize(
2134 ¤t,
2135 json!({ "provider_instances": { "uuid-1": null } }),
2136 );
2137
2138 assert!(!merged.provider_instances.contains_key("uuid-1"));
2139 assert_eq!(
2140 merged
2141 .provider_instances
2142 .get("uuid-2")
2143 .and_then(|i| i.label.as_deref()),
2144 Some("Personal"),
2145 "sibling map entries the patch didn't touch must survive"
2146 );
2147 }
2148
2149 #[test]
2150 fn null_on_whole_array_field_resets_it_to_empty() {
2151 let mut current = Config::default();
2158 current.connect.platforms = vec![connect_platform("telegram", "tok")];
2159
2160 let merged = merge_and_deserialize(¤t, json!({ "connect": { "platforms": null } }));
2161
2162 assert!(merged.connect.platforms.is_empty());
2163 }
2164
2165 #[test]
2166 fn null_inside_a_surviving_array_is_a_literal_element_not_a_delete_marker() {
2167 let mut current = Config::default();
2176 current.subagents.worker_args = Some(vec!["subagent-worker".to_string()]);
2177
2178 let mut merged = serde_json::to_value(¤t).unwrap();
2179 deep_merge_json(
2180 &mut merged,
2181 json!({ "subagents": { "worker_args": ["a", null, "b"] } }),
2182 );
2183
2184 assert_eq!(
2187 merged["subagents"]["worker_args"],
2188 json!(["a", null, "b"]),
2189 "arrays are leaf-replaced verbatim; a null element is not deleted"
2190 );
2191 let result: Result<Config, _> = serde_json::from_value(merged);
2192 assert!(
2193 result.is_err(),
2194 "a literal null inside a Vec<String> is a type error, not an element delete"
2195 );
2196 }
2197
2198 #[test]
2199 fn sentinel_string_values_still_work_after_null_delete_support() {
2200 let current = Config::default();
2204
2205 let merged = merge_and_deserialize(
2206 ¤t,
2207 json!({ "subagents": { "executor": "bamboo_runtime" } }),
2208 );
2209
2210 assert_eq!(
2211 merged.subagents.executor,
2212 Some("bamboo_runtime".to_string())
2213 );
2214 }
2215
2216 #[test]
2217 fn subagents_max_concurrent_null_clears_it_like_todays_lotus_ui() {
2218 let mut current = Config::default();
2228 current.subagents.max_concurrent = Some(4);
2229
2230 let merged =
2231 merge_and_deserialize(¤t, json!({ "subagents": { "max_concurrent": null } }));
2232
2233 assert_eq!(merged.subagents.max_concurrent, None);
2234 }
2235
2236 #[test]
2239 fn provider_api_key_intents_treats_null_as_clear_intent() {
2240 let patch = json!({
2241 "providers": { "openai": { "api_key": null } },
2242 "provider_instances": { "uuid-1": { "api_key": null } }
2243 });
2244 let intents = provider_api_key_intents(patch.as_object().unwrap());
2245 assert!(
2246 intents.providers.contains("openai"),
2247 "a null api_key must register as an explicit clear intent, \
2248 same as an empty string — otherwise preserve_unpatched_provider_secrets \
2249 would resurrect the deleted key"
2250 );
2251 assert!(intents.provider_instances.contains("uuid-1"));
2252 }
2253
2254 #[test]
2255 fn provider_api_key_intents_null_and_empty_string_are_equivalent_intents() {
2256 let null_patch = json!({ "providers": { "openai": { "api_key": null } } });
2257 let empty_patch = json!({ "providers": { "openai": { "api_key": "" } } });
2258 assert_eq!(
2259 provider_api_key_intents(null_patch.as_object().unwrap()),
2260 provider_api_key_intents(empty_patch.as_object().unwrap()),
2261 "null and \"\" must be recognized as the same clear intent"
2262 );
2263 }
2264
2265 #[test]
2266 fn null_api_key_does_not_get_resurrected_by_preserve_unpatched_secrets() {
2267 let mut current = Config::default();
2273 current
2274 .provider_instances
2275 .insert("uuid-1".to_string(), instance("sk-old", None));
2276
2277 let patch = json!({ "provider_instances": { "uuid-1": { "api_key": null } } });
2278 let intents = provider_api_key_intents(patch.as_object().unwrap());
2279 assert!(intents.provider_instances.contains("uuid-1"));
2280
2281 let mut merged = merge_and_deserialize(¤t, patch);
2282 preserve_unpatched_provider_secrets(&mut merged, ¤t, &intents);
2283
2284 assert_eq!(
2285 merged.provider_instances["uuid-1"].api_key, "",
2286 "a null-delete of api_key must stick, not get resurrected from `current`"
2287 );
2288 }
2289
2290 #[test]
2291 fn notification_secret_intents_treats_null_as_clear_intent() {
2292 let patch = json!({
2293 "notifications": { "ntfy": { "token": null }, "bark": { "device_key": null } }
2294 });
2295 let intents = notification_secret_intents(patch.as_object().unwrap());
2296 assert!(intents.ntfy_token);
2297 assert!(intents.bark_device_key);
2298 }
2299
2300 #[test]
2301 fn connect_secret_intents_treats_null_as_clear_intent() {
2302 let patch = json!({
2303 "connect": { "platforms": [ { "type": "telegram", "token": null } ] }
2304 });
2305 let intents = connect_secret_intents(patch.as_object().unwrap());
2306 assert!(intents.token.contains(&0));
2307 }
2308
2309 #[test]
2310 fn null_ntfy_token_clears_roundtripped_ciphertext_via_clear_intents() {
2311 let mut current = Config::default();
2317 current.notifications.ntfy.token = Some("existing-token".to_string());
2318 current.notifications.ntfy.token_encrypted = Some("existing-ct".to_string());
2319
2320 let patch = json!({ "notifications": { "ntfy": { "token": null } } });
2321 let intents = notification_secret_intents(patch.as_object().unwrap());
2322 assert!(intents.ntfy_token);
2323
2324 let mut merged = merge_and_deserialize(¤t, patch);
2325 assert_eq!(
2326 merged.notifications.ntfy.token_encrypted.as_deref(),
2327 Some("existing-ct"),
2328 "the merge alone leaves the round-tripped ciphertext behind"
2329 );
2330
2331 clear_notification_ciphertext_for_explicit_clears(&mut merged, &intents);
2332
2333 assert_eq!(merged.notifications.ntfy.token, None);
2334 assert!(
2335 merged.notifications.ntfy.token_encrypted.is_none(),
2336 "the clear-intent pass must drop the stale ciphertext so hydration can't refill it"
2337 );
2338 }
2339
2340 #[test]
2341 fn whole_providers_null_wipes_everything_without_resurrecting_secrets() {
2342 let mut current = Config::default();
2351 current.providers.openai = Some(crate::OpenAIConfig {
2352 api_key: "sk-legacy-live".to_string(),
2353 ..Default::default()
2354 });
2355
2356 let patch = json!({ "providers": null });
2357 let intents = provider_api_key_intents(patch.as_object().unwrap());
2358 assert!(intents.providers.is_empty());
2359
2360 let mut merged = merge_and_deserialize(¤t, patch);
2361 assert!(
2362 merged.providers.openai.is_none(),
2363 "the whole providers subtree must reset to default"
2364 );
2365
2366 preserve_unpatched_provider_secrets(&mut merged, ¤t, &intents);
2367
2368 assert!(
2369 merged.providers.openai.is_none(),
2370 "carry-forward must not resurrect a provider the patch wiped out entirely"
2371 );
2372 }
2373}