Skip to main content

sqlite_graphrag/config/
store.rs

1//! Location, load and atomic persistence of `config.toml`.
2//!
3//! Owns the on-disk representation: symlink refusal, permission warning,
4//! tempfile-fsync-rename persistence and the platform hardening call-outs.
5
6use 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
13/// Absolute path of `config.toml`.
14///
15/// GAP-SG-98: delegates to [`crate::paths::config_dir`] so `--config-dir` is
16/// honoured. This function used to call [`directories::ProjectDirs`] directly, which made it
17/// a second, independent config-directory resolver that no flag could redirect.
18///
19/// There is no cycle: [`crate::paths::config_dir`] consults only the CLI
20/// override captured in [`crate::runtime_config`], never a `config set` key.
21pub fn config_file_path() -> Result<PathBuf, AppError> {
22    Ok(crate::paths::config_dir()?.join("config.toml"))
23}
24
25/// Load application configuration from the XDG config file.
26pub 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
64/// Emits one warning per process for each retired key still present on disk.
65///
66/// `load_config` runs on every [`get_setting`] call, so the warning is gated by
67/// a [`std::sync::Once`] to keep a hot read path from flooding stderr.
68///
69/// The value is deliberately left untouched: `GAP-SG-79` is fixed by making the
70/// dead key visible, not by rewriting a file the operator owns.
71fn 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
94/// Persist application configuration to the XDG config file.
95pub 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    // GAP-SG-144: Windows counterpart of the `0o700` above.
112    //
113    // ASYMMETRY, DELIBERATE: this one WARNS, while the file below ABORTS. Do
114    // not "uniformise" them. The directory is defence in depth, not the primary
115    // guarantee: the file DACL sets PROTECTED_DACL_SECURITY_INFORMATION, which
116    // severs parent inheritance by construction, so a locked-down file stays
117    // locked down even if the directory restriction failed. Its remaining value
118    // is narrowing the TOCTOU window described on `restrict_to_current_user`,
119    // which is worth a warning but not worth losing the operator's config.
120    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    // GAP-SG-144: Windows counterpart of the `0o600` above. Applied AFTER the
165    // rename, because the DACL must land on the file that now carries the API
166    // key, not on the temporary that no longer exists.
167    //
168    // FAIL-CLOSED, unlike the directory above: this file holds the OpenRouter
169    // API key. Keeping it after the restriction failed produces exactly the
170    // state GAP-SG-144 exists to eliminate — a readable credential — and a
171    // warning in a log nobody reads is not a mitigation. Best-effort is the
172    // right policy for performance hardening, not for secret protection.
173    restrict_to_current_user(&path)?;
174
175    #[cfg(unix)]
176    unsafe {
177        libc::umask(old_umask);
178    }
179
180    // fsync parent dir for crash consistency
181    #[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}