sqlite_graphrag/config/
store.rs1use super::permissions::restrict_to_current_user;
7use super::registry::LEGACY_SETTING_KEYS;
8use super::AppConfig;
9use crate::errors::AppError;
10use crate::i18n::validation;
11use std::path::PathBuf;
12
13pub fn config_file_path() -> Result<PathBuf, AppError> {
22 Ok(crate::paths::config_dir()?.join("config.toml"))
23}
24
25pub fn load_config() -> Result<AppConfig, AppError> {
27 let path = config_file_path()?;
28
29 if !path.exists() {
30 return Ok(AppConfig::default());
31 }
32
33 let meta = std::fs::symlink_metadata(&path)?;
34 if meta.file_type().is_symlink() {
35 return Err(AppError::Validation(validation::config_file_is_symlink(
36 &path.display().to_string(),
37 )));
38 }
39
40 #[cfg(unix)]
41 {
42 use std::os::unix::fs::PermissionsExt;
43 let mode = meta.permissions().mode() & 0o777;
44 if mode > 0o600 {
45 tracing::warn!(
46 path = %path.display(),
47 mode = format!("{mode:o}"),
48 "config file permissions are too open; recommend chmod 600"
49 );
50 }
51 }
52
53 let content = std::fs::read_to_string(&path)?;
54 let cfg: AppConfig = toml::from_str(&content).map_err(|e| {
55 AppError::Validation(validation::config_parse_error(
56 &path.display().to_string(),
57 &e,
58 ))
59 })?;
60 warn_on_legacy_settings(&cfg);
61 Ok(cfg)
62}
63
64fn warn_on_legacy_settings(cfg: &AppConfig) {
72 static WARNED: std::sync::Once = std::sync::Once::new();
73 if LEGACY_SETTING_KEYS
74 .iter()
75 .all(|(legacy, _)| !cfg.settings.contains_key(*legacy))
76 {
77 return;
78 }
79 WARNED.call_once(|| {
80 for (legacy, replacement) in LEGACY_SETTING_KEYS {
81 if cfg.settings.contains_key(*legacy) {
82 tracing::warn!(
83 target: "config",
84 key = legacy,
85 replacement = replacement,
86 "config key is never read and has no effect; \
87 move the value to the replacement key and unset the old one"
88 );
89 }
90 }
91 });
92}
93
94pub fn save_config(config: &AppConfig) -> Result<(), AppError> {
96 let path = config_file_path()?;
97 let dir = path.parent().ok_or_else(|| {
98 AppError::Validation(validation::config_path_no_parent(
99 &path.display().to_string(),
100 ))
101 })?;
102
103 std::fs::create_dir_all(dir)?;
104
105 #[cfg(unix)]
106 {
107 use std::os::unix::fs::PermissionsExt;
108 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
109 }
110
111 if let Err(e) = restrict_to_current_user(dir) {
121 tracing::warn!(
122 path = %dir.display(),
123 error = %e,
124 "could not restrict config directory to the current user; \
125 the config file DACL remains the primary protection"
126 );
127 }
128
129 #[cfg(unix)]
130 if path.exists() {
131 use std::os::unix::fs::MetadataExt;
132 let meta = std::fs::metadata(&path)?;
133 let file_uid = meta.uid();
134 let my_uid = unsafe { libc::getuid() };
135 if file_uid != my_uid {
136 return Err(AppError::Validation(validation::config_file_wrong_owner(
137 &path.display().to_string(),
138 file_uid,
139 my_uid,
140 )));
141 }
142 }
143
144 let serialized =
145 toml::to_string_pretty(config).map_err(|e| AppError::Validation(e.to_string()))?;
146
147 #[cfg(unix)]
148 let old_umask = unsafe { libc::umask(0o077) };
149
150 use std::io::Write;
151 let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
152 tmp.write_all(serialized.as_bytes())?;
153 tmp.as_file().sync_all()?;
154
155 #[cfg(unix)]
156 {
157 use std::os::unix::fs::PermissionsExt;
158 std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o600))?;
159 }
160
161 tmp.persist(&path)
162 .map_err(|e| AppError::Io(std::io::Error::other(format!("atomic persist failed: {e}"))))?;
163
164 restrict_to_current_user(&path)?;
174
175 #[cfg(unix)]
176 unsafe {
177 libc::umask(old_umask);
178 }
179
180 #[cfg(unix)]
182 {
183 let dir_file = std::fs::File::open(dir)?;
184 dir_file.sync_all()?;
185 }
186
187 Ok(())
188}
189
190#[cfg(test)]
191mod tests {
192 use crate::config::{compute_fingerprint, ApiKeyEntry, AppConfig};
193 use tempfile::TempDir;
194
195 #[test]
196 fn load_config_missing_file_returns_default() {
197 let tmp = TempDir::new().unwrap();
198 let nonexistent = tmp.path().join("does-not-exist.toml");
199 assert!(!nonexistent.exists());
200 let cfg = AppConfig::default();
201 assert_eq!(cfg.schema_version, 1);
202 assert!(cfg.keys.is_empty());
203 }
204
205 #[test]
206 fn save_and_load_roundtrip() {
207 let tmp = TempDir::new().unwrap();
208 let config_path = tmp.path().join("config.toml");
209
210 let mut cfg = AppConfig::default();
211 cfg.keys.push(ApiKeyEntry {
212 provider: "openrouter".to_string(),
213 value: "sk-test-key".to_string(),
214 added_at: "2026-01-01T00:00:00Z".to_string(),
215 fingerprint: compute_fingerprint("sk-test-key"),
216 });
217
218 let serialized = toml::to_string_pretty(&cfg).unwrap();
219 std::fs::write(&config_path, &serialized).unwrap();
220
221 let content = std::fs::read_to_string(&config_path).unwrap();
222 let loaded: AppConfig = toml::from_str(&content).unwrap();
223
224 assert_eq!(loaded.schema_version, 1);
225 assert_eq!(loaded.keys.len(), 1);
226 assert_eq!(loaded.keys[0].provider, "openrouter");
227 assert_eq!(loaded.keys[0].value, "sk-test-key");
228 }
229}