1use super::manifest::ExtensionConfigEntry;
8
9pub fn redact_secret_value(value: &str) -> String {
11 let len = value.chars().count();
12 if len == 0 {
13 return String::new();
14 }
15 if len <= 3 {
16 return "***".to_string();
17 }
18 let tail_len = if len <= 7 { 2 } else { 4 };
19 let tail: String = value.chars().skip(len - tail_len).collect();
20 format!("***{}", tail)
21}
22
23pub fn extension_env_var(extension_id: &str, key: &str) -> String {
25 let id_upper = extension_id.replace('-', "_").to_ascii_uppercase();
26 let key_upper = key.replace('-', "_").to_ascii_uppercase();
27 format!("SYNAPS_EXTENSION_{}_{}", id_upper, key_upper)
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum ConfigSource {
33 HostContext(&'static str),
36 EnvOverride(String),
38 SecretEnv(String),
40 PluginConfig,
42 LegacyConfigKey(String),
44 Default,
46 Missing,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ConfigEntryStatus {
53 pub key: String,
54 pub description: Option<String>,
55 pub required: bool,
56 pub source: ConfigSource,
57 pub has_value: bool,
58}
59
60pub fn classify_config_entry(
64 extension_id: &str,
65 entry: &ExtensionConfigEntry,
66 env_lookup: &impl Fn(&str) -> Option<String>,
67 plugin_config_lookup: &impl Fn(&str) -> Option<String>,
68 legacy_config_lookup: &impl Fn(&str) -> Option<String>,
69) -> ConfigEntryStatus {
70 classify_config_entry_with_keys(
71 extension_id,
72 entry,
73 &[entry.key.as_str()],
74 env_lookup,
75 plugin_config_lookup,
76 legacy_config_lookup,
77 )
78}
79
80fn classify_config_entry_with_keys(
84 extension_id: &str,
85 entry: &ExtensionConfigEntry,
86 own_keys: &[&str],
87 env_lookup: &impl Fn(&str) -> Option<String>,
88 plugin_config_lookup: &impl Fn(&str) -> Option<String>,
89 legacy_config_lookup: &impl Fn(&str) -> Option<String>,
90) -> ConfigEntryStatus {
91 let secret_env = entry
95 .secret_env
96 .as_deref()
97 .and_then(super::manager::ExtensionManager::secret_env_name)
98 .filter(|name| {
99 super::manager::ExtensionManager::denied_secret_env(name, extension_id, own_keys)
100 .is_none()
101 });
102 let env_var = extension_env_var(extension_id, &entry.key);
103 let legacy_config_key = format!("extension.{}.{}", extension_id, entry.key);
104
105 let source = if let Some(host_key) = entry.host_context {
106 match host_key {
107 crate::extensions::manifest::HostContextKey::ProjectRoot => {
108 ConfigSource::HostContext("project_root")
109 }
110 }
111 } else if env_lookup(&env_var).is_some() {
112 ConfigSource::EnvOverride(env_var)
113 } else if let Some(secret_env) = secret_env {
114 if env_lookup(secret_env).is_some() {
115 ConfigSource::SecretEnv(secret_env.to_string())
116 } else if plugin_config_lookup(&entry.key).is_some() {
117 ConfigSource::PluginConfig
118 } else if legacy_config_lookup(&legacy_config_key).is_some() {
119 ConfigSource::LegacyConfigKey(legacy_config_key)
120 } else if entry.default.is_some() {
121 ConfigSource::Default
122 } else {
123 ConfigSource::Missing
124 }
125 } else if plugin_config_lookup(&entry.key).is_some() {
126 ConfigSource::PluginConfig
127 } else if legacy_config_lookup(&legacy_config_key).is_some() {
128 ConfigSource::LegacyConfigKey(legacy_config_key)
129 } else if entry.default.is_some() {
130 ConfigSource::Default
131 } else {
132 ConfigSource::Missing
133 };
134
135 let has_value = !matches!(source, ConfigSource::Missing);
136
137 ConfigEntryStatus {
138 key: entry.key.clone(),
139 description: entry.description.clone(),
140 required: entry.required,
141 source,
142 has_value,
143 }
144}
145
146#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct ExtensionConfigDiagnostics {
149 pub extension_id: String,
150 pub entries: Vec<ConfigEntryStatus>,
151 pub provider_missing: Vec<(String, String)>,
154}
155
156pub fn diagnose_extension_config(
160 extension_id: &str,
161 manifest_config: &[ExtensionConfigEntry],
162 provider_required: &[(String, Vec<String>)],
163 env_lookup: &impl Fn(&str) -> Option<String>,
164 plugin_config_lookup: &impl Fn(&str) -> Option<String>,
165 legacy_config_lookup: &impl Fn(&str) -> Option<String>,
166) -> ExtensionConfigDiagnostics {
167 let own_keys: Vec<&str> = manifest_config.iter().map(|e| e.key.trim()).collect();
168 let entries: Vec<ConfigEntryStatus> = manifest_config
169 .iter()
170 .map(|entry| {
171 classify_config_entry_with_keys(
172 extension_id,
173 entry,
174 &own_keys,
175 env_lookup,
176 plugin_config_lookup,
177 legacy_config_lookup,
178 )
179 })
180 .collect();
181
182 let mut provider_missing: Vec<(String, String)> = Vec::new();
183 for (provider_id, required_keys) in provider_required {
184 for key in required_keys {
185 let satisfied = entries
186 .iter()
187 .any(|status| status.key == *key && status.has_value);
188 if !satisfied {
189 provider_missing.push((provider_id.clone(), key.clone()));
190 }
191 }
192 }
193
194 ExtensionConfigDiagnostics {
195 extension_id: extension_id.to_string(),
196 entries,
197 provider_missing,
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use serde_json::Value;
205
206 fn empty_lookup(_: &str) -> Option<String> {
207 None
208 }
209
210 fn entry(key: &str) -> ExtensionConfigEntry {
211 ExtensionConfigEntry {
212 key: key.to_string(),
213 value_type: None,
214 description: None,
215 required: false,
216 default: None,
217 secret_env: None,
218 host_context: None,
219 }
220 }
221
222 #[test]
223 fn redact_empty() {
224 assert_eq!(redact_secret_value(""), "");
225 }
226
227 #[test]
228 fn redact_short() {
229 assert_eq!(redact_secret_value("a"), "***");
230 assert_eq!(redact_secret_value("abc"), "***");
231 }
232
233 #[test]
234 fn redact_medium() {
235 assert_eq!(redact_secret_value("abcd"), "***cd");
236 assert_eq!(redact_secret_value("abc1234"), "***34");
237 }
238
239 #[test]
240 fn redact_long() {
241 assert_eq!(redact_secret_value("abc12345"), "***2345");
242 assert_eq!(redact_secret_value("abcdefghijklmnopqrst"), "***qrst");
243 let s = redact_secret_value("supersecretvalue1234");
245 assert!(s.starts_with("***"));
246 assert!(!s.contains("supersecret"));
247 }
248
249 #[test]
250 fn env_var_uppercases_and_replaces_dashes() {
251 assert_eq!(
252 extension_env_var("my-ext", "api-key"),
253 "SYNAPS_EXTENSION_MY_EXT_API_KEY"
254 );
255 }
256
257 #[test]
258 fn classify_env_override() {
259 let e = entry("api-key");
260 let env = |k: &str| {
261 if k == "SYNAPS_EXTENSION_MY_EXT_API_KEY" {
262 Some("v".to_string())
263 } else {
264 None
265 }
266 };
267 let status = classify_config_entry("my-ext", &e, &env, &empty_lookup, &empty_lookup);
268 assert_eq!(
269 status.source,
270 ConfigSource::EnvOverride("SYNAPS_EXTENSION_MY_EXT_API_KEY".to_string())
271 );
272 assert!(status.has_value);
273 }
274
275 #[test]
278 fn classify_never_looks_up_denied_or_malformed_secret_env() {
279 let env = |k: &str| match k {
280 "ANTHROPIC_API_KEY" | "lower_token" => Some("v".to_string()),
281 _ => None,
282 };
283 for name in ["ANTHROPIC_API_KEY", "lower_token"] {
284 let mut e = entry("api-key");
285 e.secret_env = Some(name.to_string());
286 let status = classify_config_entry("my-ext", &e, &env, &empty_lookup, &empty_lookup);
287 assert_eq!(status.source, ConfigSource::Missing, "{name}");
288 }
289 }
290
291 #[test]
292 fn classify_secret_env() {
293 let mut e = entry("api-key");
294 e.secret_env = Some("MY_PROVIDER_KEY".to_string());
295 let env = |k: &str| {
296 if k == "MY_PROVIDER_KEY" {
297 Some("v".to_string())
298 } else {
299 None
300 }
301 };
302 let status = classify_config_entry("my-ext", &e, &env, &empty_lookup, &empty_lookup);
303 assert_eq!(
304 status.source,
305 ConfigSource::SecretEnv("MY_PROVIDER_KEY".to_string())
306 );
307 assert!(status.has_value);
308 }
309
310 #[test]
311 fn classify_plugin_config() {
312 let e = entry("api-key");
313 let plugin = |k: &str| {
314 if k == "api-key" {
315 Some("v".to_string())
316 } else {
317 None
318 }
319 };
320 let status = classify_config_entry("my-ext", &e, &empty_lookup, &plugin, &empty_lookup);
321 assert_eq!(status.source, ConfigSource::PluginConfig);
322 assert!(status.has_value);
323 }
324
325 #[test]
326 fn classify_config_key() {
327 let e = entry("api-key");
328 let cfg = |k: &str| {
329 if k == "extension.my-ext.api-key" {
330 Some("v".to_string())
331 } else {
332 None
333 }
334 };
335 let status = classify_config_entry("my-ext", &e, &empty_lookup, &empty_lookup, &cfg);
336 assert_eq!(
337 status.source,
338 ConfigSource::LegacyConfigKey("extension.my-ext.api-key".to_string())
339 );
340 assert!(status.has_value);
341 }
342
343 #[test]
344 fn classify_default() {
345 let mut e = entry("region");
346 e.default = Some(Value::String("us-east-1".to_string()));
347 let status =
348 classify_config_entry("my-ext", &e, &empty_lookup, &empty_lookup, &empty_lookup);
349 assert_eq!(status.source, ConfigSource::Default);
350 assert!(status.has_value);
351 }
352
353 #[test]
354 fn classify_missing() {
355 let mut e = entry("api-key");
356 e.required = true;
357 let status =
358 classify_config_entry("my-ext", &e, &empty_lookup, &empty_lookup, &empty_lookup);
359 assert_eq!(status.source, ConfigSource::Missing);
360 assert!(!status.has_value);
361 assert!(status.required);
362 }
363
364 #[test]
365 fn env_override_wins_over_all() {
366 let mut e = entry("api-key");
367 e.secret_env = Some("MY_PROVIDER_KEY".to_string());
368 e.default = Some(Value::String("d".to_string()));
369 let env = |k: &str| Some(format!("env-{}", k));
370 let cfg = |_: &str| Some("cfg".to_string());
371 let status = classify_config_entry("my-ext", &e, &env, &empty_lookup, &cfg);
372 assert!(matches!(status.source, ConfigSource::EnvOverride(_)));
373 }
374
375 #[test]
376 fn secret_env_wins_over_config_and_default() {
377 let mut e = entry("api-key");
378 e.secret_env = Some("MY_PROVIDER_KEY".to_string());
379 e.default = Some(Value::String("d".to_string()));
380 let env = |k: &str| {
381 if k == "MY_PROVIDER_KEY" {
382 Some("s".to_string())
383 } else {
384 None
385 }
386 };
387 let cfg = |_: &str| Some("cfg".to_string());
388 let status = classify_config_entry("my-ext", &e, &env, &empty_lookup, &cfg);
389 assert_eq!(
390 status.source,
391 ConfigSource::SecretEnv("MY_PROVIDER_KEY".to_string())
392 );
393 }
394
395 #[test]
396 fn config_key_wins_over_default() {
397 let mut e = entry("region");
398 e.default = Some(Value::String("us-east-1".to_string()));
399 let cfg = |k: &str| {
400 if k == "extension.my-ext.region" {
401 Some("eu-west-1".to_string())
402 } else {
403 None
404 }
405 };
406 let status = classify_config_entry("my-ext", &e, &empty_lookup, &empty_lookup, &cfg);
407 assert!(matches!(status.source, ConfigSource::LegacyConfigKey(_)));
408 }
409
410 #[test]
411 fn default_only_when_no_env_or_config() {
412 let mut e = entry("region");
413 e.default = Some(Value::String("us-east-1".to_string()));
414 let status =
415 classify_config_entry("my-ext", &e, &empty_lookup, &empty_lookup, &empty_lookup);
416 assert_eq!(status.source, ConfigSource::Default);
417 }
418
419 #[test]
420 fn diagnose_empty_manifest_no_providers() {
421 let diag = diagnose_extension_config(
422 "my-ext",
423 &[],
424 &[],
425 &empty_lookup,
426 &empty_lookup,
427 &empty_lookup,
428 );
429 assert_eq!(diag.extension_id, "my-ext");
430 assert!(diag.entries.is_empty());
431 assert!(diag.provider_missing.is_empty());
432 }
433
434 #[test]
435 fn diagnose_entry_with_default_resolves() {
436 let mut e = entry("region");
437 e.default = Some(Value::String("us-east-1".to_string()));
438 let diag = diagnose_extension_config(
439 "my-ext",
440 std::slice::from_ref(&e),
441 &[],
442 &empty_lookup,
443 &empty_lookup,
444 &empty_lookup,
445 );
446 assert_eq!(diag.entries.len(), 1);
447 assert_eq!(diag.entries[0].source, ConfigSource::Default);
448 assert!(diag.entries[0].has_value);
449 assert!(diag.provider_missing.is_empty());
450 }
451
452 #[test]
453 fn diagnose_provider_requires_undeclared_key() {
454 let diag = diagnose_extension_config(
455 "my-ext",
456 &[],
457 &[("p".to_string(), vec!["api-key".to_string()])],
458 &empty_lookup,
459 &empty_lookup,
460 &empty_lookup,
461 );
462 assert_eq!(
463 diag.provider_missing,
464 vec![("p".to_string(), "api-key".to_string())]
465 );
466 }
467
468 #[test]
469 fn diagnose_provider_required_key_resolved_via_env() {
470 let mut e = entry("api-key");
471 e.required = true;
472 let env = |k: &str| {
473 if k == "SYNAPS_EXTENSION_MY_EXT_API_KEY" {
474 Some("v".to_string())
475 } else {
476 None
477 }
478 };
479 let diag = diagnose_extension_config(
480 "my-ext",
481 std::slice::from_ref(&e),
482 &[("p".to_string(), vec!["api-key".to_string()])],
483 &env,
484 &empty_lookup,
485 &empty_lookup,
486 );
487 assert!(diag.entries[0].has_value);
488 assert!(diag.provider_missing.is_empty());
489 }
490
491 #[test]
492 fn diagnose_provider_required_key_declared_but_missing() {
493 let mut e = entry("api-key");
494 e.required = true;
495 let diag = diagnose_extension_config(
496 "my-ext",
497 std::slice::from_ref(&e),
498 &[("p".to_string(), vec!["api-key".to_string()])],
499 &empty_lookup,
500 &empty_lookup,
501 &empty_lookup,
502 );
503 assert!(!diag.entries[0].has_value);
504 assert_eq!(
505 diag.provider_missing,
506 vec![("p".to_string(), "api-key".to_string())]
507 );
508 }
509}