sqlite_graphrag/
config.rs1use 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 #[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
53pub fn get_setting(key: &str) -> Result<Option<String>, AppError> {
56 let cfg = load_config()?;
57 Ok(cfg.settings.get(key).cloned())
58}
59
60pub 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
72pub 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
82pub 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 #[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 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 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 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}