Skip to main content

synaps_cli/extensions/
config.rs

1//! Config redaction helpers and diagnostics types for extensions.
2//!
3//! These helpers are used by `/extensions status` and similar UX to display
4//! extension config without leaking secret values, and to mirror the
5//! resolution order implemented in `manager::resolve_config`.
6
7use super::manifest::ExtensionConfigEntry;
8
9/// Redact a secret value for display. Always cosmetic; never returns the full value.
10pub 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
23/// Compute the env override variable name for a given extension id + config key.
24pub 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/// Where a resolved config value originated from.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum ConfigSource {
33    /// Resolved from the `SYNAPS_EXTENSION_<ID>_<KEY>` env override.
34    EnvOverride(String),
35    /// Resolved from the manifest-declared `secret_env` variable.
36    SecretEnv(String),
37    /// Resolved from the plugin-owned config file `plugins/<id>/config`.
38    PluginConfig,
39    /// Resolved from the deprecated persisted config key `extension.<id>.<key>`.
40    LegacyConfigKey(String),
41    /// Resolved from the manifest-declared default value.
42    Default,
43    /// No value available from any source.
44    Missing,
45}
46
47/// Diagnostic status for a single config entry.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ConfigEntryStatus {
50    pub key: String,
51    pub description: Option<String>,
52    pub required: bool,
53    pub source: ConfigSource,
54    pub has_value: bool,
55}
56
57/// Classify a config entry against the same resolution order as
58/// `manager::resolve_config`, using injected lookup closures so callers
59/// (and tests) need not touch real `std::env` or persisted config.
60pub fn classify_config_entry(
61    extension_id: &str,
62    entry: &ExtensionConfigEntry,
63    env_lookup: &impl Fn(&str) -> Option<String>,
64    plugin_config_lookup: &impl Fn(&str) -> Option<String>,
65    legacy_config_lookup: &impl Fn(&str) -> Option<String>,
66) -> ConfigEntryStatus {
67    let env_var = extension_env_var(extension_id, &entry.key);
68    let legacy_config_key = format!("extension.{}.{}", extension_id, entry.key);
69
70    let source = if env_lookup(&env_var).is_some() {
71        ConfigSource::EnvOverride(env_var)
72    } else if let Some(secret_env) = entry.secret_env.as_ref() {
73        if env_lookup(secret_env).is_some() {
74            ConfigSource::SecretEnv(secret_env.clone())
75        } else if plugin_config_lookup(&entry.key).is_some() {
76            ConfigSource::PluginConfig
77        } else if legacy_config_lookup(&legacy_config_key).is_some() {
78            ConfigSource::LegacyConfigKey(legacy_config_key)
79        } else if entry.default.is_some() {
80            ConfigSource::Default
81        } else {
82            ConfigSource::Missing
83        }
84    } else if plugin_config_lookup(&entry.key).is_some() {
85        ConfigSource::PluginConfig
86    } else if legacy_config_lookup(&legacy_config_key).is_some() {
87        ConfigSource::LegacyConfigKey(legacy_config_key)
88    } else if entry.default.is_some() {
89        ConfigSource::Default
90    } else {
91        ConfigSource::Missing
92    };
93
94    let has_value = !matches!(source, ConfigSource::Missing);
95
96    ConfigEntryStatus {
97        key: entry.key.clone(),
98        description: entry.description.clone(),
99        required: entry.required,
100        source,
101        has_value,
102    }
103}
104
105/// Aggregated config diagnostics for a single extension.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct ExtensionConfigDiagnostics {
108    pub extension_id: String,
109    pub entries: Vec<ConfigEntryStatus>,
110    /// Provider-level required keys that aren't satisfied by any entry above.
111    /// Each item is `(provider_id, missing_key)`.
112    pub provider_missing: Vec<(String, String)>,
113}
114
115/// Compute diagnostics for an extension by combining manifest config classification
116/// with provider-level `config_schema.required` checks. Lookups are injected so
117/// the function is fully testable without touching real env or persisted config.
118pub fn diagnose_extension_config(
119    extension_id: &str,
120    manifest_config: &[ExtensionConfigEntry],
121    provider_required: &[(String, Vec<String>)],
122    env_lookup: &impl Fn(&str) -> Option<String>,
123    plugin_config_lookup: &impl Fn(&str) -> Option<String>,
124    legacy_config_lookup: &impl Fn(&str) -> Option<String>,
125) -> ExtensionConfigDiagnostics {
126    let entries: Vec<ConfigEntryStatus> = manifest_config
127        .iter()
128        .map(|entry| {
129            classify_config_entry(
130                extension_id,
131                entry,
132                env_lookup,
133                plugin_config_lookup,
134                legacy_config_lookup,
135            )
136        })
137        .collect();
138
139    let mut provider_missing: Vec<(String, String)> = Vec::new();
140    for (provider_id, required_keys) in provider_required {
141        for key in required_keys {
142            let satisfied = entries
143                .iter()
144                .any(|status| status.key == *key && status.has_value);
145            if !satisfied {
146                provider_missing.push((provider_id.clone(), key.clone()));
147            }
148        }
149    }
150
151    ExtensionConfigDiagnostics {
152        extension_id: extension_id.to_string(),
153        entries,
154        provider_missing,
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use serde_json::Value;
162
163    fn empty_lookup(_: &str) -> Option<String> {
164        None
165    }
166
167    fn entry(key: &str) -> ExtensionConfigEntry {
168        ExtensionConfigEntry {
169            key: key.to_string(),
170            value_type: None,
171            description: None,
172            required: false,
173            default: None,
174            secret_env: None,
175        }
176    }
177
178    #[test]
179    fn redact_empty() {
180        assert_eq!(redact_secret_value(""), "");
181    }
182
183    #[test]
184    fn redact_short() {
185        assert_eq!(redact_secret_value("a"), "***");
186        assert_eq!(redact_secret_value("abc"), "***");
187    }
188
189    #[test]
190    fn redact_medium() {
191        assert_eq!(redact_secret_value("abcd"), "***cd");
192        assert_eq!(redact_secret_value("abc1234"), "***34");
193    }
194
195    #[test]
196    fn redact_long() {
197        assert_eq!(redact_secret_value("abc12345"), "***2345");
198        assert_eq!(
199            redact_secret_value("abcdefghijklmnopqrst"),
200            "***qrst"
201        );
202        // sanity: never contains the full value beyond the tail
203        let s = redact_secret_value("supersecretvalue1234");
204        assert!(s.starts_with("***"));
205        assert!(!s.contains("supersecret"));
206    }
207
208    #[test]
209    fn env_var_uppercases_and_replaces_dashes() {
210        assert_eq!(
211            extension_env_var("my-ext", "api-key"),
212            "SYNAPS_EXTENSION_MY_EXT_API_KEY"
213        );
214    }
215
216    #[test]
217    fn classify_env_override() {
218        let e = entry("api-key");
219        let env = |k: &str| {
220            if k == "SYNAPS_EXTENSION_MY_EXT_API_KEY" {
221                Some("v".to_string())
222            } else {
223                None
224            }
225        };
226        let status = classify_config_entry("my-ext", &e, &env, &empty_lookup, &empty_lookup);
227        assert_eq!(
228            status.source,
229            ConfigSource::EnvOverride("SYNAPS_EXTENSION_MY_EXT_API_KEY".to_string())
230        );
231        assert!(status.has_value);
232    }
233
234    #[test]
235    fn classify_secret_env() {
236        let mut e = entry("api-key");
237        e.secret_env = Some("MY_PROVIDER_KEY".to_string());
238        let env = |k: &str| {
239            if k == "MY_PROVIDER_KEY" {
240                Some("v".to_string())
241            } else {
242                None
243            }
244        };
245        let status = classify_config_entry("my-ext", &e, &env, &empty_lookup, &empty_lookup);
246        assert_eq!(
247            status.source,
248            ConfigSource::SecretEnv("MY_PROVIDER_KEY".to_string())
249        );
250        assert!(status.has_value);
251    }
252
253    #[test]
254    fn classify_plugin_config() {
255        let e = entry("api-key");
256        let plugin = |k: &str| {
257            if k == "api-key" { Some("v".to_string()) } else { None }
258        };
259        let status = classify_config_entry("my-ext", &e, &empty_lookup, &plugin, &empty_lookup);
260        assert_eq!(status.source, ConfigSource::PluginConfig);
261        assert!(status.has_value);
262    }
263
264    #[test]
265    fn classify_config_key() {
266        let e = entry("api-key");
267        let cfg = |k: &str| {
268            if k == "extension.my-ext.api-key" {
269                Some("v".to_string())
270            } else {
271                None
272            }
273        };
274        let status = classify_config_entry("my-ext", &e, &empty_lookup, &empty_lookup, &cfg);
275        assert_eq!(
276            status.source,
277            ConfigSource::LegacyConfigKey("extension.my-ext.api-key".to_string())
278        );
279        assert!(status.has_value);
280    }
281
282    #[test]
283    fn classify_default() {
284        let mut e = entry("region");
285        e.default = Some(Value::String("us-east-1".to_string()));
286        let status = classify_config_entry("my-ext", &e, &empty_lookup, &empty_lookup, &empty_lookup);
287        assert_eq!(status.source, ConfigSource::Default);
288        assert!(status.has_value);
289    }
290
291    #[test]
292    fn classify_missing() {
293        let mut e = entry("api-key");
294        e.required = true;
295        let status = classify_config_entry("my-ext", &e, &empty_lookup, &empty_lookup, &empty_lookup);
296        assert_eq!(status.source, ConfigSource::Missing);
297        assert!(!status.has_value);
298        assert!(status.required);
299    }
300
301    #[test]
302    fn env_override_wins_over_all() {
303        let mut e = entry("api-key");
304        e.secret_env = Some("MY_PROVIDER_KEY".to_string());
305        e.default = Some(Value::String("d".to_string()));
306        let env = |k: &str| Some(format!("env-{}", k));
307        let cfg = |_: &str| Some("cfg".to_string());
308        let status = classify_config_entry("my-ext", &e, &env, &empty_lookup, &cfg);
309        assert!(matches!(status.source, ConfigSource::EnvOverride(_)));
310    }
311
312    #[test]
313    fn secret_env_wins_over_config_and_default() {
314        let mut e = entry("api-key");
315        e.secret_env = Some("MY_PROVIDER_KEY".to_string());
316        e.default = Some(Value::String("d".to_string()));
317        let env = |k: &str| {
318            if k == "MY_PROVIDER_KEY" {
319                Some("s".to_string())
320            } else {
321                None
322            }
323        };
324        let cfg = |_: &str| Some("cfg".to_string());
325        let status = classify_config_entry("my-ext", &e, &env, &empty_lookup, &cfg);
326        assert_eq!(
327            status.source,
328            ConfigSource::SecretEnv("MY_PROVIDER_KEY".to_string())
329        );
330    }
331
332    #[test]
333    fn config_key_wins_over_default() {
334        let mut e = entry("region");
335        e.default = Some(Value::String("us-east-1".to_string()));
336        let cfg = |k: &str| {
337            if k == "extension.my-ext.region" {
338                Some("eu-west-1".to_string())
339            } else {
340                None
341            }
342        };
343        let status = classify_config_entry("my-ext", &e, &empty_lookup, &empty_lookup, &cfg);
344        assert!(matches!(status.source, ConfigSource::LegacyConfigKey(_)));
345    }
346
347    #[test]
348    fn default_only_when_no_env_or_config() {
349        let mut e = entry("region");
350        e.default = Some(Value::String("us-east-1".to_string()));
351        let status = classify_config_entry("my-ext", &e, &empty_lookup, &empty_lookup, &empty_lookup);
352        assert_eq!(status.source, ConfigSource::Default);
353    }
354
355    #[test]
356    fn diagnose_empty_manifest_no_providers() {
357        let diag = diagnose_extension_config(
358            "my-ext",
359            &[],
360            &[],
361            &empty_lookup,
362            &empty_lookup,
363            &empty_lookup,
364        );
365        assert_eq!(diag.extension_id, "my-ext");
366        assert!(diag.entries.is_empty());
367        assert!(diag.provider_missing.is_empty());
368    }
369
370    #[test]
371    fn diagnose_entry_with_default_resolves() {
372        let mut e = entry("region");
373        e.default = Some(Value::String("us-east-1".to_string()));
374        let diag = diagnose_extension_config(
375            "my-ext",
376            std::slice::from_ref(&e),
377            &[],
378            &empty_lookup,
379            &empty_lookup,
380            &empty_lookup,
381        );
382        assert_eq!(diag.entries.len(), 1);
383        assert_eq!(diag.entries[0].source, ConfigSource::Default);
384        assert!(diag.entries[0].has_value);
385        assert!(diag.provider_missing.is_empty());
386    }
387
388    #[test]
389    fn diagnose_provider_requires_undeclared_key() {
390        let diag = diagnose_extension_config(
391            "my-ext",
392            &[],
393            &[("p".to_string(), vec!["api-key".to_string()])],
394            &empty_lookup,
395            &empty_lookup,
396            &empty_lookup,
397        );
398        assert_eq!(
399            diag.provider_missing,
400            vec![("p".to_string(), "api-key".to_string())]
401        );
402    }
403
404    #[test]
405    fn diagnose_provider_required_key_resolved_via_env() {
406        let mut e = entry("api-key");
407        e.required = true;
408        let env = |k: &str| {
409            if k == "SYNAPS_EXTENSION_MY_EXT_API_KEY" {
410                Some("v".to_string())
411            } else {
412                None
413            }
414        };
415        let diag = diagnose_extension_config(
416            "my-ext",
417            std::slice::from_ref(&e),
418            &[("p".to_string(), vec!["api-key".to_string()])],
419            &env,
420            &empty_lookup,
421            &empty_lookup,
422        );
423        assert!(diag.entries[0].has_value);
424        assert!(diag.provider_missing.is_empty());
425    }
426
427    #[test]
428    fn diagnose_provider_required_key_declared_but_missing() {
429        let mut e = entry("api-key");
430        e.required = true;
431        let diag = diagnose_extension_config(
432            "my-ext",
433            std::slice::from_ref(&e),
434            &[("p".to_string(), vec!["api-key".to_string()])],
435            &empty_lookup,
436            &empty_lookup,
437            &empty_lookup,
438        );
439        assert!(!diag.entries[0].has_value);
440        assert_eq!(
441            diag.provider_missing,
442            vec![("p".to_string(), "api-key".to_string())]
443        );
444    }
445}