Skip to main content

sqlite_graphrag/
config.rs

1//! XDG-based API key management for OpenRouter and other providers.
2//!
3//! Stores keys in `$XDG_CONFIG_HOME/sqlite-graphrag/config.toml` with
4//! atomic write, symlink-attack defense and Unix permission hardening.
5
6use crate::errors::AppError;
7use crate::i18n::validation;
8use secrecy::SecretBox;
9use serde::{Deserialize, Serialize};
10use std::path::PathBuf;
11
12/// App config.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct AppConfig {
15    /// Configuration schema version.
16    pub schema_version: u32,
17    /// Keys.
18    #[serde(default)]
19    pub keys: Vec<ApiKeyEntry>,
20    /// Operational settings persisted via `config set/get` (G-T-XDG-01).
21    /// Stringly-typed map keeps the schema open without migrations for every
22    /// new key. Known keys are documented in `config set --help`.
23    #[serde(default)]
24    pub settings: std::collections::BTreeMap<String, String>,
25}
26
27/// API key entry.
28#[derive(Clone, Serialize, Deserialize)]
29pub struct ApiKeyEntry {
30    /// Provider name.
31    pub provider: String,
32    /// Value.
33    pub value: String,
34    /// Added at.
35    pub added_at: String,
36    /// Fingerprint.
37    pub fingerprint: String,
38}
39
40impl std::fmt::Debug for ApiKeyEntry {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.debug_struct("ApiKeyEntry")
43            .field("provider", &self.provider)
44            .field("value", &mask_key(&self.value))
45            .field("added_at", &self.added_at)
46            .field("fingerprint", &self.fingerprint)
47            .finish()
48    }
49}
50
51impl Default for AppConfig {
52    fn default() -> Self {
53        Self {
54            schema_version: 1,
55            keys: vec![],
56            settings: std::collections::BTreeMap::new(),
57        }
58    }
59}
60
61/// One entry of the canonical setting registry.
62///
63/// Carrying the default alongside the key is what lets `config doctor` derive
64/// its whole listing from [`SETTING_KEYS`] instead of repeating a hand-written
65/// table. `GAP-SG-85` was exactly that divergence: a 14-entry manual list next
66/// to a 44-key registry, missing the one key that redirects the database.
67pub struct SettingKey {
68    /// Dotted key accepted by `config set`.
69    pub key: &'static str,
70    /// Literal default applied when neither a CLI flag nor the XDG config
71    /// supplies a value.
72    ///
73    /// `None` marks a default that cannot be a static string because it is
74    /// derived from the host at runtime — an XDG directory, the CPU count, or
75    /// a probe. `config doctor` reports those as `derived` rather than
76    /// inventing a number that would not match what the process actually uses.
77    pub default: Option<&'static str>,
78}
79
80/// Canonical registry of operational setting keys accepted by `config set`.
81///
82/// Single source of truth shared by [`set_setting`] validation and the
83/// `config doctor` knob listing. Keeping exactly one list prevents the
84/// divergence class recorded as `GAP-SG-79`, where help text advertised
85/// `db.default_path` while [`crate::paths::AppPaths::resolve`] has always
86/// read `db.path`.
87///
88/// Every entry MUST have a matching [`get_setting`] reader somewhere in the
89/// crate. Adding a key here without a reader recreates the silent no-op this
90/// registry exists to prevent; `GAP-SG-90` adds the test that enforces it.
91///
92/// Literal defaults that mirror a constant are asserted against that constant
93/// in this module's tests, so the two cannot drift apart in silence.
94///
95/// Kept sorted so the emitted diagnostics are stable across runs.
96pub const SETTING_KEYS: &[SettingKey] = &[
97    SettingKey { key: "cache.dir", default: None },
98    SettingKey { key: "cli.max_instances", default: None },
99    SettingKey { key: "db.busy_base_delay_ms", default: Some("300") },
100    SettingKey { key: "db.busy_retries", default: Some("5") },
101    SettingKey { key: "db.path", default: None },
102    SettingKey { key: "db.query_timeout_ms", default: Some("5000") },
103    SettingKey { key: "display.tz", default: Some("UTC") },
104    SettingKey { key: "embedding.batch_size", default: Some("32") },
105    SettingKey { key: "embedding.claude_model", default: None },
106    SettingKey { key: "embedding.codex_model", default: None },
107    SettingKey { key: "embedding.dim", default: Some("1024") },
108    SettingKey { key: "embedding.opencode_model", default: None },
109    SettingKey { key: "enrich.entity_connect.default_limit", default: Some("100") },
110    SettingKey { key: "enrich.entity_connect.large_ns_limit", default: Some("25") },
111    SettingKey { key: "enrich.entity_description.domain", default: Some("auto") },
112    SettingKey { key: "enrich.entity_description.grounding_threshold", default: Some("0.12") },
113    SettingKey { key: "enrich.entity_description.min_corpus_chars", default: Some("40") },
114    SettingKey { key: "enrich.entity_description.quality_sample", default: Some("50") },
115    SettingKey { key: "enrich.yield_every_n_items", default: Some("10") },
116    SettingKey { key: "i18n.lang", default: Some("en") },
117    SettingKey { key: "ingest.low_memory", default: Some("false") },
118    SettingKey { key: "limits.max_entities_per_memory", default: Some("50") },
119    SettingKey { key: "limits.max_relations_per_memory", default: Some("50") },
120    SettingKey { key: "llm.claude_binary", default: None },
121    SettingKey { key: "llm.claude_empty_config_dir", default: None },
122    SettingKey { key: "llm.codex_binary", default: None },
123    SettingKey { key: "llm.fallback", default: Some("codex,claude,none") },
124    SettingKey { key: "llm.max_host_concurrency", default: None },
125    SettingKey { key: "llm.model", default: None },
126    SettingKey { key: "llm.opencode_binary", default: None },
127    SettingKey { key: "llm.opencode_model", default: None },
128    SettingKey { key: "llm.opencode_timeout", default: Some("300") },
129    SettingKey { key: "llm.probe_timeout_ms", default: Some("800") },
130    SettingKey { key: "llm.skip_embedding_on_failure", default: Some("false") },
131    SettingKey { key: "llm.slot_no_wait", default: Some("false") },
132    SettingKey { key: "llm.slot_wait_secs", default: Some("300") },
133    SettingKey { key: "log.format", default: Some("pretty") },
134    SettingKey { key: "log.level", default: Some("warn") },
135    SettingKey { key: "log.retention_days", default: Some("7") },
136    SettingKey { key: "log.rotation", default: Some("daily") },
137    SettingKey { key: "log.to_file", default: Some("false") },
138    SettingKey { key: "namespace.default", default: Some("global") },
139    SettingKey { key: "network.chat_url", default: None },
140    SettingKey { key: "network.embed_url", default: None },
141    SettingKey { key: "network.openrouter.chat_url", default: Some(crate::constants::DEFAULT_OPENROUTER_CHAT_URL) },
142    SettingKey { key: "network.openrouter.embeddings_url", default: Some(crate::constants::DEFAULT_OPENROUTER_EMBEDDINGS_URL) },
143    SettingKey { key: "parallelism.rayon_threads", default: None },
144    SettingKey { key: "retry.disable", default: Some("false") },
145    SettingKey { key: "shutdown.ignore", default: Some("false") },
146    SettingKey { key: "spawn.skip_preflight", default: Some("false") },
147    SettingKey { key: "spawn.strict_env_clear", default: Some("false") },
148    SettingKey { key: "system.max_load_per_ncpu", default: Some("2.0") },
149];
150
151/// Iterates the registry key names in registration (sorted) order.
152///
153/// Callers that only need names use this instead of reaching into the struct,
154/// so adding a field to [`SettingKey`] does not ripple through the crate.
155pub fn setting_key_names() -> impl Iterator<Item = &'static str> {
156    SETTING_KEYS.iter().map(|entry| entry.key)
157}
158
159/// Setting keys that were advertised historically but never had a reader.
160///
161/// Each tuple maps the obsolete key to its replacement. Present so
162/// [`load_config`] can warn instead of ignoring the value in silence, which
163/// is the failure mode `GAP-SG-79` documents.
164///
165/// These keys are rejected by [`set_setting`]; the warning covers configs
166/// written before the validation existed.
167// GAP-SG-79 / GAP-SG-122: legacy aliases map to the single canonical key.
168// `db.default_path` never took effect (paths always read `db.path`).
169// `paths.cache` was a parallel name for `cache.dir`.
170const LEGACY_SETTING_KEYS: &[(&str, &str)] = &[
171    ("db.default_path", "db.path"),
172    ("paths.cache", "cache.dir"),
173];
174
175/// Minimum Jaro-Winkler similarity required to suggest a replacement key.
176///
177/// Below this a suggestion is more confusing than helpful, so the error lists
178/// nothing rather than pointing at an unrelated key.
179const SUGGESTION_THRESHOLD: f64 = 0.7;
180
181/// GAP-SG-90: every literal key passed to [`get_setting`] / [`set_setting`] in
182/// production sources must appear in [`SETTING_KEYS`]. The unit test below
183/// scans the crate source for `get_setting("…")` call sites.
184#[cfg(test)]
185mod setting_keys_drift_tests {
186    use super::SETTING_KEYS;
187
188    #[test]
189    fn setting_keys_covers_get_setting_literals_in_src() {
190        // Walk a fixed list of high-traffic modules (full-tree walk is flaky in
191        // package builds). Keys used only via aliases arrays are covered by
192        // runtime_config unit tests.
193        let sources = [
194            include_str!("paths.rs"),
195            include_str!("i18n/mod.rs"),
196            include_str!("i18n/validation/mod.rs"),
197            include_str!("i18n/validation/messages_a.rs"),
198            include_str!("i18n/validation/messages_b.rs"),
199            include_str!("tz.rs"),
200            include_str!("namespace.rs"),
201            include_str!("retry.rs"),
202            include_str!("lock.rs"),
203            include_str!("llm_slots.rs"),
204            include_str!("system_load.rs"),
205            include_str!("spawn/preflight.rs"),
206            include_str!("spawn/env_whitelist.rs"),
207            include_str!("commands/ingest/mod.rs"),
208            include_str!("commands/enrich/prompts.rs"),
209            include_str!("extract/llm_embedding/mod.rs"),
210            include_str!("lib.rs"),
211            include_str!("main.rs"),
212        ];
213        let registered: std::collections::HashSet<&str> =
214            SETTING_KEYS.iter().map(|e| e.key).collect();
215        let mut missing = Vec::new();
216        for src in sources {
217            for cap in src.split("get_setting(\"").skip(1) {
218                if let Some(end) = cap.find('"') {
219                    let key = &cap[..end];
220                    if key.is_empty() {
221                        continue;
222                    }
223                    if !registered.contains(key) {
224                        missing.push(key.to_string());
225                    }
226                }
227            }
228        }
229        missing.sort();
230        missing.dedup();
231        assert!(
232            missing.is_empty(),
233            "get_setting keys missing from SETTING_KEYS: {missing:?}"
234        );
235    }
236}
237
238/// Returns `true` when `key` belongs to [`SETTING_KEYS`].
239pub fn is_known_setting(key: &str) -> bool {
240    setting_key_names().any(|known| known == key)
241}
242
243/// Returns the closest known key to `key`, when one is similar enough.
244///
245/// Reuses `rapidfuzz` Jaro-Winkler, the same scorer already used for entity
246/// name resolution in [`crate::storage::entities`], instead of introducing a
247/// second similarity implementation.
248fn nearest_setting_key(key: &str) -> Option<&'static str> {
249    setting_key_names()
250        .map(|candidate| {
251            let score = rapidfuzz::distance::jaro_winkler::normalized_similarity(
252                key.chars(),
253                candidate.chars(),
254            );
255            (candidate, score)
256        })
257        .filter(|(_, score)| *score >= SUGGESTION_THRESHOLD)
258        .max_by(|a, b| a.1.total_cmp(&b.1))
259        .map(|(candidate, _)| candidate)
260}
261
262/// Read an operational setting from XDG config (flag > XDG > default is
263/// applied by callers). Returns `None` when unset.
264pub fn get_setting(key: &str) -> Result<Option<String>, AppError> {
265    let cfg = load_config()?;
266    if let Some(v) = cfg.settings.get(key) {
267        return Ok(Some(v.clone()));
268    }
269    // GAP-SG-122: when the canonical key is missing, fall back to any retired
270    // alias that still maps onto it (e.g. paths.cache → cache.dir).
271    for (legacy, replacement) in LEGACY_SETTING_KEYS {
272        if *replacement == key {
273            if let Some(v) = cfg.settings.get(*legacy) {
274                return Ok(Some(v.clone()));
275            }
276        }
277    }
278    Ok(None)
279}
280
281/// Persist an operational setting in XDG config.toml (G-T-XDG-01).
282pub fn set_setting(key: &str, value: &str) -> Result<(), AppError> {
283    if key.trim().is_empty() {
284        return Err(AppError::Validation(
285            "config key must be non-empty".into(),
286        ));
287    }
288    // GAP-SG-80: an unvalidated insert persisted typos and obsolete keys while
289    // reporting success, so the operator saw the value in `config show` and
290    // assumed it took effect. Reject early instead of storing a silent no-op.
291    if !is_known_setting(key) {
292        if let Some(replacement) = LEGACY_SETTING_KEYS
293            .iter()
294            .find(|(legacy, _)| *legacy == key)
295            .map(|(_, replacement)| *replacement)
296        {
297            return Err(AppError::Validation(validation::config_key_retired(
298                key,
299                replacement,
300            )));
301        }
302        return Err(AppError::Validation(validation::config_key_unknown(
303            key,
304            nearest_setting_key(key),
305        )));
306    }
307    let mut cfg = load_config()?;
308    cfg.settings.insert(key.to_string(), value.to_string());
309    save_config(&cfg)
310}
311
312/// Remove an operational setting from XDG config.toml.
313pub fn unset_setting(key: &str) -> Result<bool, AppError> {
314    let mut cfg = load_config()?;
315    let removed = cfg.settings.remove(key).is_some();
316    if removed {
317        save_config(&cfg)?;
318    }
319    Ok(removed)
320}
321
322/// List all operational settings (no secrets).
323pub fn list_settings() -> Result<std::collections::BTreeMap<String, String>, AppError> {
324    let cfg = load_config()?;
325    Ok(cfg.settings)
326}
327
328/// Resolved key.
329pub struct ResolvedKey {
330    /// Value.
331    pub value: SecretBox<String>,
332    /// Source side of the relationship.
333    pub source: &'static str,
334}
335
336/// Absolute path of `config.toml`.
337///
338/// GAP-SG-98: delegates to [`crate::paths::config_dir`] so `--config-dir` is
339/// honoured. This function used to call [`ProjectDirs`] directly, which made it
340/// a second, independent config-directory resolver that no flag could redirect.
341///
342/// There is no cycle: [`crate::paths::config_dir`] consults only the CLI
343/// override captured in [`crate::runtime_config`], never a `config set` key.
344pub fn config_file_path() -> Result<PathBuf, AppError> {
345    Ok(crate::paths::config_dir()?.join("config.toml"))
346}
347
348/// Load application configuration from the XDG config file.
349pub fn load_config() -> Result<AppConfig, AppError> {
350    let path = config_file_path()?;
351
352    if !path.exists() {
353        return Ok(AppConfig::default());
354    }
355
356    let meta = std::fs::symlink_metadata(&path)?;
357    if meta.file_type().is_symlink() {
358        return Err(AppError::Validation(validation::config_file_is_symlink(
359            &path.display().to_string(),
360        )));
361    }
362
363    #[cfg(unix)]
364    {
365        use std::os::unix::fs::PermissionsExt;
366        let mode = meta.permissions().mode() & 0o777;
367        if mode > 0o600 {
368            tracing::warn!(
369                path = %path.display(),
370                mode = format!("{mode:o}"),
371                "config file permissions are too open; recommend chmod 600"
372            );
373        }
374    }
375
376    let content = std::fs::read_to_string(&path)?;
377    let cfg: AppConfig = toml::from_str(&content).map_err(|e| {
378        AppError::Validation(validation::config_parse_error(
379            &path.display().to_string(),
380            &e,
381        ))
382    })?;
383    warn_on_legacy_settings(&cfg);
384    Ok(cfg)
385}
386
387/// Emits one warning per process for each retired key still present on disk.
388///
389/// `load_config` runs on every [`get_setting`] call, so the warning is gated by
390/// a [`std::sync::Once`] to keep a hot read path from flooding stderr.
391///
392/// The value is deliberately left untouched: `GAP-SG-79` is fixed by making the
393/// dead key visible, not by rewriting a file the operator owns.
394fn warn_on_legacy_settings(cfg: &AppConfig) {
395    static WARNED: std::sync::Once = std::sync::Once::new();
396    if LEGACY_SETTING_KEYS
397        .iter()
398        .all(|(legacy, _)| !cfg.settings.contains_key(*legacy))
399    {
400        return;
401    }
402    WARNED.call_once(|| {
403        for (legacy, replacement) in LEGACY_SETTING_KEYS {
404            if cfg.settings.contains_key(*legacy) {
405                tracing::warn!(
406                    target: "config",
407                    key = legacy,
408                    replacement = replacement,
409                    "config key is never read and has no effect; \
410                     move the value to the replacement key and unset the old one"
411                );
412            }
413        }
414    });
415}
416
417/// Persist application configuration to the XDG config file.
418pub fn save_config(config: &AppConfig) -> Result<(), AppError> {
419    let path = config_file_path()?;
420    let dir = path.parent().ok_or_else(|| {
421        AppError::Validation(validation::config_path_no_parent(
422            &path.display().to_string(),
423        ))
424    })?;
425
426    std::fs::create_dir_all(dir)?;
427
428    #[cfg(unix)]
429    {
430        use std::os::unix::fs::PermissionsExt;
431        std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
432    }
433
434    #[cfg(unix)]
435    if path.exists() {
436        use std::os::unix::fs::MetadataExt;
437        let meta = std::fs::metadata(&path)?;
438        let file_uid = meta.uid();
439        let my_uid = unsafe { libc::getuid() };
440        if file_uid != my_uid {
441            return Err(AppError::Validation(validation::config_file_wrong_owner(
442                &path.display().to_string(),
443                file_uid,
444                my_uid,
445            )));
446        }
447    }
448
449    let serialized =
450        toml::to_string_pretty(config).map_err(|e| AppError::Validation(e.to_string()))?;
451
452    #[cfg(unix)]
453    let old_umask = unsafe { libc::umask(0o077) };
454
455    use std::io::Write;
456    let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
457    tmp.write_all(serialized.as_bytes())?;
458    tmp.as_file().sync_all()?;
459
460    #[cfg(unix)]
461    {
462        use std::os::unix::fs::PermissionsExt;
463        std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o600))?;
464    }
465
466    tmp.persist(&path)
467        .map_err(|e| AppError::Io(std::io::Error::other(format!("atomic persist failed: {e}"))))?;
468
469    #[cfg(unix)]
470    unsafe {
471        libc::umask(old_umask);
472    }
473
474    // fsync parent dir for crash consistency
475    #[cfg(unix)]
476    {
477        let dir_file = std::fs::File::open(dir)?;
478        dir_file.sync_all()?;
479    }
480
481    Ok(())
482}
483
484/// Resolve API key.
485pub fn resolve_api_key(provider: &str, cli_key: Option<&str>) -> Option<ResolvedKey> {
486    // G-T-XDG-04: flag/cli > XDG `config add-key` only. Product env is not read.
487    if let Some(k) = cli_key {
488        if !k.is_empty() {
489            return Some(ResolvedKey {
490                value: SecretBox::new(Box::new(k.to_owned())),
491                source: "cli",
492            });
493        }
494    }
495
496    if let Ok(cfg) = load_config() {
497        if let Some(entry) = cfg.keys.iter().find(|k| k.provider == provider) {
498            return Some(ResolvedKey {
499                value: SecretBox::new(Box::new(entry.value.clone())),
500                source: "config",
501            });
502        }
503    }
504
505    None
506}
507
508/// Compute fingerprint.
509pub fn compute_fingerprint(key: &str) -> String {
510    let hash = blake3::hash(key.as_bytes());
511    hash.to_hex()[..16].to_string()
512}
513
514/// Mask key.
515pub fn mask_key(key: &str) -> String {
516    if key.len() <= 8 {
517        return "****".to_string();
518    }
519    format!("{}...{}", &key[..4], &key[key.len() - 4..])
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525    use secrecy::ExposeSecret;
526    use tempfile::TempDir;
527
528    #[test]
529    fn compute_fingerprint_deterministic() {
530        let fp1 = compute_fingerprint("sk-or-v1-test-key-12345");
531        let fp2 = compute_fingerprint("sk-or-v1-test-key-12345");
532        assert_eq!(fp1, fp2);
533        assert_eq!(fp1.len(), 16);
534    }
535
536    #[test]
537    fn compute_fingerprint_differs_for_different_keys() {
538        let fp1 = compute_fingerprint("key-a");
539        let fp2 = compute_fingerprint("key-b");
540        assert_ne!(fp1, fp2);
541    }
542
543    #[test]
544    fn mask_key_short() {
545        assert_eq!(mask_key("abcd"), "****");
546        assert_eq!(mask_key("12345678"), "****");
547        assert_eq!(mask_key(""), "****");
548    }
549
550    #[test]
551    fn mask_key_normal() {
552        assert_eq!(mask_key("sk-or-v1-abcdef1234"), "sk-o...1234");
553    }
554
555    #[test]
556    fn load_config_missing_file_returns_default() {
557        let tmp = TempDir::new().unwrap();
558        let nonexistent = tmp.path().join("does-not-exist.toml");
559        assert!(!nonexistent.exists());
560        let cfg = AppConfig::default();
561        assert_eq!(cfg.schema_version, 1);
562        assert!(cfg.keys.is_empty());
563    }
564
565    #[test]
566    fn save_and_load_roundtrip() {
567        let tmp = TempDir::new().unwrap();
568        let config_path = tmp.path().join("config.toml");
569
570        let mut cfg = AppConfig::default();
571        cfg.keys.push(ApiKeyEntry {
572            provider: "openrouter".to_string(),
573            value: "sk-test-key".to_string(),
574            added_at: "2026-01-01T00:00:00Z".to_string(),
575            fingerprint: compute_fingerprint("sk-test-key"),
576        });
577
578        let serialized = toml::to_string_pretty(&cfg).unwrap();
579        std::fs::write(&config_path, &serialized).unwrap();
580
581        let content = std::fs::read_to_string(&config_path).unwrap();
582        let loaded: AppConfig = toml::from_str(&content).unwrap();
583
584        assert_eq!(loaded.schema_version, 1);
585        assert_eq!(loaded.keys.len(), 1);
586        assert_eq!(loaded.keys[0].provider, "openrouter");
587        assert_eq!(loaded.keys[0].value, "sk-test-key");
588    }
589
590    #[test]
591    fn resolve_api_key_cli_wins() {
592        let resolved = resolve_api_key("openrouter", Some("cli-key-value"));
593        assert!(resolved.is_some());
594        let r = resolved.unwrap();
595        assert_eq!(r.source, "cli");
596        assert_eq!(r.value.expose_secret(), "cli-key-value");
597    }
598
599    #[test]
600    fn resolve_api_key_cli_fallback() {
601        let resolved = resolve_api_key("nonexistent-provider", Some("cli-key"));
602        assert!(resolved.is_some());
603        let r = resolved.unwrap();
604        assert_eq!(r.source, "cli");
605        assert_eq!(r.value.expose_secret(), "cli-key");
606    }
607
608    #[test]
609    fn resolve_api_key_none_when_nothing_available() {
610        let resolved = resolve_api_key("totally-unknown-provider-xyz-no-key", None);
611        // Only returns Some if host XDG config has that provider (unlikely).
612        if let Some(r) = resolved {
613            assert_eq!(r.source, "config");
614        }
615    }
616
617    #[test]
618    fn resolve_api_key_ignores_product_env() {
619        // G-T-XDG-04: even if OPENROUTER_API_KEY is set, it must not be used.
620        unsafe {
621            std::env::set_var("OPENROUTER_API_KEY", "env-must-be-ignored");
622        }
623        let resolved = resolve_api_key("openrouter-env-ignore-test-provider", None);
624        assert!(
625            resolved.is_none(),
626            "product env must not supply API keys"
627        );
628        unsafe {
629            std::env::remove_var("OPENROUTER_API_KEY");
630        }
631    }
632}