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 directories::ProjectDirs;
8use secrecy::SecretBox;
9use serde::{Deserialize, Serialize};
10use std::path::PathBuf;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct AppConfig {
14    pub schema_version: u32,
15    #[serde(default)]
16    pub keys: Vec<ApiKeyEntry>,
17    /// Operational settings persisted via `config set/get` (G-T-XDG-01).
18    /// Stringly-typed map keeps the schema open without migrations for every
19    /// new key. Known keys are documented in `config set --help`.
20    #[serde(default)]
21    pub settings: std::collections::BTreeMap<String, String>,
22}
23
24#[derive(Clone, Serialize, Deserialize)]
25pub struct ApiKeyEntry {
26    pub provider: String,
27    pub value: String,
28    pub added_at: String,
29    pub fingerprint: String,
30}
31
32impl std::fmt::Debug for ApiKeyEntry {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        f.debug_struct("ApiKeyEntry")
35            .field("provider", &self.provider)
36            .field("value", &mask_key(&self.value))
37            .field("added_at", &self.added_at)
38            .field("fingerprint", &self.fingerprint)
39            .finish()
40    }
41}
42
43impl Default for AppConfig {
44    fn default() -> Self {
45        Self {
46            schema_version: 1,
47            keys: vec![],
48            settings: std::collections::BTreeMap::new(),
49        }
50    }
51}
52
53/// Read an operational setting from XDG config (flag > XDG > default is
54/// applied by callers). Returns `None` when unset.
55pub fn get_setting(key: &str) -> Result<Option<String>, AppError> {
56    let cfg = load_config()?;
57    Ok(cfg.settings.get(key).cloned())
58}
59
60/// Persist an operational setting in XDG config.toml (G-T-XDG-01).
61pub fn set_setting(key: &str, value: &str) -> Result<(), AppError> {
62    if key.trim().is_empty() {
63        return Err(AppError::Validation(
64            "config key must be non-empty".into(),
65        ));
66    }
67    let mut cfg = load_config()?;
68    cfg.settings.insert(key.to_string(), value.to_string());
69    save_config(&cfg)
70}
71
72/// Remove an operational setting from XDG config.toml.
73pub fn unset_setting(key: &str) -> Result<bool, AppError> {
74    let mut cfg = load_config()?;
75    let removed = cfg.settings.remove(key).is_some();
76    if removed {
77        save_config(&cfg)?;
78    }
79    Ok(removed)
80}
81
82/// List all operational settings (no secrets).
83pub fn list_settings() -> Result<std::collections::BTreeMap<String, String>, AppError> {
84    let cfg = load_config()?;
85    Ok(cfg.settings)
86}
87
88pub struct ResolvedKey {
89    pub value: SecretBox<String>,
90    pub source: &'static str,
91}
92
93pub fn config_file_path() -> Result<PathBuf, AppError> {
94    let proj = ProjectDirs::from("", "", "sqlite-graphrag").ok_or_else(|| {
95        AppError::Io(std::io::Error::other(
96            "could not determine home directory for config",
97        ))
98    })?;
99    Ok(proj.config_dir().join("config.toml"))
100}
101
102pub fn load_config() -> Result<AppConfig, AppError> {
103    let path = config_file_path()?;
104
105    if !path.exists() {
106        return Ok(AppConfig::default());
107    }
108
109    let meta = std::fs::symlink_metadata(&path)?;
110    if meta.file_type().is_symlink() {
111        return Err(AppError::Validation(format!(
112            "config file is a symlink (potential attack): {}",
113            path.display()
114        )));
115    }
116
117    #[cfg(unix)]
118    {
119        use std::os::unix::fs::PermissionsExt;
120        let mode = meta.permissions().mode() & 0o777;
121        if mode > 0o600 {
122            tracing::warn!(
123                path = %path.display(),
124                mode = format!("{mode:o}"),
125                "config file permissions are too open; recommend chmod 600"
126            );
127        }
128    }
129
130    let content = std::fs::read_to_string(&path)?;
131    toml::from_str(&content)
132        .map_err(|e| AppError::Validation(format!("config parse error in {}: {e}", path.display())))
133}
134
135pub fn save_config(config: &AppConfig) -> Result<(), AppError> {
136    let path = config_file_path()?;
137    let dir = path.parent().ok_or_else(|| {
138        AppError::Validation(format!("config path has no parent: {}", path.display()))
139    })?;
140
141    std::fs::create_dir_all(dir)?;
142
143    #[cfg(unix)]
144    {
145        use std::os::unix::fs::PermissionsExt;
146        std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
147    }
148
149    #[cfg(unix)]
150    if path.exists() {
151        use std::os::unix::fs::MetadataExt;
152        let meta = std::fs::metadata(&path)?;
153        let file_uid = meta.uid();
154        let my_uid = unsafe { libc::getuid() };
155        if file_uid != my_uid {
156            return Err(AppError::Validation(format!(
157                "config file {} owned by uid {file_uid}, not current uid {my_uid}; refusing to overwrite",
158                path.display()
159            )));
160        }
161    }
162
163    let serialized =
164        toml::to_string_pretty(config).map_err(|e| AppError::Validation(e.to_string()))?;
165
166    #[cfg(unix)]
167    let old_umask = unsafe { libc::umask(0o077) };
168
169    use std::io::Write;
170    let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
171    tmp.write_all(serialized.as_bytes())?;
172    tmp.as_file().sync_all()?;
173
174    #[cfg(unix)]
175    {
176        use std::os::unix::fs::PermissionsExt;
177        std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o600))?;
178    }
179
180    tmp.persist(&path)
181        .map_err(|e| AppError::Io(std::io::Error::other(format!("atomic persist failed: {e}"))))?;
182
183    #[cfg(unix)]
184    unsafe {
185        libc::umask(old_umask);
186    }
187
188    // fsync parent dir for crash consistency
189    #[cfg(unix)]
190    {
191        let dir_file = std::fs::File::open(dir)?;
192        dir_file.sync_all()?;
193    }
194
195    Ok(())
196}
197
198pub fn resolve_api_key(provider: &str, cli_key: Option<&str>) -> Option<ResolvedKey> {
199    // G-T-XDG-04: flag/cli > XDG `config add-key` only. Product env is not read.
200    if let Some(k) = cli_key {
201        if !k.is_empty() {
202            return Some(ResolvedKey {
203                value: SecretBox::new(Box::new(k.to_owned())),
204                source: "cli",
205            });
206        }
207    }
208
209    if let Ok(cfg) = load_config() {
210        if let Some(entry) = cfg.keys.iter().find(|k| k.provider == provider) {
211            return Some(ResolvedKey {
212                value: SecretBox::new(Box::new(entry.value.clone())),
213                source: "config",
214            });
215        }
216    }
217
218    None
219}
220
221pub fn compute_fingerprint(key: &str) -> String {
222    let hash = blake3::hash(key.as_bytes());
223    hash.to_hex()[..16].to_string()
224}
225
226pub fn mask_key(key: &str) -> String {
227    if key.len() <= 8 {
228        return "****".to_string();
229    }
230    format!("{}...{}", &key[..4], &key[key.len() - 4..])
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use secrecy::ExposeSecret;
237    use tempfile::TempDir;
238
239    #[test]
240    fn compute_fingerprint_deterministic() {
241        let fp1 = compute_fingerprint("sk-or-v1-test-key-12345");
242        let fp2 = compute_fingerprint("sk-or-v1-test-key-12345");
243        assert_eq!(fp1, fp2);
244        assert_eq!(fp1.len(), 16);
245    }
246
247    #[test]
248    fn compute_fingerprint_differs_for_different_keys() {
249        let fp1 = compute_fingerprint("key-a");
250        let fp2 = compute_fingerprint("key-b");
251        assert_ne!(fp1, fp2);
252    }
253
254    #[test]
255    fn mask_key_short() {
256        assert_eq!(mask_key("abcd"), "****");
257        assert_eq!(mask_key("12345678"), "****");
258        assert_eq!(mask_key(""), "****");
259    }
260
261    #[test]
262    fn mask_key_normal() {
263        assert_eq!(mask_key("sk-or-v1-abcdef1234"), "sk-o...1234");
264    }
265
266    #[test]
267    fn load_config_missing_file_returns_default() {
268        let tmp = TempDir::new().unwrap();
269        let nonexistent = tmp.path().join("does-not-exist.toml");
270        assert!(!nonexistent.exists());
271        let cfg = AppConfig::default();
272        assert_eq!(cfg.schema_version, 1);
273        assert!(cfg.keys.is_empty());
274    }
275
276    #[test]
277    fn save_and_load_roundtrip() {
278        let tmp = TempDir::new().unwrap();
279        let config_path = tmp.path().join("config.toml");
280
281        let mut cfg = AppConfig::default();
282        cfg.keys.push(ApiKeyEntry {
283            provider: "openrouter".to_string(),
284            value: "sk-test-key".to_string(),
285            added_at: "2026-01-01T00:00:00Z".to_string(),
286            fingerprint: compute_fingerprint("sk-test-key"),
287        });
288
289        let serialized = toml::to_string_pretty(&cfg).unwrap();
290        std::fs::write(&config_path, &serialized).unwrap();
291
292        let content = std::fs::read_to_string(&config_path).unwrap();
293        let loaded: AppConfig = toml::from_str(&content).unwrap();
294
295        assert_eq!(loaded.schema_version, 1);
296        assert_eq!(loaded.keys.len(), 1);
297        assert_eq!(loaded.keys[0].provider, "openrouter");
298        assert_eq!(loaded.keys[0].value, "sk-test-key");
299    }
300
301    #[test]
302    fn resolve_api_key_cli_wins() {
303        let resolved = resolve_api_key("openrouter", Some("cli-key-value"));
304        assert!(resolved.is_some());
305        let r = resolved.unwrap();
306        assert_eq!(r.source, "cli");
307        assert_eq!(r.value.expose_secret(), "cli-key-value");
308    }
309
310    #[test]
311    fn resolve_api_key_cli_fallback() {
312        let resolved = resolve_api_key("nonexistent-provider", Some("cli-key"));
313        assert!(resolved.is_some());
314        let r = resolved.unwrap();
315        assert_eq!(r.source, "cli");
316        assert_eq!(r.value.expose_secret(), "cli-key");
317    }
318
319    #[test]
320    fn resolve_api_key_none_when_nothing_available() {
321        let resolved = resolve_api_key("totally-unknown-provider-xyz-no-key", None);
322        // Only returns Some if host XDG config has that provider (unlikely).
323        if let Some(r) = resolved {
324            assert_eq!(r.source, "config");
325        }
326    }
327
328    #[test]
329    fn resolve_api_key_ignores_product_env() {
330        // G-T-XDG-04: even if OPENROUTER_API_KEY is set, it must not be used.
331        unsafe {
332            std::env::set_var("OPENROUTER_API_KEY", "env-must-be-ignored");
333        }
334        let resolved = resolve_api_key("openrouter-env-ignore-test-provider", None);
335        assert!(
336            resolved.is_none(),
337            "product env must not supply API keys"
338        );
339        unsafe {
340            std::env::remove_var("OPENROUTER_API_KEY");
341        }
342    }
343}