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.
26///
27/// # Declared limit: the permission check below is Unix-only, on purpose
28///
29/// The write side has a Windows counterpart, `restrict_to_current_user` in
30/// `crate::config::permissions` — named in prose because it is private, and a
31/// public doc that links a private item is denied by `[lints.rustdoc]`; the
32/// READ side deliberately does not, so on Windows a `config.toml` with a loose
33/// ACL — holding the OpenRouter API key — is loaded without a word, while on
34/// Unix the same file draws a warning. That asymmetry is stated here rather
35/// than papered over, because a silent gap is the one an operator cannot plan
36/// around.
37///
38/// It stands for three reasons. First, this check only WARNS: it never refuses
39/// the file, so what Windows loses is a log line, not a guarantee. Second, the
40/// guarantee itself is on the write path, where `SetNamedSecurityInfoW`
41/// installs a PROTECTED single-ACE DACL — any file this CLI wrote is already
42/// restricted, and the loose-ACL case can only arise from a file some other
43/// tool produced. Third, deciding "too open" from a DACL means enumerating
44/// ACEs and classifying trustees in `unsafe` Win32, and this project has no
45/// Windows CI: `permissions.rs` already declares its Windows branch as
46/// reviewed-but-never-executed code. Adding a second unverifiable unsafe
47/// surface to gain a warning is a worse trade than admitting the limit.
48pub fn load_config() -> Result<AppConfig, AppError> {
49    let path = config_file_path()?;
50
51    if !path.exists() {
52        return Ok(AppConfig::default());
53    }
54
55    let meta = std::fs::symlink_metadata(&path)?;
56    if meta.file_type().is_symlink() {
57        return Err(AppError::Validation(validation::config_file_is_symlink(
58            &path.display().to_string(),
59        )));
60    }
61
62    // UNIX-ONLY BY DECLARATION, not by omission: see the `# Declared limit`
63    // section on this function for why no Windows counterpart is attempted.
64    #[cfg(unix)]
65    {
66        use std::os::unix::fs::PermissionsExt;
67        let mode = meta.permissions().mode() & 0o777;
68        if mode > 0o600 {
69            tracing::warn!(
70                path = %path.display(),
71                mode = format!("{mode:o}"),
72                "config file permissions are too open; recommend chmod 600"
73            );
74        }
75    }
76
77    let content = std::fs::read_to_string(&path)?;
78    let cfg: AppConfig = toml::from_str(&content).map_err(|e| {
79        AppError::Validation(validation::config_parse_error(
80            &path.display().to_string(),
81            &e,
82        ))
83    })?;
84    warn_on_legacy_settings(&cfg);
85    Ok(cfg)
86}
87
88/// Emits one warning per process for each retired key still present on disk.
89///
90/// `load_config` runs on every [`get_setting`] call, so the warning is gated by
91/// a [`std::sync::Once`] to keep a hot read path from flooding stderr.
92///
93/// The value is deliberately left untouched: `GAP-SG-79` is fixed by making the
94/// dead key visible, not by rewriting a file the operator owns.
95fn warn_on_legacy_settings(cfg: &AppConfig) {
96    static WARNED: std::sync::Once = std::sync::Once::new();
97    if LEGACY_SETTING_KEYS
98        .iter()
99        .all(|(legacy, _)| !cfg.settings.contains_key(*legacy))
100    {
101        return;
102    }
103    WARNED.call_once(|| {
104        for (legacy, replacement) in LEGACY_SETTING_KEYS {
105            if cfg.settings.contains_key(*legacy) {
106                tracing::warn!(
107                    target: "config",
108                    key = legacy,
109                    replacement = replacement,
110                    "config key is never read and has no effect; \
111                     move the value to the replacement key and unset the old one"
112                );
113            }
114        }
115    });
116}
117
118/// Persist application configuration to the XDG config file.
119pub fn save_config(config: &AppConfig) -> Result<(), AppError> {
120    let path = config_file_path()?;
121    let dir = path.parent().ok_or_else(|| {
122        AppError::Validation(validation::config_path_no_parent(
123            &path.display().to_string(),
124        ))
125    })?;
126
127    std::fs::create_dir_all(dir)?;
128
129    #[cfg(unix)]
130    {
131        use std::os::unix::fs::PermissionsExt;
132        std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
133    }
134
135    // GAP-SG-144: Windows counterpart of the `0o700` above.
136    //
137    // ASYMMETRY, DELIBERATE: this one WARNS, while the file below ABORTS. Do
138    // not "uniformise" them. The directory is defence in depth, not the primary
139    // guarantee: the file DACL sets PROTECTED_DACL_SECURITY_INFORMATION, which
140    // severs parent inheritance by construction, so a locked-down file stays
141    // locked down even if the directory restriction failed. Its remaining value
142    // is narrowing the TOCTOU window described on `restrict_to_current_user`,
143    // which is worth a warning but not worth losing the operator's config.
144    if let Err(e) = restrict_to_current_user(dir) {
145        tracing::warn!(
146            path = %dir.display(),
147            error = %e,
148            "could not restrict config directory to the current user; \
149             the config file DACL remains the primary protection"
150        );
151    }
152
153    #[cfg(unix)]
154    if path.exists() {
155        use std::os::unix::fs::MetadataExt;
156        let meta = std::fs::metadata(&path)?;
157        let file_uid = meta.uid();
158        let my_uid = unsafe { libc::getuid() };
159        if file_uid != my_uid {
160            return Err(AppError::Validation(validation::config_file_wrong_owner(
161                &path.display().to_string(),
162                file_uid,
163                my_uid,
164            )));
165        }
166    }
167
168    let serialized =
169        toml::to_string_pretty(config).map_err(|e| AppError::Validation(e.to_string()))?;
170
171    #[cfg(unix)]
172    let old_umask = unsafe { libc::umask(0o077) };
173
174    use std::io::Write;
175    let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
176    tmp.write_all(serialized.as_bytes())?;
177    tmp.as_file().sync_all()?;
178
179    #[cfg(unix)]
180    {
181        use std::os::unix::fs::PermissionsExt;
182        std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o600))?;
183    }
184
185    // GAP-SG-269: Windows counterpart of the `0o600` above, applied to the
186    // TEMPORARY and therefore BEFORE the rename — the same ordering Unix has had
187    // all along.
188    //
189    // This closes a window rather than shortening one, and the reason is a
190    // property of `persist` itself: it cannot move a file across filesystems,
191    // and fails instead of falling back to copy-and-delete. So the rename is
192    // always intra-volume, and an intra-volume move carries the file's own
193    // security descriptor with it; only the inter-volume case would re-inherit
194    // from the destination's parent, and `persist` makes that case impossible.
195    // Hardening the temporary is therefore equivalent to hardening the target,
196    // with no interval during which the key sits under the directory's
197    // inheritable ACEs.
198    restrict_to_current_user(tmp.path())?;
199
200    tmp.persist(&path)
201        .map_err(|e| AppError::Io(std::io::Error::other(format!("atomic persist failed: {e}"))))?;
202
203    // Re-applied to the final path, and kept deliberately rather than trusted
204    // away. The argument above says the descriptor survives the rename; this
205    // call is what makes the guarantee hold even if that argument is ever wrong
206    // — a different tempfile backend, a filesystem that reports one volume and
207    // behaves as two. The cost is one syscall on a path that runs once per
208    // `config set`; the cost of being wrong is a readable credential.
209    //
210    // FAIL-CLOSED, unlike the directory above: this file holds the OpenRouter
211    // API key. Keeping it after the restriction failed produces exactly the
212    // state GAP-SG-144 exists to eliminate — a readable credential — and a
213    // warning in a log nobody reads is not a mitigation. Best-effort is the
214    // right policy for performance hardening, not for secret protection.
215    restrict_to_current_user(&path)?;
216
217    #[cfg(unix)]
218    unsafe {
219        libc::umask(old_umask);
220    }
221
222    // fsync parent dir for crash consistency: `persist` documents that neither
223    // the contents nor the containing directory are synchronised, so the rename
224    // itself is not durable until the directory is. The file's own contents were
225    // covered by `sync_all` before the rename.
226    //
227    // Unix only, and that is not an omission. Windows exposes no supported way to
228    // flush a directory entry — `FlushFileBuffers` wants a file handle, and a
229    // directory handle opened for it is not a documented contract — so there is
230    // nothing to call rather than something skipped.
231    #[cfg(unix)]
232    {
233        let dir_file = std::fs::File::open(dir)?;
234        dir_file.sync_all()?;
235    }
236
237    Ok(())
238}
239
240#[cfg(test)]
241mod tests {
242    use crate::config::{compute_fingerprint, ApiKeyEntry, AppConfig};
243    use tempfile::TempDir;
244
245    #[test]
246    fn load_config_missing_file_returns_default() {
247        let tmp = TempDir::new().unwrap();
248        let nonexistent = tmp.path().join("does-not-exist.toml");
249        assert!(!nonexistent.exists());
250        let cfg = AppConfig::default();
251        assert_eq!(cfg.schema_version, 1);
252        assert!(cfg.keys.is_empty());
253    }
254
255    #[test]
256    fn save_and_load_roundtrip() {
257        let tmp = TempDir::new().unwrap();
258        let config_path = tmp.path().join("config.toml");
259
260        let mut cfg = AppConfig::default();
261        cfg.keys.push(ApiKeyEntry {
262            provider: "openrouter".to_string(),
263            value: "sk-test-key".to_string(),
264            added_at: "2026-01-01T00:00:00Z".to_string(),
265            fingerprint: compute_fingerprint("sk-test-key"),
266        });
267
268        let serialized = toml::to_string_pretty(&cfg).unwrap();
269        std::fs::write(&config_path, &serialized).unwrap();
270
271        let content = std::fs::read_to_string(&config_path).unwrap();
272        let loaded: AppConfig = toml::from_str(&content).unwrap();
273
274        assert_eq!(loaded.schema_version, 1);
275        assert_eq!(loaded.keys.len(), 1);
276        assert_eq!(loaded.keys[0].provider, "openrouter");
277        assert_eq!(loaded.keys[0].value, "sk-test-key");
278    }
279}