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
26impl AppPaths {
27    /// Resolve.
28    pub fn resolve(db_override: Option<&str>) -> Result<Self, AppError> {
29        let proj = ProjectDirs::from("", "", "sqlite-graphrag").ok_or_else(|| {
30            AppError::Io(std::io::Error::other("could not determine home directory"))
31        })?;
32
33        // GAP-SG-94: one resolver for the cache root, shared with `lock` and
34        // `llm_slots`, so a host can never end up with two cache directories.
35        let cache_root = cache_dir()?;
36
37        let db = if let Some(p) = db_override {
38            validate_path(p)?;
39            PathBuf::from(p)
40        } else if let Ok(Some(cfg_path)) = config::get_setting("db.path") {
41            if !cfg_path.is_empty() {
42                validate_path(&cfg_path)?;
43                PathBuf::from(cfg_path)
44            } else {
45                default_db_path(&proj)?
46            }
47        } else {
48            default_db_path(&proj)?
49        };
50
51        Ok(Self {
52            db,
53            models: cache_root.join("models"),
54        })
55    }
56
57    /// Ensure dirs.
58    pub fn ensure_dirs(&self) -> Result<(), AppError> {
59        for dir in [parent_or_err(&self.db)?, self.models.as_path()] {
60            std::fs::create_dir_all(dir)?;
61        }
62        Ok(())
63    }
64}
65
66fn default_db_path(proj: &ProjectDirs) -> Result<PathBuf, AppError> {
67    // Prefer XDG data dir; fall back to cwd for bare-metal one-shot without home.
68    let data = proj.data_dir();
69    if data.as_os_str().is_empty() {
70        return Ok(std::env::current_dir()
71            .map_err(AppError::Io)?
72            .join("graphrag.sqlite"));
73    }
74    Ok(data.join("graphrag.sqlite"))
75}
76
77fn validate_path(p: &str) -> Result<(), AppError> {
78    if Path::new(p).components().any(|c| c == Component::ParentDir) {
79        return Err(AppError::Validation(validation::path_traversal(p)));
80    }
81    Ok(())
82}
83
84/// Returns the config directory for the application.
85///
86/// Precedence (G-T-XDG-04): CLI `--config-dir` → OS config directory. No XDG
87/// `config set` key participates: the config file lives inside this directory,
88/// so consulting it here would be circular.
89pub fn config_dir() -> Result<PathBuf, AppError> {
90    if let Some(dir) = runtime_config::config_dir_override() {
91        validate_path(&dir)?;
92        return Ok(PathBuf::from(dir));
93    }
94    let proj = ProjectDirs::from("", "", "sqlite-graphrag").ok_or_else(|| {
95        AppError::Io(std::io::Error::other(
96            "could not determine home directory for config",
97        ))
98    })?;
99    Ok(proj.config_dir().to_path_buf())
100}
101
102/// Returns the cache root for lock files, model files and other artifacts.
103///
104/// Precedence (G-T-XDG-04): CLI `--cache-dir` → XDG `cache.dir` → OS cache
105/// directory.
106///
107/// GAP-SG-94: this is the SINGLE resolver for the cache root. [`crate::lock`]
108/// and [`crate::llm_slots`] delegate here. Before v1.2.0 `lock` read a separate
109/// key `paths.cache` while this module read `cache.dir`, so setting one moved
110/// the lock files and setting the other moved the model files.
111pub fn cache_dir() -> Result<PathBuf, AppError> {
112    if let Some(dir) = runtime_config::cache_dir_override() {
113        validate_path(&dir)?;
114        return Ok(PathBuf::from(dir));
115    }
116    let proj = ProjectDirs::from("", "", "sqlite-graphrag").ok_or_else(|| {
117        AppError::Io(std::io::Error::other(
118            "could not determine cache directory for sqlite-graphrag",
119        ))
120    })?;
121    Ok(proj.cache_dir().to_path_buf())
122}
123
124pub(crate) fn parent_or_err(path: &Path) -> Result<&Path, AppError> {
125    path.parent().ok_or_else(|| {
126        AppError::Validation(validation::path_no_valid_parent(
127            &path.display().to_string(),
128        ))
129    })
130}
131
132/// Derives a sidecar file path next to the database (e.g. the enrich/ingest
133/// queue), so worklist files follow `--db` instead of the process CWD. Falls
134/// back to the bare filename (CWD) when `db_path` has no parent — preserving the
135/// legacy default-DB layout.
136pub fn sidecar_path(db_path: &Path, filename: &str) -> PathBuf {
137    db_path
138        .parent()
139        .filter(|p| !p.as_os_str().is_empty())
140        .map(|p| p.join(filename))
141        .unwrap_or_else(|| PathBuf::from(filename))
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use tempfile::TempDir;
148
149    #[test]
150    fn flag_overrides_default() {
151        let tmp = TempDir::new().expect("tempdir");
152        let db_flag = tmp.path().join("via-flag.sqlite");
153        let paths =
154            AppPaths::resolve(Some(db_flag.to_str().expect("utf8"))).expect("resolve with flag");
155        assert_eq!(paths.db, db_flag);
156    }
157
158    #[test]
159    fn traversal_in_flag_rejected() {
160        let result = AppPaths::resolve(Some("/tmp/../etc/passwd"));
161        assert!(
162            matches!(result, Err(AppError::Validation(_))),
163            "traversal must fail as Validation, got {result:?}"
164        );
165    }
166
167    #[test]
168    fn default_resolve_ok() {
169        let paths = AppPaths::resolve(None).expect("default resolve");
170        assert!(!paths.db.as_os_str().is_empty());
171        assert!(paths.models.ends_with("models"));
172    }
173
174    #[test]
175    fn sidecar_path_joins_parent() {
176        let p = sidecar_path(Path::new("/data/db/graphrag.sqlite"), "enrich.queue");
177        assert_eq!(p, PathBuf::from("/data/db/enrich.queue"));
178    }
179}