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 project_id: None,
1575 platform_type: platform_type.to_string(),
1576 token: Some(token.to_string()),
1577 token_encrypted: None,
1578 token_credential_ref: None,
1579 token_configured: false,
1580 app_id: None,
1581 app_secret: None,
1582 app_secret_encrypted: None,
1583 app_secret_credential_ref: None,
1584 app_secret_configured: false,
1585 domain: None,
1586 allow_from: Vec::new(),
1587 admin_from: Vec::new(),
1588 }
1589 }
1590
1591 fn connect_platform_with_id(
1592 id: &str,
1593 platform_type: &str,
1594 token: &str,
1595 ) -> crate::ConnectPlatformConfig {
1596 crate::ConnectPlatformConfig {
1597 id: Some(id.to_string()),
1598 ..connect_platform(platform_type, token)
1599 }
1600 }
1601
1602 #[test]
1603 fn sanitize_root_patch_strips_connect_platform_encrypted_field() {
1604 let mut patch = json!({
1605 "connect": {
1606 "platforms": [
1607 { "type": "telegram", "token": "new-token", "token_encrypted": "client-supplied-cipher" }
1608 ]
1609 }
1610 });
1611 let obj = patch.as_object_mut().unwrap();
1612 sanitize_root_patch(obj);
1613
1614 let platform = &obj["connect"]["platforms"][0];
1615 assert!(!platform
1616 .as_object()
1617 .unwrap()
1618 .contains_key("token_encrypted"));
1619 assert_eq!(platform["token"], "new-token");
1620 }
1621
1622 #[test]
1623 fn sanitize_root_patch_strips_connect_platform_app_secret_encrypted_field() {
1624 let mut patch = json!({
1625 "connect": {
1626 "platforms": [
1627 {
1628 "type": "feishu",
1629 "app_id": "cli_x",
1630 "app_secret": "new-secret",
1631 "app_secret_encrypted": "client-supplied-cipher",
1632 "domain": "feishu"
1633 }
1634 ]
1635 }
1636 });
1637 let obj = patch.as_object_mut().unwrap();
1638 sanitize_root_patch(obj);
1639
1640 let platform = &obj["connect"]["platforms"][0];
1641 assert!(!platform
1642 .as_object()
1643 .unwrap()
1644 .contains_key("app_secret_encrypted"));
1645 assert_eq!(platform["app_secret"], "new-secret");
1646 assert_eq!(platform["app_id"], "cli_x");
1647 }
1648
1649 #[test]
1650 fn preserve_masked_connect_secrets_keeps_existing_plaintext_by_position() {
1651 let mut current = Config::default();
1652 current.connect.platforms = vec![connect_platform("telegram", "existing-bot-token")];
1653
1654 let mut patch: Map<String, Value> = serde_json::from_str(
1655 r#"{"connect":{"platforms":[{"type":"telegram","token":"****...****"}]}}"#,
1656 )
1657 .unwrap();
1658
1659 preserve_masked_connect_secrets(&mut patch, ¤t);
1660
1661 assert_eq!(
1662 patch["connect"]["platforms"][0]["token"],
1663 "existing-bot-token"
1664 );
1665 }
1666
1667 #[test]
1668 fn preserve_masked_connect_secrets_drops_mask_when_nothing_configured() {
1669 let current = Config::default();
1670 let mut patch: Map<String, Value> = serde_json::from_str(
1671 r#"{"connect":{"platforms":[{"type":"telegram","token":"****...****"}]}}"#,
1672 )
1673 .unwrap();
1674
1675 preserve_masked_connect_secrets(&mut patch, ¤t);
1676
1677 assert!(!patch["connect"]["platforms"][0]
1678 .as_object()
1679 .unwrap()
1680 .contains_key("token"));
1681 }
1682
1683 #[test]
1684 fn preserve_masked_connect_secrets_leaves_real_values_untouched() {
1685 let current = Config::default();
1686 let mut patch: Map<String, Value> = serde_json::from_str(
1687 r#"{"connect":{"platforms":[{"type":"telegram","token":"tg-real-new-value"}]}}"#,
1688 )
1689 .unwrap();
1690
1691 preserve_masked_connect_secrets(&mut patch, ¤t);
1692
1693 assert_eq!(
1694 patch["connect"]["platforms"][0]["token"],
1695 "tg-real-new-value"
1696 );
1697 }
1698
1699 #[test]
1705 fn preserve_masked_connect_secrets_drops_mask_when_type_at_index_disagrees() {
1706 let mut current = Config::default();
1707 current.connect.platforms = vec![connect_platform("telegram", "telegram-secret-token")];
1708
1709 let mut patch: Map<String, Value> = serde_json::from_str(
1713 r#"{"connect":{"platforms":[{"type":"feishu","token":"****...****"}]}}"#,
1714 )
1715 .unwrap();
1716
1717 preserve_masked_connect_secrets(&mut patch, ¤t);
1718
1719 assert!(
1720 !patch["connect"]["platforms"][0]
1721 .as_object()
1722 .unwrap()
1723 .contains_key("token"),
1724 "masked token must not be resolved against a different platform's secret"
1725 );
1726 }
1727
1728 #[test]
1738 fn preserve_masked_connect_secrets_type_mismatch_falls_back_to_type_lookup() {
1739 let mut current = Config::default();
1740 current.connect.platforms = vec![
1741 connect_platform("telegram", "bot-a-token"),
1742 connect_platform("feishu", "feishu-token"),
1743 ];
1744
1745 let mut patch: Map<String, Value> = serde_json::from_str(
1746 r#"{"connect":{"platforms":[
1747 {"type":"telegram","token":"tg-real-value"},
1748 {"type":"telegram","token":"****...****"}
1749 ]}}"#,
1750 )
1751 .unwrap();
1752
1753 preserve_masked_connect_secrets(&mut patch, ¤t);
1754
1755 assert_eq!(patch["connect"]["platforms"][0]["token"], "tg-real-value");
1756 assert_eq!(
1757 patch["connect"]["platforms"][1]["token"], "bot-a-token",
1758 "index 1's mismatched type must fall back to the same-typed entry found elsewhere in current"
1759 );
1760 }
1761
1762 #[test]
1766 fn preserve_masked_connect_secrets_keeps_plaintext_when_type_at_index_matches() {
1767 let mut current = Config::default();
1768 current.connect.platforms = vec![
1769 connect_platform("telegram", "bot-a-token"),
1770 connect_platform("telegram", "bot-b-token"),
1771 ];
1772
1773 let mut patch: Map<String, Value> = serde_json::from_str(
1774 r#"{"connect":{"platforms":[
1775 {"type":"telegram","token":"****...****"},
1776 {"type":"telegram","token":"****...****"}
1777 ]}}"#,
1778 )
1779 .unwrap();
1780
1781 preserve_masked_connect_secrets(&mut patch, ¤t);
1782
1783 assert_eq!(patch["connect"]["platforms"][0]["token"], "bot-a-token");
1784 assert_eq!(patch["connect"]["platforms"][1]["token"], "bot-b-token");
1785 }
1786
1787 #[test]
1791 fn preserve_masked_connect_secrets_falls_back_to_positional_when_type_is_absent() {
1792 let mut current = Config::default();
1793 current.connect.platforms = vec![connect_platform("telegram", "existing-bot-token")];
1794
1795 let mut patch: Map<String, Value> =
1796 serde_json::from_str(r#"{"connect":{"platforms":[{"token":"****...****"}]}}"#).unwrap();
1797
1798 preserve_masked_connect_secrets(&mut patch, ¤t);
1799
1800 assert_eq!(
1801 patch["connect"]["platforms"][0]["token"],
1802 "existing-bot-token"
1803 );
1804 }
1805
1806 #[test]
1814 fn preserve_masked_connect_secrets_resolves_duplicate_type_by_id_even_when_reordered() {
1815 let mut current = Config::default();
1816 current.connect.platforms = vec![
1817 connect_platform_with_id("id-a", "telegram", "bot-a-token"),
1818 connect_platform_with_id("id-b", "telegram", "bot-b-token"),
1819 ];
1820
1821 let mut patch: Map<String, Value> = serde_json::from_str(
1826 r#"{"connect":{"platforms":[
1827 {"id":"id-b","type":"telegram","token":"****...****"},
1828 {"id":"id-a","type":"telegram","token":"****...****"}
1829 ]}}"#,
1830 )
1831 .unwrap();
1832
1833 preserve_masked_connect_secrets(&mut patch, ¤t);
1834
1835 assert_eq!(
1836 patch["connect"]["platforms"][0]["token"], "bot-b-token",
1837 "index 0 (id-b) must resolve to id-b's own token, not the positionally-co-located id-a"
1838 );
1839 assert_eq!(
1840 patch["connect"]["platforms"][1]["token"], "bot-a-token",
1841 "index 1 (id-a) must resolve to id-a's own token"
1842 );
1843 }
1844
1845 #[test]
1851 fn preserve_masked_connect_secrets_falls_back_when_id_not_found_in_current() {
1852 let mut current = Config::default();
1853 current.connect.platforms = vec![connect_platform("telegram", "existing-bot-token")];
1854
1855 let mut patch: Map<String, Value> = serde_json::from_str(
1856 r#"{"connect":{"platforms":[{"id":"unknown-id","type":"telegram","token":"****...****"}]}}"#,
1857 )
1858 .unwrap();
1859
1860 preserve_masked_connect_secrets(&mut patch, ¤t);
1861
1862 assert_eq!(
1863 patch["connect"]["platforms"][0]["token"], "existing-bot-token",
1864 "an id absent from `current` falls back to the positional+type guard"
1865 );
1866 }
1867
1868 #[test]
1875 fn preserve_masked_connect_secrets_rejects_id_match_when_type_differs() {
1876 let mut current = Config::default();
1877 current.connect.platforms = vec![connect_platform_with_id(
1878 "id-a",
1879 "telegram",
1880 "telegram-secret-token",
1881 )];
1882
1883 let mut patch: Map<String, Value> = serde_json::from_str(
1887 r#"{"connect":{"platforms":[{"id":"id-a","type":"feishu","token":"****...****"}]}}"#,
1888 )
1889 .unwrap();
1890
1891 preserve_masked_connect_secrets(&mut patch, ¤t);
1892
1893 assert!(
1894 !patch["connect"]["platforms"][0]
1895 .as_object()
1896 .unwrap()
1897 .contains_key("token"),
1898 "an id match with a disagreeing type must not resolve the mask from that entry"
1899 );
1900 }
1901
1902 #[test]
1907 fn preserve_masked_connect_secrets_type_mismatched_id_still_falls_through_to_type_lookup() {
1908 let mut current = Config::default();
1909 current.connect.platforms = vec![
1910 connect_platform_with_id("id-a", "telegram", "telegram-secret-token"),
1911 connect_platform_with_id("id-b", "feishu", "feishu-secret-token"),
1912 ];
1913
1914 let mut patch: Map<String, Value> = serde_json::from_str(
1919 r#"{"connect":{"platforms":[{"id":"id-a","type":"feishu","token":"****...****"}]}}"#,
1920 )
1921 .unwrap();
1922
1923 preserve_masked_connect_secrets(&mut patch, ¤t);
1924
1925 assert_eq!(
1926 patch["connect"]["platforms"][0]["token"], "feishu-secret-token",
1927 "the fall-through must resolve via the type lookup, never via the mismatched id"
1928 );
1929 }
1930
1931 #[test]
1937 fn preserve_masked_connect_secrets_id_without_type_resolves_on_id_alone() {
1938 let mut current = Config::default();
1939 current.connect.platforms = vec![
1940 connect_platform_with_id("id-a", "telegram", "bot-a-token"),
1941 connect_platform_with_id("id-b", "telegram", "bot-b-token"),
1942 ];
1943
1944 let mut patch: Map<String, Value> = serde_json::from_str(
1945 r#"{"connect":{"platforms":[{"id":"id-b","token":"****...****"}]}}"#,
1946 )
1947 .unwrap();
1948
1949 preserve_masked_connect_secrets(&mut patch, ¤t);
1950
1951 assert_eq!(
1952 patch["connect"]["platforms"][0]["token"], "bot-b-token",
1953 "with no type to check, the id match resolves to its own entry, not the positional one"
1954 );
1955 }
1956
1957 #[test]
1964 fn preserve_masked_connect_secrets_ignores_id_branch_when_patch_omits_id() {
1965 let mut current = Config::default();
1966 current.connect.platforms = vec![connect_platform_with_id(
1967 "id-a",
1968 "telegram",
1969 "existing-bot-token",
1970 )];
1971
1972 let mut patch: Map<String, Value> = serde_json::from_str(
1973 r#"{"connect":{"platforms":[{"type":"telegram","token":"****...****"}]}}"#,
1974 )
1975 .unwrap();
1976
1977 preserve_masked_connect_secrets(&mut patch, ¤t);
1978
1979 assert_eq!(
1980 patch["connect"]["platforms"][0]["token"],
1981 "existing-bot-token"
1982 );
1983 }
1984
1985 fn feishu_platform(app_secret: &str) -> crate::ConnectPlatformConfig {
1986 crate::ConnectPlatformConfig {
1987 id: None,
1988 project_id: None,
1989 platform_type: "feishu".to_string(),
1990 token: None,
1991 token_encrypted: None,
1992 token_credential_ref: None,
1993 token_configured: false,
1994 app_id: Some("cli_x".to_string()),
1995 app_secret: Some(app_secret.to_string()),
1996 app_secret_encrypted: None,
1997 app_secret_credential_ref: None,
1998 app_secret_configured: false,
1999 domain: Some("lark".to_string()),
2000 allow_from: Vec::new(),
2001 admin_from: Vec::new(),
2002 }
2003 }
2004
2005 #[test]
2006 fn preserve_masked_connect_secrets_keeps_existing_app_secret_by_position() {
2007 let mut current = Config::default();
2008 current.connect.platforms = vec![feishu_platform("existing-app-secret")];
2009
2010 let mut patch: Map<String, Value> = serde_json::from_str(
2011 r#"{"connect":{"platforms":[{"type":"feishu","app_id":"cli_x","app_secret":"****...****","domain":"lark"}]}}"#,
2012 )
2013 .unwrap();
2014
2015 preserve_masked_connect_secrets(&mut patch, ¤t);
2016
2017 assert_eq!(
2018 patch["connect"]["platforms"][0]["app_secret"],
2019 "existing-app-secret"
2020 );
2021 assert_eq!(patch["connect"]["platforms"][0]["app_id"], "cli_x");
2023 assert_eq!(patch["connect"]["platforms"][0]["domain"], "lark");
2024 }
2025
2026 #[test]
2027 fn preserve_masked_connect_secrets_drops_app_secret_mask_when_nothing_configured() {
2028 let current = Config::default();
2029 let mut patch: Map<String, Value> = serde_json::from_str(
2030 r#"{"connect":{"platforms":[{"type":"feishu","app_secret":"****...****"}]}}"#,
2031 )
2032 .unwrap();
2033
2034 preserve_masked_connect_secrets(&mut patch, ¤t);
2035
2036 assert!(!patch["connect"]["platforms"][0]
2037 .as_object()
2038 .unwrap()
2039 .contains_key("app_secret"));
2040 }
2041
2042 #[test]
2043 fn preserve_masked_connect_secrets_leaves_real_app_secret_untouched() {
2044 let current = Config::default();
2045 let mut patch: Map<String, Value> = serde_json::from_str(
2046 r#"{"connect":{"platforms":[{"type":"feishu","app_secret":"feishu-real-new-value"}]}}"#,
2047 )
2048 .unwrap();
2049
2050 preserve_masked_connect_secrets(&mut patch, ¤t);
2051
2052 assert_eq!(
2053 patch["connect"]["platforms"][0]["app_secret"],
2054 "feishu-real-new-value"
2055 );
2056 }
2057
2058 #[test]
2062 fn preserve_masked_connect_secrets_drops_app_secret_mask_when_type_at_index_disagrees() {
2063 let mut current = Config::default();
2064 current.connect.platforms = vec![feishu_platform("feishu-secret")];
2065
2066 let mut patch: Map<String, Value> = serde_json::from_str(
2067 r#"{"connect":{"platforms":[{"type":"telegram","app_secret":"****...****"}]}}"#,
2068 )
2069 .unwrap();
2070
2071 preserve_masked_connect_secrets(&mut patch, ¤t);
2072
2073 assert!(
2074 !patch["connect"]["platforms"][0]
2075 .as_object()
2076 .unwrap()
2077 .contains_key("app_secret"),
2078 "masked app_secret must not be resolved against a different platform's secret"
2079 );
2080 }
2081
2082 #[test]
2090 fn preserve_masked_connect_secrets_resolves_by_type_when_preceding_entry_removed() {
2091 let mut current = Config::default();
2092 current.connect.platforms = vec![
2093 connect_platform("telegram", "telegram-token"),
2094 feishu_platform("existing-app-secret"),
2095 ];
2096
2097 let mut patch: Map<String, Value> = serde_json::from_str(
2100 r#"{"connect":{"platforms":[
2101 {"type":"feishu","app_id":"cli_x","app_secret":"****...****","domain":"lark"}
2102 ]}}"#,
2103 )
2104 .unwrap();
2105
2106 preserve_masked_connect_secrets(&mut patch, ¤t);
2107
2108 assert_eq!(
2109 patch["connect"]["platforms"][0]["app_secret"], "existing-app-secret",
2110 "masked app_secret must resolve via type fallback after a preceding entry was removed"
2111 );
2112 }
2113
2114 #[test]
2119 fn preserve_masked_connect_secrets_resolves_by_type_when_index_out_of_range() {
2120 let mut current = Config::default();
2121 current.connect.platforms = vec![
2122 connect_platform("telegram", "telegram-token"),
2123 feishu_platform("existing-app-secret"),
2124 ];
2125
2126 let mut patch: Map<String, Value> = serde_json::from_str(
2130 r#"{"connect":{"platforms":[
2131 {"type":"telegram","token":"tg-real-value"},
2132 {"type":"telegram","token":"new-bot-token"},
2133 {"type":"feishu","app_id":"cli_x","app_secret":"****...****","domain":"lark"}
2134 ]}}"#,
2135 )
2136 .unwrap();
2137
2138 preserve_masked_connect_secrets(&mut patch, ¤t);
2139
2140 assert_eq!(
2141 patch["connect"]["platforms"][2]["app_secret"], "existing-app-secret",
2142 "masked app_secret at an out-of-range index must resolve via type fallback"
2143 );
2144 }
2145
2146 #[test]
2151 fn preserve_masked_connect_secrets_drops_mask_when_type_absent_from_current_entirely() {
2152 let mut current = Config::default();
2153 current.connect.platforms = vec![connect_platform("telegram", "telegram-token")];
2154
2155 let mut patch: Map<String, Value> = serde_json::from_str(
2158 r#"{"connect":{"platforms":[
2159 {"type":"feishu","app_id":"cli_x","app_secret":"****...****","domain":"lark"}
2160 ]}}"#,
2161 )
2162 .unwrap();
2163
2164 preserve_masked_connect_secrets(&mut patch, ¤t);
2165
2166 assert!(
2167 !patch["connect"]["platforms"][0]
2168 .as_object()
2169 .unwrap()
2170 .contains_key("app_secret"),
2171 "mask must still drop when no entry of that type exists anywhere in current"
2172 );
2173 }
2174
2175 fn merge_and_deserialize(current: &Config, patch: Value) -> Config {
2182 let mut merged = current.to_compatibility_value().unwrap();
2183 deep_merge_json(&mut merged, patch);
2184 serde_json::from_value(merged).expect("merged config should deserialize")
2185 }
2186
2187 #[test]
2188 fn null_deletes_option_scalar_leaf() {
2189 let mut current = Config::default();
2193 current.subagents.claude_code_binary = Some("/usr/local/bin/claude".to_string());
2194 current.subagents.claude_code_model = Some("claude-sonnet".to_string());
2195
2196 let merged = merge_and_deserialize(
2197 ¤t,
2198 json!({ "subagents": { "claude_code_binary": null } }),
2199 );
2200
2201 assert_eq!(merged.subagents.claude_code_binary, None);
2202 assert_eq!(
2205 merged.subagents.claude_code_model,
2206 Some("claude-sonnet".to_string())
2207 );
2208 }
2209
2210 #[test]
2211 fn absent_key_leaves_value_unchanged_back_compat() {
2212 let mut current = Config::default();
2216 current.subagents.claude_code_binary = Some("/usr/local/bin/claude".to_string());
2217 current.subagents.max_concurrent = Some(4);
2218
2219 let merged =
2222 merge_and_deserialize(¤t, json!({ "subagents": { "max_concurrent": 16 } }));
2223
2224 assert_eq!(
2225 merged.subagents.claude_code_binary,
2226 Some("/usr/local/bin/claude".to_string()),
2227 "an omitted key must leave the existing value untouched"
2228 );
2229 assert_eq!(merged.subagents.max_concurrent, Some(16));
2230 }
2231
2232 #[test]
2233 fn null_on_whole_object_subtree_resets_it_to_defaults() {
2234 let mut current = Config::default();
2240 current.notifications.ntfy.token = Some("existing-token".to_string());
2241 current.notifications.ntfy.enabled = true;
2242
2243 let merged = merge_and_deserialize(¤t, json!({ "notifications": null }));
2244
2245 assert_eq!(merged.notifications, crate::NotificationsConfig::default());
2246 }
2247
2248 #[test]
2249 fn null_deletes_one_hashmap_entry_keeps_siblings() {
2250 fn labeled_instance(label: &str) -> crate::ProviderInstanceConfig {
2263 serde_json::from_value(json!({
2264 "provider_type": "openai",
2265 "label": label,
2266 }))
2267 .expect("valid instance")
2268 }
2269
2270 let mut current = Config::default();
2271 current
2272 .provider_instances
2273 .insert("uuid-1".to_string(), labeled_instance("Work"));
2274 current
2275 .provider_instances
2276 .insert("uuid-2".to_string(), labeled_instance("Personal"));
2277
2278 let merged = merge_and_deserialize(
2279 ¤t,
2280 json!({ "provider_instances": { "uuid-1": null } }),
2281 );
2282
2283 assert!(!merged.provider_instances.contains_key("uuid-1"));
2284 assert_eq!(
2285 merged
2286 .provider_instances
2287 .get("uuid-2")
2288 .and_then(|i| i.label.as_deref()),
2289 Some("Personal"),
2290 "sibling map entries the patch didn't touch must survive"
2291 );
2292 }
2293
2294 #[test]
2295 fn null_on_whole_array_field_resets_it_to_empty() {
2296 let mut current = Config::default();
2303 current.connect.platforms = vec![connect_platform("telegram", "tok")];
2304
2305 let merged = merge_and_deserialize(¤t, json!({ "connect": { "platforms": null } }));
2306
2307 assert!(merged.connect.platforms.is_empty());
2308 }
2309
2310 #[test]
2311 fn null_inside_a_surviving_array_is_a_literal_element_not_a_delete_marker() {
2312 let mut current = Config::default();
2321 current.subagents.worker_args = Some(vec!["subagent-worker".to_string()]);
2322
2323 let mut merged = serde_json::to_value(¤t).unwrap();
2324 deep_merge_json(
2325 &mut merged,
2326 json!({ "subagents": { "worker_args": ["a", null, "b"] } }),
2327 );
2328
2329 assert_eq!(
2332 merged["subagents"]["worker_args"],
2333 json!(["a", null, "b"]),
2334 "arrays are leaf-replaced verbatim; a null element is not deleted"
2335 );
2336 let result: Result<Config, _> = serde_json::from_value(merged);
2337 assert!(
2338 result.is_err(),
2339 "a literal null inside a Vec<String> is a type error, not an element delete"
2340 );
2341 }
2342
2343 #[test]
2344 fn sentinel_string_values_still_work_after_null_delete_support() {
2345 let current = Config::default();
2349
2350 let merged = merge_and_deserialize(
2351 ¤t,
2352 json!({ "subagents": { "executor": "bamboo_runtime" } }),
2353 );
2354
2355 assert_eq!(
2356 merged.subagents.executor,
2357 Some("bamboo_runtime".to_string())
2358 );
2359 }
2360
2361 #[test]
2362 fn subagents_max_concurrent_null_clears_it_like_todays_lotus_ui() {
2363 let mut current = Config::default();
2373 current.subagents.max_concurrent = Some(4);
2374
2375 let merged =
2376 merge_and_deserialize(¤t, json!({ "subagents": { "max_concurrent": null } }));
2377
2378 assert_eq!(merged.subagents.max_concurrent, None);
2379 }
2380
2381 #[test]
2384 fn provider_api_key_intents_treats_null_as_clear_intent() {
2385 let patch = json!({
2386 "providers": { "openai": { "api_key": null } },
2387 "provider_instances": { "uuid-1": { "api_key": null } }
2388 });
2389 let intents = provider_api_key_intents(patch.as_object().unwrap());
2390 assert!(
2391 intents.providers.contains("openai"),
2392 "a null api_key must register as an explicit clear intent, \
2393 same as an empty string — otherwise preserve_unpatched_provider_secrets \
2394 would resurrect the deleted key"
2395 );
2396 assert!(intents.provider_instances.contains("uuid-1"));
2397 }
2398
2399 #[test]
2400 fn provider_api_key_intents_treats_whole_instance_null_as_delete_intent() {
2401 let patch = json!({ "provider_instances": { "uuid-1": null } });
2402 let intents = provider_api_key_intents(patch.as_object().unwrap());
2403 assert!(intents.provider_instances.contains("uuid-1"));
2404 }
2405
2406 #[test]
2407 fn provider_api_key_intents_null_and_empty_string_are_equivalent_intents() {
2408 let null_patch = json!({ "providers": { "openai": { "api_key": null } } });
2409 let empty_patch = json!({ "providers": { "openai": { "api_key": "" } } });
2410 assert_eq!(
2411 provider_api_key_intents(null_patch.as_object().unwrap()),
2412 provider_api_key_intents(empty_patch.as_object().unwrap()),
2413 "null and \"\" must be recognized as the same clear intent"
2414 );
2415 }
2416
2417 #[test]
2418 fn null_api_key_does_not_get_resurrected_by_preserve_unpatched_secrets() {
2419 let mut current = Config::default();
2425 current
2426 .provider_instances
2427 .insert("uuid-1".to_string(), instance("sk-old", None));
2428
2429 let patch = json!({ "provider_instances": { "uuid-1": { "api_key": null } } });
2430 let intents = provider_api_key_intents(patch.as_object().unwrap());
2431 assert!(intents.provider_instances.contains("uuid-1"));
2432
2433 let mut merged = merge_and_deserialize(¤t, patch);
2434 preserve_unpatched_provider_secrets(&mut merged, ¤t, &intents);
2435
2436 assert_eq!(
2437 merged.provider_instances["uuid-1"].api_key, "",
2438 "a null-delete of api_key must stick, not get resurrected from `current`"
2439 );
2440 }
2441
2442 #[test]
2443 fn notification_secret_intents_treats_null_as_clear_intent() {
2444 let patch = json!({
2445 "notifications": { "ntfy": { "token": null }, "bark": { "device_key": null } }
2446 });
2447 let intents = notification_secret_intents(patch.as_object().unwrap());
2448 assert!(intents.ntfy_token);
2449 assert!(intents.bark_device_key);
2450 }
2451
2452 #[test]
2453 fn connect_secret_intents_treats_null_as_clear_intent() {
2454 let patch = json!({
2455 "connect": { "platforms": [ { "type": "telegram", "token": null } ] }
2456 });
2457 let intents = connect_secret_intents(patch.as_object().unwrap());
2458 assert!(intents.token.contains(&0));
2459 }
2460
2461 #[test]
2462 fn null_ntfy_token_clears_roundtripped_ciphertext_via_clear_intents() {
2463 let mut current = Config::default();
2467 current.notifications.ntfy.token = Some("existing-token".to_string());
2468 current.notifications.ntfy.token_encrypted = Some("existing-ct".to_string());
2469
2470 let patch = json!({ "notifications": { "ntfy": { "token": null } } });
2471 let intents = notification_secret_intents(patch.as_object().unwrap());
2472 assert!(intents.ntfy_token);
2473
2474 let mut merged = merge_and_deserialize(¤t, patch);
2475 assert!(
2476 merged.notifications.ntfy.token_encrypted.is_none(),
2477 "legacy notification ciphertext is no longer serialized through the merge"
2478 );
2479
2480 clear_notification_ciphertext_for_explicit_clears(&mut merged, &intents);
2481
2482 assert_eq!(merged.notifications.ntfy.token, None);
2483 assert!(
2484 merged.notifications.ntfy.token_encrypted.is_none(),
2485 "the clear-intent pass must drop the stale ciphertext so hydration can't refill it"
2486 );
2487 }
2488
2489 #[test]
2490 fn whole_providers_null_wipes_everything_without_resurrecting_secrets() {
2491 let mut current = Config::default();
2500 current.providers.openai = Some(crate::OpenAIConfig {
2501 api_key: "sk-legacy-live".to_string(),
2502 ..Default::default()
2503 });
2504
2505 let patch = json!({ "providers": null });
2506 let intents = provider_api_key_intents(patch.as_object().unwrap());
2507 assert!(intents.providers.is_empty());
2508
2509 let mut merged = merge_and_deserialize(¤t, patch);
2510 assert!(
2511 merged.providers.openai.is_none(),
2512 "the whole providers subtree must reset to default"
2513 );
2514
2515 preserve_unpatched_provider_secrets(&mut merged, ¤t, &intents);
2516
2517 assert!(
2518 merged.providers.openai.is_none(),
2519 "carry-forward must not resurrect a provider the patch wiped out entirely"
2520 );
2521 }
2522}