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}