Skip to main content

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}