sqlite_graphrag/paths.rs
1//! XDG path resolution and traversal-safe overrides.
2//!
3//! Resolves data directories via [`directories::ProjectDirs`] and validates
4//! that user-supplied paths cannot escape the project root.
5//!
6//! Precedence (G-T-XDG-04): CLI flag `--db` / `db_override` → XDG setting
7//! `db.path` → XDG data dir default `graphrag.sqlite` → cwd fallback.
8//! Product `SQLITE_GRAPHRAG_*` env vars are **not** read.
9
10use crate::config;
11use crate::errors::AppError;
12use crate::i18n::validation;
13use crate::runtime_config;
14use directories::ProjectDirs;
15use std::path::{Component, Path, PathBuf};
16
17/// Resolved filesystem paths used by the CLI at runtime.
18#[derive(Debug, Clone)]
19pub struct AppPaths {
20 /// Absolute path to the SQLite database file.
21 pub db: PathBuf,
22 /// Directory where embedding model files are cached.
23 pub models: PathBuf,
24}
25
26/// Which layer of configuration supplied the target database.
27///
28/// GAP-SG-205. Ordered from explicit to ambient.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum TargetSource {
31 /// The command line named it with `--db`.
32 Argv,
33 /// The XDG key `db.path` named it.
34 Xdg,
35 /// Nothing named it and the compiled default was used.
36 Default,
37}
38
39impl TargetSource {
40 /// Wire spelling for the output record.
41 pub fn as_str(self) -> &'static str {
42 match self {
43 Self::Argv => "argv",
44 Self::Xdg => "xdg",
45 Self::Default => "default",
46 }
47 }
48}
49
50/// Whether this process is allowed to inherit its target from the environment.
51///
52/// GAP-SG-207. The Explicit Target Designation rule asks a verb with a side
53/// effect to name its target in the argv, and to fail closed when it does not.
54/// This is the record of that decision, taken once from the parsed command line.
55#[derive(Debug, Clone, Copy)]
56pub struct WritePolicy {
57 /// The subcommand changes durable state and does not create its own target.
58 pub requires_explicit_target: bool,
59 /// `--use-active` was passed, dispensing the requirement on purpose.
60 pub use_active: bool,
61}
62
63/// The policy this process runs under, installed before any subcommand runs.
64static WRITE_POLICY: std::sync::OnceLock<WritePolicy> = std::sync::OnceLock::new();
65
66/// Installs the target policy for this process. Idempotent, first call wins.
67///
68/// Called from `crate::cli::GlobalArgs` during flag validation, which is the
69/// one hook that runs after the command line is parsed and before any handler
70/// executes. Absent — the library used directly, or a unit test — resolution
71/// stays permissive, because the rule governs the CLI contract and not the API.
72pub fn install_write_policy(policy: WritePolicy) {
73 let _ = WRITE_POLICY.set(policy);
74}
75
76/// Refuses a target NOBODY named, for a verb that changes durable state.
77///
78/// # Which layer earns a refusal, and why not all of them
79///
80/// The Explicit Target Designation rule asks a destructive verb to prove its
81/// target came from the argv, and every layer that is not the argv is ambient.
82/// Both [`TargetSource::Xdg`] and [`TargetSource::Default`] therefore fail
83/// closed here.
84///
85/// Until this was tightened the fence fired on `Default` alone, and `Xdg` was
86/// permitted on the argument that `db.path` is a first-class registry key: an
87/// operator who ran `config set` DID choose the database, just once instead of
88/// on every invocation. That argument is answered by the SCOPE of the key, which
89/// it did not account for. `db.path` is a HOST setting — there is no per-project
90/// configuration in this product — so it does not name "the database for this
91/// work", it names one database for every directory on the machine. This host
92/// carries sixteen of them, and the repository's own operating rules already say
93/// never to reach for `config set` to solve one project's problem, precisely
94/// because the change leaves the folder. A key that cannot legitimately mean
95/// "this project" cannot legitimately designate this project's write target.
96///
97/// The read path is untouched: [`WritePolicy::requires_explicit_target`] is
98/// false for idempotent verbs, so inheritance stays available exactly where it
99/// costs nothing. And `--use-active` still dispenses the requirement, which is
100/// the explicit opt-in the rule itself authorises — ambient authority is refused
101/// as a DEFAULT, never as an impossibility.
102///
103/// # Errors
104/// Returns [`AppError::Usage`] — exit `2` — when a mutating subcommand resolved
105/// its target from any layer other than the argv, with no explicit dispensation.
106fn enforce_explicit_target(source: TargetSource) -> Result<(), AppError> {
107 let Some(policy) = WRITE_POLICY.get() else {
108 return Ok(());
109 };
110 if source == TargetSource::Argv || !policy.requires_explicit_target || policy.use_active {
111 return Ok(());
112 }
113 // Two ambient layers, two messages: the operator's next move differs. One has
114 // a configured value to point at and override, the other has nothing named
115 // anywhere. A single message would have to describe both and would name the
116 // wrong remedy in half the cases.
117 let message = match source {
118 TargetSource::Xdg => validation::target_inherited_from_config(),
119 _ => validation::target_not_designated(),
120 };
121 Err(AppError::Usage {
122 message,
123 // Nothing the caller typed was discarded here: the refusal is about an
124 // argument that is MISSING, not one that was ignored.
125 discarded_flags: Vec::new(),
126 })
127}
128
129/// The target this one-shot process resolved. First resolution wins.
130static RESOLVED_TARGET: std::sync::OnceLock<(PathBuf, TargetSource)> = std::sync::OnceLock::new();
131
132/// Records the resolved target so the output layer can report it.
133fn record_target(db: &std::path::Path, source: TargetSource) {
134 let _ = RESOLVED_TARGET.set((db.to_path_buf(), source));
135}
136
137impl AppPaths {
138 /// Which layer supplied the database this process is about to touch.
139 ///
140 /// Only [`TargetSource::Argv`] is an explicit designation. The other two are
141 /// ambient authority: legitimate for an idempotent read, and the shape of a
142 /// confused deputy for a write.
143 pub fn target_source() -> Option<TargetSource> {
144 RESOLVED_TARGET.get().map(|(_, source)| *source)
145 }
146
147 /// The database path this process resolved, once it has resolved one.
148 pub fn resolved_target() -> Option<&'static std::path::Path> {
149 RESOLVED_TARGET.get().map(|(path, _)| path.as_path())
150 }
151
152 /// Resolves the database and cache paths for this invocation.
153 ///
154 /// # Errors
155 /// Returns [`AppError::Io`] when the home directory cannot be determined,
156 /// and a validation error when a supplied path is rejected.
157 pub fn resolve(db_override: Option<&str>) -> Result<Self, AppError> {
158 let proj = ProjectDirs::from("", "", "sqlite-graphrag").ok_or_else(|| {
159 AppError::Io(std::io::Error::other("could not determine home directory"))
160 })?;
161
162 // GAP-SG-94: one resolver for the cache root, shared with `lock` and
163 // `llm_slots`, so a host can never end up with two cache directories.
164 let cache_root = cache_dir()?;
165
166 // GAP-SG-205: the target is resolved from three layers, and only the
167 // first is the argv. A write verb that reaches this function without
168 // `--db` mutates a database the command line never named — the confused
169 // deputy the Explicit Target Designation rule describes. Recording WHICH
170 // layer won is what makes that detectable at all: until v1.2.6 no
171 // envelope reported the resolved target, so the wrong database could be
172 // written with no trace in the output.
173 let (db, source) = if let Some(p) = db_override {
174 validate_path(p)?;
175 (PathBuf::from(p), TargetSource::Argv)
176 } else {
177 match config::get_setting("db.path") {
178 // An empty setting is not a designation, so it falls through to
179 // the compiled default exactly as a missing one does.
180 Ok(Some(cfg_path)) if !cfg_path.is_empty() => {
181 validate_path(&cfg_path)?;
182 (PathBuf::from(cfg_path), TargetSource::Xdg)
183 }
184 _ => (default_db_path(&proj)?, TargetSource::Default),
185 }
186 };
187 // GAP-SG-207, checked AFTER resolution because the verdict depends on
188 // WHICH layer won, and only resolution knows that. Placed here rather
189 // than on each argument struct because this is the single funnel: 47
190 // structs declare `--db` and all of them converge on this function.
191 enforce_explicit_target(source)?;
192 record_target(&db, source);
193
194 Ok(Self {
195 db,
196 models: cache_root.join("models"),
197 })
198 }
199
200 /// Ensure dirs.
201 pub fn ensure_dirs(&self) -> Result<(), AppError> {
202 for dir in [parent_or_err(&self.db)?, self.models.as_path()] {
203 std::fs::create_dir_all(dir)?;
204 }
205 Ok(())
206 }
207}
208
209fn default_db_path(proj: &ProjectDirs) -> Result<PathBuf, AppError> {
210 // Prefer XDG data dir; fall back to cwd for bare-metal one-shot without home.
211 let data = proj.data_dir();
212 if data.as_os_str().is_empty() {
213 return Ok(std::env::current_dir()
214 .map_err(AppError::Io)?
215 .join("graphrag.sqlite"));
216 }
217 Ok(data.join("graphrag.sqlite"))
218}
219
220fn validate_path(p: &str) -> Result<(), AppError> {
221 if Path::new(p).components().any(|c| c == Component::ParentDir) {
222 return Err(AppError::Validation(validation::path_traversal(p)));
223 }
224 Ok(())
225}
226
227/// Returns the config directory for the application.
228///
229/// Precedence (G-T-XDG-04): CLI `--config-dir` → OS config directory. No XDG
230/// `config set` key participates: the config file lives inside this directory,
231/// so consulting it here would be circular.
232pub fn config_dir() -> Result<PathBuf, AppError> {
233 if let Some(dir) = runtime_config::config_dir_override() {
234 validate_path(&dir)?;
235 return Ok(PathBuf::from(dir));
236 }
237 let proj = ProjectDirs::from("", "", "sqlite-graphrag").ok_or_else(|| {
238 AppError::Io(std::io::Error::other(
239 "could not determine home directory for config",
240 ))
241 })?;
242 Ok(proj.config_dir().to_path_buf())
243}
244
245/// Returns the cache root for lock files, model files and other artifacts.
246///
247/// Precedence (G-T-XDG-04): CLI `--cache-dir` → XDG `cache.dir` → OS cache
248/// directory.
249///
250/// GAP-SG-94: this is the SINGLE resolver for the cache root. [`crate::lock`]
251/// and [`crate::llm_slots`] delegate here. Before v1.2.0 `lock` read a separate
252/// key `paths.cache` while this module read `cache.dir`, so setting one moved
253/// the lock files and setting the other moved the model files.
254pub fn cache_dir() -> Result<PathBuf, AppError> {
255 if let Some(dir) = runtime_config::cache_dir_override() {
256 validate_path(&dir)?;
257 return Ok(PathBuf::from(dir));
258 }
259 let proj = ProjectDirs::from("", "", "sqlite-graphrag").ok_or_else(|| {
260 AppError::Io(std::io::Error::other(
261 "could not determine cache directory for sqlite-graphrag",
262 ))
263 })?;
264 Ok(proj.cache_dir().to_path_buf())
265}
266
267pub(crate) fn parent_or_err(path: &Path) -> Result<&Path, AppError> {
268 path.parent().ok_or_else(|| {
269 AppError::Validation(validation::path_no_valid_parent(
270 &path.display().to_string(),
271 ))
272 })
273}
274
275/// Derives a sidecar file path next to the database (e.g. the enrich/ingest
276/// queue), so worklist files follow `--db` instead of the process CWD. Falls
277/// back to the bare filename (CWD) when `db_path` has no parent — preserving the
278/// legacy default-DB layout.
279pub fn sidecar_path(db_path: &Path, filename: &str) -> PathBuf {
280 db_path
281 .parent()
282 .filter(|p| !p.as_os_str().is_empty())
283 .map(|p| p.join(filename))
284 .unwrap_or_else(|| PathBuf::from(filename))
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use tempfile::TempDir;
291
292 #[test]
293 fn flag_overrides_default() {
294 let tmp = TempDir::new().expect("tempdir");
295 let db_flag = tmp.path().join("via-flag.sqlite");
296 let paths =
297 AppPaths::resolve(Some(db_flag.to_str().expect("utf8"))).expect("resolve with flag");
298 assert_eq!(paths.db, db_flag);
299 }
300
301 #[test]
302 fn traversal_in_flag_rejected() {
303 let result = AppPaths::resolve(Some("/tmp/../etc/passwd"));
304 assert!(
305 matches!(result, Err(AppError::Validation(_))),
306 "traversal must fail as Validation, got {result:?}"
307 );
308 }
309
310 #[test]
311 fn default_resolve_ok() {
312 let paths = AppPaths::resolve(None).expect("default resolve");
313 assert!(!paths.db.as_os_str().is_empty());
314 assert!(paths.models.ends_with("models"));
315 }
316
317 #[test]
318 fn sidecar_path_joins_parent() {
319 let p = sidecar_path(Path::new("/data/db/graphrag.sqlite"), "enrich.queue");
320 assert_eq!(p, PathBuf::from("/data/db/enrich.queue"));
321 }
322}