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. Read literally against three layers, that would
82/// also reject [`TargetSource::Xdg`] — and that reading is wrong here, for a
83/// reason specific to this product: `db.path` is a first-class key in the
84/// configuration registry, set through `config set`. An operator who ran that
85/// command DID choose the database; they simply chose it once instead of on
86/// every invocation. Refusing it would make the product's own configuration
87/// surface unusable, which no rule intends.
88///
89/// [`TargetSource::Default`] is the case the rule is actually about. Nothing
90/// named the database — not the argv, not the configuration — and the write
91/// lands in a compiled fallback the caller never mentioned anywhere. That is
92/// the confused deputy, and it is the one that fails closed.
93///
94/// Either way the resolved target reaches the envelope, so an `xdg` write is
95/// permitted AND visible rather than permitted and silent.
96///
97/// # Errors
98/// Returns [`AppError::Usage`] — exit `2` — when a mutating subcommand resolved
99/// the compiled default with no argv target and no explicit dispensation.
100fn enforce_explicit_target(source: TargetSource) -> Result<(), AppError> {
101 let Some(policy) = WRITE_POLICY.get() else {
102 return Ok(());
103 };
104 if source == TargetSource::Default && policy.requires_explicit_target && !policy.use_active {
105 return Err(AppError::Usage {
106 message: validation::target_not_designated(),
107 // Nothing the caller typed was discarded here: the refusal is about
108 // an argument that is MISSING, not one that was ignored.
109 discarded_flags: Vec::new(),
110 });
111 }
112 Ok(())
113}
114
115/// The target this one-shot process resolved. First resolution wins.
116static RESOLVED_TARGET: std::sync::OnceLock<(PathBuf, TargetSource)> = std::sync::OnceLock::new();
117
118/// Records the resolved target so the output layer can report it.
119fn record_target(db: &std::path::Path, source: TargetSource) {
120 let _ = RESOLVED_TARGET.set((db.to_path_buf(), source));
121}
122
123impl AppPaths {
124 /// Which layer supplied the database this process is about to touch.
125 ///
126 /// Only [`TargetSource::Argv`] is an explicit designation. The other two are
127 /// ambient authority: legitimate for an idempotent read, and the shape of a
128 /// confused deputy for a write.
129 pub fn target_source() -> Option<TargetSource> {
130 RESOLVED_TARGET.get().map(|(_, source)| *source)
131 }
132
133 /// The database path this process resolved, once it has resolved one.
134 pub fn resolved_target() -> Option<&'static std::path::Path> {
135 RESOLVED_TARGET.get().map(|(path, _)| path.as_path())
136 }
137
138 /// Resolves the database and cache paths for this invocation.
139 ///
140 /// # Errors
141 /// Returns [`AppError::Io`] when the home directory cannot be determined,
142 /// and a validation error when a supplied path is rejected.
143 pub fn resolve(db_override: Option<&str>) -> Result<Self, AppError> {
144 let proj = ProjectDirs::from("", "", "sqlite-graphrag").ok_or_else(|| {
145 AppError::Io(std::io::Error::other("could not determine home directory"))
146 })?;
147
148 // GAP-SG-94: one resolver for the cache root, shared with `lock` and
149 // `llm_slots`, so a host can never end up with two cache directories.
150 let cache_root = cache_dir()?;
151
152 // GAP-SG-205: the target is resolved from three layers, and only the
153 // first is the argv. A write verb that reaches this function without
154 // `--db` mutates a database the command line never named — the confused
155 // deputy the Explicit Target Designation rule describes. Recording WHICH
156 // layer won is what makes that detectable at all: until v1.2.6 no
157 // envelope reported the resolved target, so the wrong database could be
158 // written with no trace in the output.
159 let (db, source) = if let Some(p) = db_override {
160 validate_path(p)?;
161 (PathBuf::from(p), TargetSource::Argv)
162 } else {
163 match config::get_setting("db.path") {
164 // An empty setting is not a designation, so it falls through to
165 // the compiled default exactly as a missing one does.
166 Ok(Some(cfg_path)) if !cfg_path.is_empty() => {
167 validate_path(&cfg_path)?;
168 (PathBuf::from(cfg_path), TargetSource::Xdg)
169 }
170 _ => (default_db_path(&proj)?, TargetSource::Default),
171 }
172 };
173 // GAP-SG-207, checked AFTER resolution because the verdict depends on
174 // WHICH layer won, and only resolution knows that. Placed here rather
175 // than on each argument struct because this is the single funnel: 47
176 // structs declare `--db` and all of them converge on this function.
177 enforce_explicit_target(source)?;
178 record_target(&db, source);
179
180 Ok(Self {
181 db,
182 models: cache_root.join("models"),
183 })
184 }
185
186 /// Ensure dirs.
187 pub fn ensure_dirs(&self) -> Result<(), AppError> {
188 for dir in [parent_or_err(&self.db)?, self.models.as_path()] {
189 std::fs::create_dir_all(dir)?;
190 }
191 Ok(())
192 }
193}
194
195fn default_db_path(proj: &ProjectDirs) -> Result<PathBuf, AppError> {
196 // Prefer XDG data dir; fall back to cwd for bare-metal one-shot without home.
197 let data = proj.data_dir();
198 if data.as_os_str().is_empty() {
199 return Ok(std::env::current_dir()
200 .map_err(AppError::Io)?
201 .join("graphrag.sqlite"));
202 }
203 Ok(data.join("graphrag.sqlite"))
204}
205
206fn validate_path(p: &str) -> Result<(), AppError> {
207 if Path::new(p).components().any(|c| c == Component::ParentDir) {
208 return Err(AppError::Validation(validation::path_traversal(p)));
209 }
210 Ok(())
211}
212
213/// Returns the config directory for the application.
214///
215/// Precedence (G-T-XDG-04): CLI `--config-dir` → OS config directory. No XDG
216/// `config set` key participates: the config file lives inside this directory,
217/// so consulting it here would be circular.
218pub fn config_dir() -> Result<PathBuf, AppError> {
219 if let Some(dir) = runtime_config::config_dir_override() {
220 validate_path(&dir)?;
221 return Ok(PathBuf::from(dir));
222 }
223 let proj = ProjectDirs::from("", "", "sqlite-graphrag").ok_or_else(|| {
224 AppError::Io(std::io::Error::other(
225 "could not determine home directory for config",
226 ))
227 })?;
228 Ok(proj.config_dir().to_path_buf())
229}
230
231/// Returns the cache root for lock files, model files and other artifacts.
232///
233/// Precedence (G-T-XDG-04): CLI `--cache-dir` → XDG `cache.dir` → OS cache
234/// directory.
235///
236/// GAP-SG-94: this is the SINGLE resolver for the cache root. [`crate::lock`]
237/// and [`crate::llm_slots`] delegate here. Before v1.2.0 `lock` read a separate
238/// key `paths.cache` while this module read `cache.dir`, so setting one moved
239/// the lock files and setting the other moved the model files.
240pub fn cache_dir() -> Result<PathBuf, AppError> {
241 if let Some(dir) = runtime_config::cache_dir_override() {
242 validate_path(&dir)?;
243 return Ok(PathBuf::from(dir));
244 }
245 let proj = ProjectDirs::from("", "", "sqlite-graphrag").ok_or_else(|| {
246 AppError::Io(std::io::Error::other(
247 "could not determine cache directory for sqlite-graphrag",
248 ))
249 })?;
250 Ok(proj.cache_dir().to_path_buf())
251}
252
253pub(crate) fn parent_or_err(path: &Path) -> Result<&Path, AppError> {
254 path.parent().ok_or_else(|| {
255 AppError::Validation(validation::path_no_valid_parent(
256 &path.display().to_string(),
257 ))
258 })
259}
260
261/// Derives a sidecar file path next to the database (e.g. the enrich/ingest
262/// queue), so worklist files follow `--db` instead of the process CWD. Falls
263/// back to the bare filename (CWD) when `db_path` has no parent — preserving the
264/// legacy default-DB layout.
265pub fn sidecar_path(db_path: &Path, filename: &str) -> PathBuf {
266 db_path
267 .parent()
268 .filter(|p| !p.as_os_str().is_empty())
269 .map(|p| p.join(filename))
270 .unwrap_or_else(|| PathBuf::from(filename))
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276 use tempfile::TempDir;
277
278 #[test]
279 fn flag_overrides_default() {
280 let tmp = TempDir::new().expect("tempdir");
281 let db_flag = tmp.path().join("via-flag.sqlite");
282 let paths =
283 AppPaths::resolve(Some(db_flag.to_str().expect("utf8"))).expect("resolve with flag");
284 assert_eq!(paths.db, db_flag);
285 }
286
287 #[test]
288 fn traversal_in_flag_rejected() {
289 let result = AppPaths::resolve(Some("/tmp/../etc/passwd"));
290 assert!(
291 matches!(result, Err(AppError::Validation(_))),
292 "traversal must fail as Validation, got {result:?}"
293 );
294 }
295
296 #[test]
297 fn default_resolve_ok() {
298 let paths = AppPaths::resolve(None).expect("default resolve");
299 assert!(!paths.db.as_os_str().is_empty());
300 assert!(paths.models.ends_with("models"));
301 }
302
303 #[test]
304 fn sidecar_path_joins_parent() {
305 let p = sidecar_path(Path::new("/data/db/graphrag.sqlite"), "enrich.queue");
306 assert_eq!(p, PathBuf::from("/data/db/enrich.queue"));
307 }
308}